Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0beb0f34 |
+3
-15
@@ -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 的系统 Chromium(musl 版),而不是下载 chrome-headless-shell。
|
# 下载 HyperFrames 需要的 headless-shell 浏览器
|
||||||
# 原因:hyperframes 默认会下载 Google 官方 chrome-headless-shell(glibc 构建),
|
RUN npx hyperframes browser ensure
|
||||||
# 在 Alpine(musl 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
@@ -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
@@ -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 必须用系统 chromium:Alpine 是 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"
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%)"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"`
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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-service(multipart)→ 保存返回的 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 临时 profile(puppeteer_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: /tmp;Windows: 系统临时目录
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 ""
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
距画布左边 210px(image_1 在左边缘宽 200px,210 正好给它右边留 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>
|
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user