Compare commits
8 Commits
e76bf57d54
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bc48060dc | |||
| 883f09e6df | |||
| 556fa2f3ca | |||
| e6c27e2dee | |||
| e79f8a6131 | |||
| 036b5cec37 | |||
| 9a40fd7e1e | |||
| ccd17903c7 |
185
API文档.md
Normal file
185
API文档.md
Normal 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
194
CLAUDE.md
Normal 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-whisper(Python 版本,用于语音识别)
|
||||
- 非 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 文件访问地址前缀
|
||||
34
Dockerfile
34
Dockerfile
@@ -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,31 +18,7 @@ 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 3001
|
||||
EXPOSE 3010
|
||||
|
||||
CMD ["./main"]
|
||||
|
||||
17
config.yml
17
config.yml
@@ -1,5 +1,5 @@
|
||||
server:
|
||||
address : ":3001"
|
||||
address : ":3010"
|
||||
name: "media"
|
||||
workerId: 1
|
||||
logPath: "resource/log/server"
|
||||
@@ -37,7 +37,7 @@ database:
|
||||
redis:
|
||||
# 集群模式配置方法
|
||||
default:
|
||||
address: 116.204.74.41:6379
|
||||
address: 192.168.3.30:6379
|
||||
db: 0
|
||||
idleTimeout: "60s" #连接最大空闲时间,使用时间字符串例如30s/1m/1d
|
||||
maxConnLifetime: "90s" #连接最长存活时间,使用时间字符串例如30s/1m/1d
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
dto "media/model/dto/audio"
|
||||
service "media/service/asr"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
@@ -56,10 +57,23 @@ func (c *audio) ListTasks(ctx context.Context, req *dto.ListTaskReq) (res *dto.L
|
||||
return service.AudioTask.ListTasks(ctx, req)
|
||||
}
|
||||
|
||||
// withUser 为 context 注入默认用户(无认证基础设施时使用)
|
||||
// withUser 优先从请求头/X-User-Info/Token 提取用户信息,没有则用默认 admin
|
||||
func withUser(ctx context.Context) context.Context {
|
||||
if ctx.Value("user") == nil {
|
||||
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1})
|
||||
if ctx.Value("user") != nil {
|
||||
return ctx
|
||||
}
|
||||
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err == nil && user != nil && user.TenantId > 0 {
|
||||
g.Log().Infof(ctx, "[用户信息] 从请求头解析到用户: userName=%s, tenantId=%d", user.UserName, user.TenantId)
|
||||
ctx = context.WithValue(ctx, "user", user)
|
||||
return ctx
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "[用户信息] 解析失败(%v), 使用默认admin/tenant=1", err)
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1})
|
||||
return ctx
|
||||
}
|
||||
|
||||
38
controller/video/analysis_controller.go
Normal file
38
controller/video/analysis_controller.go
Normal 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)
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
dto "media/model/dto/video"
|
||||
service "media/service/video"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
@@ -26,6 +27,9 @@ var Concat = new(video)
|
||||
// Concat 视频拼接(URL模式) POST /video/concat
|
||||
func (c *video) Concat(ctx context.Context, req *dto.ConcatReq) (res *dto.ConcatRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
g.Log().Infof(ctx, "[视频拼接] 收到请求 入参: method=%s, upload=%v, video_urls=%v",
|
||||
req.Method, req.Upload, req.VideoURLs)
|
||||
|
||||
if req.Method == "" {
|
||||
req.Method = "auto"
|
||||
}
|
||||
@@ -44,12 +48,33 @@ func (c *video) Concat(ctx context.Context, req *dto.ConcatReq) (res *dto.Concat
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer os.Remove(svcRes.OutputPath)
|
||||
return toDTORes(svcRes), nil
|
||||
}
|
||||
|
||||
// ConcatAsync 视频拼接-异步(URL模式) POST /video/concat/async
|
||||
func (c *video) ConcatAsync(ctx context.Context, req *dto.ConcatAsyncReq) (res *dto.CreateConcatTaskRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
g.Log().Infof(ctx, "[视频拼接-异步] 收到请求 入参: method=%s, upload=%v, callback=%s, video_urls=%v",
|
||||
req.Method, req.Upload, req.CallbackURL, req.VideoURLs)
|
||||
|
||||
if req.Method == "" {
|
||||
req.Method = "auto"
|
||||
}
|
||||
|
||||
taskID, taskErr := service.Concat.CreateAsyncTask(ctx, req.VideoURLs, req.Method, req.Upload, req.CallbackURL)
|
||||
if taskErr != nil {
|
||||
return nil, taskErr
|
||||
}
|
||||
return &dto.CreateConcatTaskRes{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
// ConcatUpload 视频拼接(文件上传模式) POST /video/concat/upload
|
||||
func (c *video) ConcatUpload(ctx context.Context, req *dto.ConcatUploadReq) (res *dto.ConcatRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
g.Log().Infof(ctx, "[视频拼接-上传] 收到请求 入参: method=%s, upload=%v", req.Method, req.Upload)
|
||||
|
||||
savePaths, err := common.SaveUploadedFilesFromCtx(ctx)
|
||||
if err != nil || len(savePaths) < 2 {
|
||||
return nil, fmt.Errorf("至少需要2个视频,当前%d个", len(savePaths))
|
||||
@@ -68,14 +93,63 @@ func (c *video) ConcatUpload(ctx context.Context, req *dto.ConcatUploadReq) (res
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer os.Remove(svcRes.OutputPath)
|
||||
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})
|
||||
// ConcatUploadAsync 视频拼接-异步(文件上传模式) POST /video/concat/upload/async
|
||||
func (c *video) ConcatUploadAsync(ctx context.Context, req *dto.ConcatUploadAsyncReq) (res *dto.CreateConcatTaskRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
g.Log().Infof(ctx, "[视频拼接-上传-异步] 收到请求 入参: method=%s, upload=%v, callback=%s",
|
||||
req.Method, req.Upload, req.CallbackURL)
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
taskID, taskErr := service.Concat.CreateAsyncTaskWithFiles(ctx, savePaths, req.Method, req.Upload, req.CallbackURL)
|
||||
if taskErr != nil {
|
||||
return nil, taskErr
|
||||
}
|
||||
return &dto.CreateConcatTaskRes{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
// GetConcatTask 查询异步拼接任务结果 GET /video/concat/task/{taskId}
|
||||
func (c *video) GetConcatTask(ctx context.Context, req *dto.GetConcatTaskReq) (res *dto.GetConcatTaskRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
return service.Concat.GetTaskResult(ctx, req.TaskID)
|
||||
}
|
||||
|
||||
// withUser 优先从请求头/X-User-Info/Token 提取用户信息,没有则用默认 admin
|
||||
func withUser(ctx context.Context) context.Context {
|
||||
if ctx.Value("user") != nil {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// 调试:打印 Authorization 头
|
||||
if req := g.RequestFromCtx(ctx); req != nil {
|
||||
g.Log().Debugf(ctx, "[withUser] Authorization头=%q", req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err == nil && user != nil && user.TenantId > 0 {
|
||||
g.Log().Infof(ctx, "[用户信息] 从请求头解析到用户: userName=%s, tenantId=%d", user.UserName, user.TenantId)
|
||||
ctx = context.WithValue(ctx, "user", user)
|
||||
return ctx
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "[用户信息] 解析失败(%v), 使用默认admin/tenant=1", err)
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1})
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
92
controller/video/cut_controller.go
Normal file
92
controller/video/cut_controller.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
dto "media/model/dto/video"
|
||||
service "media/service/video"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type cut struct{}
|
||||
|
||||
var Cut = new(cut)
|
||||
|
||||
// Cut 视频分镜剪切(URL模式) POST /video/cut
|
||||
func (c *cut) Cut(ctx context.Context, req *dto.CutReq) (res *dto.CutRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
g.Log().Infof(ctx, "[视频分镜剪切] 收到请求 入参: video_url=%s, total_scenes=%d, upload=%v",
|
||||
req.VideoURL, len(req.Scenes), req.Upload)
|
||||
|
||||
// 下载视频到临时目录
|
||||
tempDir := getTempDir(ctx)
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
|
||||
savePath, err := downloadFromURL(ctx, req.VideoURL, tempDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载视频失败: %v", err)
|
||||
}
|
||||
defer os.Remove(savePath)
|
||||
|
||||
// 转换分镜为 service 层类型(解析时间字符串为秒)
|
||||
serviceShots, err := service.ConvertScenes(req.Scenes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析分镜失败: %v", err)
|
||||
}
|
||||
if len(serviceShots) == 0 {
|
||||
return nil, fmt.Errorf("没有有效的分镜片段")
|
||||
}
|
||||
|
||||
// 调用服务层
|
||||
svcRes, err := service.Cut.Cut(ctx, &service.CutReq{
|
||||
VideoPath: savePath,
|
||||
Shots: serviceShots,
|
||||
Upload: req.Upload,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer os.Remove(svcRes.OutputPath)
|
||||
|
||||
return &dto.CutRes{
|
||||
OutputPath: svcRes.OutputPath,
|
||||
FileSize: svcRes.FileSize,
|
||||
Duration: svcRes.Duration,
|
||||
DurationStr: svcRes.DurationStr,
|
||||
ShotsCount: svcRes.ShotsCount,
|
||||
FileURL: svcRes.FileURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CutAsync 视频分镜剪切-异步(URL模式) POST /video/cut/async
|
||||
func (c *cut) CutAsync(ctx context.Context, req *dto.CutAsyncReq) (res *dto.CreateCutTaskRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
g.Log().Infof(ctx, "[视频分镜剪切-异步] 收到请求 入参: video_url=%s, total_scenes=%d, upload=%v, callback=%s",
|
||||
req.VideoURL, len(req.Scenes), req.Upload, req.CallbackURL)
|
||||
|
||||
// 转换分镜为 service 层类型(解析时间字符串为秒)
|
||||
serviceShots, err := service.ConvertScenes(req.Scenes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析分镜失败: %v", err)
|
||||
}
|
||||
if len(serviceShots) == 0 {
|
||||
return nil, fmt.Errorf("没有有效的分镜片段")
|
||||
}
|
||||
|
||||
taskID, taskErr := service.Cut.CreateAsyncTask(ctx, req.VideoURL, serviceShots, req.Upload, req.CallbackURL)
|
||||
if taskErr != nil {
|
||||
return nil, taskErr
|
||||
}
|
||||
|
||||
return &dto.CreateCutTaskRes{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
// GetCutTask 查询异步剪切任务结果 GET /video/cut/task/{taskId}
|
||||
func (c *cut) GetCutTask(ctx context.Context, req *dto.GetCutTaskReq) (res *dto.GetCutTaskRes, err error) {
|
||||
ctx = withUser(ctx)
|
||||
return service.Cut.GetTaskResult(ctx, req.TaskID)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
136
dao/video/analysis_task_dao.go
Normal file
136
dao/video/analysis_task_dao.go
Normal 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
|
||||
}
|
||||
99
dao/video/analysis_task_detail_dao.go
Normal file
99
dao/video/analysis_task_detail_dao.go
Normal 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
|
||||
}
|
||||
113
dao/video/concat_task_dao.go
Normal file
113
dao/video/concat_task_dao.go
Normal file
@@ -0,0 +1,113 @@
|
||||
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 ConcatTask = new(concatTaskDao)
|
||||
|
||||
type concatTaskDao struct{}
|
||||
|
||||
const concatTaskTable = "concat_task"
|
||||
|
||||
// Insert 创建任务(排除 id 字段,让数据库自增)
|
||||
func (d *concatTaskDao) Insert(ctx context.Context, data *entity.ConcatTask) (id int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, concatTaskTable).
|
||||
Data(data).
|
||||
FieldsEx(entity.ConcatTaskCols.Id).
|
||||
Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// GetByTaskID 根据taskId查询任务
|
||||
func (d *concatTaskDao) GetByTaskID(ctx context.Context, taskID string) (res *entity.ConcatTask, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, concatTaskTable).
|
||||
Where(entity.ConcatTaskCols.TaskID, taskID).
|
||||
One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateRunning 更新为运行中
|
||||
func (d *concatTaskDao) UpdateRunning(ctx context.Context, taskID string) error {
|
||||
_, err := gfdb.DB(ctx).Model(ctx, concatTaskTable).
|
||||
Data(g.Map{
|
||||
entity.ConcatTaskCols.Status: "running",
|
||||
}).
|
||||
Where(entity.ConcatTaskCols.TaskID, taskID).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSuccess 更新为成功
|
||||
func (d *concatTaskDao) UpdateSuccess(ctx context.Context, taskID string, fileURL string, fileSize int64, fileName, fileFormat, fileAddrPrefix, methodUsed, durationStr string) error {
|
||||
_, err := gfdb.DB(ctx).Model(ctx, concatTaskTable).
|
||||
Data(g.Map{
|
||||
entity.ConcatTaskCols.Status: "success",
|
||||
entity.ConcatTaskCols.FileURL: fileURL,
|
||||
entity.ConcatTaskCols.FileSize: fileSize,
|
||||
entity.ConcatTaskCols.FileName: fileName,
|
||||
entity.ConcatTaskCols.FileFormat: fileFormat,
|
||||
entity.ConcatTaskCols.FileAddressPrefix: fileAddrPrefix,
|
||||
entity.ConcatTaskCols.MethodUsed: methodUsed,
|
||||
entity.ConcatTaskCols.DurationStr: durationStr,
|
||||
entity.ConcatTaskCols.ErrorMessage: "",
|
||||
}).
|
||||
Where(entity.ConcatTaskCols.TaskID, taskID).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateError 更新为失败
|
||||
func (d *concatTaskDao) UpdateError(ctx context.Context, taskID string, errMsg string) error {
|
||||
_, err := gfdb.DB(ctx).Model(ctx, concatTaskTable).
|
||||
Data(g.Map{
|
||||
entity.ConcatTaskCols.Status: "failed",
|
||||
entity.ConcatTaskCols.ErrorMessage: errMsg,
|
||||
}).
|
||||
Where(entity.ConcatTaskCols.TaskID, taskID).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// EntityToTaskRes 实体转DTO
|
||||
func EntityToTaskRes(e *entity.ConcatTask) *dto.GetConcatTaskRes {
|
||||
res := &dto.GetConcatTaskRes{
|
||||
TaskID: e.TaskID,
|
||||
Status: e.Status,
|
||||
CreatedAt: gconv.Int64(e.CreatedAt.Timestamp()),
|
||||
}
|
||||
if e.CreatedAt == nil {
|
||||
res.CreatedAt = time.Now().UnixMilli()
|
||||
}
|
||||
if e.Status == "success" {
|
||||
res.FileURL = e.FileURL
|
||||
res.FileSize = e.FileSize
|
||||
res.FileName = e.FileName
|
||||
res.FileFormat = e.FileFormat
|
||||
res.FileAddressPrefix = e.FileAddressPrefix
|
||||
res.MethodUsed = e.MethodUsed
|
||||
res.DurationStr = e.DurationStr
|
||||
}
|
||||
if e.Status == "failed" {
|
||||
res.ErrorMessage = e.ErrorMessage
|
||||
}
|
||||
return res
|
||||
}
|
||||
111
dao/video/cut_task_dao.go
Normal file
111
dao/video/cut_task_dao.go
Normal file
@@ -0,0 +1,111 @@
|
||||
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 CutTask = new(cutTaskDao)
|
||||
|
||||
type cutTaskDao struct{}
|
||||
|
||||
const cutTaskTable = "cut_task"
|
||||
|
||||
// Insert 创建任务(排除 id 字段,让数据库自增)
|
||||
func (d *cutTaskDao) Insert(ctx context.Context, data *entity.CutTask) (id int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, cutTaskTable).
|
||||
Data(data).
|
||||
FieldsEx(entity.CutTaskCols.Id).
|
||||
Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// GetByTaskID 根据taskId查询任务
|
||||
func (d *cutTaskDao) GetByTaskID(ctx context.Context, taskID string) (res *entity.CutTask, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, cutTaskTable).
|
||||
Where(entity.CutTaskCols.TaskID, taskID).
|
||||
One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateRunning 更新为运行中
|
||||
func (d *cutTaskDao) UpdateRunning(ctx context.Context, taskID string) error {
|
||||
_, err := gfdb.DB(ctx).Model(ctx, cutTaskTable).
|
||||
Data(g.Map{
|
||||
entity.CutTaskCols.Status: "running",
|
||||
}).
|
||||
Where(entity.CutTaskCols.TaskID, taskID).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSuccess 更新为成功
|
||||
func (d *cutTaskDao) UpdateSuccess(ctx context.Context, taskID string, fileURL string, fileSize int64, fileName, fileFormat, fileAddrPrefix, durationStr string) error {
|
||||
_, err := gfdb.DB(ctx).Model(ctx, cutTaskTable).
|
||||
Data(g.Map{
|
||||
entity.CutTaskCols.Status: "success",
|
||||
entity.CutTaskCols.FileURL: fileURL,
|
||||
entity.CutTaskCols.FileSize: fileSize,
|
||||
entity.CutTaskCols.FileName: fileName,
|
||||
entity.CutTaskCols.FileFormat: fileFormat,
|
||||
entity.CutTaskCols.FileAddressPrefix: fileAddrPrefix,
|
||||
entity.CutTaskCols.DurationStr: durationStr,
|
||||
entity.CutTaskCols.ErrorMessage: "",
|
||||
}).
|
||||
Where(entity.CutTaskCols.TaskID, taskID).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateError 更新为失败
|
||||
func (d *cutTaskDao) UpdateError(ctx context.Context, taskID string, errMsg string) error {
|
||||
_, err := gfdb.DB(ctx).Model(ctx, cutTaskTable).
|
||||
Data(g.Map{
|
||||
entity.CutTaskCols.Status: "failed",
|
||||
entity.CutTaskCols.ErrorMessage: errMsg,
|
||||
}).
|
||||
Where(entity.CutTaskCols.TaskID, taskID).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// EntityToTaskRes 实体转DTO
|
||||
func CutEntityToTaskRes(e *entity.CutTask) *dto.GetCutTaskRes {
|
||||
res := &dto.GetCutTaskRes{
|
||||
TaskID: e.TaskID,
|
||||
Status: e.Status,
|
||||
CreatedAt: gconv.Int64(e.CreatedAt.Timestamp()),
|
||||
}
|
||||
if e.CreatedAt == nil {
|
||||
res.CreatedAt = time.Now().UnixMilli()
|
||||
}
|
||||
if e.Status == "success" {
|
||||
res.FileURL = e.FileURL
|
||||
res.FileSize = e.FileSize
|
||||
res.FileName = e.FileName
|
||||
res.FileFormat = e.FileFormat
|
||||
res.FileAddressPrefix = e.FileAddressPrefix
|
||||
res.DurationStr = e.DurationStr
|
||||
}
|
||||
if e.Status == "failed" {
|
||||
res.ErrorMessage = e.ErrorMessage
|
||||
}
|
||||
return res
|
||||
}
|
||||
15
go.mod
15
go.mod
@@ -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
27
go.sum
@@ -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=
|
||||
|
||||
8
main.go
8
main.go
@@ -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"
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ func main() {
|
||||
http.RouteRegister([]interface{}{
|
||||
controllerAudio.AudioExtract,
|
||||
controllerVideo.Concat,
|
||||
controllerVideo.Cut,
|
||||
controllerVideo.Analysis,
|
||||
})
|
||||
select {}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -93,16 +93,10 @@ type ListTaskRes struct {
|
||||
|
||||
// ---------- 回调通知结构 ----------
|
||||
|
||||
// CallbackPayload 回调通知内容
|
||||
// CallbackPayload 回调通知内容(与 GetTaskRes 出参一致)
|
||||
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:"明细列表"`
|
||||
TaskInfo TranscribeTaskItem `json:"taskInfo" dc:"任务信息"`
|
||||
DetailList []TranscribeTaskDetailItem `json:"detailList" dc:"明细列表(每视频一条)"`
|
||||
}
|
||||
|
||||
// ---------- 任务处理结果结构(用于result JSONB) ----------
|
||||
|
||||
47
model/dto/video/analysis_dto.go
Normal file
47
model/dto/video/analysis_dto.go
Normal 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:"创建时间戳"`
|
||||
}
|
||||
78
model/dto/video/cut_dto.go
Normal file
78
model/dto/video/cut_dto.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package video
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// CutScene 单个分镜场景
|
||||
// 同时兼容两种格式:
|
||||
// 1. 完整格式: {"sceneIndex":1,"startTimeStr":"00:00:00.000","endTimeStr":"00:00:03.115", ...}
|
||||
// 2. 简化格式: {"start": 1.5, "end": 5.0}
|
||||
type CutScene struct {
|
||||
SceneIndex int `json:"sceneIndex" dc:"场景序号"`
|
||||
StartTimeStr string `json:"startTimeStr" dc:"起始时间 HH:MM:SS.mmm"`
|
||||
EndTimeStr string `json:"endTimeStr" dc:"结束时间 HH:MM:SS.mmm"`
|
||||
Start float64 `json:"start" dc:"起始时间(秒)"`
|
||||
End float64 `json:"end" dc:"结束时间(秒)"`
|
||||
DurationStr string `json:"durationStr" dc:"时长"`
|
||||
ShotType string `json:"shotType" dc:"镜头类型"`
|
||||
Composition string `json:"composition" dc:"构图"`
|
||||
NarrativePos string `json:"narrativePos" dc:"叙事位置"`
|
||||
Description string `json:"description" dc:"描述"`
|
||||
}
|
||||
|
||||
// CutReq 视频分镜剪切请求(URL 方式)
|
||||
type CutReq struct {
|
||||
g.Meta `path:"/video/cut" method:"post" tags:"视频剪切" summary:"视频分镜剪切(URL模式)" dc:"根据分镜JSON从视频中剪切多个片段并拼接输出"`
|
||||
VideoURL string `json:"video_url" v:"required#视频URL不能为空" dc:"原始视频URL"`
|
||||
Scenes []CutScene `json:"scenes" v:"required#分镜片段不能为空" dc:"分镜片段数组"`
|
||||
TotalScenes int `json:"totalScenes" dc:"总分镜数"`
|
||||
DurationStr string `json:"durationStr" dc:"总时长"`
|
||||
Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"`
|
||||
}
|
||||
|
||||
// CutAsyncReq 视频分镜剪切-异步请求(URL方式)
|
||||
type CutAsyncReq struct {
|
||||
g.Meta `path:"/video/cut/async" method:"post" tags:"视频剪切" summary:"视频分镜剪切-异步(URL模式)" dc:"异步根据分镜JSON剪切视频,立即返回taskId,完成后通过callback_url通知结果"`
|
||||
VideoURL string `json:"video_url" v:"required#视频URL不能为空" dc:"原始视频URL"`
|
||||
Scenes []CutScene `json:"scenes" v:"required#分镜片段不能为空" dc:"分镜片段数组"`
|
||||
TotalScenes int `json:"totalScenes" dc:"总分镜数"`
|
||||
DurationStr string `json:"durationStr" dc:"总时长"`
|
||||
Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"`
|
||||
CallbackURL string `json:"callback_url" v:"required#回调地址不能为空" dc:"回调地址,剪切完成后POST结果到该地址"`
|
||||
}
|
||||
|
||||
// CutRes 视频分镜剪切响应
|
||||
type CutRes struct {
|
||||
OutputPath string `json:"outputPath" dc:"输出文件路径"`
|
||||
FileSize int64 `json:"fileSize" dc:"文件大小(字节)"`
|
||||
Duration float64 `json:"duration" dc:"总时长(秒)"`
|
||||
DurationStr string `json:"durationStr" dc:"可读时长"`
|
||||
ShotsCount int `json:"shotsCount" dc:"输出片段数"`
|
||||
FileURL string `json:"fileURL" dc:"MinIO访问地址(上传后返回)"`
|
||||
}
|
||||
|
||||
// ---------- 异步剪切任务 ----------
|
||||
|
||||
// CreateCutTaskRes 创建异步剪切任务响应
|
||||
type CreateCutTaskRes struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
// GetCutTaskReq 查询异步剪切任务请求
|
||||
type GetCutTaskReq struct {
|
||||
g.Meta `path:"/video/cut/task/{taskId}" method:"get" tags:"视频剪切" summary:"查询剪切任务结果" dc:"根据taskId查询异步剪切任务的结果"`
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
// GetCutTaskRes 查询异步剪切任务响应
|
||||
type GetCutTaskRes struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
Status string `json:"status" dc:"状态: pending/running/success/failed"`
|
||||
FileURL string `json:"fileURL,omitempty" dc:"MinIO文件访问路径"`
|
||||
FileSize int64 `json:"fileSize,omitempty" dc:"文件大小(字节)"`
|
||||
FileName string `json:"fileName,omitempty" dc:"文件名"`
|
||||
FileFormat string `json:"fileFormat,omitempty" dc:"文件格式"`
|
||||
FileAddressPrefix string `json:"fileAddressPrefix,omitempty" dc:"MinIO地址前缀"`
|
||||
DurationStr string `json:"durationStr,omitempty" dc:"剪切后时长"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty" dc:"错误信息"`
|
||||
CreatedAt int64 `json:"createdAt" dc:"创建时间戳"`
|
||||
}
|
||||
@@ -10,6 +10,15 @@ type ConcatReq struct {
|
||||
Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"`
|
||||
}
|
||||
|
||||
// ConcatAsyncReq 视频拼接-异步请求(URL模式)
|
||||
type ConcatAsyncReq struct {
|
||||
g.Meta `path:"/concat/async" method:"post" tags:"视频拼接" summary:"视频拼接-异步(URL模式)" dc:"异步拼接视频,立即返回taskId,完成后通过callback_url通知结果"`
|
||||
VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表(按此顺序拼接)"`
|
||||
Method string `json:"method" dc:"拼接方式(auto/fast/reencode)" d:"auto"`
|
||||
Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"`
|
||||
CallbackURL string `json:"callback_url" v:"required#回调地址不能为空" dc:"回调地址,拼接完成后POST结果到该地址"`
|
||||
}
|
||||
|
||||
// ConcatUploadReq 视频拼接请求(文件上传模式)
|
||||
type ConcatUploadReq struct {
|
||||
g.Meta `path:"/concat/upload" method:"post" tags:"视频拼接" summary:"视频拼接(文件上传)" dc:"上传视频文件并拼接(至少2个视频)"`
|
||||
@@ -17,6 +26,14 @@ type ConcatUploadReq struct {
|
||||
Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"`
|
||||
}
|
||||
|
||||
// ConcatUploadAsyncReq 视频拼接-异步请求(文件上传模式)
|
||||
type ConcatUploadAsyncReq struct {
|
||||
g.Meta `path:"/concat/upload/async" method:"post" tags:"视频拼接" summary:"视频拼接-异步(文件上传)" dc:"异步拼接上传的视频,立即返回taskId,完成后通过callback_url通知结果"`
|
||||
Method string `json:"method" dc:"拼接方式(auto/fast/reencode)" d:"auto"`
|
||||
Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"`
|
||||
CallbackURL string `json:"callback_url" v:"required#回调地址不能为空" dc:"回调地址,拼接完成后POST结果到该地址"`
|
||||
}
|
||||
|
||||
// ConcatRes 视频拼接响应
|
||||
type ConcatRes struct {
|
||||
OutputPath string `json:"outputPath" dc:"输出文件路径"`
|
||||
@@ -28,6 +45,36 @@ type ConcatRes struct {
|
||||
FileURL string `json:"fileURL" dc:"MinIO访问地址(上传后返回)"`
|
||||
}
|
||||
|
||||
// ---------- 异步拼接任务 ----------
|
||||
|
||||
// CreateConcatTaskRes 创建异步拼接任务响应
|
||||
type CreateConcatTaskRes struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
// GetConcatTaskReq 查询异步拼接任务请求
|
||||
type GetConcatTaskReq struct {
|
||||
g.Meta `path:"/concat/task/{taskId}" method:"get" tags:"视频拼接" summary:"查询拼接任务结果" dc:"根据taskId查询异步拼接任务的结果"`
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
}
|
||||
|
||||
// GetConcatTaskRes 查询异步拼接任务响应
|
||||
type GetConcatTaskRes struct {
|
||||
TaskID string `json:"taskId" dc:"任务ID"`
|
||||
Status string `json:"status" dc:"状态: pending/running/success/failed"`
|
||||
FileURL string `json:"fileURL,omitempty" dc:"MinIO文件访问路径"`
|
||||
FileSize int64 `json:"fileSize,omitempty" dc:"文件大小(字节)"`
|
||||
FileName string `json:"fileName,omitempty" dc:"文件名"`
|
||||
FileFormat string `json:"fileFormat,omitempty" dc:"文件格式"`
|
||||
FileAddressPrefix string `json:"fileAddressPrefix,omitempty" dc:"MinIO地址前缀"`
|
||||
MethodUsed string `json:"methodUsed,omitempty" dc:"实际使用的拼接方式"`
|
||||
DurationStr string `json:"durationStr,omitempty" dc:"拼接后时长"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty" dc:"错误信息"`
|
||||
CreatedAt int64 `json:"createdAt" dc:"创建时间戳"`
|
||||
}
|
||||
|
||||
// ---------- 上传工具 ----------
|
||||
|
||||
// UploadFileBytesReq 上传文件请求(字节流)
|
||||
type UploadFileBytesReq struct {
|
||||
FileName string `json:"fileName" dc:"文件名"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
39
model/entity/video/analysis_task.go
Normal file
39
model/entity/video/analysis_task.go
Normal 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",
|
||||
}
|
||||
36
model/entity/video/analysis_task_detail.go
Normal file
36
model/entity/video/analysis_task_detail.go
Normal 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",
|
||||
}
|
||||
48
model/entity/video/concat_task.go
Normal file
48
model/entity/video/concat_task.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package video
|
||||
|
||||
import "gitea.redpowerfuture.com/red-future/common/beans"
|
||||
|
||||
// ConcatTask 视频拼接异步任务实体
|
||||
type ConcatTask struct {
|
||||
beans.SQLBaseDO `orm:",inherit"`
|
||||
TaskID string `orm:"task_id" json:"taskId" description:"任务唯一标识"`
|
||||
Status string `orm:"status" json:"status" description:"任务状态:pending/running/success/failed"`
|
||||
FileURL string `orm:"file_url" json:"fileUrl" description:"MinIO文件访问路径"`
|
||||
FileSize int64 `orm:"file_size" json:"fileSize" description:"文件大小(字节)"`
|
||||
FileName string `orm:"file_name" json:"fileName" description:"文件名"`
|
||||
FileFormat string `orm:"file_format" json:"fileFormat" description:"文件格式"`
|
||||
FileAddressPrefix string `orm:"file_address_prefix" json:"fileAddressPrefix" description:"MinIO地址前缀"`
|
||||
MethodUsed string `orm:"method_used" json:"methodUsed" description:"实际使用的拼接方式"`
|
||||
DurationStr string `orm:"duration_str" json:"durationStr" description:"拼接后时长"`
|
||||
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"`
|
||||
}
|
||||
|
||||
// ConcatTaskCol 字段定义
|
||||
type ConcatTaskCol struct {
|
||||
beans.SQLBaseCol
|
||||
TaskID string
|
||||
Status string
|
||||
FileURL string
|
||||
FileSize string
|
||||
FileName string
|
||||
FileFormat string
|
||||
FileAddressPrefix string
|
||||
MethodUsed string
|
||||
DurationStr string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// ConcatTaskCols 字段常量
|
||||
var ConcatTaskCols = ConcatTaskCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
TaskID: "task_id",
|
||||
Status: "status",
|
||||
FileURL: "file_url",
|
||||
FileSize: "file_size",
|
||||
FileName: "file_name",
|
||||
FileFormat: "file_format",
|
||||
FileAddressPrefix: "file_address_prefix",
|
||||
MethodUsed: "method_used",
|
||||
DurationStr: "duration_str",
|
||||
ErrorMessage: "error_message",
|
||||
}
|
||||
54
model/entity/video/cut_task.go
Normal file
54
model/entity/video/cut_task.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package video
|
||||
|
||||
import "gitea.redpowerfuture.com/red-future/common/beans"
|
||||
|
||||
// CutTask 视频分镜剪切异步任务实体
|
||||
type CutTask struct {
|
||||
beans.SQLBaseDO `orm:",inherit"`
|
||||
TaskID string `orm:"task_id" json:"taskId" description:"任务唯一标识"`
|
||||
VideoURL string `orm:"video_url" json:"videoUrl" description:"原始视频URL"`
|
||||
ShotsJSON string `orm:"shots_json" json:"shotsJson" description:"分镜JSON"`
|
||||
Status string `orm:"status" json:"status" description:"任务状态:pending/running/success/failed"`
|
||||
FileURL string `orm:"file_url" json:"fileUrl" description:"MinIO文件访问路径"`
|
||||
FileSize int64 `orm:"file_size" json:"fileSize" description:"文件大小(字节)"`
|
||||
FileName string `orm:"file_name" json:"fileName" description:"文件名"`
|
||||
FileFormat string `orm:"file_format" json:"fileFormat" description:"文件格式"`
|
||||
FileAddressPrefix string `orm:"file_address_prefix" json:"fileAddressPrefix" description:"MinIO地址前缀"`
|
||||
DurationStr string `orm:"duration_str" json:"durationStr" description:"剪切后时长"`
|
||||
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"`
|
||||
CallbackURL string `orm:"callback_url" json:"callbackUrl" description:"回调地址"`
|
||||
}
|
||||
|
||||
// CutTaskCol 字段定义
|
||||
type CutTaskCol struct {
|
||||
beans.SQLBaseCol
|
||||
TaskID string
|
||||
VideoURL string
|
||||
ShotsJSON string
|
||||
Status string
|
||||
FileURL string
|
||||
FileSize string
|
||||
FileName string
|
||||
FileFormat string
|
||||
FileAddressPrefix string
|
||||
DurationStr string
|
||||
ErrorMessage string
|
||||
CallbackURL string
|
||||
}
|
||||
|
||||
// CutTaskCols 字段常量
|
||||
var CutTaskCols = CutTaskCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
TaskID: "task_id",
|
||||
VideoURL: "video_url",
|
||||
ShotsJSON: "shots_json",
|
||||
Status: "status",
|
||||
FileURL: "file_url",
|
||||
FileSize: "file_size",
|
||||
FileName: "file_name",
|
||||
FileFormat: "file_format",
|
||||
FileAddressPrefix: "file_address_prefix",
|
||||
DurationStr: "duration_str",
|
||||
ErrorMessage: "error_message",
|
||||
CallbackURL: "callback_url",
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
entity "media/model/entity/audio"
|
||||
serviceScene "media/service/scene"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"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"
|
||||
@@ -79,16 +80,19 @@ func (s *audioTaskService) Create(ctx context.Context, params *CreateTaskParams)
|
||||
g.Log().Infof(ctx, "[创建任务 %s] 文件数=%d, 模型=%s, 语言=%s, 回调=%s",
|
||||
taskID, len(params.InputData), params.Model, params.Language, params.CallbackURL)
|
||||
|
||||
// 提取调用方用户信息,传给 goroutine
|
||||
user := getUserFromCtx(ctx)
|
||||
|
||||
// 异步处理
|
||||
go s.processTask(taskID, params.InputData, params.Model, params.Language, params.Threshold, params.CallbackURL)
|
||||
go s.processTask(user, 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) {
|
||||
func (s *audioTaskService) processTask(user *beans.User, 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})
|
||||
ctx = context.WithValue(ctx, "user", user)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@@ -196,7 +200,7 @@ func (s *audioTaskService) processTask(taskID string, urls []string, model, lang
|
||||
g.Log().Infof(ctx, "[任务 %s] 全部处理流程结束", taskID)
|
||||
}
|
||||
|
||||
// callback 向回调地址 POST 任务结果
|
||||
// callback 向回调地址 POST 任务结果(与查询接口 GetTaskRes 出参一致)
|
||||
func (s *audioTaskService) callback(ctx context.Context, taskID, status, errMsg, callbackURL string) {
|
||||
if callbackURL == "" {
|
||||
return
|
||||
@@ -214,27 +218,30 @@ func (s *audioTaskService) callback(ctx context.Context, taskID, status, errMsg,
|
||||
detailItems = append(detailItems, dao.DetailEntityToItem(&detailList[i]))
|
||||
}
|
||||
|
||||
// 构建与查询接口一致的 taskInfo
|
||||
taskInfo := dao.EntityToItem(task)
|
||||
|
||||
// 与查询接口一致:从 result 中补全 scenes 等字段
|
||||
detailItems = enrichDetailsFromResult(task.Result, detailItems)
|
||||
|
||||
payload := dto.CallbackPayload{
|
||||
TaskID: taskID,
|
||||
Status: status,
|
||||
TotalFiles: task.TotalFiles,
|
||||
SuccessFiles: task.SuccessFiles,
|
||||
FailFiles: task.FailFiles,
|
||||
ErrorMessage: errMsg,
|
||||
Result: task.Result,
|
||||
DetailList: detailItems,
|
||||
TaskInfo: taskInfo,
|
||||
DetailList: detailItems,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
g.Log().Infof(ctx, "[回调 %s] 触发回调, 状态=%s, 成功=%d 失败=%d, 错误=%s, 目标=%s",
|
||||
taskID, status, payload.SuccessFiles, payload.FailFiles, errMsg, callbackURL)
|
||||
taskID, taskInfo.Status, taskInfo.SuccessFiles, taskInfo.FailFiles, errMsg, callbackURL)
|
||||
g.Log().Debugf(ctx, "[回调 %s] 回调载荷长度=%d字节, 明细条数=%d",
|
||||
taskID, len(body), len(detailItems))
|
||||
// 透传调用方的用户信息,供回调方 GetUserInfo 从 X-User-Info 头获取
|
||||
cbUser := getUserFromCtx(ctx)
|
||||
userJSON, _ := json.Marshal(cbUser)
|
||||
g.Log().Infof(ctx, "[回调 %s] curl -X POST '%s' -H 'Content-Type: application/json' -H 'X-User-Info: %s' -d '%s'",
|
||||
taskID, callbackURL, string(userJSON), strings.ReplaceAll(string(body), "'", "'\\''"))
|
||||
|
||||
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)
|
||||
@@ -314,6 +321,21 @@ func (s *audioTaskService) processSingleVideo(ctx context.Context, taskID, saveP
|
||||
}
|
||||
}
|
||||
|
||||
// getUserFromCtx 从 context 提取用户信息,没有则返回默认 admin
|
||||
func getUserFromCtx(ctx context.Context) *beans.User {
|
||||
if u := ctx.Value("user"); u != nil {
|
||||
if user, ok := u.(*beans.User); ok {
|
||||
return user
|
||||
}
|
||||
}
|
||||
// 尝试用 common 库解析
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err == nil && user != nil {
|
||||
return user
|
||||
}
|
||||
return &beans.User{UserName: "admin", TenantId: 1}
|
||||
}
|
||||
|
||||
// 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{
|
||||
|
||||
382
service/video/analysis_service.go
Normal file
382
service/video/analysis_service.go
Normal 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))
|
||||
}
|
||||
@@ -7,15 +7,24 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonHttp "gitea.com/red-future/common/http"
|
||||
dao "media/dao/video"
|
||||
dto "media/model/dto/video"
|
||||
entity "media/model/entity/video"
|
||||
|
||||
"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"
|
||||
"github.com/gogf/gf/v2/util/guid"
|
||||
)
|
||||
|
||||
type concatService struct{}
|
||||
@@ -44,6 +53,9 @@ type ConcatRes struct {
|
||||
|
||||
// Concat 拼接多个视频为一个
|
||||
func (s *concatService) Concat(ctx context.Context, req *ConcatReq) (res *ConcatRes, err error) {
|
||||
g.Log().Infof(ctx, "[Concat] 服务层收到请求: videoPaths=%v, method=%s, upload=%v",
|
||||
req.VideoPaths, req.Method, req.Upload)
|
||||
|
||||
if len(req.VideoPaths) < 2 {
|
||||
return nil, fmt.Errorf("至少需要2个视频才能拼接")
|
||||
}
|
||||
@@ -64,7 +76,16 @@ func (s *concatService) Concat(ctx context.Context, req *ConcatReq) (res *Concat
|
||||
outputPath := req.OutputPath
|
||||
if outputPath == "" {
|
||||
outputDir := filepath.Dir(req.VideoPaths[0])
|
||||
outputPath = filepath.Join(outputDir, "concat_output.mp4")
|
||||
// 用第一个输入文件名 + 拼接数 + 时间戳,溯源更清晰
|
||||
baseName := filepath.Base(req.VideoPaths[0])
|
||||
ext := filepath.Ext(baseName)
|
||||
stem := strings.TrimSuffix(baseName, ext)
|
||||
stemRunes := []rune(stem)
|
||||
if len(stemRunes) > 20 {
|
||||
stemRunes = stemRunes[:20]
|
||||
}
|
||||
outputPath = filepath.Join(outputDir,
|
||||
fmt.Sprintf("concat_%s_x%d_%s%s", string(stemRunes), len(req.VideoPaths), time.Now().Format("150405"), ext))
|
||||
}
|
||||
|
||||
method := req.Method
|
||||
@@ -107,9 +128,10 @@ func (s *concatService) Concat(ctx context.Context, req *ConcatReq) (res *Concat
|
||||
InputFiles: len(req.VideoPaths),
|
||||
}
|
||||
|
||||
// 如果需要上传到 MinIO
|
||||
// 如果需要上传到 MinIO(用独立 context,避免 HTTP 断开后 ctx 被取消)
|
||||
if req.Upload {
|
||||
uploadRes, uploadErr := s.UploadToMinIO(ctx, outputPath)
|
||||
uploadCtx := context.WithValue(context.Background(), "user", getUserFromCtx(ctx))
|
||||
uploadRes, uploadErr := s.UploadToMinIO(uploadCtx, outputPath)
|
||||
if uploadErr != nil {
|
||||
return nil, fmt.Errorf("上传到MinIO失败: %v", uploadErr)
|
||||
}
|
||||
@@ -141,7 +163,9 @@ func (s *concatService) concatByDemuxer(ctx context.Context, ffmpegPath string,
|
||||
output,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
// 使用独立 context,避免 HTTP 请求超时导致 ffmpeg 被 SIGKILL
|
||||
bgCtx := context.Background()
|
||||
cmd := exec.CommandContext(bgCtx, ffmpegPath, args...)
|
||||
outputBytes, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg demuxer 失败: %v\n%s", err, string(outputBytes))
|
||||
@@ -180,22 +204,40 @@ func (s *concatService) concatByFilter(ctx context.Context, ffmpegPath string, i
|
||||
inputArgs = append(inputArgs, "-i", p)
|
||||
}
|
||||
|
||||
// 3. 构建 filter_complex:每个视频 scale+pad 到统一尺寸,然后 concat
|
||||
// 3. 检测每个视频是否有音频轨道及时长
|
||||
hasAudio := make([]bool, n)
|
||||
videoDuration := make([]float64, n)
|
||||
for i, p := range inputs {
|
||||
hasAudio[i] = s.hasVideoAudio(ctx, ffmpegPath, p)
|
||||
videoDuration[i], _ = s.getVideoDuration(ctx, ffmpegPath, p)
|
||||
}
|
||||
|
||||
// 4. 构建 filter_complex:每个视频 scale+pad 到统一尺寸,然后 concat
|
||||
var filterParts []string
|
||||
var concatInputs []string
|
||||
for i := 0; i < n; i++ {
|
||||
filterParts = append(filterParts, fmt.Sprintf(
|
||||
"[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v%d]",
|
||||
i, maxW, maxH, maxW, maxH, i,
|
||||
))
|
||||
filterParts = append(filterParts, fmt.Sprintf(
|
||||
"[%d:a]aresample=44100[a%d]",
|
||||
i, i,
|
||||
))
|
||||
}
|
||||
// 收集归一化后的流
|
||||
var concatInputs []string
|
||||
for i := 0; i < n; i++ {
|
||||
concatInputs = append(concatInputs, fmt.Sprintf("[v%d][a%d]", i, i))
|
||||
if hasAudio[i] {
|
||||
filterParts = append(filterParts, fmt.Sprintf(
|
||||
"[%d:a]aresample=44100[a%d]",
|
||||
i, i,
|
||||
))
|
||||
concatInputs = append(concatInputs, fmt.Sprintf("[v%d][a%d]", i, i))
|
||||
} else {
|
||||
// 无音频轨道,生成匹配视频时长的静音音频
|
||||
dur := videoDuration[i]
|
||||
if dur <= 0 {
|
||||
dur = 30 // 保底30秒
|
||||
}
|
||||
filterParts = append(filterParts, fmt.Sprintf(
|
||||
"aevalsrc=0:n=2:s=44100:d=%.2f[a%d]",
|
||||
dur, i,
|
||||
))
|
||||
concatInputs = append(concatInputs, fmt.Sprintf("[v%d][a%d]", i, i))
|
||||
}
|
||||
}
|
||||
filterStr := fmt.Sprintf("%s;%sconcat=n=%d:v=1:a=1[outv][outa]",
|
||||
strings.Join(filterParts, ";"),
|
||||
@@ -206,8 +248,10 @@ func (s *concatService) concatByFilter(ctx context.Context, ffmpegPath string, i
|
||||
"-filter_complex", filterStr,
|
||||
"-map", "[outv]",
|
||||
"-map", "[outa]",
|
||||
"-preset", "fast",
|
||||
"-crf", "23",
|
||||
"-c:v", "h264_videotoolbox",
|
||||
"-b:v", "5M",
|
||||
"-allow_sw", "true",
|
||||
"-c:a", "aac",
|
||||
"-y",
|
||||
output,
|
||||
)
|
||||
@@ -220,7 +264,9 @@ func (s *concatService) concatByFilter(ctx context.Context, ffmpegPath string, i
|
||||
os.WriteFile(filterFile, []byte(filterStr), 0644)
|
||||
defer os.Remove(filterFile)
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
// 使用独立 context,避免 HTTP 请求超时导致 ffmpeg 被 SIGKILL
|
||||
bgCtx := context.Background()
|
||||
cmd := exec.CommandContext(bgCtx, ffmpegPath, args...)
|
||||
outputBytes, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg filter 失败: %v\n日志:\n%s", err, string(outputBytes))
|
||||
@@ -274,6 +320,28 @@ func (s *concatService) getVideoDuration(ctx context.Context, ffmpegPath, videoP
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// hasVideoAudio 检测视频文件是否有音频轨道
|
||||
func (s *concatService) hasVideoAudio(ctx context.Context, ffmpegPath, videoPath string) bool {
|
||||
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
||||
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
|
||||
ffprobePath = "ffprobe"
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_type",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||
return false
|
||||
}
|
||||
// 检测视频时长,如果为0则用 aevalsrc 生成静音
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *concatService) getFFmpegPath() (string, error) {
|
||||
ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String()
|
||||
if ffmpegPath != "" {
|
||||
@@ -304,9 +372,9 @@ type uploadFileRes struct {
|
||||
FileAddressPrefix string `json:"fileAddressPrefix"`
|
||||
}
|
||||
|
||||
// UploadToMinIO 通过 OSS 微服务的 multipart 文件上传接口上传到 MinIO
|
||||
// UploadToMinIO 通过 OSS 微服务的 uploadFile 接口上传到 MinIO(multipart/form-data)
|
||||
func (s *concatService) UploadToMinIO(ctx context.Context, localFilePath string) (*uploadFileRes, error) {
|
||||
// 手动构建 multipart/form-data 表单
|
||||
// 构建 multipart/form-data 表单
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
|
||||
@@ -325,25 +393,40 @@ func (s *concatService) UploadToMinIO(ctx context.Context, localFilePath string)
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
// 使用 commonHttp 的客户端(含 Consul 服务发现),大文件上传设置长超时
|
||||
client := commonHttp.Httpclient.Clone()
|
||||
// 必须单独设置 Transport.ResponseHeaderTimeout,SetTimeout 只设 Client.Timeout
|
||||
newTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
newTransport.ResponseHeaderTimeout = 5 * time.Minute
|
||||
client.Transport = newTransport
|
||||
client.SetTimeout(10 * time.Minute)
|
||||
|
||||
// 透传认证 headers
|
||||
// 透传认证 headers(优先从 HTTP 请求头取)
|
||||
hasAuthHeader := false
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Header {
|
||||
client.SetHeader(k, v[0])
|
||||
if strings.EqualFold(k, "Authorization") || strings.EqualFold(k, "X-User-Info") {
|
||||
hasAuthHeader = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无 HTTP 请求时(异步 goroutine),从 context 的用户信息构造 header
|
||||
if !hasAuthHeader {
|
||||
uploadUser := getUserFromCtx(ctx)
|
||||
userJSON, _ := json.Marshal(uploadUser)
|
||||
client.SetHeader("X-User-Info", string(userJSON))
|
||||
}
|
||||
|
||||
// 设置 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())
|
||||
g.Log().Debugf(ctx, "[UploadToMinIO] 请求URL: oss/file/uploadFile, 文件: %s, Body大小: %d bytes",
|
||||
localFilePath, buf.Len())
|
||||
|
||||
response, err := client.Post(ctx, "oss/file/uploadFile", postBytes)
|
||||
// 发送 multipart 请求(原始字节流)
|
||||
response, err := client.Post(ctx, "oss/file/uploadFile", buf.Bytes())
|
||||
if err != nil {
|
||||
glog.Error(ctx, err)
|
||||
return nil, fmt.Errorf("调用OSS上传服务失败: %v", err)
|
||||
@@ -379,3 +462,281 @@ func CleanupConcat(paths []string) {
|
||||
os.Remove(p)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 异步拼接任务管理 ----------
|
||||
|
||||
// CreateAsyncTask 创建异步拼接任务(URL模式),返回 taskId,后台处理
|
||||
func (s *concatService) CreateAsyncTask(ctx context.Context, videoURLs []string, method string, upload bool, callbackURL string) (string, error) {
|
||||
if len(videoURLs) < 2 {
|
||||
return "", fmt.Errorf("至少需要2个视频才能拼接")
|
||||
}
|
||||
|
||||
taskID := "concat_" + guid.S()
|
||||
task := &entity.ConcatTask{
|
||||
TaskID: taskID,
|
||||
Status: "pending",
|
||||
MethodUsed: method,
|
||||
}
|
||||
if _, err := dao.ConcatTask.Insert(ctx, task); 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, method, upload, callbackURL)
|
||||
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// CreateAsyncTaskWithFiles 创建异步拼接任务(文件上传模式),直接处理本地文件
|
||||
func (s *concatService) CreateAsyncTaskWithFiles(ctx context.Context, filePaths []string, method string, upload bool, callbackURL string) (string, error) {
|
||||
if len(filePaths) < 2 {
|
||||
return "", fmt.Errorf("至少需要2个视频才能拼接")
|
||||
}
|
||||
|
||||
taskID := "concat_" + guid.S()
|
||||
task := &entity.ConcatTask{
|
||||
TaskID: taskID,
|
||||
Status: "pending",
|
||||
MethodUsed: method,
|
||||
}
|
||||
if _, err := dao.ConcatTask.Insert(ctx, task); err != nil {
|
||||
return "", fmt.Errorf("创建任务失败: %v", err)
|
||||
}
|
||||
|
||||
// 提取调用方用户信息,传给 goroutine
|
||||
user := getUserFromCtx(ctx)
|
||||
|
||||
g.Log().Infof(ctx, "[异步拼接-文件] 创建任务 %s, 文件数=%d, 回调=%s", taskID, len(filePaths), callbackURL)
|
||||
|
||||
// 异步处理:已有本地文件,直接拼接
|
||||
go s.processAsyncTaskWithFiles(user, taskID, filePaths, method, upload, callbackURL)
|
||||
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// getUserFromCtx 从 context 中提取用户信息,如果没有则返回默认 admin
|
||||
func getUserFromCtx(ctx context.Context) *beans.User {
|
||||
if u := ctx.Value("user"); u != nil {
|
||||
if user, ok := u.(*beans.User); ok {
|
||||
return user
|
||||
}
|
||||
}
|
||||
return &beans.User{UserName: "admin", TenantId: 1}
|
||||
}
|
||||
|
||||
// GetTaskResult 查询异步任务结果
|
||||
func (s *concatService) GetTaskResult(ctx context.Context, taskID string) (*dto.GetConcatTaskRes, error) {
|
||||
task, err := dao.ConcatTask.GetByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询任务失败: %v", err)
|
||||
}
|
||||
if task == nil {
|
||||
return nil, fmt.Errorf("任务不存在: %s", taskID)
|
||||
}
|
||||
|
||||
return dao.EntityToTaskRes(task), nil
|
||||
}
|
||||
|
||||
// processAsyncTaskWithFiles 后台处理异步拼接任务(文件上传模式,文件已在本地)
|
||||
func (s *concatService) processAsyncTaskWithFiles(user *beans.User, taskID string, filePaths []string, method string, upload bool, callbackURL string) {
|
||||
bgCtx := context.Background()
|
||||
bgCtx = context.WithValue(bgCtx, "user", user)
|
||||
|
||||
dao.ConcatTask.UpdateRunning(bgCtx, taskID)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errMsg := fmt.Sprintf("异步拼接异常: %v", r)
|
||||
g.Log().Errorf(bgCtx, "[异步拼接 %s] %s", taskID, errMsg)
|
||||
dao.ConcatTask.UpdateError(bgCtx, taskID, errMsg)
|
||||
s.concatCallback(bgCtx, taskID, callbackURL)
|
||||
}
|
||||
}()
|
||||
|
||||
concatErr := s.executeConcat(bgCtx, taskID, filePaths, method, upload)
|
||||
if concatErr != nil {
|
||||
dao.ConcatTask.UpdateError(bgCtx, taskID, concatErr.Error())
|
||||
s.concatCallback(bgCtx, taskID, callbackURL)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(bgCtx, "[异步拼接 %s] 完成", taskID)
|
||||
|
||||
if callbackURL != "" {
|
||||
s.concatCallback(bgCtx, taskID, callbackURL)
|
||||
}
|
||||
}
|
||||
|
||||
// processAsyncTask 后台处理异步拼接任务(URL模式,需要先下载)
|
||||
func (s *concatService) processAsyncTask(user *beans.User, taskID string, videoURLs []string, method string, upload bool, callbackURL string) {
|
||||
bgCtx := context.Background()
|
||||
bgCtx = context.WithValue(bgCtx, "user", user)
|
||||
|
||||
dao.ConcatTask.UpdateRunning(bgCtx, taskID)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errMsg := fmt.Sprintf("异步拼接异常: %v", r)
|
||||
g.Log().Errorf(bgCtx, "[异步拼接 %s] %s", taskID, errMsg)
|
||||
dao.ConcatTask.UpdateError(bgCtx, taskID, errMsg)
|
||||
s.concatCallback(bgCtx, taskID, callbackURL)
|
||||
}
|
||||
}()
|
||||
|
||||
// 下载视频
|
||||
var savePaths []string
|
||||
tempDir := g.Cfg().MustGet(bgCtx, "ffmpeg.temp_dir", "resource/temp").String()
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
|
||||
for _, videoURL := range videoURLs {
|
||||
savePath, dlErr := downloadFile(bgCtx, videoURL, tempDir)
|
||||
if dlErr != nil {
|
||||
g.Log().Warningf(bgCtx, "[异步拼接 %s] 下载失败 %s: %v", taskID, videoURL, dlErr)
|
||||
continue
|
||||
}
|
||||
savePaths = append(savePaths, savePath)
|
||||
}
|
||||
|
||||
if len(savePaths) < 2 {
|
||||
errMsg := fmt.Sprintf("成功下载的视频不足2个(共%d)", len(videoURLs))
|
||||
dao.ConcatTask.UpdateError(bgCtx, taskID, errMsg)
|
||||
CleanupConcat(savePaths)
|
||||
s.concatCallback(bgCtx, taskID, callbackURL)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行拼接
|
||||
concatErr := s.executeConcat(bgCtx, taskID, savePaths, method, upload)
|
||||
CleanupConcat(savePaths)
|
||||
|
||||
if concatErr != nil {
|
||||
dao.ConcatTask.UpdateError(bgCtx, taskID, concatErr.Error())
|
||||
s.concatCallback(bgCtx, taskID, callbackURL)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(bgCtx, "[异步拼接 %s] 完成", taskID)
|
||||
|
||||
if callbackURL != "" {
|
||||
s.concatCallback(bgCtx, taskID, callbackURL)
|
||||
}
|
||||
}
|
||||
|
||||
// executeConcat 执行拼接并更新任务状态,返回输出路径
|
||||
func (s *concatService) executeConcat(ctx context.Context, taskID string, filePaths []string, method string, upload bool) error {
|
||||
tempDir := filepath.Dir(filePaths[0])
|
||||
outputPath := filepath.Join(tempDir,
|
||||
fmt.Sprintf("concat_%s_x%d_%s.mp4", taskID, len(filePaths), time.Now().Format("150405")))
|
||||
|
||||
res, concatErr := s.Concat(ctx, &ConcatReq{
|
||||
VideoPaths: filePaths,
|
||||
OutputPath: outputPath,
|
||||
Method: method,
|
||||
Upload: upload,
|
||||
})
|
||||
if concatErr != nil {
|
||||
os.Remove(outputPath)
|
||||
return concatErr
|
||||
}
|
||||
|
||||
// 更新数据库为成功
|
||||
fileName := filepath.Base(outputPath)
|
||||
fileFormat := ""
|
||||
if idx := strings.LastIndex(fileName, "."); idx > 0 {
|
||||
fileFormat = fileName[idx+1:]
|
||||
}
|
||||
dao.ConcatTask.UpdateSuccess(ctx, taskID,
|
||||
res.FileURL, res.FileSize, fileName, fileFormat,
|
||||
"", res.MethodUsed, res.DurationStr)
|
||||
|
||||
os.Remove(outputPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// concatCallback 回调通知(从数据库读取任务结果发送)
|
||||
func (s *concatService) concatCallback(ctx context.Context, taskID, callbackURL string) {
|
||||
if callbackURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
task, err := dao.ConcatTask.GetByTaskID(ctx, taskID)
|
||||
if err != nil || task == nil {
|
||||
g.Log().Errorf(ctx, "[异步拼接回调 %s] 查询任务失败: %v", taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"taskId": task.TaskID,
|
||||
"status": task.Status,
|
||||
}
|
||||
if task.Status == "success" {
|
||||
payload["fileURL"] = task.FileURL
|
||||
payload["fileSize"] = task.FileSize
|
||||
}
|
||||
if task.Status == "failed" {
|
||||
payload["errorMessage"] = task.ErrorMessage
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
g.Log().Infof(ctx, "[异步拼接回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL)
|
||||
|
||||
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// 透传调用方用户信息
|
||||
cbUser := getUserFromCtx(ctx)
|
||||
userJSON, _ := json.Marshal(cbUser)
|
||||
req.Header.Set("X-User-Info", string(userJSON))
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
resp, reqErr := client.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))
|
||||
}
|
||||
|
||||
// downloadFile 下载文件到临时目录
|
||||
func downloadFile(ctx context.Context, rawURL, tempDir string) (string, error) {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
segments := strings.Split(parsedURL.Path, "/")
|
||||
fileName := segments[len(segments)-1]
|
||||
if fileName == "" {
|
||||
fileName = fmt.Sprintf("video_%d.mp4", time.Now().UnixMilli())
|
||||
}
|
||||
savePath := filepath.Join(tempDir, fmt.Sprintf("%d_%s", time.Now().UnixMilli(), fileName))
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(savePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
os.Remove(savePath)
|
||||
return "", err
|
||||
}
|
||||
return savePath, nil
|
||||
}
|
||||
|
||||
662
service/video/cut_service.go
Normal file
662
service/video/cut_service.go
Normal file
@@ -0,0 +1,662 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dao "media/dao/video"
|
||||
dto "media/model/dto/video"
|
||||
entity "media/model/entity/video"
|
||||
|
||||
"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"
|
||||
"github.com/gogf/gf/v2/util/guid"
|
||||
)
|
||||
|
||||
type cutService struct{}
|
||||
|
||||
// Cut 视频分镜剪切服务单例
|
||||
var Cut = new(cutService)
|
||||
|
||||
// CutShot 单个分镜片段(转换为秒后内部存储)
|
||||
type CutShot struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
}
|
||||
|
||||
// CutReq 视频剪切请求
|
||||
type CutReq struct {
|
||||
VideoPath string // 输入视频文件路径
|
||||
Shots []CutShot // 分镜片段列表(按此顺序剪切拼接)
|
||||
OutputPath string // 输出视频文件路径,空则自动生成
|
||||
Upload bool // 是否上传到MinIO
|
||||
}
|
||||
|
||||
// CutRes 视频剪切响应
|
||||
type CutRes struct {
|
||||
OutputPath string `json:"outputPath"` // 输出文件路径
|
||||
FileSize int64 `json:"fileSize"` // 文件大小(bytes)
|
||||
Duration float64 `json:"duration"` // 总时长(秒)
|
||||
DurationStr string `json:"durationStr"` // 可读时长
|
||||
ShotsCount int `json:"shotsCount"` // 输出片段数
|
||||
FileURL string `json:"fileURL"` // MinIO访问地址(上传后返回)
|
||||
}
|
||||
|
||||
// parseTimeStr 解析 HH:MM:SS.mmm 格式为秒
|
||||
func parseTimeStr(timeStr string) (float64, error) {
|
||||
parts := strings.Split(timeStr, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0, fmt.Errorf("invalid time format: %s, expected HH:MM:SS.mmm", timeStr)
|
||||
}
|
||||
|
||||
h, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
m, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 处理秒和毫秒
|
||||
secParts := strings.SplitN(parts[2], ".", 2)
|
||||
s, err := strconv.Atoi(secParts[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ms := 0
|
||||
if len(secParts) > 1 {
|
||||
ms, _ = strconv.Atoi(secParts[1])
|
||||
}
|
||||
|
||||
total := float64(h)*3600 + float64(m)*60 + float64(s) + float64(ms)/1000.0
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// ConvertScenes 将 DTO 的分镜片段数组转换为内部的 CutShot 列表
|
||||
// 同时兼容两种格式:
|
||||
// 1. 如果 scene.Start > 0 && scene.End > scene.Start → 直接使用
|
||||
// 2. 否则 → 解析 scene.StartTimeStr / scene.EndTimeStr
|
||||
func ConvertScenes(scenes []dto.CutScene) ([]CutShot, error) {
|
||||
var shots []CutShot
|
||||
for _, scene := range scenes {
|
||||
var (
|
||||
start float64
|
||||
end float64
|
||||
err error
|
||||
)
|
||||
|
||||
// 优先使用 start/end 直接给出的秒数
|
||||
if scene.Start > 0 && scene.End > scene.Start {
|
||||
start = scene.Start
|
||||
end = scene.End
|
||||
} else if scene.StartTimeStr != "" && scene.EndTimeStr != "" {
|
||||
// 否则解析时间字符串 HH:MM:SS.mmm
|
||||
start, err = parseTimeStr(scene.StartTimeStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse start time %s: %v", scene.StartTimeStr, err)
|
||||
}
|
||||
end, err = parseTimeStr(scene.EndTimeStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse end time %s: %v", scene.EndTimeStr, err)
|
||||
}
|
||||
} else {
|
||||
// 没有可用的时间信息,跳过
|
||||
continue
|
||||
}
|
||||
|
||||
if end > start {
|
||||
shots = append(shots, CutShot{Start: start, End: end})
|
||||
}
|
||||
}
|
||||
return shots, nil
|
||||
}
|
||||
|
||||
// Cut 根据分镜剪切多个片段并拼接输出
|
||||
func (s *cutService) Cut(ctx context.Context, req *CutReq) (res *CutRes, err error) {
|
||||
g.Log().Infof(ctx, "[Cut] 服务层收到请求: video=%s, shots=%d, upload=%v",
|
||||
req.VideoPath, len(req.Shots), req.Upload)
|
||||
|
||||
// 校验输入
|
||||
if _, err := os.Stat(req.VideoPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("输入视频文件不存在: %s", req.VideoPath)
|
||||
}
|
||||
|
||||
// 过滤掉无效片段(start >= end)
|
||||
var validShots []CutShot
|
||||
for _, shot := range req.Shots {
|
||||
if shot.End > shot.Start {
|
||||
validShots = append(validShots, shot)
|
||||
}
|
||||
}
|
||||
if len(validShots) == 0 {
|
||||
return nil, fmt.Errorf("没有有效的分镜片段(所有片段 start >= end)")
|
||||
}
|
||||
|
||||
ffmpegPath, err := s.getFFmpegPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 生成输出路径
|
||||
outputPath := req.OutputPath
|
||||
if outputPath == "" {
|
||||
outputDir := filepath.Dir(req.VideoPath)
|
||||
baseName := filepath.Base(req.VideoPath)
|
||||
ext := filepath.Ext(baseName)
|
||||
stem := strings.TrimSuffix(baseName, ext)
|
||||
stemRunes := []rune(stem)
|
||||
if len(stemRunes) > 20 {
|
||||
stemRunes = stemRunes[:20]
|
||||
}
|
||||
outputPath = filepath.Join(outputDir,
|
||||
fmt.Sprintf("cut_%s_x%d_%s%s", string(stemRunes), len(validShots), time.Now().Format("150405"), ext))
|
||||
}
|
||||
|
||||
// 执行剪切拼接
|
||||
err = s.cutByFilterComplex(ctx, ffmpegPath, req.VideoPath, validShots, outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("视频剪切失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取输出文件信息
|
||||
stat, statErr := os.Stat(outputPath)
|
||||
if statErr != nil {
|
||||
return nil, fmt.Errorf("输出文件异常: %v", statErr)
|
||||
}
|
||||
|
||||
// 获取时长
|
||||
duration, _ := s.getVideoDuration(ctx, ffmpegPath, outputPath)
|
||||
|
||||
res = &CutRes{
|
||||
OutputPath: outputPath,
|
||||
FileSize: stat.Size(),
|
||||
Duration: duration,
|
||||
DurationStr: formatDuration(duration),
|
||||
ShotsCount: len(validShots),
|
||||
}
|
||||
|
||||
// 如果需要上传到 MinIO(用独立 context,避免 HTTP 断开后 ctx 被取消,同时保留用户信息)
|
||||
if req.Upload {
|
||||
uploadCtx := context.WithValue(context.Background(), "user", getUserFromCtx(ctx))
|
||||
uploadRes, uploadErr := s.UploadToMinIO(uploadCtx, outputPath)
|
||||
if uploadErr != nil {
|
||||
return nil, fmt.Errorf("上传到MinIO失败: %v", uploadErr)
|
||||
}
|
||||
res.FileURL = uploadRes.FileURL
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// cutByFilterComplex 使用 filter_complex 一步完成剪切拼接
|
||||
// 对每个片段用 trim/atrim 截取,然后 concat 拼接
|
||||
func (s *cutService) cutByFilterComplex(ctx context.Context, ffmpegPath, inputPath string, shots []CutShot, outputPath string) error {
|
||||
n := len(shots)
|
||||
|
||||
// 检测视频是否有音频轨道
|
||||
hasAudio, err := s.checkVideoAudio(ctx, ffmpegPath, inputPath)
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "[Cut] 检测音频失败: %v", err)
|
||||
hasAudio = false
|
||||
}
|
||||
|
||||
// 获取视频总时长,用于裁剪超出的 end
|
||||
videoDuration, _ := s.getVideoDuration(ctx, ffmpegPath, inputPath)
|
||||
|
||||
// 构建 filter_complex
|
||||
var filterParts []string
|
||||
var concatInputs []string
|
||||
|
||||
for i, shot := range shots {
|
||||
// 确保 end 不超过视频时长
|
||||
end := shot.End
|
||||
if videoDuration > 0 && end > videoDuration {
|
||||
end = videoDuration
|
||||
}
|
||||
if end <= shot.Start {
|
||||
continue
|
||||
}
|
||||
|
||||
// 视频: trim 截取,重置 PTS
|
||||
filterParts = append(filterParts, fmt.Sprintf(
|
||||
"[0:v]trim=start=%.3f:end=%.3f,setpts=PTS-STARTPTS[v%d]",
|
||||
shot.Start, end, i,
|
||||
))
|
||||
|
||||
// 音频处理
|
||||
if hasAudio {
|
||||
// 有音频轨道,用 atrim 截取
|
||||
filterParts = append(filterParts, fmt.Sprintf(
|
||||
"[0:a]atrim=start=%.3f:end=%.3f,asetpts=PTS-STARTPTS[a%d]",
|
||||
shot.Start, end, i,
|
||||
))
|
||||
concatInputs = append(concatInputs, fmt.Sprintf("[v%d][a%d]", i, i))
|
||||
} else {
|
||||
// 无音频轨道,为这个片段生成静音音频
|
||||
dur := end - shot.Start
|
||||
filterParts = append(filterParts, fmt.Sprintf(
|
||||
"aevalsrc=0:n=2:s=44100:d=%.2f[a%d]",
|
||||
dur, i,
|
||||
))
|
||||
concatInputs = append(concatInputs, fmt.Sprintf("[v%d][a%d]", i, i))
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接所有片段
|
||||
filterStr := fmt.Sprintf("%s;%sconcat=n=%d:v=1:a=1[outv][outa]",
|
||||
strings.Join(filterParts, ";"),
|
||||
strings.Join(concatInputs, ""), n,
|
||||
)
|
||||
|
||||
// 构造完整参数
|
||||
args := []string{
|
||||
"-i", inputPath,
|
||||
"-filter_complex", filterStr,
|
||||
"-map", "[outv]",
|
||||
"-map", "[outa]",
|
||||
"-c:v", "h264_videotoolbox",
|
||||
"-b:v", "5M",
|
||||
"-allow_sw", "true",
|
||||
"-c:a", "aac",
|
||||
"-y",
|
||||
outputPath,
|
||||
}
|
||||
|
||||
// 调试:记录完整命令
|
||||
g.Log().Debugf(ctx, "[Cut] ffmpeg 命令: %s %v", ffmpegPath, args)
|
||||
|
||||
// 保存 filter 用于调试
|
||||
outputDir := filepath.Dir(outputPath)
|
||||
filterFile := filepath.Join(outputDir, "cut_filter.txt")
|
||||
os.WriteFile(filterFile, []byte(filterStr), 0644)
|
||||
defer os.Remove(filterFile)
|
||||
|
||||
// 使用独立 context,避免 HTTP 请求超时导致 ffmpeg 被 SIGKILL
|
||||
bgCtx := context.Background()
|
||||
cmd := exec.CommandContext(bgCtx, ffmpegPath, args...)
|
||||
outputBytes, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg 执行失败: %v\n日志:\n%s", err, string(outputBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFFmpegPath 获取 FFmpeg 路径
|
||||
func (s *cutService) getFFmpegPath() (string, error) {
|
||||
ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String()
|
||||
if ffmpegPath != "" {
|
||||
if _, err := os.Stat(ffmpegPath); err == nil {
|
||||
return ffmpegPath, nil
|
||||
}
|
||||
}
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未找到 ffmpeg")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// getVideoDuration 获取视频时长
|
||||
func (s *cutService) getVideoDuration(ctx context.Context, ffmpegPath, videoPath string) (float64, error) {
|
||||
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
||||
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
|
||||
ffprobePath = "ffprobe"
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var duration float64
|
||||
fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration)
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// checkVideoAudio 检测视频文件是否有音频轨道
|
||||
func (s *cutService) checkVideoAudio(ctx context.Context, ffmpegPath, videoPath string) (bool, error) {
|
||||
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
||||
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
|
||||
ffprobePath = "ffprobe"
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_type",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// hasVideoAudio 别名保持命名一致(从concat_service复制)
|
||||
func (s *cutService) hasVideoAudio(ctx context.Context, ffmpegPath, videoPath string) bool {
|
||||
has, _ := s.checkVideoAudio(ctx, ffmpegPath, videoPath)
|
||||
return has
|
||||
}
|
||||
|
||||
// getVideoResolution 获取视频分辨率(复用)
|
||||
func (s *cutService) getVideoResolution(ctx context.Context, ffmpegPath, videoPath string) (width, height int, err error) {
|
||||
ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
|
||||
if _, err := os.Stat(ffprobePath); os.IsNotExist(err) {
|
||||
ffprobePath = "ffprobe"
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height",
|
||||
"-of", "csv=p=0",
|
||||
videoPath,
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
fmt.Sscanf(strings.TrimSpace(string(output)), "%d,%d", &width, &height)
|
||||
return
|
||||
}
|
||||
|
||||
// cutUploadFileRes 上传文件响应(类型别名避免重定义)
|
||||
type cutUploadFileRes 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" dc:"文件地址前缀"`
|
||||
}
|
||||
|
||||
// UploadToMinIO 通过 OSS 微服务的 uploadFile 接口上传到 MinIO(multipart/form-data)
|
||||
func (s *cutService) UploadToMinIO(ctx context.Context, localFilePath string) (*cutUploadFileRes, 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()
|
||||
|
||||
// 使用 commonHttp 的客户端(含 Consul 服务发现),大文件上传设置长超时
|
||||
client := commonHttp.Httpclient.Clone()
|
||||
// 必须单独设置 Transport.ResponseHeaderTimeout,SetTimeout 只设 Client.Timeout
|
||||
newTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
newTransport.ResponseHeaderTimeout = 5 * time.Minute
|
||||
client.Transport = newTransport
|
||||
client.SetTimeout(10 * time.Minute)
|
||||
|
||||
// 透传认证 headers(优先从 HTTP 请求头取)
|
||||
hasAuthHeader := false
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Header {
|
||||
client.SetHeader(k, v[0])
|
||||
if strings.EqualFold(k, "Authorization") || strings.EqualFold(k, "X-User-Info") {
|
||||
hasAuthHeader = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无 HTTP 请求时(异步 goroutine),从 context 的用户信息构造 header
|
||||
if !hasAuthHeader {
|
||||
uploadUser := getUserFromCtx(ctx)
|
||||
userJSON, _ := json.Marshal(uploadUser)
|
||||
client.SetHeader("X-User-Info", string(userJSON))
|
||||
}
|
||||
|
||||
// 设置 multipart Content-Type(含 boundary)
|
||||
contentType := mw.FormDataContentType()
|
||||
client.SetHeader("Content-Type", contentType)
|
||||
|
||||
g.Log().Debugf(ctx, "[UploadToMinIO] 请求URL: oss/file/uploadFile, 文件: %s, Body大小: %d bytes",
|
||||
localFilePath, buf.Len())
|
||||
|
||||
// 发送 multipart 请求(原始字节流)
|
||||
response, err := client.Post(ctx, "oss/file/uploadFile", buf.Bytes())
|
||||
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 *cutUploadFileRes `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
|
||||
}
|
||||
|
||||
// ---------- 异步任务管理 ----------
|
||||
|
||||
// CreateAsyncTask 创建异步剪切任务,返回 taskId,后台处理
|
||||
func (s *cutService) CreateAsyncTask(ctx context.Context, videoURL string, shots []CutShot, upload bool, callbackURL string) (string, error) {
|
||||
if videoURL == "" {
|
||||
return "", fmt.Errorf("视频URL不能为空")
|
||||
}
|
||||
|
||||
// 过滤无效片段
|
||||
var validShots []CutShot
|
||||
for _, shot := range shots {
|
||||
if shot.End > shot.Start {
|
||||
validShots = append(validShots, shot)
|
||||
}
|
||||
}
|
||||
if len(validShots) == 0 {
|
||||
return "", fmt.Errorf("没有有效的分镜片段")
|
||||
}
|
||||
|
||||
// 序列化 shots JSON
|
||||
shotsJSON, err := json.Marshal(shots)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化分镜失败: %v", err)
|
||||
}
|
||||
|
||||
taskID := "cut_" + guid.S()
|
||||
task := &entity.CutTask{
|
||||
TaskID: taskID,
|
||||
VideoURL: videoURL,
|
||||
ShotsJSON: string(shotsJSON),
|
||||
Status: "pending",
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
if _, err := dao.CutTask.Insert(ctx, task); err != nil {
|
||||
return "", fmt.Errorf("创建任务失败: %v", err)
|
||||
}
|
||||
|
||||
// 提取调用方用户信息,传给 goroutine
|
||||
user := getUserFromCtx(ctx)
|
||||
|
||||
g.Log().Infof(ctx, "[异步剪切] 创建任务 %s, 视频=%s, 片段数=%d, 回调=%s", taskID, videoURL, len(validShots), callbackURL)
|
||||
|
||||
// 异步处理
|
||||
go s.processAsyncTask(user, taskID, videoURL, shots, upload, callbackURL)
|
||||
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// GetTaskResult 查询异步任务结果
|
||||
func (s *cutService) GetTaskResult(ctx context.Context, taskID string) (*dto.GetCutTaskRes, error) {
|
||||
task, err := dao.CutTask.GetByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询任务失败: %v", err)
|
||||
}
|
||||
if task == nil {
|
||||
return nil, fmt.Errorf("任务不存在: %s", taskID)
|
||||
}
|
||||
|
||||
return dao.CutEntityToTaskRes(task), nil
|
||||
}
|
||||
|
||||
// processAsyncTask 后台处理异步剪切任务
|
||||
func (s *cutService) processAsyncTask(user *beans.User, taskID string, videoURL string, shots []CutShot, upload bool, callbackURL string) {
|
||||
bgCtx := context.Background()
|
||||
bgCtx = context.WithValue(bgCtx, "user", user)
|
||||
|
||||
dao.CutTask.UpdateRunning(bgCtx, taskID)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errMsg := fmt.Sprintf("异步剪切异常: %v", r)
|
||||
g.Log().Errorf(bgCtx, "[异步剪切 %s] %s", taskID, errMsg)
|
||||
dao.CutTask.UpdateError(bgCtx, taskID, errMsg)
|
||||
s.cutCallback(bgCtx, taskID, callbackURL)
|
||||
}
|
||||
}()
|
||||
|
||||
// 下载视频
|
||||
tempDir := g.Cfg().MustGet(bgCtx, "ffmpeg.temp_dir", "resource/temp").String()
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
|
||||
savePath, dlErr := downloadFile(bgCtx, videoURL, tempDir)
|
||||
if dlErr != nil {
|
||||
errMsg := fmt.Sprintf("下载视频失败: %v", dlErr)
|
||||
dao.CutTask.UpdateError(bgCtx, taskID, errMsg)
|
||||
s.cutCallback(bgCtx, taskID, callbackURL)
|
||||
return
|
||||
}
|
||||
defer os.Remove(savePath)
|
||||
|
||||
// 执行剪切
|
||||
cutErr := s.executeCut(bgCtx, taskID, savePath, shots, upload)
|
||||
if cutErr != nil {
|
||||
dao.CutTask.UpdateError(bgCtx, taskID, cutErr.Error())
|
||||
s.cutCallback(bgCtx, taskID, callbackURL)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(bgCtx, "[异步剪切 %s] 完成", taskID)
|
||||
|
||||
if callbackURL != "" {
|
||||
s.cutCallback(bgCtx, taskID, callbackURL)
|
||||
}
|
||||
}
|
||||
|
||||
// executeCut 执行剪切并更新任务状态
|
||||
func (s *cutService) executeCut(ctx context.Context, taskID string, videoPath string, shots []CutShot, upload bool) error {
|
||||
tempDir := filepath.Dir(videoPath)
|
||||
outputPath := filepath.Join(tempDir,
|
||||
fmt.Sprintf("cut_%s_x%d_%s.mp4", taskID, len(shots), time.Now().Format("150405")))
|
||||
|
||||
res, cutErr := s.Cut(ctx, &CutReq{
|
||||
VideoPath: videoPath,
|
||||
Shots: shots,
|
||||
OutputPath: outputPath,
|
||||
Upload: upload,
|
||||
})
|
||||
if cutErr != nil {
|
||||
os.Remove(outputPath)
|
||||
return cutErr
|
||||
}
|
||||
|
||||
// 更新数据库为成功
|
||||
fileName := filepath.Base(outputPath)
|
||||
fileFormat := ""
|
||||
if idx := strings.LastIndex(fileName, "."); idx > 0 {
|
||||
fileFormat = fileName[idx+1:]
|
||||
}
|
||||
dao.CutTask.UpdateSuccess(ctx, taskID,
|
||||
res.FileURL, res.FileSize, fileName, fileFormat,
|
||||
"", res.DurationStr)
|
||||
|
||||
os.Remove(outputPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cutCallback 回调通知
|
||||
func (s *cutService) cutCallback(ctx context.Context, taskID, callbackURL string) {
|
||||
if callbackURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
task, err := dao.CutTask.GetByTaskID(ctx, taskID)
|
||||
if err != nil || task == nil {
|
||||
g.Log().Errorf(ctx, "[异步剪切回调 %s] 查询任务失败: %v", taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"taskId": task.TaskID,
|
||||
"status": task.Status,
|
||||
}
|
||||
if task.Status == "success" {
|
||||
payload["fileURL"] = task.FileURL
|
||||
payload["fileSize"] = task.FileSize
|
||||
}
|
||||
if task.Status == "failed" {
|
||||
payload["errorMessage"] = task.ErrorMessage
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
g.Log().Infof(ctx, "[异步剪切回调 %s] 状态=%s, 目标=%s", taskID, task.Status, callbackURL)
|
||||
|
||||
req, _ := http.NewRequest("POST", callbackURL, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// 透传调用方用户信息
|
||||
cbUser := getUserFromCtx(ctx)
|
||||
userJSON, _ := json.Marshal(cbUser)
|
||||
req.Header.Set("X-User-Info", string(userJSON))
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
resp, reqErr := client.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))
|
||||
}
|
||||
39
sql/concat_task.sql
Normal file
39
sql/concat_task.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- concat_task 视频拼接异步任务表
|
||||
CREATE TABLE IF NOT EXISTS concat_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',
|
||||
file_url TEXT NOT NULL DEFAULT '',
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
file_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
file_format VARCHAR(32) NOT NULL DEFAULT '',
|
||||
file_address_prefix TEXT NOT NULL DEFAULT '',
|
||||
method_used VARCHAR(64) NOT NULL DEFAULT '',
|
||||
duration_str VARCHAR(32) NOT NULL DEFAULT '',
|
||||
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 concat_task IS '视频拼接异步任务表';
|
||||
COMMENT ON COLUMN concat_task.task_id IS '任务唯一标识';
|
||||
COMMENT ON COLUMN concat_task.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN concat_task.status IS '任务状态:pending/running/success/failed';
|
||||
COMMENT ON COLUMN concat_task.file_url IS 'MinIO文件访问路径';
|
||||
COMMENT ON COLUMN concat_task.file_size IS '文件大小(字节)';
|
||||
COMMENT ON COLUMN concat_task.file_name IS '文件名';
|
||||
COMMENT ON COLUMN concat_task.file_format IS '文件格式';
|
||||
COMMENT ON COLUMN concat_task.file_address_prefix IS 'MinIO地址前缀';
|
||||
COMMENT ON COLUMN concat_task.method_used IS '实际使用的拼接方式';
|
||||
COMMENT ON COLUMN concat_task.duration_str IS '拼接后时长';
|
||||
COMMENT ON COLUMN concat_task.error_message IS '错误信息';
|
||||
COMMENT ON COLUMN concat_task.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN concat_task.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN concat_task.deleted_at IS '删除时间(软删除)';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_concat_task_task_id ON concat_task(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_concat_task_status ON concat_task(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_concat_task_created_at ON concat_task(created_at);
|
||||
42
sql/cut_task.sql
Normal file
42
sql/cut_task.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- cut_task 视频分镜剪切异步任务表
|
||||
CREATE TABLE IF NOT EXISTS cut_task (
|
||||
id BIGSERIAL NOT NULL,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
task_id VARCHAR(64) NOT NULL,
|
||||
video_url TEXT NOT NULL,
|
||||
shots_json TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
file_url TEXT NOT NULL DEFAULT '',
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
file_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
file_format VARCHAR(32) NOT NULL DEFAULT '',
|
||||
file_address_prefix TEXT NOT NULL DEFAULT '',
|
||||
duration_str VARCHAR(32) NOT NULL DEFAULT '',
|
||||
error_message TEXT,
|
||||
callback_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
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 cut_task IS '视频分镜剪切异步任务表';
|
||||
COMMENT ON COLUMN cut_task.task_id IS '任务唯一标识';
|
||||
COMMENT ON COLUMN cut_task.video_url IS '原始视频URL';
|
||||
COMMENT ON COLUMN cut_task.shots_json IS '分镜JSON数组';
|
||||
COMMENT ON COLUMN cut_task.status IS '任务状态:pending/running/success/failed';
|
||||
COMMENT ON COLUMN cut_task.file_url IS 'MinIO文件访问路径';
|
||||
COMMENT ON COLUMN cut_task.file_size IS '文件大小(字节)';
|
||||
COMMENT ON COLUMN cut_task.file_name IS '文件名';
|
||||
COMMENT ON COLUMN cut_task.file_format IS '文件格式';
|
||||
COMMENT ON COLUMN cut_task.file_address_prefix IS 'MinIO地址前缀';
|
||||
COMMENT ON COLUMN cut_task.duration_str IS '剪切后时长';
|
||||
COMMENT ON COLUMN cut_task.error_message IS '错误信息';
|
||||
COMMENT ON COLUMN cut_task.callback_url IS '回调地址';
|
||||
COMMENT ON COLUMN cut_task.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN cut_task.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN cut_task.deleted_at IS '删除时间(软删除)';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_cut_task_task_id ON cut_task(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cut_task_status ON cut_task(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_cut_task_created_at ON cut_task(created_at);
|
||||
60
sql/video_analysis_task.sql
Normal file
60
sql/video_analysis_task.sql
Normal 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);
|
||||
Reference in New Issue
Block a user