27 Commits
Author SHA1 Message Date
19904408334 0883acf6ab chore(media): 更新数据库连接凭据 2026-09-04 14:06:28 +08:00
19904408334 9755280746 chore(deploy): 更新 PostgreSQL 密码 2026-09-02 16:22:13 +08:00
lmk 68a5f45173 模板渲染逻辑调整 2026-08-28 13:38:40 +08:00
lmk 486c4a2ff7 添加日志 2026-08-21 16:31:43 +08:00
lmk 610f914085 调整字幕渲染逻辑 2026-08-18 13:43:11 +08:00
lmk dca668a56b 调整模板渲染模式 2026-08-17 17:36:09 +08:00
lmk a785930f21 修复hyperframes渲染失败的问题 2026-08-15 17:50:57 +08:00
admin 9631a91658 禁用 hyperframes 自动更新 2026-08-14 11:08:13 +08:00
admin a3fc47a9cb Merge remote-tracking branch 'origin/dev' into dev 2026-08-14 10:54:50 +08:00
admin 1fa8cb67a3 禁用 hyperframes 自动更新 2026-08-14 10:53:31 +08:00
lmk 66395f71b9 修复python3找不到的bug 2026-08-13 15:10:26 +08:00
admin 2c3ed56755 gitea,gitea-runner写入docker-compose 2026-08-06 09:40:34 +08:00
lmk ecb41269cd 根据模版渲染视频 2026-07-07 16:02:31 +08:00
lmk b740e400c9 新增获取视频时长接口 2026-07-01 16:52:33 +08:00
admin 09a3637b56 common版本更新 2026-06-26 09:07:38 +08:00
lmk 7d48ea50b4 Merge remote-tracking branch 'origin/dev' into dev 2026-06-25 17:30:15 +08:00
lmk 4807b81dd5 优化字幕显示效果 2026-06-25 17:29:47 +08:00
admin ffad253390 Merge remote-tracking branch 'origin/dev' into dev 2026-06-25 17:05:55 +08:00
admin 07b7b5b4f7 common版本更新 2026-06-25 17:05:15 +08:00
lmk d31b1afb50 Merge remote-tracking branch 'origin/dev' into dev 2026-06-25 16:48:27 +08:00
lmk 2d475743f7 字幕默认固定位置 2026-06-25 16:48:18 +08:00
admin e21d4773d2 common版本更新 2026-06-25 14:54:01 +08:00
lmk 4ded242e66 修复异常被吞的问题 2026-06-24 15:51:02 +08:00
lmk a3b8005175 新增打印错误日志 2026-06-24 13:57:11 +08:00
admin 90627ac258 common版本升级 2026-06-23 18:32:02 +08:00
lmk ecde6c9fdc 修复渲染的bug 2026-06-23 17:52:45 +08:00
19904408334 632aa00a3e build: 升级 common 依赖至 v0.0.25 2026-06-23 17:10:09 +08:00
24 changed files with 2229 additions and 255 deletions
+1
View File
@@ -0,0 +1 @@
.git
+22 -6
View File
@@ -1,7 +1,8 @@
# 阶段1: 构建 # 阶段1: 构建
FROM golang:alpine AS builder FROM golang:alpine AS builder
RUN apk add --no-cache git ca-certificates tzdata RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
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
@@ -37,22 +38,37 @@ RUN apk add --no-cache py3-opencv
# 预装 PySceneDetect--no-deps: scenedetect 强依赖 opencv-python 会触发源码编译) # 预装 PySceneDetect--no-deps: scenedetect 强依赖 opencv-python 会触发源码编译)
# opencv 已由 apk 的 py3-opencv 提供,无需 pip 重复安装 # opencv 已由 apk 的 py3-opencv 提供,无需 pip 重复安装
RUN pip3 install --break-system-packages --no-deps -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com scenedetect \ # pip 24+ 扫描系统包元数据时崩溃(python-4.10.0 版本号无效)
&& pip3 install --break-system-packages -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com click platformdirs tqdm # 用虚拟环境隔离
RUN python3 -m venv --system-site-packages /opt/venv \
&& /opt/venv/bin/pip install --no-deps -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com scenedetect \
&& /opt/venv/bin/pip install -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com click platformdirs tqdm
ENV PATH=/opt/venv/bin:$PATH
# 验证 PySceneDetect 安装 # 验证 PySceneDetect 安装
RUN python3 -c "import scenedetect; print(f'scenedetect {scenedetect.__version__} installed')" RUN python3 -c "import scenedetect; print(f'scenedetect {scenedetect.__version__} installed')"
# 预装 HyperFrames(全局安装) # 预装 HyperFrames(全局安装)
# 使用 npmmirror 镜像加速 # 使用 npmmirror 镜像加速
RUN npm install -g hyperframes --registry=https://registry.npmmirror.com/ --ignore-scripts # 禁用 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
# 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 需要的 headless-shell 浏览器 # HyperFrames 直接使用 Alpine 的系统 Chromiummusl 版),而不是下载 chrome-headless-shell
RUN npx hyperframes browser ensure # 原因:hyperframes 默认会下载 Google 官方 chrome-headless-shellglibc 构建),
# 在 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
+32
View File
@@ -0,0 +1,32 @@
# 阶段1: 构建
FROM golang:alpine AS builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk add --no-cache git ca-certificates tzdata
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ENV GO111MODULE=on
ENV GOPROXY=https://goproxy.cn,direct
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=auto
WORKDIR /build
COPY . .
RUN go mod download && go mod tidy
RUN go build -ldflags="-s -w" -o main ./main.go
# 阶段2: 运行时(继承 media-deps 基础镜像)
FROM redfuture-media-deps:latest
# 复制二进制和运行时必需的文件
COPY --from=builder /build/main .
COPY --from=builder /build/config.yml .
COPY --from=builder /build/scripts ./scripts
EXPOSE 3010
CMD ["./main"]
+9 -2
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: "postgres" user: "sql9f15b63fd203b36e"
pass: "Bjang09@686^*^" pass: "1ec94b1acdaf57b66030242d418fad5a"
name: "media" name: "media"
role: "master" role: "master"
maxIdle: "5" maxIdle: "5"
@@ -86,4 +86,11 @@ hyperframes:
render_timeout: 30 render_timeout: 30
# 是否启用 headless 模式 # 是否启用 headless 模式
headless: true headless: true
# 浏览器可执行文件路径(非空且路径存在时设置 HYPERFRAMES_BROWSER_PATH,覆盖 HyperFrames 默认解析)。
# Docker/Alpine 必须用系统 chromiumAlpine 是 musl libc,而 hyperframes 默认下载的
# 官方 chrome-headless-shell 是 glibc 构建,musl 下无法加载(渲染报 spawn ENOENT / signal: killed)。
# 指向普通 Chromium 时 HyperFrames 会自动回退到 screenshot 捕获模式(比 headless-shell 略慢,功能正常)。
# Windows 本地无需改此值:该路径不存在时服务层会自动探测本机 Chrome/Chromium(含 %LOCALAPPDATA% 等标准路径),
# 避免 HyperFrames 回退到从 Google CDN 慢速下载;也可用系统环境变量 HYPERFRAMES_BROWSER_PATH 显式指定。
browser_path: "/usr/bin/chromium-browser"
+60
View File
@@ -0,0 +1,60 @@
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
}
@@ -0,0 +1,26 @@
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
}
+40 -36
View File
@@ -3,43 +3,46 @@ module media
go 1.26.1 go 1.26.1
require ( require (
gitea.redpowerfuture.com/red-future/common v0.0.23 gitea.redpowerfuture.com/red-future/common v0.0.26
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2 github.com/gogf/gf/v2 v2.10.2
) )
require ( require (
github.com/BurntSushi/toml v1.5.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dgraph-io/badger/v4 v4.2.0 // indirect github.com/dgraph-io/badger/v4 v4.2.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-ego/gse v1.0.2 // indirect github.com/go-ego/gse v1.0.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 // indirect
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/glog v1.2.5 // indirect github.com/golang/glog v1.2.5 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect github.com/golang/snappy v1.0.0 // indirect
github.com/google/flatbuffers v1.12.1 // indirect github.com/google/btree v1.1.3 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/consul/api v1.26.1 // indirect github.com/hashicorp/consul/api v1.33.5 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/go-hclog v1.5.0 // indirect
@@ -48,42 +51,43 @@ require (
github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/serf v0.10.1 // indirect github.com/hashicorp/serf v0.10.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.6 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.12.3 // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/errors v1.3.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect github.com/olekukonko/ll v0.1.8 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect github.com/olekukonko/tablewriter v1.1.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/r3labs/diff/v2 v2.15.1 // indirect github.com/r3labs/diff/v2 v2.15.1 // indirect
github.com/redis/go-redis/v9 v9.12.1 // indirect github.com/redis/go-redis/v9 v9.21.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/tiger1103/gfast-token v1.0.10 // indirect github.com/tiger1103/gfast-token v1.0.10 // indirect
github.com/vcaesar/cedar v0.30.0 // indirect github.com/vcaesar/cedar v0.30.0 // indirect
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
go.mongodb.org/mongo-driver/v2 v2.4.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.opencensus.io v0.23.0 // indirect go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.75.0 // indirect google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+104 -94
View File
@@ -1,9 +1,9 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
gitea.redpowerfuture.com/red-future/common v0.0.23 h1:xieoA00iKOCDm5SO9iXn+cSyMKBAlZwI0fuEVPWrHLg= gitea.redpowerfuture.com/red-future/common v0.0.26 h1:C4V2Y6XbzMRttajoVFlc6MyXBER3fYhg1XOUq2wl9Uo=
gitea.redpowerfuture.com/red-future/common v0.0.23/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY= gitea.redpowerfuture.com/red-future/common v0.0.26/go.mod h1:Og3l2TFq10ICcbtc6JRsou17+MsEiXz4m2Ir7A0ODtQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -36,6 +36,10 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -47,8 +51,6 @@ github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWa
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
@@ -61,10 +63,10 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4= github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4=
github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc= github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@@ -77,14 +79,16 @@ github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 h1:u8EpP24GkprogROnJ7htMov9Fc66pTP1eVYrWxiCYOs= github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 h1:u8EpP24GkprogROnJ7htMov9Fc66pTP1eVYrWxiCYOs=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2/go.mod h1:GmvM3r8GVByVMi4RD2+MCs5+CfxVXPMeT8mVDkAaAXE= github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2/go.mod h1:GmvM3r8GVByVMi4RD2+MCs5+CfxVXPMeT8mVDkAaAXE=
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1 h1:egobo4YfQX3C4NtrEFunBqMX3jsddagklgut9u91+BM= github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 h1:iTQegT+lEg/wDKvj2mi3W1wrdrwFarjokf88EXVVgu4=
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1/go.mod h1:YQ+u5Cs5N2ETCeQaLbv29z/UWYuxw27mJpUkOLj0kJ8= github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2/go.mod h1:ZRw3GNz5cq4uYrW4TPSVyrYWaoqzujKdWro/AOcGBaE=
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 h1:eUqwJ/qNH8lJ6yssiqskazgp1ACQuNU6zXlLOZVuXTQ= github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 h1:eUqwJ/qNH8lJ6yssiqskazgp1ACQuNU6zXlLOZVuXTQ=
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5/go.mod h1:sjQyMry9+0POYZCA6lHXBxO77WoNKkruJpRB4xKqk5k= github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5/go.mod h1:sjQyMry9+0POYZCA6lHXBxO77WoNKkruJpRB4xKqk5k=
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 h1:tHUEZYB5GTqEYYVDYnlGobf1xISARKDE4KHVlgjwTec=
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5/go.mod h1:cfzTn2HS9RDX8f5pUVkbGxUWcSosouqfNQ1G6cY0V88=
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8= github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -114,10 +118,10 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -134,12 +138,12 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM= github.com/hashicorp/consul/api v1.33.5 h1:Nn6q87zudRU1rLBTJEgaWxz9STCNadilLCD7B8OA5aI=
github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A= github.com/hashicorp/consul/api v1.33.5/go.mod h1:pa6fJOSHKLOzNHpUVeqLDtxA5+J1D7NNzLasuk8eRXA=
github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU= github.com/hashicorp/consul/sdk v0.17.3 h1:oZMMxzQGSsiT+ToOH50y3Qcs0nc9Ud+7L5lRx+EmMU0=
github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo= github.com/hashicorp/consul/sdk v0.17.3/go.mod h1:jnOmYjiNfVRpBaujQ1DFFVs0N6g3S1y6wygSjLTzYfc=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -169,8 +173,8 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
@@ -185,8 +189,10 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -196,8 +202,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -205,39 +211,39 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
@@ -264,13 +270,10 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/r3labs/diff/v2 v2.15.1 h1:EOrVqPUzi+njlumoqJwiS/TgGgmZo83619FNDB9xQUg= github.com/r3labs/diff/v2 v2.15.1 h1:EOrVqPUzi+njlumoqJwiS/TgGgmZo83619FNDB9xQUg=
github.com/r3labs/diff/v2 v2.15.1/go.mod h1:I8noH9Fc2fjSaMxqF3G2lhDdC0b+JXCfyx85tWFM9kc= github.com/r3labs/diff/v2 v2.15.1/go.mod h1:I8noH9Fc2fjSaMxqF3G2lhDdC0b+JXCfyx85tWFM9kc=
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
@@ -278,14 +281,18 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s= github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s=
@@ -299,28 +306,32 @@ github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaU
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver/v2 v2.4.0 h1:Oq6BmUAAFTzMeh6AonuDlgZMuAuEiUxoAD1koK5MuFo= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
go.mongodb.org/mongo-driver/v2 v2.4.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -329,15 +340,15 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -353,8 +364,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -363,8 +374,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -388,16 +399,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -407,8 +417,8 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -423,17 +433,17 @@ google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -443,8 +453,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+2
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
controllerAudio "media/controller/audio" controllerAudio "media/controller/audio"
controllerVideo "media/controller/video" controllerVideo "media/controller/video"
@@ -27,6 +28,7 @@ func main() {
controllerVideo.Caption, controllerVideo.Caption,
controllerVideo.Transcode, controllerVideo.Transcode,
controllerVideo.SceneSplit, controllerVideo.SceneSplit,
controllerVideo.Template,
}) })
select {} select {}
} }
+10 -5
View File
@@ -15,11 +15,15 @@ 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:"元素宽度(type=image有效)"` Width int `json:"width" dc:"元素宽度(px)"`
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:"轨道层级(数字越大越靠前)"`
} }
@@ -32,12 +36,13 @@ type SubtitleSegment struct {
// SubtitleStyle 字幕样式配置 // SubtitleStyle 字幕样式配置
type SubtitleStyle struct { type SubtitleStyle struct {
FontSize int `json:"fontSize" d:"28" dc:"字号(默认28)"` FontSize int `json:"fontSize" d:"38" dc:"字号(默认38)"`
FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色(默认白色)"` FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色(默认白色)"`
BgColor *string `json:"bgColor" dc:"背景色,空字符串=透明(默认#000000)。传空字符串 '' 可去掉背景色"` FontStroke *string `json:"fontStroke" dc:"字体描边,如0.5px #000000(默认0.5px #000000极细)。传空字符串 '' 可去掉描边"`
BgOpacity *float64 `json:"bgOpacity" dc:"背景透明度0-1(默认0.6)。传0可使背景完全透明"` BgColor *string `json:"bgColor" dc:"背景色,空=透明(默认无背景)。传颜色值如#FFFF00可背景"`
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:"bottom" dc:"垂直位置:top/center/bottom或像素值(默认bottom)"` Y string `json:"y" d:"65%" dc:"垂直位置:top/center/bottom或像素值或百分比(默认65%)"`
} }
// ---------- 创建字幕任务 ---------- // ---------- 创建字幕任务 ----------
+25
View File
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,50 @@
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:"错误信息"`
}
+2 -2
View File
@@ -20,8 +20,8 @@ def detect_scenes(video_path: str, threshold: float) -> list[dict]:
scenes = [] scenes = []
for start, end in scene_list: for start, end in scene_list:
scenes.append({ scenes.append({
"start_time": start.seconds, "start_time": start.get_seconds(),
"end_time": end.seconds, "end_time": end.get_seconds(),
}) })
return scenes return scenes
+38 -8
View File
@@ -53,8 +53,14 @@ func (s *audioTaskService) Create(ctx context.Context, params *CreateTaskParams)
params.Threshold = 0.3 params.Threshold = 0.3
} }
inputBytes, _ := json.Marshal(params.InputData) inputBytes, err := json.Marshal(params.InputData)
fnBytes, _ := json.Marshal(params.FileNames) if err != nil {
return nil, fmt.Errorf("序列化输入数据失败: %v", err)
}
fnBytes, err := json.Marshal(params.FileNames)
if err != nil {
return nil, fmt.Errorf("序列化文件名列表失败: %v", err)
}
now := time.Now() now := time.Now()
task := &entity.TranscribeTask{ task := &entity.TranscribeTask{
@@ -229,18 +235,30 @@ func (s *audioTaskService) callback(ctx context.Context, taskID, status, errMsg,
DetailList: detailItems, DetailList: detailItems,
} }
body, _ := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil {
g.Log().Errorf(ctx, "[回调 %s] 序列化payload失败: %v", taskID, err)
return
}
g.Log().Infof(ctx, "[回调 %s] 触发回调, 状态=%s, 成功=%d 失败=%d, 错误=%s, 目标=%s", g.Log().Infof(ctx, "[回调 %s] 触发回调, 状态=%s, 成功=%d 失败=%d, 错误=%s, 目标=%s",
taskID, taskInfo.Status, taskInfo.SuccessFiles, taskInfo.FailFiles, errMsg, callbackURL) taskID, taskInfo.Status, taskInfo.SuccessFiles, taskInfo.FailFiles, errMsg, callbackURL)
g.Log().Debugf(ctx, "[回调 %s] 回调载荷长度=%d字节, 明细条数=%d", g.Log().Debugf(ctx, "[回调 %s] 回调载荷长度=%d字节, 明细条数=%d",
taskID, len(body), len(detailItems)) taskID, len(body), len(detailItems))
// 透传调用方的用户信息,供回调方 GetUserInfo 从 X-User-Info 头获取 // 透传调用方的用户信息,供回调方 GetUserInfo 从 X-User-Info 头获取
cbUser := getUserFromCtx(ctx) cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser) userJSON, je := json.Marshal(cbUser)
if je != nil {
g.Log().Errorf(ctx, "[回调 %s] 序列化用户信息失败: %v", taskID, je)
return
}
g.Log().Infof(ctx, "[回调 %s] curl -X POST '%s' -H 'Content-Type: application/json' -H 'X-User-Info: %s' -d '%s'", g.Log().Infof(ctx, "[回调 %s] curl -X POST '%s' -H 'Content-Type: application/json' -H 'X-User-Info: %s' -d '%s'",
taskID, callbackURL, string(userJSON), strings.ReplaceAll(string(body), "'", "'\\''")) taskID, callbackURL, string(userJSON), strings.ReplaceAll(string(body), "'", "'\\''"))
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) req, reqErr := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
if reqErr != nil {
g.Log().Errorf(ctx, "[回调 %s] 创建请求失败: %v", taskID, reqErr)
return
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Info", string(userJSON)) req.Header.Set("X-User-Info", string(userJSON))
@@ -251,7 +269,11 @@ func (s *audioTaskService) callback(ctx context.Context, taskID, status, errMsg,
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
g.Log().Errorf(ctx, "[回调 %s] 读取响应失败: %v", taskID, readErr)
return
}
g.Log().Infof(ctx, "[回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) g.Log().Infof(ctx, "[回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
} }
@@ -300,9 +322,13 @@ func (s *audioTaskService) processSingleVideo(ctx context.Context, taskID, saveP
var scenesJSON string var scenesJSON string
if scenes != nil { if scenes != nil {
sb, _ := json.Marshal(scenes) sb, je := json.Marshal(scenes)
if je != nil {
g.Log().Warningf(ctx, "[任务 %s] 序列化场景信息失败: %v", taskID, je)
} else {
scenesJSON = string(sb) scenesJSON = string(sb)
} }
}
s.saveDetail(ctx, taskID, fileIndex, fileName, s.saveDetail(ctx, taskID, fileIndex, fileName,
transRes.Text, scenesJSON, transRes.AudioSize, transRes.AudioDuration, model, language, "") transRes.Text, scenesJSON, transRes.AudioSize, transRes.AudioDuration, model, language, "")
@@ -414,8 +440,12 @@ func enrichDetailsFromResult(resultJSON string, details []dto.TranscribeTaskDeta
continue continue
} }
if r.FileName == d.FileName { if r.FileName == d.FileName {
sb, _ := json.Marshal(r.Result.Scenes) sb, je := json.Marshal(r.Result.Scenes)
if je != nil {
g.Log().Warningf(context.TODO(), "[回调] 序列化场景信息失败: %v", je)
} else {
details[i].Scenes = string(sb) details[i].Scenes = string(sb)
}
// 同时补全其他可能缺失的字段 // 同时补全其他可能缺失的字段
if d.AudioDuration == "" { if d.AudioDuration == "" {
details[i].AudioDuration = r.Result.AudioDuration details[i].AudioDuration = r.Result.AudioDuration
+40 -9
View File
@@ -92,7 +92,9 @@ func (s *analysisService) processAsyncTask(user *beans.User, taskID string, vide
if r := recover(); r != nil { if r := recover(); r != nil {
errMsg := fmt.Sprintf("视频分析异常: %v", r) errMsg := fmt.Sprintf("视频分析异常: %v", r)
g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg) g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg)
analysisDao.AnalysisTask.UpdateError(bgCtx, taskID, errMsg) if err := analysisDao.AnalysisTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[视频分析 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.analysisCallback(bgCtx, taskID, callbackURL) s.analysisCallback(bgCtx, taskID, callbackURL)
} }
}() }()
@@ -109,7 +111,9 @@ func (s *analysisService) processAsyncTask(user *beans.User, taskID string, vide
if dlErr != nil { if dlErr != nil {
errMsg := fmt.Sprintf("下载视频失败 %s: %v", videoURL, dlErr) errMsg := fmt.Sprintf("下载视频失败 %s: %v", videoURL, dlErr)
g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg) g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg)
analysisDao.AnalysisTaskDetail.UpdateError(bgCtx, taskID, videoURL, errMsg) if err := analysisDao.AnalysisTaskDetail.UpdateError(bgCtx, taskID, videoURL, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[视频分析 %s] 更新明细失败状态到数据库出错: %v", taskID, err)
}
failedCount++ failedCount++
analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount) analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount)
continue continue
@@ -122,7 +126,9 @@ func (s *analysisService) processAsyncTask(user *beans.User, taskID string, vide
if cpErr != nil { if cpErr != nil {
errMsg := fmt.Sprintf("调用Caption接口失败 %s: %v", videoURL, cpErr) errMsg := fmt.Sprintf("调用Caption接口失败 %s: %v", videoURL, cpErr)
g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg) g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg)
analysisDao.AnalysisTaskDetail.UpdateError(bgCtx, taskID, videoURL, errMsg) if err := analysisDao.AnalysisTaskDetail.UpdateError(bgCtx, taskID, videoURL, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[视频分析 %s] 更新明细失败状态到数据库出错: %v", taskID, err)
}
failedCount++ failedCount++
analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount) analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount)
continue continue
@@ -131,7 +137,13 @@ func (s *analysisService) processAsyncTask(user *beans.User, taskID string, vide
g.Log().Infof(bgCtx, "[视频分析 %s] Caption接口调用成功: %s", taskID, videoURL) g.Log().Infof(bgCtx, "[视频分析 %s] Caption接口调用成功: %s", taskID, videoURL)
// 3. 保存结果到数据库(视频不删除,永久保留) // 3. 保存结果到数据库(视频不删除,永久保留)
captionJSON, _ := json.Marshal(captionResult) captionJSON, je := json.Marshal(captionResult)
if je != nil {
g.Log().Errorf(bgCtx, "[视频分析 %s] 序列化结果失败: %v", taskID, je)
failedCount++
analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount)
continue
}
analysisDao.AnalysisTaskDetail.UpdateSuccess(bgCtx, taskID, videoURL, savePath, string(captionJSON)) analysisDao.AnalysisTaskDetail.UpdateSuccess(bgCtx, taskID, videoURL, savePath, string(captionJSON))
successCount++ successCount++
analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount) analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount)
@@ -143,7 +155,10 @@ func (s *analysisService) processAsyncTask(user *beans.User, taskID string, vide
if failedCount == 0 { if failedCount == 0 {
analysisDao.AnalysisTask.UpdateSuccess(bgCtx, taskID, successCount, failedCount) analysisDao.AnalysisTask.UpdateSuccess(bgCtx, taskID, successCount, failedCount)
} else if successCount == 0 { } else if successCount == 0 {
analysisDao.AnalysisTask.UpdateError(bgCtx, taskID, fmt.Sprintf("所有视频处理失败(共%d个)", len(videoURLs))) g.Log().Errorf(bgCtx, "[视频分析 %s] 所有视频处理失败(共%d个)", taskID, len(videoURLs))
if err := analysisDao.AnalysisTask.UpdateError(bgCtx, taskID, fmt.Sprintf("所有视频处理失败(共%d个)", len(videoURLs))); err != nil {
g.Log().Errorf(bgCtx, "[视频分析 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
} else { } else {
analysisDao.AnalysisTask.UpdateSuccess(bgCtx, taskID, successCount, failedCount) analysisDao.AnalysisTask.UpdateSuccess(bgCtx, taskID, successCount, failedCount)
} }
@@ -355,14 +370,26 @@ func (s *analysisService) analysisCallback(ctx context.Context, taskID, callback
"results": results, "results": results,
} }
body, _ := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil {
g.Log().Errorf(ctx, "[视频分析回调 %s] 序列化payload失败: %v", taskID, err)
return
}
g.Log().Infof(ctx, "[视频分析回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL) g.Log().Infof(ctx, "[视频分析回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL)
cbReq, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) cbReq, reqErr := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
if reqErr != nil {
g.Log().Errorf(ctx, "[视频分析回调 %s] 创建请求失败: %v", taskID, reqErr)
return
}
cbReq.Header.Set("Content-Type", "application/json") cbReq.Header.Set("Content-Type", "application/json")
// 透传调用方用户信息 // 透传调用方用户信息
cbUser := getUserFromCtx(ctx) cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser) userJSON, je := json.Marshal(cbUser)
if je != nil {
g.Log().Errorf(ctx, "[视频分析回调 %s] 序列化用户信息失败: %v", taskID, je)
return
}
cbReq.Header.Set("X-User-Info", string(userJSON)) cbReq.Header.Set("X-User-Info", string(userJSON))
// 打印 curl 命令方便调试 // 打印 curl 命令方便调试
@@ -377,6 +404,10 @@ func (s *analysisService) analysisCallback(ctx context.Context, taskID, callback
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
g.Log().Errorf(ctx, "[视频分析回调 %s] 读取响应失败: %v", taskID, readErr)
return
}
g.Log().Infof(ctx, "[视频分析回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) g.Log().Infof(ctx, "[视频分析回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
} }
+224 -34
View File
@@ -11,6 +11,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -46,8 +47,14 @@ func (s *captionService) CreateAsyncTask(ctx context.Context, videoURLs []string
subtitles = []dto.SubtitleSegment{} subtitles = []dto.SubtitleSegment{}
} }
videoURLsJSON, _ := json.Marshal(videoURLs) videoURLsJSON, err := json.Marshal(videoURLs)
elementsJSON, _ := json.Marshal(elements) if err != nil {
return "", fmt.Errorf("序列化视频URL失败: %v", err)
}
elementsJSON, err := json.Marshal(elements)
if err != nil {
return "", fmt.Errorf("序列化元素失败: %v", err)
}
taskID := "cap_" + guid.S() taskID := "cap_" + guid.S()
task := &entity.VideoCaptionTask{ task := &entity.VideoCaptionTask{
@@ -98,16 +105,21 @@ 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 {
errMsg := fmt.Sprintf("所有视频下载失败(共%d个)", len(videoURLs)) errMsg := fmt.Sprintf("所有视频下载失败(共%d个)", len(videoURLs))
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL, nil) s.callback(bgCtx, taskID, callbackURL, nil)
return return
} }
@@ -161,10 +173,12 @@ 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 {
@@ -187,7 +201,10 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
htmlPath := filepath.Join(projectDir, "index.html") htmlPath := filepath.Join(projectDir, "index.html")
if err := os.WriteFile(htmlPath, []byte(htmlContent), 0644); err != nil { if err := os.WriteFile(htmlPath, []byte(htmlContent), 0644); err != nil {
errMsg := fmt.Sprintf("生成HTML失败: %v", err) errMsg := fmt.Sprintf("生成HTML失败: %v", err)
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL, nil) s.callback(bgCtx, taskID, callbackURL, nil)
return return
} }
@@ -197,7 +214,10 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
outputPath := filepath.Join(projectDir, "output.mp4") outputPath := filepath.Join(projectDir, "output.mp4")
if err := s.runHyperFramesRender(bgCtx, projectDir, outputPath); err != nil { if err := s.runHyperFramesRender(bgCtx, projectDir, outputPath); err != nil {
errMsg := fmt.Sprintf("视频渲染失败: %v", err) errMsg := fmt.Sprintf("视频渲染失败: %v", err)
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL, nil) s.callback(bgCtx, taskID, callbackURL, nil)
return return
} }
@@ -206,7 +226,10 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
stat, statErr := os.Stat(outputPath) stat, statErr := os.Stat(outputPath)
if statErr != nil { if statErr != nil {
errMsg := fmt.Sprintf("输出文件不存在: %v", statErr) errMsg := fmt.Sprintf("输出文件不存在: %v", statErr)
dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL, nil) s.callback(bgCtx, taskID, callbackURL, nil)
return return
} }
@@ -218,7 +241,11 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
uploadCtx := context.WithValue(context.Background(), "user", user) uploadCtx := context.WithValue(context.Background(), "user", user)
uploadRes, uploadErr := uploadToMinIO(uploadCtx, outputPath) uploadRes, uploadErr := uploadToMinIO(uploadCtx, outputPath)
if uploadErr != nil { if uploadErr != nil {
dao.CaptionTask.UpdateError(bgCtx, taskID, fmt.Sprintf("上传失败: %v", uploadErr)) errMsg := fmt.Sprintf("上传失败: %v", uploadErr)
g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg)
if err := dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[字幕 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL, nil) s.callback(bgCtx, taskID, callbackURL, nil)
return return
} }
@@ -246,7 +273,8 @@ func (s *captionService) processTask(user *beans.User, taskID string, videoURLs
// runHyperFramesRender 执行 HyperFrames 渲染 // runHyperFramesRender 执行 HyperFrames 渲染
func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, outputPath string) error { func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, outputPath string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Minute) timeoutMin := g.Cfg().MustGet(ctx, "hyperframes.render_timeout", 30).Int()
ctxWithTimeout, cancel := context.WithTimeout(ctx, time.Duration(timeoutMin)*time.Minute)
defer cancel() defer cancel()
// 使用全局 hyperframes 命令(已在 setup 中安装检查) // 使用全局 hyperframes 命令(已在 setup 中安装检查)
@@ -258,16 +286,83 @@ func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, o
// 直接在工作目录运行 hyperframes render,输出默认 output.mp4 // 直接在工作目录运行 hyperframes render,输出默认 output.mp4
cmd := exec.CommandContext(ctxWithTimeout, hyperframesPath, "render") cmd := exec.CommandContext(ctxWithTimeout, hyperframesPath, "render")
cmd.Dir = projectDir cmd.Dir = projectDir
cmd.Env = append(os.Environ(),
"HYPERFRAMES_HEADLESS=true",
"HYPERFRAMES_BROWSER_PATH=/usr/bin/chromium",
)
output, err := cmd.CombinedOutput() // 从配置文件读取并构建环境变量
if err != nil { headless := g.Cfg().MustGet(ctx, "hyperframes.headless", true).Bool()
return fmt.Errorf("hyperframes render 失败: %v\n%s", err, string(output)) browserPath := g.Cfg().MustGet(ctx, "hyperframes.browser_path", "").String()
ffmpegCfgPath := g.Cfg().MustGet(ctx, "ffmpeg.path", "").String()
env := os.Environ()
// 用短路径作为 hyperframes/Chrome 的 TMPDIR。
// 原因:Chrome 会在 TMPDIR 下创建 puppeteer 临时 profilepuppeteer_dev_chrome_profile-XXXXXX
// 及 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")
} }
g.Log().Infof(ctx, "[HyperFrames] render 完成: %s", strings.TrimSpace(string(output))) }
os.MkdirAll(tmpBase, 0755)
env = append(env, "TMP="+tmpBase, "TEMP="+tmpBase, "TMPDIR="+tmpBase)
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:渲染可能长达数分钟,
// 若用 CombinedOutput 缓冲,控制台在渲染期间会长时间无输出,无法判断是否卡住。
// 输出同时保留到内存,供失败时诊断。
var renderOutput bytes.Buffer
cmd.Stdout = io.MultiWriter(os.Stdout, &renderOutput)
cmd.Stderr = io.MultiWriter(os.Stdout, &renderOutput)
renderStart := time.Now()
g.Log().Infof(ctx, "[HyperFrames] 开始渲染: 项目目录=%s, 渲染超时=%d分钟", projectDir, timeoutMin)
if err := cmd.Run(); err != nil {
return fmt.Errorf("hyperframes render 失败(耗时%s): %v\n%s", time.Since(renderStart), err, renderOutput.String())
}
g.Log().Infof(ctx, "[HyperFrames] render 完成(耗时%s): %s", time.Since(renderStart), strings.TrimSpace(renderOutput.String()))
// 查找输出文件:优先 renders/ 目录(HyperFrames 默认输出位置) // 查找输出文件:优先 renders/ 目录(HyperFrames 默认输出位置)
rendersDir := filepath.Join(projectDir, "renders") rendersDir := filepath.Join(projectDir, "renders")
@@ -296,6 +391,38 @@ func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, o
return fmt.Errorf("未找到输出文件(已在 %s 和 %s 中查找)", rendersDir, fallback) return fmt.Errorf("未找到输出文件(已在 %s 和 %s 中查找)", rendersDir, fallback)
} }
// detectSystemChromePath 探测本机系统级 Chrome/Chromium 可执行文件路径。
// 用于配置的 browser_path 在当前平台不存在(如 Windows 本地没有 Linux 的 /usr/bin/chromium-browser
// 或未配置时,自动找到可用的浏览器,避免 HyperFrames 回退到从 Google CDN 慢速下载。
func detectSystemChromePath() string {
// 优先级最高:显式设置的环境变量(用户可在 OS 层指定,Go 进程继承后这里能读到)
if p := os.Getenv("HYPERFRAMES_BROWSER_PATH"); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
switch runtime.GOOS {
case "windows":
// Chrome 标准安装路径(用户级/系统级)
for _, p := range []string{
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
}
}
case "linux":
for _, p := range []string{"/usr/bin/chromium-browser", "/usr/bin/chromium"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
}
return ""
}
// buildHTML 生成 HyperFrames HTML 模板 // buildHTML 生成 HyperFrames HTML 模板
// videoPath: 已拼接好的单视频文件路径;totalDuration: 视频真实总时长(秒) // videoPath: 已拼接好的单视频文件路径;totalDuration: 视频真实总时长(秒)
func (s *captionService) buildHTML(taskID, videoPath, audioPath string, elements []dto.CaptionElement, totalDuration float64, width, height int) string { func (s *captionService) buildHTML(taskID, videoPath, audioPath string, elements []dto.CaptionElement, totalDuration float64, width, height int) string {
@@ -309,7 +436,7 @@ func (s *captionService) buildHTML(taskID, videoPath, audioPath string, elements
sb.WriteString("* { margin: 0; padding: 0; box-sizing: border-box; }\n") sb.WriteString("* { margin: 0; padding: 0; box-sizing: border-box; }\n")
sb.WriteString(fmt.Sprintf("body { width: %dpx; height: %dpx; overflow: hidden; background: #000; }\n", width, height)) sb.WriteString(fmt.Sprintf("body { width: %dpx; height: %dpx; overflow: hidden; background: #000; }\n", width, height))
sb.WriteString(".clip { position: absolute; }\n") sb.WriteString(".clip { position: absolute; }\n")
sb.WriteString(".text-element { font-family: \"Noto Sans SC\", \"Noto Sans CJK SC\", \"PingFang SC\", \"Microsoft YaHei\", Arial, sans-serif; text-align: center; display: flex; align-items: center; justify-content: center; white-space: pre-wrap; word-break: break-word; }\n") sb.WriteString(".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; }\n")
sb.WriteString("@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }\n") sb.WriteString("@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }\n")
sb.WriteString("@keyframes slideUp { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }\n") sb.WriteString("@keyframes slideUp { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }\n")
sb.WriteString("@keyframes slideLeft { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }\n") sb.WriteString("@keyframes slideLeft { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }\n")
@@ -321,7 +448,6 @@ func (s *captionService) buildHTML(taskID, videoPath, audioPath string, elements
sb.WriteString(".anim-scaleIn { animation: scaleIn 0.5s ease-out; }\n") sb.WriteString(".anim-scaleIn { animation: scaleIn 0.5s ease-out; }\n")
sb.WriteString(".anim-pulse { animation: pulse 1.5s ease-in-out infinite; }\n") sb.WriteString(".anim-pulse { animation: pulse 1.5s ease-in-out infinite; }\n")
sb.WriteString("</style>\n") sb.WriteString("</style>\n")
sb.WriteString("<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap\" rel=\"stylesheet\">\n")
sb.WriteString("<script src=\"https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js\"></script>\n") sb.WriteString("<script src=\"https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js\"></script>\n")
sb.WriteString("</head>\n<body>\n") sb.WriteString("</head>\n<body>\n")
@@ -367,15 +493,22 @@ func (s *captionService) buildHTML(taskID, videoPath, audioPath string, elements
elemID, animClass, elem.StartTime, elemDuration, trackIdx, html.EscapeString(elem.ImageURL), imgStyle)) elemID, animClass, elem.StartTime, elemDuration, trackIdx, html.EscapeString(elem.ImageURL), imgStyle))
} else { } else {
bgStyle := "" bgStyle := ""
hasBg := false
if elem.BgColor != "" { if elem.BgColor != "" {
hasBg = true
if elem.BgOpacity > 0 && elem.BgOpacity < 1 { if elem.BgOpacity > 0 && elem.BgOpacity < 1 {
bgStyle = fmt.Sprintf("background:%s;padding:12px 24px;border-radius:8px;", hexToRGBA(elem.BgColor, elem.BgOpacity)) bgStyle = fmt.Sprintf("background:%s;padding:12px 24px;border-radius:8px;", hexToRGBA(elem.BgColor, elem.BgOpacity))
} else { } else {
bgStyle = fmt.Sprintf("background:%s;padding:12px 24px;border-radius:8px;", elem.BgColor) bgStyle = fmt.Sprintf("background:%s;padding:12px 24px;border-radius:8px;", elem.BgColor)
} }
} }
sb.WriteString(fmt.Sprintf(" <div id=\"%s\" class=\"clip text-element %s\" data-start=\"%.3f\" data-duration=\"%.3f\" data-track-index=\"%d\" style=\"%s%s\">%s</div>\n", if hasBg {
sb.WriteString(fmt.Sprintf(" <div id=\"%s\" class=\"clip text-element %s\" data-start=\"%.3f\" data-duration=\"%.3f\" data-track-index=\"%d\" style=\"%s\"><span style=\"%s\">%s</span></div>\n",
elemID, animClass, elem.StartTime, elemDuration, trackIdx, style, bgStyle, html.EscapeString(elem.Text))) elemID, animClass, elem.StartTime, elemDuration, trackIdx, style, bgStyle, html.EscapeString(elem.Text)))
} else {
sb.WriteString(fmt.Sprintf(" <div id=\"%s\" class=\"clip text-element %s\" data-start=\"%.3f\" data-duration=\"%.3f\" data-track-index=\"%d\" style=\"%s\">%s</div>\n",
elemID, animClass, elem.StartTime, elemDuration, trackIdx, style, html.EscapeString(elem.Text)))
}
} }
clipIndex++ clipIndex++
} }
@@ -454,6 +587,20 @@ 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
@@ -464,7 +611,10 @@ func (s *captionService) buildElemStyle(elem dto.CaptionElement, canvasWidth, ca
maxWidthPct = 90 maxWidthPct = 90
} }
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("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))
@@ -472,6 +622,19 @@ 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, "")
@@ -508,6 +671,12 @@ 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...)
@@ -574,8 +743,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, err := exec.LookPath("ffprobe") ffprobePath := lookupFFprobePath()
if err != nil { if ffprobePath == "" {
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长") g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长")
return 0 return 0
} }
@@ -608,8 +777,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, err := exec.LookPath("ffprobe") ffprobePath := lookupFFprobePath()
if err != nil { if ffprobePath == "" {
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长字符串") g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,无法获取视频时长字符串")
return "" return ""
} }
@@ -647,8 +816,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, err := exec.LookPath("ffprobe") ffprobePath := lookupFFprobePath()
if err != nil { if ffprobePath == "" {
g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,使用默认分辨率 1080x1920") g.Log().Warningf(ctx, "[ffprobe] ⚠ 未找到,使用默认分辨率 1080x1920")
return 1080, 1920 // 默认竖屏 return 1080, 1920 // 默认竖屏
} }
@@ -705,10 +874,11 @@ 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 := 28 fontSize := 38
fontColor := "#FFFFFF" fontColor := "#FFFFFF"
bgColor := "#000000" fontStroke := "0.5px #000000" // 默认极细黑描边
bgOpacity := 0.6 bgColor := "" // 默认无背景色(透明)
bgOpacity := 0.4
if style != nil { if style != nil {
if style.FontSize > 0 { if style.FontSize > 0 {
fontSize = style.FontSize fontSize = style.FontSize
@@ -716,9 +886,12 @@ func subtitlesToElements(subtitles []dto.SubtitleSegment, style *dto.SubtitleSty
if style.FontColor != "" { if style.FontColor != "" {
fontColor = style.FontColor fontColor = style.FontColor
} }
// BgColor/BgOpacity 使用 *string/*float64,精确区分"没传"和"传了零值" // FontStroke/BgColor/BgOpacity 使用指针,精确区分"没传"和"传了零值"
// - 没传字段 → 指针为 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
} }
@@ -729,7 +902,7 @@ func subtitlesToElements(subtitles []dto.SubtitleSegment, style *dto.SubtitleSty
// 从样式配置中读取字幕位置 // 从样式配置中读取字幕位置
subtitleX := "center" subtitleX := "center"
subtitleY := "bottom" subtitleY := "65%"
if style != nil { if style != nil {
if style.X != "" { if style.X != "" {
subtitleX = style.X subtitleX = style.X
@@ -755,6 +928,7 @@ 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,
@@ -812,13 +986,25 @@ func (s *captionService) callback(ctx context.Context, taskID, callbackURL strin
} }
} }
body, _ := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil {
g.Log().Errorf(ctx, "[字幕回调 %s] 序列化回调payload失败: %v", taskID, err)
return
}
g.Log().Infof(ctx, "[字幕回调 %s] 状态=%s, 目标=%s, body=%s", taskID, task.Status, callbackURL, string(body)) g.Log().Infof(ctx, "[字幕回调 %s] 状态=%s, 目标=%s, body=%s", taskID, task.Status, callbackURL, string(body))
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) req, reqErr := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
if reqErr != nil {
g.Log().Errorf(ctx, "[字幕回调 %s] 创建请求失败: %v", taskID, reqErr)
return
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
cbUser := getUserFromCtx(ctx) cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser) userJSON, je := json.Marshal(cbUser)
if je != nil {
g.Log().Errorf(ctx, "[字幕回调 %s] 序列化用户信息失败: %v", taskID, je)
return
}
req.Header.Set("X-User-Info", string(userJSON)) req.Header.Set("X-User-Info", string(userJSON))
client := &http.Client{Timeout: 2 * time.Minute} client := &http.Client{Timeout: 2 * time.Minute}
@@ -828,7 +1014,11 @@ func (s *captionService) callback(ctx context.Context, taskID, callbackURL strin
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
g.Log().Errorf(ctx, "[字幕回调 %s] 读取响应失败: %v", taskID, readErr)
return
}
g.Log().Infof(ctx, "[字幕回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) g.Log().Infof(ctx, "[字幕回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
} }
+45 -7
View File
@@ -702,14 +702,26 @@ func (s *concatService) concatCallback(ctx context.Context, taskID, callbackURL
payload["errorMessage"] = task.ErrorMessage payload["errorMessage"] = task.ErrorMessage
} }
body, _ := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil {
g.Log().Errorf(ctx, "[异步拼接回调 %s] 序列化payload失败: %v", taskID, err)
return
}
g.Log().Infof(ctx, "[异步拼接回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL) g.Log().Infof(ctx, "[异步拼接回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL)
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) req, reqErr := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
if reqErr != nil {
g.Log().Errorf(ctx, "[异步拼接回调 %s] 创建请求失败: %v", taskID, reqErr)
return
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
// 透传调用方用户信息 // 透传调用方用户信息
cbUser := getUserFromCtx(ctx) cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser) userJSON, je := json.Marshal(cbUser)
if je != nil {
g.Log().Errorf(ctx, "[异步拼接回调 %s] 序列化用户信息失败: %v", taskID, je)
return
}
req.Header.Set("X-User-Info", string(userJSON)) req.Header.Set("X-User-Info", string(userJSON))
client := &http.Client{Timeout: 2 * time.Minute} client := &http.Client{Timeout: 2 * time.Minute}
@@ -719,7 +731,11 @@ func (s *concatService) concatCallback(ctx context.Context, taskID, callbackURL
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
g.Log().Errorf(ctx, "[异步拼接回调 %s] 读取响应失败: %v", taskID, readErr)
return
}
g.Log().Infof(ctx, "[异步拼接回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) g.Log().Infof(ctx, "[异步拼接回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
} }
@@ -748,6 +764,7 @@ 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 {
@@ -757,7 +774,10 @@ func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// 读取响应体用于错误诊断 // 读取响应体用于错误诊断
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024))
if readErr != nil {
g.Log().Warningf(ctx, "[下载] 读取HTTP错误响应失败: %v", readErr)
}
// 尝试检测签名 URL 是否过期 // 尝试检测签名 URL 是否过期
if parsedURL.Query().Get("x-expires") != "" || parsedURL.Query().Get("expires") != "" { if parsedURL.Query().Get("x-expires") != "" || parsedURL.Query().Get("expires") != "" {
return "", fmt.Errorf("HTTP %d (签名URL可能已过期,请使用新鲜地址) body=%s", resp.StatusCode, string(bodyBytes)) return "", fmt.Errorf("HTTP %d (签名URL可能已过期,请使用新鲜地址) body=%s", resp.StatusCode, string(bodyBytes))
@@ -765,16 +785,34 @@ func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
return "", fmt.Errorf("HTTP %d body=%s", resp.StatusCode, string(bodyBytes)) return "", fmt.Errorf("HTTP %d body=%s", resp.StatusCode, string(bodyBytes))
} }
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
} }
defer out.Close() defer out.Close()
_, 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)
return "", err // 增强错误信息:给出已下载字节/总大小/耗时,unexpected EOF 时能看出下载到多少就断了
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
if expectedSize > 0 && written != expectedSize {
os.Remove(savePath)
return "", fmt.Errorf("下载不完整: 期望 %d 字节, 实际 %d 字节", expectedSize, written)
} }
return savePath, nil return savePath, nil
} }
+33 -7
View File
@@ -442,9 +442,13 @@ func (s *cutService) UploadToMinIO(ctx context.Context, localFilePath string) (*
// 无 HTTP 请求时(异步 goroutine),从 context 的用户信息构造 header // 无 HTTP 请求时(异步 goroutine),从 context 的用户信息构造 header
if !hasAuthHeader { if !hasAuthHeader {
uploadUser := getUserFromCtx(ctx) uploadUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(uploadUser) userJSON, je := json.Marshal(uploadUser)
if je != nil {
g.Log().Errorf(ctx, "[UploadToMinIO] 序列化用户信息失败: %v", je)
} else {
client.SetHeader("X-User-Info", string(userJSON)) client.SetHeader("X-User-Info", string(userJSON))
} }
}
// 设置 multipart Content-Type(含 boundary // 设置 multipart Content-Type(含 boundary
contentType := mw.FormDataContentType() contentType := mw.FormDataContentType()
@@ -568,7 +572,10 @@ func (s *cutService) processAsyncTask(user *beans.User, taskID string, videoURL
savePath, dlErr := downloadFile(bgCtx, videoURL, tempDir) savePath, dlErr := downloadFile(bgCtx, videoURL, tempDir)
if dlErr != nil { if dlErr != nil {
errMsg := fmt.Sprintf("下载视频失败: %v", dlErr) errMsg := fmt.Sprintf("下载视频失败: %v", dlErr)
dao.CutTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[异步剪切 %s] %s", taskID, errMsg)
if err := dao.CutTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[异步剪切 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.cutCallback(bgCtx, taskID, callbackURL) s.cutCallback(bgCtx, taskID, callbackURL)
return return
} }
@@ -577,7 +584,10 @@ func (s *cutService) processAsyncTask(user *beans.User, taskID string, videoURL
// 执行剪切 // 执行剪切
cutErr := s.executeCut(bgCtx, taskID, savePath, shots, upload) cutErr := s.executeCut(bgCtx, taskID, savePath, shots, upload)
if cutErr != nil { if cutErr != nil {
dao.CutTask.UpdateError(bgCtx, taskID, cutErr.Error()) g.Log().Errorf(bgCtx, "[异步剪切 %s] %v", taskID, cutErr)
if err := dao.CutTask.UpdateError(bgCtx, taskID, cutErr.Error()); err != nil {
g.Log().Errorf(bgCtx, "[异步剪切 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.cutCallback(bgCtx, taskID, callbackURL) s.cutCallback(bgCtx, taskID, callbackURL)
return return
} }
@@ -644,14 +654,26 @@ func (s *cutService) cutCallback(ctx context.Context, taskID, callbackURL string
payload["errorMessage"] = task.ErrorMessage payload["errorMessage"] = task.ErrorMessage
} }
body, _ := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil {
g.Log().Errorf(ctx, "[异步剪切回调 %s] 序列化payload失败: %v", taskID, err)
return
}
g.Log().Infof(ctx, "[异步剪切回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL) g.Log().Infof(ctx, "[异步剪切回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL)
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) req, reqErr := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
if reqErr != nil {
g.Log().Errorf(ctx, "[异步剪切回调 %s] 创建请求失败: %v", taskID, reqErr)
return
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
// 透传调用方用户信息 // 透传调用方用户信息
cbUser := getUserFromCtx(ctx) cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser) userJSON, je := json.Marshal(cbUser)
if je != nil {
g.Log().Errorf(ctx, "[异步剪切回调 %s] 序列化用户信息失败: %v", taskID, je)
return
}
req.Header.Set("X-User-Info", string(userJSON)) req.Header.Set("X-User-Info", string(userJSON))
client := &http.Client{Timeout: 2 * time.Minute} client := &http.Client{Timeout: 2 * time.Minute}
@@ -661,6 +683,10 @@ func (s *cutService) cutCallback(ctx context.Context, taskID, callbackURL string
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
g.Log().Errorf(ctx, "[异步剪切回调 %s] 读取响应失败: %v", taskID, readErr)
return
}
g.Log().Infof(ctx, "[异步剪切回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) g.Log().Infof(ctx, "[异步剪切回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
} }
+75 -11
View File
@@ -47,8 +47,14 @@ func (s *mergeService) CreateAsyncTask(ctx context.Context, videoURLs, audioURLs
} }
// 将 videoURLs/audioURLs 序列化为 JSON 存入数据库 // 将 videoURLs/audioURLs 序列化为 JSON 存入数据库
videoURLsJSON, _ := json.Marshal(videoURLs) videoURLsJSON, err := json.Marshal(videoURLs)
audioURLsJSON, _ := json.Marshal(audioURLs) if err != nil {
return "", fmt.Errorf("序列化视频URL失败: %v", err)
}
audioURLsJSON, err := json.Marshal(audioURLs)
if err != nil {
return "", fmt.Errorf("序列化音频URL失败: %v", err)
}
taskID := "merge_" + guid.S() taskID := "merge_" + guid.S()
task := &entity.VideoAudioMergeTask{ task := &entity.VideoAudioMergeTask{
@@ -85,7 +91,9 @@ func (s *mergeService) processAsyncTask(user *beans.User, taskID string, videoUR
if r := recover(); r != nil { if r := recover(); r != nil {
errMsg := fmt.Sprintf("异步拼接混音异常: %v", r) errMsg := fmt.Sprintf("异步拼接混音异常: %v", r)
g.Log().Errorf(bgCtx, "[拼接混音 %s] %s", taskID, errMsg) g.Log().Errorf(bgCtx, "[拼接混音 %s] %s", taskID, errMsg)
dao.MergeTask.UpdateError(bgCtx, taskID, errMsg) if err := dao.MergeTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[拼接混音 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
} }
}() }()
@@ -106,7 +114,10 @@ func (s *mergeService) processAsyncTask(user *beans.User, taskID string, videoUR
if len(videoPaths) < 1 { if len(videoPaths) < 1 {
errMsg := fmt.Sprintf("所有视频下载失败(共%d个)", len(videoURLs)) errMsg := fmt.Sprintf("所有视频下载失败(共%d个)", len(videoURLs))
dao.MergeTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[拼接混音 %s] %s", taskID, errMsg)
if err := dao.MergeTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[拼接混音 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
cleanupFiles(videoPaths) cleanupFiles(videoPaths)
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
return return
@@ -125,7 +136,10 @@ func (s *mergeService) processAsyncTask(user *beans.User, taskID string, videoUR
if len(audioPaths) < 1 { if len(audioPaths) < 1 {
errMsg := fmt.Sprintf("所有音频下载失败(共%d个)", len(audioURLs)) errMsg := fmt.Sprintf("所有音频下载失败(共%d个)", len(audioURLs))
dao.MergeTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[拼接混音 %s] %s", taskID, errMsg)
if err := dao.MergeTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[拼接混音 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
cleanupFiles(videoPaths) cleanupFiles(videoPaths)
cleanupFiles(audioPaths) cleanupFiles(audioPaths)
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
@@ -141,7 +155,10 @@ func (s *mergeService) processAsyncTask(user *beans.User, taskID string, videoUR
cleanupFiles(audioPaths) cleanupFiles(audioPaths)
if mergeErr != nil { if mergeErr != nil {
dao.MergeTask.UpdateError(bgCtx, taskID, mergeErr.Error()) g.Log().Errorf(bgCtx, "[拼接混音 %s] %v", taskID, mergeErr)
if err := dao.MergeTask.UpdateError(bgCtx, taskID, mergeErr.Error()); err != nil {
g.Log().Errorf(bgCtx, "[拼接混音 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
return return
} }
@@ -303,13 +320,25 @@ func (s *mergeService) callback(ctx context.Context, taskID, callbackURL string)
payload["errorMessage"] = task.ErrorMessage payload["errorMessage"] = task.ErrorMessage
} }
body, _ := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil {
g.Log().Errorf(ctx, "[拼接混音回调 %s] 序列化payload失败: %v", taskID, err)
return
}
g.Log().Infof(ctx, "[拼接混音回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL) g.Log().Infof(ctx, "[拼接混音回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL)
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) req, reqErr := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
if reqErr != nil {
g.Log().Errorf(ctx, "[拼接混音回调 %s] 创建请求失败: %v", taskID, reqErr)
return
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
cbUser := getUserFromCtx(ctx) cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser) userJSON, je := json.Marshal(cbUser)
if je != nil {
g.Log().Errorf(ctx, "[拼接混音回调 %s] 序列化用户信息失败: %v", taskID, je)
return
}
req.Header.Set("X-User-Info", string(userJSON)) req.Header.Set("X-User-Info", string(userJSON))
client := &http.Client{Timeout: 2 * time.Minute} client := &http.Client{Timeout: 2 * time.Minute}
@@ -319,7 +348,11 @@ func (s *mergeService) callback(ctx context.Context, taskID, callbackURL string)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
g.Log().Errorf(ctx, "[拼接混音回调 %s] 读取响应失败: %v", taskID, readErr)
return
}
g.Log().Infof(ctx, "[拼接混音回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) g.Log().Infof(ctx, "[拼接混音回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
} }
@@ -362,9 +395,13 @@ func uploadToMinIO(ctx context.Context, localFilePath string) (*uploadFileRes, e
} }
if !hasAuthHeader { if !hasAuthHeader {
uploadUser := getUserFromCtx(ctx) uploadUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(uploadUser) userJSON, je := json.Marshal(uploadUser)
if je != nil {
g.Log().Errorf(ctx, "[上传MinIO] 序列化用户信息失败: %v", je)
} else {
client.SetHeader("X-User-Info", string(userJSON)) client.SetHeader("X-User-Info", string(userJSON))
} }
}
contentType := mw.FormDataContentType() contentType := mw.FormDataContentType()
client.SetHeader("Content-Type", contentType) client.SetHeader("Content-Type", contentType)
@@ -415,6 +452,33 @@ 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()
+159 -16
View File
@@ -10,6 +10,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"time" "time"
dao "media/dao/video" dao "media/dao/video"
@@ -82,7 +83,10 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
videoPath, dlErr := downloadFile(bgCtx, videoURL, workDir) videoPath, dlErr := downloadFile(bgCtx, videoURL, workDir)
if dlErr != nil { if dlErr != nil {
errMsg := fmt.Sprintf("视频下载失败: %v", dlErr) errMsg := fmt.Sprintf("视频下载失败: %v", dlErr)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg)
if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
return return
} }
@@ -105,7 +109,10 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
scenesJSONPath := filepath.Join(workDir, "scenes.json") scenesJSONPath := filepath.Join(workDir, "scenes.json")
if err := s.detectScenes(bgCtx, videoPath, scenesJSONPath, threshold); err != nil { if err := s.detectScenes(bgCtx, videoPath, scenesJSONPath, threshold); err != nil {
errMsg := fmt.Sprintf("场景检测失败: %v", err) errMsg := fmt.Sprintf("场景检测失败: %v", err)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg)
if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
return return
} }
@@ -114,7 +121,10 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
scenes, parseErr := s.parseScenes(scenesJSONPath) scenes, parseErr := s.parseScenes(scenesJSONPath)
if parseErr != nil { if parseErr != nil {
errMsg := fmt.Sprintf("解析场景结果失败: %v", parseErr) errMsg := fmt.Sprintf("解析场景结果失败: %v", parseErr)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg)
if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
return return
} }
@@ -122,7 +132,10 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
if len(scenes) == 0 { if len(scenes) == 0 {
errMsg := "未检测到任何场景" errMsg := "未检测到任何场景"
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg)
if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
return return
} }
@@ -132,7 +145,10 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
results, splitErr := s.splitVideo(bgCtx, videoPath, scenes, workDir, taskID) results, splitErr := s.splitVideo(bgCtx, videoPath, scenes, workDir, taskID)
if splitErr != nil { if splitErr != nil {
errMsg := fmt.Sprintf("视频分割失败: %v", splitErr) errMsg := fmt.Sprintf("视频分割失败: %v", splitErr)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg)
if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
return return
} }
@@ -146,7 +162,10 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
uploadRes, uploadErr := uploadToMinIO(uploadCtx, r.Path) uploadRes, uploadErr := uploadToMinIO(uploadCtx, r.Path)
if uploadErr != nil { if uploadErr != nil {
errMsg := fmt.Sprintf("上传分片%d(%s)到MinIO失败: %v", i+1, r.Timeline, uploadErr) errMsg := fmt.Sprintf("上传分片%d(%s)到MinIO失败: %v", i+1, r.Timeline, uploadErr)
dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg) g.Log().Errorf(bgCtx, "[场景分割 %s] %s", taskID, errMsg)
if err := dao.SceneSplitTask.UpdateError(bgCtx, taskID, errMsg); err != nil {
g.Log().Errorf(bgCtx, "[场景分割 %s] 更新失败状态到数据库出错: %v", taskID, err)
}
s.callback(bgCtx, taskID, callbackURL) s.callback(bgCtx, taskID, callbackURL)
return return
} }
@@ -172,8 +191,12 @@ func (s *sceneSplitService) processTask(user *beans.User, taskID, videoURL strin
} }
// 9. 更新数据库为成功 // 9. 更新数据库为成功
segmentsJSON, _ := json.Marshal(segments) segmentsJSON, je := json.Marshal(segments)
if je != nil {
g.Log().Errorf(bgCtx, "[场景分割 %s] 序列化分片信息失败: %v", taskID, je)
} else {
dao.SceneSplitTask.UpdateSuccess(bgCtx, taskID, string(segmentsJSON), audioURL, len(segments), audioDuration, videoDuration) dao.SceneSplitTask.UpdateSuccess(bgCtx, taskID, string(segmentsJSON), audioURL, len(segments), audioDuration, videoDuration)
}
g.Log().Infof(bgCtx, "[场景分割 %s] 完成! 分片数=%d, 音频=%s", taskID, len(segments), audioURL) g.Log().Infof(bgCtx, "[场景分割 %s] 完成! 分片数=%d, 音频=%s", taskID, len(segments), audioURL)
@@ -185,12 +208,11 @@ 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 可执行文件
pythonPath, err := exec.LookPath("python3") // Windows 上 python3/python 别名可能指向 Microsoft Store 占位(假 Python),
// 需要绕过 WindowsApps 目录并检查常见安装路径
pythonPath, err := s.lookupPython(ctx)
if err != nil { if err != nil {
pythonPath, err = exec.LookPath("python") return fmt.Errorf("未找到 Python 环境,请安装 Python 3.x 并执行 pip install scenedetect[opencv,ffmpeg]: %v", err)
if err != nil {
return fmt.Errorf("未找到 Python 环境,请安装 Python 3.x 并执行 pip install scenedetect[opencv,ffmpeg]")
}
} }
// 脚本路径:相对于服务运行目录的 scripts/scene_detect.py // 脚本路径:相对于服务运行目录的 scripts/scene_detect.py
@@ -225,6 +247,111 @@ 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")
@@ -409,15 +536,27 @@ func (s *sceneSplitService) callback(ctx context.Context, taskID, callbackURL st
payload["errorMessage"] = task.ErrorMessage payload["errorMessage"] = task.ErrorMessage
} }
body, _ := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil {
g.Log().Errorf(ctx, "[场景分割回调 %s] 序列化payload失败: %v", taskID, err)
return
}
cbUser := getUserFromCtx(ctx) cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser) userJSON, je := json.Marshal(cbUser)
if je != nil {
g.Log().Errorf(ctx, "[场景分割回调 %s] 序列化用户信息失败: %v", taskID, je)
return
}
g.Log().Infof(ctx, "[场景分割回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL) g.Log().Infof(ctx, "[场景分割回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL)
g.Log().Infof(ctx, "[场景分割回调 %s] curl: curl -X POST '%s' -H 'Content-Type: application/json' -H 'X-User-Info: %s' -d '%s'", g.Log().Infof(ctx, "[场景分割回调 %s] curl: curl -X POST '%s' -H 'Content-Type: application/json' -H 'X-User-Info: %s' -d '%s'",
taskID, callbackURL, string(userJSON), string(body)) taskID, callbackURL, string(userJSON), string(body))
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body)) req, reqErr := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
if reqErr != nil {
g.Log().Errorf(ctx, "[场景分割回调 %s] 创建请求失败: %v", taskID, reqErr)
return
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Info", string(userJSON)) req.Header.Set("X-User-Info", string(userJSON))
@@ -428,6 +567,10 @@ func (s *sceneSplitService) callback(ctx context.Context, taskID, callbackURL st
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
g.Log().Errorf(ctx, "[场景分割回调 %s] 读取响应失败: %v", taskID, readErr)
return
}
g.Log().Infof(ctx, "[场景分割回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody)) g.Log().Infof(ctx, "[场景分割回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
} }
+428
View File
@@ -0,0 +1,428 @@
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
@@ -0,0 +1,108 @@
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
@@ -0,0 +1,376 @@
<!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
@@ -0,0 +1,302 @@
<!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>