Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0883acf6ab | ||
|
|
9755280746 | ||
|
|
68a5f45173 | ||
|
|
486c4a2ff7 | ||
|
|
610f914085 | ||
|
|
dca668a56b | ||
|
|
a785930f21 | ||
|
|
9631a91658 | ||
|
|
a3fc47a9cb | ||
|
|
1fa8cb67a3 | ||
|
|
66395f71b9 | ||
|
|
2c3ed56755 | ||
|
|
ecb41269cd | ||
|
|
b740e400c9 | ||
|
|
09a3637b56 | ||
|
|
7d48ea50b4 | ||
|
|
4807b81dd5 | ||
|
|
ffad253390 | ||
|
|
07b7b5b4f7 | ||
|
|
d31b1afb50 | ||
|
|
2d475743f7 | ||
|
|
e21d4773d2 | ||
|
|
4ded242e66 | ||
|
|
a3b8005175 | ||
|
|
90627ac258 | ||
|
|
ecde6c9fdc | ||
|
|
632aa00a3e |
@@ -0,0 +1 @@
|
|||||||
|
.git
|
||||||
+22
-6
@@ -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 的系统 Chromium(musl 版),而不是下载 chrome-headless-shell。
|
||||||
RUN npx hyperframes browser ensure
|
# 原因:hyperframes 默认会下载 Google 官方 chrome-headless-shell(glibc 构建),
|
||||||
|
# 在 Alpine(musl libc)上无法加载:Error loading shared library
|
||||||
|
# ld-linux-x86-64.so.2: No such file or directory + symbol not found,
|
||||||
|
# 上层 puppeteer 表现为 spawn ENOENT,渲染直接失败。
|
||||||
|
# HYPERFRAMES_BROWSER_PATH 在 hyperframes 浏览器解析中优先级最高(env > 缓存 > 系统)。
|
||||||
|
# 注意:系统 Chromium 捕获会回退到 screenshot 模式(比 beginFrame 略慢,但功能正常)。
|
||||||
|
# hyperframes 升级若改变浏览器解析逻辑,需同步检查此配置。
|
||||||
|
ENV HYPERFRAMES_BROWSER_PATH=/usr/bin/chromium-browser
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 必须用系统 chromium:Alpine 是 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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 创建字幕任务 ----------
|
// ---------- 创建字幕任务 ----------
|
||||||
|
|||||||
@@ -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)"`
|
||||||
|
}
|
||||||
@@ -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:"错误信息"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 临时 profile(puppeteer_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: /tmp;Windows: 系统临时目录
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
距画布左边 210px(image_1 在左边缘宽 200px,210 正好给它右边留 10px)
|
||||||
|
→ 文字整体往左/往右移,改这个数字
|
||||||
|
|
||||||
|
font-size: 34px
|
||||||
|
字号
|
||||||
|
→ 改大字变大,改小字变小
|
||||||
|
|
||||||
|
color: #FFFFFF
|
||||||
|
文字颜色(白色)
|
||||||
|
|
||||||
|
text-align: left
|
||||||
|
文字水平对齐方式
|
||||||
|
→ left = 靠左(本元素默认) center = 居中 right = 靠右
|
||||||
|
|
||||||
|
提示:文字内容里可以用 \n 换行
|
||||||
|
-->
|
||||||
|
<div data-key="product_desc" data-start="0" data-duration="0" data-track-index="3"
|
||||||
|
class="clip text-element"
|
||||||
|
style="
|
||||||
|
left: 210px;
|
||||||
|
top: calc(100% - 180px);
|
||||||
|
width: calc(100% - 320px);
|
||||||
|
font-size: 17px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
text-align: left;
|
||||||
|
justify-content: flex-start;
|
||||||
|
">产品卖点描述</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
======================================================================
|
||||||
|
data-key="product_info" 产品信息(左边竖排显示)
|
||||||
|
|
||||||
|
最终效果:贴左边缘,文字从上往下竖排显示
|
||||||
|
|
||||||
|
★ 可调参数 ★
|
||||||
|
|
||||||
|
writing-mode: vertical-rl
|
||||||
|
竖排显示(文字从上往下写)
|
||||||
|
这是竖排效果的关键,不要删(服务端已支持)
|
||||||
|
|
||||||
|
left: 380px
|
||||||
|
距离左边 380 像素(往中间收)
|
||||||
|
→ 改大数字,文字往右移
|
||||||
|
|
||||||
|
top: 50% + transform: translateY(-50%)
|
||||||
|
垂直居中,让竖排文字位于左边中间位置
|
||||||
|
|
||||||
|
font-size: 28px
|
||||||
|
字号(和 disclaimer 一致)
|
||||||
|
|
||||||
|
color: #FFFFFF
|
||||||
|
文字颜色(白色)
|
||||||
|
|
||||||
|
-webkit-text-stroke: 1px #000000
|
||||||
|
文字描边(黑色描边一圈)
|
||||||
|
→ 描边别太粗:中文字笔画细,3px 会把白色盖成黑字,1px 稳妥
|
||||||
|
→ 想更明显改成 2px;改成 0 或删掉这行 = 不要描边
|
||||||
|
-->
|
||||||
|
<div data-key="product_info" data-start="0" data-duration="0" data-track-index="4"
|
||||||
|
class="clip text-element"
|
||||||
|
style="
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
left: 680px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
-webkit-text-stroke: 1px #000000;
|
||||||
|
">产品信息文字</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
======================================================================
|
||||||
|
data-key="disclaimer" 免责声明(竖排显示)
|
||||||
|
|
||||||
|
最终效果:竖排显示,白字黑描边
|
||||||
|
|
||||||
|
★ 可调参数 ★
|
||||||
|
|
||||||
|
writing-mode: vertical-rl
|
||||||
|
竖排显示(文字从上往下写)
|
||||||
|
这是竖排效果的关键,不要删(服务端已支持)
|
||||||
|
|
||||||
|
left: calc(100% - 730px)
|
||||||
|
距画布左边 730 像素(竖排文字宽约 30px,当前位于画面偏左)
|
||||||
|
→ 改大 730 这个数字,整段往左移;改小 → 往右移
|
||||||
|
→ 注意:这里不能写 right: Npx!偏移量会被解析器忽略,
|
||||||
|
等价于 right:0(直接贴右边)。
|
||||||
|
|
||||||
|
top: 50% + transform: translateY(-50%)
|
||||||
|
垂直居中,让竖排文字位于中间位置
|
||||||
|
|
||||||
|
font-size: 28px
|
||||||
|
字号(和 product_info 一致)
|
||||||
|
|
||||||
|
color: #FFFFFF
|
||||||
|
文字颜色(白色)
|
||||||
|
|
||||||
|
-webkit-text-stroke: 1px #000000
|
||||||
|
文字描边(黑色描边一圈)
|
||||||
|
→ 描边别太粗:中文字笔画细,3px 会把白色盖成黑字,1px 稳妥
|
||||||
|
→ 想更明显改成 2px;改成 0 或删掉这行 = 不要描边
|
||||||
|
-->
|
||||||
|
<div data-key="disclaimer" data-start="0" data-duration="0" data-track-index="5"
|
||||||
|
class="clip text-element"
|
||||||
|
style="
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
left: calc(100% - 730px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
-webkit-text-stroke: 1px #000000;
|
||||||
|
">
|
||||||
|
免责声明文字
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
======================================================================
|
||||||
|
以下脚本不要改,这是程序内部使用的 GSAP 时间轴初始化
|
||||||
|
======================================================================
|
||||||
|
-->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to('#stage', { duration: 0, opacity: 1 }, 0);
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
window.__timelines['main'] = tl;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user