代码初始化
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/.idea/*
|
||||
/resource/temp/*
|
||||
/resource/log/server/*
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -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"]
|
||||
64
config.yml
Normal file
64
config.yml
Normal file
@@ -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
|
||||
106
controller/audio/audio_extract_controller.go
Normal file
106
controller/audio/audio_extract_controller.go
Normal file
@@ -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
|
||||
}
|
||||
68
controller/common/upload.go
Normal file
68
controller/common/upload.go
Normal file
@@ -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
|
||||
}
|
||||
156
controller/video/concat_controller.go
Normal file
156
controller/video/concat_controller.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
94
go.mod
Normal file
94
go.mod
Normal file
@@ -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
|
||||
)
|
||||
472
go.sum
Normal file
472
go.sum
Normal file
@@ -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=
|
||||
73
main.go
Normal file
73
main.go
Normal file
@@ -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)
|
||||
}
|
||||
55
model/dto/audio/audio_dto.go
Normal file
55
model/dto/audio/audio_dto.go
Normal file
@@ -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:"场景描述"`
|
||||
}
|
||||
17
model/dto/video/video_dto.go
Normal file
17
model/dto/video/video_dto.go
Normal file
@@ -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:"输入文件数"`
|
||||
}
|
||||
232
service/asr/transcribe_service.go
Normal file
232
service/asr/transcribe_service.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
391
service/asr/whisper_service.go
Normal file
391
service/asr/whisper_service.go
Normal file
@@ -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
|
||||
}
|
||||
185
service/audio/audio_extract_service.go
Normal file
185
service/audio/audio_extract_service.go
Normal file
@@ -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
|
||||
}
|
||||
657
service/scene/scene_service.go
Normal file
657
service/scene/scene_service.go
Normal file
@@ -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()
|
||||
}
|
||||
394
service/setup/setup_service.go
Normal file
394
service/setup/setup_service.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
285
service/video/concat_service.go
Normal file
285
service/video/concat_service.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user