1 Commits
Author SHA1 Message Date
lmk ff0beb0f34 HyperFrames独立出来 2026-06-29 10:28:38 +08:00
17 changed files with 159 additions and 1771 deletions
+3 -15
View File
@@ -1,8 +1,7 @@
# 阶段1: 构建 # 阶段1: 构建
FROM golang:alpine AS builder FROM golang:alpine AS builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ RUN apk add --no-cache git ca-certificates tzdata
apk add --no-cache git ca-certificates tzdata
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
@@ -50,25 +49,14 @@ RUN python3 -c "import scenedetect; print(f'scenedetect {scenedetect.__version__
# 预装 HyperFrames(全局安装) # 预装 HyperFrames(全局安装)
# 使用 npmmirror 镜像加速 # 使用 npmmirror 镜像加速
# 禁用 hyperframes 自动更新:其后台 npm install 的 postinstall 会连 GitHub 下载二进制,
# 国内网络下挂死并阻塞后续 browser ensure(曾卡 1 小时+
ENV HYPERFRAMES_NO_UPDATE_CHECK=1 \
HYPERFRAMES_NO_AUTO_INSTALL=1
RUN npm install -g hyperframes --ignore-scripts RUN npm install -g hyperframes --ignore-scripts
# HyperFrames 渲染需要 Chromium/Chrome # HyperFrames 渲染需要 Chromium/Chrome
# 安装 Chromium(提供系统级共享库依赖)和中文字体 # 安装 Chromium(提供系统级共享库依赖)和中文字体
RUN apk add --no-cache chromium font-noto-cjk fontconfig \ RUN apk add --no-cache chromium font-noto-cjk fontconfig \
&& fc-cache -f && fc-cache -f
# HyperFrames 直接使用 Alpine 的系统 Chromiummusl 版),而不是下载 chrome-headless-shell # 下载 HyperFrames 需要的 headless-shell 浏览器
# 原因:hyperframes 默认会下载 Google 官方 chrome-headless-shellglibc 构建), RUN npx hyperframes browser ensure
# 在 Alpinemusl libc)上无法加载:Error loading shared library
# ld-linux-x86-64.so.2: No such file or directory + symbol not found
# 上层 puppeteer 表现为 spawn ENOENT,渲染直接失败。
# HYPERFRAMES_BROWSER_PATH 在 hyperframes 浏览器解析中优先级最高(env > 缓存 > 系统)。
# 注意:系统 Chromium 捕获会回退到 screenshot 模式(比 beginFrame 略慢,但功能正常)。
# hyperframes 升级若改变浏览器解析逻辑,需同步检查此配置。
ENV HYPERFRAMES_BROWSER_PATH=/usr/bin/chromium-browser
WORKDIR /app WORKDIR /app
+1 -2
View File
@@ -1,8 +1,7 @@
# 阶段1: 构建 # 阶段1: 构建
FROM golang:alpine AS builder FROM golang:alpine AS builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ RUN apk add --no-cache git ca-certificates tzdata
apk add --no-cache git ca-certificates tzdata
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
+7 -9
View File
@@ -19,8 +19,8 @@ database:
type: "pgsql" type: "pgsql"
host: "192.168.0.83" host: "192.168.0.83"
port: "15432" port: "15432"
user: "sql9f15b63fd203b36e" user: "postgres"
pass: "1ec94b1acdaf57b66030242d418fad5a" pass: "Bjang09@686^*^"
name: "media" name: "media"
role: "master" role: "master"
maxIdle: "5" maxIdle: "5"
@@ -86,11 +86,9 @@ hyperframes:
render_timeout: 30 render_timeout: 30
# 是否启用 headless 模式 # 是否启用 headless 模式
headless: true headless: true
# 浏览器可执行文件路径(非空且路径存在时设置 HYPERFRAMES_BROWSER_PATH,覆盖 HyperFrames 默认解析)。
# Docker/Alpine 必须用系统 chromiumAlpine 是 musl libc,而 hyperframes 默认下载的 # HyperFrames 远程渲染服务配置(独立微服务)
# 官方 chrome-headless-shell 是 glibc 构建,musl 下无法加载(渲染报 spawn ENOENT / signal: killed)。 hyperframes_service:
# 指向普通 Chromium 时 HyperFrames 会自动回退到 screenshot 捕获模式(比 headless-shell 略慢,功能正常)。 # 服务地址,留空则使用默认服务名 (http://127.0.0.1:3029)
# Windows 本地无需改此值:该路径不存在时服务层会自动探测本机 Chrome/Chromium(含 %LOCALAPPDATA% 等标准路径), address: "http://127.0.0.1:3029"
# 避免 HyperFrames 回退到从 Google CDN 慢速下载;也可用系统环境变量 HYPERFRAMES_BROWSER_PATH 显式指定。
browser_path: "/usr/bin/chromium-browser"
-60
View File
@@ -1,60 +0,0 @@
package video
import (
"context"
"encoding/json"
"fmt"
dto "media/model/dto/video"
service "media/service/video"
"github.com/gogf/gf/v2/frame/g"
)
type template struct{}
var Template = new(template)
// CreateTemplate 创建模板视频任务 POST /video/template
func (c *template) CreateTemplate(ctx context.Context, req *dto.CreateTemplateTaskReq) (res *dto.CreateTemplateTaskRes, err error) {
ctx = withUser(ctx)
g.Log().Infof(ctx, "[模板视频] 收到请求 入参: video_count=%d, template_count=%d, callback=%s",
len(req.VideoURLs), len(req.Templates), req.CallbackURL)
if reqJSON, err := json.MarshalIndent(req, "", " "); err == nil {
g.Log().Infof(ctx, "[lmk模板视频] 完整入参(JSON):\n%s", string(reqJSON))
} else {
g.Log().Infof(ctx, "[lmk模板视频] 完整入参序列化失败: %v, 原始: %+v", err, *req)
}
if len(req.VideoURLs) < 1 {
return nil, fmt.Errorf("至少需要1个视频")
}
taskID, taskErr := service.Template.CreateAsyncTask(ctx, req.VideoURLs, req.AudioURL, req.Templates, req.Subtitles, req.SubtitleStyle, req.CallbackURL)
if taskErr != nil {
return nil, taskErr
}
g.Log().Infof(ctx, "[模板视频] 模板任务创建成功 taskId=%s(模板解析完成,转交异步渲染)", taskID)
return &dto.CreateTemplateTaskRes{TaskID: taskID}, nil
}
// GetTemplateTask 查询模板任务结果 GET /video/template/{taskId}
// 实际委托给字幕叠加服务的查询(任务由 Caption 服务创建)
func (c *template) GetTemplateTask(ctx context.Context, req *dto.GetTemplateTaskReq) (res *dto.GetTemplateTaskRes, err error) {
ctx = withUser(ctx)
captionRes, err := service.Caption.GetTaskResult(ctx, req.TaskID)
if err != nil {
return nil, err
}
// 映射响应
return &dto.GetTemplateTaskRes{
TaskID: captionRes.TaskID,
Status: captionRes.Status,
FileURL: captionRes.FileURL,
FileSize: captionRes.FileSize,
FileName: captionRes.FileName,
DurationStr: captionRes.DurationStr,
ErrorMessage: captionRes.ErrorMessage,
}, nil
}
@@ -1,26 +0,0 @@
package video
import (
"context"
dto "media/model/dto/video"
service "media/service/video"
"github.com/gogf/gf/v2/frame/g"
)
// GetDuration 获取视频时长 POST /video/duration
// 方法挂载在 video struct 上,利用 Concat 已注册的 `/video` 组前缀
func (c *video) GetDuration(ctx context.Context, req *dto.VideoDurationReq) (res *dto.VideoDurationRes, err error) {
ctx = withUser(ctx)
g.Log().Infof(ctx, "[视频时长] 收到请求: video_urls=%v", req.VideoURLs)
res, err = service.VideoDuration.GetVideoDurations(ctx, req.VideoURLs)
if err != nil {
g.Log().Errorf(ctx, "[视频时长] 获取失败: %v", err)
return nil, err
}
g.Log().Infof(ctx, "[视频时长] 响应: count=%d, total=%s", res.Count, res.TotalDurationStr)
return res, nil
}
-2
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
controllerAudio "media/controller/audio" controllerAudio "media/controller/audio"
controllerVideo "media/controller/video" controllerVideo "media/controller/video"
@@ -28,7 +27,6 @@ func main() {
controllerVideo.Caption, controllerVideo.Caption,
controllerVideo.Transcode, controllerVideo.Transcode,
controllerVideo.SceneSplit, controllerVideo.SceneSplit,
controllerVideo.Template,
}) })
select {} select {}
} }
+4 -9
View File
@@ -15,15 +15,11 @@ type CaptionElement struct {
Y string `json:"y" d:"center" dc:"垂直位置:top/center/bottom或像素值"` Y string `json:"y" d:"center" dc:"垂直位置:top/center/bottom或像素值"`
FontSize int `json:"fontSize" d:"36" dc:"字号(type=text有效)"` FontSize int `json:"fontSize" d:"36" dc:"字号(type=text有效)"`
FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色"` FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色"`
TextAlign string `json:"textAlign" d:"center" dc:"水平对齐:left/center/right(默认center,type=text有效)"`
FontStroke string `json:"fontStroke" dc:"字体描边,如3px #000000(空=无描边)"`
BgColor string `json:"bgColor" dc:"背景色,如#FFFF00,空=透明"` BgColor string `json:"bgColor" dc:"背景色,如#FFFF00,空=透明"`
BgOpacity float64 `json:"bgOpacity" d:"1.0" dc:"背景透明度0-1"` BgOpacity float64 `json:"bgOpacity" d:"1.0" dc:"背景透明度0-1"`
Width int `json:"width" dc:"元素宽度(px)"` Width int `json:"width" dc:"元素宽度(type=image有效)"`
WidthStr string `json:"widthStr" dc:"宽度表达式(如calc(100% - 320px),非整数宽度用,type=text优先于width)"`
Height int `json:"height" dc:"元素高度(type=image有效)"` Height int `json:"height" dc:"元素高度(type=image有效)"`
Animation string `json:"animation" dc:"动画效果:fadeIn/slideUp/slideLeft/scaleIn"` Animation string `json:"animation" dc:"动画效果:fadeIn/slideUp/slideLeft/scaleIn"`
Vertical bool `json:"vertical" dc:"竖排显示(type=text有效,如左右两侧竖排文案,渲染为writing-mode:vertical-rl)"`
TrackIndex int `json:"trackIndex" d:"1" dc:"轨道层级(数字越大越靠前)"` TrackIndex int `json:"trackIndex" d:"1" dc:"轨道层级(数字越大越靠前)"`
} }
@@ -36,11 +32,10 @@ type SubtitleSegment struct {
// SubtitleStyle 字幕样式配置 // SubtitleStyle 字幕样式配置
type SubtitleStyle struct { type SubtitleStyle struct {
FontSize int `json:"fontSize" d:"38" dc:"字号(默认38)"` FontSize int `json:"fontSize" d:"48" dc:"字号(默认48)"`
FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色(默认白色)"` FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色(默认白色)"`
FontStroke *string `json:"fontStroke" dc:"字体描边,如0.5px #000000(默认0.5px #000000极细)。传空字符串 '' 可去掉描边"` BgColor *string `json:"bgColor" dc:"背景色,空字符串=透明(默认#000000)。传空字符串 '' 可去掉背景色"`
BgColor *string `json:"bgColor" dc:"背景色,空=透明(默认无背景)。传颜色值如#FFFF00可背景"` BgOpacity *float64 `json:"bgOpacity" dc:"背景透明度0-1(默认0.4)。传0可使背景完全透明"`
BgOpacity *float64 `json:"bgOpacity" dc:"背景透明度0-1(默认0.4,仅在设置bgColor时生效)。传0可使背景完全透明"`
X string `json:"x" d:"center" dc:"水平位置:left/center/right或像素值(默认center)"` X string `json:"x" d:"center" dc:"水平位置:left/center/right或像素值(默认center)"`
Y string `json:"y" d:"65%" dc:"垂直位置:top/center/bottom或像素值或百分比(默认65%)"` Y string `json:"y" d:"65%" dc:"垂直位置:top/center/bottom或像素值或百分比(默认65%)"`
} }
-25
View File
@@ -1,25 +0,0 @@
package video
import "github.com/gogf/gf/v2/frame/g"
// VideoDurationReq 视频时长查询请求
type VideoDurationReq struct {
g.Meta `path:"/duration" method:"post" tags:"视频时长" summary:"获取视频时长" dc:"传入一个或多个视频URL,返回每个视频的时长及总时长"`
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表"`
}
// VideoInfo 单个视频时长信息
type VideoInfo struct {
Index int `json:"index" dc:"序号"`
VideoURL string `json:"videoUrl" dc:"视频URL"`
Duration float64 `json:"duration" dc:"时长(秒)"`
DurationStr string `json:"durationStr" dc:"可读时长(HH:MM:SS)"`
}
// VideoDurationRes 视频时长查询响应
type VideoDurationRes struct {
Videos []VideoInfo `json:"videos" dc:"视频时长列表"`
Count int `json:"count" dc:"视频数量"`
TotalDuration float64 `json:"totalDuration" dc:"总时长(秒)"`
TotalDurationStr string `json:"totalDurationStr" dc:"可读总时长(HH:MM:SS)"`
}
-50
View File
@@ -1,50 +0,0 @@
package video
import "github.com/gogf/gf/v2/frame/g"
// ---------- HTML 模板定义 ----------
// HtmlTemplate 单段HTML模板
type HtmlTemplate struct {
URL string `json:"url" v:"required#模板URL不能为空" dc:"模板HTML文件URL,HyperFrames格式,元素使用data-key做占位符"`
Start float64 `json:"start" d:"0" dc:"模板开始时间(秒)"`
End float64 `json:"end" dc:"模板结束时间(秒),不传=持续到视频结束"`
Data map[string]string `json:"data" dc:"模板数据,key对应HTML中元素的data-key属性,value为替换内容。图片URL自动下载"`
}
// ---------- 创建模板任务 ----------
// CreateTemplateTaskReq 创建模板视频任务请求
type CreateTemplateTaskReq struct {
g.Meta `path:"/template" method:"post" tags:"模板视频" summary:"创建模板视频任务(异步)" dc:"使用HTML模板+数据创建营销视频,支持多段模板不同时间区间,返回taskId"`
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"背景视频URL列表"`
AudioURL string `json:"audio_url" dc:"背景音频URL(可选)"`
Templates []HtmlTemplate `json:"templates" dc:"HTML模板列表,每段可指定时间范围和填充数据。传空数组=跳过模板逻辑,仅拼接视频+音频+字幕"`
Subtitles []SubtitleSegment `json:"subtitles" dc:"字幕时间线列表(可选)"`
SubtitleStyle *SubtitleStyle `json:"subtitle_style" dc:"字幕样式配置(可选)"`
CallbackURL string `json:"callback_url" dc:"任务完成后的回调地址(可选)"`
}
// CreateTemplateTaskRes 创建模板任务响应
type CreateTemplateTaskRes struct {
TaskID string `json:"taskId" dc:"任务ID"`
}
// ---------- 查询模板任务 ----------
// GetTemplateTaskReq 查询模板任务请求
type GetTemplateTaskReq struct {
g.Meta `path:"/template/{taskId}" method:"get" tags:"模板视频" summary:"查询模板视频任务结果" dc:"根据taskId查询模板视频任务详情"`
TaskID string `json:"taskId" dc:"任务ID"`
}
// GetTemplateTaskRes 查询模板任务响应
type GetTemplateTaskRes struct {
TaskID string `json:"taskId" dc:"任务ID"`
Status string `json:"status" dc:"任务状态"`
FileURL string `json:"fileUrl,omitempty" dc:"输出文件URL"`
FileSize int64 `json:"fileSize,omitempty" dc:"输出文件大小"`
FileName string `json:"fileName,omitempty" dc:"输出文件名"`
DurationStr string `json:"durationStr,omitempty" dc:"视频时长"`
ErrorMessage string `json:"errorMessage,omitempty" dc:"错误信息"`
}
+115 -186
View File
@@ -1,17 +1,18 @@
package video package video
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -105,13 +106,11 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
// 2. 下载所有视频 // 2. 下载所有视频
var videoPaths []string var videoPaths []string
for i, videoURL := range videoURLs { for i, videoURL := range videoURLs {
dlStart := time.Now()
savePath, dlErr := downloadFile(bgCtx, videoURL, projectDir) savePath, dlErr := downloadFile(bgCtx, videoURL, projectDir)
if dlErr != nil { if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 视频%d下载失败 %s: %v", taskID, i, videoURL, dlErr) g.Log().Warningf(bgCtx, "[字幕 %s] 视频%d下载失败 %s: %v", taskID, i, videoURL, dlErr)
continue continue
} }
g.Log().Infof(bgCtx, "[字幕 %s] 视频%d下载完成: %s, 耗时=%s", taskID, i, videoURL, time.Since(dlStart))
videoPaths = append(videoPaths, savePath) videoPaths = append(videoPaths, savePath)
} }
if len(videoPaths) < 1 { if len(videoPaths) < 1 {
@@ -173,12 +172,10 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
// 7. 下载背景音乐(可选) // 7. 下载背景音乐(可选)
audioPath := "" audioPath := ""
if audioURL != "" { if audioURL != "" {
audioDlStart := time.Now()
savePath, dlErr := downloadFile(bgCtx, audioURL, projectDir) savePath, dlErr := downloadFile(bgCtx, audioURL, projectDir)
if dlErr != nil { if dlErr != nil {
g.Log().Warningf(bgCtx, "[字幕 %s] 音频下载失败 %s: %v", taskID, audioURL, dlErr) g.Log().Warningf(bgCtx, "[字幕 %s] 音频下载失败 %s: %v", taskID, audioURL, dlErr)
} else { } else {
g.Log().Infof(bgCtx, "[字幕 %s] 音频下载完成: %s, 耗时=%s", taskID, audioURL, time.Since(audioDlStart))
// 7.5 音频降噪:去除气口/口水音/底噪(时长不变) // 7.5 音频降噪:去除气口/口水音/底噪(时长不变)
denoisedPath := filepath.Join(projectDir, "denoised_audio.wav") denoisedPath := filepath.Join(projectDir, "denoised_audio.wav")
if err := s.denoiseAudio(bgCtx, savePath, denoisedPath); err != nil { if err := s.denoiseAudio(bgCtx, savePath, denoisedPath); err != nil {
@@ -210,10 +207,10 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
} }
g.Log().Infof(bgCtx, "[字幕 %s] HTML生成完成: %s", taskID, htmlPath) g.Log().Infof(bgCtx, "[字幕 %s] HTML生成完成: %s", taskID, htmlPath)
// 10. 执行 HyperFrames 渲染 // 10. 远程渲染:打包项目 → 上传 OSS → 调用 hyperframes-service → 下载结果
outputPath := filepath.Join(projectDir, "output.mp4") outputPath := filepath.Join(projectDir, "hf_output.mp4")
if err := s.runHyperFramesRender(bgCtx, projectDir, outputPath); err != nil { if err := s.renderViaRemoteService(bgCtx, taskID, projectDir, outputPath); err != nil {
errMsg := fmt.Sprintf("视频渲染失败: %v", err) errMsg := fmt.Sprintf("远程渲染失败: %v", err)
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg) g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil { if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err) g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
@@ -271,156 +268,128 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
} }
} }
// runHyperFramesRender 执行 HyperFrames 渲染 // ---------- 远程渲染(通过 hyperframes-service 微服务) ----------
func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, outputPath string) error {
timeoutMin := g.Cfg().MustGet(ctx, "hyperframes.render_timeout", 30).Int()
ctxWithTimeout, cancel := context.WithTimeout(ctx, time.Duration(timeoutMin)*time.Minute)
defer cancel()
// 使用全局 hyperframes 命令(已在 setup 中安装检查) // renderViaRemoteService 通过远程 hyperframes-service 执行渲染
hyperframesPath, lookErr := exec.LookPath("hyperframes") // 流程:打包项目 ZIP → 直接 POST 给 hyperframes-servicemultipart)→ 保存返回的 MP4
if lookErr != nil { func (s *captionService) renderViaRemoteService(ctx context.Context, taskID, projectDir, outputPath string) error {
return fmt.Errorf("hyperframes 未安装, 请运行: npm install -g hyperframes") // 1. 打包项目目录为 ZIP
zipPath := filepath.Join(projectDir, "project.zip")
if err := s.zipProjectDir(projectDir, zipPath); err != nil {
return fmt.Errorf("打包项目文件失败: %v", err)
}
defer os.Remove(zipPath)
// 2. 获取 hyperframes-service 地址
serviceAddr := g.Cfg().MustGet(ctx, "hyperframes_service.address", "").String()
if serviceAddr == "" {
serviceAddr = "http://127.0.0.1:3029"
} }
// 直接在工作目录运行 hyperframes render,输出默认 output.mp4 // 3. 将 ZIP 作为 multipart 发送到渲染服务(同步渲染)
cmd := exec.CommandContext(ctxWithTimeout, hyperframesPath, "render") var reqBuf bytes.Buffer
cmd.Dir = projectDir mw := multipart.NewWriter(&reqBuf)
// 从配置文件读取并构建环境变量 fw, err := mw.CreateFormFile("file", "project.zip")
headless := g.Cfg().MustGet(ctx, "hyperframes.headless", true).Bool() if err != nil {
browserPath := g.Cfg().MustGet(ctx, "hyperframes.browser_path", "").String() return fmt.Errorf("创建表单文件字段失败: %v", err)
ffmpegCfgPath := g.Cfg().MustGet(ctx, "ffmpeg.path", "").String() }
env := os.Environ() zipFile, err := os.Open(zipPath)
// 用短路径作为 hyperframes/Chrome 的 TMPDIR。 if err != nil {
// 原因:Chrome 会在 TMPDIR 下创建 puppeteer 临时 profilepuppeteer_dev_chrome_profile-XXXXXX return fmt.Errorf("打开ZIP文件失败: %v", err)
// 及 Unix socket,而 sockaddr_un.sun_path 上限 108 字节。若 TMPDIR 过长(如
// /app/resource/temp/caption_cap_<36位taskID>/hf_tmp,加上 profile 后已 >108),socket 创建失败,
// 浏览器启动即崩溃 —— puppeteer 报 "Failed to launch the browser process: Code: null",渲染失败。
// Linux 直接用 /tmp(很短);Windows 保留"与工作目录同盘"约束(避免跨盘符号链接失败),
// 用项目盘符下的短路径。
tmpBase := os.TempDir() // Linux: /tmpWindows: 系统临时目录
if runtime.GOOS == "windows" {
if volume := filepath.VolumeName(projectDir); volume != "" {
tmpBase = filepath.Join(volume+string(os.PathSeparator), "hf_tmp")
} }
if _, err = io.Copy(fw, zipFile); err != nil {
zipFile.Close()
mw.Close()
return fmt.Errorf("写入ZIP内容失败: %v", err)
} }
os.MkdirAll(tmpBase, 0755) zipFile.Close()
env = append(env, "TMP="+tmpBase, "TEMP="+tmpBase, "TMPDIR="+tmpBase) mw.Close()
g.Log().Infof(ctx, "[HyperFrames] 设置 TMP=%s (短路径,避免 Unix socket 超长; Windows 同盘避免跨盘符号链接)", tmpBase)
if headless {
env = append(env, "HYPERFRAMES_HEADLESS=true")
}
// 解析实际要用的浏览器路径:
// - 配置了 browser_path 且路径存在 → 直接用
// - 不存在(如 Windows 本地没有 Linux 的 /usr/bin/chromium-browser)或未配置 → 自动探测本机系统
// Chrome/Chromium,避免 HyperFrames 回退到从 Google CDN 慢速下载(国内网络会长时间卡住)
resolvedBrowserPath := ""
if browserPath != "" {
if _, statErr := os.Stat(browserPath); statErr == nil {
resolvedBrowserPath = browserPath
} else {
g.Log().Warningf(ctx, "[HyperFrames] 配置的 hyperframes.browser_path=%s 不存在(%v),自动探测系统浏览器", browserPath, statErr)
}
}
if resolvedBrowserPath == "" {
resolvedBrowserPath = detectSystemChromePath()
}
if resolvedBrowserPath != "" {
env = append(env, "HYPERFRAMES_BROWSER_PATH="+resolvedBrowserPath)
g.Log().Infof(ctx, "[HyperFrames] 使用浏览器: %s", resolvedBrowserPath)
}
if ffmpegCfgPath != "" {
ffmpegDir := filepath.Dir(ffmpegCfgPath)
// 把 FFmpeg 目录插入 PATH 最前面,让 HyperFrames 能找到 ffmpeg/ffprobe
// Windows 上环境变量名是 Path(大小写不敏感)
pathFound := false
for i, e := range env {
if len(e) > 5 && strings.EqualFold(e[:5], "PATH=") {
env[i] = "PATH=" + ffmpegDir + string(os.PathListSeparator) + e[5:]
pathFound = true
break
}
}
if !pathFound {
env = append(env, "PATH="+ffmpegDir)
}
g.Log().Infof(ctx, "[HyperFrames] 已将 FFmpeg 目录加入 PATH: %s", ffmpegDir)
}
cmd.Env = env
// 实时转发 hyperframes 子进程输出到本服务 stdout:渲染可能长达数分钟, g.Log().Infof(ctx, "[字幕 %s] 发送渲染请求, ZIP大小=%d", taskID, reqBuf.Len())
// 若用 CombinedOutput 缓冲,控制台在渲染期间会长时间无输出,无法判断是否卡住。
// 输出同时保留到内存,供失败时诊断。
var renderOutput bytes.Buffer
cmd.Stdout = io.MultiWriter(os.Stdout, &renderOutput)
cmd.Stderr = io.MultiWriter(os.Stdout, &renderOutput)
renderStart := time.Now() client := &http.Client{Timeout: 60 * time.Minute}
g.Log().Infof(ctx, "[HyperFrames] 开始渲染: 项目目录=%s, 渲染超时=%d分钟", projectDir, timeoutMin) resp, err := client.Post(serviceAddr+"/render", mw.FormDataContentType(), &reqBuf)
if err := cmd.Run(); err != nil { if err != nil {
return fmt.Errorf("hyperframes render 失败(耗时%s): %v\n%s", time.Since(renderStart), err, renderOutput.String()) return fmt.Errorf("调用渲染服务失败: %v", err)
} }
g.Log().Infof(ctx, "[HyperFrames] render 完成(耗时%s): %s", time.Since(renderStart), strings.TrimSpace(renderOutput.String())) defer resp.Body.Close()
// 查找输出文件:优先 renders/ 目录(HyperFrames 默认输出位置) if resp.StatusCode != http.StatusOK {
rendersDir := filepath.Join(projectDir, "renders") body, _ := io.ReadAll(resp.Body)
if entries, readErr := os.ReadDir(rendersDir); readErr == nil { return fmt.Errorf("渲染服务返回错误(%d): %s", resp.StatusCode, string(body))
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".mp4") {
src := filepath.Join(rendersDir, entry.Name())
if err := os.Rename(src, outputPath); err != nil {
return fmt.Errorf("移动输出文件失败: %v", err)
} }
g.Log().Infof(ctx, "[HyperFrames] 找到输出文件: %s", entry.Name())
// 4. 保存响应体(MP4)到本地
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("创建输出文件失败: %v", err)
}
defer outFile.Close()
written, err := io.Copy(outFile, resp.Body)
if err != nil {
return fmt.Errorf("保存渲染结果失败: %v", err)
}
g.Log().Infof(ctx, "[字幕 %s] ✅ 远程渲染完成, 输出=%s (%d bytes)", taskID, outputPath, written)
return nil return nil
}
}
}
// 回退:检查 projectDir 下的 output.mp4
fallback := filepath.Join(projectDir, "output.mp4")
if _, statErr := os.Stat(fallback); statErr == nil {
if err := os.Rename(fallback, outputPath); err != nil {
return fmt.Errorf("移动输出文件失败: %v", err)
}
return nil
}
return fmt.Errorf("未找到输出文件(已在 %s 和 %s 中查找)", rendersDir, fallback)
} }
// detectSystemChromePath 探测本机系统级 Chrome/Chromium 可执行文件路径。 // zipProjectDir 将项目目录打包为 ZIP 文件
// 用于配置的 browser_path 在当前平台不存在(如 Windows 本地没有 Linux 的 /usr/bin/chromium-browser func (s *captionService) zipProjectDir(sourceDir, targetPath string) error {
// 或未配置时,自动找到可用的浏览器,避免 HyperFrames 回退到从 Google CDN 慢速下载。 zipFile, err := os.Create(targetPath)
func detectSystemChromePath() string { if err != nil {
// 优先级最高:显式设置的环境变量(用户可在 OS 层指定,Go 进程继承后这里能读到) return fmt.Errorf("创建ZIP文件失败: %v", err)
if p := os.Getenv("HYPERFRAMES_BROWSER_PATH"); p != "" {
if _, err := os.Stat(p); err == nil {
return p
} }
defer zipFile.Close()
writer := zip.NewWriter(zipFile)
defer writer.Close()
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
} }
switch runtime.GOOS {
case "windows": // 跳过 ZIP 文件自身
// Chrome 标准安装路径(用户级/系统级) if path == targetPath {
for _, p := range []string{ return nil
filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Google", "Chrome", "Application", "chrome.exe"),
} {
if _, err := os.Stat(p); err == nil {
return p
} }
// 计算相对路径
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
} }
case "linux":
for _, p := range []string{"/usr/bin/chromium-browser", "/usr/bin/chromium"} { if info.IsDir() {
if _, err := os.Stat(p); err == nil { // 跳过空目录(HyperFrames 渲染只需要文件)
return p return nil
} }
header, err := zip.FileInfoHeader(info)
if err != nil {
return fmt.Errorf("创建ZIP头失败: %v", err)
} }
header.Name = relPath
header.Method = zip.Deflate
fw, err := writer.CreateHeader(header)
if err != nil {
return fmt.Errorf("创建ZIP条目失败: %v", err)
} }
return ""
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("打开文件失败: %v", err)
}
defer file.Close()
_, err = io.Copy(fw, file)
return err
})
} }
// buildHTML 生成 HyperFrames HTML 模板 // buildHTML 生成 HyperFrames HTML 模板
@@ -587,20 +556,6 @@ func (s *captionService) buildElemStyle(elem dto.CaptionElement, canvasWidth, ca
// 文字样式 // 文字样式
if elem.Type == "text" { if elem.Type == "text" {
if elem.Vertical {
// 竖排显示:writing-mode 竖排,宽度自动收缩为单列,不强制百分比宽度
parts = append(parts, "writing-mode:vertical-rl;")
} else {
if elem.WidthStr != "" {
// 模板指定了非整数宽度(如 calc(100% - 320px)):文字宽度随画布自适应,
// 始终落在两图之间的空隙里,避免压到图片
parts = append(parts, fmt.Sprintf("width:%s;", elem.WidthStr))
parts = append(parts, fmt.Sprintf("max-width:%s;", elem.WidthStr))
} else if elem.Width > 0 {
// 模板指定了像素宽度:文字宽度精确受控,不再按画布百分比
parts = append(parts, fmt.Sprintf("width:%dpx;", elem.Width))
parts = append(parts, fmt.Sprintf("max-width:%dpx;", elem.Width))
} else {
// 自动计算 max-width 防止文字溢出画布 // 自动计算 max-width 防止文字溢出画布
// 底部居中字幕: 90% 宽度; 左/右贴边: 45% 宽度 // 底部居中字幕: 90% 宽度; 左/右贴边: 45% 宽度
maxWidthPct := 90 maxWidthPct := 90
@@ -613,8 +568,6 @@ func (s *captionService) buildElemStyle(elem dto.CaptionElement, canvasWidth, ca
maxWidth := int(float64(canvasWidth) * float64(maxWidthPct) / 100.0) maxWidth := int(float64(canvasWidth) * float64(maxWidthPct) / 100.0)
parts = append(parts, fmt.Sprintf("width:%dpx;", maxWidth)) parts = append(parts, fmt.Sprintf("width:%dpx;", maxWidth))
parts = append(parts, fmt.Sprintf("max-width:%dpx;", maxWidth)) parts = append(parts, fmt.Sprintf("max-width:%dpx;", maxWidth))
}
}
if elem.FontSize > 0 { if elem.FontSize > 0 {
parts = append(parts, fmt.Sprintf("font-size:%dpx;", elem.FontSize)) parts = append(parts, fmt.Sprintf("font-size:%dpx;", elem.FontSize))
@@ -622,19 +575,6 @@ func (s *captionService) buildElemStyle(elem dto.CaptionElement, canvasWidth, ca
if elem.FontColor != "" { if elem.FontColor != "" {
parts = append(parts, fmt.Sprintf("color:%s;", elem.FontColor)) parts = append(parts, fmt.Sprintf("color:%s;", elem.FontColor))
} }
if elem.FontStroke != "" {
parts = append(parts, fmt.Sprintf("-webkit-text-stroke:%s;", elem.FontStroke))
}
// 水平对齐:覆盖全局 .text-element 的居中(flex 容器用 justify-content,多行文字用 text-align
switch elem.TextAlign {
case "left":
parts = append(parts, "text-align:left;")
parts = append(parts, "justify-content:flex-start;")
case "right":
parts = append(parts, "text-align:right;")
parts = append(parts, "justify-content:flex-end;")
}
} }
return strings.Join(parts, "") return strings.Join(parts, "")
@@ -671,12 +611,6 @@ func (s *captionService) muteVideo(ctx context.Context, inputPath, outputPath st
"-c:v", "libx264", // 重新编码视频 "-c:v", "libx264", // 重新编码视频
"-crf", "23", // 画质(23=默认,越小越好) "-crf", "23", // 画质(23=默认,越小越好)
"-preset", "veryfast", // 编码速度优先 "-preset", "veryfast", // 编码速度优先
// 固定关键帧间隔(30帧=1秒@30fps),避免产生稀疏关键帧:
// HyperFrames 对稀疏关键帧会告警 "sparse keyframes (max interval: Xs). This causes
// seek failures and frame freezing",浏览器在 probe 阶段 seek 时可能异常甚至崩溃
"-g", "30",
"-keyint_min", "30",
"-movflags", "+faststart", // MOOV 前置,便于浏览器快速播放
"-y", outputPath, "-y", outputPath,
} }
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
@@ -743,8 +677,8 @@ func (s *captionService) concatVideos(ctx context.Context, videoPaths []string,
// getVideoRealDuration 用 ffprobe 获取视频时长(秒,float) // getVideoRealDuration 用 ffprobe 获取视频时长(秒,float)
func getVideoRealDuration(ctx context.Context, videoPath string) float64 { func getVideoRealDuration(ctx context.Context, videoPath string) float64 {
ffprobePath := lookupFFprobePath() ffprobePath, err := exec.LookPath("ffprobe")
if ffprobePath == "" { if err != nil {
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长") g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长")
return 0 return 0
} }
@@ -777,8 +711,8 @@ func getVideoRealDuration(ctx context.Context, videoPath string) float64 {
// getVideoDurationStr 获取视频时长可读字符串 // getVideoDurationStr 获取视频时长可读字符串
func getVideoDurationStr(ctx context.Context, videoPath string) string { func getVideoDurationStr(ctx context.Context, videoPath string) string {
ffprobePath := lookupFFprobePath() ffprobePath, err := exec.LookPath("ffprobe")
if ffprobePath == "" { if err != nil {
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长字符串") g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长字符串")
return "" return ""
} }
@@ -816,8 +750,8 @@ func getVideoDurationStr(ctx context.Context, videoPath string) string {
// getVideoResolution 使用 ffprobe 获取视频分辨率 // getVideoResolution 使用 ffprobe 获取视频分辨率
func getVideoResolution(ctx context.Context, videoPath string) (width, height int) { func getVideoResolution(ctx context.Context, videoPath string) (width, height int) {
ffprobePath := lookupFFprobePath() ffprobePath, err := exec.LookPath("ffprobe")
if ffprobePath == "" { if err != nil {
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,使用默认分辨率 1080x1920") g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,使用默认分辨率 1080x1920")
return 1080, 1920 // 默认竖屏 return 1080, 1920 // 默认竖屏
} }
@@ -874,10 +808,9 @@ func hexToRGBA(hex string, alpha float64) string {
// subtitlesToElements 将外部传入的字幕时间线转为底部字幕元素 // subtitlesToElements 将外部传入的字幕时间线转为底部字幕元素
func subtitlesToElements(subtitles []dto.SubtitleSegment, style *dto.SubtitleStyle) []dto.CaptionElement { func subtitlesToElements(subtitles []dto.SubtitleSegment, style *dto.SubtitleStyle) []dto.CaptionElement {
// 应用默认值 // 应用默认值
fontSize := 38 fontSize := 48
fontColor := "#FFFFFF" fontColor := "#FFFFFF"
fontStroke := "0.5px #000000" // 默认极细黑描边 bgColor := "#000000"
bgColor := "" // 默认无背景色(透明)
bgOpacity := 0.4 bgOpacity := 0.4
if style != nil { if style != nil {
if style.FontSize > 0 { if style.FontSize > 0 {
@@ -886,12 +819,9 @@ func subtitlesToElements(subtitles []dto.SubtitleSegment, style *dto.SubtitleSty
if style.FontColor != "" { if style.FontColor != "" {
fontColor = style.FontColor fontColor = style.FontColor
} }
// FontStroke/BgColor/BgOpacity 使用指针,精确区分"没传"和"传了零值" // BgColor/BgOpacity 使用 *string/*float64,精确区分"没传"和"传了零值"
// - 没传字段 → 指针为 nil → 使用默认值 // - 没传字段 → 指针为 nil → 使用默认值
// - 传了 "" 或 0 → 指针非 nil → 按用户意愿设置 // - 传了 "" 或 0 → 指针非 nil → 按用户意愿设置
if style.FontStroke != nil {
fontStroke = *style.FontStroke
}
if style.BgColor != nil { if style.BgColor != nil {
bgColor = *style.BgColor bgColor = *style.BgColor
} }
@@ -928,7 +858,6 @@ func subtitlesToElements(subtitles []dto.SubtitleSegment, style *dto.SubtitleSty
Y: subtitleY, Y: subtitleY,
FontSize: fontSize, FontSize: fontSize,
FontColor: fontColor, FontColor: fontColor,
FontStroke: fontStroke,
BgColor: bgColor, BgColor: bgColor,
BgOpacity: bgOpacity, BgOpacity: bgOpacity,
TrackIndex: 10, TrackIndex: 10,
+1 -13
View File
@@ -764,7 +764,6 @@ func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
req.Header.Set("Accept", "*/*") req.Header.Set("Accept", "*/*")
req.Header.Set("Referer", parsedURL.Scheme+"://"+parsedURL.Host+"/") req.Header.Set("Referer", parsedURL.Scheme+"://"+parsedURL.Host+"/")
dlStart := time.Now()
client := &http.Client{Timeout: 10 * time.Minute} client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
@@ -786,13 +785,6 @@ func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
} }
expectedSize := resp.ContentLength expectedSize := resp.ContentLength
// 下载开始日志:大文件下载可能持续数分钟,先打出文件大小,避免控制台长时间无输出误判卡死
if expectedSize > 0 {
g.Log().Infof(ctx, "[下载] 开始下载: %s, 大小=%.1fMB", rawURL, float64(expectedSize)/1024/1024)
} else {
g.Log().Infof(ctx, "[下载] 开始下载: %s, 大小未知(分块传输)", rawURL)
}
out, err := os.Create(savePath) out, err := os.Create(savePath)
if err != nil { if err != nil {
return "", err return "", err
@@ -802,11 +794,7 @@ func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
written, err := io.Copy(out, resp.Body) written, err := io.Copy(out, resp.Body)
if err != nil { if err != nil {
os.Remove(savePath) os.Remove(savePath)
// 增强错误信息:给出已下载字节/总大小/耗时,unexpected EOF 时能看出下载到多少就断了 return "", err
if expectedSize > 0 {
return "", fmt.Errorf("下载中断(已下载 %d/%d 字节, 耗时%s): %v", written, expectedSize, time.Since(dlStart), err)
}
return "", fmt.Errorf("下载中断(已下载 %d 字节, 耗时%s): %v", written, time.Since(dlStart), err)
} }
// 验证下载完整性:检查文件大小是否匹配 Content-Length // 验证下载完整性:检查文件大小是否匹配 Content-Length
-27
View File
@@ -452,33 +452,6 @@ func cleanupFiles(paths []string) {
} }
} }
// lookupFFprobePath 查找 ffprobe 可执行文件路径
// 优先查找 ffmpeg.path 配置同目录下的 ffprobe,回退到系统 PATH
func lookupFFprobePath() string {
ctx := context.Background()
ffmpegCfgPath := g.Cfg().MustGet(ctx, "ffmpeg.path", "").String()
if ffmpegCfgPath != "" {
// 尝试在 ffmpeg 同目录下找 ffprobe
ffprobeCandidate := filepath.Join(filepath.Dir(ffmpegCfgPath), "ffprobe")
if _, err := os.Stat(ffprobeCandidate); err == nil {
g.Log().Infof(ctx, "[ffprobe] ✔ 使用 ffmpeg.path 同目录下的 ffprobe: %s", ffprobeCandidate)
return ffprobeCandidate
}
// Windows: 尝试加 .exe
ffprobeCandidateExe := ffprobeCandidate + ".exe"
if _, err := os.Stat(ffprobeCandidateExe); err == nil {
g.Log().Infof(ctx, "[ffprobe] ✔ 使用 ffmpeg.path 同目录下的 ffprobe.exe: %s", ffprobeCandidateExe)
return ffprobeCandidateExe
}
}
path, err := exec.LookPath("ffprobe")
if err == nil {
return path
}
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到 ffprobe")
return ""
}
// lookupFFmpegPath 查找 ffmpeg 可执行文件路径 // lookupFFmpegPath 查找 ffmpeg 可执行文件路径
func lookupFFmpegPath() (string, error) { func lookupFFmpegPath() (string, error) {
ctx := context.Background() ctx := context.Background()
+5 -110
View File
@@ -10,7 +10,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"time" "time"
dao "media/dao/video" dao "media/dao/video"
@@ -208,11 +207,12 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
// detectScenes 调用 Python 脚本进行场景检测 // detectScenes 调用 Python 脚本进行场景检测
func (s *sceneSplitService) detectScenes(ctx context.Context, videoPath, outputJSON string, threshold float64) error { func (s *sceneSplitService) detectScenes(ctx context.Context, videoPath, outputJSON string, threshold float64) error {
// 查找 Python 可执行文件 // 查找 Python 可执行文件
// Windows 上 python3/python 别名可能指向 Microsoft Store 占位(假 Python), pythonPath, err := exec.LookPath("python3")
// 需要绕过 WindowsApps 目录并检查常见安装路径
pythonPath, err := s.lookupPython(ctx)
if err != nil { if err != nil {
return fmt.Errorf("未找到 Python 环境,请安装 Python 3.x 并执行 pip install scenedetect[opencv,ffmpeg]: %v", err) pythonPath, err = exec.LookPath("python")
if err != nil {
return fmt.Errorf("未找到 Python 环境,请安装 Python 3.x 并执行 pip install scenedetect[opencv,ffmpeg]")
}
} }
// 脚本路径:相对于服务运行目录的 scripts/scene_detect.py // 脚本路径:相对于服务运行目录的 scripts/scene_detect.py
@@ -247,111 +247,6 @@ func (s *sceneSplitService) detectScenes(ctx context.Context, videoPath, outputJ
return nil return nil
} }
// lookupPython 查找真实可用的 Python 可执行文件
// 优先级:PATH 中的 python3/python(容器镜像内 venv 已置于 PATH 首位,命中带 scenedetect 的解释器)
// → 硬编码常见安装路径(Windows 用户/系统目录、/usr/bin/python3 等)
// 每个候选都必须同时通过「真实 Python」与「可导入 scenedetect」验证,两者缺一即跳过,
// 禁止只按 --version 选中解释器(否则会命中系统 python,其无 scenedetect,运行时报 ModuleNotFoundError)。
func (s *sceneSplitService) lookupPython(ctx context.Context) (string, error) {
for _, p := range s.collectPythonCandidates(ctx) {
if s.verifyPython(ctx, p) {
g.Log().Infof(ctx, "[场景检测] 使用 Python: %s", p)
return p, nil
}
}
return "", fmt.Errorf("未找到可用的 Python 3(需能导入 scenedetect,安装参考: pip install scenedetect[opencv,ffmpeg]")
}
// collectPythonCandidates 收集候选 Python 路径,PATH 优先,硬编码路径回退,去重
func (s *sceneSplitService) collectPythonCandidates(ctx context.Context) []string {
var candidates []string
seen := make(map[string]bool)
add := func(p string) {
if p != "" && !seen[p] {
seen[p] = true
candidates = append(candidates, p)
}
}
// 1. PATH 中的 python3 / python(容器镜像内 venv 已置于 PATH 首位)
for _, name := range []string{"python3", "python"} {
if path, err := exec.LookPath(name); err == nil {
// 跳过 Microsoft Store 假占位
if strings.Contains(path, "WindowsApps") {
g.Log().Warningf(ctx, "[场景检测] 跳过 %s(%s): Microsoft Store 占位,非真实 Python", name, path)
continue
}
add(path)
}
}
// 2. 常见安装路径(Windows/Linux/macOS),仅收集实际存在的
for _, p := range commonPythonPaths() {
if _, err := os.Stat(p); err == nil {
add(p)
}
}
return candidates
}
// commonPythonPaths 硬编码的常见 Python 安装路径,作为 PATH 查找的回退
func commonPythonPaths() []string {
return []string{
// 用户安装
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python314", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python313", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python312", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python311", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python310", "python.exe"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python39", "python.exe"),
// 系统安装
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python314", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python313", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python312", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python311", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles"), "Python", "Python310", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python313", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python312", "python.exe"),
filepath.Join(os.Getenv("ProgramFiles(x86)"), "Python", "Python311", "python.exe"),
// C:\Python311 等根目录安装
filepath.Join("C:", "Python314", "python.exe"),
filepath.Join("C:", "Python313", "python.exe"),
filepath.Join("C:", "Python312", "python.exe"),
filepath.Join("C:", "Python311", "python.exe"),
filepath.Join("C:", "Python310", "python.exe"),
// WSL / Git Bash / Linux 环境
"/usr/bin/python3",
"/usr/bin/python",
}
}
// checkPythonImport 探测候选 Python 能否执行指定 Python 代码
func checkPythonImport(ctx context.Context, path, code string, timeout time.Duration) bool {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, path, "-c", code)
if err := cmd.Run(); err != nil {
g.Log().Warningf(ctx, "[场景检测] %s 执行 python -c %q 失败: %v", path, code, err)
return false
}
return true
}
// verifyPython 验证候选 Python:真实可用 且 能导入 scenedetect
func (s *sceneSplitService) verifyPython(ctx context.Context, path string) bool {
// 1. 确认是真实可用的 Python(排除 Microsoft Store 假占位等)
if !checkPythonImport(ctx, path, "import sys", 5*time.Second) {
return false
}
// 2. 确认能导入 scenedetect(场景分割实际依赖,缺失则运行时报 ModuleNotFoundError
if !checkPythonImport(ctx, path, "import scenedetect", 30*time.Second) {
g.Log().Warningf(ctx, "[场景检测] %s 无法导入 scenedetect,跳过", path)
return false
}
return true
}
// getVideoDurationSeconds 使用 ffprobe 获取音视频时长(秒) // getVideoDurationSeconds 使用 ffprobe 获取音视频时长(秒)
func getVideoDurationSeconds(ctx context.Context, videoPath string) float64 { func getVideoDurationSeconds(ctx context.Context, videoPath string) float64 {
ffprobePath, err := exec.LookPath("ffprobe") ffprobePath, err := exec.LookPath("ffprobe")
-428
View File
@@ -1,428 +0,0 @@
package video
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
dto "media/model/dto/video"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/guid"
"golang.org/x/net/html"
)
// Template 模板视频服务单例
// 职责:将 HTML 模板解析为 CaptionElement,然后复用 Caption 服务的完整渲染流程
var Template = new(templateService)
type templateService struct{}
// CreateAsyncTask 创建模板视频任务
// 解析 HTML 模板 → 转为 CaptionElement → 调用 Caption 服务的异步任务
func (s *templateService) CreateAsyncTask(ctx context.Context, videoURLs []string, audioURL string, templates []dto.HtmlTemplate, subtitles []dto.SubtitleSegment, subtitleStyle *dto.SubtitleStyle, callbackURL string) (string, error) {
if len(videoURLs) < 1 {
return "", fmt.Errorf("至少需要1个视频")
}
// templates 为空:跳过模板解析与渲染,直接转交 Caption 服务(拼接视频+混入音频+叠加字幕)
if len(templates) < 1 {
g.Log().Infof(ctx, "[模板解析] templates 为空, 跳过模板逻辑, 直接拼接视频+音频+字幕")
return Caption.CreateAsyncTask(ctx, videoURLs, audioURL, subtitles, subtitleStyle, nil, callbackURL)
}
// 创建临时目录用于下载模板和图片
tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String()
workDir := filepath.Join(tempDir, "tmpl_parse_"+guid.S())
os.RemoveAll(workDir)
os.MkdirAll(workDir, 0755)
defer os.RemoveAll(workDir)
// 解析所有 HTML 模板为 CaptionElement
var allElements []dto.CaptionElement
for i, tmpl := range templates {
tmplStart := time.Now()
g.Log().Infof(ctx, "[模板解析] 解析模板%d/%d: url=%s, start=%.2f, end=%.2f", i+1, len(templates), tmpl.URL, tmpl.Start, tmpl.End)
elements, err := s.parseSingleTemplate(ctx, tmpl, workDir)
if err != nil {
return "", fmt.Errorf("解析模板%d失败: %v", i, err)
}
g.Log().Infof(ctx, "[模板解析] 模板%d/%d 解析完成: 元素数=%d, 耗时=%s", i+1, len(templates), len(elements), time.Since(tmplStart))
allElements = append(allElements, elements...)
}
g.Log().Infof(ctx, "[模板解析] 共解析出 %d 个元素, 转交 Caption 服务处理", len(allElements))
// 直接调用 Caption 服务的异步任务(复用完整渲染流程)
return Caption.CreateAsyncTask(ctx, videoURLs, audioURL, subtitles, subtitleStyle, allElements, callbackURL)
}
// parseSingleTemplate 解析单个 HTML 模板,提取 CaptionElement 列表
func (s *templateService) parseSingleTemplate(ctx context.Context, tmpl dto.HtmlTemplate, downloadDir string) ([]dto.CaptionElement, error) {
// 1. 下载模板 HTML
dlStart := time.Now()
g.Log().Infof(ctx, "[模板解析] 开始下载模板HTML: %s", tmpl.URL)
htmlPath, err := downloadFile(ctx, tmpl.URL, downloadDir)
if err != nil {
return nil, fmt.Errorf("下载模板HTML失败: %v", err)
}
g.Log().Infof(ctx, "[模板解析] 模板HTML下载完成: %s, 耗时=%s", tmpl.URL, time.Since(dlStart))
// 2. 读取内容
htmlBytes, err := os.ReadFile(htmlPath)
if err != nil {
return nil, fmt.Errorf("读取模板HTML失败: %v", err)
}
// 3. 解析 HTML 树
doc, err := html.Parse(bytes.NewReader(htmlBytes))
if err != nil {
return nil, fmt.Errorf("解析模板HTML失败: %v", err)
}
// 4. 遍历 HTML 提取元素
var elements []dto.CaptionElement
s.collectElements(doc, tmpl.Data, tmpl.Start, tmpl.End, downloadDir, ctx, &elements)
return elements, nil
}
// collectElements 递归遍历 HTML 节点,找出带 data-key 的元素并转为 CaptionElement
func (s *templateService) collectElements(n *html.Node, data map[string]string, offset, tmplEnd float64, downloadDir string, ctx context.Context, elements *[]dto.CaptionElement) {
if n.Type == html.ElementNode {
key := getAttr(n, "data-key")
if key != "" && data != nil {
if value, ok := data[key]; ok && value != "" {
elem := s.nodeToElement(n, key, value, offset, tmplEnd, downloadDir, ctx)
if elem != nil {
*elements = append(*elements, *elem)
}
}
}
}
// 递归子节点
for c := n.FirstChild; c != nil; c = c.NextSibling {
s.collectElements(c, data, offset, tmplEnd, downloadDir, ctx, elements)
}
}
// nodeToElement 将 HTML 节点转换为 CaptionElement
func (s *templateService) nodeToElement(n *html.Node, key, value string, offset, tmplEnd float64, downloadDir string, ctx context.Context) *dto.CaptionElement {
elem := &dto.CaptionElement{
Type: "text",
Text: value,
Animation: "",
TrackIndex: 1,
}
// --- data-* 属性解析 ---
// data-start(需要加偏移量)
startStr := getAttr(n, "data-start")
if startStr != "" {
if startVal, err := strconv.ParseFloat(startStr, 64); err == nil {
elem.StartTime = startVal + offset
}
}
// data-duration(自动裁剪到模板结束时间)
durStr := getAttr(n, "data-duration")
if durStr != "" {
if durVal, err := strconv.ParseFloat(durStr, 64); err == nil {
if durVal > 0 {
elem.Duration = durVal
}
}
}
// 如果没有 duration 或 =0,自动按模板结束时间计算
if elem.Duration <= 0 && tmplEnd > offset {
elem.Duration = tmplEnd - elem.StartTime
if elem.Duration <= 0 {
elem.Duration = tmplEnd - offset
}
}
// data-track-index
trackStr := getAttr(n, "data-track-index")
if trackStr != "" {
if trackVal, err := strconv.Atoi(trackStr); err == nil {
elem.TrackIndex = trackVal
}
}
// --- 类型判断 ---
if n.Data == "img" {
elem.Type = "image"
elem.ImageURL = value // 图片URL(Caption服务后续会下载到本地)
elem.Text = ""
}
// --- 从 style 属性解析定位和样式 ---
styleStr := getAttr(n, "style")
if styleStr != "" {
styles := parseInlineStyle(styleStr)
s.applyStyles(elem, styles)
}
// --- 如果当前元素本身没有背景色,检查子 span 是否带背景(如免责声明等嵌套结构) ---
if elem.BgColor == "" && !strings.HasPrefix(styleStr, "background") && n.FirstChild != nil {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == "span" {
childStyle := getAttr(c, "style")
if childStyle != "" {
childStyles := parseInlineStyle(childStyle)
if bg, ok := childStyles["background"]; ok && bg != "" {
s.applyStyles(elem, childStyles)
}
}
break
}
}
}
// --- 从 width/height 属性解析图片尺寸(仅 img 元素) ---
if n.Data == "img" {
wStr := getAttr(n, "width")
if wStr != "" {
if wVal, err := strconv.Atoi(wStr); err == nil {
elem.Width = wVal
}
}
hStr := getAttr(n, "height")
if hStr != "" {
if hVal, err := strconv.Atoi(hStr); err == nil {
elem.Height = hVal
}
}
}
// --- 从 class 解析动画 ---
classStr := getAttr(n, "class")
if classStr != "" {
classes := strings.Fields(classStr)
for _, cls := range classes {
if anim := classToAnimation(cls); anim != "" {
elem.Animation = anim
break
}
}
}
return elem
}
// applyStyles 将 CSS 样式映射到 CaptionElement 字段
func (s *templateService) applyStyles(elem *dto.CaptionElement, styles map[string]string) {
// X 轴定位
if left, ok := styles["left"]; ok {
left = strings.TrimSpace(left)
elem.X = cssPositionToX(left, styles)
} else if _, ok := styles["right"]; ok {
elem.X = "right"
}
// Y 轴定位
if top, ok := styles["top"]; ok {
top = strings.TrimSpace(top)
elem.Y = cssPositionToY(top, styles)
} else if bottom, ok := styles["bottom"]; ok {
bottom = strings.TrimSpace(bottom)
if bottom == "0" || bottom == "0px" {
// bottom:0 → 语义化 "bottom",渲染端输出 bottom:0px(正确贴底)
elem.Y = "bottom"
} else {
// bottom: Npx → 需元素高度才能换算 top,解析阶段拿不到高度;
// 保留 calc(100% - Npx)(顶边在 100%-N 处)。带高度元素请用
// top: calc(100% - 高度px - Npx) 写法
elem.Y = "calc(100% - " + bottom + ")"
}
}
// 竖排显示(writing-mode: vertical-rl / vertical-lr
if wm, ok := styles["writing-mode"]; ok {
if strings.Contains(strings.TrimSpace(wm), "vertical") {
elem.Vertical = true
}
}
// 字号
if fontSize, ok := styles["font-size"]; ok {
elem.FontSize = parsePxToInt(fontSize)
}
// 字体颜色
if color, ok := styles["color"]; ok {
elem.FontColor = strings.TrimSpace(color)
}
// 水平对齐
if ta, ok := styles["text-align"]; ok {
elem.TextAlign = strings.TrimSpace(ta)
}
// 字体描边(如 -webkit-text-stroke: 3px #000000,白字黑描边效果)
if stroke, ok := styles["-webkit-text-stroke"]; ok {
elem.FontStroke = strings.TrimSpace(stroke)
}
// 背景色 + 透明度
if bg, ok := styles["background"]; ok {
bg = strings.TrimSpace(bg)
if strings.HasPrefix(bg, "rgba(") {
// rgba(r,g,b,a) 格式
bg = strings.TrimPrefix(bg, "rgba(")
bg = strings.TrimSuffix(bg, ")")
parts := strings.Split(bg, ",")
if len(parts) == 4 {
elem.BgColor = fmt.Sprintf("#%02x%02x%02x", parseHexByte(strings.TrimSpace(parts[0])), parseHexByte(strings.TrimSpace(parts[1])), parseHexByte(strings.TrimSpace(parts[2])))
if opacity, err := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64); err == nil {
elem.BgOpacity = opacity
}
}
} else if strings.HasPrefix(bg, "#") {
elem.BgColor = bg
} else if strings.HasPrefix(bg, "rgb(") {
elem.BgColor = bg
}
}
// 图片宽高
if w, ok := styles["width"]; ok && elem.Width <= 0 {
w = strings.TrimSpace(w)
if px := parsePxToInt(w); px > 0 {
elem.Width = px
} else if w != "" {
// 非整数宽度(如 calc(100% - 320px)),原样透传,文字可精确控制换行范围
elem.WidthStr = w
}
}
if h, ok := styles["height"]; ok && elem.Height <= 0 {
elem.Height = parsePxToInt(h)
}
}
// ---------- CSS 解析工具 ----------
// parseInlineStyle 将 style 属性字符串解析为 map
func parseInlineStyle(style string) map[string]string {
result := make(map[string]string)
parts := strings.Split(style, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
colonIdx := strings.Index(part, ":")
if colonIdx < 0 {
continue
}
key := strings.TrimSpace(part[:colonIdx])
val := strings.TrimSpace(part[colonIdx+1:])
result[key] = val
}
return result
}
// cssPositionToX 将 CSS left/transform 转为 X 定位值
func cssPositionToX(left string, styles map[string]string) string {
transform := styles["transform"]
left = stripPx(left)
if left == "0" || left == "0px" {
return "left"
}
if left == "50%" && strings.Contains(transform, "translateX(-50%)") {
return "center"
}
if left == "50%" && strings.Contains(transform, "translateX(50%)") {
return "center"
}
return left
}
// cssPositionToY 将 CSS top/transform 转为 Y 定位值
func cssPositionToY(top string, styles map[string]string) string {
transform := styles["transform"]
top = stripPx(top)
if top == "0" || top == "0px" {
return "top"
}
if top == "50%" && strings.Contains(transform, "translateY(-50%)") {
return "center"
}
return top
}
// stripPx 仅当整串为纯数值(N 或 Npx)时去掉 px 后缀,避免破坏 calc(...) 等表达式。
// 例如 "200px" → "200",而 "calc(100% - 200px)" 原样保留。
func stripPx(s string) string {
trimmed := strings.TrimSpace(s)
if strings.HasSuffix(trimmed, "px") {
core := strings.TrimSuffix(trimmed, "px")
if isNumeric(core) {
return core
}
}
return trimmed
}
// parsePxToInt 从 "48px" 或 "48" 解析出 int
func parsePxToInt(s string) int {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, "px")
s = strings.TrimSuffix(s, "PX")
val, err := strconv.Atoi(s)
if err != nil {
return 0
}
return val
}
// parseHexByte 解析数字字符串(可能是 "255" 或 "0xff" 等)为 byte,用于 rgba 转换
func parseHexByte(s string) byte {
val, err := strconv.Atoi(s)
if err != nil {
return 0
}
if val > 255 {
return 255
}
if val < 0 {
return 0
}
return byte(val)
}
// classToAnimation 从 CSS class 名推断动画类型
func classToAnimation(cls string) string {
switch {
case strings.Contains(cls, "fadeIn"):
return "fadeIn"
case strings.Contains(cls, "slideUp"):
return "slideUp"
case strings.Contains(cls, "slideLeft"):
return "slideLeft"
case strings.Contains(cls, "scaleIn"):
return "scaleIn"
case strings.Contains(cls, "pulse"):
return "pulse"
}
return ""
}
// getAttr 获取 HTML 节点属性值
func getAttr(n *html.Node, key string) string {
for _, attr := range n.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
-108
View File
@@ -1,108 +0,0 @@
package video
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
dto "media/model/dto/video"
"github.com/gogf/gf/v2/frame/g"
)
type videoDurationService struct{}
// VideoDuration 视频时长服务单例
var VideoDuration = new(videoDurationService)
// GetVideoDurations 并发获取多个视频的时长,返回每个视频的时长信息及总时长
func (s *videoDurationService) GetVideoDurations(ctx context.Context, urls []string) (*dto.VideoDurationRes, error) {
if len(urls) == 0 {
return nil, fmt.Errorf("视频URL列表不能为空")
}
tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String()
if tempDir == "" {
tempDir = "resource/temp"
}
if !filepath.IsAbs(tempDir) {
absDir, _ := filepath.Abs(tempDir)
tempDir = absDir
}
os.MkdirAll(tempDir, 0755)
g.Log().Infof(ctx, "[视频时长] 开始获取 %d 个视频时长", len(urls))
type itemResult struct {
index int
url string
duration float64
durationStr string
err error
}
results := make([]itemResult, len(urls))
sem := make(chan struct{}, 5) // 最大并发数 5
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
i, u := i, u
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
// 使用独立 context(带超时),不受请求 context 生命周期影响
dlCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
localPath, dlErr := downloadFile(dlCtx, u, tempDir)
if dlErr != nil {
g.Log().Warningf(ctx, "[视频时长] 下载失败 index=%d, url=%s, err=%v", i+1, u, dlErr)
results[i] = itemResult{index: i, url: u, err: dlErr}
return
}
duration := getVideoRealDuration(ctx, localPath)
durationStr := formatDuration(duration)
os.Remove(localPath)
results[i] = itemResult{index: i, url: u, duration: duration, durationStr: durationStr}
}()
}
wg.Wait()
var videos []dto.VideoInfo
var total float64
for _, r := range results {
if r.err != nil {
continue
}
videos = append(videos, dto.VideoInfo{
Index: r.index + 1,
VideoURL: r.url,
Duration: r.duration,
DurationStr: r.durationStr,
})
total += r.duration
}
if len(videos) == 0 {
return nil, fmt.Errorf("所有视频下载或时长获取失败")
}
totalStr := formatDuration(total)
g.Log().Infof(ctx, "[视频时长] 完成: 成功=%d, 总时长=%s", len(videos), totalStr)
return &dto.VideoDurationRes{
Videos: videos,
Count: len(videos),
TotalDuration: total,
TotalDurationStr: totalStr,
}, nil
}
-376
View File
@@ -1,376 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* ========== 全局重置:去掉浏览器默认边距 ========== */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* ========== 画布尺寸 ========== */
/*
width / height:最终视频的输出分辨率
- 当前 1080 x 1920 是竖屏 9:16(手机全屏)
- 想改分辨率就改这两个数字
例如 720 x 1280 就是 720p 竖屏
- 注意:这里只是"预览用"尺寸,实际渲染时画布高度会跟随
你上传的视频分辨率变化,所以模板里贴底/贴右请用 calc(100% - Npx)
不要写死像素值。
- overflow: hidden → 超出画布的部分裁掉,不会出滚动条
- background: #000 → 背景黑色,视频没铺满时露出的颜色
*/
body {
width: 1080px;
height: 1920px;
overflow: hidden;
background: #000;
}
/* .clip:所有元素都要有这个 class,才能用 left/top 精确控制位置 */
.clip { position: absolute; }
/* .text-element:所有文字元素都要有这个 class */
.text-element {
font-family: Arial, Helvetica, sans-serif; /* 字体 */
text-align: center; /* 文字水平居中 */
display: flex; /* 弹性盒子布局(不用管) */
align-items: center; /* 垂直居中 */
justify-content: center; /* 水平居中 */
white-space: pre-wrap; /* 保留换行符,文字超出自动换行 */
word-break: break-word; /* 长单词撑破时强制换行 */
}
/* ========== 入场动画 ========== */
/* 给元素加上 class="anim-xxx" 就能用对应的动画效果 */
/*
动画时长怎么改?
数字越小动画越快,越大越慢
例如 0.5s → 改成 1s 就慢一倍,改成 0.2s 就快一倍
*/
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideLeft { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } }
@keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
.anim-fadeIn { animation: fadeIn 0.5s ease-out; }
.anim-slideUp { animation: slideUp 0.6s ease-out; }
.anim-slideLeft { animation: slideLeft 0.6s ease-out; }
.anim-scaleIn { animation: scaleIn 0.5s ease-out; }
.anim-pulse { animation: pulse 1.5s ease-in-out infinite; }
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!--
======================================================================
id="stage" → 舞台容器,所有元素都放在这里面
重要属性说明:
data-composition-id="main" → 不要改,程序内部标识
data-width / data-height → 必须和上面 body 的宽高一致
======================================================================
-->
<div id="stage" data-composition-id="main" data-start="0" data-width="1080" data-height="1920">
<!--
======================================================================
★ data-key 属性 ★
每个元素必须有一个 data-key 属性,程序会根据 data-key 的名字,
把你传的 data 数据替换到元素内容上。
例如:
模板里 <div data-key="title">标题文字</div>
你传参时 data: { "title": "你好世界" }
程序就会把"标题文字"替换成"你好世界"
★ data-start / data-duration 属性 ★
data-start="0" → 第 0 秒开始显示
data-duration="0" → 持续显示到视频结束(0=一直显示)
★ data-track-index 属性 ★
控制元素的前后层级,数字越大越靠前(盖住数字小的)
track-index=0 是背景视频专用,其他元素从 1 开始
★★ 渲染器 CSS 支持范围(写模板前必看,避免踩坑)★★
模板只是一份"配置",服务端会把它解析成有限的元素模型,再重新生成
HTML 渲染,因此不是所有 CSS 都会被保留。请遵守以下规则:
1. 定位支持:left / right / top / bottom 关键字、纯像素值、
calc(...) 表达式;文字支持 writing-mode: vertical-rl 竖排。
2. ★ 不要用 bottom: Npx 给"有高度"的元素定位 ★
它会被翻译成 top: calc(100% - Npx),把元素顶边放到画布
100%-N 的位置,图片会整体掉出画面(表现为"图片丢失")。
想让元素贴底,请写 top: calc(100% - 元素高度px)
想离底 Npx,请写 top: calc(100% - 元素高度px - Npx)。
3. ★ right: Npx 的偏移量会被忽略 ★(等价 right:0,直接贴右边)
想让元素距右边 Npx,请写 left: calc(100% - Npx - 元素宽度px)。
4. 竖排文字配 top: 50% + transform: translateY(-50%) 可垂直居中;
水平居中仍用 left: 50% + transform: translateX(-50%)。
5. 文字描边支持 -webkit-text-stroke: Npx #000000
(例如白字一圈黑边:-webkit-text-stroke: 1px #000000。
★ 描边别太粗:中文字笔画细,28px 字号下 3px 描边会把白色
填充盖成黑字;1px 稳妥,想明显可加到 2px。)
6. 文字也支持 width(模板指定 width 可覆盖渲染端的默认宽度,
精确控制换行范围,如把文字限定在两张图片之间不压图)。
7. 文字水平对齐支持 text-align: left / center / right
(默认 center 居中;写 left 靠左、right 靠右)。
======================================================================
-->
<!--
======================================================================
data-key="title" 标题
最终效果:居中靠上,红色大字
======================================================================
★ 你可以改下面这些参数来调整效果 ★
left: 50% + transform: translateX(-50%)
水平居中,不要改
top: 320px
距离顶部 320 像素
→ 改大数字,标题往下移;改小数字,标题往上移
font-size: 48px
字号(字体大小)
→ 改大数字,字变大;改小数字,字变小
color: #D82828
文字颜色,十六进制颜色码
→ 常见颜色:
#FFFFFF = 白色 #000000 = 黑色
#D82828 = 红色 #333333 = 深灰
→ 也可以直接写英文 red / white / black
-->
<div data-key="title" data-start="0" data-duration="0" data-track-index="1"
class="clip text-element"
style="
left: 50%;
top: 320px;
transform: translateX(-50%);
font-size: 48px;
color: #D82828;
">标题文字</div>
<!--
======================================================================
data-key="product_image_1" 产品图片(底部左边)
注意:这是 <img> 图片标签,不是文字
★ 可调参数 ★
width: 200px / height: 200px
图片的宽和高
→ 改大图片变大,改小图片变小
→ 改高度时,下面 top: calc(100% - 200px) 里的 200 也要跟着改,
保持等于图片高度,图片才会正好贴底。
left: 200px
距离左边 200 像素(图片不在贴左边缘,而是往中间收)
→ 改大数字,图片往右移
top: calc(100% - 200px)
距离顶部 = 画布高度 - 200px,即图片贴住底部
(100% 是父容器全部高度;200 = 图片高度)
→ 注意:这里不能用 bottom: 0px!会被解析器翻译成
top: calc(100% - 0px),把图片顶边放到画布最底,图片就
整块掉出画面了(这就是之前"图片丢失"的原因)。
→ 想离底部 Npx,写成 calc(100% - 200px - Npx)
提示:
如果你有多张产品图片,可以复制这个元素,
把 data-key 改成 product_image_2、product_image_3 ...
然后在传参时给对应的 key 传不同的图片 URL
-->
<img data-key="product_image_1" data-start="0" data-duration="0" data-track-index="2"
class="clip"
style="
width: 200px;
height: 200px;
left: 1px;
top: calc(100% - 200px);
"
src="占位图片.jpg" />
<img data-key="product_image_2" data-start="0" data-duration="0" data-track-index="2"
class="clip"
style="
width: 100px;
height: 100px;
right: 1px;
top: calc(100% - 200px);
"
src="占位图片.jpg" />
<!--
======================================================================
data-key="product_desc" 产品卖点描述(底部两个图片之间)
最终效果:
- 白字,水平靠左,落在 product_image_1 与 product_image_2 之间的空隙里
- width: calc(100% - 320px) 让文字宽度随画布自适应,正好落在两图空隙内,
任何分辨率下都不会压到图片
- 因为 image_2 图片比较小(比 image_1 矮,只占上面一段),
文字的前几行会被左右两张图片夹住;后面的几行高度超过 image_2 后,
右侧空出来,文字就能延伸到 image_2 图片的下方显示
★ 可调参数 ★
top: calc(100% - 180px)
文字顶部到画布底部的距离(100% 是父容器全部高度)
→ 改大 180 这个数字,整段文字往上移(更远离底部图片)
→ 改小 180 这个数字,文字往下移
→ 想要"前几行被两张图夹住、后几行落到 image_2 下方"
就把这个数字控制在 100~200 之间(两张图都从距底 200px 处开始)
width: calc(100% - 320px)
文字宽度 = 画布宽 - 320px
320 = image_1 宽 200 + image_2 宽 100 + 左右边距各 10px
→ 渲染端支持模板指定非整数宽度(calc/百分比),不再强制画布 90%
→ 想让文字更宽,把 320 改小;想留更多边距,把 320 改大
left: 210px
距画布左边 210pximage_1 在左边缘宽 200px210 正好给它右边留 10px
→ 文字整体往左/往右移,改这个数字
font-size: 34px
字号
→ 改大字变大,改小字变小
color: #FFFFFF
文字颜色(白色)
text-align: left
文字水平对齐方式
→ left = 靠左(本元素默认) center = 居中 right = 靠右
提示:文字内容里可以用 \n 换行
-->
<div data-key="product_desc" data-start="0" data-duration="0" data-track-index="3"
class="clip text-element"
style="
left: 210px;
top: calc(100% - 180px);
width: calc(100% - 320px);
font-size: 17px;
color: #FFFFFF;
text-align: left;
justify-content: flex-start;
">产品卖点描述</div>
<!--
======================================================================
data-key="product_info" 产品信息(左边竖排显示)
最终效果:贴左边缘,文字从上往下竖排显示
★ 可调参数 ★
writing-mode: vertical-rl
竖排显示(文字从上往下写)
这是竖排效果的关键,不要删(服务端已支持)
left: 380px
距离左边 380 像素(往中间收)
→ 改大数字,文字往右移
top: 50% + transform: translateY(-50%)
垂直居中,让竖排文字位于左边中间位置
font-size: 28px
字号(和 disclaimer 一致)
color: #FFFFFF
文字颜色(白色)
-webkit-text-stroke: 1px #000000
文字描边(黑色描边一圈)
→ 描边别太粗:中文字笔画细,3px 会把白色盖成黑字,1px 稳妥
→ 想更明显改成 2px;改成 0 或删掉这行 = 不要描边
-->
<div data-key="product_info" data-start="0" data-duration="0" data-track-index="4"
class="clip text-element"
style="
writing-mode: vertical-rl;
left: 680px;
top: 50%;
transform: translateY(-50%);
font-size: 28px;
color: #FFFFFF;
-webkit-text-stroke: 1px #000000;
">产品信息文字</div>
<!--
======================================================================
data-key="disclaimer" 免责声明(竖排显示)
最终效果:竖排显示,白字黑描边
★ 可调参数 ★
writing-mode: vertical-rl
竖排显示(文字从上往下写)
这是竖排效果的关键,不要删(服务端已支持)
left: calc(100% - 730px)
距画布左边 730 像素(竖排文字宽约 30px,当前位于画面偏左)
→ 改大 730 这个数字,整段往左移;改小 → 往右移
→ 注意:这里不能写 right: Npx!偏移量会被解析器忽略,
等价于 right:0(直接贴右边)。
top: 50% + transform: translateY(-50%)
垂直居中,让竖排文字位于中间位置
font-size: 28px
字号(和 product_info 一致)
color: #FFFFFF
文字颜色(白色)
-webkit-text-stroke: 1px #000000
文字描边(黑色描边一圈)
→ 描边别太粗:中文字笔画细,3px 会把白色盖成黑字,1px 稳妥
→ 想更明显改成 2px;改成 0 或删掉这行 = 不要描边
-->
<div data-key="disclaimer" data-start="0" data-duration="0" data-track-index="5"
class="clip text-element"
style="
writing-mode: vertical-rl;
left: calc(100% - 730px);
top: 50%;
transform: translateY(-50%);
font-size: 28px;
color: #FFFFFF;
-webkit-text-stroke: 1px #000000;
">
免责声明文字
</div>
</div>
<!--
======================================================================
以下脚本不要改,这是程序内部使用的 GSAP 时间轴初始化
======================================================================
-->
<script>
(function() {
var tl = gsap.timeline({ paused: true });
tl.to('#stage', { duration: 0, opacity: 1 }, 0);
window.__timelines = window.__timelines || {};
window.__timelines['main'] = tl;
})();
</script>
</body>
</html>
-302
View File
@@ -1,302 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* ========== 全局重置:去掉浏览器默认边距 ========== */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* ========== 画布尺寸 ========== */
/*
width / height:最终视频的输出分辨率
- 当前 1080 x 1920 是竖屏 9:16(手机全屏)
- 想改分辨率就改这两个数字
例如 720 x 1280 就是 720p 竖屏
- overflow: hidden → 超出画布的部分裁掉,不会出滚动条
- background: #000 → 背景黑色,视频没铺满时露出的颜色
*/
body {
width: 1080px;
height: 1920px;
overflow: hidden;
background: #000;
}
/* .clip:所有元素都要有这个 class,才能用 left/top 精确控制位置 */
.clip { position: absolute; }
/* .text-element:所有文字元素都要有这个 class */
.text-element {
font-family: Arial, Helvetica, sans-serif; /* 字体 */
text-align: center; /* 文字水平居中 */
display: flex; /* 弹性盒子布局(不用管) */
align-items: center; /* 垂直居中 */
justify-content: center; /* 水平居中 */
white-space: pre-wrap; /* 保留换行符,文字超出自动换行 */
word-break: break-word; /* 长单词撑破时强制换行 */
}
/* ========== 入场动画 ========== */
/* 给元素加上 class="anim-xxx" 就能用对应的动画效果 */
/*
动画时长怎么改?
数字越小动画越快,越大越慢
例如 0.5s → 改成 1s 就慢一倍,改成 0.2s 就快一倍
*/
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideLeft { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } }
@keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
.anim-fadeIn { animation: fadeIn 0.5s ease-out; }
.anim-slideUp { animation: slideUp 0.6s ease-out; }
.anim-slideLeft { animation: slideLeft 0.6s ease-out; }
.anim-scaleIn { animation: scaleIn 0.5s ease-out; }
.anim-pulse { animation: pulse 1.5s ease-in-out infinite; }
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!--
======================================================================
id="stage" → 舞台容器,所有元素都放在这里面
重要属性说明:
data-composition-id="main" → 不要改,程序内部标识
data-width / data-height → 必须和上面 body 的宽高一致
======================================================================
-->
<div id="stage" data-composition-id="main" data-start="0" data-width="1080" data-height="1920">
<!--
======================================================================
★ data-key 属性 ★
每个元素必须有一个 data-key 属性,程序会根据 data-key 的名字,
把你传的 data 数据替换到元素内容上。
例如:
模板里 <div data-key="title">标题文字</div>
你传参时 data: { "title": "你好世界" }
程序就会把"标题文字"替换成"你好世界"
★ data-start / data-duration 属性 ★
data-start="0" → 第 0 秒开始显示
data-duration="0" → 持续显示到视频结束(0=一直显示)
★ data-track-index 属性 ★
控制元素的前后层级,数字越大越靠前(盖住数字小的)
track-index=0 是背景视频专用,其他元素从 1 开始
======================================================================
-->
<!--
======================================================================
data-key="title" 标题
最终效果:居中靠上,红色大字
======================================================================
★ 你可以改下面这些参数来调整效果 ★
left: 50% + transform: translateX(-50%)
水平居中,不要改
top: 70px
距离顶部 70 像素
→ 改大数字,标题往下移;改小数字,标题往上移
font-size: 48px
字号(字体大小)
→ 改大数字,字变大;改小数字,字变小
color: #D82828
文字颜色,十六进制颜色码
→ 常见颜色:
#FFFFFF = 白色 #000000 = 黑色
#D82828 = 红色 #333333 = 深灰
→ 也可以直接写英文 red / white / black
-->
<div data-key="title" data-start="0" data-duration="0" data-track-index="1"
class="clip text-element"
style="
left: 50%;
top: 70px;
transform: translateX(-50%);
font-size: 48px;
color: #D82828;
">标题文字</div>
<!--
======================================================================
data-key="product_image_1" 产品图片
注意:这是 <img> 图片标签,不是文字
★ 可调参数 ★
width: 200px / height: 200px
图片的宽和高
→ 改大图片变大,改小图片变小
left: 0px
距离左边 0 像素(贴左边缘)
→ 改大数字,图片往右移
top: 200px
距离顶部 200 像素
→ 改大数字,图片往下移;改小数字,图片往上移
提示:
如果你有多张产品图片,可以复制这个元素,
把 data-key 改成 product_image_2、product_image_3 ...
然后在传参时给对应的 key 传不同的图片 URL
-->
<img data-key="product_image_1" data-start="0" data-duration="0" data-track-index="2"
class="clip"
style="
width: 200px;
height: 200px;
left: 0px;
top: 200px;
"
src="占位图片.jpg" />
<!--
======================================================================
data-key="product_desc" 产品卖点描述(第一行)
★ 可调参数 ★
top: 830px
距离顶部 830 像素
→ 改大数字,文字往下移;改小数字,文字往上移
font-size: 34px
字号
→ 改大字变大,改小字变小
color: #333333
文字颜色
→ #333333 = 深灰色
→ 改成 #000000 就是纯黑色
→ 改成 #FFFFFF 就是白色
提示:文字内容里可以用 \n 换行
-->
<div data-key="product_desc" data-start="0" data-duration="0" data-track-index="3"
class="clip text-element"
style="
left: 50%;
top: 830px;
transform: translateX(-50%);
font-size: 34px;
color: #333333;
">产品卖点描述</div>
<!--
======================================================================
data-key="product_info" 产品信息(第二行描述,更靠下的位置)
用法和上面的 product_desc 一样,只是位置不同
★ 可调参数 ★
top: 950px
距离顶部 950 像素
→ 比 product_desc 更靠下
font-size: 28px
字号比第一行略小
color: #444444
文字颜色,比第一行稍浅
-->
<div data-key="product_info" data-start="0" data-duration="0" data-track-index="4"
class="clip text-element"
style="
left: 50%;
top: 950px;
transform: translateX(-50%);
font-size: 28px;
color: #444444;
">第二行描述文字</div>
<!--
======================================================================
data-key="disclaimer" 底部免责声明
最终效果:贴在底部,白字黑底半透明
★ 注意 ★
这个元素内部有一个 <span> 标签,背景色写在 span 上,
程序会自动识别 span 上的背景色并应用到最终视频。
★ 可调参数 ★
top: calc(100% - 45px)
距离底部 45 像素(100% 是父容器的全部高度)
→ 改大 45 这个数字,文字往上移(离底部更远)
→ 改小 45 这个数字,文字往下移(贴底部更近)
font-size: 20px
字号
color: #FFFFFF
文字颜色(白色)
★ 下面 <span> 标签里的样式 ★
background: rgba(0, 0, 0, 0.70)
黑底半透明背景
→ 括号里前三个数字 0,0,0 是黑色(改成 255,255,255 就是白色底)
→ 最后一个数字 0.70 是不透明度
1.0 = 完全不透明(纯黑底)
0.5 = 半透明
0.0 = 完全透明(看不见背景)
padding: 12px 24px
背景的内边距
→ 上下 12px,左右 24px
→ 改大数字,背景范围变大(文字周围的空隙变大)
→ 改小数字,背景范围变小
border-radius: 8px
背景圆角
→ 改大数字(如 20px),圆角更明显
→ 改成 0,就是直角
-->
<div data-key="disclaimer" data-start="0" data-duration="0" data-track-index="5"
class="clip text-element"
style="
left: 50%;
top: calc(100% - 45px);
transform: translateX(-50%);
font-size: 20px;
color: #FFFFFF;
">
<span style="background:rgba(0,0,0,0.70); padding:12px 24px; border-radius:8px;">
免责声明文字
</span>
</div>
</div>
<!--
======================================================================
以下脚本不要改,这是程序内部使用的 GSAP 时间轴初始化
======================================================================
-->
<script>
(function() {
var tl = gsap.timeline({ paused: true });
tl.to('#stage', { duration: 0, opacity: 1 }, 0);
window.__timelines = window.__timelines || {};
window.__timelines['main'] = tl;
})();
</script>
</body>
</html>