From 7d4ac823347ddfe70d01b2dad6d436c1e32acfe4 Mon Sep 17 00:00:00 2001 From: lmk <1095689763@qq.com> Date: Mon, 22 Jun 2026 09:18:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=89=AA=E8=BE=91=E5=92=8C?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API文档.md | 176 +++- CLAUDE.md | 134 +-- config.yml | 29 +- controller/video/caption_controller.go | 39 + controller/video/merge_controller.go | 42 + controller/video/scene_split_controller.go | 39 + controller/video/transcode_controller.go | 42 + dao/video/scene_split_task_dao.go | 159 ++++ dao/video/video_audio_merge_task_dao.go | 112 +++ dao/video/video_caption_task_dao.go | 98 +++ main.go | 7 + model/dto/video/scene_split_dto.go | 43 + model/dto/video/transcode_dto.go | 9 + model/dto/video/video_audio_merge_dto.go | 39 + model/dto/video/video_caption_dto.go | 78 ++ model/entity/video/scene_split_task.go | 48 ++ model/entity/video/video_audio_merge_task.go | 54 ++ model/entity/video/video_caption_task.go | 59 ++ service/asr/task_service.go | 4 +- service/asr/transcribe_service.go | 31 +- service/asr/whisper_service.go | 403 --------- service/audio/audio_extract_service.go | 11 +- service/scene/scene_service.go | 7 +- service/setup/setup_service.go | 539 +++++------- service/video/caption_service.go | 837 +++++++++++++++++++ service/video/concat_service.go | 29 +- service/video/cut_service.go | 7 +- service/video/merge_service.go | 436 ++++++++++ service/video/scene_split_service.go | 433 ++++++++++ service/video/transcode_service.go | 93 +++ sql/scene_split_task.sql | 37 + sql/scene_split_task_alter.sql | 4 + sql/video_audio_merge_task.sql | 42 + sql/video_caption_task.sql | 38 + 34 files changed, 3319 insertions(+), 839 deletions(-) create mode 100644 controller/video/caption_controller.go create mode 100644 controller/video/merge_controller.go create mode 100644 controller/video/scene_split_controller.go create mode 100644 controller/video/transcode_controller.go create mode 100644 dao/video/scene_split_task_dao.go create mode 100644 dao/video/video_audio_merge_task_dao.go create mode 100644 dao/video/video_caption_task_dao.go create mode 100644 model/dto/video/scene_split_dto.go create mode 100644 model/dto/video/transcode_dto.go create mode 100644 model/dto/video/video_audio_merge_dto.go create mode 100644 model/dto/video/video_caption_dto.go create mode 100644 model/entity/video/scene_split_task.go create mode 100644 model/entity/video/video_audio_merge_task.go create mode 100644 model/entity/video/video_caption_task.go delete mode 100644 service/asr/whisper_service.go create mode 100644 service/video/caption_service.go create mode 100644 service/video/merge_service.go create mode 100644 service/video/scene_split_service.go create mode 100644 service/video/transcode_service.go create mode 100644 sql/scene_split_task.sql create mode 100644 sql/scene_split_task_alter.sql create mode 100644 sql/video_audio_merge_task.sql create mode 100644 sql/video_caption_task.sql diff --git a/API文档.md b/API文档.md index e89c8f9..4bc1d1c 100644 --- a/API文档.md +++ b/API文档.md @@ -167,7 +167,181 @@ curl -X POST http://localhost:8900/generate \ --- -## 错误响应 +## 5. 字幕叠加任务接口 + +**服务地址**:`http://127.0.0.1:3010` + +### 5.1 创建字幕叠加任务 + +**接口路径**:`POST /video/caption` + +**功能说明**:将背景视频、字幕、文字/图片元素、背景音频合成输出最终视频。异步执行,返回 taskId。 + +**请求参数**(Content-Type: `application/json`): + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| video_urls | string[] | **是** | 背景视频 URL 列表,多个会自动拼接 | +| audio_url | string | 否 | 背景音频 URL(配音/BGM),原视频会被消音 | +| subtitles | SubtitleSegment[] | 否 | 字幕时间线列表 | +| subtitle_style | SubtitleStyle | 否 | 字幕样式配置 | +| elements | CaptionElement[] | 否 | 文字/图片元素列表 | +| callback_url | string | 否 | 任务完成回调地址 | + +**SubtitleSegment(字幕时间线片段):** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| start | float | **是** | 开始时间(秒) | +| end | float | **是** | 结束时间(秒) | +| text | string | **是** | 字幕文本 | + +**SubtitleStyle(字幕样式):** + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| fontSize | int | 否 | 28 | 字号(px) | +| fontColor | string | 否 | `#FFFFFF` | 字体颜色(十六进制) | +| bgColor | string | 否 | `#000000` | 字幕背景色,空=透明 | +| bgOpacity | float | 否 | 0.6 | 背景透明度,0-1 | +| x | string | 否 | `"center"` | 水平位置:`"left"`/`"center"`/`"right"` 或像素值如 `"100"` | +| y | string | 否 | `"bottom"` | 垂直位置:`"top"`/`"center"`/`"bottom"`、像素值如 `"200"`、或 CSS 表达式如 `"calc(100% - 300px)"` | + +**CaptionElement(自定义元素):** + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| type | string | **是** | - | 元素类型:`"text"` 或 `"image"` | +| text | string | type=text时必填 | - | 文字内容 | +| imageUrl | string | type=image时必填 | - | 图片下载 URL | +| x | string | 否 | `"center"` | 水平位置:`"left"`/`"center"`/`"right"` 或像素值 | +| y | string | 否 | `"center"` | 垂直位置:`"top"`/`"center"`/`"bottom"`、像素值、或 `calc()` | +| fontSize | int | 否 | 36 | 字号(px,type=text有效) | +| fontColor | string | 否 | `#FFFFFF` | 字体颜色 | +| bgColor | string | 否 | 透明 | 背景色,如 `"#FFFF00"` | +| bgOpacity | float | 否 | 1.0 | 背景透明度,0-1 | +| width | int | 否 | 原图尺寸 | 图片显示宽度(px,type=image有效) | +| height | int | 否 | 原图尺寸 | 图片显示高度(px,type=image有效) | +| startTime | float | 否 | 0 | 开始显示时间(秒),0=立即显示 | +| duration | float | 否 | 0 | 持续时长(秒),0=一直显示到结束 | +| trackIndex | int | 否 | 1 | 层级(类似 z-index),越大越靠前 | +| animation | string | 否 | 无 | 动画:`"fadeIn"`/`"slideUp"`/`"slideLeft"`/`"scaleIn"` | + +**请求示例:** +```bash +curl --location --request POST 'http://127.0.0.1:3010/video/caption' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "video_urls": [ + "http://example.com/video1.mp4", + "http://example.com/video2.mp4" + ], + "audio_url": "http://example.com/audio.mp3", + "subtitles": [ + {"start": 0.0, "end": 3.0, "text": "第一段字幕"}, + {"start": 3.0, "end": 6.0, "text": "第二段字幕"} + ], + "subtitle_style": { + "fontSize": 36, + "fontColor": "#FFFF00", + "bgColor": "#000000", + "bgOpacity": 0.7, + "x": "center", + "y": "bottom" + }, + "elements": [ + { + "type": "text", + "text": "标题文字", + "x": "center", + "y": "60", + "fontSize": 48, + "fontColor": "#FF0000", + "startTime": 0, + "duration": 0, + "trackIndex": 5, + "animation": "fadeIn" + }, + { + "type": "image", + "imageUrl": "http://example.com/logo.png", + "x": "right", + "y": "top", + "width": 100, + "height": 100, + "startTime": 0, + "duration": 0, + "trackIndex": 5 + } + ], + "callback_url": "https://your-server.com/callback" +}' +``` + +**返回参数:** +| 字段名 | 类型 | 说明 | +|--------|------|------| +| taskId | string | 任务 ID,用于后续查询结果 | + +**返回示例:** +```json +{ + "taskId": "CAPTION_20260602123456_abc123" +} +``` + +--- + +### 5.2 查询字幕任务结果 + +**接口路径**:`GET /video/caption/{taskId}` + +**功能说明**:根据 taskId 查询字幕叠加任务的状态和输出文件。 + +**路径参数:** +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| taskId | string | **是** | 创建任务时返回的 taskId | + +**返回参数:** +| 字段名 | 类型 | 说明 | +|--------|------|------| +| taskId | string | 任务 ID | +| status | string | 任务状态:`"running"`(处理中)/ `"completed"`(完成)/ `"failed"`(失败) | +| fileUrl | string | 完成时返回:输出视频下载 URL | +| fileSize | int64 | 完成时返回:输出文件大小(字节) | +| fileName | string | 完成时返回:输出文件名 | +| durationStr | string | 完成时返回:视频时长(如 "0:45") | +| errorMessage | string | 失败时返回:错误信息 | + +**返回示例(完成时):** +```json +{ + "taskId": "CAPTION_20260602123456_abc123", + "status": "completed", + "fileUrl": "http://127.0.0.1:3010/storage/output.mp4", + "fileSize": 5242880, + "fileName": "output.mp4", + "durationStr": "0:45" +} +``` + +**返回示例(处理中):** +```json +{ + "taskId": "CAPTION_20260602123456_abc123", + "status": "running" +} +``` + +**返回示例(失败):** +```json +{ + "taskId": "CAPTION_20260602123456_abc123", + "status": "failed", + "errorMessage": "视频下载失败" +} +``` 当请求失败时,接口返回 HTTP 错误码和错误信息: diff --git a/CLAUDE.md b/CLAUDE.md index d614d06..41da62b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,58 +29,54 @@ docker run -p 3010:3010 media ## Architecture -这是一个多媒体处理微服务项目,基于 GoFrame 框架开发,提供视频处理、音频提取、语音识别等功能。 +这是一个多媒体处理微服务项目,基于 GoFrame 框架开发,提供视频处理、音频提取、语音识别、字幕叠加等功能。 ### 目录结构 ``` -main.go # 应用入口 +main.go # 应用入口,注册所有 Controller 路由 config.yml # 配置文件 consts/ # 常量定义 - - video/ # 视频相关常量(包括视频分析任务状态) controller/ # HTTP 控制器层(路由入口) - - audio/ # 音频相关接口 - - video/ # 视频相关接口(拼接、剪切、分析) - - common/ # 公共工具 - - scene/ # 场景检测接口 - - image/ # 图片处理接口 + - audio/ # 音频提取接口 + - video/ # 视频相关接口(拼接、剪切、分析、合并、字幕、转码、场景分割) + - common/ # 公共工具(文件上传保存) service/ # 业务逻辑层 - - video/ # 视频服务(拼接、剪切、分析、分析队列) + - video/ # 视频服务(拼接、剪切、分析、合并混音、字幕叠加、转码、场景分割) - audio/ # 音频提取服务 - - asr/ # 语音识别(Whisper) - - scene/ # 场景检测 - - image/ # 图片处理 - - setup/ # 初始化服务 + - asr/ # 语音识别服务 + - scene/ # 场景检测服务 + - image/ # 图片处理服务 + - setup/ # 初始化服务(自动检查/安装依赖) dao/ # 数据访问层 - - audio/ # ASR 任务数据访问 - - video/ # 视频分析任务数据访问 - - image/ model/ # 数据模型 - dto/ # 传输对象(请求/响应) - - video/ # 视频相关 DTO(包括视频分析) - entity/ # 数据库实体 - - video/ # 视频相关实体(包括分析任务) resource/ # 静态资源(日志、临时文件) -sql/ # 数据库 SQL - - video_analysis_task.sql - 视频分析任务表建表SQL +sql/ # 数据库建表 SQL +scripts/ # 外部脚本(scene_detect.py) ``` ### 核心功能 | 功能 | 说明 | 依赖 | |------|------|------| -| 视频拼接 | 支持多个视频拼接,提供 fast(无损 concat demuxer)和 reencode(重编码归一化)两种模式,可上传结果到 MinIO,支持同步和异步任务 | FFmpeg | +| 视频拼接 | 支持多视频拼接,提供 fast(无损 concat demuxer)和 reencode(重编码归一化)两种模式,可上传结果到 MinIO,支持同步和异步任务 | FFmpeg | | 视频分镜剪切 | 根据分镜时间片段列表剪切视频并重新拼接输出,支持同步和异步任务 | FFmpeg | +| 视频拼接+混音 | 拼接多段视频后混入音频(支持多段),以视频时长为准自动补静音或截断,异步任务 | FFmpeg | +| 字幕叠加 | 使用 HyperFrames 将字幕/图片/动画元素渲染叠加到视频上,自动检测分辨率、拼接多视频、消音、降噪,异步任务 | FFmpeg + HyperFrames (npm) | +| 场景分割 | 使用 PySceneDetect 自动检测视频场景切分点,按场景分割为独立片段并上传,异步任务 | FFmpeg + Python3 + PySceneDetect | +| 视频转码 | 将视频转码为 H.264 + AAC + MP4 + faststart(MOOV前置),同步接口 | 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 服务 | +| 语音识别 | 异步语音转文字任务,基于 OpenAI Whisper | FFmpeg + Whisper | +| 视频分析 | 调用外部 Marlin-2B VLM 服务对视频进行理解分析,生成场景描述和事件切分,支持 mock 模式,异步串行处理 | FFmpeg + 外部 VLM 服务 | ### 启动初始化 -- `setup` 包在 `init()` 阶段自动执行,启动时会检查 FFmpeg 和 Whisper 依赖是否可用 -- 自动检测 whisper-cpp > whisper > python -m whisper 三个优先级 -- 如果依赖缺失会输出警告提示安装 +- `setup` 包在 `init()` 阶段自动执行,启动时自动检查并安装缺失依赖 +- 检查项:FFmpeg、Python3、PySceneDetect(scenedetect)、HyperFrames(npm 全局包) +- 每个依赖都按平台自动安装(macOS: brew, Linux: apt/apk/yum, Windows: winget/choco/scoop) +- Docker 容器环境自动检测,跳过 sudo ### API 端点 @@ -96,34 +92,47 @@ sql/ # 数据库 SQL - `POST /video/cut/async` - 视频分镜剪切(URL 输入,异步) - `GET /video/cut/task/{taskId}` - 查询异步剪切任务结果 +**视频拼接+混音:** +- `POST /video/merge/async` - 创建拼接混音异步任务 +- `GET /video/merge/task/{taskId}` - 查询任务结果 + +**字幕叠加:** +- `POST /video/caption` - 创建字幕叠加异步任务 +- `GET /video/caption/{taskId}` - 查询任务结果 + +**场景分割:** +- `POST /video/scene-split` - 创建场景分割异步任务 +- `GET /video/scene-split/task/{taskId}` - 查询任务结果 + +**视频转码:** +- `POST /video/transcode` - 上传视频并转码为 H.264+AAC+MP4(同步) + +**视频分析:** +- `POST /video/analysis` - 创建视频分析异步任务 +- `GET /video/analysis/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 - 多媒体处理 +- OSS/MinIO - 文件存储(通过内部 oss 微服务 `oss/file/uploadFile` 接口上传) +- FFmpeg + ffprobe - 多媒体处理 - Whisper - 语音识别 -- Marlin-2B VLM 服务 - 视频理解大模型(提供字幕生成、事件定位功能) +- HyperFrames (npm) - HTML/GSAP 视频渲染引擎,用于字幕叠加 +- PySceneDetect - Python 场景检测库 +- Marlin-2B VLM 服务 - 视频理解大模型(分析功能使用) ### 内部依赖 -项目依赖内部私有公共包 `gitea.redpowerfuture.com/red-future/common`,包含 HTTP 路由注册、用户信息解析、Consul、Jaeger 等基础设施封装。Docker 构建过程中已配置访问凭证。 +项目依赖内部私有公共包 `gitea.redpowerfuture.com/red-future/common`,包含 HTTP 路由注册(`http.RouteRegister`)、用户信息解析(`beans.User`)、Consul、Jaeger 等基础设施封装。Docker 构建过程中已配置访问凭证。 ### Docker 镜像 @@ -139,25 +148,37 @@ sql/ # 数据库 SQL **分层架构:** - `controller` - HTTP 入口,参数解析,调用 Service,返回响应 - `service` - 业务逻辑实现,每个功能领域一个子包 -- `dao` - 数据访问层,数据库操作 +- `dao` - 数据访问层,数据库 CRUD 操作 - `model` - 数据模型,`dto` 存放请求/响应传输对象,`entity` 存放数据库实体 **设计模式:** - 使用 GoFrame 框架的依赖注入模式 -- 所有 Service 和 Controller 都使用**单例模式**(`var Xxx = new(XxxStruct)`) -- 遵循标准的 Go 命名约定 -- 临时文件处理完需要**及时清理**(使用 `defer os.Remove()`) +- 所有 Service 和 Controller 都使用**单例模式**(`var Xxx = new(xxxStruct)`) +- 路由注册在 `main.go` 中通过 `http.RouteRegister` 集中管理 +- 临时文件处理完需要**及时清理**(使用 `defer os.Remove()` 或 `defer os.RemoveAll()`) **异步任务处理:** -- 长时任务(视频拼接、视频剪切、语音识别)都支持**异步执行** +- 长时任务(拼接、剪切、合并混音、字幕叠加、场景分割、语音识别、视频分析)都支持**异步执行** - 同步模式直接等待结果返回,异步模式创建任务后立即返回任务 ID - 异步任务状态持久化到数据库,可通过任务 ID 查询进度和结果 -- 支持回调 URL,任务完成后会回调通知调用方 -- 任务执行使用 goroutine 异步处理 +- 支持回调 URL,任务完成后 POST 回调通知调用方,携带 `X-User-Info` 头透传用户信息 +- 任务执行使用 goroutine 异步处理,通过 recover 捕获 panic 并更新为失败状态 +- 视频拼接+混音使用信号量控制并发(通过 `merge.concurrency` 配置) **用户身份:** - 所有接口优先从请求头 `Authorization` / `X-User-Info` 解析用户信息 - 解析失败使用默认 `admin` / tenantId=1 用于开发和调试 +- 异步任务通过 `context.WithValue` 将用户信息传递给 goroutine + +**Service 层共享工具函数(`service/video` 包内):** +- `getUserFromCtx(ctx)` - 从 context 提取用户信息 +- `downloadFile(ctx, url, dir)` - 下载远程文件到本地 +- `uploadToMinIO(ctx, localPath)` - 通过 OSS 微服务上传文件到 MinIO +- `lookupFFmpegPath()` - 查找 FFmpeg 可执行文件路径(优先配置路径,回退系统 PATH) +- `getVideoResolution(ctx, path)` - 用 ffprobe 获取视频分辨率 +- `getVideoRealDuration(ctx, path)` - 用 ffprobe 获取视频时长(float64秒) +- `getVideoDurationStr(ctx, path)` - 获取视频时长可读字符串(m:ss格式) +- `cleanupFiles(paths)` - 批量清理临时文件 ### 配置 @@ -175,16 +196,19 @@ sql/ # 数据库 SQL - `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) +- `analysis.video_dir` - 视频永久存储目录(按 taskId 子目录组织) +- `analysis.caption_url` - Caption 接口地址 +- `analysis.caption_timeout` - 单次调用超时(默认 `30m`) +- `analysis.max_new_tokens` - 传递给 Caption 接口的参数 +- `analysis.mock_caption` - 是否启用 mock 模式(true: 返回模拟数据,false: 真实调用) + +**视频拼接+混音:** +- `merge.concurrency` - 并发数控制(默认 1,串行处理) + +**HyperFrames 字幕叠加:** +- `hyperframes.render_timeout` - 渲染超时时间(分钟,默认 30) +- `hyperframes.headless` - 是否启用 headless 模式(默认 true) **外部服务:** - `database` - PostgreSQL 数据库配置 diff --git a/config.yml b/config.yml index 080b911..a718ca6 100644 --- a/config.yml +++ b/config.yml @@ -73,20 +73,17 @@ analysis: mock_caption: true # OSS/MinIO 文件上传配置 -filePrefix: "http://116.204.74.41:9000" +filePrefix: "http://cdn.redpowerfuture.com" + +# 视频拼接+混音配置 +merge: + # 并发数,控制同时处理的拼接混音任务数量(默认 1,串行) + concurrency: 1 + +# HyperFrames 字幕叠加配置 +hyperframes: + # HyperFrames 渲染超时时间(分钟),复杂项目可能需要更长时间 + render_timeout: 30 + # 是否启用 headless 模式 + headless: true -# Whisper 语音识别配置 -whisper: - # whisper 可执行文件路径,留空则自动查找 - # 优先检测: whisper-cpp(推荐) > whisper > python -m whisper - # 安装 whisper.cpp: brew install whisper-cpp(速度比 Python 快 3-5 倍) - path: "" - # 默认模型: tiny(75MB/最快) / base(150MB) / small(500MB) / medium(1.5GB) - # CPU 环境建议用 tiny,MacBook Air 用 base 即可 - model: "medium" - # 默认语言(zh=中文, en=英文, ja=日文 等) - language: "zh" - # 模型缓存目录,留空使用默认 (~/.cache/whisper/) - model_dir: "" - # CPU 线程数(限制资源占用,建议 2-4) - threads: 2 diff --git a/controller/video/caption_controller.go b/controller/video/caption_controller.go new file mode 100644 index 0000000..e126a6e --- /dev/null +++ b/controller/video/caption_controller.go @@ -0,0 +1,39 @@ +package video + +import ( + "context" + "fmt" + + dto "media/model/dto/video" + service "media/service/video" + + "github.com/gogf/gf/v2/frame/g" +) + +type caption struct{} + +var Caption = new(caption) + +// CreateCaption 创建字幕叠加任务 POST /video/caption +func (c *caption) CreateCaption(ctx context.Context, req *dto.CreateCaptionTaskReq) (res *dto.CreateCaptionTaskRes, err error) { + ctx = withUser(ctx) + g.Log().Infof(ctx, "[字幕叠加] 收到请求 入参: video_count=%d, element_count=%d, callback=%s", + len(req.VideoURLs), len(req.Elements), req.CallbackURL) + + if len(req.VideoURLs) < 1 { + return nil, fmt.Errorf("至少需要1个视频") + } + + taskID, taskErr := service.Caption.CreateAsyncTask(ctx, req.VideoURLs, req.AudioURL, req.Subtitles, req.SubtitleStyle, req.Elements, req.CallbackURL) + if taskErr != nil { + return nil, taskErr + } + + return &dto.CreateCaptionTaskRes{TaskID: taskID}, nil +} + +// GetCaptionTask 查询字幕任务结果 GET /video/caption/{taskId} +func (c *caption) GetCaptionTask(ctx context.Context, req *dto.GetCaptionTaskReq) (res *dto.GetCaptionTaskRes, err error) { + ctx = withUser(ctx) + return service.Caption.GetTaskResult(ctx, req.TaskID) +} diff --git a/controller/video/merge_controller.go b/controller/video/merge_controller.go new file mode 100644 index 0000000..cf0d9a7 --- /dev/null +++ b/controller/video/merge_controller.go @@ -0,0 +1,42 @@ +package video + +import ( + "context" + "fmt" + + dto "media/model/dto/video" + service "media/service/video" + + "github.com/gogf/gf/v2/frame/g" +) + +type merge struct{} + +var Merge = new(merge) + +// MergeAsync 视频拼接+混音(异步) POST /video/merge/async +func (c *merge) MergeAsync(ctx context.Context, req *dto.VideoAudioMergeAsyncReq) (res *dto.CreateMergeTaskRes, err error) { + ctx = withUser(ctx) + g.Log().Infof(ctx, "[视频拼接+混音-异步] 收到请求 入参: video_count=%d, audio_count=%d, upload=%v, callback=%s", + len(req.VideoURLs), len(req.AudioURLs), req.Upload, req.CallbackURL) + + if len(req.VideoURLs) < 1 { + return nil, fmt.Errorf("至少需要1个视频") + } + if len(req.AudioURLs) < 1 { + return nil, fmt.Errorf("至少需要1个音频") + } + + taskID, taskErr := service.Merge.CreateAsyncTask(ctx, req.VideoURLs, req.AudioURLs, req.CallbackURL, req.Upload) + if taskErr != nil { + return nil, taskErr + } + + return &dto.CreateMergeTaskRes{TaskID: taskID}, nil +} + +// GetMergeTask 查询异步拼接+混音任务结果 GET /video/merge/task/{taskId} +func (c *merge) GetMergeTask(ctx context.Context, req *dto.GetMergeTaskReq) (res *dto.GetMergeTaskRes, err error) { + ctx = withUser(ctx) + return service.Merge.GetTaskResult(ctx, req.TaskID) +} diff --git a/controller/video/scene_split_controller.go b/controller/video/scene_split_controller.go new file mode 100644 index 0000000..ea5d6c8 --- /dev/null +++ b/controller/video/scene_split_controller.go @@ -0,0 +1,39 @@ +package video + +import ( + "context" + "fmt" + + dto "media/model/dto/video" + service "media/service/video" + + "github.com/gogf/gf/v2/frame/g" +) + +type sceneSplit struct{} + +var SceneSplit = new(sceneSplit) + +// CreateSceneSplit 创建场景分割任务 POST /video/scene-split +func (c *sceneSplit) CreateSceneSplit(ctx context.Context, req *dto.SceneSplitReq) (res *dto.CreateSceneSplitTaskRes, err error) { + ctx = withUser(ctx) + g.Log().Infof(ctx, "[场景分割] 收到请求 入参: videoUrl=%s, threshold=%.1f, callback=%s", + req.VideoURL, req.Threshold, req.CallbackURL) + + if req.VideoURL == "" { + return nil, fmt.Errorf("视频URL不能为空") + } + + taskID, taskErr := service.SceneSplit.CreateAsyncTask(ctx, req.VideoURL, req.Threshold, req.CallbackURL) + if taskErr != nil { + return nil, taskErr + } + + return &dto.CreateSceneSplitTaskRes{TaskID: taskID}, nil +} + +// GetSceneSplitTask 查询场景分割任务结果 GET /video/scene-split/task/{taskId} +func (c *sceneSplit) GetSceneSplitTask(ctx context.Context, req *dto.GetSceneSplitTaskReq) (res *dto.GetSceneSplitTaskRes, err error) { + ctx = withUser(ctx) + return service.SceneSplit.GetTaskResult(ctx, req.TaskID) +} diff --git a/controller/video/transcode_controller.go b/controller/video/transcode_controller.go new file mode 100644 index 0000000..dbf3c30 --- /dev/null +++ b/controller/video/transcode_controller.go @@ -0,0 +1,42 @@ +package video + +import ( + "context" + "fmt" + + dto "media/model/dto/video" + service "media/service/video" + + commonController "media/controller/common" + + "github.com/gogf/gf/v2/frame/g" +) + +type transcode struct{} + +var Transcode = new(transcode) + +// Transcode 上传视频并转码为 H.264+AAC+MP4(faststart) POST /video/transcode +func (c *transcode) Transcode(ctx context.Context, req *struct { + g.Meta `path:"/video/transcode" method:"post" tags:"视频转码" summary:"上传视频转码为H.264+AAC+MP4(faststart)" dc:"上传视频文件,转码为标准MP4格式"` + OutputDir string `json:"outputDir" dc:"输出目录(可选),不传则输出到resource/temp下"` +}) (res *dto.TranscodeRes, err error) { + ctx = withUser(ctx) + + // 保存上传文件 + filePaths, saveErr := commonController.SaveUploadedFilesFromCtx(ctx) + if saveErr != nil || len(filePaths) == 0 { + return nil, fmt.Errorf("上传文件失败: %v", saveErr) + } + inputPath := filePaths[0] + g.Log().Infof(ctx, "[转码] 上传文件保存到: %s", inputPath) + + // 调用转码服务,outputDir 为空则 service 用默认值 + res, err = service.Transcode.TranscodeToMP4(ctx, inputPath, req.OutputDir) + if err != nil { + return nil, fmt.Errorf("转码失败: %v", err) + } + + g.Log().Infof(ctx, "[转码] 完成: %s", res.OutputPath) + return res, nil +} diff --git a/dao/video/scene_split_task_dao.go b/dao/video/scene_split_task_dao.go new file mode 100644 index 0000000..76d9f44 --- /dev/null +++ b/dao/video/scene_split_task_dao.go @@ -0,0 +1,159 @@ +package video + +import ( + "context" + "encoding/json" + "sort" + "strconv" + "strings" + "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 SceneSplitTask = new(sceneSplitTaskDao) + +type sceneSplitTaskDao struct{} + +const sceneSplitTaskTable = "scene_split_task" + +// Insert 创建任务(排除 id 字段,让数据库自增) +func (d *sceneSplitTaskDao) Insert(ctx context.Context, data *entity.SceneSplitTask) (id int64, err error) { + r, err := gfdb.DB(ctx).Model(ctx, sceneSplitTaskTable). + Data(data). + FieldsEx(entity.SceneSplitTaskCols.Id). + Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +// GetByTaskID 根据taskId查询任务 +func (d *sceneSplitTaskDao) GetByTaskID(ctx context.Context, taskID string) (res *entity.SceneSplitTask, err error) { + r, err := gfdb.DB(ctx).Model(ctx, sceneSplitTaskTable). + Where(entity.SceneSplitTaskCols.TaskID, taskID). + One() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + err = r.Struct(&res) + return +} + +// UpdateRunning 更新为运行中 +func (d *sceneSplitTaskDao) UpdateRunning(ctx context.Context, taskID string) error { + _, err := gfdb.DB(ctx).Model(ctx, sceneSplitTaskTable). + Data(g.Map{ + entity.SceneSplitTaskCols.Status: "running", + }). + Where(entity.SceneSplitTaskCols.TaskID, taskID). + Update() + return err +} + +// UpdateSuccess 更新为成功 +func (d *sceneSplitTaskDao) UpdateSuccess(ctx context.Context, taskID string, segmentURLs, audioURL string, sceneCount int, audioDuration float64, videoDuration float64) error { + _, err := gfdb.DB(ctx).Model(ctx, sceneSplitTaskTable). + Data(g.Map{ + entity.SceneSplitTaskCols.Status: "success", + entity.SceneSplitTaskCols.SegmentURLs: segmentURLs, + entity.SceneSplitTaskCols.AudioURL: audioURL, + entity.SceneSplitTaskCols.SceneCount: sceneCount, + entity.SceneSplitTaskCols.AudioDuration: audioDuration, + entity.SceneSplitTaskCols.VideoDuration: videoDuration, + entity.SceneSplitTaskCols.ErrorMessage: "", + }). + Where(entity.SceneSplitTaskCols.TaskID, taskID). + Update() + return err +} + +// UpdateError 更新为失败 +func (d *sceneSplitTaskDao) UpdateError(ctx context.Context, taskID string, errMsg string) error { + _, err := gfdb.DB(ctx).Model(ctx, sceneSplitTaskTable). + Data(g.Map{ + entity.SceneSplitTaskCols.Status: "failed", + entity.SceneSplitTaskCols.ErrorMessage: errMsg, + }). + Where(entity.SceneSplitTaskCols.TaskID, taskID). + Update() + return err +} + +// EntityToSceneSplitTaskRes 实体转DTO +func EntityToSceneSplitTaskRes(e *entity.SceneSplitTask) *dto.GetSceneSplitTaskRes { + res := &dto.GetSceneSplitTaskRes{ + TaskID: e.TaskID, + Status: e.Status, + SceneCount: e.SceneCount, + AudioDuration: e.AudioDuration, + VideoDuration: e.VideoDuration, + } + if e.CreatedAt != nil { + res.CreatedAt = gconv.Int64(e.CreatedAt.Timestamp()) + } else { + res.CreatedAt = time.Now().UnixMilli() + } + if e.Status == "success" { + res.AudioURL = e.AudioURL + res.Segments = ParseSegmentEntries(e.SegmentURLs) + res.SceneCount = e.SceneCount + res.AudioDuration = e.AudioDuration + res.VideoDuration = e.VideoDuration + } + if e.Status == "failed" { + res.ErrorMessage = e.ErrorMessage + } + return res +} + +// ParseSegmentEntries 将 JSON 数组解析为按时间线排序的 SegmentEntry 切片 +// JSON 格式: [{"timeline":"0.0-7.2","url":"..."}, ...] +func ParseSegmentEntries(jsonStr string) []dto.SegmentEntry { + if jsonStr == "" { + return nil + } + + // 先尝试解析为数组格式 + var entries []dto.SegmentEntry + if err := json.Unmarshal([]byte(jsonStr), &entries); err == nil && len(entries) > 0 { + // 按起始时间排序确保有序 + sort.Slice(entries, func(i, j int) bool { + return extractStartTime(entries[i].Timeline) < extractStartTime(entries[j].Timeline) + }) + return entries + } + + // 兼容旧的 map 格式 {"0.0-7.2":"url", ...} + var m map[string]string + if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { + return nil + } + entries = make([]dto.SegmentEntry, 0, len(m)) + for timeline, url := range m { + entries = append(entries, dto.SegmentEntry{Timeline: timeline, URL: url}) + } + sort.Slice(entries, func(i, j int) bool { + return extractStartTime(entries[i].Timeline) < extractStartTime(entries[j].Timeline) + }) + return entries +} + +// extractStartTime 从时间线 key(如 "0.0-7.2")提取起始秒数用于排序 +func extractStartTime(timeline string) float64 { + idx := strings.Index(timeline, "-") + if idx < 0 { + return 0 + } + v, _ := strconv.ParseFloat(timeline[:idx], 64) + return v +} diff --git a/dao/video/video_audio_merge_task_dao.go b/dao/video/video_audio_merge_task_dao.go new file mode 100644 index 0000000..650852e --- /dev/null +++ b/dao/video/video_audio_merge_task_dao.go @@ -0,0 +1,112 @@ +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 MergeTask = new(mergeTaskDao) + +type mergeTaskDao struct{} + +const mergeTaskTable = "video_audio_merge_task" + +// Insert 创建任务(排除 id 字段,让数据库自增) +func (d *mergeTaskDao) Insert(ctx context.Context, data *entity.VideoAudioMergeTask) (id int64, err error) { + r, err := gfdb.DB(ctx).Model(ctx, mergeTaskTable). + Data(data). + FieldsEx(entity.VideoAudioMergeTaskCols.Id). + Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +// GetByTaskID 根据taskId查询任务 +func (d *mergeTaskDao) GetByTaskID(ctx context.Context, taskID string) (res *entity.VideoAudioMergeTask, err error) { + r, err := gfdb.DB(ctx).Model(ctx, mergeTaskTable). + Where(entity.VideoAudioMergeTaskCols.TaskID, taskID). + One() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + err = r.Struct(&res) + return +} + +// UpdateRunning 更新为运行中 +func (d *mergeTaskDao) UpdateRunning(ctx context.Context, taskID string) error { + _, err := gfdb.DB(ctx).Model(ctx, mergeTaskTable). + Data(g.Map{ + entity.VideoAudioMergeTaskCols.Status: "running", + }). + Where(entity.VideoAudioMergeTaskCols.TaskID, taskID). + Update() + return err +} + +// UpdateSuccess 更新为成功 +func (d *mergeTaskDao) UpdateSuccess(ctx context.Context, taskID string, fileURL string, fileSize int64, fileName, fileFormat, fileAddrPrefix, durationStr string) error { + _, err := gfdb.DB(ctx).Model(ctx, mergeTaskTable). + Data(g.Map{ + entity.VideoAudioMergeTaskCols.Status: "success", + entity.VideoAudioMergeTaskCols.FileURL: fileURL, + entity.VideoAudioMergeTaskCols.FileSize: fileSize, + entity.VideoAudioMergeTaskCols.FileName: fileName, + entity.VideoAudioMergeTaskCols.FileFormat: fileFormat, + entity.VideoAudioMergeTaskCols.FileAddressPrefix: fileAddrPrefix, + entity.VideoAudioMergeTaskCols.DurationStr: durationStr, + entity.VideoAudioMergeTaskCols.ErrorMessage: "", + }). + Where(entity.VideoAudioMergeTaskCols.TaskID, taskID). + Update() + return err +} + +// UpdateError 更新为失败 +func (d *mergeTaskDao) UpdateError(ctx context.Context, taskID string, errMsg string) error { + _, err := gfdb.DB(ctx).Model(ctx, mergeTaskTable). + Data(g.Map{ + entity.VideoAudioMergeTaskCols.Status: "failed", + entity.VideoAudioMergeTaskCols.ErrorMessage: errMsg, + }). + Where(entity.VideoAudioMergeTaskCols.TaskID, taskID). + Update() + return err +} + +// EntityToTaskRes 实体转DTO +func EntityToMergeTaskRes(e *entity.VideoAudioMergeTask) *dto.GetMergeTaskRes { + res := &dto.GetMergeTaskRes{ + TaskID: e.TaskID, + Status: e.Status, + } + if e.CreatedAt != nil { + res.CreatedAt = gconv.Int64(e.CreatedAt.Timestamp()) + } else { + 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 +} diff --git a/dao/video/video_caption_task_dao.go b/dao/video/video_caption_task_dao.go new file mode 100644 index 0000000..64af6c8 --- /dev/null +++ b/dao/video/video_caption_task_dao.go @@ -0,0 +1,98 @@ +package video + +import ( + "context" + + 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" +) + +// CaptionTask 字幕叠加任务 DAO 单例 +var CaptionTask = new(captionTaskDao) + +type captionTaskDao struct{} + +const captionTaskTable = "video_caption_task" + +// Insert 插入字幕任务记录 +func (d *captionTaskDao) Insert(ctx context.Context, data *entity.VideoCaptionTask) (id int64, err error) { + r, err := gfdb.DB(ctx).Model(ctx, captionTaskTable).Data(data).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +// GetByTaskID 按 taskId 查询 +func (d *captionTaskDao) GetByTaskID(ctx context.Context, taskID string) (res *entity.VideoCaptionTask, err error) { + r, err := gfdb.DB(ctx).Model(ctx, captionTaskTable). + Where(entity.VideoCaptionTaskCols.TaskID, taskID).One() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + err = r.Struct(&res) + return +} + +// UpdateRunning 更新任务为运行中 +func (d *captionTaskDao) UpdateRunning(ctx context.Context, taskID string) error { + _, err := gfdb.DB(ctx).Model(ctx, captionTaskTable). + Data(g.Map{entity.VideoCaptionTaskCols.Status: "running"}). + Where(entity.VideoCaptionTaskCols.TaskID, taskID).Update() + return err +} + +// UpdateResolution 更新视频分辨率 +func (d *captionTaskDao) UpdateResolution(ctx context.Context, taskID string, width, height int) error { + _, err := gfdb.DB(ctx).Model(ctx, captionTaskTable). + Data(g.Map{ + entity.VideoCaptionTaskCols.Width: width, + entity.VideoCaptionTaskCols.Height: height, + }). + Where(entity.VideoCaptionTaskCols.TaskID, taskID).Update() + return err +} + +// UpdateSuccess 更新任务为成功 +func (d *captionTaskDao) UpdateSuccess(ctx context.Context, taskID, fileURL string, fileSize int64, fileName, durationStr string) error { + _, err := gfdb.DB(ctx).Model(ctx, captionTaskTable). + Data(g.Map{ + entity.VideoCaptionTaskCols.Status: "success", + entity.VideoCaptionTaskCols.FileURL: fileURL, + entity.VideoCaptionTaskCols.FileSize: fileSize, + entity.VideoCaptionTaskCols.FileName: fileName, + entity.VideoCaptionTaskCols.DurationStr: durationStr, + }). + Where(entity.VideoCaptionTaskCols.TaskID, taskID).Update() + return err +} + +// UpdateError 更新任务为失败 +func (d *captionTaskDao) UpdateError(ctx context.Context, taskID, errMsg string) error { + _, err := gfdb.DB(ctx).Model(ctx, captionTaskTable). + Data(g.Map{ + entity.VideoCaptionTaskCols.Status: "failed", + entity.VideoCaptionTaskCols.ErrorMessage: errMsg, + }). + Where(entity.VideoCaptionTaskCols.TaskID, taskID).Update() + return err +} + +// EntityToCaptionTaskRes 实体转查询响应 +func EntityToCaptionTaskRes(e *entity.VideoCaptionTask) *dto.GetCaptionTaskRes { + return &dto.GetCaptionTaskRes{ + TaskID: e.TaskID, + Status: e.Status, + FileURL: e.FileURL, + FileSize: e.FileSize, + FileName: e.FileName, + DurationStr: e.DurationStr, + ErrorMessage: e.ErrorMessage, + } +} diff --git a/main.go b/main.go index 12c28fe..a687fc0 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,9 @@ import ( _ "gitea.redpowerfuture.com/red-future/common/consul" "gitea.redpowerfuture.com/red-future/common/http" "gitea.redpowerfuture.com/red-future/common/jaeger" + + _ "media/service/setup" + _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" ) @@ -20,6 +23,10 @@ func main() { controllerVideo.Concat, controllerVideo.Cut, controllerVideo.Analysis, + controllerVideo.Merge, + controllerVideo.Caption, + controllerVideo.Transcode, + controllerVideo.SceneSplit, }) select {} } diff --git a/model/dto/video/scene_split_dto.go b/model/dto/video/scene_split_dto.go new file mode 100644 index 0000000..0ea35fc --- /dev/null +++ b/model/dto/video/scene_split_dto.go @@ -0,0 +1,43 @@ +package video + +import "github.com/gogf/gf/v2/frame/g" + +// ---------- 场景检测+视频分割(异步)---------- + +// SceneSplitReq 场景检测+视频分割请求 +type SceneSplitReq struct { + g.Meta `path:"/scene-split" method:"post" tags:"场景分割" summary:"场景检测并分割视频(异步)" dc:"下载视频,基于PySceneDetect检测场景变化并分割成多个视频片段,同时提取音频,全部上传到MinIO后回调通知"` + VideoURL string `json:"videoUrl" v:"required#视频URL不能为空" dc:"视频URL地址"` + Threshold float64 `json:"threshold" dc:"场景检测阈值(默认27),越小越敏感、场景切割越多" d:"27.0"` + CallbackURL string `json:"callbackUrl" dc:"回调地址,处理完成后POST结果到该地址(可选)"` +} + +// CreateSceneSplitTaskRes 创建场景分割任务响应 +type CreateSceneSplitTaskRes struct { + TaskID string `json:"taskId" dc:"任务ID"` +} + +// GetSceneSplitTaskReq 查询场景分割任务请求 +type GetSceneSplitTaskReq struct { + g.Meta `path:"/scene-split/task/{taskId}" method:"get" tags:"场景分割" summary:"查询场景分割任务结果" dc:"根据taskId查询异步场景分割任务结果"` + TaskID string `json:"taskId" dc:"任务ID"` +} + +// SegmentEntry 单个分片信息(按时间线排列) +type SegmentEntry struct { + Timeline string `json:"timeline" dc:"时间线标识,如 0.0-7.2"` + URL string `json:"url" dc:"分片MinIO地址"` +} + +// GetSceneSplitTaskRes 查询场景分割任务响应 +type GetSceneSplitTaskRes struct { + TaskID string `json:"taskId" dc:"任务ID"` + Status string `json:"status" dc:"状态: pending/running/success/failed"` + AudioURL string `json:"audioUrl,omitempty" dc:"提取的音频MinIO地址"` + Segments []SegmentEntry `json:"segments,omitempty" dc:"按时间线排列的视频分片列表"` + SceneCount int `json:"sceneCount" dc:"检测到的场景数/分片数"` + AudioDuration float64 `json:"audioDuration,omitempty" dc:"音频时长(秒)"` + VideoDuration float64 `json:"videoDuration,omitempty" dc:"视频总时长(秒)"` + ErrorMessage string `json:"errorMessage,omitempty" dc:"错误信息"` + CreatedAt int64 `json:"createdAt" dc:"创建时间戳"` +} diff --git a/model/dto/video/transcode_dto.go b/model/dto/video/transcode_dto.go new file mode 100644 index 0000000..cac820d --- /dev/null +++ b/model/dto/video/transcode_dto.go @@ -0,0 +1,9 @@ +package video + +// TranscodeRes 视频转码响应 +type TranscodeRes struct { + OutputPath string `json:"outputPath" dc:"输出文件完整路径"` + FileSize int64 `json:"fileSize" dc:"文件大小(字节)"` + FileName string `json:"fileName" dc:"输出文件名"` + DurationStr string `json:"durationStr" dc:"视频时长"` +} diff --git a/model/dto/video/video_audio_merge_dto.go b/model/dto/video/video_audio_merge_dto.go new file mode 100644 index 0000000..ebb2837 --- /dev/null +++ b/model/dto/video/video_audio_merge_dto.go @@ -0,0 +1,39 @@ +package video + +import "github.com/gogf/gf/v2/frame/g" + +// ---------- 视频拼接+混音(异步) ---------- + +// VideoAudioMergeAsyncReq 视频拼接+混音异步请求 +type VideoAudioMergeAsyncReq struct { + g.Meta `path:"/merge/async" method:"post" tags:"视频拼接+混音" summary:"视频拼接并混音(异步)" dc:"将多个视频拼接后混入多个音频(按顺序拼接),立即返回taskId,完成后通过callback_url通知结果"` + VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"视频URL列表(按此顺序拼接)"` + AudioURLs []string `json:"audio_urls" v:"required#音频URL列表不能为空" dc:"音频URL列表(按此顺序拼接)"` + Upload bool `json:"upload" dc:"是否上传到MinIO" d:"false"` + CallbackURL string `json:"callback_url" v:"required#回调地址不能为空" dc:"回调地址,处理完成后POST结果到该地址"` +} + +// CreateMergeTaskRes 创建异步拼接+混音任务响应 +type CreateMergeTaskRes struct { + TaskID string `json:"taskId" dc:"任务ID"` +} + +// GetMergeTaskReq 查询异步拼接+混音任务请求 +type GetMergeTaskReq struct { + g.Meta `path:"/merge/task/{taskId}" method:"get" tags:"视频拼接+混音" summary:"查询拼接混音任务结果" dc:"根据taskId查询异步拼接+混音任务的结果"` + TaskID string `json:"taskId" dc:"任务ID"` +} + +// GetMergeTaskRes 查询异步拼接+混音任务响应 +type GetMergeTaskRes 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:"创建时间戳"` +} diff --git a/model/dto/video/video_caption_dto.go b/model/dto/video/video_caption_dto.go new file mode 100644 index 0000000..d2e1dc2 --- /dev/null +++ b/model/dto/video/video_caption_dto.go @@ -0,0 +1,78 @@ +package video + +import "github.com/gogf/gf/v2/frame/g" + +// ---------- 字幕元素定义 ---------- + +// CaptionElement 单个字幕/图片元素 +type CaptionElement struct { + Type string `json:"type" v:"required#元素类型必填" dc:"元素类型:text/image"` + Text string `json:"text" dc:"文字内容(type=text时必填)"` + ImageURL string `json:"imageUrl" dc:"图片URL(type=image时必填)"` + StartTime float64 `json:"startTime" d:"0" dc:"开始时间(秒,默认0)"` + Duration float64 `json:"duration" d:"0" dc:"持续时长(秒,0或空=一直显示到视频结束)"` + X string `json:"x" d:"center" dc:"水平位置:left/center/right或像素值"` + Y string `json:"y" d:"center" dc:"垂直位置:top/center/bottom或像素值"` + FontSize int `json:"fontSize" d:"36" dc:"字号(type=text有效)"` + FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色"` + BgColor string `json:"bgColor" dc:"背景色,如#FFFF00,空=透明"` + BgOpacity float64 `json:"bgOpacity" d:"1.0" dc:"背景透明度0-1"` + Width int `json:"width" dc:"元素宽度(type=image有效)"` + Height int `json:"height" dc:"元素高度(type=image有效)"` + Animation string `json:"animation" dc:"动画效果:fadeIn/slideUp/slideLeft/scaleIn"` + TrackIndex int `json:"trackIndex" d:"1" dc:"轨道层级(数字越大越靠前)"` +} + +// SubtitleSegment 外部传入的字幕时间线片段 +type SubtitleSegment struct { + Start float64 `json:"start" v:"required#开始时间必填" dc:"开始时间(秒)"` + End float64 `json:"end" v:"required#结束时间必填" dc:"结束时间(秒)"` + Text string `json:"text" v:"required#字幕文本必填" dc:"字幕文本内容"` +} + +// SubtitleStyle 字幕样式配置 +type SubtitleStyle struct { + FontSize int `json:"fontSize" d:"28" dc:"字号(默认28)"` + FontColor string `json:"fontColor" d:"#FFFFFF" dc:"字体颜色(默认白色)"` + BgColor *string `json:"bgColor" dc:"背景色,空字符串=透明(默认#000000)。传空字符串 '' 可去掉背景色"` + BgOpacity *float64 `json:"bgOpacity" dc:"背景透明度0-1(默认0.6)。传0可使背景完全透明"` + X string `json:"x" d:"center" dc:"水平位置:left/center/right或像素值(默认center)"` + Y string `json:"y" d:"bottom" dc:"垂直位置:top/center/bottom或像素值(默认bottom)"` +} + +// ---------- 创建字幕任务 ---------- + +// CreateCaptionTaskReq 创建字幕叠加任务请求 +type CreateCaptionTaskReq struct { + g.Meta `path:"/" method:"post" tags:"字幕叠加" summary:"创建字幕叠加任务(异步)" dc:"创建使用HyperFrames渲染的视频字幕叠加任务,返回taskId"` + VideoURLs []string `json:"video_urls" v:"required#视频URL列表不能为空" dc:"背景视频URL列表"` + AudioURL string `json:"audio_url" dc:"背景音频URL(可选)"` + Subtitles []SubtitleSegment `json:"subtitles" dc:"字幕时间线列表(可选),每段包含起始时间和文本"` + SubtitleStyle *SubtitleStyle `json:"subtitle_style" dc:"字幕样式配置(可选),不传则使用默认值"` + Elements []CaptionElement `json:"elements" dc:"字幕/图片元素列表(可选)"` + CallbackURL string `json:"callback_url" dc:"任务完成后的回调地址(可选)"` +} + +// CreateCaptionTaskRes 创建字幕任务响应 +type CreateCaptionTaskRes struct { + TaskID string `json:"taskId" dc:"任务ID"` +} + +// ---------- 查询字幕任务 ---------- + +// GetCaptionTaskReq 查询字幕任务请求 +type GetCaptionTaskReq struct { + g.Meta `path:"/{taskId}" method:"get" tags:"字幕叠加" summary:"查询字幕任务结果" dc:"根据taskId查询字幕任务详情"` + TaskID string `json:"taskId" dc:"任务ID"` +} + +// GetCaptionTaskRes 查询字幕任务响应 +type GetCaptionTaskRes struct { + TaskID string `json:"taskId" dc:"任务ID"` + Status string `json:"status" dc:"任务状态"` + FileURL string `json:"fileUrl,omitempty" dc:"输出文件URL"` + FileSize int64 `json:"fileSize,omitempty" dc:"输出文件大小"` + FileName string `json:"fileName,omitempty" dc:"输出文件名"` + DurationStr string `json:"durationStr,omitempty" dc:"视频时长"` + ErrorMessage string `json:"errorMessage,omitempty" dc:"错误信息"` +} diff --git a/model/entity/video/scene_split_task.go b/model/entity/video/scene_split_task.go new file mode 100644 index 0000000..f38795e --- /dev/null +++ b/model/entity/video/scene_split_task.go @@ -0,0 +1,48 @@ +package video + +import "gitea.redpowerfuture.com/red-future/common/beans" + +// SceneSplitTask 场景检测+视频分割异步任务实体 +type SceneSplitTask struct { + beans.SQLBaseDO `orm:",inherit"` + TaskID string `orm:"task_id" json:"taskId" description:"任务唯一标识"` + VideoURL string `orm:"video_url" json:"videoUrl" description:"原始视频URL"` + Status string `orm:"status" json:"status" description:"任务状态:pending/running/success/failed"` + SegmentURLs string `orm:"segment_urls" json:"segmentURLs" description:"分片列表JSON数组,如 [{\"timeline\":\"0.0-5.2\",\"url\":\"...\"}]"` + AudioURL string `orm:"audio_url" json:"audioUrl" description:"提取的音频URL"` + SceneCount int `orm:"scene_count" json:"sceneCount" description:"场景数/分片数"` + AudioDuration float64 `orm:"audio_duration" json:"audioDuration" description:"音频时长(秒)"` + VideoDuration float64 `orm:"video_duration" json:"videoDuration" description:"视频总时长(秒)"` + ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"` + CallbackURL string `orm:"callback_url" json:"callbackUrl" description:"回调地址"` +} + +// SceneSplitTaskCol 字段定义 +type SceneSplitTaskCol struct { + beans.SQLBaseCol + TaskID string + VideoURL string + Status string + SegmentURLs string + AudioURL string + SceneCount string + AudioDuration string + VideoDuration string + ErrorMessage string + CallbackURL string +} + +// SceneSplitTaskCols 字段常量 +var SceneSplitTaskCols = SceneSplitTaskCol{ + SQLBaseCol: beans.DefSQLBaseCol, + TaskID: "task_id", + VideoURL: "video_url", + Status: "status", + SegmentURLs: "segment_urls", + AudioURL: "audio_url", + SceneCount: "scene_count", + AudioDuration: "audio_duration", + VideoDuration: "video_duration", + ErrorMessage: "error_message", + CallbackURL: "callback_url", +} diff --git a/model/entity/video/video_audio_merge_task.go b/model/entity/video/video_audio_merge_task.go new file mode 100644 index 0000000..3af744c --- /dev/null +++ b/model/entity/video/video_audio_merge_task.go @@ -0,0 +1,54 @@ +package video + +import "gitea.redpowerfuture.com/red-future/common/beans" + +// VideoAudioMergeTask 视频拼接+混音异步任务实体 +type VideoAudioMergeTask struct { + beans.SQLBaseDO `orm:",inherit"` + TaskID string `orm:"task_id" json:"taskId" description:"任务唯一标识"` + VideoURLs string `orm:"video_urls" json:"videoUrls" description:"视频URL列表(JSON数组)"` + AudioURLs string `orm:"audio_urls" json:"audioUrls" description:"音频URL列表(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:"回调地址"` +} + +// VideoAudioMergeTaskCol 字段定义 +type VideoAudioMergeTaskCol struct { + beans.SQLBaseCol + TaskID string + VideoURLs string + AudioURLs string + Status string + FileURL string + FileSize string + FileName string + FileFormat string + FileAddressPrefix string + DurationStr string + ErrorMessage string + CallbackURL string +} + +// VideoAudioMergeTaskCols 字段常量 +var VideoAudioMergeTaskCols = VideoAudioMergeTaskCol{ + SQLBaseCol: beans.DefSQLBaseCol, + TaskID: "task_id", + VideoURLs: "video_urls", + AudioURLs: "audio_urls", + 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", +} diff --git a/model/entity/video/video_caption_task.go b/model/entity/video/video_caption_task.go new file mode 100644 index 0000000..8c57ba7 --- /dev/null +++ b/model/entity/video/video_caption_task.go @@ -0,0 +1,59 @@ +package video + +import ( + "gitea.redpowerfuture.com/red-future/common/beans" +) + +// VideoCaptionTask 字幕叠加任务实体 +type VideoCaptionTask struct { + beans.SQLBaseDO `orm:",inherit"` + TaskID string `orm:"task_id" json:"taskId" description:"任务唯一标识"` + VideoURLs string `orm:"video_urls" json:"videoUrls" description:"视频URL列表JSON"` + AudioURL string `orm:"audio_url" json:"audioUrl" description:"背景音乐URL"` + Elements string `orm:"elements" json:"elements" description:"字幕元素列表JSON"` + Width int `orm:"width" json:"width" description:"视频宽度"` + Height int `orm:"height" json:"height" description:"视频高度"` + Status string `orm:"status" json:"status" description:"任务状态"` + FileURL string `orm:"file_url" json:"fileUrl" description:"输出文件URL"` + FileSize int64 `orm:"file_size" json:"fileSize" description:"输出文件大小"` + FileName string `orm:"file_name" json:"fileName" description:"输出文件名"` + DurationStr string `orm:"duration_str" json:"durationStr" description:"视频时长"` + ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"` + CallbackURL string `orm:"callback_url" json:"callbackUrl" description:"回调地址"` +} + +// VideoCaptionTaskCol 数据库字段名常量 +type VideoCaptionTaskCol struct { + beans.SQLBaseCol + TaskID string + VideoURLs string + AudioURL string + Elements string + Width string + Height string + Status string + FileURL string + FileSize string + FileName string + DurationStr string + ErrorMessage string + CallbackURL string +} + +// VideoCaptionTaskCols 字段名常量实例 +var VideoCaptionTaskCols = VideoCaptionTaskCol{ + SQLBaseCol: beans.DefSQLBaseCol, + TaskID: "task_id", + VideoURLs: "video_urls", + AudioURL: "audio_url", + Elements: "elements", + Width: "width", + Height: "height", + Status: "status", + FileURL: "file_url", + FileSize: "file_size", + FileName: "file_name", + DurationStr: "duration_str", + ErrorMessage: "error_message", + CallbackURL: "callback_url", +} diff --git a/service/asr/task_service.go b/service/asr/task_service.go index 4333b2f..32e55b9 100644 --- a/service/asr/task_service.go +++ b/service/asr/task_service.go @@ -44,10 +44,10 @@ func (s *audioTaskService) Create(ctx context.Context, params *CreateTaskParams) taskID := "tsk_" + guid.S() if params.Model == "" { - params.Model = g.Cfg().MustGet(ctx, "whisper.model", "medium").String() + params.Model = "medium" } if params.Language == "" { - params.Language = g.Cfg().MustGet(ctx, "whisper.language", "zh").String() + params.Language = "zh" } if params.Threshold <= 0 { params.Threshold = 0.3 diff --git a/service/asr/transcribe_service.go b/service/asr/transcribe_service.go index 16309c0..e6ec786 100644 --- a/service/asr/transcribe_service.go +++ b/service/asr/transcribe_service.go @@ -13,7 +13,6 @@ import ( "time" dto "media/model/dto/audio" - serviceAudio "media/service/audio" serviceScene "media/service/scene" "github.com/gogf/gf/v2/frame/g" @@ -123,35 +122,7 @@ func (s *transcribeService) processVideos(ctx context.Context, savePaths []strin // TranscribeVideo 从视频提取音频并转为文字 func (s *transcribeService) TranscribeVideo(ctx context.Context, req *VideoTranscribeReq) (res *VideoTranscribeRes, err error) { - audioReq := &serviceAudio.ExtractAudioReq{VideoPath: req.VideoPath, Format: "mp3"} - audioRes, err := serviceAudio.AudioExtract.Extract(ctx, audioReq) - if err != nil { - return nil, fmt.Errorf("音频提取失败: %v", err) - } - - whisperRes, err := Whisper.Transcribe(ctx, &TranscribeReq{AudioPath: audioRes.AudioPath, Model: req.Model, Language: req.Language}) - if err != nil { - os.Remove(audioRes.AudioPath) - return nil, fmt.Errorf("语音识别失败: %v", err) - } - - os.Remove(req.VideoPath) - if !req.KeepAudio { - os.Remove(audioRes.AudioPath) - baseName := strings.TrimSuffix(audioRes.AudioPath, filepath.Ext(audioRes.AudioPath)) - os.Remove(baseName + ".txt") - os.Remove(baseName + "." + whisperRes.Model + ".txt") - } - - res = &VideoTranscribeRes{ - Text: whisperRes.Text, - Model: whisperRes.Model, - Language: whisperRes.Language, - AudioPath: audioRes.AudioPath, - AudioSize: audioRes.Size, - AudioDuration: audioRes.Duration, - } - return + return nil, fmt.Errorf("语音识别功能已移除(Whisper 已卸载)") } func downloadFromURL(ctx context.Context, rawURL, tempDir string) (string, error) { diff --git a/service/asr/whisper_service.go b/service/asr/whisper_service.go deleted file mode 100644 index c90f974..0000000 --- a/service/asr/whisper_service.go +++ /dev/null @@ -1,403 +0,0 @@ -package asr - -import ( - "context" - "fmt" - "io" - "media/service/setup" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/gogf/gf/v2/frame/g" -) - -// WhisperBackend 后端类型 -type WhisperBackend int - -const ( - backendPython WhisperBackend = iota // python -m whisper - backendCLI // openai-whisper CLI (whisper 命令) - backendCpp // whisper.cpp (whisper-cpp) -) - -type whisperService struct{} - -// Whisper 语音识别服务单例 -var Whisper = new(whisperService) - -// TranscribeReq 语音识别请求 -type TranscribeReq struct { - AudioPath string // 音频文件路径 - Model string // whisper 模型: tiny/base/small/medium/large - Language string // 语言代码,默认 zh(中文) -} - -// TranscribeRes 语音识别响应 -type TranscribeRes struct { - Text string // 完整识别文本 - Segments []Segment - Model string // 使用的模型 - Language string // 识别的语言 - OutputPath string // 输出的 txt 文件路径 -} - -// Segment 识别片段(带时间戳) -type Segment struct { - Start float64 `json:"start"` // 开始时间(秒) - End float64 `json:"end"` // 结束时间(秒) - Text string `json:"text"` // 文本内容 -} - -// Transcribe 对音频文件进行语音识别(自动检测后端,自动降级) -func (s *whisperService) Transcribe(ctx context.Context, req *TranscribeReq) (res *TranscribeRes, err error) { - // 1. 校验音频文件 - if _, err = os.Stat(req.AudioPath); os.IsNotExist(err) { - return nil, fmt.Errorf("音频文件不存在: %s", req.AudioPath) - } - - // 2. 设置默认值 - model := req.Model - if model == "" { - model = g.Cfg().MustGet(ctx, "whisper.model", "small").String() - } - language := req.Language - if language == "" { - language = g.Cfg().MustGet(ctx, "whisper.language", "zh").String() - } - - // 3. 检测后端,C++ 版找不到模型文件时自动降级 - backend, whisperPath := s.detectBackend() - if backend == backendCpp { - modelPath := s.resolveCppModelPath(model) - if modelPath == "" { - g.Log().Warningf(ctx, "whisper.cpp 模型文件(%s)未找到,降级到 Python whisper", model) - backend = backendPython - } else { - g.Log().Infof(ctx, "语音识别(whisper.cpp): audio=%s, model=%s", req.AudioPath, modelPath) - return s.transcribeWithCpp(ctx, req, whisperPath, modelPath, language) - } - } - - switch backend { - case backendCLI: - g.Log().Infof(ctx, "语音识别(CLI): audio=%s, model=%s, language=%s", req.AudioPath, model, language) - return s.transcribeWithCLI(ctx, req, whisperPath, model, language) - default: - g.Log().Infof(ctx, "语音识别(python): audio=%s, model=%s, language=%s", req.AudioPath, model, language) - return s.transcribeWithPython(ctx, req, model, language) - } -} - -// transcribeWithCLI 使用 whisper CLI 命令 -func (s *whisperService) transcribeWithCLI(ctx context.Context, req *TranscribeReq, whisperPath, model, language string) (res *TranscribeRes, err error) { - outputDir := filepath.Dir(req.AudioPath) - modelDir := g.Cfg().MustGet(ctx, "whisper.model_dir", "").String() - threads := g.Cfg().MustGet(ctx, "whisper.threads", 2).Int() - - args := []string{ - req.AudioPath, - "--model", model, - "--language", language, - "--output_dir", outputDir, - "--output_format", "txt", - "--threads", fmt.Sprintf("%d", threads), - } - if modelDir != "" { - args = append(args, "--model_dir", modelDir) - } - - cmd := exec.CommandContext(ctx, whisperPath, args...) - output, execErr := cmd.CombinedOutput() - if execErr != nil { - g.Log().Errorf(ctx, "whisper CLI 执行失败: %v\n%s", execErr, string(output)) - return nil, fmt.Errorf("语音识别失败: %v", execErr) - } - - return s.readTxtResult(outputDir, req.AudioPath, model) -} - -// transcribeWithPython 使用 python -m whisper -func (s *whisperService) transcribeWithPython(ctx context.Context, req *TranscribeReq, model, language string) (res *TranscribeRes, err error) { - // 查找 python - pythonPath, err := exec.LookPath("python3") - if err != nil { - pythonPath, err = exec.LookPath("python") - if err != nil { - return nil, fmt.Errorf("未找到 python,请安装: pip3 install openai-whisper") - } - } - - outputDir := filepath.Dir(req.AudioPath) - modelDir := g.Cfg().MustGet(ctx, "whisper.model_dir", "").String() - threads := g.Cfg().MustGet(ctx, "whisper.threads", 2).Int() - - args := []string{ - "-m", "whisper", - req.AudioPath, - "--model", model, - "--language", language, - "--output_dir", outputDir, - "--output_format", "txt", - "--threads", fmt.Sprintf("%d", threads), - } - if modelDir != "" { - args = append(args, "--model_dir", modelDir) - } - - cmd := exec.CommandContext(ctx, pythonPath, args...) - output, execErr := cmd.CombinedOutput() - if execErr != nil { - g.Log().Errorf(ctx, "whisper(python) 执行失败: %v\n%s", execErr, string(output)) - return nil, fmt.Errorf("语音识别失败: %v", execErr) - } - - return s.readTxtResult(outputDir, req.AudioPath, model) -} - -// readTxtResult 读取 whisper 输出的 txt 文件 -func (s *whisperService) readTxtResult(outputDir, audioPath, model string) (res *TranscribeRes, err error) { - baseName := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath)) - txtPaths := []string{ - filepath.Join(outputDir, baseName+".txt"), - filepath.Join(outputDir, baseName+"."+model+".txt"), - } - - var textBytes []byte - var txtPath string - for _, p := range txtPaths { - if b, e := os.ReadFile(p); e == nil { - textBytes = b - txtPath = p - break - } - } - if textBytes == nil { - return nil, fmt.Errorf("读取识别结果文件失败") - } - - res = &TranscribeRes{ - Text: cleanTranscript(string(textBytes)), - Model: model, - OutputPath: txtPath, - } - return -} - -// cleanTranscript 清理识别结果:去换行、合并空格 -func cleanTranscript(text string) string { - text = strings.ReplaceAll(text, "\r\n", " ") - text = strings.ReplaceAll(text, "\n", " ") - text = strings.ReplaceAll(text, "\r", " ") - // 合并多个空格 - for strings.Contains(text, " ") { - text = strings.ReplaceAll(text, " ", " ") - } - return strings.TrimSpace(text) -} - -// detectBackend 检测可用的 whisper 后端,返回后端类型和可执行路径 -func (s *whisperService) detectBackend() (WhisperBackend, string) { - // 1. 优先检测 C++ 版 whisper.cpp(最快,但参数格式不同) - for _, name := range []string{"whisper-cpp", "whisper-cli"} { - if path, err := exec.LookPath(name); err == nil { - return backendCpp, path - } - } - - // 2. 检查 setup 检测到的 C++ 路径 - if setup.DetectedWhisperPath != "" { - base := filepath.Base(setup.DetectedWhisperPath) - if base == "whisper-cpp" || base == "whisper-cli" { - if _, err := os.Stat(setup.DetectedWhisperPath); err == nil { - return backendCpp, setup.DetectedWhisperPath - } - } - } - - // 3. 检测 Python CLI(whisper 命令) - if path, err := exec.LookPath("whisper"); err == nil { - return backendCLI, path - } - - // 4. 检查 setup 检测到的 Python CLI 路径 - if setup.DetectedWhisperPath != "" { - if _, err := os.Stat(setup.DetectedWhisperPath); err == nil { - return backendCLI, setup.DetectedWhisperPath - } - } - - // 5. 检查配置中的路径 - if p := g.Cfg().MustGet(context.Background(), "whisper.path", "").String(); p != "" { - if _, err := os.Stat(p); err == nil { - return backendCLI, p - } - } - - return backendPython, "" -} - -// resolveCppModelPath 查找或下载 whisper.cpp 模型文件 -func (s *whisperService) resolveCppModelPath(model string) string { - modelName := strings.TrimPrefix(model, "ggml-") - modelName = strings.TrimSuffix(modelName, ".bin") - - cppModelName := "ggml-" + modelName + ".bin" - home, _ := os.UserHomeDir() - - // 目标路径:~/.cache/whisper/ggml-{model}.bin - targetDir := filepath.Join(home, ".cache", "whisper") - targetPath := filepath.Join(targetDir, cppModelName) - - // 1. 如果已存在,直接返回 - if _, err := os.Stat(targetPath); err == nil { - return targetPath - } - - // 2. 检查其他常见位置 - altPaths := []string{ - cppModelName, - filepath.Join(home, ".cache", "whisper", "ggml-"+modelName+"-q5_0.bin"), - } - // macOS: Homebrew 安装的 whisper.cpp 模型路径 - if runtime.GOOS == "darwin" { - altPaths = append(altPaths, - "/opt/homebrew/share/whisper-cpp/models/"+cppModelName, - "/usr/local/share/whisper-cpp/models/"+cppModelName, - ) - } - // Linux: 常见系统安装路径 - if runtime.GOOS == "linux" { - altPaths = append(altPaths, - "/usr/share/whisper-cpp/models/"+cppModelName, - "/usr/local/share/whisper-cpp/models/"+cppModelName, - ) - } - for _, p := range altPaths { - if _, err := os.Stat(p); err == nil { - return p - } - } - - // 3. 自动下载 - modelSize := map[string]string{ - "tiny": "75MB", - "base": "150MB", - "small": "500MB", - "medium": "1.5GB", - } - size, _ := modelSize[modelName] - - // 下载源:先试 hf-mirror(国内可访问),失败再试官方 - modelPath := fmt.Sprintf("ggerganov/whisper.cpp/resolve/main/%s", cppModelName) - urls := []string{ - fmt.Sprintf("https://hf-mirror.com/%s", modelPath), - fmt.Sprintf("https://huggingface.co/%s", modelPath), - } - - g.Log().Infof(context.TODO(), "[whisper.cpp] 正在下载模型 %s (%s)...", cppModelName, size) - - // 创建目录 - os.MkdirAll(targetDir, 0755) - - // 下载文件(多个源,依次尝试) - var lastErr error - for _, url := range urls { - g.Log().Infof(context.TODO(), "[whisper.cpp] 下载地址: %s", url) - if err := s.downloadFile(url, targetPath, 5*time.Minute); err == nil { - g.Log().Infof(context.TODO(), "[whisper.cpp] 模型下载完成: %s", targetPath) - return targetPath - } else { - lastErr = err - g.Log().Warningf(context.TODO(), "[whisper.cpp] 从 %s 下载失败: %v,尝试下一个源...", url, err) - } - } - - g.Log().Errorf(context.TODO(), "[whisper.cpp] 所有下载源均失败: %v", lastErr) - return "" -} - -// downloadFile 下载文件到指定路径(支持超时) -func (s *whisperService) downloadFile(url, destPath string, timeout time.Duration) error { - tmpPath := destPath + ".tmp" - out, err := os.Create(tmpPath) - if err != nil { - return fmt.Errorf("创建临时文件失败: %v", err) - } - defer out.Close() - - client := &http.Client{Timeout: timeout} - resp, err := client.Get(url) - if err != nil { - os.Remove(tmpPath) - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - os.Remove(tmpPath) - return fmt.Errorf("HTTP %d", resp.StatusCode) - } - - written, err := io.Copy(out, resp.Body) - if err != nil { - os.Remove(tmpPath) - return err - } - - if err := os.Rename(tmpPath, destPath); err != nil { - return fmt.Errorf("文件重命名失败: %v", err) - } - - g.Log().Infof(context.TODO(), "[whisper.cpp] 下载完成: %d bytes", written) - return nil -} - -// transcribeWithCpp 使用 whisper.cpp(C++ 版,参数格式不同) -func (s *whisperService) transcribeWithCpp(ctx context.Context, req *TranscribeReq, binaryPath, model, language string) (res *TranscribeRes, err error) { - outputDir := filepath.Dir(req.AudioPath) - baseName := strings.TrimSuffix(filepath.Base(req.AudioPath), filepath.Ext(req.AudioPath)) - outputPrefix := filepath.Join(outputDir, baseName) - threads := g.Cfg().MustGet(ctx, "whisper.threads", 2).Int() - - // whisper.cpp 参数: - // -f input.mp3 输入文件 - // -l zh 语言 - // -t 2 线程数 - // -otxt 输出 txt - // -of /path/prefix 输出文件前缀(自动加 .txt) - args := []string{ - "-f", req.AudioPath, - "-l", language, - "-t", fmt.Sprintf("%d", threads), - "-otxt", - "-of", outputPrefix, - "-m", model, - } - - cmd := exec.CommandContext(ctx, binaryPath, args...) - output, execErr := cmd.CombinedOutput() - if execErr != nil { - g.Log().Errorf(ctx, "whisper.cpp 执行失败: %v\n%s", execErr, string(output)) - return nil, fmt.Errorf("语音识别失败: %v", execErr) - } - - // whisper.cpp 输出: {prefix}.txt - txtPath := outputPrefix + ".txt" - textBytes, readErr := os.ReadFile(txtPath) - if readErr != nil { - return nil, fmt.Errorf("读取识别结果文件失败: %v", readErr) - } - - res = &TranscribeRes{ - Text: cleanTranscript(string(textBytes)), - Model: model, - Language: language, - OutputPath: txtPath, - } - return -} diff --git a/service/audio/audio_extract_service.go b/service/audio/audio_extract_service.go index b11fa8a..c1cfaad 100644 --- a/service/audio/audio_extract_service.go +++ b/service/audio/audio_extract_service.go @@ -116,21 +116,28 @@ func (s *audioExtractService) Extract(ctx context.Context, req *ExtractAudioReq) return } -// getFFmpegPath 获取 ffmpeg 可执行路径 +// getFFmpegPath 获取 ffmpeg 可执行路径,并打印安装状态日志 func (s *audioExtractService) getFFmpegPath() (string, error) { + ctx := context.Background() + // 1. 优先从配置读取 - ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String() + ffmpegPath := g.Cfg().MustGet(ctx, "ffmpeg.path", "").String() if ffmpegPath != "" { if _, err := os.Stat(ffmpegPath); err == nil { + g.Log().Infof(ctx, "[ffmpeg] ✔ 已安装, 使用配置路径: %s", ffmpegPath) return ffmpegPath, nil } + g.Log().Infof(ctx, "[ffmpeg] 配置路径不可用: %s,回退到系统 PATH 查找", ffmpegPath) } // 2. 从 PATH 中查找 path, err := exec.LookPath("ffmpeg") if err != nil { + g.Log().Errorf(ctx, "[ffmpeg] ❌ 未找到,请确保已安装 ffmpeg(启动时已自动尝试安装,若仍缺失请手动安装)") return "", fmt.Errorf("未找到 ffmpeg,请确保已安装 ffmpeg 或在配置中指定路径") } + + g.Log().Infof(ctx, "[ffmpeg] ✔ 已安装, 系统路径: %s", path) return path, nil } diff --git a/service/scene/scene_service.go b/service/scene/scene_service.go index ab2cc9a..a1f1f30 100644 --- a/service/scene/scene_service.go +++ b/service/scene/scene_service.go @@ -546,16 +546,21 @@ func gcd(a, b int) int { } func getFFmpegPath() (string, error) { - ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String() + ctx := context.Background() + ffmpegPath := g.Cfg().MustGet(ctx, "ffmpeg.path", "").String() if ffmpegPath != "" { if _, err := os.Stat(ffmpegPath); err == nil { + g.Log().Infof(ctx, "[ffmpeg] ✔ 已安装, 使用配置路径: %s", ffmpegPath) return ffmpegPath, nil } + g.Log().Infof(ctx, "[ffmpeg] 配置路径不可用: %s,回退到系统 PATH 查找", ffmpegPath) } path, err := exec.LookPath("ffmpeg") if err != nil { + g.Log().Error(ctx, "[ffmpeg] ❌ 未找到,启动时已自动尝试安装,若仍缺失请手动安装") return "", fmt.Errorf("未找到 ffmpeg") } + g.Log().Infof(ctx, "[ffmpeg] ✔ 已安装, 系统路径: %s", path) return path, nil } diff --git a/service/setup/setup_service.go b/service/setup/setup_service.go index 0174b83..bc9e9bd 100644 --- a/service/setup/setup_service.go +++ b/service/setup/setup_service.go @@ -1,29 +1,21 @@ package setup import ( + "bytes" "context" - "fmt" "os" "os/exec" - "path/filepath" "runtime" "strings" "github.com/gogf/gf/v2/frame/g" ) -var ( - envConfigured bool - - // DetectedWhisperPath 自动检测到的 whisper 命令行路径(空则使用 python -m whisper) - DetectedWhisperPath string -) - func init() { ensureDependencies() } -// ensureDependencies 启动时检查并安装 ffmpeg 和 whisper +// ensureDependencies 启动时检查 ffmpeg 依赖 func ensureDependencies() { ctx := context.Background() g.Log().Info(ctx, "========== 检查依赖环境 ==========") @@ -32,14 +24,11 @@ func ensureDependencies() { g.Log().Infof(ctx, "平台: %s/%s, Docker: %v", runtime.GOOS, runtime.GOARCH, isRunningInContainer()) ensureFFmpeg(ctx) - ensureWhisper(ctx) - resolveWhisperPath(ctx) + ensurePython3(ctx) + ensureSceneDetect(ctx) + ensureHyperFrames(ctx) - if envConfigured { - g.Log().Info(ctx, "依赖检查完成,新环境变量已配置,建议重启终端") - } else { - g.Log().Info(ctx, "依赖检查完成,所有依赖已就绪") - } + g.Log().Info(ctx, "依赖检查完成,所有依赖已就绪") g.Log().Info(ctx, "===================================") } @@ -211,353 +200,253 @@ func installFFmpegOnWindows(ctx context.Context) { 3. 从 https://ffmpeg.org/download.html 下载并加入 PATH`) } -// ensureWhisper 确保 whisper 可用(优先安装 C++ 版,速度更快) -func ensureWhisper(ctx context.Context) { - // 1. 检查是否已有 whisper-cpp(C++ 版,最快) - // exec.LookPath 在 Windows 上会自动查找 .exe 后缀 - if path, err := exec.LookPath("whisper-cpp"); err == nil { - g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装: %s", path) - return - } - if path, err := exec.LookPath("whisper-cli"); err == nil { - g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装: %s", path) +// ensureHyperFrames 检查 HyperFrames 是否可用,不可用时自动安装 +func ensureHyperFrames(ctx context.Context) { + // 1. 优先检查全局 hyperframes 命令 + if path, err := exec.LookPath("hyperframes"); err == nil { + version := getHyperFramesVersion(path) + g.Log().Infof(ctx, "[hyperframes] ✔ 已安装, 版本=%s, 路径=%s", version, path) return } - // 2. 仅在 macOS 上检查 Homebrew 安装目录(即使不在 PATH 也能找到) - if runtime.GOOS == "darwin" { - if p := findHomebrewWhisperCpp(); p != "" { - DetectedWhisperPath = p - if !inContainer { - addToShellPath(ctx, filepath.Dir(p)) - } - g.Log().Infof(ctx, "[whisper] ✔ C++ 版已安装(自动检测): %s", p) - return + // 2. 检查 npx hyperframes 是否可用 + var outBuf bytes.Buffer + checkCmd := exec.Command("npx", "hyperframes", "--version") + checkCmd.Stdout = &outBuf + checkCmd.Stderr = nil + if checkCmd.Run() == nil { + ver := strings.TrimSpace(outBuf.String()) + if ver == "" { + ver = "未知" } - } - - // 3. 仅在 macOS 上尝试使用 Homebrew 安装 C++ 版 - if runtime.GOOS == "darwin" { - if _, err := exec.LookPath("brew"); err == nil { - g.Log().Infof(ctx, "[whisper] 安装 C++ 版 (brew install whisper-cpp)...") - cmd := exec.CommandContext(ctx, "brew", "install", "whisper-cpp") - output, err := cmd.CombinedOutput() - if err == nil { - g.Log().Info(ctx, "[whisper] ✔ C++ 版安装成功") - if !inContainer { - addToShellPath(ctx, getHomebrewBinDir()) - } - if p := findHomebrewWhisperCpp(); p != "" { - DetectedWhisperPath = p - } - return - } - g.Log().Warningf(ctx, "[whisper] ⚠ brew 安装失败: %v\n%s", err, string(output)) - g.Log().Infof(ctx, "[whisper] 降级安装 Python 版...") - } - } - - // 4. 降级:检查 python -m whisper 是否可用 - if pythonWhisperAvailable() { - g.Log().Info(ctx, "[whisper] ✔ Python 版已安装 (python3 -m whisper)") + g.Log().Infof(ctx, "[hyperframes] ✔ 已安装(npx), 版本=%s", ver) return } - // 5. 降级:pip 安装 Python 版 - if _, err := exec.LookPath("pip3"); err != nil { - if _, err2 := exec.LookPath("pip"); err2 != nil { - g.Log().Warningf(ctx, "[whisper] ⚠ 未找到 pip,请手动安装:\n pip3 install openai-whisper") - return + // 3. 未安装,检查 npm/node 可用性再决定是否自动安装 + if _, err := exec.LookPath("npm"); err != nil { + if inContainer { + g.Log().Infof(ctx, "[hyperframes] npm 不可用,跳过自动安装(Docker 中请预装 Node.js)") + } else { + g.Log().Infof(ctx, "[hyperframes] npm 不可用,跳过自动安装(请先安装 Node.js: https://nodejs.org)") } + return } - g.Log().Infof(ctx, "[whisper] 安装 Python 版 (pip install openai-whisper)...") - pipCmd := "pip3" - if _, err := exec.LookPath("pip3"); err != nil { - pipCmd = "pip" - } - - // pip install --user 可能在某些环境下不兼容,尝试先不加 --user,失败后再加 - cmd := exec.CommandContext(ctx, pipCmd, "install", "openai-whisper") - output, err := cmd.CombinedOutput() + g.Log().Infof(ctx, "[hyperframes] 未找到,尝试自动安装 (npm install -g hyperframes)...") + installCmd := exec.Command("npm", "install", "-g", "hyperframes") + output, err := installCmd.CombinedOutput() if err != nil { - // 尝试 --user 模式 - g.Log().Warningf(ctx, "[whisper] pip 全局安装失败: %v,尝试 --user 模式...", err) - cmd = exec.CommandContext(ctx, pipCmd, "install", "--user", "openai-whisper") - output, err = cmd.CombinedOutput() - if err != nil { - g.Log().Errorf(ctx, "[whisper] ❌ pip 安装失败: %v\n%s", err, string(output)) - return - } + g.Log().Warningf(ctx, "[hyperframes] ⚠ 自动安装失败: %v\n%s", err, string(output)) + g.Log().Infof(ctx, "[hyperframes] 请手动安装: npm install -g hyperframes") + return } - g.Log().Info(ctx, "[whisper] ✔ Python 版安装成功") + g.Log().Info(ctx, "[hyperframes] ✔ 自动安装成功") - // 安装后自动配置 PATH(仅在非容器、非 Windows 环境) - if !inContainer && runtime.GOOS != "windows" { - configureWhisperPath(ctx) + // 安装后检查路径 + if path, err := exec.LookPath("hyperframes"); err == nil { + version := getHyperFramesVersion(path) + g.Log().Infof(ctx, "[hyperframes] 路径=%s, 版本=%s", path, version) } } -// resolveWhisperPath 自动找到 whisper 二进制路径并存储 -func resolveWhisperPath(ctx context.Context) { - // 0. 如果已经通过 ensure 检测到了路径,直接使用 - if DetectedWhisperPath != "" { - if _, err := os.Stat(DetectedWhisperPath); err == nil { - g.Log().Infof(ctx, "[whisper] ✔ 路径: %s", DetectedWhisperPath) - return - } +// getHyperFramesVersion 获取 HyperFrames 版本号 +func getHyperFramesVersion(path string) string { + var outBuf bytes.Buffer + vCmd := exec.Command(path, "--version") + vCmd.Stdout = &outBuf + if vCmd.Run() == nil { + return strings.TrimSpace(outBuf.String()) } + return "未知" +} - // 1. 优先检测 C++ 版本(快 3-5 倍) - // exec.LookPath 在 Windows 上自动查找 .exe 后缀 - for _, name := range []string{"whisper-cpp", "whisper-cli"} { - if path, err := exec.LookPath(name); err == nil { - DetectedWhisperPath = path - g.Log().Infof(ctx, "[whisper] ✔ C++ 版: %s", path) - return - } - } - - // 2. 仅在 macOS 上查找 Homebrew 目录下的 C++ 版本 - if runtime.GOOS == "darwin" { - if p := findHomebrewWhisperCpp(); p != "" { - DetectedWhisperPath = p - g.Log().Infof(ctx, "[whisper] ✔ C++ 版(自动检测): %s", p) - return - } - } - - // 3. 从 PATH 查找 Python 版 whisper - if path, err := exec.LookPath("whisper"); err == nil { - DetectedWhisperPath = path - g.Log().Infof(ctx, "[whisper] ✔ Python 版: %s", path) +// ensurePython3 确保 Python3 可用 +func ensurePython3(ctx context.Context) { + // 优先检查 python3 + if path, err := exec.LookPath("python3"); err == nil { + version := getPythonVersion(ctx, path) + g.Log().Infof(ctx, "[python3] ✔ 已安装, 版本=%s, 路径=%s", version, path) return } - // 4. 尝试常见 pip user bin 路径 - for _, p := range getWhisperCandidates() { - if info, err := os.Stat(p); err == nil && !info.IsDir() { - DetectedWhisperPath = p - g.Log().Infof(ctx, "[whisper] ✔ Python 版(自动检测): %s", p) + // 回退检查 python(部分系统用 python 指向 python3) + if path, err := exec.LookPath("python"); err == nil { + version := getPythonVersion(ctx, path) + if strings.HasPrefix(version, "3.") { + g.Log().Infof(ctx, "[python3] ✔ 已安装(python), 版本=%s, 路径=%s", version, path) + return + } + // Python 2,不满足需求 + g.Log().Infof(ctx, "[python3] 检测到 python=%s, 需要 Python 3,尝试自动安装...", version) + } else { + g.Log().Infof(ctx, "[python3] 未找到,尝试自动安装...") + } + + installPython3(ctx) +} + +// ensureSceneDetect 确保 scenedetect 库可用 +func ensureSceneDetect(ctx context.Context) { + // 检查 python3 是否可导入 scenedetect + checkCmd := exec.Command("python3", "-c", "import scenedetect; print(scenedetect.__version__)") + var outBuf bytes.Buffer + checkCmd.Stdout = &outBuf + checkCmd.Stderr = nil + if checkCmd.Run() == nil { + ver := strings.TrimSpace(outBuf.String()) + if ver == "" { + ver = "未知" + } + g.Log().Infof(ctx, "[scenedetect] ✔ 已安装, 版本=%s", ver) + return + } + + g.Log().Infof(ctx, "[scenedetect] 未找到,尝试自动安装 (pip install scenedetect[opencv,ffmpeg])...") + installCmd := exec.Command("pip3", "install", "scenedetect[opencv,ffmpeg]") + output, err := installCmd.CombinedOutput() + if err != nil { + g.Log().Warningf(ctx, "[scenedetect] ⚠ pip3 安装失败: %v\n%s", err, string(output)) + g.Log().Infof(ctx, "[scenedetect] 尝试使用 pip 安装...") + installCmd = exec.Command("pip", "install", "scenedetect[opencv,ffmpeg]") + output, err = installCmd.CombinedOutput() + if err != nil { + g.Log().Warningf(ctx, "[scenedetect] ⚠ pip 安装也失败: %v\n%s", err, string(output)) + if inContainer { + g.Log().Infof(ctx, "[scenedetect] Docker 提示: pip 安装失败可能是因为缺少系统级依赖(如 libopencv-dev)。") + g.Log().Infof(ctx, "[scenedetect] 建议在 Dockerfile 中预装: apt-get install -y python3-opencv ffmpeg && pip install scenedetect") + } else { + g.Log().Infof(ctx, "[scenedetect] 请手动安装: pip install 'scenedetect[opencv,ffmpeg]'") + } return } } + g.Log().Info(ctx, "[scenedetect] ✔ 安装成功") - g.Log().Info(ctx, "[whisper] ✔ 使用 python3 -m whisper 方式") + // 验证安装 + verifyCmd := exec.Command("python3", "-c", "import scenedetect; print(scenedetect.__version__)") + outBuf.Reset() + verifyCmd.Stdout = &outBuf + if verifyCmd.Run() == nil { + g.Log().Infof(ctx, "[scenedetect] 验证通过, 版本=%s", strings.TrimSpace(outBuf.String())) + } } -// getWhisperCandidates 返回可能的 whisper 二进制路径 -func getWhisperCandidates() []string { - var candidates []string - - // 通过 python 探针获取 user-site bin 目录 - if p := getUserPythonBin(); p != "" { - candidates = append(candidates, filepath.Join(p, "whisper")) - // Windows 上 pip 安装的可执行文件是 .exe - if runtime.GOOS == "windows" { - candidates = append(candidates, filepath.Join(p, "whisper.exe")) - } +// getPythonVersion 获取 Python 版本号 +func getPythonVersion(ctx context.Context, pythonPath string) string { + var outBuf bytes.Buffer + vCmd := exec.CommandContext(ctx, pythonPath, "--version") + vCmd.Stdout = &outBuf + vCmd.Stderr = &outBuf + if vCmd.Run() == nil { + return strings.TrimPrefix(strings.TrimSpace(outBuf.String()), "Python ") } + return "未知" +} - // 常见 pip user base 路径 - userHome, _ := os.UserHomeDir() - +// installPython3 根据平台自动安装 Python3 +func installPython3(ctx context.Context) { switch runtime.GOOS { case "darwin": - // macOS 常见的 Python 版本路径 - pythonVersions := []string{"3.9", "3.10", "3.11", "3.12", "3.13"} - for _, ver := range pythonVersions { - candidates = append(candidates, - filepath.Join(userHome, "Library", "Python", ver, "bin", "whisper"), - ) + if _, err := exec.LookPath("brew"); err == nil { + g.Log().Infof(ctx, "[python3] 通过 brew 安装...") + cmd := exec.CommandContext(ctx, "brew", "install", "python@3") + output, err := cmd.CombinedOutput() + if err != nil { + g.Log().Warningf(ctx, "[python3] ⚠ brew 安装失败: %v\n%s", err, string(output)) + g.Log().Infof(ctx, "[python3] 请手动安装: brew install python@3") + } else { + g.Log().Info(ctx, "[python3] ✔ 安装成功") + } + } else { + g.Log().Warningf(ctx, "[python3] ⚠ 未检测到 Homebrew,请手动安装 Python3:\n https://www.python.org/downloads/") } + case "linux": - candidates = append(candidates, - filepath.Join(userHome, ".local", "bin", "whisper"), - ) + sudoPrefix := "" + if !inContainer { + if _, err := exec.LookPath("sudo"); err == nil { + sudoPrefix = "sudo" + } + } + + // 1. apt (Debian/Ubuntu) + if _, err := exec.LookPath("apt-get"); err == nil { + args := []string{"install", "-y", "python3", "python3-pip"} + if sudoPrefix != "" { + args = append([]string{sudoPrefix}, args...) + } + cmd := exec.CommandContext(ctx, "apt-get", args...) + output, err := cmd.CombinedOutput() + if err != nil { + g.Log().Warningf(ctx, "[python3] ⚠ apt-get 安装失败: %v\n%s", err, string(output)) + } else { + g.Log().Info(ctx, "[python3] ✔ 安装成功") + } + return + } + // 2. apk (Alpine) + if _, err := exec.LookPath("apk"); err == nil { + cmd := exec.CommandContext(ctx, "apk", "add", "python3", "py3-pip") + output, err := cmd.CombinedOutput() + if err != nil { + g.Log().Warningf(ctx, "[python3] ⚠ apk 安装失败: %v\n%s", err, string(output)) + } else { + g.Log().Info(ctx, "[python3] ✔ 安装成功") + } + return + } + // 3. yum (CentOS/RHEL) + if _, err := exec.LookPath("yum"); err == nil { + args := []string{"install", "-y", "python3", "python3-pip"} + if sudoPrefix != "" { + args = append([]string{sudoPrefix}, args...) + } + cmd := exec.CommandContext(ctx, "yum", args...) + output, err := cmd.CombinedOutput() + if err != nil { + g.Log().Warningf(ctx, "[python3] ⚠ yum 安装失败: %v\n%s", err, string(output)) + } else { + g.Log().Info(ctx, "[python3] ✔ 安装成功") + } + return + } + + if inContainer { + g.Log().Warningf(ctx, "[python3] ⚠ 容器中未找到包管理器,请将 python3 预装在 Docker 镜像中") + } else { + g.Log().Warningf(ctx, "[python3] ⚠ 请手动安装: sudo apt-get install python3 python3-pip") + } + case "windows": - // Windows 上 pip --user 安装的脚本路径 - candidates = append(candidates, - filepath.Join(userHome, "AppData", "Roaming", "Python", "Scripts", "whisper.exe"), - filepath.Join(userHome, "AppData", "Roaming", "Python", "Scripts", "whisper"), - filepath.Join(userHome, "AppData", "Local", "Programs", "Python", "Scripts", "whisper.exe"), - filepath.Join(userHome, "AppData", "Local", "Programs", "Python", "Scripts", "whisper"), - ) - // Python 版本特定路径 - pythonVersions := []string{"39", "310", "311", "312", "313"} - for _, ver := range pythonVersions { - candidates = append(candidates, - filepath.Join(userHome, "AppData", "Roaming", "Python", "Python"+ver, "Scripts", "whisper.exe"), - filepath.Join(userHome, "AppData", "Roaming", "Python", "Python"+ver, "Scripts", "whisper"), - ) - } - } - - return candidates -} - -// getUserPythonBin 通过 python 获取 user bin 目录 -func getUserPythonBin() string { - pythonCandidates := []string{"python3", "python"} - for _, py := range pythonCandidates { - path, err := exec.LookPath(py) - if err != nil { - continue - } - cmd := exec.Command(path, "-m", "site", "--user-base") - output, err := cmd.Output() - if err != nil { - continue - } - base := strings.TrimSpace(string(output)) - if base != "" { - return filepath.Join(base, "bin") - } - } - return "" -} - -// configureWhisperPath 将 pip user bin 目录加到 shell 配置 -func configureWhisperPath(ctx context.Context) { - binDir := getUserPythonBin() - if binDir == "" { - return - } - - // 检查是否已经在 PATH 中 - currentPath := os.Getenv("PATH") - if strings.Contains(currentPath, binDir) { - return - } - - // 配置到 .zshrc 或 .bashrc - home, _ := os.UserHomeDir() - rcFiles := []string{".zshrc", ".bashrc", ".bash_profile"} - - for _, rc := range rcFiles { - rcPath := filepath.Join(home, rc) - // 文件不存在则跳过 - if _, err := os.Stat(rcPath); os.IsNotExist(err) { - continue - } - // 检查是否已添加 - data, _ := os.ReadFile(rcPath) - if strings.Contains(string(data), binDir) { - continue - } - // 追加 - line := fmt.Sprintf("\nexport PATH=\"%s:$PATH\"\n", binDir) - f, err := os.OpenFile(rcPath, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - g.Log().Warningf(ctx, "[whisper] 写入 %s 失败: %v", rc, err) - continue - } - f.WriteString(line) - f.Close() - g.Log().Infof(ctx, "[whisper] 已将 %s 添加到 %s,请执行: source ~/%s", binDir, rc, rc) - envConfigured = true - break - } -} - -// pythonWhisperAvailable 检查 python -m whisper 是否可用 -func pythonWhisperAvailable() bool { - pythonCandidates := []string{"python3", "python"} - for _, py := range pythonCandidates { - if path, err := exec.LookPath(py); err == nil { - cmd := exec.Command(path, "-m", "whisper", "--help") - if cmd.Run() == nil { - return true + // 1. winget + if _, err := exec.LookPath("winget"); err == nil { + g.Log().Infof(ctx, "[python3] 通过 winget 安装...") + cmd := exec.CommandContext(ctx, "winget", "install", "--id", "Python.Python.3", "-e", "--accept-package-agreements") + output, err := cmd.CombinedOutput() + if err == nil { + g.Log().Info(ctx, "[python3] ✔ 安装成功") + return } + g.Log().Warningf(ctx, "[python3] ⚠ winget 安装失败: %v\n%s", err, string(output)) } - } - return false -} - -// findHomebrewWhisperCpp 在 Homebrew 安装目录查找 whisper-cpp -func findHomebrewWhisperCpp() string { - dirs := getHomebrewBinDirs() - for _, dir := range dirs { - for _, name := range []string{"whisper-cpp", "whisper-cli"} { - p := filepath.Join(dir, name) - if info, err := os.Stat(p); err == nil && !info.IsDir() { - return p + // 2. choco + if _, err := exec.LookPath("choco"); err == nil { + g.Log().Infof(ctx, "[python3] 通过 choco 安装...") + cmd := exec.CommandContext(ctx, "choco", "install", "python", "-y") + output, err := cmd.CombinedOutput() + if err == nil { + g.Log().Info(ctx, "[python3] ✔ 安装成功") + return } + g.Log().Warningf(ctx, "[python3] ⚠ choco 安装失败: %v\n%s", err, string(output)) } - } - return "" -} -// getHomebrewBinDirs 返回 Homebrew 可能的 bin 目录 -func getHomebrewBinDirs() []string { - userHome, _ := os.UserHomeDir() - return []string{ - "/opt/homebrew/bin", // Apple Silicon - "/usr/local/bin", // Intel - filepath.Join(userHome, ".homebrew", "bin"), - } -} + g.Log().Warningf(ctx, `[python3] ⚠ 请手动安装 Python3: + 1. winget install --id Python.Python.3 -e + 2. 从 https://www.python.org/downloads/ 下载安装(记得勾选"Add to PATH")`) -// getHomebrewBinDir 返回当前系统的 Homebrew bin 目录 -func getHomebrewBinDir() string { - dirs := getHomebrewBinDirs() - for _, dir := range dirs { - if _, err := os.Stat(filepath.Join(dir, "brew")); err == nil { - return dir - } - // 也检查 brew 命令路径 - if path, err := exec.LookPath("brew"); err == nil { - return filepath.Dir(path) - } - } - return "/opt/homebrew/bin" // 默认 Apple Silicon 路径 -} - -// addToShellPath 将目录添加到 shell rc 文件的 PATH 中 -func addToShellPath(ctx context.Context, dir string) { - if dir == "" { - return - } - - // 容器环境不修改 shell 配置(无意义) - if inContainer { - return - } - - // Windows 环境不修改 shell rc 文件(使用系统环境变量) - if runtime.GOOS == "windows" { - g.Log().Infof(ctx, "[setup] Windows 环境,请手动将 %s 添加到系统 PATH 环境变量", dir) - return - } - - // 检查是否已在 PATH 中 - currentPath := os.Getenv("PATH") - if strings.Contains(currentPath, dir) { - return - } - - home, _ := os.UserHomeDir() - rcFiles := []string{".zshrc", ".bashrc", ".bash_profile"} - - for _, rc := range rcFiles { - rcPath := filepath.Join(home, rc) - if _, err := os.Stat(rcPath); os.IsNotExist(err) { - continue - } - data, _ := os.ReadFile(rcPath) - if strings.Contains(string(data), dir) { - continue - } - line := fmt.Sprintf("\nexport PATH=\"%s:$PATH\"\n", dir) - f, err := os.OpenFile(rcPath, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - g.Log().Warningf(ctx, "[setup] 写入 %s 失败: %v", rc, err) - continue - } - f.WriteString(line) - f.Close() - g.Log().Infof(ctx, "[setup] 已将 %s 添加到 %s", dir, rc) - envConfigured = true - break + default: + g.Log().Warningf(ctx, "[python3] ⚠ 不支持的平台(%s),请手动安装 Python3", runtime.GOOS) } } diff --git a/service/video/caption_service.go b/service/video/caption_service.go new file mode 100644 index 0000000..b216a91 --- /dev/null +++ b/service/video/caption_service.go @@ -0,0 +1,837 @@ +package video + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "html" + "io" + "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" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/guid" +) + +// Caption 字幕叠加服务单例 +var Caption = new(captionService) + +type captionService struct{} + +// ---------- 异步任务管理 ---------- + +// CreateAsyncTask 创建字幕叠加任务,返回 taskID +func (s *captionService) CreateAsyncTask(ctx context.Context, videoURLs []string, audioURL string, subtitles []dto.SubtitleSegment, subtitleStyle *dto.SubtitleStyle, elements []dto.CaptionElement, callbackURL string) (string, error) { + if len(videoURLs) < 1 { + return "", fmt.Errorf("至少需要1个视频") + } + if len(elements) < 1 && len(subtitles) < 1 { + return "", fmt.Errorf("至少需要字幕时间线或字幕元素") + } + if elements == nil { + elements = []dto.CaptionElement{} + } + if subtitles == nil { + subtitles = []dto.SubtitleSegment{} + } + + videoURLsJSON, _ := json.Marshal(videoURLs) + elementsJSON, _ := json.Marshal(elements) + + taskID := "cap_" + guid.S() + task := &entity.VideoCaptionTask{ + TaskID: taskID, + VideoURLs: string(videoURLsJSON), + AudioURL: audioURL, + Elements: string(elementsJSON), + Status: "pending", + CallbackURL: callbackURL, + } + if _, err := dao.CaptionTask.Insert(ctx, task); err != nil { + return "", fmt.Errorf("创建任务失败: %v", err) + } + + user := getUserFromCtx(ctx) + + g.Log().Infof(ctx, "[字幕叠加-异步] 创建任务 %s, 视频数=%d, 字幕段数=%d, 元素数=%d, 回调=%s", + taskID, len(videoURLs), len(subtitles), len(elements), callbackURL) + + go s.processTask(user, taskID, videoURLs, audioURL, subtitles, subtitleStyle, elements, callbackURL) + + return taskID, nil +} + +// processTask 后台处理字幕叠加任务 +func (s *captionService) processTask(user *beans.User, taskID string, videoURLs []string, audioURL string, subtitles []dto.SubtitleSegment, subtitleStyle *dto.SubtitleStyle, elements []dto.CaptionElement, callbackURL string) { + bgCtx := context.Background() + bgCtx = context.WithValue(bgCtx, "user", user) + + dao.CaptionTask.UpdateRunning(bgCtx, taskID) + + defer func() { + if r := recover(); r != nil { + errMsg := fmt.Sprintf("字幕叠加异常: %v", r) + g.Log().Errorf(bgCtx, "[字幕 %s] %s", taskID, errMsg) + dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) + s.callback(bgCtx, taskID, callbackURL, nil) + } + }() + + // 1. 创建临时工作目录 + tempDir := g.Cfg().MustGet(bgCtx, "ffmpeg.temp_dir", "resource/temp").String() + projectDir := filepath.Join(tempDir, fmt.Sprintf("caption_%s", taskID)) + os.RemoveAll(projectDir) + os.MkdirAll(projectDir, 0755) + defer os.RemoveAll(projectDir) + + // 2. 下载所有视频 + var videoPaths []string + for i, videoURL := range videoURLs { + savePath, dlErr := downloadFile(bgCtx, videoURL, projectDir) + if dlErr != nil { + g.Log().Warningf(bgCtx, "[字幕 %s] 视频%d下载失败 %s: %v", taskID, i, videoURL, dlErr) + continue + } + videoPaths = append(videoPaths, savePath) + } + if len(videoPaths) < 1 { + errMsg := fmt.Sprintf("所有视频下载失败(共%d个)", len(videoURLs)) + dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) + s.callback(bgCtx, taskID, callbackURL, nil) + return + } + + // 3. 从第一个视频自动检测分辨率 + width, height := getVideoResolution(bgCtx, videoPaths[0]) + g.Log().Infof(bgCtx, "[字幕 %s] 视频分辨率: %dx%d", taskID, width, height) + // 更新 DB 中的分辨率字段 + dao.CaptionTask.UpdateResolution(bgCtx, taskID, width, height) + + // 4. 如果有多段视频,先用 FFmpeg 拼接成一段 + videoCount := len(videoPaths) + if videoCount > 1 { + concatedPath := filepath.Join(projectDir, "concated.mp4") + if err := s.concatVideos(bgCtx, videoPaths, concatedPath); err != nil { + g.Log().Warningf(bgCtx, "[字幕 %s] 视频拼接失败(使用首个视频): %v", taskID, err) + } else { + videoPaths = []string{concatedPath} + g.Log().Infof(bgCtx, "[字幕 %s] 视频拼接完成: %d段 → %s", taskID, videoCount, concatedPath) + } + } + + // 4.5 消音处理:去掉原视频音频,避免与新音频混音 + mutedPath := filepath.Join(projectDir, "muted.mp4") + if err := s.muteVideo(bgCtx, videoPaths[0], mutedPath); err != nil { + g.Log().Warningf(bgCtx, "[字幕 %s] 视频消音失败: %v", taskID, err) + } else { + videoPaths = []string{mutedPath} + g.Log().Infof(bgCtx, "[字幕 %s] 视频消音完成: %s", taskID, mutedPath) + } + + // 5. 用 ffprobe 获取视频真实总时长 + totalVideoDuration := getVideoRealDuration(bgCtx, videoPaths[0]) + if totalVideoDuration <= 0 { + totalVideoDuration = 30 + } + g.Log().Infof(bgCtx, "[字幕 %s] 视频总时长: %.2f 秒", taskID, totalVideoDuration) + + // 6. 下载元素中的图片资源(type=image) + for i, elem := range elements { + if elem.Type == "image" && elem.ImageURL != "" { + savePath, dlErr := downloadFile(bgCtx, elem.ImageURL, projectDir) + if dlErr != nil { + g.Log().Warningf(bgCtx, "[字幕 %s] 图片%d下载失败 %s: %v", taskID, i, elem.ImageURL, dlErr) + continue + } + elements[i].ImageURL = filepath.Base(savePath) + } + } + + // 7. 下载背景音乐(可选) + audioPath := "" + if audioURL != "" { + savePath, dlErr := downloadFile(bgCtx, audioURL, projectDir) + if dlErr != nil { + g.Log().Warningf(bgCtx, "[字幕 %s] 音频下载失败 %s: %v", taskID, audioURL, dlErr) + } else { + // 7.5 音频降噪:去除气口/口水音/底噪(时长不变) + denoisedPath := filepath.Join(projectDir, "denoised_audio.wav") + if err := s.denoiseAudio(bgCtx, savePath, denoisedPath); err != nil { + g.Log().Warningf(bgCtx, "[字幕 %s] 音频降噪失败(使用原始音频): %v", taskID, err) + audioPath = savePath + } else { + audioPath = denoisedPath + g.Log().Infof(bgCtx, "[字幕 %s] 音频降噪完成: %s", taskID, denoisedPath) + } + } + } + + // 8. 将外部字幕时间线转为字幕元素 + subtitleElements := subtitlesToElements(subtitles, subtitleStyle) + // 合并所有元素 + allElements := append(subtitleElements, elements...) + + // 9. 生成 index.html(基于真实视频时长) + htmlContent := s.buildHTML(taskID, videoPaths[0], audioPath, allElements, totalVideoDuration, width, height) + htmlPath := filepath.Join(projectDir, "index.html") + if err := os.WriteFile(htmlPath, []byte(htmlContent), 0644); err != nil { + errMsg := fmt.Sprintf("生成HTML失败: %v", err) + dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) + s.callback(bgCtx, taskID, callbackURL, nil) + return + } + g.Log().Infof(bgCtx, "[字幕 %s] HTML生成完成: %s", taskID, htmlPath) + + // 10. 执行 HyperFrames 渲染 + outputPath := filepath.Join(projectDir, "output.mp4") + if err := s.runHyperFramesRender(bgCtx, projectDir, outputPath); err != nil { + errMsg := fmt.Sprintf("视频渲染失败: %v", err) + dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) + s.callback(bgCtx, taskID, callbackURL, nil) + return + } + + // 7. 检查输出文件 + stat, statErr := os.Stat(outputPath) + if statErr != nil { + errMsg := fmt.Sprintf("输出文件不存在: %v", statErr) + dao.CaptionTask.UpdateError(bgCtx, taskID, errMsg) + s.callback(bgCtx, taskID, callbackURL, nil) + return + } + + durationStr := getVideoDurationStr(bgCtx, outputPath) + + // 8. 上传到 MinIO(固定上传) + fileURL := "" + uploadCtx := context.WithValue(context.Background(), "user", user) + uploadRes, uploadErr := uploadToMinIO(uploadCtx, outputPath) + if uploadErr != nil { + dao.CaptionTask.UpdateError(bgCtx, taskID, fmt.Sprintf("上传失败: %v", uploadErr)) + s.callback(bgCtx, taskID, callbackURL, nil) + return + } + fileURL = uploadRes.FileURL + g.Log().Infof(bgCtx, "[字幕 %s] MinIO 上传完成: fileUrl=%s, fileName=%s", taskID, uploadRes.FileURL, uploadRes.FileName) + + // 9. 更新为成功 + fileName := filepath.Base(outputPath) + dao.CaptionTask.UpdateSuccess(bgCtx, taskID, fileURL, stat.Size(), fileName, durationStr) + + g.Log().Infof(bgCtx, "[字幕 %s] 完成, 文件=%s, 大小=%d, 时长=%s", taskID, outputPath, stat.Size(), durationStr) + + if callbackURL != "" { + extra := map[string]interface{}{ + "fileURL": uploadRes.FileURL, + "fileSize": uploadRes.FileSize, + "fileName": uploadRes.FileName, + "fileFormat": uploadRes.FileFormat, + "fileAddressPrefix": uploadRes.FileAddressPrefix, + "durationStr": durationStr, + } + s.callback(bgCtx, taskID, callbackURL, extra) + } +} + +// runHyperFramesRender 执行 HyperFrames 渲染 +func (s *captionService) runHyperFramesRender(ctx context.Context, projectDir, outputPath string) error { + ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + + // 使用全局 hyperframes 命令(已在 setup 中安装检查) + hyperframesPath, lookErr := exec.LookPath("hyperframes") + if lookErr != nil { + return fmt.Errorf("hyperframes 未安装, 请运行: npm install -g hyperframes") + } + + // 直接在工作目录运行 hyperframes render,输出默认 output.mp4 + cmd := exec.CommandContext(ctxWithTimeout, hyperframesPath, "render") + cmd.Dir = projectDir + cmd.Env = append(os.Environ(), + "HYPERFRAMES_HEADLESS=true", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("hyperframes render 失败: %v\n%s", err, string(output)) + } + g.Log().Infof(ctx, "[HyperFrames] render 完成: %s", strings.TrimSpace(string(output))) + + // 查找输出文件:优先 renders/ 目录(HyperFrames 默认输出位置) + rendersDir := filepath.Join(projectDir, "renders") + if entries, readErr := os.ReadDir(rendersDir); readErr == nil { + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".mp4") { + src := filepath.Join(rendersDir, entry.Name()) + if err := os.Rename(src, outputPath); err != nil { + return fmt.Errorf("移动输出文件失败: %v", err) + } + g.Log().Infof(ctx, "[HyperFrames] 找到输出文件: %s", entry.Name()) + return nil + } + } + } + + // 回退:检查 projectDir 下的 output.mp4 + fallback := filepath.Join(projectDir, "output.mp4") + if _, statErr := os.Stat(fallback); statErr == nil { + if err := os.Rename(fallback, outputPath); err != nil { + return fmt.Errorf("移动输出文件失败: %v", err) + } + return nil + } + + return fmt.Errorf("未找到输出文件(已在 %s 和 %s 中查找)", rendersDir, fallback) +} + +// buildHTML 生成 HyperFrames HTML 模板 +// videoPath: 已拼接好的单视频文件路径;totalDuration: 视频真实总时长(秒) +func (s *captionService) buildHTML(taskID, videoPath, audioPath string, elements []dto.CaptionElement, totalDuration float64, width, height int) string { + var sb strings.Builder + compID := "main" + + sb.WriteString("\n\n
\n") + sb.WriteString("\n") + sb.WriteString("\n") + sb.WriteString("\n") + sb.WriteString("\n") + sb.WriteString("\n\n") + + // Composition 容器(data-start="0" 必须) + sb.WriteString(fmt.Sprintf("