commit 219b7e39c7543edf89bbe56c889a456d790dd788 Author: lmk <1095689763@qq.com> Date: Tue May 19 14:33:06 2026 +0800 代码初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ae019a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea/* +/resource/temp/* +/resource/log/server/* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..f6906f2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d8bff7b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# 阶段1: 构建 +FROM golang:1.26-alpine AS builder + +RUN 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 +ENV GOPRIVATE=gitea.com/red-future/common + +# 配置git使用私有Gitea仓库 +RUN git config --global url."http://x-token-auth:9b31146aa8c10a7cb4f2e49dcee0934a223be1076289810e1ad98b968066c2bc@116.204.74.41:3000/red-future/common.git".insteadOf "https://gitea.com/red-future/common.git" && \ + git config --global credential.helper store + +WORKDIR /build + +COPY . . + +RUN go mod download && go mod tidy + +RUN go build -ldflags="-s -w" -o main ./main.go + +# 阶段2: 运行 +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app + +COPY --from=builder /build/main . +COPY --from=builder /build/config.yml ./ + +RUN mkdir -p /app/resource/log/run \ + /app/resource/log/server \ + && adduser -D -u 1000 appuser \ + && chown -R appuser:appuser /app + +USER appuser + +EXPOSE 3001 + +CMD ["./main"] diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..3baac4b --- /dev/null +++ b/config.yml @@ -0,0 +1,64 @@ +server: + address : ":3001" + name: "media" + workerId: 1 + logPath: "resource/log/server" + logStdout: true + errorStack: true + # 开启请求访问日志 + accessLogEnabled: true + # 上传文件大小限制(视频文件通常较大) + clientMaxBodySize: "200MB" +rate: + limit: 200 + burst: 300 + +# 数据库配置(PostgreSQL) +database: + default: + type: "pgsql" + host: "116.204.74.41" + port: "15432" + user: "postgres" + pass: "Bjang09@686^*^" + name: "media" + role: "master" + maxIdle: "5" + maxOpen: "20" + maxLifetime: "60s" + charset: "utf8mb4" + debug: true + dryRun: false + createdAt: "created_at" + updatedAt: "updated_at" + deletedAt: "deleted_at" + timeMaintainDisabled: false + +consul: + address: 192.168.3.30:8500 + +jaeger: + addr: 116.204.74.41:4318 + +# FFmpeg 配置(视频音频提取) +ffmpeg: + # ffmpeg 可执行文件路径,留空则从 PATH 查找 + path: "" + # 临时文件目录(上传的视频和提取的音频) + temp_dir: "resource/temp" + +# Whisper 语音识别配置 +whisper: + # whisper 可执行文件路径,留空则自动查找 + # 优先检测: whisper-cpp(推荐) > whisper > python -m whisper + # 安装 whisper.cpp: brew install whisper-cpp(速度比 Python 快 3-5 倍) + path: "" + # 默认模型: tiny(75MB/最快) / base(150MB) / small(500MB) / medium(1.5GB) + # CPU 环境建议用 tiny,MacBook Air 用 base 即可 + model: "medium" + # 默认语言(zh=中文, en=英文, ja=日文 等) + language: "zh" + # 模型缓存目录,留空使用默认 (~/.cache/whisper/) + model_dir: "" + # CPU 线程数(限制资源占用,建议 2-4) + threads: 2 diff --git a/controller/audio/audio_extract_controller.go b/controller/audio/audio_extract_controller.go new file mode 100644 index 0000000..4aa9769 --- /dev/null +++ b/controller/audio/audio_extract_controller.go @@ -0,0 +1,106 @@ +package audio + +import ( + "context" + "encoding/json" + + common "media/controller/common" + dto "media/model/dto/audio" + service "media/service/asr" + + "gitea.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +type audio struct{} + +var AudioExtract = new(audio) + +// safeResult 对外输出的识别结果(隐藏内部路径) +type safeResult struct { + Text string `json:"text"` + Model string `json:"model"` + Language string `json:"language"` + AudioSize int64 `json:"audioSize"` + AudioDuration string `json:"audioDuration"` + Scenes *dto.SceneSummaryDTO `json:"scenes,omitempty"` +} + +// safeItem 对外输出的单视频结果 +type safeItem struct { + FileName string `json:"fileName"` + Result *safeResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// TranscribeHandler 语音转文字+分镜分析 +// 支持两种入参方式: +// 1. JSON body: {"video_urls":[...], "model":"medium", "language":"zh", "threshold":0.3} +// 2. 文件上传: files 参数(兼容单/多文件) +func (c *audio) TranscribeHandler(r *ghttp.Request) { + ctx := r.Context() + ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"}) + + // 优先尝试 JSON body(URL 列表模式) + body := r.GetBody() + if len(body) > 0 && body[0] == '{' { + var req dto.TranscribeReq + if json.Unmarshal(body, &req) == nil && len(req.VideoURLs) > 0 { + // 填充默认值 + if req.Model == "" { + req.Model = g.Cfg().MustGet(ctx, "whisper.model", "medium").String() + } + if req.Language == "" { + req.Language = g.Cfg().MustGet(ctx, "whisper.language", "zh").String() + } + if req.Threshold <= 0 { + req.Threshold = 0.3 + } + + res, svcErr := service.VideoTranscribe.TranscribeWithURLs(ctx, &req) + if svcErr != nil { + r.Response.WriteJson(g.Map{"code": 500, "message": svcErr.Error()}) + return + } + r.Response.WriteJson(g.Map{"code": 200, "message": "success", "data": toSafeItems(res.Results)}) + return + } + } + + // 文件上传模式 + savePaths, err := common.SaveUploadedFiles(r) + if err != nil || len(savePaths) == 0 { + r.Response.WriteJson(g.Map{"code": 400, "message": "请上传视频文件( multipart )或提供 video_urls( JSON )"}) + return + } + + results := service.VideoTranscribe.TranscribeUpload(ctx, savePaths, + r.Get("model", g.Cfg().MustGet(ctx, "whisper.model", "medium").String()).String(), + r.Get("language", g.Cfg().MustGet(ctx, "whisper.language", "zh").String()).String(), + r.Get("threshold", 0.3).Float64()) + + r.Response.WriteJson(g.Map{"code": 200, "message": "success", "data": toSafeItems(results)}) +} + +// toSafeItems 将结果转为安全的响应格式(移除 audioPath 等内部路径) +func toSafeItems(results []dto.TranscribeItem) []safeItem { + var items []safeItem + for _, item := range results { + si := safeItem{FileName: item.FileName, Error: item.Error} + if item.Result != nil { + if r, ok := item.Result.(*dto.TranscribeResult); ok { + si.Result = &safeResult{ + Text: r.Text, + Model: r.Model, + Language: r.Language, + AudioSize: r.AudioSize, + AudioDuration: r.AudioDuration, + Scenes: r.Scenes, + } + } + } + items = append(items, si) + } + return items +} diff --git a/controller/common/upload.go b/controller/common/upload.go new file mode 100644 index 0000000..4442c97 --- /dev/null +++ b/controller/common/upload.go @@ -0,0 +1,68 @@ +package common + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +var allowedExts = map[string]bool{ + ".mp4": true, ".avi": true, ".mov": true, ".mkv": true, + ".flv": true, ".wmv": true, ".webm": true, ".m4v": true, + ".ts": true, ".mpeg": true, ".mpg": true, +} + +// SaveUploadedFiles 保存上传的视频文件,返回本地路径列表 +func SaveUploadedFiles(r *ghttp.Request) ([]string, error) { + ctx := r.Context() + tempDir := getTempDir(ctx) + os.MkdirAll(tempDir, 0755) + + files := r.GetUploadFiles("files") + if len(files) == 0 { + if f := r.GetUploadFile("file"); f != nil { + files = append(files, f) + } + } + + var saved []string + for _, f := range files { + ext := filepath.Ext(f.Filename) + if !allowedExts[ext] { + continue + } + savePath := filepath.Join(tempDir, fmt.Sprintf("%d_%s", time.Now().UnixMilli(), f.Filename)) + src, err := f.Open() + if err != nil { + continue + } + dst, err := os.Create(savePath) + if err != nil { + src.Close() + continue + } + io.Copy(dst, src) + src.Close() + dst.Close() + saved = append(saved, savePath) + } + return saved, nil +} + +func getTempDir(ctx context.Context) string { + 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 + } + return tempDir +} diff --git a/controller/video/concat_controller.go b/controller/video/concat_controller.go new file mode 100644 index 0000000..a58bdc5 --- /dev/null +++ b/controller/video/concat_controller.go @@ -0,0 +1,156 @@ +package video + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + common "media/controller/common" + dto "media/model/dto/video" + service "media/service/video" + + "gitea.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +type video struct{} + +var Concat = new(video) + +// ConcatVideosHandler 视频拼接 +// 支持两种入参方式: +// 1. JSON body: {"video_urls":[...], "method":"auto"} +// 2. 文件上传: files 参数(至少2个视频) +func (c *video) ConcatVideosHandler(r *ghttp.Request) { + ctx := r.Context() + ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin"}) + + // 优先尝试 JSON body(URL 列表模式) + body := r.GetBody() + if len(body) > 0 && body[0] == '{' { + var req dto.ConcatReq + if json.Unmarshal(body, &req) == nil && len(req.VideoURLs) >= 2 { + if req.Method == "" { + req.Method = "auto" + } + + tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String() + if !filepath.IsAbs(tempDir) { + absDir, _ := filepath.Abs(tempDir) + tempDir = absDir + } + os.MkdirAll(tempDir, 0755) + + var savePaths []string + for _, videoURL := range req.VideoURLs { + savePath, dlErr := downloadFromURL(ctx, videoURL, tempDir) + if dlErr != nil { + continue + } + savePaths = append(savePaths, savePath) + } + if len(savePaths) < 2 { + cleanupConcat(savePaths) + r.Response.WriteJson(g.Map{"code": 400, "message": "成功下载的视频不足2个"}) + return + } + + svcRes, svcErr := service.Concat.Concat(ctx, &service.ConcatReq{ + VideoPaths: savePaths, + Method: req.Method, + }) + cleanupConcat(savePaths) + if svcErr != nil { + r.Response.WriteJson(g.Map{"code": 500, "message": "视频拼接失败: " + svcErr.Error()}) + return + } + + r.Response.WriteJson(g.Map{ + "code": 200, + "message": "success", + "data": g.Map{ + "outputPath": svcRes.OutputPath, + "fileSize": svcRes.FileSize, + "duration": svcRes.Duration, + "durationStr": svcRes.DurationStr, + "methodUsed": svcRes.MethodUsed, + "inputFiles": svcRes.InputFiles, + }, + }) + return + } + } + + // 文件上传模式 + savePaths, err := common.SaveUploadedFiles(r) + if err != nil || len(savePaths) < 2 { + r.Response.WriteJson(g.Map{"code": 400, "message": fmt.Sprintf("至少需要2个视频,当前%d个", len(savePaths))}) + return + } + + svcRes, svcErr := service.Concat.Concat(ctx, &service.ConcatReq{ + VideoPaths: savePaths, + Method: r.Get("method", "auto").String(), + }) + service.CleanupConcat(savePaths) + if svcErr != nil { + r.Response.WriteJson(g.Map{"code": 500, "message": "视频拼接失败: " + svcErr.Error()}) + return + } + + r.Response.ServeFile(svcRes.OutputPath) + go func(path string) { + time.Sleep(5 * time.Second) + os.Remove(path) + }(svcRes.OutputPath) +} + +func downloadFromURL(ctx context.Context, rawURL, tempDir string) (string, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", err + } + segments := strings.Split(parsedURL.Path, "/") + fileName := segments[len(segments)-1] + if fileName == "" { + fileName = fmt.Sprintf("video_%d.mp4", time.Now().UnixMilli()) + } + savePath := filepath.Join(tempDir, fmt.Sprintf("%d_%s", time.Now().UnixMilli(), fileName)) + + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Get(rawURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + out, err := os.Create(savePath) + if err != nil { + return "", err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + os.Remove(savePath) + } + return savePath, err +} + +func cleanupConcat(paths []string) { + for _, p := range paths { + os.Remove(p) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bc41e4d --- /dev/null +++ b/go.mod @@ -0,0 +1,94 @@ +module media + +go 1.26.0 + +require ( + gitea.com/red-future/common v0.0.19 + github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 + github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5 + github.com/gogf/gf/v2 v2.10.0 + github.com/yidun/yidun-golang-sdk v1.0.38 +) + +//replace gitea.com/red-future/common => ../common + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/armon/go-metrics v0.4.1 // indirect + github.com/bwmarrin/snowflake v0.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/dgraph-io/badger/v4 v4.2.0 // 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/emirpasic/gods/v2 v2.0.0-alpha // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-ego/gse v1.0.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // 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/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang/glog v1.2.5 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/flatbuffers v1.12.1 // indirect + github.com/google/uuid v1.6.0 // 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/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/hashicorp/consul/api v1.26.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/serf v0.10.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.1.0 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/r3labs/diff/v2 v2.15.1 // indirect + github.com/redis/go-redis/v9 v9.12.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/tiger1103/gfast-token v1.0.10 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/vcaesar/cedar v0.30.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + go.mongodb.org/mongo-driver/v2 v2.4.0 // indirect + go.opencensus.io v0.23.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel 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/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // 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/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f5cdd53 --- /dev/null +++ b/go.sum @@ -0,0 +1,472 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +gitea.com/red-future/common v0.0.19 h1:9/WrfCFUCeFUYwuhBYF+JOQi5F5xuOy+gVnf2ZvHZu4= +gitea.com/red-future/common v0.0.19/go.mod h1:6/nqIucVzmjOyqDTIq71feYBXXFNBy0rFwzaQ0/Ueoo= +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.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +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-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= +github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +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/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.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +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.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +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-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 h1:39+jbTenm7KBj4hO2C8ANAxVHpX/7OuRDs1VcGC9ylA= +github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0/go.mod h1:B0s0fVzn0W220E8UTpSGzrrGKsop5KcB90twBeLCiz0= +github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5 h1:Ku7p3CvGchxC7zPSgArf/tZs2w9Yb8tS/gH5ADN+p9g= +github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5/go.mod h1:cjy18NsSLZQf5zaLAzuo7B2gr8GGjCTWDTEPY7T+6FI= +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/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.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs= +github.com/gogf/gf/v2 v2.10.0/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.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +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 v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +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.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +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/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.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM= +github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A= +github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU= +github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo= +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/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/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/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.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +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/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +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/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.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +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.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +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.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +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.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/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.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.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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +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/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +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-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 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/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= +github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +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/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +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/go.mod h1:I8noH9Fc2fjSaMxqF3G2lhDdC0b+JXCfyx85tWFM9kc= +github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= +github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +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/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/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +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.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.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.6.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +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/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/vcaesar/cedar v0.30.0 h1:9fSDpM7FTjjUdPiBUUa0MWYMRGSEcqgFXvppZcZ4d7Y= +github.com/vcaesar/cedar v0.30.0/go.mod h1:lyuGvALuZZDPNXwpzv/9LyxW+8Y6faN7zauFezNsnik= +github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4= +github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/yidun/yidun-golang-sdk v1.0.38 h1:4NjQdt2GGMgLToB2+zTA0L4YRpqY3ZQjVpl2ot1gwfk= +github.com/yidun/yidun-golang-sdk v1.0.38/go.mod h1:+JGdWbkUvLi9uKTtHI+nrxajulfZKA7BXDPlzt1RCsU= +github.com/yuin/goldmark v1.1.27/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= +go.mongodb.org/mongo-driver/v2 v2.4.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI= +go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +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/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/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +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-20201012173705-84dcc777aaee/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-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +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-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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +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-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/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-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/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-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.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/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-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +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/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.3/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.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/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-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +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-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-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +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.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +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.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +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-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.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.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..64f9813 --- /dev/null +++ b/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + controllerAudio "media/controller/audio" + controllerVideo "media/controller/video" + serviceSetup "media/service/setup" + "os" + "path/filepath" + "time" + + _ "gitea.com/red-future/common/consul" + "gitea.com/red-future/common/http" + "gitea.com/red-future/common/jaeger" + "github.com/gogf/gf/v2/frame/g" +) + +func main() { + ctx := context.Background() + defer jaeger.ShutDown(ctx) + + loc, err := time.LoadLocation("Asia/Shanghai") + if err == nil { + time.Local = loc + } + os.Setenv("TZ", "Asia/Shanghai") + + serviceSetup.EnsureDependencies(ctx) + + // 清理旧 temp 文件(防止异常中断残留) + cleanupTempDir(ctx) + + // 文件上传路由(在 RouteRegister 启动服务器之前注册) + http.Httpserver.BindHandler("/audio/transcribe", controllerAudio.AudioExtract.TranscribeHandler) + http.Httpserver.BindHandler("/video/concat", controllerVideo.Concat.ConcatVideosHandler) + + // 启动服务器(无需 g.Meta 自动注册) + http.RouteRegister(nil) + + port := g.Cfg().MustGet(ctx, "server.address", ":3001").String() + g.Log().Info(ctx, "============================================") + g.Log().Infof(ctx, "服务启动: http://localhost%s", port) + g.Log().Infof(ctx, " POST %s/audio/transcribe - 语音转文字+分镜分析(文件上传,参数名 files)", port) + g.Log().Infof(ctx, " POST %s/video/concat - 视频拼接(文件上传,参数名 files,至少2个视频)", port) + g.Log().Info(ctx, "============================================") + + select {} +} + +// cleanupTempDir 清理临时文件目录,防止旧运行残留 +func cleanupTempDir(ctx context.Context) { + tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String() + if tempDir == "" { + tempDir = "resource/temp" + } + if !filepath.IsAbs(tempDir) { + absDir, err := filepath.Abs(tempDir) + if err != nil { + return + } + tempDir = absDir + } + + entries, err := os.ReadDir(tempDir) + if err != nil { + return + } + for _, entry := range entries { + fullPath := filepath.Join(tempDir, entry.Name()) + os.RemoveAll(fullPath) + } + g.Log().Infof(ctx, "临时目录已清理: %s", tempDir) +} diff --git a/model/dto/audio/audio_dto.go b/model/dto/audio/audio_dto.go new file mode 100644 index 0000000..c8fbc5f --- /dev/null +++ b/model/dto/audio/audio_dto.go @@ -0,0 +1,55 @@ +package audio + +// TranscribeReq 语音转文字请求(JSON body / URL 方式) +type TranscribeReq struct { + VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表"` + Model string `json:"model" dc:"whisper模型(tiny/base/small/medium)" d:"medium"` + Language string `json:"language" dc:"语言(zh/en/ja)" d:"zh"` + Threshold float64 `json:"threshold" dc:"场景检测阈值(0.1-0.5)" d:"0.3"` +} + +// TranscribeRes 语音转文字响应 +type TranscribeRes struct { + Results []TranscribeItem `json:"results" dc:"处理结果列表"` +} + +// TranscribeItem 单视频处理结果 +type TranscribeItem struct { + FileName string `json:"fileName" dc:"文件名"` + Result interface{} `json:"result,omitempty" dc:"识别结果"` + Error string `json:"error,omitempty" dc:"错误信息"` +} + +// TranscribeResult 语音识别结果详情 +type TranscribeResult struct { + Text string `json:"text" dc:"识别文本"` + Model string `json:"model" dc:"使用的模型"` + Language string `json:"language" dc:"语言"` + AudioPath string `json:"audioPath" dc:"音频文件路径"` + AudioSize int64 `json:"audioSize" dc:"音频文件大小(字节)"` + AudioDuration string `json:"audioDuration" dc:"音频时长"` + Scenes *SceneSummaryDTO `json:"scenes,omitempty" dc:"分镜分析"` +} + +// SceneSummaryDTO 分镜分析摘要 +type SceneSummaryDTO struct { + TotalScenes int `json:"totalScenes" dc:"场景总数"` + DurationStr string `json:"durationStr" dc:"总时长"` + AspectRatio string `json:"aspectRatio" dc:"画面比例"` + Orientation string `json:"orientation" dc:"横屏/竖屏"` + Pacing string `json:"pacing" dc:"剪辑节奏"` + ShotTypes map[string]int `json:"shotTypes" dc:"镜头类型分布"` + Scenes []SceneShotDTO `json:"scenes" dc:"分镜列表"` +} + +// SceneShotDTO 单镜头信息 +type SceneShotDTO struct { + SceneIndex int `json:"sceneIndex" dc:"场景序号"` + StartTimeStr string `json:"startTimeStr" dc:"开始时间"` + EndTimeStr string `json:"endTimeStr" dc:"结束时间"` + DurationStr string `json:"durationStr" dc:"时长"` + ShotType string `json:"shotType" dc:"镜头类型"` + Composition string `json:"composition" dc:"构图"` + NarrativePos string `json:"narrativePos" dc:"叙事位置"` + Description string `json:"description" dc:"场景描述"` +} diff --git a/model/dto/video/video_dto.go b/model/dto/video/video_dto.go new file mode 100644 index 0000000..216ce29 --- /dev/null +++ b/model/dto/video/video_dto.go @@ -0,0 +1,17 @@ +package video + +// ConcatReq 视频拼接请求(JSON body / URL 方式) +type ConcatReq struct { + VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表(按此顺序拼接)"` + Method string `json:"method" dc:"拼接方式(auto/fast/reencode)" d:"auto"` +} + +// ConcatRes 视频拼接响应 +type ConcatRes struct { + OutputPath string `json:"outputPath" dc:"输出文件路径"` + FileSize int64 `json:"fileSize" dc:"文件大小(字节)"` + Duration float64 `json:"duration" dc:"总时长(秒)"` + DurationStr string `json:"durationStr" dc:"可读时长"` + MethodUsed string `json:"methodUsed" dc:"实际使用的拼接方式"` + InputFiles int `json:"inputFiles" dc:"输入文件数"` +} diff --git a/service/asr/transcribe_service.go b/service/asr/transcribe_service.go new file mode 100644 index 0000000..16309c0 --- /dev/null +++ b/service/asr/transcribe_service.go @@ -0,0 +1,232 @@ +package asr + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + dto "media/model/dto/audio" + serviceAudio "media/service/audio" + serviceScene "media/service/scene" + + "github.com/gogf/gf/v2/frame/g" +) + +// VideoTranscribeReq 视频语音识别请求 +type VideoTranscribeReq struct { + VideoPath string + Model string + Language string + KeepAudio bool +} + +// VideoTranscribeRes 视频语音识别响应 +type VideoTranscribeRes struct { + Text string `json:"text"` + Model string `json:"model"` + Language string `json:"language"` + AudioPath string `json:"audioPath"` + AudioSize int64 `json:"audioSize"` + AudioDuration string `json:"audioDuration"` +} + +type transcribeService struct{} + +var VideoTranscribe = new(transcribeService) + +// TranscribeWithURLs 从 URL 下载视频并转录 +func (s *transcribeService) TranscribeWithURLs(ctx context.Context, req *dto.TranscribeReq) (res *dto.TranscribeRes, err error) { + if len(req.VideoURLs) == 0 { + return nil, errors.New("video_urls 不能为空") + } + + tempDir := getTempDir(ctx) + os.MkdirAll(tempDir, 0755) + + var savePaths []string + for _, videoURL := range req.VideoURLs { + savePath, dlErr := downloadFromURL(ctx, videoURL, tempDir) + if dlErr != nil { + continue + } + savePaths = append(savePaths, savePath) + } + if len(savePaths) == 0 { + return nil, errors.New("所有视频下载均失败") + } + + results := s.processVideos(ctx, savePaths, req.Model, req.Language, req.Threshold) + res = &dto.TranscribeRes{Results: results} + return +} + +// TranscribeUpload 从已保存的文件转录 +func (s *transcribeService) TranscribeUpload(ctx context.Context, savePaths []string, model, language string, threshold float64) []dto.TranscribeItem { + return s.processVideos(ctx, savePaths, model, language, threshold) +} + +// processVideos 逐个处理视频 +func (s *transcribeService) processVideos(ctx context.Context, savePaths []string, model, language string, threshold float64) []dto.TranscribeItem { + var results []dto.TranscribeItem + + for _, savePath := range savePaths { + fileName := filepath.Base(savePath) + if idx := strings.Index(fileName, "_"); idx > 0 { + fileName = fileName[idx+1:] + } + + // 场景分析 + var scenes *dto.SceneSummaryDTO + sceneRes, sceneErr := serviceScene.SceneAnalyzer.Analyze(ctx, &serviceScene.SceneAnalyzeReq{ + VideoPaths: []string{savePath}, + Threshold: threshold, + ExtractKeyframes: false, + }) + if sceneErr == nil && len(sceneRes.Analyses) > 0 { + scenes = toSceneDTO(&sceneRes.Analyses[0]) + } + + // 语音转文字(内部删除视频文件) + transRes, transErr := s.TranscribeVideo(ctx, &VideoTranscribeReq{ + VideoPath: savePath, + Model: model, + Language: language, + }) + if transErr != nil { + os.Remove(savePath) + results = append(results, dto.TranscribeItem{FileName: fileName, Error: transErr.Error()}) + continue + } + + results = append(results, dto.TranscribeItem{ + FileName: fileName, + Result: &dto.TranscribeResult{ + Text: transRes.Text, + Model: transRes.Model, + Language: transRes.Language, + AudioPath: transRes.AudioPath, + AudioSize: transRes.AudioSize, + AudioDuration: transRes.AudioDuration, + Scenes: scenes, + }, + }) + } + return results +} + +// TranscribeVideo 从视频提取音频并转为文字 +func (s *transcribeService) TranscribeVideo(ctx context.Context, req *VideoTranscribeReq) (res *VideoTranscribeRes, err error) { + audioReq := &serviceAudio.ExtractAudioReq{VideoPath: req.VideoPath, Format: "mp3"} + audioRes, err := serviceAudio.AudioExtract.Extract(ctx, audioReq) + if err != nil { + return nil, fmt.Errorf("音频提取失败: %v", err) + } + + whisperRes, err := Whisper.Transcribe(ctx, &TranscribeReq{AudioPath: audioRes.AudioPath, Model: req.Model, Language: req.Language}) + if err != nil { + os.Remove(audioRes.AudioPath) + return nil, fmt.Errorf("语音识别失败: %v", err) + } + + os.Remove(req.VideoPath) + if !req.KeepAudio { + os.Remove(audioRes.AudioPath) + baseName := strings.TrimSuffix(audioRes.AudioPath, filepath.Ext(audioRes.AudioPath)) + os.Remove(baseName + ".txt") + os.Remove(baseName + "." + whisperRes.Model + ".txt") + } + + res = &VideoTranscribeRes{ + Text: whisperRes.Text, + Model: whisperRes.Model, + Language: whisperRes.Language, + AudioPath: audioRes.AudioPath, + AudioSize: audioRes.Size, + AudioDuration: audioRes.Duration, + } + return +} + +func downloadFromURL(ctx context.Context, rawURL, tempDir string) (string, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", err + } + segments := strings.Split(parsedURL.Path, "/") + fileName := segments[len(segments)-1] + if fileName == "" { + fileName = fmt.Sprintf("video_%d.mp4", time.Now().UnixMilli()) + } + savePath := filepath.Join(tempDir, fmt.Sprintf("%d_%s", time.Now().UnixMilli(), fileName)) + + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Get(rawURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + out, err := os.Create(savePath) + if err != nil { + return "", err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + os.Remove(savePath) + } + return savePath, err +} + +func getTempDir(ctx context.Context) string { + 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 + } + return tempDir +} + +// toSceneDTO 将场景服务的原始结果转为 DTO 格式 +func toSceneDTO(analysis *serviceScene.VideoSceneAnalysis) *dto.SceneSummaryDTO { + if analysis == nil { + return nil + } + shots := make([]dto.SceneShotDTO, 0, len(analysis.Scenes)) + for _, s := range analysis.Scenes { + shots = append(shots, dto.SceneShotDTO{ + SceneIndex: s.SceneIndex, + StartTimeStr: s.StartTimeStr, + EndTimeStr: s.EndTimeStr, + DurationStr: s.DurationStr, + ShotType: s.ShotType, + Composition: s.Composition, + NarrativePos: s.NarrativePos, + Description: s.Description, + }) + } + return &dto.SceneSummaryDTO{ + TotalScenes: analysis.TotalScenes, + DurationStr: analysis.DurationStr, + AspectRatio: analysis.AspectRatio, + Orientation: analysis.Orientation, + Pacing: analysis.Summary.Pacing, + ShotTypes: analysis.Summary.ShotTypeDist, + Scenes: shots, + } +} diff --git a/service/asr/whisper_service.go b/service/asr/whisper_service.go new file mode 100644 index 0000000..2ae664b --- /dev/null +++ b/service/asr/whisper_service.go @@ -0,0 +1,391 @@ +package asr + +import ( + "context" + "fmt" + "io" + "media/service/setup" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +// WhisperBackend 后端类型 +type WhisperBackend int + +const ( + backendPython WhisperBackend = iota // python -m whisper + backendCLI // openai-whisper CLI (whisper 命令) + backendCpp // whisper.cpp (whisper-cpp) +) + +// WhisperService 语音识别服务 +type WhisperService struct{} + +// Whisper 语音识别服务单例 +var Whisper = new(WhisperService) + +// TranscribeReq 语音识别请求 +type TranscribeReq struct { + AudioPath string // 音频文件路径 + Model string // whisper 模型: tiny/base/small/medium/large + Language string // 语言代码,默认 zh(中文) +} + +// TranscribeRes 语音识别响应 +type TranscribeRes struct { + Text string // 完整识别文本 + Segments []Segment + Model string // 使用的模型 + Language string // 识别的语言 + OutputPath string // 输出的 txt 文件路径 +} + +// Segment 识别片段(带时间戳) +type Segment struct { + Start float64 `json:"start"` // 开始时间(秒) + End float64 `json:"end"` // 结束时间(秒) + Text string `json:"text"` // 文本内容 +} + +// Transcribe 对音频文件进行语音识别(自动检测后端,自动降级) +func (s *WhisperService) Transcribe(ctx context.Context, req *TranscribeReq) (res *TranscribeRes, err error) { + // 1. 校验音频文件 + if _, err = os.Stat(req.AudioPath); os.IsNotExist(err) { + return nil, fmt.Errorf("音频文件不存在: %s", req.AudioPath) + } + + // 2. 设置默认值 + model := req.Model + if model == "" { + model = g.Cfg().MustGet(ctx, "whisper.model", "small").String() + } + language := req.Language + if language == "" { + language = g.Cfg().MustGet(ctx, "whisper.language", "zh").String() + } + + // 3. 检测后端,C++ 版找不到模型文件时自动降级 + backend, whisperPath := s.detectBackend() + if backend == backendCpp { + modelPath := s.resolveCppModelPath(model) + if modelPath == "" { + g.Log().Warningf(ctx, "whisper.cpp 模型文件(%s)未找到,降级到 Python whisper", model) + backend = backendPython + } else { + g.Log().Infof(ctx, "语音识别(whisper.cpp): audio=%s, model=%s", req.AudioPath, modelPath) + return s.transcribeWithCpp(ctx, req, whisperPath, modelPath, language) + } + } + + switch backend { + case backendCLI: + g.Log().Infof(ctx, "语音识别(CLI): audio=%s, model=%s, language=%s", req.AudioPath, model, language) + return s.transcribeWithCLI(ctx, req, whisperPath, model, language) + default: + g.Log().Infof(ctx, "语音识别(python): audio=%s, model=%s, language=%s", req.AudioPath, model, language) + return s.transcribeWithPython(ctx, req, model, language) + } +} + +// transcribeWithCLI 使用 whisper CLI 命令 +func (s *WhisperService) transcribeWithCLI(ctx context.Context, req *TranscribeReq, whisperPath, model, language string) (res *TranscribeRes, err error) { + outputDir := filepath.Dir(req.AudioPath) + modelDir := g.Cfg().MustGet(ctx, "whisper.model_dir", "").String() + threads := g.Cfg().MustGet(ctx, "whisper.threads", 2).Int() + + args := []string{ + req.AudioPath, + "--model", model, + "--language", language, + "--output_dir", outputDir, + "--output_format", "txt", + "--threads", fmt.Sprintf("%d", threads), + } + if modelDir != "" { + args = append(args, "--model_dir", modelDir) + } + + cmd := exec.CommandContext(ctx, whisperPath, args...) + output, execErr := cmd.CombinedOutput() + if execErr != nil { + g.Log().Errorf(ctx, "whisper CLI 执行失败: %v\n%s", execErr, string(output)) + return nil, fmt.Errorf("语音识别失败: %v", execErr) + } + + return s.readTxtResult(outputDir, req.AudioPath, model) +} + +// transcribeWithPython 使用 python -m whisper +func (s *WhisperService) transcribeWithPython(ctx context.Context, req *TranscribeReq, model, language string) (res *TranscribeRes, err error) { + // 查找 python + pythonPath, err := exec.LookPath("python3") + if err != nil { + pythonPath, err = exec.LookPath("python") + if err != nil { + return nil, fmt.Errorf("未找到 python,请安装: pip3 install openai-whisper") + } + } + + outputDir := filepath.Dir(req.AudioPath) + modelDir := g.Cfg().MustGet(ctx, "whisper.model_dir", "").String() + threads := g.Cfg().MustGet(ctx, "whisper.threads", 2).Int() + + args := []string{ + "-m", "whisper", + req.AudioPath, + "--model", model, + "--language", language, + "--output_dir", outputDir, + "--output_format", "txt", + "--threads", fmt.Sprintf("%d", threads), + } + if modelDir != "" { + args = append(args, "--model_dir", modelDir) + } + + cmd := exec.CommandContext(ctx, pythonPath, args...) + output, execErr := cmd.CombinedOutput() + if execErr != nil { + g.Log().Errorf(ctx, "whisper(python) 执行失败: %v\n%s", execErr, string(output)) + return nil, fmt.Errorf("语音识别失败: %v", execErr) + } + + return s.readTxtResult(outputDir, req.AudioPath, model) +} + +// readTxtResult 读取 whisper 输出的 txt 文件 +func (s *WhisperService) readTxtResult(outputDir, audioPath, model string) (res *TranscribeRes, err error) { + baseName := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath)) + txtPaths := []string{ + filepath.Join(outputDir, baseName+".txt"), + filepath.Join(outputDir, baseName+"."+model+".txt"), + } + + var textBytes []byte + var txtPath string + for _, p := range txtPaths { + if b, e := os.ReadFile(p); e == nil { + textBytes = b + txtPath = p + break + } + } + if textBytes == nil { + return nil, fmt.Errorf("读取识别结果文件失败") + } + + res = &TranscribeRes{ + Text: cleanTranscript(string(textBytes)), + Model: model, + OutputPath: txtPath, + } + return +} + +// cleanTranscript 清理识别结果:去换行、合并空格 +func cleanTranscript(text string) string { + text = strings.ReplaceAll(text, "\r\n", " ") + text = strings.ReplaceAll(text, "\n", " ") + text = strings.ReplaceAll(text, "\r", " ") + // 合并多个空格 + for strings.Contains(text, " ") { + text = strings.ReplaceAll(text, " ", " ") + } + return strings.TrimSpace(text) +} + +// detectBackend 检测可用的 whisper 后端,返回后端类型和可执行路径 +func (s *WhisperService) detectBackend() (WhisperBackend, string) { + // 1. 优先检测 C++ 版 whisper.cpp(最快,但参数格式不同) + for _, name := range []string{"whisper-cpp", "whisper-cli"} { + if path, err := exec.LookPath(name); err == nil { + return backendCpp, path + } + } + + // 2. 检查 setup 检测到的 C++ 路径 + if setup.DetectedWhisperPath != "" { + base := filepath.Base(setup.DetectedWhisperPath) + if base == "whisper-cpp" || base == "whisper-cli" { + if _, err := os.Stat(setup.DetectedWhisperPath); err == nil { + return backendCpp, setup.DetectedWhisperPath + } + } + } + + // 3. 检测 Python CLI(whisper 命令) + if path, err := exec.LookPath("whisper"); err == nil { + return backendCLI, path + } + + // 4. 检查 setup 检测到的 Python CLI 路径 + if setup.DetectedWhisperPath != "" { + if _, err := os.Stat(setup.DetectedWhisperPath); err == nil { + return backendCLI, setup.DetectedWhisperPath + } + } + + // 5. 检查配置中的路径 + if p := g.Cfg().MustGet(context.Background(), "whisper.path", "").String(); p != "" { + if _, err := os.Stat(p); err == nil { + return backendCLI, p + } + } + + return backendPython, "" +} + +// resolveCppModelPath 查找或下载 whisper.cpp 模型文件 +func (s *WhisperService) resolveCppModelPath(model string) string { + modelName := strings.TrimPrefix(model, "ggml-") + modelName = strings.TrimSuffix(modelName, ".bin") + + cppModelName := "ggml-" + modelName + ".bin" + home, _ := os.UserHomeDir() + + // 目标路径:~/.cache/whisper/ggml-{model}.bin + targetDir := filepath.Join(home, ".cache", "whisper") + targetPath := filepath.Join(targetDir, cppModelName) + + // 1. 如果已存在,直接返回 + if _, err := os.Stat(targetPath); err == nil { + return targetPath + } + + // 2. 检查其他常见位置 + altPaths := []string{ + cppModelName, + filepath.Join(home, ".cache", "whisper", "ggml-"+modelName+"-q5_0.bin"), + "/opt/homebrew/share/whisper-cpp/models/" + cppModelName, + "/usr/local/share/whisper-cpp/models/" + cppModelName, + } + for _, p := range altPaths { + if _, err := os.Stat(p); err == nil { + return p + } + } + + // 3. 自动下载 + modelSize := map[string]string{ + "tiny": "75MB", + "base": "150MB", + "small": "500MB", + "medium": "1.5GB", + } + size, _ := modelSize[modelName] + + // 下载源:先试 hf-mirror(国内可访问),失败再试官方 + modelPath := fmt.Sprintf("ggerganov/whisper.cpp/resolve/main/%s", cppModelName) + urls := []string{ + fmt.Sprintf("https://hf-mirror.com/%s", modelPath), + fmt.Sprintf("https://huggingface.co/%s", modelPath), + } + + g.Log().Infof(context.TODO(), "[whisper.cpp] 正在下载模型 %s (%s)...", cppModelName, size) + + // 创建目录 + os.MkdirAll(targetDir, 0755) + + // 下载文件(多个源,依次尝试) + var lastErr error + for _, url := range urls { + g.Log().Infof(context.TODO(), "[whisper.cpp] 下载地址: %s", url) + if err := s.downloadFile(url, targetPath, 5*time.Minute); err == nil { + g.Log().Infof(context.TODO(), "[whisper.cpp] 模型下载完成: %s", targetPath) + return targetPath + } else { + lastErr = err + g.Log().Warningf(context.TODO(), "[whisper.cpp] 从 %s 下载失败: %v,尝试下一个源...", url, err) + } + } + + g.Log().Errorf(context.TODO(), "[whisper.cpp] 所有下载源均失败: %v", lastErr) + return "" +} + +// downloadFile 下载文件到指定路径(支持超时) +func (s *WhisperService) downloadFile(url, destPath string, timeout time.Duration) error { + tmpPath := destPath + ".tmp" + out, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("创建临时文件失败: %v", err) + } + defer out.Close() + + client := &http.Client{Timeout: timeout} + resp, err := client.Get(url) + if err != nil { + os.Remove(tmpPath) + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + os.Remove(tmpPath) + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + written, err := io.Copy(out, resp.Body) + if err != nil { + os.Remove(tmpPath) + return err + } + + if err := os.Rename(tmpPath, destPath); err != nil { + return fmt.Errorf("文件重命名失败: %v", err) + } + + g.Log().Infof(context.TODO(), "[whisper.cpp] 下载完成: %d bytes", written) + return nil +} + +// transcribeWithCpp 使用 whisper.cpp(C++ 版,参数格式不同) +func (s *WhisperService) transcribeWithCpp(ctx context.Context, req *TranscribeReq, binaryPath, model, language string) (res *TranscribeRes, err error) { + outputDir := filepath.Dir(req.AudioPath) + baseName := strings.TrimSuffix(filepath.Base(req.AudioPath), filepath.Ext(req.AudioPath)) + outputPrefix := filepath.Join(outputDir, baseName) + threads := g.Cfg().MustGet(ctx, "whisper.threads", 2).Int() + + // whisper.cpp 参数: + // -f input.mp3 输入文件 + // -l zh 语言 + // -t 2 线程数 + // -otxt 输出 txt + // -of /path/prefix 输出文件前缀(自动加 .txt) + args := []string{ + "-f", req.AudioPath, + "-l", language, + "-t", fmt.Sprintf("%d", threads), + "-otxt", + "-of", outputPrefix, + "-m", model, + } + + cmd := exec.CommandContext(ctx, binaryPath, args...) + output, execErr := cmd.CombinedOutput() + if execErr != nil { + g.Log().Errorf(ctx, "whisper.cpp 执行失败: %v\n%s", execErr, string(output)) + return nil, fmt.Errorf("语音识别失败: %v", execErr) + } + + // whisper.cpp 输出: {prefix}.txt + txtPath := outputPrefix + ".txt" + textBytes, readErr := os.ReadFile(txtPath) + if readErr != nil { + return nil, fmt.Errorf("读取识别结果文件失败: %v", readErr) + } + + res = &TranscribeRes{ + Text: cleanTranscript(string(textBytes)), + Model: model, + Language: language, + OutputPath: txtPath, + } + return +} diff --git a/service/audio/audio_extract_service.go b/service/audio/audio_extract_service.go new file mode 100644 index 0000000..93bc693 --- /dev/null +++ b/service/audio/audio_extract_service.go @@ -0,0 +1,185 @@ +package audio + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +// AudioExtractService 音频提取服务 +type AudioExtractService struct{} + +// AudioExtract 音频提取服务单例 +var AudioExtract = new(AudioExtractService) + +// ExtractAudioReq 提取音频请求 +type ExtractAudioReq struct { + VideoPath string // 视频文件路径 + Format string // 输出音频格式,默认 mp3 +} + +// ExtractAudioRes 提取音频响应 +type ExtractAudioRes struct { + AudioPath string // 提取后的音频文件路径 + Duration string // 音频时长 + Size int64 // 音频文件大小(bytes) +} + +// Extract 从视频中提取音频 +func (s *AudioExtractService) Extract(ctx context.Context, req *ExtractAudioReq) (res *ExtractAudioRes, err error) { + // 1. 校验视频文件存在 + if _, err = os.Stat(req.VideoPath); os.IsNotExist(err) { + return nil, fmt.Errorf("视频文件不存在: %s", req.VideoPath) + } + + // 2. 校验 ffmpeg 是否可用 + ffmpegPath, err := s.getFFmpegPath() + if err != nil { + return nil, err + } + + // 3. 确定输出格式 + format := req.Format + if format == "" { + format = "mp3" + } + format = strings.TrimLeft(format, ".") + + // 4. 生成输出文件路径 + outputDir := filepath.Dir(req.VideoPath) + baseName := strings.TrimSuffix(filepath.Base(req.VideoPath), filepath.Ext(req.VideoPath)) + timestamp := time.Now().UnixMilli() + outputName := fmt.Sprintf("%s_audio_%d.%s", baseName, timestamp, format) + outputPath := filepath.Join(outputDir, outputName) + + g.Log().Infof(ctx, "开始提取音频: video=%s, output=%s", req.VideoPath, outputPath) + + // 5. 构建 ffmpeg 命令 + // 提取音频并转换为指定格式 + args := []string{ + "-i", req.VideoPath, + "-vn", // 去掉视频流 + "-acodec", "libmp3lame", // 使用 mp3 编码器(mp3格式) + "-ab", "192k", // 音频比特率 + "-ar", "44100", // 采样率 + "-ac", "2", // 双声道 + "-y", // 覆盖输出文件 + outputPath, + } + + // 如果输出不是 mp3,调整编码器 + switch format { + case "aac": + args[4] = "aac" + case "wav": + args[4] = "pcm_s16le" + args[5] = "-vn" + args = args[:8] // wav 不需要指定比特率等参数 + args = append(args, outputPath) + case "ogg": + args[4] = "libvorbis" + case "flac": + args[4] = "flac" + } + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + + // 捕获输出用于调试 + output, execErr := cmd.CombinedOutput() + if execErr != nil { + g.Log().Errorf(ctx, "ffmpeg 执行失败: %v, output: %s", execErr, string(output)) + return nil, fmt.Errorf("音频提取失败: %v", execErr) + } + + // 6. 验证输出文件 + stat, statErr := os.Stat(outputPath) + if statErr != nil { + return nil, fmt.Errorf("音频文件生成失败: %v", statErr) + } + + // 7. 获取音频时长(通过 ffprobe) + duration, _ := s.getAudioDuration(ctx, ffmpegPath, outputPath) + + g.Log().Infof(ctx, "音频提取成功: path=%s, size=%d, duration=%s", outputPath, stat.Size(), duration) + + res = &ExtractAudioRes{ + AudioPath: outputPath, + Duration: duration, + Size: stat.Size(), + } + return +} + +// getFFmpegPath 获取 ffmpeg 可执行路径 +func (s *AudioExtractService) getFFmpegPath() (string, error) { + // 1. 优先从配置读取 + ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String() + if ffmpegPath != "" { + if _, err := os.Stat(ffmpegPath); err == nil { + return ffmpegPath, nil + } + } + + // 2. 从 PATH 中查找 + path, err := exec.LookPath("ffmpeg") + if err != nil { + return "", fmt.Errorf("未找到 ffmpeg,请确保已安装 ffmpeg 或在配置中指定路径") + } + return path, nil +} + +// getAudioDuration 获取音频时长 +func (s *AudioExtractService) getAudioDuration(ctx context.Context, ffmpegPath string, audioPath string) (string, error) { + // 使用 ffprobe 获取时长 + // 先尝试查找 ffprobe + ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") + if _, err := os.Stat(ffprobePath); os.IsNotExist(err) { + ffprobePath = "ffprobe" + } + + cmd := exec.CommandContext(ctx, ffprobePath, + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + audioPath, + ) + + output, err := cmd.Output() + if err != nil { + return "", err + } + + durationStr := strings.TrimSpace(string(output)) + // 转换为人类可读格式: 秒 -> HH:MM:SS + var seconds float64 + fmt.Sscanf(durationStr, "%f", &seconds) + + hours := int(seconds) / 3600 + minutes := (int(seconds) % 3600) / 60 + secs := int(seconds) % 60 + + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs), nil +} + +// ExtractAndCleanup 提取音频并清理临时视频文件 +func (s *AudioExtractService) ExtractAndCleanup(ctx context.Context, req *ExtractAudioReq) (res *ExtractAudioRes, err error) { + res, err = s.Extract(ctx, req) + if err != nil { + return nil, err + } + + // 尝试删除原始视频文件 + if req.VideoPath != "" { + if removeErr := os.Remove(req.VideoPath); removeErr != nil { + g.Log().Warningf(ctx, "删除临时视频文件失败: %v", removeErr) + } + } + + return +} diff --git a/service/scene/scene_service.go b/service/scene/scene_service.go new file mode 100644 index 0000000..ab2cc9a --- /dev/null +++ b/service/scene/scene_service.go @@ -0,0 +1,657 @@ +package scene + +import ( + "bufio" + "context" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/gogf/gf/v2/frame/g" +) + +// SceneAnalyzerService 场景分析服务 +type SceneAnalyzerService struct{} + +// SceneAnalyzer 场景分析服务单例 +var SceneAnalyzer = new(SceneAnalyzerService) + +// KeyframeInfo 关键帧信息 +type KeyframeInfo struct { + Path string `json:"path"` // 关键帧图片路径 + TimeStr string `json:"timeStr"` // 时间点 + Width int `json:"width"` // 图片宽度 + Height int `json:"height"` // 图片高度 +} + +// SceneInfo 单个场景信息 +type SceneInfo struct { + SceneIndex int `json:"sceneIndex"` // 场景序号 + StartTime float64 `json:"startTime"` // 开始时间(秒,精确到3位小数) + EndTime float64 `json:"endTime"` // 结束时间(秒) + Duration float64 `json:"duration"` // 时长(秒) + StartTimeStr string `json:"startTimeStr"` // HH:MM:SS.mmm + EndTimeStr string `json:"endTimeStr"` + DurationStr string `json:"durationStr"` + ShotType string `json:"shotType"` // 镜头类型 + MotionLevel string `json:"motionLevel"` // 运动程度 + Composition string `json:"composition"` // 构图类型 + NarrativePos string `json:"narrativePos"` // 叙事位置 + Keyframe *KeyframeInfo `json:"keyframe,omitempty"` // 关键帧(如有提取) + Description string `json:"description"` // 场景描述(供 AI 使用) +} + +// VideoSceneAnalysis 单视频场景分析结果 +type VideoSceneAnalysis struct { + FileName string `json:"fileName"` + FilePath string `json:"filePath"` + Duration float64 `json:"duration"` + DurationStr string `json:"durationStr"` + FrameRate float64 `json:"frameRate"` + Width int `json:"width"` + Height int `json:"height"` + AspectRatio string `json:"aspectRatio"` // 画面比例 + Orientation string `json:"orientation"` // 横屏/竖屏 + TotalScenes int `json:"totalScenes"` + Scenes []SceneInfo `json:"scenes"` + DetectParams DetectParams `json:"detectParams"` + Summary SceneSummary `json:"summary"` // 场景总览 +} + +// SceneSummary 场景总览 +type SceneSummary struct { + AvgShotDuration float64 `json:"avgShotDuration"` // 平均镜头时长 + MinShotDuration float64 `json:"minShotDuration"` + MaxShotDuration float64 `json:"maxShotDuration"` + ShotTypeDist map[string]int `json:"shotTypeDist"` // 镜头类型分布 + MotionDist map[string]int `json:"motionDist"` // 运动程度分布 + CompositionDist map[string]int `json:"compositionDist"` // 构图分布 + Pacing string `json:"pacing"` // 剪辑节奏 + KeyframesDir string `json:"keyframesDir,omitempty"` // 关键帧目录 +} + +// DetectParams 检测参数 +type DetectParams struct { + Threshold float64 `json:"threshold"` + Method string `json:"method"` + ExtractKeyframes bool `json:"extractKeyframes"` +} + +// SceneAnalyzeReq 场景分析请求 +type SceneAnalyzeReq struct { + VideoPaths []string // 视频文件路径列表 + Threshold float64 // 场景检测阈值 0.1-0.5,默认 0.3 + ExtractKeyframes bool // 是否提取关键帧图片 +} + +// SceneAnalyzeRes 场景分析响应 +type SceneAnalyzeRes struct { + Analyses []VideoSceneAnalysis `json:"analyses"` +} + +var ( + ptsTimeRegex = regexp.MustCompile(`pts_time:([\d.]+)`) +) + +// Analyze 分析多个视频的场景 +func (s *SceneAnalyzerService) Analyze(ctx context.Context, req *SceneAnalyzeReq) (res *SceneAnalyzeRes, err error) { + threshold := req.Threshold + if threshold <= 0 || threshold > 1 { + threshold = 0.3 + } + + var ( + mu sync.Mutex + analyses []VideoSceneAnalysis + wg sync.WaitGroup + errCh = make(chan error, len(req.VideoPaths)) + ) + + for _, videoPath := range req.VideoPaths { + wg.Add(1) + go func(vp string) { + defer wg.Done() + analysis, aErr := s.analyzeSingle(ctx, vp, threshold, req.ExtractKeyframes) + if aErr != nil { + errCh <- fmt.Errorf("分析失败 [%s]: %v", filepath.Base(vp), aErr) + return + } + mu.Lock() + analyses = append(analyses, *analysis) + mu.Unlock() + }(videoPath) + } + + wg.Wait() + close(errCh) + + var errs []string + for e := range errCh { + errs = append(errs, e.Error()) + } + if len(errs) > 0 { + g.Log().Errorf(ctx, "部分视频分析失败: %s", strings.Join(errs, "; ")) + } + if len(analyses) == 0 { + return nil, fmt.Errorf("所有视频分析均失败: %s", strings.Join(errs, "; ")) + } + + res = &SceneAnalyzeRes{Analyses: analyses} + return +} + +// analyzeSingle 分析单个视频 +func (s *SceneAnalyzerService) analyzeSingle(ctx context.Context, videoPath string, threshold float64, extractKeyframes bool) (*VideoSceneAnalysis, error) { + ffmpegPath, err := s.getFFmpegPath() + if err != nil { + return nil, err + } + + // 1. 视频元数据 + duration, frameRate, width, height, err := s.getVideoMeta(ctx, ffmpegPath, videoPath) + if err != nil { + return nil, fmt.Errorf("获取视频元数据失败: %v", err) + } + + // 2. 场景检测 + sceneChanges, err := s.detectScenes(ctx, ffmpegPath, videoPath, threshold) + if err != nil { + return nil, fmt.Errorf("场景检测失败: %v", err) + } + + // 3. 构建场景列表 + 分析 + rawScenes := s.buildScenes(sceneChanges, duration) + totalDuration := duration + + // 4. 提取关键帧(如果需要) + keyframesDir := "" + if extractKeyframes { + keyframesDir = filepath.Join(filepath.Dir(videoPath), "keyframes_"+filepath.Base(videoPath)) + os.MkdirAll(keyframesDir, 0755) + } + + // 构建带分析信息的场景 + aspectRatio := fmt.Sprintf("%d:%d", width/gcd(width, height), height/gcd(width, height)) + orientation := "横屏" + if height > width { + orientation = "竖屏" + } + + fileName := filepath.Base(videoPath) + if idx := strings.Index(fileName, "_"); idx > 0 { + fileName = fileName[idx+1:] + } + + // 生成场景分析 + totalScenes := len(rawScenes) + scenes := make([]SceneInfo, totalScenes) + + shotDist := make(map[string]int) + motionDist := make(map[string]int) + compDist := make(map[string]int) + var durTotal float64 + + for i, rs := range rawScenes { + scene := SceneInfo{ + SceneIndex: rs.SceneIndex, + StartTime: round3(rs.StartTime), + EndTime: round3(rs.EndTime), + Duration: round3(rs.Duration), + StartTimeStr: rs.StartTimeStr, + EndTimeStr: rs.EndTimeStr, + DurationStr: rs.DurationStr, + } + + // 镜头类型 + scene.ShotType = classifyShotType(rs.Duration) + shotDist[scene.ShotType]++ + + // 运动程度 + scene.MotionLevel = classifyMotionLevel(rs.Duration, totalDuration) + motionDist[scene.MotionLevel]++ + + // 构图 + scene.Composition = classifyComposition(rs.Duration, width, height) + compDist[scene.Composition]++ + + // 叙事位置 + ratio := rs.StartTime / totalDuration + switch { + case ratio < 0.15: + scene.NarrativePos = "开头引入" + case ratio < 0.35: + scene.NarrativePos = "前段发展" + case ratio < 0.65: + scene.NarrativePos = "中段高潮" + case ratio < 0.85: + scene.NarrativePos = "后段收束" + default: + scene.NarrativePos = "结尾总结" + } + + // 关键帧 + if extractKeyframes && keyframesDir != "" { + midTime := (rs.StartTime + rs.EndTime) / 2 + kfPath := filepath.Join(keyframesDir, fmt.Sprintf("scene_%03d.jpg", rs.SceneIndex)) + if kfErr := s.extractKeyframe(ctx, ffmpegPath, videoPath, midTime, kfPath); kfErr == nil { + scene.Keyframe = &KeyframeInfo{ + Path: kfPath, + TimeStr: formatTime(midTime), + Width: width, + Height: height, + } + } + } + + // AI 描述 + scene.Description = buildSceneDescription(scene) + + durTotal += rs.Duration + scenes[i] = scene + } + + analysis := &VideoSceneAnalysis{ + FileName: fileName, + FilePath: videoPath, + Duration: round3(totalDuration), + DurationStr: formatTime(totalDuration), + FrameRate: round3(frameRate), + Width: width, + Height: height, + AspectRatio: aspectRatio, + Orientation: orientation, + TotalScenes: totalScenes, + Scenes: scenes, + DetectParams: DetectParams{ + Threshold: threshold, + Method: "ffmpeg scene filter", + ExtractKeyframes: extractKeyframes, + }, + Summary: s.buildSummary(scenes, shotDist, motionDist, compDist, keyframesDir), + } + + return analysis, nil +} + +// buildSummary 构建场景总览 +func (s *SceneAnalyzerService) buildSummary(scenes []SceneInfo, shotDist, motionDist, compDist map[string]int, kfDir string) SceneSummary { + if len(scenes) == 0 { + return SceneSummary{} + } + var minD, maxD, sumD float64 + minD = math.MaxFloat64 + for _, sc := range scenes { + sumD += sc.Duration + if sc.Duration < minD { + minD = sc.Duration + } + if sc.Duration > maxD { + maxD = sc.Duration + } + } + avgD := sumD / float64(len(scenes)) + + pacing := "平稳" + if avgD < 2 { + pacing = "快节奏(快速剪辑)" + } else if avgD < 4 { + pacing = "适中节奏" + } else if avgD < 8 { + pacing = "舒缓节奏" + } else { + pacing = "慢节奏(长镜头为主)" + } + + sm := SceneSummary{ + AvgShotDuration: round3(avgD), + MinShotDuration: round3(minD), + MaxShotDuration: round3(maxD), + ShotTypeDist: shotDist, + MotionDist: motionDist, + CompositionDist: compDist, + Pacing: pacing, + } + if kfDir != "" { + sm.KeyframesDir = kfDir + } + return sm +} + +// getVideoMeta 获取视频元数据 +func (s *SceneAnalyzerService) getVideoMeta(ctx context.Context, ffmpegPath, videoPath string) (duration, frameRate float64, width, height int, err error) { + ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") + if _, statErr := os.Stat(ffprobePath); os.IsNotExist(statErr) { + ffprobePath = "ffprobe" + } + + cmd := exec.CommandContext(ctx, ffprobePath, + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + videoPath, + ) + + output, execErr := cmd.Output() + if execErr != nil { + err = fmt.Errorf("ffprobe 执行失败: %v", execErr) + return + } + + text := string(output) + duration = parseJSONFloat(text, `"duration":`) + frameRate = parseFrameRate(text) + width = parseJSONInt(text, `"width":`) + height = parseJSONInt(text, `"height":`) + return +} + +// detectScenes 通过 ffmpeg scene filter 检测场景变化 +func (s *SceneAnalyzerService) detectScenes(ctx context.Context, ffmpegPath, videoPath string, threshold float64) ([]float64, error) { + thresholdStr := strconv.FormatFloat(threshold, 'f', 1, 64) + + args := []string{ + "-i", videoPath, + "-filter:v", fmt.Sprintf("select='gt(scene,%s)',showinfo", thresholdStr), + "-f", "null", + "-", + } + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + output, _ := cmd.CombinedOutput() + + var timestamps []float64 + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + line := scanner.Text() + matches := ptsTimeRegex.FindStringSubmatch(line) + if len(matches) >= 2 { + ts, parseErr := strconv.ParseFloat(matches[1], 64) + if parseErr == nil && ts > 0 { + timestamps = append(timestamps, ts) + } + } + } + return timestamps, nil +} + +// extractKeyframe 提取指定时间点的关键帧 +func (s *SceneAnalyzerService) extractKeyframe(ctx context.Context, ffmpegPath, videoPath string, timeSec float64, outputPath string) error { + timeStr := strconv.FormatFloat(timeSec, 'f', 3, 64) + + args := []string{ + "-ss", timeStr, + "-i", videoPath, + "-vframes", "1", + "-q:v", "3", + "-y", + outputPath, + } + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + return cmd.Run() +} + +// buildScenes 根据场景变化时间戳构建场景列表 +func (s *SceneAnalyzerService) buildScenes(sceneChanges []float64, totalDuration float64) []SceneInfo { + var scenes []SceneInfo + + if len(sceneChanges) == 0 { + scenes = append(scenes, SceneInfo{ + SceneIndex: 1, + StartTime: 0, + EndTime: totalDuration, + Duration: totalDuration, + StartTimeStr: formatTime(0), + EndTimeStr: formatTime(totalDuration), + DurationStr: formatTime(totalDuration), + }) + return scenes + } + + startTime := 0.0 + for i, ts := range sceneChanges { + if ts <= startTime || ts > totalDuration { + continue + } + scenes = append(scenes, SceneInfo{ + SceneIndex: i + 1, + StartTime: startTime, + EndTime: ts, + Duration: ts - startTime, + StartTimeStr: formatTime(startTime), + EndTimeStr: formatTime(ts), + DurationStr: formatTime(ts - startTime), + }) + startTime = ts + } + + if startTime < totalDuration { + scenes = append(scenes, SceneInfo{ + SceneIndex: len(scenes) + 1, + StartTime: startTime, + EndTime: totalDuration, + Duration: totalDuration - startTime, + StartTimeStr: formatTime(startTime), + EndTimeStr: formatTime(totalDuration), + DurationStr: formatTime(totalDuration - startTime), + }) + } + + return scenes +} + +// ---------- 镜头分类逻辑 ---------- + +// classifyShotType 根据时长判断镜头类型 +func classifyShotType(duration float64) string { + switch { + case duration < 0.8: + return "极速闪切" + case duration < 1.5: + return "快速切换" + case duration < 2.5: + return "短镜头" + case duration < 4: + return "标准镜头" + case duration < 8: + return "中长镜头" + case duration < 15: + return "长镜头" + default: + return "超长镜头" + } +} + +// classifyMotionLevel 基于时长和相对比例推断运动程度 +func classifyMotionLevel(duration, totalDuration float64) string { + switch { + case duration < 1.0: + return "高动态(快速切换)" + case duration < 2.0: + return "中高动态" + case duration < 4.0: + return "中等动态" + case duration < 8.0: + return "低动态(平稳)" + default: + return "静态/固定机位" + } +} + +// classifyComposition 基于时长和画面比例推断构图类型 +func classifyComposition(duration float64, width, height int) string { + isVertical := height > width + + switch { + case duration < 1.2: + if isVertical { + return "竖屏特写/细节" + } + return "特写/细节" + case duration < 2.5: + if isVertical { + return "竖屏近景" + } + return "近景/中近景" + case duration < 5: + if isVertical { + return "竖屏中景" + } + return "中景/半身" + case duration < 10: + if isVertical { + return "竖屏全景" + } + return "全景/环境" + default: + if isVertical { + return "竖屏远景/固定机位" + } + return "远景/广角" + } +} + +// buildSceneDescription 生成可读的场景描述(供 AI 使用) +func buildSceneDescription(scene SceneInfo) string { + return fmt.Sprintf( + "场景%d:%s~%s,时长%s,%s,%s,%s,%s", + scene.SceneIndex, + scene.StartTimeStr, scene.EndTimeStr, + scene.DurationStr, + scene.ShotType, + scene.Composition, + scene.MotionLevel, + scene.NarrativePos, + ) +} + +// ---------- 工具函数 ---------- + +func round3(v float64) float64 { + return math.Round(v*1000) / 1000 +} + +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + return a +} + +func getFFmpegPath() (string, error) { + ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String() + if ffmpegPath != "" { + if _, err := os.Stat(ffmpegPath); err == nil { + return ffmpegPath, nil + } + } + path, err := exec.LookPath("ffmpeg") + if err != nil { + return "", fmt.Errorf("未找到 ffmpeg") + } + return path, nil +} + +func formatTime(seconds float64) string { + h := int(seconds) / 3600 + m := (int(seconds) % 3600) / 60 + s := int(seconds) % 60 + ms := int(math.Round((seconds - float64(int(seconds))) * 1000)) + return fmt.Sprintf("%02d:%02d:%02d.%03d", h, m, s, ms) +} + +func parseJSONFloat(text, key string) float64 { + idx := strings.Index(text, key) + if idx < 0 { + return 0 + } + start := idx + len(key) + for start < len(text) && (text[start] == ' ' || text[start] == '"') { + start++ + } + end := start + for end < len(text) && (isDigit(text[end]) || text[end] == '.') { + end++ + } + if start < end { + val, _ := strconv.ParseFloat(text[start:end], 64) + return val + } + return 0 +} + +func parseJSONInt(text, key string) int { + idx := strings.Index(text, key) + if idx < 0 { + return 0 + } + start := idx + len(key) + for start < len(text) && (text[start] == ' ' || text[start] == '"') { + start++ + } + end := start + for end < len(text) && isDigit(text[end]) { + end++ + } + if start < end { + val, _ := strconv.Atoi(text[start:end]) + return val + } + return 0 +} + +func parseFrameRate(text string) float64 { + for _, key := range []string{`"r_frame_rate":`, `"avg_frame_rate":`} { + idx := strings.Index(text, key) + if idx < 0 { + continue + } + start := idx + len(key) + for start < len(text) && (text[start] == ' ' || text[start] == '"') { + start++ + } + end := start + for end < len(text) && text[end] != '"' && text[end] != ',' && text[end] != '}' && text[end] != ' ' { + end++ + } + valStr := text[start:end] + if strings.Contains(valStr, "/") { + parts := strings.Split(valStr, "/") + if len(parts) == 2 { + num, _ := strconv.ParseFloat(parts[0], 64) + den, _ := strconv.ParseFloat(parts[1], 64) + if den > 0 { + return num / den + } + } + } + val, _ := strconv.ParseFloat(valStr, 64) + if val > 0 { + return val + } + } + return 0 +} + +func isDigit(b byte) bool { + return b >= '0' && b <= '9' +} + +// Cleanup 清理视频和关键帧文件 +func Cleanup(paths []string) { + for _, p := range paths { + os.RemoveAll(p) + } +} + +// getFFmpegPath on SceneAnalyzerService +func (s *SceneAnalyzerService) getFFmpegPath() (string, error) { + return getFFmpegPath() +} diff --git a/service/setup/setup_service.go b/service/setup/setup_service.go new file mode 100644 index 0000000..862ec5f --- /dev/null +++ b/service/setup/setup_service.go @@ -0,0 +1,394 @@ +package setup + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/gogf/gf/v2/frame/g" +) + +var ( + envConfigured bool + + // DetectedWhisperPath 自动检测到的 whisper 命令行路径(空则使用 python -m whisper) + DetectedWhisperPath string +) + +// EnsureDependencies 启动时检查并安装 ffmpeg 和 whisper +func EnsureDependencies(ctx context.Context) { + g.Log().Info(ctx, "========== 检查依赖环境 ==========") + + ensureFFmpeg(ctx) + ensureWhisper(ctx) + resolveWhisperPath(ctx) + + if envConfigured { + g.Log().Info(ctx, "依赖检查完成,新环境变量已配置,建议重启终端") + } else { + g.Log().Info(ctx, "依赖检查完成,所有依赖已就绪") + } + g.Log().Info(ctx, "===================================") +} + +// ensureFFmpeg 确保 ffmpeg 可用 +func ensureFFmpeg(ctx context.Context) { + if _, err := exec.LookPath("ffmpeg"); err == nil { + g.Log().Info(ctx, "[ffmpeg] ✔ 已安装") + return + } + + g.Log().Infof(ctx, "[ffmpeg] 未找到,尝试自动安装...") + + switch runtime.GOOS { + case "darwin": + // 检查是否安装了 Homebrew + if _, err := exec.LookPath("brew"); err != nil { + g.Log().Warningf(ctx, "[ffmpeg] ⚠ 未检测到 Homebrew,请手动安装:\n brew install ffmpeg") + return + } + cmd := exec.CommandContext(ctx, "brew", "install", "ffmpeg") + output, err := cmd.CombinedOutput() + if err != nil { + g.Log().Errorf(ctx, "[ffmpeg] ❌ 安装失败: %v\n%s", err, string(output)) + return + } + g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功") + + case "linux": + // 尝试 apt + if _, err := exec.LookPath("apt"); err == nil { + cmd := exec.CommandContext(ctx, "sudo", "apt", "install", "-y", "ffmpeg") + output, err := cmd.CombinedOutput() + if err != nil { + g.Log().Errorf(ctx, "[ffmpeg] ❌ apt 安装失败: %v\n%s", err, string(output)) + return + } + g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功") + return + } + // 尝试 yum + if _, err := exec.LookPath("yum"); err == nil { + cmd := exec.CommandContext(ctx, "sudo", "yum", "install", "-y", "ffmpeg") + output, err := cmd.CombinedOutput() + if err != nil { + g.Log().Errorf(ctx, "[ffmpeg] ❌ yum 安装失败: %v\n%s", err, string(output)) + return + } + g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功") + return + } + g.Log().Warningf(ctx, "[ffmpeg] ⚠ 请手动安装: sudo apt install ffmpeg") + + default: + g.Log().Warningf(ctx, "[ffmpeg] ⚠ 不支持的平台(%s),请手动安装 ffmpeg", runtime.GOOS) + } +} + +// ensureWhisper 确保 whisper 可用(优先安装 C++ 版,速度更快) +func ensureWhisper(ctx context.Context) { + // 1. 检查是否已有 whisper-cpp(C++ 版,最快) + if path, err := exec.LookPath("whisper-cpp"); err == nil { + g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装: %s", path) + return + } + if path, err := exec.LookPath("whisper-cli"); err == nil { + g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装: %s", path) + return + } + + // 2. 检查 Homebrew 安装目录(即使不在 PATH 也能找到) + if p := findHomebrewWhisperCpp(); p != "" { + DetectedWhisperPath = p + // 自动添加到 PATH 环境变量 + addToShellPath(ctx, filepath.Dir(p)) + g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装(自动检测): %s", p) + return + } + + // 3. 尝试安装 whisper-cpp(C++ 版) + if runtime.GOOS == "darwin" { + if _, err := exec.LookPath("brew"); err == nil { + g.Log().Infof(ctx, "[whisper] 安装 C++ 版 (brew install whisper-cpp)...") + cmd := exec.CommandContext(ctx, "brew", "install", "whisper-cpp") + output, err := cmd.CombinedOutput() + if err == nil { + g.Log().Info(ctx, "[whisper] ✔ C++ 版安装成功") + // 装好后把 Homebrew bin 加到 PATH + addToShellPath(ctx, getHomebrewBinDir()) + // 检测安装路径 + if p := findHomebrewWhisperCpp(); p != "" { + DetectedWhisperPath = p + } + return + } + g.Log().Warningf(ctx, "[whisper] ⚠ brew 安装失败: %v\n%s", err, string(output)) + g.Log().Infof(ctx, "[whisper] 降级安装 Python 版...") + } + } + + // 4. 降级:检查 python -m whisper 是否可用 + if pythonWhisperAvailable() { + g.Log().Info(ctx, "[whisper] ✔ Python 版已安装 (python3 -m whisper)") + return + } + + // 5. 降级:pip 安装 Python 版 + if _, err := exec.LookPath("pip3"); err != nil { + if _, err2 := exec.LookPath("pip"); err2 != nil { + g.Log().Warningf(ctx, "[whisper] ⚠ 未找到 pip,请手动安装:\n pip3 install openai-whisper") + return + } + } + + g.Log().Infof(ctx, "[whisper] 安装 Python 版 (pip install openai-whisper)...") + pipCmd := "pip3" + if _, err := exec.LookPath("pip3"); err != nil { + pipCmd = "pip" + } + + cmd := exec.CommandContext(ctx, pipCmd, "install", "--user", "openai-whisper") + output, err := cmd.CombinedOutput() + if err != nil { + g.Log().Errorf(ctx, "[whisper] ❌ pip 安装失败: %v\n%s", err, string(output)) + return + } + g.Log().Info(ctx, "[whisper] ✔ Python 版安装成功") + + // 安装后自动配置 PATH + configureWhisperPath(ctx) +} + +// resolveWhisperPath 自动找到 whisper 二进制路径并存储 +func resolveWhisperPath(ctx context.Context) { + // 0. 如果已经通过 ensure 检测到了路径,直接使用 + if DetectedWhisperPath != "" { + if _, err := os.Stat(DetectedWhisperPath); err == nil { + g.Log().Infof(ctx, "[whisper] ✔ 路径: %s", DetectedWhisperPath) + return + } + } + + // 1. 优先检测 C++ 版本(快 3-5 倍) + for _, name := range []string{"whisper-cpp", "whisper-cli"} { + if path, err := exec.LookPath(name); err == nil { + DetectedWhisperPath = path + g.Log().Infof(ctx, "[whisper] ✔ C++ 版: %s", path) + return + } + } + + // 2. 在 Homebrew 目录查找 C++ 版本 + if p := findHomebrewWhisperCpp(); p != "" { + DetectedWhisperPath = p + g.Log().Infof(ctx, "[whisper] ✔ C++ 版(自动检测): %s", p) + return + } + + // 3. 从 PATH 查找 Python 版 whisper + if path, err := exec.LookPath("whisper"); err == nil { + DetectedWhisperPath = path + g.Log().Infof(ctx, "[whisper] ✔ Python 版: %s", path) + return + } + + // 4. 尝试常见 pip user bin 路径 + for _, p := range getWhisperCandidates() { + if info, err := os.Stat(p); err == nil && !info.IsDir() { + DetectedWhisperPath = p + g.Log().Infof(ctx, "[whisper] ✔ Python 版(自动检测): %s", p) + return + } + } + + g.Log().Info(ctx, "[whisper] ✔ 使用 python3 -m whisper 方式") +} + +// getWhisperCandidates 返回可能的 whisper 二进制路径 +func getWhisperCandidates() []string { + var candidates []string + + // 通过 python 探针获取 user-site bin 目录 + if p := getUserPythonBin(); p != "" { + candidates = append(candidates, filepath.Join(p, "whisper")) + } + + // 常见 pip user base 路径 + userHome, _ := os.UserHomeDir() + + switch runtime.GOOS { + case "darwin": + // macOS 常见的 Python 版本路径 + pythonVersions := []string{"3.9", "3.10", "3.11", "3.12", "3.13"} + for _, ver := range pythonVersions { + candidates = append(candidates, + filepath.Join(userHome, "Library", "Python", ver, "bin", "whisper"), + ) + } + case "linux": + candidates = append(candidates, + filepath.Join(userHome, ".local", "bin", "whisper"), + ) + } + + return candidates +} + +// getUserPythonBin 通过 python 获取 user bin 目录 +func getUserPythonBin() string { + pythonCandidates := []string{"python3", "python"} + for _, py := range pythonCandidates { + path, err := exec.LookPath(py) + if err != nil { + continue + } + cmd := exec.Command(path, "-m", "site", "--user-base") + output, err := cmd.Output() + if err != nil { + continue + } + base := strings.TrimSpace(string(output)) + if base != "" { + return filepath.Join(base, "bin") + } + } + return "" +} + +// configureWhisperPath 将 pip user bin 目录加到 shell 配置 +func configureWhisperPath(ctx context.Context) { + binDir := getUserPythonBin() + if binDir == "" { + return + } + + // 检查是否已经在 PATH 中 + currentPath := os.Getenv("PATH") + if strings.Contains(currentPath, binDir) { + return + } + + // 配置到 .zshrc 或 .bashrc + home, _ := os.UserHomeDir() + rcFiles := []string{".zshrc", ".bashrc", ".bash_profile"} + + for _, rc := range rcFiles { + rcPath := filepath.Join(home, rc) + // 文件不存在则跳过 + if _, err := os.Stat(rcPath); os.IsNotExist(err) { + continue + } + // 检查是否已添加 + data, _ := os.ReadFile(rcPath) + if strings.Contains(string(data), binDir) { + continue + } + // 追加 + line := fmt.Sprintf("\nexport PATH=\"%s:$PATH\"\n", binDir) + f, err := os.OpenFile(rcPath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + g.Log().Warningf(ctx, "[whisper] 写入 %s 失败: %v", rc, err) + continue + } + f.WriteString(line) + f.Close() + g.Log().Infof(ctx, "[whisper] 已将 %s 添加到 %s,请执行: source ~/%s", binDir, rc, rc) + envConfigured = true + break + } +} + +// pythonWhisperAvailable 检查 python -m whisper 是否可用 +func pythonWhisperAvailable() bool { + pythonCandidates := []string{"python3", "python"} + for _, py := range pythonCandidates { + if path, err := exec.LookPath(py); err == nil { + cmd := exec.Command(path, "-m", "whisper", "--help") + if cmd.Run() == nil { + return true + } + } + } + return false +} + +// findHomebrewWhisperCpp 在 Homebrew 安装目录查找 whisper-cpp +func findHomebrewWhisperCpp() string { + dirs := getHomebrewBinDirs() + for _, dir := range dirs { + for _, name := range []string{"whisper-cpp", "whisper-cli"} { + p := filepath.Join(dir, name) + if info, err := os.Stat(p); err == nil && !info.IsDir() { + return p + } + } + } + return "" +} + +// getHomebrewBinDirs 返回 Homebrew 可能的 bin 目录 +func getHomebrewBinDirs() []string { + userHome, _ := os.UserHomeDir() + return []string{ + "/opt/homebrew/bin", // Apple Silicon + "/usr/local/bin", // Intel + filepath.Join(userHome, ".homebrew", "bin"), + } +} + +// getHomebrewBinDir 返回当前系统的 Homebrew bin 目录 +func getHomebrewBinDir() string { + dirs := getHomebrewBinDirs() + for _, dir := range dirs { + if _, err := os.Stat(filepath.Join(dir, "brew")); err == nil { + return dir + } + // 也检查 brew 命令路径 + if path, err := exec.LookPath("brew"); err == nil { + return filepath.Dir(path) + } + } + return "/opt/homebrew/bin" // 默认 Apple Silicon 路径 +} + +// addToShellPath 将目录添加到 shell rc 文件的 PATH 中 +func addToShellPath(ctx context.Context, dir string) { + if dir == "" { + return + } + + // 检查是否已在 PATH 中 + currentPath := os.Getenv("PATH") + if strings.Contains(currentPath, dir) { + return + } + + home, _ := os.UserHomeDir() + rcFiles := []string{".zshrc", ".bashrc", ".bash_profile"} + + for _, rc := range rcFiles { + rcPath := filepath.Join(home, rc) + if _, err := os.Stat(rcPath); os.IsNotExist(err) { + continue + } + data, _ := os.ReadFile(rcPath) + if strings.Contains(string(data), dir) { + continue + } + line := fmt.Sprintf("\nexport PATH=\"%s:$PATH\"\n", dir) + f, err := os.OpenFile(rcPath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + g.Log().Warningf(ctx, "[setup] 写入 %s 失败: %v", rc, err) + continue + } + f.WriteString(line) + f.Close() + g.Log().Infof(ctx, "[setup] 已将 %s 添加到 %s", dir, rc) + envConfigured = true + break + } +} diff --git a/service/video/concat_service.go b/service/video/concat_service.go new file mode 100644 index 0000000..2d39383 --- /dev/null +++ b/service/video/concat_service.go @@ -0,0 +1,285 @@ +package video + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gogf/gf/v2/frame/g" +) + +// ConcatService 视频拼接服务 +type ConcatService struct{} + +// Concat 视频拼接服务单例 +var Concat = new(ConcatService) + +// ConcatReq 视频拼接请求 +type ConcatReq struct { + VideoPaths []string // 视频文件路径列表(按此顺序拼接) + OutputPath string // 输出视频文件路径,空则自动生成 + Method string // 拼接方式: auto/fast/reencode,默认 auto +} + +// ConcatRes 视频拼接响应 +type ConcatRes struct { + OutputPath string `json:"outputPath"` // 输出文件路径 + FileSize int64 `json:"fileSize"` // 文件大小(bytes) + Duration float64 `json:"duration"` // 拼接后总时长(秒) + DurationStr string `json:"durationStr"` // 可读时长 + MethodUsed string `json:"methodUsed"` // 实际使用的拼接方式 + InputFiles int `json:"inputFiles"` // 输入文件数 +} + +// Concat 拼接多个视频为一个 +func (s *ConcatService) Concat(ctx context.Context, req *ConcatReq) (res *ConcatRes, err error) { + if len(req.VideoPaths) < 2 { + return nil, fmt.Errorf("至少需要2个视频才能拼接") + } + + // 校验所有视频文件存在 + for i, p := range req.VideoPaths { + if _, err := os.Stat(p); os.IsNotExist(err) { + return nil, fmt.Errorf("第%d个视频文件不存在: %s", i+1, p) + } + } + + ffmpegPath, err := s.getFFmpegPath() + if err != nil { + return nil, err + } + + // 生成输出路径 + outputPath := req.OutputPath + if outputPath == "" { + outputDir := filepath.Dir(req.VideoPaths[0]) + outputPath = filepath.Join(outputDir, "concat_output.mp4") + } + + method := req.Method + if method == "" { + method = "auto" + } + + var methodUsed string + + switch method { + case "fast": + // 无损拼接(要求同编码参数,速度快但可能黑屏) + err = s.concatByDemuxer(ctx, ffmpegPath, req.VideoPaths, outputPath) + methodUsed = "concat demuxer (无损)" + default: + // 重编码拼接(自动归一化分辨率/音频,兼容所有视频) + err = s.concatByFilter(ctx, ffmpegPath, req.VideoPaths, outputPath) + methodUsed = "concat filter (重编码)" + } + + if err != nil { + return nil, fmt.Errorf("视频拼接失败: %v", err) + } + + // 获取输出文件信息 + stat, statErr := os.Stat(outputPath) + if statErr != nil { + return nil, fmt.Errorf("输出文件异常: %v", statErr) + } + + // 获取时长 + duration, _ := s.getVideoDuration(ctx, ffmpegPath, outputPath) + + res = &ConcatRes{ + OutputPath: outputPath, + FileSize: stat.Size(), + Duration: duration, + DurationStr: formatDuration(duration), + MethodUsed: methodUsed, + InputFiles: len(req.VideoPaths), + } + return +} + +// concatByDemuxer 使用 concat demuxer 无损拼接(要求同编码参数) +func (s *ConcatService) concatByDemuxer(ctx context.Context, ffmpegPath string, inputs []string, output string) error { + // 创建文件列表 + fileListPath := filepath.Join(filepath.Dir(output), "concat_list.txt") + var lines []string + for _, p := range inputs { + lines = append(lines, fmt.Sprintf("file '%s'", p)) + } + if err := os.WriteFile(fileListPath, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil { + return fmt.Errorf("创建文件列表失败: %v", err) + } + defer os.Remove(fileListPath) + + args := []string{ + "-f", "concat", + "-safe", "0", + "-i", fileListPath, + "-c", "copy", // 直接复制流,不重编码 + "-y", + output, + } + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + outputBytes, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg demuxer 失败: %v\n%s", err, string(outputBytes)) + } + return nil +} + +// concatByFilter 使用 concat filter 重编码拼接(自动归一化分辨率/音频参数) +func (s *ConcatService) concatByFilter(ctx context.Context, ffmpegPath string, inputs []string, output string) error { + n := len(inputs) + + // 1. 获取所有视频的分辨率,确定统一输出尺寸 + maxW, maxH := 0, 0 + var inputMeta []struct{ w, h int } + for _, p := range inputs { + w, h, _ := s.getVideoResolution(ctx, ffmpegPath, p) + inputMeta = append(inputMeta, struct{ w, h int }{w, h}) + if w > maxW { + maxW = w + } + if h > maxH { + maxH = h + } + } + // 保底 + if maxW == 0 { + maxW = 1920 + } + if maxH == 0 { + maxH = 1080 + } + + // 2. 构建输入参数 + var inputArgs []string + for _, p := range inputs { + inputArgs = append(inputArgs, "-i", p) + } + + // 3. 构建 filter_complex:每个视频 scale+pad 到统一尺寸,然后 concat + var filterParts []string + for i := 0; i < n; i++ { + filterParts = append(filterParts, fmt.Sprintf( + "[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v%d]", + i, maxW, maxH, maxW, maxH, i, + )) + filterParts = append(filterParts, fmt.Sprintf( + "[%d:a]aresample=44100[a%d]", + i, i, + )) + } + // 收集归一化后的流 + var concatInputs []string + for i := 0; i < n; i++ { + concatInputs = append(concatInputs, fmt.Sprintf("[v%d][a%d]", i, i)) + } + filterStr := fmt.Sprintf("%s;%sconcat=n=%d:v=1:a=1[outv][outa]", + strings.Join(filterParts, ";"), + strings.Join(concatInputs, ""), n) + + outputDir := filepath.Dir(output) + args := append(inputArgs, + "-filter_complex", filterStr, + "-map", "[outv]", + "-map", "[outa]", + "-preset", "fast", + "-crf", "23", + "-y", + output, + ) + + // 调试:记录完整命令 + g.Log().Debugf(ctx, "concat filter 命令: %s %v", ffmpegPath, args) + + // 保存 filter graph 用于调试 + filterFile := filepath.Join(outputDir, "concat_filter.txt") + os.WriteFile(filterFile, []byte(filterStr), 0644) + defer os.Remove(filterFile) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + outputBytes, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg filter 失败: %v\n日志:\n%s", err, string(outputBytes)) + } + return nil +} + +// getVideoResolution 获取视频分辨率 +func (s *ConcatService) getVideoResolution(ctx context.Context, ffmpegPath, videoPath string) (width, height int, err error) { + ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") + if _, err := os.Stat(ffprobePath); os.IsNotExist(err) { + ffprobePath = "ffprobe" + } + + cmd := exec.CommandContext(ctx, ffprobePath, + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0", + videoPath, + ) + output, err := cmd.Output() + if err != nil { + return 0, 0, err + } + fmt.Sscanf(strings.TrimSpace(string(output)), "%d,%d", &width, &height) + return +} + +// getVideoDuration 获取视频时长 +func (s *ConcatService) getVideoDuration(ctx context.Context, ffmpegPath, videoPath string) (float64, error) { + ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") + if _, err := os.Stat(ffprobePath); os.IsNotExist(err) { + ffprobePath = "ffprobe" + } + + cmd := exec.CommandContext(ctx, ffprobePath, + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + videoPath, + ) + + output, err := cmd.Output() + if err != nil { + return 0, err + } + + var duration float64 + fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration) + return duration, nil +} + +func (s *ConcatService) getFFmpegPath() (string, error) { + ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String() + if ffmpegPath != "" { + if _, err := os.Stat(ffmpegPath); err == nil { + return ffmpegPath, nil + } + } + path, err := exec.LookPath("ffmpeg") + if err != nil { + return "", fmt.Errorf("未找到 ffmpeg") + } + return path, nil +} + +func formatDuration(seconds float64) string { + h := int(seconds) / 3600 + m := (int(seconds) % 3600) / 60 + s := int(seconds) % 60 + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} + +// CleanupConcat 清理输入视频文件 +func CleanupConcat(paths []string) { + for _, p := range paths { + os.Remove(p) + } +}