代码初始化

This commit is contained in:
2026-05-20 11:32:39 +08:00
parent 219b7e39c7
commit e76bf57d54
20 changed files with 1585 additions and 309 deletions

449
service/asr/task_service.go Normal file
View 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
}

View File

@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@@ -24,11 +25,10 @@ const (
backendCpp // whisper.cpp (whisper-cpp)
)
// WhisperService 语音识别服务
type WhisperService struct{}
type whisperService struct{}
// Whisper 语音识别服务单例
var Whisper = new(WhisperService)
var Whisper = new(whisperService)
// TranscribeReq 语音识别请求
type TranscribeReq struct {
@@ -54,7 +54,7 @@ type Segment struct {
}
// 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. 校验音频文件
if _, err = os.Stat(req.AudioPath); os.IsNotExist(err) {
return nil, fmt.Errorf("音频文件不存在: %s", req.AudioPath)
@@ -94,7 +94,7 @@ func (s *WhisperService) Transcribe(ctx context.Context, req *TranscribeReq) (re
}
// 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)
modelDir := g.Cfg().MustGet(ctx, "whisper.model_dir", "").String()
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
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
pythonPath, err := exec.LookPath("python3")
if err != nil {
@@ -160,7 +160,7 @@ func (s *WhisperService) transcribeWithPython(ctx context.Context, req *Transcri
}
// 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))
txtPaths := []string{
filepath.Join(outputDir, baseName+".txt"),
@@ -201,7 +201,7 @@ func cleanTranscript(text string) string {
}
// detectBackend 检测可用的 whisper 后端,返回后端类型和可执行路径
func (s *WhisperService) detectBackend() (WhisperBackend, string) {
func (s *whisperService) detectBackend() (WhisperBackend, string) {
// 1. 优先检测 C++ 版 whisper.cpp最快但参数格式不同
for _, name := range []string{"whisper-cpp", "whisper-cli"} {
if path, err := exec.LookPath(name); err == nil {
@@ -242,7 +242,7 @@ func (s *WhisperService) detectBackend() (WhisperBackend, string) {
}
// resolveCppModelPath 查找或下载 whisper.cpp 模型文件
func (s *WhisperService) resolveCppModelPath(model string) string {
func (s *whisperService) resolveCppModelPath(model string) string {
modelName := strings.TrimPrefix(model, "ggml-")
modelName = strings.TrimSuffix(modelName, ".bin")
@@ -262,8 +262,20 @@ func (s *WhisperService) resolveCppModelPath(model string) string {
altPaths := []string{
cppModelName,
filepath.Join(home, ".cache", "whisper", "ggml-"+modelName+"-q5_0.bin"),
"/opt/homebrew/share/whisper-cpp/models/" + cppModelName,
"/usr/local/share/whisper-cpp/models/" + cppModelName,
}
// macOS: Homebrew 安装的 whisper.cpp 模型路径
if runtime.GOOS == "darwin" {
altPaths = append(altPaths,
"/opt/homebrew/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 {
if _, err := os.Stat(p); err == nil {
@@ -310,7 +322,7 @@ func (s *WhisperService) resolveCppModelPath(model string) string {
}
// 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"
out, err := os.Create(tmpPath)
if err != nil {
@@ -346,7 +358,7 @@ func (s *WhisperService) downloadFile(url, destPath string, timeout time.Duratio
}
// transcribeWithCpp 使用 whisper.cppC++ 版,参数格式不同)
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)
baseName := strings.TrimSuffix(filepath.Base(req.AudioPath), filepath.Ext(req.AudioPath))
outputPrefix := filepath.Join(outputDir, baseName)

View File

@@ -13,10 +13,10 @@ import (
)
// AudioExtractService 音频提取服务
type AudioExtractService struct{}
type audioExtractService struct{}
// AudioExtract 音频提取服务单例
var AudioExtract = new(AudioExtractService)
var AudioExtract = new(audioExtractService)
// ExtractAudioReq 提取音频请求
type ExtractAudioReq struct {
@@ -32,7 +32,7 @@ type ExtractAudioRes struct {
}
// 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. 校验视频文件存在
if _, err = os.Stat(req.VideoPath); os.IsNotExist(err) {
return nil, fmt.Errorf("视频文件不存在: %s", req.VideoPath)
@@ -117,7 +117,7 @@ func (s *AudioExtractService) Extract(ctx context.Context, req *ExtractAudioReq)
}
// getFFmpegPath 获取 ffmpeg 可执行路径
func (s *AudioExtractService) getFFmpegPath() (string, error) {
func (s *audioExtractService) getFFmpegPath() (string, error) {
// 1. 优先从配置读取
ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String()
if ffmpegPath != "" {
@@ -135,7 +135,7 @@ func (s *AudioExtractService) getFFmpegPath() (string, error) {
}
// 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
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
@@ -168,7 +168,7 @@ func (s *AudioExtractService) getAudioDuration(ctx context.Context, ffmpegPath s
}
// 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)
if err != nil {
return nil, err

View File

@@ -19,10 +19,18 @@ var (
DetectedWhisperPath string
)
// EnsureDependencies 启动时检查并安装 ffmpeg 和 whisper
func EnsureDependencies(ctx context.Context) {
func init() {
ensureDependencies()
}
// ensureDependencies 启动时检查并安装 ffmpeg 和 whisper
func ensureDependencies() {
ctx := context.Background()
g.Log().Info(ctx, "========== 检查依赖环境 ==========")
// 打印当前运行环境信息
g.Log().Infof(ctx, "平台: %s/%s, Docker: %v", runtime.GOOS, runtime.GOARCH, isRunningInContainer())
ensureFFmpeg(ctx)
ensureWhisper(ctx)
resolveWhisperPath(ctx)
@@ -35,6 +43,26 @@ func EnsureDependencies(ctx context.Context) {
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 可用
func ensureFFmpeg(ctx context.Context) {
if _, err := exec.LookPath("ffmpeg"); err == nil {
@@ -46,52 +74,147 @@ func ensureFFmpeg(ctx context.Context) {
switch runtime.GOOS {
case "darwin":
// 检查是否安装了 Homebrew
if _, err := exec.LookPath("brew"); err != nil {
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 未检测到 Homebrew请手动安装:\n brew install ffmpeg")
return
}
cmd := exec.CommandContext(ctx, "brew", "install", "ffmpeg")
output, err := cmd.CombinedOutput()
if err != nil {
g.Log().Errorf(ctx, "[ffmpeg] ❌ 安装失败: %v\n%s", err, string(output))
return
}
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
installFFmpegOnMac(ctx)
case "linux":
// 尝试 apt
if _, err := exec.LookPath("apt"); err == nil {
cmd := exec.CommandContext(ctx, "sudo", "apt", "install", "-y", "ffmpeg")
output, err := cmd.CombinedOutput()
if err != nil {
g.Log().Errorf(ctx, "[ffmpeg] ❌ apt 安装失败: %v\n%s", err, string(output))
return
}
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
return
}
// 尝试 yum
if _, err := exec.LookPath("yum"); err == nil {
cmd := exec.CommandContext(ctx, "sudo", "yum", "install", "-y", "ffmpeg")
output, err := cmd.CombinedOutput()
if err != nil {
g.Log().Errorf(ctx, "[ffmpeg] ❌ yum 安装失败: %v\n%s", err, string(output))
return
}
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
return
}
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 请手动安装: sudo apt install ffmpeg")
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 {
g.Log().Warningf(ctx, "[ffmpeg] ⚠ 未检测到 Homebrew请手动安装:\n brew install ffmpeg")
return
}
cmd := exec.CommandContext(ctx, "brew", "install", "ffmpeg")
output, err := cmd.CombinedOutput()
if err != nil {
g.Log().Errorf(ctx, "[ffmpeg] ❌ 安装失败: %v\n%s", err, string(output))
return
}
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
}
// installFFmpegOnLinux 在 Linux含 Docker上安装 ffmpeg
func installFFmpegOnLinux(ctx context.Context) {
// Docker 容器通常以 root 运行,不需要 sudo
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()
if err != nil {
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
}
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
return
}
// 3. 尝试 yum (CentOS/RHEL)
if _, err := exec.LookPath("yum"); err == nil {
args := []string{"install", "-y", "ffmpeg"}
if sudoPrefix != "" {
args = append([]string{sudoPrefix}, args...)
}
cmd := exec.CommandContext(ctx, "yum", args...)
output, err := cmd.CombinedOutput()
if err != nil {
g.Log().Errorf(ctx, "[ffmpeg] ❌ yum 安装失败: %v\n%s", err, string(output))
return
}
g.Log().Info(ctx, "[ffmpeg] ✔ 安装成功")
return
}
if inContainer {
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++ 版,速度更快)
func ensureWhisper(ctx context.Context) {
// 1. 检查是否已有 whisper-cppC++ 版,最快)
// exec.LookPath 在 Windows 上会自动查找 .exe 后缀
if path, err := exec.LookPath("whisper-cpp"); err == nil {
g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装: %s", path)
return
@@ -101,16 +224,19 @@ func ensureWhisper(ctx context.Context) {
return
}
// 2. 检查 Homebrew 安装目录(即使不在 PATH 也能找到)
if p := findHomebrewWhisperCpp(); p != "" {
DetectedWhisperPath = p
// 自动添加到 PATH 环境变量
addToShellPath(ctx, filepath.Dir(p))
g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装(自动检测): %s", p)
return
// 2. 仅在 macOS 上检查 Homebrew 安装目录(即使不在 PATH 也能找到)
if runtime.GOOS == "darwin" {
if p := findHomebrewWhisperCpp(); p != "" {
DetectedWhisperPath = p
if !inContainer {
addToShellPath(ctx, filepath.Dir(p))
}
g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装(自动检测): %s", p)
return
}
}
// 3. 尝试安装 whisper-cppC++ 版
// 3. 仅在 macOS 上尝试使用 Homebrew 安装 C++ 版
if runtime.GOOS == "darwin" {
if _, err := exec.LookPath("brew"); err == nil {
g.Log().Infof(ctx, "[whisper] 安装 C++ 版 (brew install whisper-cpp)...")
@@ -118,9 +244,9 @@ func ensureWhisper(ctx context.Context) {
output, err := cmd.CombinedOutput()
if err == nil {
g.Log().Info(ctx, "[whisper] ✔ C++ 版安装成功")
// 装好后把 Homebrew bin 加到 PATH
addToShellPath(ctx, getHomebrewBinDir())
// 检测安装路径
if !inContainer {
addToShellPath(ctx, getHomebrewBinDir())
}
if p := findHomebrewWhisperCpp(); p != "" {
DetectedWhisperPath = p
}
@@ -151,16 +277,25 @@ func ensureWhisper(ctx context.Context) {
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()
if err != nil {
g.Log().Errorf(ctx, "[whisper] ❌ pip 安装失败: %v\n%s", err, string(output))
return
// 尝试 --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 {
g.Log().Errorf(ctx, "[whisper] ❌ pip 安装失败: %v\n%s", err, string(output))
return
}
}
g.Log().Info(ctx, "[whisper] ✔ Python 版安装成功")
// 安装后自动配置 PATH
configureWhisperPath(ctx)
// 安装后自动配置 PATH(仅在非容器、非 Windows 环境)
if !inContainer && runtime.GOOS != "windows" {
configureWhisperPath(ctx)
}
}
// resolveWhisperPath 自动找到 whisper 二进制路径并存储
@@ -174,6 +309,7 @@ func resolveWhisperPath(ctx context.Context) {
}
// 1. 优先检测 C++ 版本(快 3-5 倍)
// exec.LookPath 在 Windows 上自动查找 .exe 后缀
for _, name := range []string{"whisper-cpp", "whisper-cli"} {
if path, err := exec.LookPath(name); err == nil {
DetectedWhisperPath = path
@@ -182,11 +318,13 @@ func resolveWhisperPath(ctx context.Context) {
}
}
// 2. 在 Homebrew 目录查找 C++ 版本
if p := findHomebrewWhisperCpp(); p != "" {
DetectedWhisperPath = p
g.Log().Infof(ctx, "[whisper] ✔ C++ 版(自动检测): %s", p)
return
// 2. macOS 上查找 Homebrew 目录下的 C++ 版本
if runtime.GOOS == "darwin" {
if p := findHomebrewWhisperCpp(); p != "" {
DetectedWhisperPath = p
g.Log().Infof(ctx, "[whisper] ✔ C++ 版(自动检测): %s", p)
return
}
}
// 3. 从 PATH 查找 Python 版 whisper
@@ -215,6 +353,10 @@ func getWhisperCandidates() []string {
// 通过 python 探针获取 user-site bin 目录
if p := getUserPythonBin(); p != "" {
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 路径
@@ -233,6 +375,22 @@ func getWhisperCandidates() []string {
candidates = append(candidates,
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
@@ -361,6 +519,17 @@ func addToShellPath(ctx context.Context, dir string) {
return
}
// 容器环境不修改 shell 配置(无意义)
if inContainer {
return
}
// Windows 环境不修改 shell rc 文件(使用系统环境变量)
if runtime.GOOS == "windows" {
g.Log().Infof(ctx, "[setup] Windows 环境,请手动将 %s 添加到系统 PATH 环境变量", dir)
return
}
// 检查是否已在 PATH 中
currentPath := os.Getenv("PATH")
if strings.Contains(currentPath, dir) {

View File

@@ -1,27 +1,34 @@
package video
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"os"
"os/exec"
"path/filepath"
"strings"
commonHttp "gitea.com/red-future/common/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
)
// ConcatService 视频拼接服务
type ConcatService struct{}
type concatService struct{}
// Concat 视频拼接服务单例
var Concat = new(ConcatService)
var Concat = new(concatService)
// ConcatReq 视频拼接请求
type ConcatReq struct {
VideoPaths []string // 视频文件路径列表(按此顺序拼接)
OutputPath string // 输出视频文件路径,空则自动生成
Method string // 拼接方式: auto/fast/reencode默认 auto
Upload bool // 是否上传到MinIO
}
// ConcatRes 视频拼接响应
@@ -32,10 +39,11 @@ type ConcatRes struct {
DurationStr string `json:"durationStr"` // 可读时长
MethodUsed string `json:"methodUsed"` // 实际使用的拼接方式
InputFiles int `json:"inputFiles"` // 输入文件数
FileURL string `json:"fileURL"` // MinIO访问地址上传后返回
}
// 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 {
return nil, fmt.Errorf("至少需要2个视频才能拼接")
}
@@ -98,11 +106,21 @@ func (s *ConcatService) Concat(ctx context.Context, req *ConcatReq) (res *Concat
MethodUsed: methodUsed,
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
}
// 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")
var lines []string
@@ -132,7 +150,7 @@ func (s *ConcatService) concatByDemuxer(ctx context.Context, ffmpegPath string,
}
// 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)
// 1. 获取所有视频的分辨率,确定统一输出尺寸
@@ -211,7 +229,7 @@ func (s *ConcatService) concatByFilter(ctx context.Context, ffmpegPath string, i
}
// 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")
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
ffprobePath = "ffprobe"
@@ -233,7 +251,7 @@ func (s *ConcatService) getVideoResolution(ctx context.Context, ffmpegPath, vide
}
// 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")
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
ffprobePath = "ffprobe"
@@ -256,7 +274,7 @@ func (s *ConcatService) getVideoDuration(ctx context.Context, ffmpegPath, videoP
return duration, nil
}
func (s *ConcatService) getFFmpegPath() (string, error) {
func (s *concatService) getFFmpegPath() (string, error) {
ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String()
if ffmpegPath != "" {
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)
}
// 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 清理输入视频文件
func CleanupConcat(paths []string) {
for _, p := range paths {