代码初始化
This commit is contained in:
@@ -27,7 +27,12 @@ RUN go build -ldflags="-s -w" -o main ./main.go
|
|||||||
# 阶段2: 运行
|
# 阶段2: 运行
|
||||||
FROM alpine:3.19
|
FROM alpine:3.19
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
# 安装运行时依赖: ca-certificates(HTTPS), tzdata(时区), ffmpeg(音视频处理)
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata ffmpeg bash
|
||||||
|
# 安装 Python3 和 pip(用于 whisper 语音识别)
|
||||||
|
RUN apk add --no-cache python3 py3-pip && \
|
||||||
|
pip3 install --no-cache-dir --break-system-packages openai-whisper 2>/dev/null || \
|
||||||
|
pip3 install --no-cache-dir openai-whisper
|
||||||
|
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|||||||
17
config.yml
17
config.yml
@@ -34,11 +34,23 @@ database:
|
|||||||
deletedAt: "deleted_at"
|
deletedAt: "deleted_at"
|
||||||
timeMaintainDisabled: false
|
timeMaintainDisabled: false
|
||||||
|
|
||||||
|
redis:
|
||||||
|
# 集群模式配置方法
|
||||||
|
default:
|
||||||
|
address: 116.204.74.41:6379
|
||||||
|
db: 0
|
||||||
|
idleTimeout: "60s" #连接最大空闲时间,使用时间字符串例如30s/1m/1d
|
||||||
|
maxConnLifetime: "90s" #连接最长存活时间,使用时间字符串例如30s/1m/1d
|
||||||
|
waitTimeout: "60s" #等待连接池连接的超时时间,使用时间字符串例如30s/1m/1d
|
||||||
|
dialTimeout: "30s" #TCP连接的超时时间,使用时间字符串例如30s/1m/1d
|
||||||
|
readTimeout: "30s" #TCP的Read操作超时时间,使用时间字符串例如30s/1m/1d
|
||||||
|
writeTimeout: "30s" #TCP的Write操作超时时间,使用时间字符串例如30s/1m/1d
|
||||||
|
maxActive: 100
|
||||||
consul:
|
consul:
|
||||||
address: 192.168.3.30:8500
|
address: 192.168.3.30:8500
|
||||||
|
|
||||||
jaeger:
|
jaeger:
|
||||||
addr: 116.204.74.41:4318
|
addr: 192.168.3.30:4318
|
||||||
|
|
||||||
# FFmpeg 配置(视频音频提取)
|
# FFmpeg 配置(视频音频提取)
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
@@ -47,6 +59,9 @@ ffmpeg:
|
|||||||
# 临时文件目录(上传的视频和提取的音频)
|
# 临时文件目录(上传的视频和提取的音频)
|
||||||
temp_dir: "resource/temp"
|
temp_dir: "resource/temp"
|
||||||
|
|
||||||
|
# OSS/MinIO 文件上传配置
|
||||||
|
filePrefix: "http://116.204.74.41:9000"
|
||||||
|
|
||||||
# Whisper 语音识别配置
|
# Whisper 语音识别配置
|
||||||
whisper:
|
whisper:
|
||||||
# whisper 可执行文件路径,留空则自动查找
|
# whisper 可执行文件路径,留空则自动查找
|
||||||
|
|||||||
20
consts/audio/task.go
Normal file
20
consts/audio/task.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
// 任务状态常量
|
||||||
|
const (
|
||||||
|
TaskStatusPending = "pending" // 等待处理
|
||||||
|
TaskStatusRunning = "running" // 处理中
|
||||||
|
TaskStatusSuccess = "success" // 处理成功
|
||||||
|
TaskStatusFailed = "failed" // 处理失败
|
||||||
|
)
|
||||||
|
|
||||||
|
// 输入类型常量
|
||||||
|
const (
|
||||||
|
InputTypeURL = "url" // URL列表
|
||||||
|
)
|
||||||
|
|
||||||
|
// 表名常量
|
||||||
|
const (
|
||||||
|
TranscribeTaskTable = "transcribe_task"
|
||||||
|
TranscribeTaskDetailTable = "transcribe_task_detail"
|
||||||
|
)
|
||||||
@@ -2,105 +2,64 @@ package audio
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"strings"
|
||||||
|
|
||||||
common "media/controller/common"
|
|
||||||
dto "media/model/dto/audio"
|
dto "media/model/dto/audio"
|
||||||
service "media/service/asr"
|
service "media/service/asr"
|
||||||
|
|
||||||
"gitea.com/red-future/common/beans"
|
"gitea.com/red-future/common/beans"
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
"github.com/gogf/gf/v2/net/ghttp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type audio struct{}
|
type audio struct{}
|
||||||
|
|
||||||
var AudioExtract = new(audio)
|
var AudioExtract = new(audio)
|
||||||
|
|
||||||
// safeResult 对外输出的识别结果(隐藏内部路径)
|
// Create 创建语音转文字异步任务 POST /audio/transcribe
|
||||||
type safeResult struct {
|
func (c *audio) Create(ctx context.Context, req *dto.TranscribeReq) (res *dto.CreateTaskRes, err error) {
|
||||||
Text string `json:"text"`
|
ctx = withUser(ctx)
|
||||||
Model string `json:"model"`
|
fileNames := make([]string, len(req.VideoURLs))
|
||||||
Language string `json:"language"`
|
for i, u := range req.VideoURLs {
|
||||||
AudioSize int64 `json:"audioSize"`
|
parts := strings.Split(u, "/")
|
||||||
AudioDuration string `json:"audioDuration"`
|
fileNames[i] = parts[len(parts)-1]
|
||||||
Scenes *dto.SceneSummaryDTO `json:"scenes,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeItem 对外输出的单视频结果
|
g.Log().Infof(ctx, "收到转写请求, 回调URL: %s", req.CallbackURL)
|
||||||
type safeItem struct {
|
|
||||||
FileName string `json:"fileName"`
|
params := &service.CreateTaskParams{
|
||||||
Result *safeResult `json:"result,omitempty"`
|
InputData: req.VideoURLs,
|
||||||
Error string `json:"error,omitempty"`
|
FileNames: fileNames,
|
||||||
|
Model: req.Model,
|
||||||
|
Language: req.Language,
|
||||||
|
Threshold: req.Threshold,
|
||||||
|
CallbackURL: req.CallbackURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranscribeHandler 语音转文字+分镜分析
|
return service.AudioTask.Create(ctx, params)
|
||||||
// 支持两种入参方式:
|
|
||||||
// 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)
|
// GetTask 获取任务详情 GET /audio/task/{taskId}
|
||||||
if svcErr != nil {
|
func (c *audio) GetTask(ctx context.Context, req *dto.GetTaskReq) (res *dto.GetTaskRes, err error) {
|
||||||
r.Response.WriteJson(g.Map{"code": 500, "message": svcErr.Error()})
|
ctx = withUser(ctx)
|
||||||
return
|
return service.AudioTask.GetTask(ctx, req)
|
||||||
}
|
|
||||||
r.Response.WriteJson(g.Map{"code": 200, "message": "success", "data": toSafeItems(res.Results)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件上传模式
|
// GetProgress 获取任务进度 GET /audio/task/{taskId}/progress
|
||||||
savePaths, err := common.SaveUploadedFiles(r)
|
func (c *audio) GetProgress(ctx context.Context, req *dto.GetProgressReq) (res *dto.GetProgressRes, err error) {
|
||||||
if err != nil || len(savePaths) == 0 {
|
ctx = withUser(ctx)
|
||||||
r.Response.WriteJson(g.Map{"code": 400, "message": "请上传视频文件( multipart )或提供 video_urls( JSON )"})
|
return service.AudioTask.GetProgress(ctx, req)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
results := service.VideoTranscribe.TranscribeUpload(ctx, savePaths,
|
// ListTasks 获取任务列表 GET /audio/tasks
|
||||||
r.Get("model", g.Cfg().MustGet(ctx, "whisper.model", "medium").String()).String(),
|
func (c *audio) ListTasks(ctx context.Context, req *dto.ListTaskReq) (res *dto.ListTaskRes, err error) {
|
||||||
r.Get("language", g.Cfg().MustGet(ctx, "whisper.language", "zh").String()).String(),
|
ctx = withUser(ctx)
|
||||||
r.Get("threshold", 0.3).Float64())
|
return service.AudioTask.ListTasks(ctx, req)
|
||||||
|
|
||||||
r.Response.WriteJson(g.Map{"code": 200, "message": "success", "data": toSafeItems(results)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// toSafeItems 将结果转为安全的响应格式(移除 audioPath 等内部路径)
|
// withUser 为 context 注入默认用户(无认证基础设施时使用)
|
||||||
func toSafeItems(results []dto.TranscribeItem) []safeItem {
|
func withUser(ctx context.Context) context.Context {
|
||||||
var items []safeItem
|
if ctx.Value("user") == nil {
|
||||||
for _, item := range results {
|
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1})
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
return ctx
|
||||||
}
|
|
||||||
items = append(items, si)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ func SaveUploadedFiles(r *ghttp.Request) ([]string, error) {
|
|||||||
return saved, nil
|
return saved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveUploadedFilesFromCtx 从请求上下文中获取上传文件并保存
|
||||||
|
func SaveUploadedFilesFromCtx(ctx context.Context) ([]string, error) {
|
||||||
|
r := g.RequestFromCtx(ctx)
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("无法获取请求上下文")
|
||||||
|
}
|
||||||
|
return SaveUploadedFiles(r)
|
||||||
|
}
|
||||||
|
|
||||||
func getTempDir(ctx context.Context) string {
|
func getTempDir(ctx context.Context) string {
|
||||||
tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String()
|
tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String()
|
||||||
if tempDir == "" {
|
if tempDir == "" {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package video
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,39 +17,88 @@ import (
|
|||||||
|
|
||||||
"gitea.com/red-future/common/beans"
|
"gitea.com/red-future/common/beans"
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
"github.com/gogf/gf/v2/net/ghttp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type video struct{}
|
type video struct{}
|
||||||
|
|
||||||
var Concat = new(video)
|
var Concat = new(video)
|
||||||
|
|
||||||
// ConcatVideosHandler 视频拼接
|
// Concat 视频拼接(URL模式) POST /video/concat
|
||||||
// 支持两种入参方式:
|
func (c *video) Concat(ctx context.Context, req *dto.ConcatReq) (res *dto.ConcatRes, err error) {
|
||||||
// 1. JSON body: {"video_urls":[...], "method":"auto"}
|
ctx = withUser(ctx)
|
||||||
// 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 == "" {
|
if req.Method == "" {
|
||||||
req.Method = "auto"
|
req.Method = "auto"
|
||||||
}
|
}
|
||||||
|
|
||||||
tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String()
|
savePaths, err := downloadVideos(ctx, req.VideoURLs)
|
||||||
if !filepath.IsAbs(tempDir) {
|
if err != nil {
|
||||||
absDir, _ := filepath.Abs(tempDir)
|
return nil, err
|
||||||
tempDir = absDir
|
|
||||||
}
|
}
|
||||||
|
defer cleanupConcat(savePaths)
|
||||||
|
|
||||||
|
svcRes, err := service.Concat.Concat(ctx, &service.ConcatReq{
|
||||||
|
VideoPaths: savePaths,
|
||||||
|
Method: req.Method,
|
||||||
|
Upload: req.Upload,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toDTORes(svcRes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConcatUpload 视频拼接(文件上传模式) POST /video/concat/upload
|
||||||
|
func (c *video) ConcatUpload(ctx context.Context, req *dto.ConcatUploadReq) (res *dto.ConcatRes, err error) {
|
||||||
|
ctx = withUser(ctx)
|
||||||
|
savePaths, err := common.SaveUploadedFilesFromCtx(ctx)
|
||||||
|
if err != nil || len(savePaths) < 2 {
|
||||||
|
return nil, fmt.Errorf("至少需要2个视频,当前%d个", len(savePaths))
|
||||||
|
}
|
||||||
|
defer service.CleanupConcat(savePaths)
|
||||||
|
|
||||||
|
if req.Method == "" {
|
||||||
|
req.Method = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
svcRes, err := service.Concat.Concat(ctx, &service.ConcatReq{
|
||||||
|
VideoPaths: savePaths,
|
||||||
|
Method: req.Method,
|
||||||
|
Upload: req.Upload,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toDTORes(svcRes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withUser 为 context 注入默认用户(无认证基础设施时使用)
|
||||||
|
func withUser(ctx context.Context) context.Context {
|
||||||
|
if ctx.Value("user") == nil {
|
||||||
|
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1})
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDTORes 将 Service 内部响应类型转换为 DTO 响应类型
|
||||||
|
func toDTORes(svcRes *service.ConcatRes) *dto.ConcatRes {
|
||||||
|
return &dto.ConcatRes{
|
||||||
|
OutputPath: svcRes.OutputPath,
|
||||||
|
FileSize: svcRes.FileSize,
|
||||||
|
Duration: svcRes.Duration,
|
||||||
|
DurationStr: svcRes.DurationStr,
|
||||||
|
MethodUsed: svcRes.MethodUsed,
|
||||||
|
InputFiles: svcRes.InputFiles,
|
||||||
|
FileURL: svcRes.FileURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadVideos 下载视频URL列表
|
||||||
|
func downloadVideos(ctx context.Context, videoURLs []string) ([]string, error) {
|
||||||
|
tempDir := getTempDir(ctx)
|
||||||
os.MkdirAll(tempDir, 0755)
|
os.MkdirAll(tempDir, 0755)
|
||||||
|
|
||||||
var savePaths []string
|
var savePaths []string
|
||||||
for _, videoURL := range req.VideoURLs {
|
for _, videoURL := range videoURLs {
|
||||||
savePath, dlErr := downloadFromURL(ctx, videoURL, tempDir)
|
savePath, dlErr := downloadFromURL(ctx, videoURL, tempDir)
|
||||||
if dlErr != nil {
|
if dlErr != nil {
|
||||||
continue
|
continue
|
||||||
@@ -58,59 +106,9 @@ func (c *video) ConcatVideosHandler(r *ghttp.Request) {
|
|||||||
savePaths = append(savePaths, savePath)
|
savePaths = append(savePaths, savePath)
|
||||||
}
|
}
|
||||||
if len(savePaths) < 2 {
|
if len(savePaths) < 2 {
|
||||||
cleanupConcat(savePaths)
|
return savePaths, fmt.Errorf("成功下载的视频不足2个")
|
||||||
r.Response.WriteJson(g.Map{"code": 400, "message": "成功下载的视频不足2个"})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return savePaths, nil
|
||||||
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) {
|
func downloadFromURL(ctx context.Context, rawURL, tempDir string) (string, error) {
|
||||||
@@ -154,3 +152,15 @@ func cleanupConcat(paths []string) {
|
|||||||
os.Remove(p)
|
os.Remove(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
188
dao/audio/transcribe_task_dao.go
Normal file
188
dao/audio/transcribe_task_dao.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
consts "media/consts/audio"
|
||||||
|
dto "media/model/dto/audio"
|
||||||
|
entity "media/model/entity/audio"
|
||||||
|
|
||||||
|
"gitea.com/red-future/common/db/gfdb"
|
||||||
|
"github.com/gogf/gf/v2/database/gdb"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/util/gconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TranscribeTask = new(transcribeTaskDao)
|
||||||
|
|
||||||
|
type transcribeTaskDao struct{}
|
||||||
|
|
||||||
|
// Insert 创建任务
|
||||||
|
func (d *transcribeTaskDao) Insert(ctx context.Context, data *entity.TranscribeTask) (id int64, err error) {
|
||||||
|
// FieldsEx 排除空 result 字段(JSONB 列不支持空串 '')
|
||||||
|
r, err := gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskTable).
|
||||||
|
Data(data).
|
||||||
|
FieldsEx(entity.TranscribeTaskCols.Result).
|
||||||
|
Insert()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByTaskID 根据taskId获取任务
|
||||||
|
func (d *transcribeTaskDao) GetByTaskID(ctx context.Context, taskID string) (res *entity.TranscribeTask, err error) {
|
||||||
|
r, err := gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskTable).
|
||||||
|
Where(entity.TranscribeTaskCols.TaskID, taskID).
|
||||||
|
One()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
err = r.Struct(&res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgress 更新任务进度
|
||||||
|
func (d *transcribeTaskDao) UpdateProgress(ctx context.Context, taskID string, progress int) (rows int64, err error) {
|
||||||
|
r, err := gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskTable).
|
||||||
|
Data(g.Map{
|
||||||
|
entity.TranscribeTaskCols.Progress: progress,
|
||||||
|
}).
|
||||||
|
Where(entity.TranscribeTaskCols.TaskID, taskID).
|
||||||
|
Update()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaskRunning 将任务更新为运行中
|
||||||
|
func (d *transcribeTaskDao) UpdateTaskRunning(ctx context.Context, taskID string, progress int) (rows int64, err error) {
|
||||||
|
r, err := gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskTable).
|
||||||
|
Data(g.Map{
|
||||||
|
entity.TranscribeTaskCols.Status: consts.TaskStatusRunning,
|
||||||
|
entity.TranscribeTaskCols.Progress: progress,
|
||||||
|
}).
|
||||||
|
Where(entity.TranscribeTaskCols.TaskID, taskID).
|
||||||
|
Update()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateResult 更新任务成功状态(result: 完整结果JSON)
|
||||||
|
func (d *transcribeTaskDao) UpdateResult(ctx context.Context, taskID, result string, successCount, failCount int) (rows int64, err error) {
|
||||||
|
r, err := gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskTable).
|
||||||
|
Data(g.Map{
|
||||||
|
entity.TranscribeTaskCols.Status: consts.TaskStatusSuccess,
|
||||||
|
entity.TranscribeTaskCols.Progress: 100,
|
||||||
|
entity.TranscribeTaskCols.Result: result,
|
||||||
|
entity.TranscribeTaskCols.SuccessFiles: successCount,
|
||||||
|
entity.TranscribeTaskCols.FailFiles: failCount,
|
||||||
|
entity.TranscribeTaskCols.ErrorMessage: "",
|
||||||
|
}).
|
||||||
|
Where(entity.TranscribeTaskCols.TaskID, taskID).
|
||||||
|
Update()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateError 更新任务错误(失败后)
|
||||||
|
func (d *transcribeTaskDao) UpdateError(ctx context.Context, taskID string, errMsg string) (rows int64, err error) {
|
||||||
|
r, err := gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskTable).
|
||||||
|
Data(g.Map{
|
||||||
|
entity.TranscribeTaskCols.Status: consts.TaskStatusFailed,
|
||||||
|
entity.TranscribeTaskCols.ErrorMessage: errMsg,
|
||||||
|
}).
|
||||||
|
Where(entity.TranscribeTaskCols.TaskID, taskID).
|
||||||
|
Update()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 获取任务列表
|
||||||
|
func (d *transcribeTaskDao) List(ctx context.Context, req *dto.ListTaskReq) (res []entity.TranscribeTask, total int, err error) {
|
||||||
|
model := d.buildListFilter(ctx, req)
|
||||||
|
model.OrderDesc(entity.TranscribeTaskCols.CreatedAt)
|
||||||
|
if req.Page != nil {
|
||||||
|
model.Page(int(req.Page.PageNum), int(req.Page.PageSize))
|
||||||
|
}
|
||||||
|
r, total, err := model.AllAndCount(false)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = r.Structs(&res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildListFilter 构建列表过滤
|
||||||
|
func (d *transcribeTaskDao) buildListFilter(ctx context.Context, req *dto.ListTaskReq) *gdb.Model {
|
||||||
|
model := gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskTable).Model
|
||||||
|
model.Where(entity.TranscribeTaskCols.Status, req.Status)
|
||||||
|
model.OmitEmptyWhere()
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntityToItem 将实体转为DTO项
|
||||||
|
// 注意: 不返回 result 字段(数据在 detailList 结构化返回中,避免与 result JSON 重复)
|
||||||
|
//
|
||||||
|
// result 仅在回调通知时直接使用 task.Result
|
||||||
|
func EntityToItem(e *entity.TranscribeTask) dto.TranscribeTaskItem {
|
||||||
|
item := dto.TranscribeTaskItem{
|
||||||
|
ID: e.Id,
|
||||||
|
TaskID: e.TaskID,
|
||||||
|
Status: e.Status,
|
||||||
|
Progress: e.Progress,
|
||||||
|
TotalFiles: e.TotalFiles,
|
||||||
|
SuccessFiles: e.SuccessFiles,
|
||||||
|
FailFiles: e.FailFiles,
|
||||||
|
Model: e.Model,
|
||||||
|
Language: e.Language,
|
||||||
|
Threshold: e.Threshold,
|
||||||
|
InputType: e.InputType,
|
||||||
|
InputData: e.InputData,
|
||||||
|
FileNames: e.FileNames,
|
||||||
|
CallbackURL: e.CallbackURL,
|
||||||
|
ErrorMessage: e.ErrorMessage,
|
||||||
|
}
|
||||||
|
if e.CreatedAt != nil {
|
||||||
|
item.CreatedAt = gconv.Int64(e.CreatedAt.Timestamp())
|
||||||
|
}
|
||||||
|
if e.UpdatedAt != nil {
|
||||||
|
item.UpdatedAt = gconv.Int64(e.UpdatedAt.Timestamp())
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntityToProgress 将实体转为进度DTO
|
||||||
|
func EntityToProgress(e *entity.TranscribeTask) dto.GetProgressRes {
|
||||||
|
return dto.GetProgressRes{
|
||||||
|
TaskID: e.TaskID,
|
||||||
|
Status: e.Status,
|
||||||
|
Progress: e.Progress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailEntityToItem 将明细实体转为DTO项
|
||||||
|
func DetailEntityToItem(e *entity.TranscribeTaskDetail) dto.TranscribeTaskDetailItem {
|
||||||
|
return dto.TranscribeTaskDetailItem{
|
||||||
|
ID: e.Id,
|
||||||
|
TaskID: e.TaskID,
|
||||||
|
FileIndex: e.FileIndex,
|
||||||
|
FileName: e.FileName,
|
||||||
|
TranscribedText: e.TranscribedText,
|
||||||
|
Scenes: e.Scenes,
|
||||||
|
AudioSize: e.AudioSize,
|
||||||
|
AudioDuration: e.AudioDuration,
|
||||||
|
Model: e.Model,
|
||||||
|
Language: e.Language,
|
||||||
|
ErrorMessage: e.ErrorMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
35
dao/audio/transcribe_task_detail_dao.go
Normal file
35
dao/audio/transcribe_task_detail_dao.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
consts "media/consts/audio"
|
||||||
|
entity "media/model/entity/audio"
|
||||||
|
|
||||||
|
"gitea.com/red-future/common/db/gfdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TranscribeTaskDetail = new(transcribeTaskDetailDao)
|
||||||
|
|
||||||
|
type transcribeTaskDetailDao struct{}
|
||||||
|
|
||||||
|
// Insert 插入明细
|
||||||
|
func (d *transcribeTaskDetailDao) Insert(ctx context.Context, data *entity.TranscribeTaskDetail) (id int64, err error) {
|
||||||
|
// FieldsEx 排除空 scenes 字段(JSONB 列不支持空串 '')
|
||||||
|
r, err := gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskDetailTable).
|
||||||
|
Data(data).
|
||||||
|
FieldsEx(entity.TranscribeTaskDetailCols.Scenes).
|
||||||
|
Insert()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByTaskID 根据taskId查询明细列表(按file_index升序)
|
||||||
|
func (d *transcribeTaskDetailDao) ListByTaskID(ctx context.Context, taskID string) (res []entity.TranscribeTaskDetail, err error) {
|
||||||
|
err = gfdb.DB(ctx).Model(ctx, consts.TranscribeTaskDetailTable).
|
||||||
|
Where(entity.TranscribeTaskDetailCols.TaskID, taskID).
|
||||||
|
OrderAsc(entity.TranscribeTaskDetailCols.FileIndex).
|
||||||
|
Scan(&res)
|
||||||
|
return
|
||||||
|
}
|
||||||
60
main.go
60
main.go
@@ -4,70 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
controllerAudio "media/controller/audio"
|
controllerAudio "media/controller/audio"
|
||||||
controllerVideo "media/controller/video"
|
controllerVideo "media/controller/video"
|
||||||
serviceSetup "media/service/setup"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
_ "gitea.com/red-future/common/consul"
|
_ "gitea.com/red-future/common/consul"
|
||||||
"gitea.com/red-future/common/http"
|
"gitea.com/red-future/common/http"
|
||||||
"gitea.com/red-future/common/jaeger"
|
"gitea.com/red-future/common/jaeger"
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
defer jaeger.ShutDown(ctx)
|
defer jaeger.ShutDown(ctx)
|
||||||
|
|
||||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
http.RouteRegister([]interface{}{
|
||||||
if err == nil {
|
controllerAudio.AudioExtract,
|
||||||
time.Local = loc
|
controllerVideo.Concat,
|
||||||
}
|
})
|
||||||
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 {}
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package audio
|
package audio
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
// TranscribeReq 语音转文字请求(JSON body / URL 方式)
|
// TranscribeReq 语音转文字请求(JSON body / URL 方式)
|
||||||
type TranscribeReq struct {
|
type TranscribeReq struct {
|
||||||
|
g.Meta `path:"/transcribe" method:"post" tags:"音频转写" summary:"语音转文字(异步)" dc:"创建异步语音转文字任务,返回taskId"`
|
||||||
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表"`
|
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表"`
|
||||||
Model string `json:"model" dc:"whisper模型(tiny/base/small/medium)" d:"medium"`
|
Model string `json:"model" dc:"whisper模型(tiny/base/small/medium)" d:"medium"`
|
||||||
Language string `json:"language" dc:"语言(zh/en/ja)" d:"zh"`
|
Language string `json:"language" dc:"语言(zh/en/ja)" d:"zh"`
|
||||||
Threshold float64 `json:"threshold" dc:"场景检测阈值(0.1-0.5)" d:"0.3"`
|
Threshold float64 `json:"threshold" dc:"场景检测阈值(0.1-0.5)" d:"0.3"`
|
||||||
|
CallbackURL string `json:"callback_url" dc:"任务完成后的回调地址(可选),成功后POST结果到此URL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranscribeRes 语音转文字响应
|
// TranscribeRes 语音转文字响应
|
||||||
|
|||||||
130
model/dto/audio/task_dto.go
Normal file
130
model/dto/audio/task_dto.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.com/red-future/common/beans"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateTaskRes 创建任务响应
|
||||||
|
type CreateTaskRes struct {
|
||||||
|
TaskID string `json:"taskId" dc:"任务ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 获取任务详情 ----------
|
||||||
|
|
||||||
|
// GetTaskReq 获取任务详情请求
|
||||||
|
type GetTaskReq struct {
|
||||||
|
g.Meta `path:"/{taskId}" method:"get" tags:"音频转写" summary:"查询任务详情" dc:"根据taskId查询任务详情和明细"`
|
||||||
|
TaskID string `json:"taskId" dc:"任务ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskRes 获取任务详情响应
|
||||||
|
type GetTaskRes struct {
|
||||||
|
TaskInfo TranscribeTaskItem `json:"taskInfo" dc:"任务信息"`
|
||||||
|
DetailList []TranscribeTaskDetailItem `json:"detailList" dc:"明细列表(每视频一条)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscribeTaskItem 任务批次项
|
||||||
|
type TranscribeTaskItem struct {
|
||||||
|
ID int64 `json:"id,string" dc:"数据库ID"`
|
||||||
|
TaskID string `json:"taskId" dc:"任务ID"`
|
||||||
|
Status string `json:"status" dc:"任务状态"`
|
||||||
|
Progress int `json:"progress" dc:"进度0-100"`
|
||||||
|
TotalFiles int `json:"totalFiles" dc:"文件总数"`
|
||||||
|
SuccessFiles int `json:"successFiles" dc:"成功文件数"`
|
||||||
|
FailFiles int `json:"failFiles" dc:"失败文件数"`
|
||||||
|
Model string `json:"model" dc:"whisper模型"`
|
||||||
|
Language string `json:"language" dc:"语言"`
|
||||||
|
Threshold float64 `json:"threshold" dc:"场景检测阈值"`
|
||||||
|
InputType string `json:"inputType" dc:"输入类型"`
|
||||||
|
InputData string `json:"inputData" dc:"输入数据"`
|
||||||
|
FileNames string `json:"fileNames" dc:"文件名列表"`
|
||||||
|
CallbackURL string `json:"callbackUrl" dc:"回调地址"`
|
||||||
|
Result string `json:"result,omitempty" dc:"完整的处理结果JSON(成功后返回)"`
|
||||||
|
ErrorMessage string `json:"errorMessage" dc:"错误信息(失败后返回)"`
|
||||||
|
CreatedAt int64 `json:"createdAt" dc:"创建时间戳"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" dc:"更新时间戳"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscribeTaskDetailItem 任务明细项(每视频)
|
||||||
|
type TranscribeTaskDetailItem struct {
|
||||||
|
ID int64 `json:"id,string" dc:"明细ID"`
|
||||||
|
TaskID string `json:"taskId" dc:"任务ID"`
|
||||||
|
FileIndex int `json:"fileIndex" dc:"文件序号"`
|
||||||
|
FileName string `json:"fileName" dc:"文件名"`
|
||||||
|
TranscribedText string `json:"transcribedText" dc:"语音识别文字"`
|
||||||
|
Scenes string `json:"scenes" dc:"分镜分析JSON"`
|
||||||
|
AudioSize int64 `json:"audioSize" dc:"音频文件大小"`
|
||||||
|
AudioDuration string `json:"audioDuration" dc:"音频时长"`
|
||||||
|
Model string `json:"model" dc:"whisper模型"`
|
||||||
|
Language string `json:"language" dc:"语言代码"`
|
||||||
|
ErrorMessage string `json:"errorMessage" dc:"错误信息"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 获取任务进度 ----------
|
||||||
|
|
||||||
|
// GetProgressReq 获取任务进度请求
|
||||||
|
type GetProgressReq struct {
|
||||||
|
g.Meta `path:"/{taskId}/progress" method:"get" tags:"音频转写" summary:"查询任务进度" dc:"查询任务的当前处理进度"`
|
||||||
|
TaskID string `json:"taskId" dc:"任务ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProgressRes 获取任务进度响应
|
||||||
|
type GetProgressRes struct {
|
||||||
|
TaskID string `json:"taskId" dc:"任务ID"`
|
||||||
|
Status string `json:"status" dc:"任务状态"`
|
||||||
|
Progress int `json:"progress" dc:"进度0-100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 任务列表 ----------
|
||||||
|
|
||||||
|
// ListTaskReq 获取任务列表请求
|
||||||
|
type ListTaskReq struct {
|
||||||
|
g.Meta `path:"/tasks" method:"get" tags:"音频转写" summary:"查询任务列表" dc:"分页查询任务列表,可按状态筛选"`
|
||||||
|
*beans.Page
|
||||||
|
Status string `json:"status" dc:"按状态筛选"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTaskRes 获取任务列表响应
|
||||||
|
type ListTaskRes struct {
|
||||||
|
List []TranscribeTaskItem `json:"list" dc:"任务列表"`
|
||||||
|
Total int `json:"total" dc:"总数"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 回调通知结构 ----------
|
||||||
|
|
||||||
|
// CallbackPayload 回调通知内容
|
||||||
|
type CallbackPayload struct {
|
||||||
|
TaskID string `json:"taskId" dc:"任务ID"`
|
||||||
|
Status string `json:"status" dc:"任务状态"`
|
||||||
|
TotalFiles int `json:"totalFiles" dc:"文件总数"`
|
||||||
|
SuccessFiles int `json:"successFiles" dc:"成功文件数"`
|
||||||
|
FailFiles int `json:"failFiles" dc:"失败文件数"`
|
||||||
|
Result string `json:"result,omitempty" dc:"完整的处理结果JSON"`
|
||||||
|
ErrorMessage string `json:"errorMessage,omitempty" dc:"错误信息"`
|
||||||
|
DetailList []TranscribeTaskDetailItem `json:"detailList" dc:"明细列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 任务处理结果结构(用于result JSONB) ----------
|
||||||
|
|
||||||
|
// TaskResult 单任务处理结果
|
||||||
|
type TaskResult struct {
|
||||||
|
Results []TaskResultItem `json:"results" dc:"处理结果列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskResultItem 单视频处理结果
|
||||||
|
type TaskResultItem struct {
|
||||||
|
FileName string `json:"fileName" dc:"文件名"`
|
||||||
|
Result *TaskResultDTO `json:"result,omitempty" dc:"识别结果"`
|
||||||
|
Error string `json:"error,omitempty" dc:"错误信息"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskResultDTO 识别结果详情(对外输出,隐藏内部路径)
|
||||||
|
type TaskResultDTO struct {
|
||||||
|
Text string `json:"text" dc:"识别文本"`
|
||||||
|
Model string `json:"model" dc:"使用的模型"`
|
||||||
|
Language string `json:"language" dc:"语言"`
|
||||||
|
AudioSize int64 `json:"audioSize" dc:"音频文件大小(字节)"`
|
||||||
|
AudioDuration string `json:"audioDuration" dc:"音频时长"`
|
||||||
|
Scenes *SceneSummaryDTO `json:"scenes,omitempty" dc:"分镜分析"`
|
||||||
|
}
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
// ConcatReq 视频拼接请求(JSON body / URL 方式)
|
// ConcatReq 视频拼接请求(JSON body / URL 方式)
|
||||||
type ConcatReq struct {
|
type ConcatReq struct {
|
||||||
|
g.Meta `path:"/concat" method:"post" tags:"视频拼接" summary:"视频拼接(URL模式)" dc:"从视频URL下载并拼接"`
|
||||||
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表(按此顺序拼接)"`
|
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表(按此顺序拼接)"`
|
||||||
Method string `json:"method" dc:"拼接方式(auto/fast/reencode)" d:"auto"`
|
Method string `json:"method" dc:"拼接方式(auto/fast/reencode)" d:"auto"`
|
||||||
|
Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConcatUploadReq 视频拼接请求(文件上传模式)
|
||||||
|
type ConcatUploadReq struct {
|
||||||
|
g.Meta `path:"/concat/upload" method:"post" tags:"视频拼接" summary:"视频拼接(文件上传)" dc:"上传视频文件并拼接(至少2个视频)"`
|
||||||
|
Method string `json:"method" dc:"拼接方式(auto/fast/reencode)" d:"auto"`
|
||||||
|
Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConcatRes 视频拼接响应
|
// ConcatRes 视频拼接响应
|
||||||
@@ -14,4 +25,21 @@ type ConcatRes struct {
|
|||||||
DurationStr string `json:"durationStr" dc:"可读时长"`
|
DurationStr string `json:"durationStr" dc:"可读时长"`
|
||||||
MethodUsed string `json:"methodUsed" dc:"实际使用的拼接方式"`
|
MethodUsed string `json:"methodUsed" dc:"实际使用的拼接方式"`
|
||||||
InputFiles int `json:"inputFiles" dc:"输入文件数"`
|
InputFiles int `json:"inputFiles" dc:"输入文件数"`
|
||||||
|
FileURL string `json:"fileURL" dc:"MinIO访问地址(上传后返回)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFileBytesReq 上传文件请求(字节流)
|
||||||
|
type UploadFileBytesReq struct {
|
||||||
|
FileName string `json:"fileName" dc:"文件名"`
|
||||||
|
FileBytes []byte `json:"fileBytes" dc:"文件字节流"`
|
||||||
|
FileStoreURL string `json:"fileStoreURL" dc:"文件存储路径"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFileBytesRes 上传文件响应
|
||||||
|
type UploadFileBytesRes struct {
|
||||||
|
FileURL string `json:"fileURL" dc:"上传地址"`
|
||||||
|
FileSize int `json:"fileSize" dc:"文件大小"`
|
||||||
|
FileName string `json:"fileName" dc:"文件名称"`
|
||||||
|
FileFormat string `json:"fileFormat" dc:"文件格式"`
|
||||||
|
FileAddressPrefix string `json:"fileAddressPrefix"`
|
||||||
}
|
}
|
||||||
|
|||||||
63
model/entity/audio/transcribe_task.go
Normal file
63
model/entity/audio/transcribe_task.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "gitea.com/red-future/common/beans"
|
||||||
|
|
||||||
|
// TranscribeTask 语音转文字任务批次头实体
|
||||||
|
type TranscribeTask struct {
|
||||||
|
beans.SQLBaseDO `orm:",inherit"`
|
||||||
|
TaskID string `orm:"task_id" json:"taskId" description:"任务批次唯一标识"`
|
||||||
|
Status string `orm:"status" json:"status" description:"任务状态:pending/running/success/failed"`
|
||||||
|
Progress int `orm:"progress" json:"progress" description:"进度0-100"`
|
||||||
|
TotalFiles int `orm:"total_files" json:"totalFiles" description:"文件总数"`
|
||||||
|
SuccessFiles int `orm:"success_files" json:"successFiles" description:"成功文件数"`
|
||||||
|
FailFiles int `orm:"fail_files" json:"failFiles" description:"失败文件数"`
|
||||||
|
Model string `orm:"model" json:"model" description:"whisper模型"`
|
||||||
|
Language string `orm:"language" json:"language" description:"语言"`
|
||||||
|
Threshold float64 `orm:"threshold" json:"threshold" description:"场景检测阈值"`
|
||||||
|
InputType string `orm:"input_type" json:"inputType" description:"输入类型:upload/url"`
|
||||||
|
InputData string `orm:"input_data" json:"inputData" description:"输入数据(文件路径/URL列表JSON)"`
|
||||||
|
FileNames string `orm:"file_names" json:"fileNames" description:"文件名列表JSON"`
|
||||||
|
CallbackURL string `orm:"callback_url" json:"callbackUrl" description:"任务完成后的回调地址"`
|
||||||
|
Result string `orm:"result" json:"result" description:"完整的处理结果JSON"`
|
||||||
|
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscribeTaskCol 字段定义
|
||||||
|
type TranscribeTaskCol struct {
|
||||||
|
beans.SQLBaseCol
|
||||||
|
TaskID string
|
||||||
|
Status string
|
||||||
|
Progress string
|
||||||
|
TotalFiles string
|
||||||
|
SuccessFiles string
|
||||||
|
FailFiles string
|
||||||
|
Model string
|
||||||
|
Language string
|
||||||
|
Threshold string
|
||||||
|
InputType string
|
||||||
|
InputData string
|
||||||
|
FileNames string
|
||||||
|
CallbackURL string
|
||||||
|
Result string
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscribeTaskCols 字段常量
|
||||||
|
var TranscribeTaskCols = TranscribeTaskCol{
|
||||||
|
SQLBaseCol: beans.DefSQLBaseCol,
|
||||||
|
TaskID: "task_id",
|
||||||
|
Status: "status",
|
||||||
|
Progress: "progress",
|
||||||
|
TotalFiles: "total_files",
|
||||||
|
SuccessFiles: "success_files",
|
||||||
|
FailFiles: "fail_files",
|
||||||
|
Model: "model",
|
||||||
|
Language: "language",
|
||||||
|
Threshold: "threshold",
|
||||||
|
InputType: "input_type",
|
||||||
|
InputData: "input_data",
|
||||||
|
FileNames: "file_names",
|
||||||
|
CallbackURL: "callback_url",
|
||||||
|
Result: "result",
|
||||||
|
ErrorMessage: "error_message",
|
||||||
|
}
|
||||||
48
model/entity/audio/transcribe_task_detail.go
Normal file
48
model/entity/audio/transcribe_task_detail.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "gitea.com/red-future/common/beans"
|
||||||
|
|
||||||
|
// TranscribeTaskDetail 语音转文字任务明细实体(每视频一条)
|
||||||
|
type TranscribeTaskDetail struct {
|
||||||
|
beans.SQLBaseDO `orm:",inherit"`
|
||||||
|
TaskID string `orm:"task_id" json:"taskId" description:"所属任务批次ID"`
|
||||||
|
FileIndex int `orm:"file_index" json:"fileIndex" description:"文件序号(从0开始)"`
|
||||||
|
FileName string `orm:"file_name" json:"fileName" description:"文件名"`
|
||||||
|
TranscribedText string `orm:"transcribed_text" json:"transcribedText" description:"语音识别文字"`
|
||||||
|
Scenes string `orm:"scenes" json:"scenes" description:"分镜分析JSON"`
|
||||||
|
AudioSize int64 `orm:"audio_size" json:"audioSize" description:"音频文件大小(字节)"`
|
||||||
|
AudioDuration string `orm:"audio_duration" json:"audioDuration" description:"音频时长"`
|
||||||
|
Model string `orm:"model" json:"model" description:"whisper模型"`
|
||||||
|
Language string `orm:"language" json:"language" description:"语言代码"`
|
||||||
|
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscribeTaskDetailCol 字段定义
|
||||||
|
type TranscribeTaskDetailCol struct {
|
||||||
|
beans.SQLBaseCol
|
||||||
|
TaskID string
|
||||||
|
FileIndex string
|
||||||
|
FileName string
|
||||||
|
TranscribedText string
|
||||||
|
Scenes string
|
||||||
|
AudioSize string
|
||||||
|
AudioDuration string
|
||||||
|
Model string
|
||||||
|
Language string
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscribeTaskDetailCols 字段常量
|
||||||
|
var TranscribeTaskDetailCols = TranscribeTaskDetailCol{
|
||||||
|
SQLBaseCol: beans.DefSQLBaseCol,
|
||||||
|
TaskID: "task_id",
|
||||||
|
FileIndex: "file_index",
|
||||||
|
FileName: "file_name",
|
||||||
|
TranscribedText: "transcribed_text",
|
||||||
|
Scenes: "scenes",
|
||||||
|
AudioSize: "audio_size",
|
||||||
|
AudioDuration: "audio_duration",
|
||||||
|
Model: "model",
|
||||||
|
Language: "language",
|
||||||
|
ErrorMessage: "error_message",
|
||||||
|
}
|
||||||
449
service/asr/task_service.go
Normal file
449
service/asr/task_service.go
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
package asr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
consts "media/consts/audio"
|
||||||
|
dao "media/dao/audio"
|
||||||
|
dto "media/model/dto/audio"
|
||||||
|
entity "media/model/entity/audio"
|
||||||
|
serviceScene "media/service/scene"
|
||||||
|
|
||||||
|
"gitea.com/red-future/common/beans"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/util/gconv"
|
||||||
|
"github.com/gogf/gf/v2/util/guid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AudioTask = new(audioTaskService)
|
||||||
|
|
||||||
|
type audioTaskService struct{}
|
||||||
|
|
||||||
|
// CreateTaskParams 创建任务参数
|
||||||
|
type CreateTaskParams struct {
|
||||||
|
InputData []string // URL列表
|
||||||
|
FileNames []string // 文件名列表
|
||||||
|
Model string
|
||||||
|
Language string
|
||||||
|
Threshold float64
|
||||||
|
CallbackURL string // 任务完成回调地址(完整URL,含ip+端口+路径)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建转写任务并立即返回taskId
|
||||||
|
func (s *audioTaskService) Create(ctx context.Context, params *CreateTaskParams) (res *dto.CreateTaskRes, err error) {
|
||||||
|
taskID := "tsk_" + guid.S()
|
||||||
|
|
||||||
|
if params.Model == "" {
|
||||||
|
params.Model = g.Cfg().MustGet(ctx, "whisper.model", "medium").String()
|
||||||
|
}
|
||||||
|
if params.Language == "" {
|
||||||
|
params.Language = g.Cfg().MustGet(ctx, "whisper.language", "zh").String()
|
||||||
|
}
|
||||||
|
if params.Threshold <= 0 {
|
||||||
|
params.Threshold = 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
inputBytes, _ := json.Marshal(params.InputData)
|
||||||
|
fnBytes, _ := json.Marshal(params.FileNames)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
task := &entity.TranscribeTask{
|
||||||
|
TaskID: taskID,
|
||||||
|
Status: consts.TaskStatusPending,
|
||||||
|
Progress: 0,
|
||||||
|
TotalFiles: len(params.InputData),
|
||||||
|
InputType: consts.InputTypeURL,
|
||||||
|
Model: params.Model,
|
||||||
|
Language: params.Language,
|
||||||
|
Threshold: params.Threshold,
|
||||||
|
InputData: string(inputBytes),
|
||||||
|
FileNames: string(fnBytes),
|
||||||
|
CallbackURL: params.CallbackURL,
|
||||||
|
}
|
||||||
|
task.CreatedAt = gconv.GTime(now)
|
||||||
|
task.UpdatedAt = gconv.GTime(now)
|
||||||
|
|
||||||
|
if _, daoErr := dao.TranscribeTask.Insert(ctx, task); daoErr != nil {
|
||||||
|
return nil, fmt.Errorf("创建任务失败: %v", daoErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "[创建任务 %s] 文件数=%d, 模型=%s, 语言=%s, 回调=%s",
|
||||||
|
taskID, len(params.InputData), params.Model, params.Language, params.CallbackURL)
|
||||||
|
|
||||||
|
// 异步处理
|
||||||
|
go s.processTask(taskID, params.InputData, params.Model, params.Language, params.Threshold, params.CallbackURL)
|
||||||
|
|
||||||
|
return &dto.CreateTaskRes{TaskID: taskID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processTask 异步处理所有URL,每个文件生成一条明细
|
||||||
|
func (s *audioTaskService) processTask(taskID string, urls []string, model, language string, threshold float64, callbackURL string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1})
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
errMsg := fmt.Sprintf("任务处理异常: %v", r)
|
||||||
|
g.Log().Errorf(ctx, "[任务 %s] %s, 将通过回调通知调用方", taskID, errMsg)
|
||||||
|
dao.TranscribeTask.UpdateError(ctx, taskID, errMsg)
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 触发失败回调(panic恢复)", taskID)
|
||||||
|
s.callback(ctx, taskID, consts.TaskStatusFailed, errMsg, callbackURL)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 开始处理, 共%d个URL, 回调地址=%s", taskID, len(urls), callbackURL)
|
||||||
|
dao.TranscribeTask.UpdateTaskRunning(ctx, taskID, 5)
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 状态已更新为 running, 进度=5", taskID)
|
||||||
|
|
||||||
|
tempDir := getTempDir(ctx)
|
||||||
|
os.MkdirAll(tempDir, 0755)
|
||||||
|
|
||||||
|
var results []dto.TranscribeItem
|
||||||
|
successCount, failCount := 0, 0
|
||||||
|
|
||||||
|
total := len(urls)
|
||||||
|
for i, videoURL := range urls {
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 下载 %d/%d: %s", taskID, i+1, total, videoURL)
|
||||||
|
|
||||||
|
progress := 10 + (i*70)/total
|
||||||
|
dao.TranscribeTask.UpdateProgress(ctx, taskID, progress)
|
||||||
|
g.Log().Debugf(ctx, "[任务 %s] 进度更新: %d/%d → %d%%", taskID, i+1, total, progress)
|
||||||
|
|
||||||
|
savePath, dlErr := downloadFromURL(ctx, videoURL, tempDir)
|
||||||
|
if dlErr != nil {
|
||||||
|
g.Log().Warningf(ctx, "[任务 %s] 文件%d/%d 下载失败: %v", taskID, i+1, total, dlErr)
|
||||||
|
s.saveDetail(ctx, taskID, i, fmt.Sprintf("url_%d.mp4", i+1),
|
||||||
|
"", "", 0, "", model, language, dlErr.Error())
|
||||||
|
results = append(results, dto.TranscribeItem{
|
||||||
|
FileName: fmt.Sprintf("url_%d.mp4", i+1),
|
||||||
|
Error: dlErr.Error(),
|
||||||
|
})
|
||||||
|
failCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := filepath.Base(savePath)
|
||||||
|
result := s.processSingleVideo(ctx, taskID, savePath, i, fileName, model, language, threshold)
|
||||||
|
results = append(results, *result)
|
||||||
|
if result.Error != "" {
|
||||||
|
g.Log().Warningf(ctx, "[任务 %s] 文件%d/%d 处理失败: %s - %s", taskID, i+1, total, fileName, result.Error)
|
||||||
|
failCount++
|
||||||
|
} else {
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 文件%d/%d 处理成功: %s", taskID, i+1, total, fileName)
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 所有文件处理完毕, 成功=%d 失败=%d, 开始构建结果JSON", taskID, successCount, failCount)
|
||||||
|
|
||||||
|
// 构建完整结果JSON
|
||||||
|
progress := 95
|
||||||
|
dao.TranscribeTask.UpdateProgress(ctx, taskID, progress)
|
||||||
|
g.Log().Debugf(ctx, "[任务 %s] 进度更新: 95%% (结果构建中)", taskID)
|
||||||
|
|
||||||
|
resultObj := dto.TaskResult{Results: make([]dto.TaskResultItem, len(results))}
|
||||||
|
for i, item := range results {
|
||||||
|
itemDTO := dto.TaskResultItem{
|
||||||
|
FileName: item.FileName,
|
||||||
|
Error: item.Error,
|
||||||
|
}
|
||||||
|
if item.Result != nil {
|
||||||
|
if r, ok := item.Result.(*dto.TranscribeResult); ok {
|
||||||
|
itemDTO.Result = &dto.TaskResultDTO{
|
||||||
|
Text: r.Text,
|
||||||
|
Model: r.Model,
|
||||||
|
Language: r.Language,
|
||||||
|
AudioSize: r.AudioSize,
|
||||||
|
AudioDuration: r.AudioDuration,
|
||||||
|
Scenes: r.Scenes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resultObj.Results[i] = itemDTO
|
||||||
|
}
|
||||||
|
|
||||||
|
resultJSON, marshalErr := json.Marshal(resultObj)
|
||||||
|
if marshalErr != nil {
|
||||||
|
errMsg := "结果序列化失败: " + marshalErr.Error()
|
||||||
|
g.Log().Errorf(ctx, "[任务 %s] %s", taskID, errMsg)
|
||||||
|
dao.TranscribeTask.UpdateError(ctx, taskID, errMsg)
|
||||||
|
s.callback(ctx, taskID, consts.TaskStatusFailed, errMsg, callbackURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultSize := len(resultJSON)
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 结果JSON序列化完成, 大小=%d字节", taskID, resultSize)
|
||||||
|
|
||||||
|
if _, err := dao.TranscribeTask.UpdateResult(ctx, taskID, string(resultJSON), successCount, failCount); err != nil {
|
||||||
|
g.Log().Errorf(ctx, "[任务 %s] 保存结果失败: %v, 通过回调发送结果", taskID, err)
|
||||||
|
s.callback(ctx, taskID, consts.TaskStatusFailed, fmt.Sprintf("保存结果失败: %v", err), callbackURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 结果已入库, 成功=%d 失败=%d, 触发成功回调", taskID, successCount, failCount)
|
||||||
|
|
||||||
|
if callbackURL != "" {
|
||||||
|
s.callback(ctx, taskID, consts.TaskStatusSuccess, "", callbackURL)
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 全部处理流程结束", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callback 向回调地址 POST 任务结果
|
||||||
|
func (s *audioTaskService) callback(ctx context.Context, taskID, status, errMsg, callbackURL string) {
|
||||||
|
if callbackURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, _ := dao.TranscribeTask.GetByTaskID(ctx, taskID)
|
||||||
|
if task == nil {
|
||||||
|
g.Log().Errorf(ctx, "[回调 %s] 任务不存在", taskID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailList, _ := dao.TranscribeTaskDetail.ListByTaskID(ctx, taskID)
|
||||||
|
detailItems := make([]dto.TranscribeTaskDetailItem, 0, len(detailList))
|
||||||
|
for i := range detailList {
|
||||||
|
detailItems = append(detailItems, dao.DetailEntityToItem(&detailList[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := dto.CallbackPayload{
|
||||||
|
TaskID: taskID,
|
||||||
|
Status: status,
|
||||||
|
TotalFiles: task.TotalFiles,
|
||||||
|
SuccessFiles: task.SuccessFiles,
|
||||||
|
FailFiles: task.FailFiles,
|
||||||
|
ErrorMessage: errMsg,
|
||||||
|
Result: task.Result,
|
||||||
|
DetailList: detailItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
g.Log().Infof(ctx, "[回调 %s] 触发回调, 状态=%s, 成功=%d 失败=%d, 错误=%s, 目标=%s",
|
||||||
|
taskID, status, payload.SuccessFiles, payload.FailFiles, errMsg, callbackURL)
|
||||||
|
g.Log().Debugf(ctx, "[回调 %s] 回调载荷长度=%d字节, 明细条数=%d",
|
||||||
|
taskID, len(body), len(detailItems))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
// 透传调用方的用户信息,供回调方 GetUserInfo 从 X-User-Info 头获取
|
||||||
|
userJSON, _ := json.Marshal(beans.User{UserName: "admin", TenantId: 1})
|
||||||
|
req.Header.Set("X-User-Info", string(userJSON))
|
||||||
|
|
||||||
|
resp, reqErr := http.DefaultClient.Do(req)
|
||||||
|
if reqErr != nil {
|
||||||
|
g.Log().Errorf(ctx, "[回调 %s] 请求失败: %v", taskID, reqErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
g.Log().Infof(ctx, "[回调 %s] 响应 status=%d, body=%s", taskID, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// processSingleVideo 处理单个文件,同时写入明细表
|
||||||
|
func (s *audioTaskService) processSingleVideo(ctx context.Context, taskID, savePath string, fileIndex int, fileName, model, language string, threshold float64) *dto.TranscribeItem {
|
||||||
|
if idx := strings.Index(fileName, "_"); idx > 0 {
|
||||||
|
fileName = fileName[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 开始处理文件(fileIndex=%d): %s", taskID, fileIndex, fileName)
|
||||||
|
|
||||||
|
var scenes *dto.SceneSummaryDTO
|
||||||
|
sceneRes, sceneErr := serviceScene.SceneAnalyzer.Analyze(ctx, &serviceScene.SceneAnalyzeReq{
|
||||||
|
VideoPaths: []string{savePath},
|
||||||
|
Threshold: threshold,
|
||||||
|
ExtractKeyframes: false,
|
||||||
|
})
|
||||||
|
if sceneErr != nil {
|
||||||
|
g.Log().Warningf(ctx, "[任务 %s] 文件 %s 分镜分析失败: %v", taskID, fileName, sceneErr)
|
||||||
|
} else if len(sceneRes.Analyses) > 0 {
|
||||||
|
scenes = toSceneDTO(&sceneRes.Analyses[0])
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 文件 %s 分镜分析完成, 场景数=%d", taskID, fileName, scenes.TotalScenes)
|
||||||
|
} else {
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 文件 %s 分镜分析无结果", taskID, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 文件 %s 开始语音识别, 模型=%s, 语言=%s", taskID, fileName, model, language)
|
||||||
|
transRes, transErr := VideoTranscribe.TranscribeVideo(ctx, &VideoTranscribeReq{
|
||||||
|
VideoPath: savePath,
|
||||||
|
Model: model,
|
||||||
|
Language: language,
|
||||||
|
})
|
||||||
|
if transErr != nil {
|
||||||
|
g.Log().Errorf(ctx, "[任务 %s] 文件 %s 语音识别失败: %v", taskID, fileName, transErr)
|
||||||
|
os.Remove(savePath)
|
||||||
|
s.saveDetail(ctx, taskID, fileIndex, fileName,
|
||||||
|
"", "", 0, "", model, language, transErr.Error())
|
||||||
|
return &dto.TranscribeItem{
|
||||||
|
FileName: fileName,
|
||||||
|
Error: transErr.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "[任务 %s] 文件 %s 语音识别成功, 文本长度=%d, 音频时长=%s, 大小=%d",
|
||||||
|
taskID, fileName, len(transRes.Text), transRes.AudioDuration, transRes.AudioSize)
|
||||||
|
|
||||||
|
var scenesJSON string
|
||||||
|
if scenes != nil {
|
||||||
|
sb, _ := json.Marshal(scenes)
|
||||||
|
scenesJSON = string(sb)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.saveDetail(ctx, taskID, fileIndex, fileName,
|
||||||
|
transRes.Text, scenesJSON, transRes.AudioSize, transRes.AudioDuration, model, language, "")
|
||||||
|
|
||||||
|
return &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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveDetail 保存单文件明细到 transcribe_task_detail
|
||||||
|
func (s *audioTaskService) saveDetail(ctx context.Context, taskID string, fileIndex int, fileName, text, scenes string, audioSize int64, audioDuration, model, language, errMsg string) {
|
||||||
|
detail := &entity.TranscribeTaskDetail{
|
||||||
|
TaskID: taskID,
|
||||||
|
FileIndex: fileIndex,
|
||||||
|
FileName: fileName,
|
||||||
|
TranscribedText: text,
|
||||||
|
Scenes: scenes,
|
||||||
|
AudioSize: audioSize,
|
||||||
|
AudioDuration: audioDuration,
|
||||||
|
Model: model,
|
||||||
|
Language: language,
|
||||||
|
ErrorMessage: errMsg,
|
||||||
|
}
|
||||||
|
if _, daoErr := dao.TranscribeTaskDetail.Insert(ctx, detail); daoErr != nil {
|
||||||
|
g.Log().Errorf(ctx, "[任务 %s] 保存明细失败(fileIndex=%d): %v", taskID, fileIndex, daoErr)
|
||||||
|
} else {
|
||||||
|
g.Log().Debugf(ctx, "[任务 %s] 明细已保存(fileIndex=%d, fileName=%s, 有错误=%v)",
|
||||||
|
taskID, fileIndex, fileName, errMsg != "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 查询任务 ----------
|
||||||
|
|
||||||
|
func (s *audioTaskService) GetTask(ctx context.Context, req *dto.GetTaskReq) (res *dto.GetTaskRes, err error) {
|
||||||
|
if req.TaskID == "" {
|
||||||
|
return nil, fmt.Errorf("taskId不能为空")
|
||||||
|
}
|
||||||
|
task, err := dao.TranscribeTask.GetByTaskID(ctx, req.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询任务失败: %v", err)
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
return nil, fmt.Errorf("任务不存在: %s", req.TaskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
detailList, err := dao.TranscribeTaskDetail.ListByTaskID(ctx, req.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
g.Log().Warningf(ctx, "[任务 %s] 查询明细失败: %v", req.TaskID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "[查询任务] taskId=%s, 状态=%s, 进度=%d", req.TaskID, task.Status, task.Progress)
|
||||||
|
|
||||||
|
item := dao.EntityToItem(task)
|
||||||
|
detailItems := make([]dto.TranscribeTaskDetailItem, 0, len(detailList))
|
||||||
|
for i := range detailList {
|
||||||
|
detailItems = append(detailItems, dao.DetailEntityToItem(&detailList[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容历史数据: 若 detail.scenes 为空但有 result JSON, 从 result 中提取 scenes 补上
|
||||||
|
detailItems = enrichDetailsFromResult(task.Result, detailItems)
|
||||||
|
|
||||||
|
return &dto.GetTaskRes{
|
||||||
|
TaskInfo: item,
|
||||||
|
DetailList: detailItems,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichDetailsFromResult 从 result JSON 中补全明细中的 scenes 等字段
|
||||||
|
func enrichDetailsFromResult(resultJSON string, details []dto.TranscribeTaskDetailItem) []dto.TranscribeTaskDetailItem {
|
||||||
|
if resultJSON == "" || len(details) == 0 {
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskResult dto.TaskResult
|
||||||
|
if err := json.Unmarshal([]byte(resultJSON), &taskResult); err != nil {
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, d := range details {
|
||||||
|
if d.Scenes != "" {
|
||||||
|
continue // 已有 scenes,不需要补
|
||||||
|
}
|
||||||
|
for _, r := range taskResult.Results {
|
||||||
|
if r.Result == nil || r.Result.Scenes == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.FileName == d.FileName {
|
||||||
|
sb, _ := json.Marshal(r.Result.Scenes)
|
||||||
|
details[i].Scenes = string(sb)
|
||||||
|
// 同时补全其他可能缺失的字段
|
||||||
|
if d.AudioDuration == "" {
|
||||||
|
details[i].AudioDuration = r.Result.AudioDuration
|
||||||
|
}
|
||||||
|
if d.AudioSize == 0 {
|
||||||
|
details[i].AudioSize = r.Result.AudioSize
|
||||||
|
}
|
||||||
|
if d.Model == "" {
|
||||||
|
details[i].Model = r.Result.Model
|
||||||
|
}
|
||||||
|
if d.Language == "" {
|
||||||
|
details[i].Language = r.Result.Language
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *audioTaskService) GetProgress(ctx context.Context, req *dto.GetProgressReq) (res *dto.GetProgressRes, err error) {
|
||||||
|
if req.TaskID == "" {
|
||||||
|
return nil, fmt.Errorf("taskId不能为空")
|
||||||
|
}
|
||||||
|
task, err := dao.TranscribeTask.GetByTaskID(ctx, req.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询任务失败: %v", err)
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
return nil, fmt.Errorf("任务不存在: %s", req.TaskID)
|
||||||
|
}
|
||||||
|
p := dao.EntityToProgress(task)
|
||||||
|
g.Log().Infof(ctx, "[查询进度] taskId=%s, 状态=%s, 进度=%d", req.TaskID, p.Status, p.Progress)
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *audioTaskService) ListTasks(ctx context.Context, req *dto.ListTaskReq) (res *dto.ListTaskRes, err error) {
|
||||||
|
// 确保分页参数不为 nil
|
||||||
|
if req.Page == nil {
|
||||||
|
req.Page = &beans.Page{PageNum: 1, PageSize: 10}
|
||||||
|
}
|
||||||
|
list, total, err := dao.TranscribeTask.List(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询任务列表失败: %v", err)
|
||||||
|
}
|
||||||
|
items := make([]dto.TranscribeTaskItem, len(list))
|
||||||
|
for i, task := range list {
|
||||||
|
items[i] = dao.EntityToItem(&task)
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "[查询列表] status=%s, pageNum=%d, pageSize=%d, 命中=%d/总量=%d",
|
||||||
|
req.Status, req.Page.PageNum, req.Page.PageSize, len(items), total)
|
||||||
|
return &dto.ListTaskRes{List: items, Total: total}, nil
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,11 +25,10 @@ const (
|
|||||||
backendCpp // whisper.cpp (whisper-cpp)
|
backendCpp // whisper.cpp (whisper-cpp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// WhisperService 语音识别服务
|
type whisperService struct{}
|
||||||
type WhisperService struct{}
|
|
||||||
|
|
||||||
// Whisper 语音识别服务单例
|
// Whisper 语音识别服务单例
|
||||||
var Whisper = new(WhisperService)
|
var Whisper = new(whisperService)
|
||||||
|
|
||||||
// TranscribeReq 语音识别请求
|
// TranscribeReq 语音识别请求
|
||||||
type TranscribeReq struct {
|
type TranscribeReq struct {
|
||||||
@@ -54,7 +54,7 @@ type Segment struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transcribe 对音频文件进行语音识别(自动检测后端,自动降级)
|
// Transcribe 对音频文件进行语音识别(自动检测后端,自动降级)
|
||||||
func (s *WhisperService) Transcribe(ctx context.Context, req *TranscribeReq) (res *TranscribeRes, err error) {
|
func (s *whisperService) Transcribe(ctx context.Context, req *TranscribeReq) (res *TranscribeRes, err error) {
|
||||||
// 1. 校验音频文件
|
// 1. 校验音频文件
|
||||||
if _, err = os.Stat(req.AudioPath); os.IsNotExist(err) {
|
if _, err = os.Stat(req.AudioPath); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("音频文件不存在: %s", req.AudioPath)
|
return nil, fmt.Errorf("音频文件不存在: %s", req.AudioPath)
|
||||||
@@ -94,7 +94,7 @@ func (s *WhisperService) Transcribe(ctx context.Context, req *TranscribeReq) (re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// transcribeWithCLI 使用 whisper CLI 命令
|
// transcribeWithCLI 使用 whisper CLI 命令
|
||||||
func (s *WhisperService) transcribeWithCLI(ctx context.Context, req *TranscribeReq, whisperPath, model, language string) (res *TranscribeRes, err error) {
|
func (s *whisperService) transcribeWithCLI(ctx context.Context, req *TranscribeReq, whisperPath, model, language string) (res *TranscribeRes, err error) {
|
||||||
outputDir := filepath.Dir(req.AudioPath)
|
outputDir := filepath.Dir(req.AudioPath)
|
||||||
modelDir := g.Cfg().MustGet(ctx, "whisper.model_dir", "").String()
|
modelDir := g.Cfg().MustGet(ctx, "whisper.model_dir", "").String()
|
||||||
threads := g.Cfg().MustGet(ctx, "whisper.threads", 2).Int()
|
threads := g.Cfg().MustGet(ctx, "whisper.threads", 2).Int()
|
||||||
@@ -122,7 +122,7 @@ func (s *WhisperService) transcribeWithCLI(ctx context.Context, req *TranscribeR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// transcribeWithPython 使用 python -m whisper
|
// transcribeWithPython 使用 python -m whisper
|
||||||
func (s *WhisperService) transcribeWithPython(ctx context.Context, req *TranscribeReq, model, language string) (res *TranscribeRes, err error) {
|
func (s *whisperService) transcribeWithPython(ctx context.Context, req *TranscribeReq, model, language string) (res *TranscribeRes, err error) {
|
||||||
// 查找 python
|
// 查找 python
|
||||||
pythonPath, err := exec.LookPath("python3")
|
pythonPath, err := exec.LookPath("python3")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,7 +160,7 @@ func (s *WhisperService) transcribeWithPython(ctx context.Context, req *Transcri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readTxtResult 读取 whisper 输出的 txt 文件
|
// readTxtResult 读取 whisper 输出的 txt 文件
|
||||||
func (s *WhisperService) readTxtResult(outputDir, audioPath, model string) (res *TranscribeRes, err error) {
|
func (s *whisperService) readTxtResult(outputDir, audioPath, model string) (res *TranscribeRes, err error) {
|
||||||
baseName := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath))
|
baseName := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath))
|
||||||
txtPaths := []string{
|
txtPaths := []string{
|
||||||
filepath.Join(outputDir, baseName+".txt"),
|
filepath.Join(outputDir, baseName+".txt"),
|
||||||
@@ -201,7 +201,7 @@ func cleanTranscript(text string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectBackend 检测可用的 whisper 后端,返回后端类型和可执行路径
|
// detectBackend 检测可用的 whisper 后端,返回后端类型和可执行路径
|
||||||
func (s *WhisperService) detectBackend() (WhisperBackend, string) {
|
func (s *whisperService) detectBackend() (WhisperBackend, string) {
|
||||||
// 1. 优先检测 C++ 版 whisper.cpp(最快,但参数格式不同)
|
// 1. 优先检测 C++ 版 whisper.cpp(最快,但参数格式不同)
|
||||||
for _, name := range []string{"whisper-cpp", "whisper-cli"} {
|
for _, name := range []string{"whisper-cpp", "whisper-cli"} {
|
||||||
if path, err := exec.LookPath(name); err == nil {
|
if path, err := exec.LookPath(name); err == nil {
|
||||||
@@ -242,7 +242,7 @@ func (s *WhisperService) detectBackend() (WhisperBackend, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveCppModelPath 查找或下载 whisper.cpp 模型文件
|
// resolveCppModelPath 查找或下载 whisper.cpp 模型文件
|
||||||
func (s *WhisperService) resolveCppModelPath(model string) string {
|
func (s *whisperService) resolveCppModelPath(model string) string {
|
||||||
modelName := strings.TrimPrefix(model, "ggml-")
|
modelName := strings.TrimPrefix(model, "ggml-")
|
||||||
modelName = strings.TrimSuffix(modelName, ".bin")
|
modelName = strings.TrimSuffix(modelName, ".bin")
|
||||||
|
|
||||||
@@ -262,8 +262,20 @@ func (s *WhisperService) resolveCppModelPath(model string) string {
|
|||||||
altPaths := []string{
|
altPaths := []string{
|
||||||
cppModelName,
|
cppModelName,
|
||||||
filepath.Join(home, ".cache", "whisper", "ggml-"+modelName+"-q5_0.bin"),
|
filepath.Join(home, ".cache", "whisper", "ggml-"+modelName+"-q5_0.bin"),
|
||||||
|
}
|
||||||
|
// macOS: Homebrew 安装的 whisper.cpp 模型路径
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
altPaths = append(altPaths,
|
||||||
"/opt/homebrew/share/whisper-cpp/models/"+cppModelName,
|
"/opt/homebrew/share/whisper-cpp/models/"+cppModelName,
|
||||||
"/usr/local/share/whisper-cpp/models/"+cppModelName,
|
"/usr/local/share/whisper-cpp/models/"+cppModelName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Linux: 常见系统安装路径
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
altPaths = append(altPaths,
|
||||||
|
"/usr/share/whisper-cpp/models/"+cppModelName,
|
||||||
|
"/usr/local/share/whisper-cpp/models/"+cppModelName,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
for _, p := range altPaths {
|
for _, p := range altPaths {
|
||||||
if _, err := os.Stat(p); err == nil {
|
if _, err := os.Stat(p); err == nil {
|
||||||
@@ -310,7 +322,7 @@ func (s *WhisperService) resolveCppModelPath(model string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// downloadFile 下载文件到指定路径(支持超时)
|
// downloadFile 下载文件到指定路径(支持超时)
|
||||||
func (s *WhisperService) downloadFile(url, destPath string, timeout time.Duration) error {
|
func (s *whisperService) downloadFile(url, destPath string, timeout time.Duration) error {
|
||||||
tmpPath := destPath + ".tmp"
|
tmpPath := destPath + ".tmp"
|
||||||
out, err := os.Create(tmpPath)
|
out, err := os.Create(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -346,7 +358,7 @@ func (s *WhisperService) downloadFile(url, destPath string, timeout time.Duratio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// transcribeWithCpp 使用 whisper.cpp(C++ 版,参数格式不同)
|
// transcribeWithCpp 使用 whisper.cpp(C++ 版,参数格式不同)
|
||||||
func (s *WhisperService) transcribeWithCpp(ctx context.Context, req *TranscribeReq, binaryPath, model, language string) (res *TranscribeRes, err error) {
|
func (s *whisperService) transcribeWithCpp(ctx context.Context, req *TranscribeReq, binaryPath, model, language string) (res *TranscribeRes, err error) {
|
||||||
outputDir := filepath.Dir(req.AudioPath)
|
outputDir := filepath.Dir(req.AudioPath)
|
||||||
baseName := strings.TrimSuffix(filepath.Base(req.AudioPath), filepath.Ext(req.AudioPath))
|
baseName := strings.TrimSuffix(filepath.Base(req.AudioPath), filepath.Ext(req.AudioPath))
|
||||||
outputPrefix := filepath.Join(outputDir, baseName)
|
outputPrefix := filepath.Join(outputDir, baseName)
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// AudioExtractService 音频提取服务
|
// AudioExtractService 音频提取服务
|
||||||
type AudioExtractService struct{}
|
type audioExtractService struct{}
|
||||||
|
|
||||||
// AudioExtract 音频提取服务单例
|
// AudioExtract 音频提取服务单例
|
||||||
var AudioExtract = new(AudioExtractService)
|
var AudioExtract = new(audioExtractService)
|
||||||
|
|
||||||
// ExtractAudioReq 提取音频请求
|
// ExtractAudioReq 提取音频请求
|
||||||
type ExtractAudioReq struct {
|
type ExtractAudioReq struct {
|
||||||
@@ -32,7 +32,7 @@ type ExtractAudioRes struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract 从视频中提取音频
|
// Extract 从视频中提取音频
|
||||||
func (s *AudioExtractService) Extract(ctx context.Context, req *ExtractAudioReq) (res *ExtractAudioRes, err error) {
|
func (s *audioExtractService) Extract(ctx context.Context, req *ExtractAudioReq) (res *ExtractAudioRes, err error) {
|
||||||
// 1. 校验视频文件存在
|
// 1. 校验视频文件存在
|
||||||
if _, err = os.Stat(req.VideoPath); os.IsNotExist(err) {
|
if _, err = os.Stat(req.VideoPath); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("视频文件不存在: %s", req.VideoPath)
|
return nil, fmt.Errorf("视频文件不存在: %s", req.VideoPath)
|
||||||
@@ -117,7 +117,7 @@ func (s *AudioExtractService) Extract(ctx context.Context, req *ExtractAudioReq)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getFFmpegPath 获取 ffmpeg 可执行路径
|
// getFFmpegPath 获取 ffmpeg 可执行路径
|
||||||
func (s *AudioExtractService) getFFmpegPath() (string, error) {
|
func (s *audioExtractService) getFFmpegPath() (string, error) {
|
||||||
// 1. 优先从配置读取
|
// 1. 优先从配置读取
|
||||||
ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String()
|
ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String()
|
||||||
if ffmpegPath != "" {
|
if ffmpegPath != "" {
|
||||||
@@ -135,7 +135,7 @@ func (s *AudioExtractService) getFFmpegPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getAudioDuration 获取音频时长
|
// getAudioDuration 获取音频时长
|
||||||
func (s *AudioExtractService) getAudioDuration(ctx context.Context, ffmpegPath string, audioPath string) (string, error) {
|
func (s *audioExtractService) getAudioDuration(ctx context.Context, ffmpegPath string, audioPath string) (string, error) {
|
||||||
// 使用 ffprobe 获取时长
|
// 使用 ffprobe 获取时长
|
||||||
// 先尝试查找 ffprobe
|
// 先尝试查找 ffprobe
|
||||||
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
||||||
@@ -168,7 +168,7 @@ func (s *AudioExtractService) getAudioDuration(ctx context.Context, ffmpegPath s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExtractAndCleanup 提取音频并清理临时视频文件
|
// ExtractAndCleanup 提取音频并清理临时视频文件
|
||||||
func (s *AudioExtractService) ExtractAndCleanup(ctx context.Context, req *ExtractAudioReq) (res *ExtractAudioRes, err error) {
|
func (s *audioExtractService) ExtractAndCleanup(ctx context.Context, req *ExtractAudioReq) (res *ExtractAudioRes, err error) {
|
||||||
res, err = s.Extract(ctx, req)
|
res, err = s.Extract(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -19,10 +19,18 @@ var (
|
|||||||
DetectedWhisperPath string
|
DetectedWhisperPath string
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureDependencies 启动时检查并安装 ffmpeg 和 whisper
|
func init() {
|
||||||
func EnsureDependencies(ctx context.Context) {
|
ensureDependencies()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDependencies 启动时检查并安装 ffmpeg 和 whisper
|
||||||
|
func ensureDependencies() {
|
||||||
|
ctx := context.Background()
|
||||||
g.Log().Info(ctx, "========== 检查依赖环境 ==========")
|
g.Log().Info(ctx, "========== 检查依赖环境 ==========")
|
||||||
|
|
||||||
|
// 打印当前运行环境信息
|
||||||
|
g.Log().Infof(ctx, "平台: %s/%s, Docker: %v", runtime.GOOS, runtime.GOARCH, isRunningInContainer())
|
||||||
|
|
||||||
ensureFFmpeg(ctx)
|
ensureFFmpeg(ctx)
|
||||||
ensureWhisper(ctx)
|
ensureWhisper(ctx)
|
||||||
resolveWhisperPath(ctx)
|
resolveWhisperPath(ctx)
|
||||||
@@ -35,6 +43,26 @@ func EnsureDependencies(ctx context.Context) {
|
|||||||
g.Log().Info(ctx, "===================================")
|
g.Log().Info(ctx, "===================================")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isRunningInContainer 检测是否运行在 Docker 容器中
|
||||||
|
func isRunningInContainer() bool {
|
||||||
|
// 方法1: 检查 /.dockerenv 文件
|
||||||
|
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 方法2: 检查 /proc/1/cgroup 是否包含 docker 关键字
|
||||||
|
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
|
||||||
|
if strings.Contains(string(data), "docker") ||
|
||||||
|
strings.Contains(string(data), "kubepods") ||
|
||||||
|
strings.Contains(string(data), "containerd") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// inContainer 是否为容器环境(简化调用)
|
||||||
|
var inContainer = isRunningInContainer()
|
||||||
|
|
||||||
// ensureFFmpeg 确保 ffmpeg 可用
|
// ensureFFmpeg 确保 ffmpeg 可用
|
||||||
func ensureFFmpeg(ctx context.Context) {
|
func ensureFFmpeg(ctx context.Context) {
|
||||||
if _, err := exec.LookPath("ffmpeg"); err == nil {
|
if _, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
@@ -46,7 +74,21 @@ func ensureFFmpeg(ctx context.Context) {
|
|||||||
|
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
// 检查是否安装了 Homebrew
|
installFFmpegOnMac(ctx)
|
||||||
|
|
||||||
|
case "linux":
|
||||||
|
installFFmpegOnLinux(ctx)
|
||||||
|
|
||||||
|
case "windows":
|
||||||
|
installFFmpegOnWindows(ctx)
|
||||||
|
|
||||||
|
default:
|
||||||
|
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 不支持的平台(%s),请手动安装 ffmpeg", runtime.GOOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// installFFmpegOnMac 通过 Homebrew 安装 ffmpeg
|
||||||
|
func installFFmpegOnMac(ctx context.Context) {
|
||||||
if _, err := exec.LookPath("brew"); err != nil {
|
if _, err := exec.LookPath("brew"); err != nil {
|
||||||
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 未检测到 Homebrew,请手动安装:\n brew install ffmpeg")
|
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 未检测到 Homebrew,请手动安装:\n brew install ffmpeg")
|
||||||
return
|
return
|
||||||
@@ -58,22 +100,56 @@ func ensureFFmpeg(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
||||||
|
}
|
||||||
|
|
||||||
case "linux":
|
// installFFmpegOnLinux 在 Linux(含 Docker)上安装 ffmpeg
|
||||||
// 尝试 apt
|
func installFFmpegOnLinux(ctx context.Context) {
|
||||||
if _, err := exec.LookPath("apt"); err == nil {
|
// Docker 容器通常以 root 运行,不需要 sudo
|
||||||
cmd := exec.CommandContext(ctx, "sudo", "apt", "install", "-y", "ffmpeg")
|
sudoPrefix := ""
|
||||||
|
if !inContainer {
|
||||||
|
// 非容器环境,检查是否需要 sudo
|
||||||
|
if _, err := exec.LookPath("sudo"); err == nil {
|
||||||
|
sudoPrefix = "sudo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 尝试 apt (Debian/Ubuntu)
|
||||||
|
if _, err := exec.LookPath("apt-get"); err == nil {
|
||||||
|
args := []string{"install", "-y", "ffmpeg"}
|
||||||
|
if sudoPrefix != "" {
|
||||||
|
args = append([]string{sudoPrefix}, args...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "apt-get", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
g.Log().Errorf(ctx, "[ffmpeg] ❌ apt 安装失败: %v\n%s", err, string(output))
|
g.Log().Errorf(ctx, "[ffmpeg] ❌ apt-get 安装失败: %v\n%s", err, string(output))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
||||||
|
// 更新库缓存(Debian/Ubuntu 会用 ldconfig 更新)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试 apk (Alpine Linux,常见于 Docker 精简镜像)
|
||||||
|
if _, err := exec.LookPath("apk"); err == nil {
|
||||||
|
// Alpine 的 apk 不需要 sudo(默认以 root 运行)
|
||||||
|
cmd := exec.CommandContext(ctx, "apk", "add", "ffmpeg")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
g.Log().Errorf(ctx, "[ffmpeg] ❌ apk 安装失败: %v\n%s", err, string(output))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 尝试 yum
|
|
||||||
|
// 3. 尝试 yum (CentOS/RHEL)
|
||||||
if _, err := exec.LookPath("yum"); err == nil {
|
if _, err := exec.LookPath("yum"); err == nil {
|
||||||
cmd := exec.CommandContext(ctx, "sudo", "yum", "install", "-y", "ffmpeg")
|
args := []string{"install", "-y", "ffmpeg"}
|
||||||
|
if sudoPrefix != "" {
|
||||||
|
args = append([]string{sudoPrefix}, args...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "yum", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
g.Log().Errorf(ctx, "[ffmpeg] ❌ yum 安装失败: %v\n%s", err, string(output))
|
g.Log().Errorf(ctx, "[ffmpeg] ❌ yum 安装失败: %v\n%s", err, string(output))
|
||||||
@@ -82,16 +158,63 @@ func ensureFFmpeg(ctx context.Context) {
|
|||||||
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 请手动安装: sudo apt install ffmpeg")
|
|
||||||
|
|
||||||
default:
|
if inContainer {
|
||||||
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 不支持的平台(%s),请手动安装 ffmpeg", runtime.GOOS)
|
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 容器中未找到 apt-get/apk/yum,请将 ffmpeg 预装在 Docker 镜像中")
|
||||||
|
} else {
|
||||||
|
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 请手动安装: sudo apt-get install ffmpeg")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// installFFmpegOnWindows 在 Windows 上安装 ffmpeg
|
||||||
|
func installFFmpegOnWindows(ctx context.Context) {
|
||||||
|
// 1. 尝试 winget (Windows 10/11 内置)
|
||||||
|
if _, err := exec.LookPath("winget"); err == nil {
|
||||||
|
g.Log().Infof(ctx, "[ffmpeg] 通过 winget 安装...")
|
||||||
|
cmd := exec.CommandContext(ctx, "winget", "install", "--id", "FFmpeg.FFmpeg", "-e", "--accept-package-agreements")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Log().Warningf(ctx, "[ffmpeg] ⚠ winget 安装失败: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试 choco (Chocolatey)
|
||||||
|
if _, err := exec.LookPath("choco"); err == nil {
|
||||||
|
// choco 安装可能需要管理员权限
|
||||||
|
g.Log().Infof(ctx, "[ffmpeg] 通过 choco 安装...")
|
||||||
|
cmd := exec.CommandContext(ctx, "choco", "install", "ffmpeg", "-y")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Log().Warningf(ctx, "[ffmpeg] ⚠ choco 安装失败: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 尝试 scoop
|
||||||
|
if _, err := exec.LookPath("scoop"); err == nil {
|
||||||
|
g.Log().Infof(ctx, "[ffmpeg] 通过 scoop 安装...")
|
||||||
|
cmd := exec.CommandContext(ctx, "scoop", "install", "ffmpeg")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Log().Warningf(ctx, "[ffmpeg] ⚠ scoop 安装失败: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Warningf(ctx, `[ffmpeg] ⚠ 请手动安装 ffmpeg,推荐方式:
|
||||||
|
1. winget install --id FFmpeg.FFmpeg -e
|
||||||
|
2. choco install ffmpeg -y
|
||||||
|
3. 从 https://ffmpeg.org/download.html 下载并加入 PATH`)
|
||||||
|
}
|
||||||
|
|
||||||
// ensureWhisper 确保 whisper 可用(优先安装 C++ 版,速度更快)
|
// ensureWhisper 确保 whisper 可用(优先安装 C++ 版,速度更快)
|
||||||
func ensureWhisper(ctx context.Context) {
|
func ensureWhisper(ctx context.Context) {
|
||||||
// 1. 检查是否已有 whisper-cpp(C++ 版,最快)
|
// 1. 检查是否已有 whisper-cpp(C++ 版,最快)
|
||||||
|
// exec.LookPath 在 Windows 上会自动查找 .exe 后缀
|
||||||
if path, err := exec.LookPath("whisper-cpp"); err == nil {
|
if path, err := exec.LookPath("whisper-cpp"); err == nil {
|
||||||
g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装: %s", path)
|
g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装: %s", path)
|
||||||
return
|
return
|
||||||
@@ -101,16 +224,19 @@ func ensureWhisper(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查 Homebrew 安装目录(即使不在 PATH 也能找到)
|
// 2. 仅在 macOS 上检查 Homebrew 安装目录(即使不在 PATH 也能找到)
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
if p := findHomebrewWhisperCpp(); p != "" {
|
if p := findHomebrewWhisperCpp(); p != "" {
|
||||||
DetectedWhisperPath = p
|
DetectedWhisperPath = p
|
||||||
// 自动添加到 PATH 环境变量
|
if !inContainer {
|
||||||
addToShellPath(ctx, filepath.Dir(p))
|
addToShellPath(ctx, filepath.Dir(p))
|
||||||
|
}
|
||||||
g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装(自动检测): %s", p)
|
g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装(自动检测): %s", p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 尝试安装 whisper-cpp(C++ 版)
|
// 3. 仅在 macOS 上尝试使用 Homebrew 安装 C++ 版
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
if _, err := exec.LookPath("brew"); err == nil {
|
if _, err := exec.LookPath("brew"); err == nil {
|
||||||
g.Log().Infof(ctx, "[whisper] 安装 C++ 版 (brew install whisper-cpp)...")
|
g.Log().Infof(ctx, "[whisper] 安装 C++ 版 (brew install whisper-cpp)...")
|
||||||
@@ -118,9 +244,9 @@ func ensureWhisper(ctx context.Context) {
|
|||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
g.Log().Info(ctx, "[whisper] ✔ C++ 版安装成功")
|
g.Log().Info(ctx, "[whisper] ✔ C++ 版安装成功")
|
||||||
// 装好后把 Homebrew bin 加到 PATH
|
if !inContainer {
|
||||||
addToShellPath(ctx, getHomebrewBinDir())
|
addToShellPath(ctx, getHomebrewBinDir())
|
||||||
// 检测安装路径
|
}
|
||||||
if p := findHomebrewWhisperCpp(); p != "" {
|
if p := findHomebrewWhisperCpp(); p != "" {
|
||||||
DetectedWhisperPath = p
|
DetectedWhisperPath = p
|
||||||
}
|
}
|
||||||
@@ -151,17 +277,26 @@ func ensureWhisper(ctx context.Context) {
|
|||||||
pipCmd = "pip"
|
pipCmd = "pip"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, pipCmd, "install", "--user", "openai-whisper")
|
// pip install --user 可能在某些环境下不兼容,尝试先不加 --user,失败后再加
|
||||||
|
cmd := exec.CommandContext(ctx, pipCmd, "install", "openai-whisper")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// 尝试 --user 模式
|
||||||
|
g.Log().Warningf(ctx, "[whisper] pip 全局安装失败: %v,尝试 --user 模式...", err)
|
||||||
|
cmd = exec.CommandContext(ctx, pipCmd, "install", "--user", "openai-whisper")
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
g.Log().Errorf(ctx, "[whisper] ❌ pip 安装失败: %v\n%s", err, string(output))
|
g.Log().Errorf(ctx, "[whisper] ❌ pip 安装失败: %v\n%s", err, string(output))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
g.Log().Info(ctx, "[whisper] ✔ Python 版安装成功")
|
g.Log().Info(ctx, "[whisper] ✔ Python 版安装成功")
|
||||||
|
|
||||||
// 安装后自动配置 PATH
|
// 安装后自动配置 PATH(仅在非容器、非 Windows 环境)
|
||||||
|
if !inContainer && runtime.GOOS != "windows" {
|
||||||
configureWhisperPath(ctx)
|
configureWhisperPath(ctx)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// resolveWhisperPath 自动找到 whisper 二进制路径并存储
|
// resolveWhisperPath 自动找到 whisper 二进制路径并存储
|
||||||
func resolveWhisperPath(ctx context.Context) {
|
func resolveWhisperPath(ctx context.Context) {
|
||||||
@@ -174,6 +309,7 @@ func resolveWhisperPath(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. 优先检测 C++ 版本(快 3-5 倍)
|
// 1. 优先检测 C++ 版本(快 3-5 倍)
|
||||||
|
// exec.LookPath 在 Windows 上自动查找 .exe 后缀
|
||||||
for _, name := range []string{"whisper-cpp", "whisper-cli"} {
|
for _, name := range []string{"whisper-cpp", "whisper-cli"} {
|
||||||
if path, err := exec.LookPath(name); err == nil {
|
if path, err := exec.LookPath(name); err == nil {
|
||||||
DetectedWhisperPath = path
|
DetectedWhisperPath = path
|
||||||
@@ -182,12 +318,14 @@ func resolveWhisperPath(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 在 Homebrew 目录查找 C++ 版本
|
// 2. 仅在 macOS 上查找 Homebrew 目录下的 C++ 版本
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
if p := findHomebrewWhisperCpp(); p != "" {
|
if p := findHomebrewWhisperCpp(); p != "" {
|
||||||
DetectedWhisperPath = p
|
DetectedWhisperPath = p
|
||||||
g.Log().Infof(ctx, "[whisper] ✔ C++ 版(自动检测): %s", p)
|
g.Log().Infof(ctx, "[whisper] ✔ C++ 版(自动检测): %s", p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 从 PATH 查找 Python 版 whisper
|
// 3. 从 PATH 查找 Python 版 whisper
|
||||||
if path, err := exec.LookPath("whisper"); err == nil {
|
if path, err := exec.LookPath("whisper"); err == nil {
|
||||||
@@ -215,6 +353,10 @@ func getWhisperCandidates() []string {
|
|||||||
// 通过 python 探针获取 user-site bin 目录
|
// 通过 python 探针获取 user-site bin 目录
|
||||||
if p := getUserPythonBin(); p != "" {
|
if p := getUserPythonBin(); p != "" {
|
||||||
candidates = append(candidates, filepath.Join(p, "whisper"))
|
candidates = append(candidates, filepath.Join(p, "whisper"))
|
||||||
|
// Windows 上 pip 安装的可执行文件是 .exe
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
candidates = append(candidates, filepath.Join(p, "whisper.exe"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 常见 pip user base 路径
|
// 常见 pip user base 路径
|
||||||
@@ -233,6 +375,22 @@ func getWhisperCandidates() []string {
|
|||||||
candidates = append(candidates,
|
candidates = append(candidates,
|
||||||
filepath.Join(userHome, ".local", "bin", "whisper"),
|
filepath.Join(userHome, ".local", "bin", "whisper"),
|
||||||
)
|
)
|
||||||
|
case "windows":
|
||||||
|
// Windows 上 pip --user 安装的脚本路径
|
||||||
|
candidates = append(candidates,
|
||||||
|
filepath.Join(userHome, "AppData", "Roaming", "Python", "Scripts", "whisper.exe"),
|
||||||
|
filepath.Join(userHome, "AppData", "Roaming", "Python", "Scripts", "whisper"),
|
||||||
|
filepath.Join(userHome, "AppData", "Local", "Programs", "Python", "Scripts", "whisper.exe"),
|
||||||
|
filepath.Join(userHome, "AppData", "Local", "Programs", "Python", "Scripts", "whisper"),
|
||||||
|
)
|
||||||
|
// Python 版本特定路径
|
||||||
|
pythonVersions := []string{"39", "310", "311", "312", "313"}
|
||||||
|
for _, ver := range pythonVersions {
|
||||||
|
candidates = append(candidates,
|
||||||
|
filepath.Join(userHome, "AppData", "Roaming", "Python", "Python"+ver, "Scripts", "whisper.exe"),
|
||||||
|
filepath.Join(userHome, "AppData", "Roaming", "Python", "Python"+ver, "Scripts", "whisper"),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
@@ -361,6 +519,17 @@ func addToShellPath(ctx context.Context, dir string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 容器环境不修改 shell 配置(无意义)
|
||||||
|
if inContainer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows 环境不修改 shell rc 文件(使用系统环境变量)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
g.Log().Infof(ctx, "[setup] Windows 环境,请手动将 %s 添加到系统 PATH 环境变量", dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否已在 PATH 中
|
// 检查是否已在 PATH 中
|
||||||
currentPath := os.Getenv("PATH")
|
currentPath := os.Getenv("PATH")
|
||||||
if strings.Contains(currentPath, dir) {
|
if strings.Contains(currentPath, dir) {
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
commonHttp "gitea.com/red-future/common/http"
|
||||||
|
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/os/glog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConcatService 视频拼接服务
|
type concatService struct{}
|
||||||
type ConcatService struct{}
|
|
||||||
|
|
||||||
// Concat 视频拼接服务单例
|
// Concat 视频拼接服务单例
|
||||||
var Concat = new(ConcatService)
|
var Concat = new(concatService)
|
||||||
|
|
||||||
// ConcatReq 视频拼接请求
|
// ConcatReq 视频拼接请求
|
||||||
type ConcatReq struct {
|
type ConcatReq struct {
|
||||||
VideoPaths []string // 视频文件路径列表(按此顺序拼接)
|
VideoPaths []string // 视频文件路径列表(按此顺序拼接)
|
||||||
OutputPath string // 输出视频文件路径,空则自动生成
|
OutputPath string // 输出视频文件路径,空则自动生成
|
||||||
Method string // 拼接方式: auto/fast/reencode,默认 auto
|
Method string // 拼接方式: auto/fast/reencode,默认 auto
|
||||||
|
Upload bool // 是否上传到MinIO
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConcatRes 视频拼接响应
|
// ConcatRes 视频拼接响应
|
||||||
@@ -32,10 +39,11 @@ type ConcatRes struct {
|
|||||||
DurationStr string `json:"durationStr"` // 可读时长
|
DurationStr string `json:"durationStr"` // 可读时长
|
||||||
MethodUsed string `json:"methodUsed"` // 实际使用的拼接方式
|
MethodUsed string `json:"methodUsed"` // 实际使用的拼接方式
|
||||||
InputFiles int `json:"inputFiles"` // 输入文件数
|
InputFiles int `json:"inputFiles"` // 输入文件数
|
||||||
|
FileURL string `json:"fileURL"` // MinIO访问地址(上传后返回)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Concat 拼接多个视频为一个
|
// Concat 拼接多个视频为一个
|
||||||
func (s *ConcatService) Concat(ctx context.Context, req *ConcatReq) (res *ConcatRes, err error) {
|
func (s *concatService) Concat(ctx context.Context, req *ConcatReq) (res *ConcatRes, err error) {
|
||||||
if len(req.VideoPaths) < 2 {
|
if len(req.VideoPaths) < 2 {
|
||||||
return nil, fmt.Errorf("至少需要2个视频才能拼接")
|
return nil, fmt.Errorf("至少需要2个视频才能拼接")
|
||||||
}
|
}
|
||||||
@@ -98,11 +106,21 @@ func (s *ConcatService) Concat(ctx context.Context, req *ConcatReq) (res *Concat
|
|||||||
MethodUsed: methodUsed,
|
MethodUsed: methodUsed,
|
||||||
InputFiles: len(req.VideoPaths),
|
InputFiles: len(req.VideoPaths),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果需要上传到 MinIO
|
||||||
|
if req.Upload {
|
||||||
|
uploadRes, uploadErr := s.UploadToMinIO(ctx, outputPath)
|
||||||
|
if uploadErr != nil {
|
||||||
|
return nil, fmt.Errorf("上传到MinIO失败: %v", uploadErr)
|
||||||
|
}
|
||||||
|
res.FileURL = uploadRes.FileURL
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// concatByDemuxer 使用 concat demuxer 无损拼接(要求同编码参数)
|
// concatByDemuxer 使用 concat demuxer 无损拼接(要求同编码参数)
|
||||||
func (s *ConcatService) concatByDemuxer(ctx context.Context, ffmpegPath string, inputs []string, output string) error {
|
func (s *concatService) concatByDemuxer(ctx context.Context, ffmpegPath string, inputs []string, output string) error {
|
||||||
// 创建文件列表
|
// 创建文件列表
|
||||||
fileListPath := filepath.Join(filepath.Dir(output), "concat_list.txt")
|
fileListPath := filepath.Join(filepath.Dir(output), "concat_list.txt")
|
||||||
var lines []string
|
var lines []string
|
||||||
@@ -132,7 +150,7 @@ func (s *ConcatService) concatByDemuxer(ctx context.Context, ffmpegPath string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// concatByFilter 使用 concat filter 重编码拼接(自动归一化分辨率/音频参数)
|
// concatByFilter 使用 concat filter 重编码拼接(自动归一化分辨率/音频参数)
|
||||||
func (s *ConcatService) concatByFilter(ctx context.Context, ffmpegPath string, inputs []string, output string) error {
|
func (s *concatService) concatByFilter(ctx context.Context, ffmpegPath string, inputs []string, output string) error {
|
||||||
n := len(inputs)
|
n := len(inputs)
|
||||||
|
|
||||||
// 1. 获取所有视频的分辨率,确定统一输出尺寸
|
// 1. 获取所有视频的分辨率,确定统一输出尺寸
|
||||||
@@ -211,7 +229,7 @@ func (s *ConcatService) concatByFilter(ctx context.Context, ffmpegPath string, i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getVideoResolution 获取视频分辨率
|
// getVideoResolution 获取视频分辨率
|
||||||
func (s *ConcatService) getVideoResolution(ctx context.Context, ffmpegPath, videoPath string) (width, height int, err error) {
|
func (s *concatService) getVideoResolution(ctx context.Context, ffmpegPath, videoPath string) (width, height int, err error) {
|
||||||
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
||||||
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
|
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
|
||||||
ffprobePath = "ffprobe"
|
ffprobePath = "ffprobe"
|
||||||
@@ -233,7 +251,7 @@ func (s *ConcatService) getVideoResolution(ctx context.Context, ffmpegPath, vide
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getVideoDuration 获取视频时长
|
// getVideoDuration 获取视频时长
|
||||||
func (s *ConcatService) getVideoDuration(ctx context.Context, ffmpegPath, videoPath string) (float64, error) {
|
func (s *concatService) getVideoDuration(ctx context.Context, ffmpegPath, videoPath string) (float64, error) {
|
||||||
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
||||||
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
|
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
|
||||||
ffprobePath = "ffprobe"
|
ffprobePath = "ffprobe"
|
||||||
@@ -256,7 +274,7 @@ func (s *ConcatService) getVideoDuration(ctx context.Context, ffmpegPath, videoP
|
|||||||
return duration, nil
|
return duration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConcatService) getFFmpegPath() (string, error) {
|
func (s *concatService) getFFmpegPath() (string, error) {
|
||||||
ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String()
|
ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String()
|
||||||
if ffmpegPath != "" {
|
if ffmpegPath != "" {
|
||||||
if _, err := os.Stat(ffmpegPath); err == nil {
|
if _, err := os.Stat(ffmpegPath); err == nil {
|
||||||
@@ -277,6 +295,84 @@ func formatDuration(seconds float64) string {
|
|||||||
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uploadFileRes 上传文件响应
|
||||||
|
type uploadFileRes struct {
|
||||||
|
FileURL string `json:"fileURL" dc:"上传地址"`
|
||||||
|
FileSize int `json:"fileSize" dc:"文件大小"`
|
||||||
|
FileName string `json:"fileName" dc:"文件名称"`
|
||||||
|
FileFormat string `json:"fileFormat" dc:"文件格式"`
|
||||||
|
FileAddressPrefix string `json:"fileAddressPrefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadToMinIO 通过 OSS 微服务的 multipart 文件上传接口上传到 MinIO
|
||||||
|
func (s *concatService) UploadToMinIO(ctx context.Context, localFilePath string) (*uploadFileRes, error) {
|
||||||
|
// 手动构建 multipart/form-data 表单
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
|
||||||
|
file, err := os.Open(localFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开文件失败: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fw, err := mw.CreateFormFile("file", filepath.Base(localFilePath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建表单文件字段失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = io.Copy(fw, file); err != nil {
|
||||||
|
return nil, fmt.Errorf("写入文件内容失败: %v", err)
|
||||||
|
}
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
client := commonHttp.Httpclient.Clone()
|
||||||
|
|
||||||
|
// 透传认证 headers
|
||||||
|
if r := g.RequestFromCtx(ctx); r != nil {
|
||||||
|
for k, v := range r.Header {
|
||||||
|
client.SetHeader(k, v[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 设置 multipart Content-Type(含 boundary)
|
||||||
|
contentType := mw.FormDataContentType()
|
||||||
|
g.Log().Debugf(ctx, "[UploadToMinIO] Content-Type: %s", contentType)
|
||||||
|
client.SetHeader("Content-Type", contentType)
|
||||||
|
|
||||||
|
// 打印请求信息
|
||||||
|
postBytes := buf.Bytes()
|
||||||
|
g.Log().Debugf(ctx, "[UploadToMinIO] 请求URL: oss/file/uploadFile, 文件: %s, Body大小: %d bytes, Boundary: %s",
|
||||||
|
localFilePath, len(postBytes), mw.Boundary())
|
||||||
|
|
||||||
|
response, err := client.Post(ctx, "oss/file/uploadFile", postBytes)
|
||||||
|
if err != nil {
|
||||||
|
glog.Error(ctx, err)
|
||||||
|
return nil, fmt.Errorf("调用OSS上传服务失败: %v", err)
|
||||||
|
}
|
||||||
|
defer response.Close()
|
||||||
|
|
||||||
|
body := response.ReadAll()
|
||||||
|
|
||||||
|
// 调试:打印原始响应
|
||||||
|
g.Log().Debugf(ctx, "[UploadToMinIO] OSS原始响应: %s", string(body))
|
||||||
|
|
||||||
|
// 解析标准 GoFrame 响应格式 {code, message, data}
|
||||||
|
var apiResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data *uploadFileRes `json:"data"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("响应解析失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Code != 200 && apiResp.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("OSS上传失败: %s", apiResp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log().Infof(ctx, "[UploadToMinIO] 上传成功 fileURL=%s size=%d", apiResp.Data.FileURL, apiResp.Data.FileSize)
|
||||||
|
return apiResp.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CleanupConcat 清理输入视频文件
|
// CleanupConcat 清理输入视频文件
|
||||||
func CleanupConcat(paths []string) {
|
func CleanupConcat(paths []string) {
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
|
|||||||
86
sql/transcribe_task.sql
Normal file
86
sql/transcribe_task.sql
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
-- transcribe_task 语音转文字任务表(批次头)
|
||||||
|
CREATE TABLE IF NOT EXISTS transcribe_task (
|
||||||
|
id BIGSERIAL NOT NULL,
|
||||||
|
task_id VARCHAR(64) NOT NULL,
|
||||||
|
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
progress INT NOT NULL DEFAULT 0,
|
||||||
|
total_files INT NOT NULL DEFAULT 0,
|
||||||
|
success_files INT NOT NULL DEFAULT 0,
|
||||||
|
fail_files INT NOT NULL DEFAULT 0,
|
||||||
|
model VARCHAR(32) NOT NULL DEFAULT 'medium',
|
||||||
|
language VARCHAR(10) NOT NULL DEFAULT 'zh',
|
||||||
|
threshold DECIMAL(4,2) NOT NULL DEFAULT 0.30,
|
||||||
|
input_type VARCHAR(10) NOT NULL DEFAULT 'upload',
|
||||||
|
input_data TEXT,
|
||||||
|
file_names TEXT,
|
||||||
|
callback_url TEXT,
|
||||||
|
result TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE transcribe_task IS '语音转文字异步任务批次头表';
|
||||||
|
COMMENT ON COLUMN transcribe_task.id IS '主键ID';
|
||||||
|
COMMENT ON COLUMN transcribe_task.task_id IS '任务批次唯一标识';
|
||||||
|
COMMENT ON COLUMN transcribe_task.tenant_id IS '租户ID';
|
||||||
|
COMMENT ON COLUMN transcribe_task.status IS '任务状态:pending等待/running处理中/success成功/failed失败';
|
||||||
|
COMMENT ON COLUMN transcribe_task.progress IS '处理进度(0-100)';
|
||||||
|
COMMENT ON COLUMN transcribe_task.total_files IS '文件总数';
|
||||||
|
COMMENT ON COLUMN transcribe_task.success_files IS '成功文件数';
|
||||||
|
COMMENT ON COLUMN transcribe_task.fail_files IS '失败文件数';
|
||||||
|
COMMENT ON COLUMN transcribe_task.model IS 'whisper模型:tiny/base/small/medium/large';
|
||||||
|
COMMENT ON COLUMN transcribe_task.language IS '语言代码:zh/en/ja等';
|
||||||
|
COMMENT ON COLUMN transcribe_task.threshold IS '场景检测阈值(0.1-0.5)';
|
||||||
|
COMMENT ON COLUMN transcribe_task.input_type IS '输入类型:upload文件上传/url远程URL';
|
||||||
|
COMMENT ON COLUMN transcribe_task.input_data IS '输入数据JSON:上传模式存文件路径列表,URL模式存URL列表';
|
||||||
|
COMMENT ON COLUMN transcribe_task.file_names IS '文件名列表JSON,用于展示';
|
||||||
|
COMMENT ON COLUMN transcribe_task.callback_url IS '任务完成后的回调地址(可选)';
|
||||||
|
COMMENT ON COLUMN transcribe_task.result IS '完整的处理结果JSON(成功后填充)';
|
||||||
|
COMMENT ON COLUMN transcribe_task.error_message IS '错误信息,失败后填充';
|
||||||
|
COMMENT ON COLUMN transcribe_task.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN transcribe_task.updated_at IS '更新时间';
|
||||||
|
COMMENT ON COLUMN transcribe_task.deleted_at IS '删除时间(软删除)';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_transcribe_task_task_id ON transcribe_task(task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transcribe_task_status ON transcribe_task(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transcribe_task_created_at ON transcribe_task(created_at);
|
||||||
|
|
||||||
|
-- transcribe_task_detail 语音转文字任务明细表(每个视频一条)
|
||||||
|
CREATE TABLE IF NOT EXISTS transcribe_task_detail (
|
||||||
|
id BIGSERIAL NOT NULL,
|
||||||
|
task_id VARCHAR(64) NOT NULL,
|
||||||
|
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
file_index INT NOT NULL DEFAULT 0,
|
||||||
|
file_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
transcribed_text TEXT,
|
||||||
|
scenes TEXT,
|
||||||
|
audio_size BIGINT DEFAULT 0,
|
||||||
|
audio_duration VARCHAR(32) DEFAULT '',
|
||||||
|
model VARCHAR(32) DEFAULT '',
|
||||||
|
language VARCHAR(10) DEFAULT '',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE transcribe_task_detail IS '语音转文字任务明细表(每视频一条)';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.id IS '主键ID';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.task_id IS '所属任务批次ID';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.tenant_id IS '租户ID';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.file_index IS '文件在批次中的序号(从0开始)';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.file_name IS '文件名';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.transcribed_text IS '语音识别文字';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.scenes IS '分镜分析JSON';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.audio_size IS '音频文件大小(字节)';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.audio_duration IS '音频时长';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.model IS 'whisper模型';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.language IS '语言代码';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.error_message IS '错误信息,失败后填充';
|
||||||
|
COMMENT ON COLUMN transcribe_task_detail.created_at IS '创建时间';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transcribe_task_detail_task_id ON transcribe_task_detail(task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transcribe_task_detail_file_idx ON transcribe_task_detail(task_id, file_index);
|
||||||
Reference in New Issue
Block a user