Compare commits

3 Commits
master ... dev

Author SHA1 Message Date
9bc48060dc ci/cd调整 2026-06-10 16:10:10 +08:00
lmk
883f09e6df 调用模型,返回事件的时间线 2026-05-27 16:02:44 +08:00
lmk
556fa2f3ca 修复上传minio路径错误 2026-05-27 09:27:52 +08:00
29 changed files with 1269 additions and 85 deletions

185
API文档.md Normal file
View File

@@ -0,0 +1,185 @@
# Marlin-2B Video VLM API 文档
## 基础信息
- 服务地址:`http://0.0.0.0:8900`
- 模型Marlin-2B
- 设备Apple Silicon (MPS) / CUDA / CPU
---
## 1. 健康检查接口
**接口路径**`GET /health`
**请求示例**
```bash
curl http://localhost:8900/health
```
**返回参数**
| 字段名 | 类型 | 说明 |
|--------|------|------|
| status | string | 状态ok已加载或 loading加载中 |
| model | string | 模型名称,固定为 "Marlin-2B" |
| device | string | 运行设备mps/cuda/cpu |
**返回示例**
```json
{
"status": "ok",
"model": "Marlin-2B",
"device": "mps"
}
```
---
## 2. 视频字幕生成接口
**接口路径**`POST /caption`
**功能说明**:为视频生成结构化字幕,包括场景描述和带时间戳的事件列表。
**请求参数**multipart/form-data
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| video | 文件 | 是 | 视频文件(支持 mp4, avi, mov, webm 等格式) |
| max_new_tokens | int | 否 | 最大生成 token 数,默认值 2048 |
**请求示例**
```bash
curl -X POST http://localhost:8900/caption \
-F "video=@/path/to/video.mp4" \
-F "max_new_tokens=2048"
```
**返回参数**
| 字段名 | 类型 | 说明 |
|--------|------|------|
| caption | string \| null | 完整的原始字幕文本 |
| scene | string \| null | 场景描述段落 |
| events | array \| null | 事件列表,每个事件包含 start/end/description |
**events 数组元素**
| 字段名 | 类型 | 说明 |
|--------|------|------|
| start | float | 事件开始时间(秒) |
| end | float | 事件结束时间(秒) |
| description | string | 事件描述 |
**返回示例**
```json
{
"caption": "Scene: ... Events: ...",
"scene": "这是一个室内场景...",
"events": [
{
"start": 0.0,
"end": 5.0,
"description": "一个人走进房间"
},
{
"start": 5.5,
"end": 10.0,
"description": "这个人坐在沙发上"
}
]
}
```
---
## 3. 事件时间定位接口
**接口路径**`POST /find`
**功能说明**:在视频中查找指定事件发生的时间区间(时间定位)。
**请求参数**multipart/form-data
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| video | 文件 | 是 | 视频文件 |
| event | 字符串 | 是 | 自然语言事件查询,例如 "一个人进入房间" |
**请求示例**
```bash
curl -X POST http://localhost:8900/find \
-F "video=@/path/to/video.mp4" \
-F "event=一个人进入房间"
```
**返回参数**
| 字段名 | 类型 | 说明 |
|--------|------|------|
| raw | string \| null | 原始模型输出,例如 "From 14.3 to 18.2." |
| span | array \| null | 时间区间 [开始时间, 结束时间],单位秒 |
| format_ok | bool | 输出格式是否符合训练格式 |
**返回示例**
```json
{
"raw": "From 14.3 to 18.2.",
"span": [14.3, 18.2],
"format_ok": true
}
```
---
## 4. 自定义提示生成接口
**接口路径**`POST /generate`
**功能说明**:使用自定义提示词与视频进行交互,实现更灵活的问答。
**注意**:此接口需要安装 `torchvision` 库才能正常工作。
**请求参数**multipart/form-data
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| video | 文件 | 是 | 视频文件 |
| prompt | 字符串 | 是 | 自定义文本提示词 |
| max_new_tokens | int | 否 | 最大生成 token 数,默认值 512 |
| do_sample | bool | 否 | 是否启用采样,默认 false确定性输出 |
| temperature | float | 否 | 温度参数,控制随机性,默认 1.0 |
| top_p | float | 否 | top-p 采样参数,默认 1.0 |
**请求示例**
```bash
curl -X POST http://localhost:8900/generate \
-F "video=@/path/to/video.mp4" \
-F "prompt=描述这个视频的内容" \
-F "max_new_tokens=512" \
-F "temperature=0.7"
```
**返回参数**
| 字段名 | 类型 | 说明 |
|--------|------|------|
| text | string | 生成的文本内容 |
**返回示例**
```json
{
"text": "这个视频展示了..."
}
```
---
## 错误响应
当请求失败时,接口返回 HTTP 错误码和错误信息:
| HTTP 状态码 | 说明 |
|-------------|------|
| 400 | 请求参数错误 |
| 500 | 服务器内部错误 |
| 503 | 模型尚未加载完成 |
**错误响应示例**
```json
{
"detail": "Caption failed: 错误详情"
}
```

194
CLAUDE.md Normal file
View File

@@ -0,0 +1,194 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# 下载依赖
go mod download
# 编译
go build -o media main.go
# 运行(开发)
go run main.go
# 格式化代码
go fmt ./...
# 代码检查
go vet ./...
# Docker 构建(多阶段构建,包含 FFmpeg 和 whisper 运行时)
docker build -t media .
# Docker 运行
docker run -p 3010:3010 media
```
## Architecture
这是一个多媒体处理微服务项目,基于 GoFrame 框架开发,提供视频处理、音频提取、语音识别等功能。
### 目录结构
```
main.go # 应用入口
config.yml # 配置文件
consts/ # 常量定义
- video/ # 视频相关常量(包括视频分析任务状态)
controller/ # HTTP 控制器层(路由入口)
- audio/ # 音频相关接口
- video/ # 视频相关接口(拼接、剪切、分析)
- common/ # 公共工具
- scene/ # 场景检测接口
- image/ # 图片处理接口
service/ # 业务逻辑层
- video/ # 视频服务(拼接、剪切、分析、分析队列)
- audio/ # 音频提取服务
- asr/ # 语音识别Whisper
- scene/ # 场景检测
- image/ # 图片处理
- setup/ # 初始化服务
dao/ # 数据访问层
- audio/ # ASR 任务数据访问
- video/ # 视频分析任务数据访问
- image/
model/ # 数据模型
- dto/ # 传输对象(请求/响应)
- video/ # 视频相关 DTO包括视频分析
- entity/ # 数据库实体
- video/ # 视频相关实体(包括分析任务)
resource/ # 静态资源(日志、临时文件)
sql/ # 数据库 SQL
- video_analysis_task.sql - 视频分析任务表建表SQL
```
### 核心功能
| 功能 | 说明 | 依赖 |
|------|------|------|
| 视频拼接 | 支持多个视频拼接,提供 fast无损 concat demuxer和 reencode重编码归一化两种模式可上传结果到 MinIO支持同步和异步任务 | FFmpeg |
| 视频分镜剪切 | 根据分镜时间片段列表剪切视频并重新拼接输出,支持同步和异步任务 | FFmpeg |
| 音频提取 | 从视频文件中提取音频,支持 mp3/aac/wav/ogg/flac 多种格式 | FFmpeg |
| 语音识别 | 异步语音转文字任务,基于 OpenAI Whisper支持 whisper.cpp 加速 | FFmpeg + Whisper/whisper.cpp |
| 场景检测 | 视频场景切分检测,提取关键帧,输出场景信息 | FFmpeg + ffprobe |
| 视频分析 | 基于 Marlin-2B Video VLM 大模型进行视频理解,自动生成场景描述、事件切分和向量化,存入 RAG 系统 | FFmpeg + 外部 Marlin-2B VLM 服务 |
### 启动初始化
- `setup` 包在 `init()` 阶段自动执行,启动时会检查 FFmpeg 和 Whisper 依赖是否可用
- 自动检测 whisper-cpp > whisper > python -m whisper 三个优先级
- 如果依赖缺失会输出警告提示安装
### API 端点
**视频拼接:**
- `POST /video/concat` - 视频拼接URL 输入,同步)
- `POST /video/concat/async` - 视频拼接URL 输入,异步)
- `POST /video/concat/upload` - 视频拼接(文件上传,同步)
- `POST /video/concat/upload/async` - 视频拼接(文件上传,异步)
- `GET /video/concat/task/{taskId}` - 查询异步拼接任务结果
**视频分镜剪切:**
- `POST /video/cut` - 视频分镜剪切URL 输入,同步)
- `POST /video/cut/async` - 视频分镜剪切URL 输入,异步)
- `GET /video/cut/task/{taskId}` - 查询异步剪切任务结果
**语音识别:**
- `POST /audio/transcribe` - 创建语音转文字异步任务
- `GET /audio/task/{taskId}` - 获取转写任务详情
- `GET /audio/task/{taskId}/progress` - 获取任务进度
- `GET /audio/tasks` - 获取任务列表
**视频分析(规划中):**
- `POST /video/analysis` - 创建视频分析异步任务(基于 Marlin-2B VLM
- `GET /video/analysis/task/{taskId}` - 查询分析任务结果
- `GET /video/analysis/task/{taskId}/progress` - 查询分析任务进度
- `POST /video/analysis/retry/{taskId}` - 重试失败的分析事件
> **Note**: `scene` 场景检测和 `image` 图片处理服务目录已创建,但 HTTP 端点尚未实现暴露。音频提取服务已实现但尚未暴露。
### 依赖外部服务
- PostgreSQL - 数据存储
- Redis - 缓存
- Consul - 服务发现
- Jaeger - 链路追踪
- OSS/MinIO - 文件存储(通过内部 oss 微服务上传)
- FFmpeg - 多媒体处理
- Whisper - 语音识别
- Marlin-2B VLM 服务 - 视频理解大模型(提供字幕生成、事件定位功能)
### 内部依赖
项目依赖内部私有公共包 `gitea.redpowerfuture.com/red-future/common`,包含 HTTP 路由注册、用户信息解析、Consul、Jaeger 等基础设施封装。Docker 构建过程中已配置访问凭证。
### Docker 镜像
多阶段构建镜像包含:
- 编译后的二进制
- FFmpeg 运行时
- Python 3 + openai-whisperPython 版本,用于语音识别)
- 非 root 用户 `appuser` 运行
- 暴露端口 `3010`
### 架构设计
**分层架构:**
- `controller` - HTTP 入口,参数解析,调用 Service返回响应
- `service` - 业务逻辑实现,每个功能领域一个子包
- `dao` - 数据访问层,数据库操作
- `model` - 数据模型,`dto` 存放请求/响应传输对象,`entity` 存放数据库实体
**设计模式:**
- 使用 GoFrame 框架的依赖注入模式
- 所有 Service 和 Controller 都使用**单例模式**`var Xxx = new(XxxStruct)`
- 遵循标准的 Go 命名约定
- 临时文件处理完需要**及时清理**(使用 `defer os.Remove()`
**异步任务处理:**
- 长时任务(视频拼接、视频剪切、语音识别)都支持**异步执行**
- 同步模式直接等待结果返回,异步模式创建任务后立即返回任务 ID
- 异步任务状态持久化到数据库,可通过任务 ID 查询进度和结果
- 支持回调 URL任务完成后会回调通知调用方
- 任务执行使用 goroutine 异步处理
**用户身份:**
- 所有接口优先从请求头 `Authorization` / `X-User-Info` 解析用户信息
- 解析失败使用默认 `admin` / tenantId=1 用于开发和调试
### 配置
主要配置在 `config.yml`:
**Server:**
- `server.address` - 监听地址(默认 `:3010`
- `server.clientMaxBodySize` - 上传文件大小限制(默认 `200MB`
**限流:**
- `rate.limit` - 每秒请求限制(默认 200
- `rate.burst` - 突发请求允许量(默认 300
**FFmpeg:**
- `ffmpeg.path` - FFmpeg 可执行文件路径,留空则从 PATH 自动查找
- `ffmpeg.temp_dir` - 临时文件目录(存放上传的视频和处理输出)
**Whisper 语音识别:**
- `whisper.path` - Whisper 可执行文件路径,留空自动查找
- `whisper.model` - 默认模型tiny(最快)/base/small/medium
- `whisper.language` - 默认语言zh=中文, en=英文)
- `whisper.model_dir` - 模型缓存目录,留空使用默认 (~/.cache/whisper/)
- `whisper.threads` - CPU 线程数(限制资源占用,建议 2-4
**视频分析:**
- `analysis.concurrency` - 并发处理数,控制同时处理的分析任务数量(默认 1串行处理建议不超过 CPU 核心数)
- `analysis.maxRetries` - 单事件最大重试次数,失败时自动重试(默认 3
**外部服务:**
- `database` - PostgreSQL 数据库配置
- `redis` - Redis 配置
- `consul` - Consul 服务发现配置
- `jaeger` - Jaeger 链路追踪配置
- `filePrefix` - OSS/MinIO 文件访问地址前缀

View File

@@ -1,5 +1,5 @@
# 阶段1: 构建
FROM golang:1.26-alpine AS builder
FROM golang:alpine AS builder
RUN apk add --no-cache git ca-certificates tzdata
@@ -10,12 +10,6 @@ ENV GO111MODULE=on
ENV GOPROXY=https://goproxy.cn,direct
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=auto
ENV GOPRIVATE=gitea.com/red-future/common
# 配置git使用私有Gitea仓库
RUN git config --global url."http://x-token-auth:9b31146aa8c10a7cb4f2e49dcee0934a223be1076289810e1ad98b968066c2bc@116.204.74.41:3000/red-future/common.git".insteadOf "https://gitea.com/red-future/common.git" && \
git config --global credential.helper store
WORKDIR /build
COPY . .
@@ -24,30 +18,6 @@ RUN go mod download && go mod tidy
RUN go build -ldflags="-s -w" -o main ./main.go
# 阶段2: 运行
FROM alpine:3.19
# 安装运行时依赖: 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
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY --from=builder /build/main .
COPY --from=builder /build/config.yml ./
RUN mkdir -p /app/resource/log/run \
/app/resource/log/server \
&& adduser -D -u 1000 appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 3010

View File

@@ -59,6 +59,19 @@ ffmpeg:
# 临时文件目录(上传的视频和提取的音频)
temp_dir: "resource/temp"
# 视频分析配置
analysis:
# 视频永久存储目录(按 taskId 子目录组织,不删除)
video_dir: "resource/videos"
# Caption 接口地址
caption_url: "http://192.168.3.49:8900/caption"
# Caption 接口超时时间(单个视频分析可能耗时较长)
caption_timeout: "30m"
# Max new tokens传递给 Caption 接口的参数)
max_new_tokens: "2048"
# 是否启用 mock 模式true: 返回模拟数据false: 调用真实 caption 接口)
mock_caption: true
# OSS/MinIO 文件上传配置
filePrefix: "http://116.204.74.41:9000"

View File

@@ -7,8 +7,8 @@ import (
dto "media/model/dto/audio"
service "media/service/asr"
"gitea.com/red-future/common/beans"
"gitea.com/red-future/common/utils"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/frame/g"
)

View File

@@ -0,0 +1,38 @@
package video
import (
"context"
"fmt"
dto "media/model/dto/video"
service "media/service/video"
"github.com/gogf/gf/v2/frame/g"
)
type analysis struct{}
var Analysis = new(analysis)
// Analysis 提交视频分析任务 POST /video/analysis
func (c *analysis) Analysis(ctx context.Context, req *dto.AnalysisReq) (res *dto.CreateAnalysisTaskRes, err error) {
ctx = withUser(ctx)
g.Log().Infof(ctx, "[视频分析] 收到请求 入参: total_videos=%d, callback=%s", len(req.VideoURLs), req.CallbackURL)
if len(req.VideoURLs) == 0 {
return nil, fmt.Errorf("视频URL列表不能为空")
}
taskID, taskErr := service.Analysis.CreateAsyncTask(ctx, req.VideoURLs, req.CallbackURL)
if taskErr != nil {
return nil, taskErr
}
return &dto.CreateAnalysisTaskRes{TaskID: taskID}, nil
}
// GetAnalysisTask 查询视频分析任务结果 GET /video/analysis/task/{taskId}
func (c *analysis) GetAnalysisTask(ctx context.Context, req *dto.GetAnalysisTaskReq) (res *dto.GetAnalysisTaskRes, err error) {
ctx = withUser(ctx)
return service.Analysis.GetTaskResult(ctx, req.TaskID)
}

View File

@@ -15,8 +15,8 @@ import (
dto "media/model/dto/video"
service "media/service/video"
"gitea.com/red-future/common/beans"
"gitea.com/red-future/common/utils"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/frame/g"
)

View File

@@ -6,7 +6,7 @@ import (
dto "media/model/dto/audio"
entity "media/model/entity/audio"
"gitea.com/red-future/common/db/gfdb"
"gitea.redpowerfuture.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"

View File

@@ -5,7 +5,7 @@ import (
consts "media/consts/audio"
entity "media/model/entity/audio"
"gitea.com/red-future/common/db/gfdb"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
)
var TranscribeTaskDetail = new(transcribeTaskDetailDao)

View File

@@ -0,0 +1,136 @@
package video
import (
"context"
"time"
dto "media/model/dto/video"
entity "media/model/entity/video"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var AnalysisTask = new(analysisTaskDao)
type analysisTaskDao struct{}
const analysisTaskTable = "video_analysis_task"
// Insert 创建任务(排除 id 字段,让数据库自增)
func (d *analysisTaskDao) Insert(ctx context.Context, data *entity.AnalysisTask) (id int64, err error) {
r, err := gfdb.DB(ctx).Model(ctx, analysisTaskTable).
Data(data).
FieldsEx(entity.AnalysisTaskCols.Id).
Insert()
if err != nil {
return 0, err
}
return r.LastInsertId()
}
// GetByTaskID 根据taskId查询任务
func (d *analysisTaskDao) GetByTaskID(ctx context.Context, taskID string) (res *entity.AnalysisTask, err error) {
r, err := gfdb.DB(ctx).Model(ctx, analysisTaskTable).
Where(entity.AnalysisTaskCols.TaskID, taskID).
One()
if err != nil {
return nil, err
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
// UpdateProcessing 更新为处理中
func (d *analysisTaskDao) UpdateProcessing(ctx context.Context, taskID string) error {
_, err := gfdb.DB(ctx).Model(ctx, analysisTaskTable).
Data(g.Map{
entity.AnalysisTaskCols.Status: "processing",
}).
Where(entity.AnalysisTaskCols.TaskID, taskID).
Update()
return err
}
// UpdateProgress 更新视频处理计数
func (d *analysisTaskDao) UpdateProgress(ctx context.Context, taskID string, successCount, failedCount int) error {
_, err := gfdb.DB(ctx).Model(ctx, analysisTaskTable).
Data(g.Map{
entity.AnalysisTaskCols.SuccessCount: successCount,
entity.AnalysisTaskCols.FailedCount: failedCount,
}).
Where(entity.AnalysisTaskCols.TaskID, taskID).
Update()
return err
}
// UpdateSuccess 更新为成功
func (d *analysisTaskDao) UpdateSuccess(ctx context.Context, taskID string, successCount, failedCount int) error {
_, err := gfdb.DB(ctx).Model(ctx, analysisTaskTable).
Data(g.Map{
entity.AnalysisTaskCols.Status: "success",
entity.AnalysisTaskCols.SuccessCount: successCount,
entity.AnalysisTaskCols.FailedCount: failedCount,
entity.AnalysisTaskCols.ErrorMessage: "",
}).
Where(entity.AnalysisTaskCols.TaskID, taskID).
Update()
return err
}
// UpdateError 更新为失败
func (d *analysisTaskDao) UpdateError(ctx context.Context, taskID string, errMsg string) error {
_, err := gfdb.DB(ctx).Model(ctx, analysisTaskTable).
Data(g.Map{
entity.AnalysisTaskCols.Status: "failed",
entity.AnalysisTaskCols.ErrorMessage: errMsg,
}).
Where(entity.AnalysisTaskCols.TaskID, taskID).
Update()
return err
}
// EntityToTaskRes 实体转DTO
func AnalysisEntityToTaskRes(e *entity.AnalysisTask, details []*entity.AnalysisTaskDetail) *dto.GetAnalysisTaskRes {
res := &dto.GetAnalysisTaskRes{
TaskID: e.TaskID,
Status: e.Status,
Total: e.Total,
SuccessCount: e.SuccessCount,
FailedCount: e.FailedCount,
Processed: e.SuccessCount + e.FailedCount,
CreatedAt: gconv.Int64(e.CreatedAt.Timestamp()),
}
if e.CreatedAt == nil {
res.CreatedAt = time.Now().UnixMilli()
}
if e.Status == "failed" {
res.ErrorMessage = e.ErrorMessage
}
// 转换详情列表
for _, d := range details {
item := dto.AnalysisDetailItem{
VideoURL: d.VideoURL,
VideoSavePath: d.VideoSavePath,
Status: d.Status,
FailReason: d.FailReason,
}
// caption_result 如果非空,尝试解析为 interface{}
if d.CaptionResult != "" {
var captionResult interface{}
if err := gconv.Scan(d.CaptionResult, &captionResult); err == nil {
item.CaptionResult = captionResult
} else {
item.CaptionResult = d.CaptionResult
}
}
res.List = append(res.List, item)
}
return res
}

View File

@@ -0,0 +1,99 @@
package video
import (
"context"
entity "media/model/entity/video"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
)
var AnalysisTaskDetail = new(analysisTaskDetailDao)
type analysisTaskDetailDao struct{}
const analysisTaskDetailTable = "video_analysis_task_detail"
// Insert 创建明细
func (d *analysisTaskDetailDao) Insert(ctx context.Context, data *entity.AnalysisTaskDetail) (id int64, err error) {
r, err := gfdb.DB(ctx).Model(ctx, analysisTaskDetailTable).
Data(data).
FieldsEx(entity.AnalysisTaskDetailCols.Id).
Insert()
if err != nil {
return 0, err
}
return r.LastInsertId()
}
// BatchInsert 批量创建明细
func (d *analysisTaskDetailDao) BatchInsert(ctx context.Context, taskID string, videoURLs []string) error {
for _, videoURL := range videoURLs {
detail := &entity.AnalysisTaskDetail{
TaskID: taskID,
VideoURL: videoURL,
Status: "pending",
}
if _, err := d.Insert(ctx, detail); err != nil {
return err
}
}
return nil
}
// GetByTaskID 根据taskId查询所有明细
func (d *analysisTaskDetailDao) GetByTaskID(ctx context.Context, taskID string) (res []*entity.AnalysisTaskDetail, err error) {
r, err := gfdb.DB(ctx).Model(ctx, analysisTaskDetailTable).
Where(entity.AnalysisTaskDetailCols.TaskID, taskID).
Order("id asc").
All()
if err != nil {
return nil, err
}
if r == nil {
return nil, nil
}
err = r.Structs(&res)
return
}
// UpdateSuccess 更新明细为成功
func (d *analysisTaskDetailDao) UpdateSuccess(ctx context.Context, taskID string, videoURL string, videoSavePath string, captionResult string) error {
_, err := gfdb.DB(ctx).Model(ctx, analysisTaskDetailTable).
Data(g.Map{
entity.AnalysisTaskDetailCols.Status: "success",
entity.AnalysisTaskDetailCols.VideoSavePath: videoSavePath,
entity.AnalysisTaskDetailCols.CaptionResult: captionResult,
entity.AnalysisTaskDetailCols.FailReason: "",
}).
Where(entity.AnalysisTaskDetailCols.TaskID, taskID).
Where(entity.AnalysisTaskDetailCols.VideoURL, videoURL).
Update()
return err
}
// UpdateError 更新明细为失败
func (d *analysisTaskDetailDao) UpdateError(ctx context.Context, taskID string, videoURL string, failReason string) error {
_, err := gfdb.DB(ctx).Model(ctx, analysisTaskDetailTable).
Data(g.Map{
entity.AnalysisTaskDetailCols.Status: "failed",
entity.AnalysisTaskDetailCols.FailReason: failReason,
}).
Where(entity.AnalysisTaskDetailCols.TaskID, taskID).
Where(entity.AnalysisTaskDetailCols.VideoURL, videoURL).
Update()
return err
}
// UpdateVideoSavePath 更新视频保存路径
func (d *analysisTaskDetailDao) UpdateVideoSavePath(ctx context.Context, taskID string, videoURL string, savePath string) error {
_, err := gfdb.DB(ctx).Model(ctx, analysisTaskDetailTable).
Data(g.Map{
entity.AnalysisTaskDetailCols.VideoSavePath: savePath,
}).
Where(entity.AnalysisTaskDetailCols.TaskID, taskID).
Where(entity.AnalysisTaskDetailCols.VideoURL, videoURL).
Update()
return err
}

View File

@@ -7,7 +7,7 @@ import (
dto "media/model/dto/video"
entity "media/model/entity/video"
"gitea.com/red-future/common/db/gfdb"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)

View File

@@ -7,7 +7,7 @@ import (
dto "media/model/dto/video"
entity "media/model/entity/video"
"gitea.com/red-future/common/db/gfdb"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)

15
go.mod
View File

@@ -1,17 +1,13 @@
module media
go 1.26.0
go 1.26.1
require (
gitea.com/red-future/common v0.0.19
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5
github.com/gogf/gf/v2 v2.10.0
github.com/yidun/yidun-golang-sdk v1.0.38
gitea.redpowerfuture.com/red-future/common v0.0.23
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
)
//replace gitea.com/red-future/common => ../common
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
@@ -29,6 +25,7 @@ require (
github.com/go-ego/gse v1.0.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1 // indirect
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@@ -62,13 +59,11 @@ require (
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/r3labs/diff/v2 v2.15.1 // indirect
github.com/redis/go-redis/v9 v9.12.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/tiger1103/gfast-token v1.0.10 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/vcaesar/cedar v0.30.0 // indirect
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
go.mongodb.org/mongo-driver/v2 v2.4.0 // indirect

27
go.sum
View File

@@ -1,6 +1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
gitea.com/red-future/common v0.0.19 h1:9/WrfCFUCeFUYwuhBYF+JOQi5F5xuOy+gVnf2ZvHZu4=
gitea.com/red-future/common v0.0.19/go.mod h1:6/nqIucVzmjOyqDTIq71feYBXXFNBy0rFwzaQ0/Ueoo=
gitea.redpowerfuture.com/red-future/common v0.0.23 h1:xieoA00iKOCDm5SO9iXn+cSyMKBAlZwI0fuEVPWrHLg=
gitea.redpowerfuture.com/red-future/common v0.0.23/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
@@ -77,16 +77,16 @@ github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 h1:39+jbTenm7KBj4hO2C8ANAxVHpX/7OuRDs1VcGC9ylA=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0/go.mod h1:B0s0fVzn0W220E8UTpSGzrrGKsop5KcB90twBeLCiz0=
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5 h1:Ku7p3CvGchxC7zPSgArf/tZs2w9Yb8tS/gH5ADN+p9g=
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.5/go.mod h1:cjy18NsSLZQf5zaLAzuo7B2gr8GGjCTWDTEPY7T+6FI=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 h1:u8EpP24GkprogROnJ7htMov9Fc66pTP1eVYrWxiCYOs=
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2/go.mod h1:GmvM3r8GVByVMi4RD2+MCs5+CfxVXPMeT8mVDkAaAXE=
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1 h1:egobo4YfQX3C4NtrEFunBqMX3jsddagklgut9u91+BM=
github.com/gogf/gf/contrib/nosql/redis/v2 v2.9.1/go.mod h1:YQ+u5Cs5N2ETCeQaLbv29z/UWYuxw27mJpUkOLj0kJ8=
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 h1:eUqwJ/qNH8lJ6yssiqskazgp1ACQuNU6zXlLOZVuXTQ=
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5/go.mod h1:sjQyMry9+0POYZCA6lHXBxO77WoNKkruJpRB4xKqk5k=
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 h1:tHUEZYB5GTqEYYVDYnlGobf1xISARKDE4KHVlgjwTec=
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5/go.mod h1:cfzTn2HS9RDX8f5pUVkbGxUWcSosouqfNQ1G6cY0V88=
github.com/gogf/gf/v2 v2.10.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs=
github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@@ -102,14 +102,12 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -243,8 +241,6 @@ github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfB
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -294,8 +290,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s=
github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/vcaesar/cedar v0.30.0 h1:9fSDpM7FTjjUdPiBUUa0MWYMRGSEcqgFXvppZcZ4d7Y=
github.com/vcaesar/cedar v0.30.0/go.mod h1:lyuGvALuZZDPNXwpzv/9LyxW+8Y6faN7zauFezNsnik=
@@ -303,8 +297,6 @@ github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4=
github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/yidun/yidun-golang-sdk v1.0.38 h1:4NjQdt2GGMgLToB2+zTA0L4YRpqY3ZQjVpl2ot1gwfk=
github.com/yidun/yidun-golang-sdk v1.0.38/go.mod h1:+JGdWbkUvLi9uKTtHI+nrxajulfZKA7BXDPlzt1RCsU=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver/v2 v2.4.0 h1:Oq6BmUAAFTzMeh6AonuDlgZMuAuEiUxoAD1koK5MuFo=
@@ -336,7 +328,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
@@ -358,7 +349,6 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -441,7 +431,6 @@ google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZi
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=

View File

@@ -5,9 +5,9 @@ import (
controllerAudio "media/controller/audio"
controllerVideo "media/controller/video"
_ "gitea.com/red-future/common/consul"
"gitea.com/red-future/common/http"
"gitea.com/red-future/common/jaeger"
_ "gitea.redpowerfuture.com/red-future/common/consul"
"gitea.redpowerfuture.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/jaeger"
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
)
@@ -19,6 +19,7 @@ func main() {
controllerAudio.AudioExtract,
controllerVideo.Concat,
controllerVideo.Cut,
controllerVideo.Analysis,
})
select {}
}

View File

@@ -1,7 +1,7 @@
package audio
import (
"gitea.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/beans"
"github.com/gogf/gf/v2/frame/g"
)

View File

@@ -0,0 +1,47 @@
package video
import "github.com/gogf/gf/v2/frame/g"
// ---------- 提交视频分析任务 ----------
// AnalysisReq 提交视频分析任务请求
type AnalysisReq struct {
g.Meta `path:"/video/analysis" method:"post" tags:"视频分析" summary:"提交视频分析任务" dc:"接收视频链接数组后台异步串行分析立即返回taskId"`
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表"`
CallbackURL string `json:"callback_url" v:"required#回调地址不能为空" dc:"回调地址分析完成后POST结果到该地址"`
}
// CreateAnalysisTaskRes 创建视频分析任务响应
type CreateAnalysisTaskRes struct {
TaskID string `json:"taskId" dc:"任务ID"`
}
// ---------- 查询任务进度/结果 ----------
// GetAnalysisTaskReq 查询视频分析任务请求
type GetAnalysisTaskReq struct {
g.Meta `path:"/video/analysis/task/{taskId}" method:"get" tags:"视频分析" summary:"查询分析任务结果" dc:"根据taskId查询视频分析任务的进度和结果"`
TaskID string `json:"taskId" dc:"任务ID"`
}
// AnalysisDetailItem 单个视频分析结果项
type AnalysisDetailItem struct {
VideoURL string `json:"video_url" dc:"原始视频URL"`
VideoSavePath string `json:"video_save_path" dc:"视频本地保存路径"`
Status string `json:"status" dc:"状态: pending/success/failed"`
CaptionResult interface{} `json:"caption_result,omitempty" dc:"Caption接口返回结果"`
FailReason string `json:"fail_reason,omitempty" dc:"失败原因"`
}
// GetAnalysisTaskRes 查询视频分析任务响应
type GetAnalysisTaskRes struct {
TaskID string `json:"taskId" dc:"任务ID"`
Status string `json:"status" dc:"状态: pending/processing/success/failed"`
Total int `json:"total" dc:"视频总数"`
Processed int `json:"processed" dc:"已处理数"`
SuccessCount int `json:"successCount" dc:"成功数"`
FailedCount int `json:"failedCount" dc:"失败数"`
List []AnalysisDetailItem `json:"list" dc:"视频详情列表"`
ErrorMessage string `json:"errorMessage,omitempty" dc:"错误信息"`
CreatedAt int64 `json:"createdAt" dc:"创建时间戳"`
}

View File

@@ -1,6 +1,6 @@
package audio
import "gitea.com/red-future/common/beans"
import "gitea.redpowerfuture.com/red-future/common/beans"
// TranscribeTask 语音转文字任务批次头实体
type TranscribeTask struct {

View File

@@ -1,6 +1,6 @@
package audio
import "gitea.com/red-future/common/beans"
import "gitea.redpowerfuture.com/red-future/common/beans"
// TranscribeTaskDetail 语音转文字任务明细实体(每视频一条)
type TranscribeTaskDetail struct {

View File

@@ -0,0 +1,39 @@
package video
import "gitea.redpowerfuture.com/red-future/common/beans"
// AnalysisTask 视频分析异步任务实体
type AnalysisTask struct {
beans.SQLBaseDO `orm:",inherit"`
TaskID string `orm:"task_id" json:"taskId" description:"任务唯一标识"`
CallbackURL string `orm:"callback_url" json:"callbackUrl" description:"回调地址"`
Status string `orm:"status" json:"status" description:"任务状态:pending/processing/success/failed"`
Total int `orm:"total" json:"total" description:"待分析视频总数"`
SuccessCount int `orm:"success_count" json:"successCount" description:"成功数"`
FailedCount int `orm:"failed_count" json:"failedCount" description:"失败数"`
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"`
}
// AnalysisTaskCol 字段定义
type AnalysisTaskCol struct {
beans.SQLBaseCol
TaskID string
CallbackURL string
Status string
Total string
SuccessCount string
FailedCount string
ErrorMessage string
}
// AnalysisTaskCols 字段常量
var AnalysisTaskCols = AnalysisTaskCol{
SQLBaseCol: beans.DefSQLBaseCol,
TaskID: "task_id",
CallbackURL: "callback_url",
Status: "status",
Total: "total",
SuccessCount: "success_count",
FailedCount: "failed_count",
ErrorMessage: "error_message",
}

View File

@@ -0,0 +1,36 @@
package video
import "gitea.redpowerfuture.com/red-future/common/beans"
// AnalysisTaskDetail 视频分析任务明细实体
type AnalysisTaskDetail struct {
beans.SQLBaseDO `orm:",inherit"`
TaskID string `orm:"task_id" json:"taskId" description:"所属任务ID"`
VideoURL string `orm:"video_url" json:"videoUrl" description:"原始视频URL"`
VideoSavePath string `orm:"video_save_path" json:"videoSavePath" description:"视频本地保存路径(永久保留)"`
Status string `orm:"status" json:"status" description:"状态:pending/success/failed"`
CaptionResult string `orm:"caption_result" json:"captionResult" description:"Caption接口返回结果JSON"`
FailReason string `orm:"fail_reason" json:"failReason" description:"失败原因"`
}
// AnalysisTaskDetailCol 字段定义
type AnalysisTaskDetailCol struct {
beans.SQLBaseCol
TaskID string
VideoURL string
VideoSavePath string
Status string
CaptionResult string
FailReason string
}
// AnalysisTaskDetailCols 字段常量
var AnalysisTaskDetailCols = AnalysisTaskDetailCol{
SQLBaseCol: beans.DefSQLBaseCol,
TaskID: "task_id",
VideoURL: "video_url",
VideoSavePath: "video_save_path",
Status: "status",
CaptionResult: "caption_result",
FailReason: "fail_reason",
}

View File

@@ -1,6 +1,6 @@
package video
import "gitea.com/red-future/common/beans"
import "gitea.redpowerfuture.com/red-future/common/beans"
// ConcatTask 视频拼接异步任务实体
type ConcatTask struct {

View File

@@ -1,6 +1,6 @@
package video
import "gitea.com/red-future/common/beans"
import "gitea.redpowerfuture.com/red-future/common/beans"
// CutTask 视频分镜剪切异步任务实体
type CutTask struct {

View File

@@ -18,8 +18,8 @@ import (
entity "media/model/entity/audio"
serviceScene "media/service/scene"
"gitea.com/red-future/common/beans"
"gitea.com/red-future/common/utils"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"github.com/gogf/gf/v2/util/guid"

View File

@@ -0,0 +1,382 @@
package video
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
analysisDao "media/dao/video"
dto "media/model/dto/video"
entity "media/model/entity/video"
"gitea.redpowerfuture.com/red-future/common/beans"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/guid"
)
type analysisService struct{}
// Analysis 视频分析服务单例
var Analysis = new(analysisService)
// ---------- 异步任务管理 ----------
// CreateAsyncTask 创建异步分析任务,返回 taskId后台串行处理
func (s *analysisService) CreateAsyncTask(ctx context.Context, videoURLs []string, callbackURL string) (string, error) {
if len(videoURLs) == 0 {
return "", fmt.Errorf("视频URL列表不能为空")
}
taskID := "anl_" + guid.S()
task := &entity.AnalysisTask{
TaskID: taskID,
CallbackURL: callbackURL,
Status: "pending",
Total: len(videoURLs),
}
if _, err := analysisDao.AnalysisTask.Insert(ctx, task); err != nil {
return "", fmt.Errorf("创建任务失败: %v", err)
}
// 批量创建明细
if err := analysisDao.AnalysisTaskDetail.BatchInsert(ctx, taskID, videoURLs); err != nil {
return "", fmt.Errorf("创建任务明细失败: %v", err)
}
// 提取调用方用户信息,传给 goroutine
user := getUserFromCtx(ctx)
g.Log().Infof(ctx, "[视频分析] 创建任务 %s, 视频数=%d, 回调=%s", taskID, len(videoURLs), callbackURL)
// 异步处理
go s.processAsyncTask(user, taskID, videoURLs, callbackURL)
return taskID, nil
}
// GetTaskResult 查询异步任务结果
func (s *analysisService) GetTaskResult(ctx context.Context, taskID string) (*dto.GetAnalysisTaskRes, error) {
task, err := analysisDao.AnalysisTask.GetByTaskID(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("查询任务失败: %v", err)
}
if task == nil {
return nil, fmt.Errorf("任务不存在: %s", taskID)
}
details, err := analysisDao.AnalysisTaskDetail.GetByTaskID(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("查询任务明细失败: %v", err)
}
return analysisDao.AnalysisEntityToTaskRes(task, details), nil
}
// processAsyncTask 后台串行处理异步分析任务
func (s *analysisService) processAsyncTask(user *beans.User, taskID string, videoURLs []string, callbackURL string) {
bgCtx := context.Background()
bgCtx = context.WithValue(bgCtx, "user", user)
analysisDao.AnalysisTask.UpdateProcessing(bgCtx, taskID)
defer func() {
if r := recover(); r != nil {
errMsg := fmt.Sprintf("视频分析异常: %v", r)
g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg)
analysisDao.AnalysisTask.UpdateError(bgCtx, taskID, errMsg)
s.analysisCallback(bgCtx, taskID, callbackURL)
}
}()
successCount := 0
failedCount := 0
// 逐个串行处理视频
for _, videoURL := range videoURLs {
g.Log().Infof(bgCtx, "[视频分析 %s] 开始处理视频: %s", taskID, videoURL)
// 1. 下载视频到永久存储目录
savePath, dlErr := s.downloadVideo(bgCtx, taskID, videoURL)
if dlErr != nil {
errMsg := fmt.Sprintf("下载视频失败 %s: %v", videoURL, dlErr)
g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg)
analysisDao.AnalysisTaskDetail.UpdateError(bgCtx, taskID, videoURL, errMsg)
failedCount++
analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount)
continue
}
g.Log().Infof(bgCtx, "[视频分析 %s] 视频下载完成: %s -> %s", taskID, videoURL, savePath)
// 2. 调用第三方 caption 接口一次一个视频form-data 上传)
captionResult, cpErr := s.callCaptionAPI(bgCtx, savePath)
if cpErr != nil {
errMsg := fmt.Sprintf("调用Caption接口失败 %s: %v", videoURL, cpErr)
g.Log().Errorf(bgCtx, "[视频分析 %s] %s", taskID, errMsg)
analysisDao.AnalysisTaskDetail.UpdateError(bgCtx, taskID, videoURL, errMsg)
failedCount++
analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount)
continue
}
g.Log().Infof(bgCtx, "[视频分析 %s] Caption接口调用成功: %s", taskID, videoURL)
// 3. 保存结果到数据库(视频不删除,永久保留)
captionJSON, _ := json.Marshal(captionResult)
analysisDao.AnalysisTaskDetail.UpdateSuccess(bgCtx, taskID, videoURL, savePath, string(captionJSON))
successCount++
analysisDao.AnalysisTask.UpdateProgress(bgCtx, taskID, successCount, failedCount)
g.Log().Infof(bgCtx, "[视频分析 %s] 视频处理完成: %s (成功=%d, 失败=%d)", taskID, videoURL, successCount, failedCount)
}
// 更新任务最终状态
if failedCount == 0 {
analysisDao.AnalysisTask.UpdateSuccess(bgCtx, taskID, successCount, failedCount)
} else if successCount == 0 {
analysisDao.AnalysisTask.UpdateError(bgCtx, taskID, fmt.Sprintf("所有视频处理失败(共%d个)", len(videoURLs)))
} else {
analysisDao.AnalysisTask.UpdateSuccess(bgCtx, taskID, successCount, failedCount)
}
g.Log().Infof(bgCtx, "[视频分析 %s] 任务完成, 总视频=%d, 成功=%d, 失败=%d", taskID, len(videoURLs), successCount, failedCount)
// 回调通知
if callbackURL != "" {
s.analysisCallback(bgCtx, taskID, callbackURL)
}
}
// downloadVideo 下载视频到永久存储目录
func (s *analysisService) downloadVideo(ctx context.Context, taskID string, videoURL string) (string, error) {
// 从配置获取视频永久存储目录
videoDir := g.Cfg().MustGet(ctx, "analysis.video_dir", "resource/videos").String()
if !filepath.IsAbs(videoDir) {
absDir, _ := filepath.Abs(videoDir)
videoDir = absDir
}
// 按 taskId 子目录组织
taskDir := filepath.Join(videoDir, taskID)
os.MkdirAll(taskDir, 0755)
// 从URL提取文件名
segments := strings.Split(videoURL, "/")
fileName := segments[len(segments)-1]
if fileName == "" {
fileName = fmt.Sprintf("video_%d.mp4", time.Now().UnixMilli())
}
savePath := filepath.Join(taskDir, fileName)
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(videoURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
out, err := os.Create(savePath)
if err != nil {
return "", err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
os.Remove(savePath)
return "", err
}
return savePath, nil
}
// callCaptionAPI 调用第三方 caption 接口form-data 上传单个视频)
// 根据配置 analysis.mock_caption 决定使用 mock 数据还是真实调用
func (s *analysisService) callCaptionAPI(ctx context.Context, videoPath string) (map[string]interface{}, error) {
if g.Cfg().MustGet(ctx, "analysis.mock_caption", true).Bool() {
return s.mockCallCaptionAPI(ctx, videoPath)
}
return s.realCallCaptionAPI(ctx, videoPath)
}
// mockCallCaptionAPI 返回 mock 的 caption 数据
func (s *analysisService) mockCallCaptionAPI(ctx context.Context, videoPath string) (map[string]interface{}, error) {
g.Log().Infof(ctx, "[呼叫Caption-Mock] 使用mock数据, 文件=%s", videoPath)
return map[string]interface{}{
"caption": "Scene: 视频展示了一个产品演示和讲解过程。Events: 一个人走进画面开始介绍产品, 展示了产品的各个功能模块, 最后总结产品优势。",
"scene": "这是一个产品演示和讲解的场景。视频中有人在画面中介绍一款产品,展示了产品的各个功能模块和使用方式,包括产品的外观、核心功能、操作界面等。视频整体节奏适中,配合专业的产品讲解。",
"events": []map[string]interface{}{
{
"start": 0.0,
"end": 5.0,
"description": "视频开场,人物走进画面,开始打招呼并介绍本次演示的主题",
},
{
"start": 5.5,
"end": 15.0,
"description": "展示产品外观包装,详细说明产品设计理念和特点",
},
{
"start": 15.5,
"end": 30.0,
"description": "开机演示,展示产品主界面和核心功能入口",
},
{
"start": 30.5,
"end": 50.0,
"description": "详细演示产品主要功能模块的操作流程和使用方法",
},
},
}, nil
}
// realCallCaptionAPI 真实调用第三方 caption 接口form-data 上传单个视频)
func (s *analysisService) realCallCaptionAPI(ctx context.Context, videoPath string) (map[string]interface{}, error) {
// 获取 caption 服务地址
captionURL := g.Cfg().MustGet(ctx, "analysis.caption_url", "http://192.168.3.49:8900/caption").String()
// 构建 multipart/form-data 表单
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
// 添加视频文件字段
file, err := os.Open(videoPath)
if err != nil {
return nil, fmt.Errorf("打开视频文件失败: %v", err)
}
defer file.Close()
fw, err := mw.CreateFormFile("video", filepath.Base(videoPath))
if err != nil {
return nil, fmt.Errorf("创建表单文件字段失败: %v", err)
}
if _, err = io.Copy(fw, file); err != nil {
return nil, fmt.Errorf("写入文件内容失败: %v", err)
}
// 添加 max_new_tokens 参数
if err = mw.WriteField("max_new_tokens", "2048"); err != nil {
return nil, fmt.Errorf("写入表单字段失败: %v", err)
}
mw.Close()
// 发送请求长超时caption 接口耗时较长)
client := &http.Client{Timeout: 30 * time.Minute}
req, err := http.NewRequest("POST", captionURL, &buf)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
g.Log().Debugf(ctx, "[呼叫Caption] 请求URL=%s, 文件=%s", captionURL, videoPath)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求Caption接口失败: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取Caption响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Caption接口返回非200: %d, body=%s", resp.StatusCode, string(body))
}
// 解析响应 JSON
var result map[string]interface{}
if err = json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析Caption响应JSON失败: %v, body=%s", err, string(body))
}
g.Log().Debugf(ctx, "[呼叫Caption] 响应成功, 文件=%s, 结果长度=%d", videoPath, len(body))
return result, nil
}
// analysisCallback 回调通知
func (s *analysisService) analysisCallback(ctx context.Context, taskID, callbackURL string) {
if callbackURL == "" {
return
}
task, err := analysisDao.AnalysisTask.GetByTaskID(ctx, taskID)
if err != nil || task == nil {
g.Log().Errorf(ctx, "[视频分析回调 %s] 查询任务失败: %v", taskID, err)
return
}
details, err := analysisDao.AnalysisTaskDetail.GetByTaskID(ctx, taskID)
if err != nil {
g.Log().Errorf(ctx, "[视频分析回调 %s] 查询明细失败: %v", taskID, err)
return
}
// 构造回调结果
var results []map[string]interface{}
for _, d := range details {
item := map[string]interface{}{
"video_url": d.VideoURL,
"video_save_path": d.VideoSavePath,
}
if d.Status == "success" && d.CaptionResult != "" {
var captionResult interface{}
json.Unmarshal([]byte(d.CaptionResult), &captionResult)
item["caption_result"] = captionResult
}
if d.Status == "failed" {
item["caption_result"] = nil
item["fail_reason"] = d.FailReason
}
results = append(results, item)
}
payload := map[string]interface{}{
"task_id": task.TaskID,
"status": task.Status,
"total": task.Total,
"success_count": task.SuccessCount,
"failed_count": task.FailedCount,
"results": results,
}
body, _ := json.Marshal(payload)
g.Log().Infof(ctx, "[视频分析回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL)
cbReq, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
cbReq.Header.Set("Content-Type", "application/json")
// 透传调用方用户信息
cbUser := getUserFromCtx(ctx)
userJSON, _ := json.Marshal(cbUser)
cbReq.Header.Set("X-User-Info", string(userJSON))
// 打印 curl 命令方便调试
escapedBody := strings.ReplaceAll(string(body), "'", "'\\''")
g.Log().Infof(ctx, "[视频分析回调 %s] curl 调试命令:\ncurl -X POST '%s' \\\n -H 'Content-Type: application/json' \\\n -H 'X-User-Info: %s' \\\n -d '%s'",
taskID, callbackURL, string(userJSON), escapedBody)
client := &http.Client{Timeout: 2 * time.Minute}
resp, reqErr := client.Do(cbReq)
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))
}

View File

@@ -19,8 +19,8 @@ import (
dto "media/model/dto/video"
entity "media/model/entity/video"
"gitea.com/red-future/common/beans"
commonHttp "gitea.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/beans"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
@@ -130,7 +130,7 @@ func (s *concatService) Concat(ctx context.Context, req *ConcatReq) (res *Concat
// 如果需要上传到 MinIO用独立 context避免 HTTP 断开后 ctx 被取消)
if req.Upload {
uploadCtx := context.Background()
uploadCtx := context.WithValue(context.Background(), "user", getUserFromCtx(ctx))
uploadRes, uploadErr := s.UploadToMinIO(uploadCtx, outputPath)
if uploadErr != nil {
return nil, fmt.Errorf("上传到MinIO失败: %v", uploadErr)

View File

@@ -19,8 +19,8 @@ import (
dto "media/model/dto/video"
entity "media/model/entity/video"
"gitea.com/red-future/common/beans"
commonHttp "gitea.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/beans"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
@@ -192,9 +192,9 @@ func (s *cutService) Cut(ctx context.Context, req *CutReq) (res *CutRes, err err
ShotsCount: len(validShots),
}
// 如果需要上传到 MinIO
// 如果需要上传到 MinIO(用独立 context避免 HTTP 断开后 ctx 被取消,同时保留用户信息)
if req.Upload {
uploadCtx := context.Background()
uploadCtx := context.WithValue(context.Background(), "user", getUserFromCtx(ctx))
uploadRes, uploadErr := s.UploadToMinIO(uploadCtx, outputPath)
if uploadErr != nil {
return nil, fmt.Errorf("上传到MinIO失败: %v", uploadErr)

View File

@@ -0,0 +1,60 @@
-- video_analysis_task 视频分析异步任务主表
CREATE TABLE IF NOT EXISTS video_analysis_task (
id BIGSERIAL NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0,
task_id VARCHAR(64) NOT NULL,
callback_url VARCHAR(500) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
failed_count INT NOT NULL DEFAULT 0,
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 video_analysis_task IS '视频分析异步任务表';
COMMENT ON COLUMN video_analysis_task.task_id IS '任务唯一标识';
COMMENT ON COLUMN video_analysis_task.callback_url IS '回调地址';
COMMENT ON COLUMN video_analysis_task.status IS '任务状态:pending/processing/success/failed';
COMMENT ON COLUMN video_analysis_task.total IS '待分析视频总数';
COMMENT ON COLUMN video_analysis_task.success_count IS '成功数';
COMMENT ON COLUMN video_analysis_task.failed_count IS '失败数';
COMMENT ON COLUMN video_analysis_task.error_message IS '错误信息';
COMMENT ON COLUMN video_analysis_task.created_at IS '创建时间';
COMMENT ON COLUMN video_analysis_task.updated_at IS '更新时间';
COMMENT ON COLUMN video_analysis_task.deleted_at IS '删除时间(软删除)';
CREATE UNIQUE INDEX IF NOT EXISTS idx_video_analysis_task_task_id ON video_analysis_task(task_id);
CREATE INDEX IF NOT EXISTS idx_video_analysis_task_status ON video_analysis_task(status);
CREATE INDEX IF NOT EXISTS idx_video_analysis_task_created_at ON video_analysis_task(created_at);
-- video_analysis_task_detail 视频分析任务明细表(每视频一条)
CREATE TABLE IF NOT EXISTS video_analysis_task_detail (
id BIGSERIAL NOT NULL,
task_id VARCHAR(64) NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0,
video_url TEXT NOT NULL,
video_save_path TEXT NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
caption_result TEXT,
fail_reason TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (id)
);
COMMENT ON TABLE video_analysis_task_detail IS '视频分析任务明细表';
COMMENT ON COLUMN video_analysis_task_detail.task_id IS '所属任务ID';
COMMENT ON COLUMN video_analysis_task_detail.tenant_id IS '租户ID';
COMMENT ON COLUMN video_analysis_task_detail.video_url IS '原始视频URL';
COMMENT ON COLUMN video_analysis_task_detail.video_save_path IS '视频本地保存路径(永久保留)';
COMMENT ON COLUMN video_analysis_task_detail.status IS '状态:pending/success/failed';
COMMENT ON COLUMN video_analysis_task_detail.caption_result IS 'Caption接口返回结果JSON';
COMMENT ON COLUMN video_analysis_task_detail.fail_reason IS '失败原因';
COMMENT ON COLUMN video_analysis_task_detail.created_at IS '创建时间';
COMMENT ON COLUMN video_analysis_task_detail.updated_at IS '更新时间';
CREATE INDEX IF NOT EXISTS idx_video_analysis_task_detail_task_id ON video_analysis_task_detail(task_id);