Compare commits

...

35 Commits

Author SHA1 Message Date
xiaoxia 3441b3fece fix: Canvas 播放黑屏二次修复 — FrameQueue 扩容到 600 + 按需解码限制 300 帧
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m52s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m2s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m6s
CI/CD Pipeline / Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Unit Tests (push) Successful in 13m54s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
根因:按需解码 decodeAroundPosition 调用 decodeSegment 时未传 maxFrames,
导致一次解码整个片段(~300帧)全部 push 进 maxSize=60 的队列,前 240 帧被淘汰,
只剩末尾帧(pts≈8~10s),getCurrentFrame(0) 找不到帧 → 黑屏 3~4 秒。

修复:
- FrameQueue maxSize: 60 → 600(覆盖约 20 秒 @30fps)
- decodeAroundPosition 中 decodeSegment 传入 maxFrames=300,
  限制单次按需解码帧数,避免远超队列容量的帧挤兑
2026-08-21 00:46:41 +08:00
xiaoxia b7be479ae7 fix: staging test_asset.spec.ts 适配 POST /assets 返回 410 (#1453)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 57s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m2s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 53s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m15s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m3s
CI/CD Pipeline / Unit Tests (push) Successful in 13m55s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m59s
CI/CD Pipeline / CI Gate (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-21 00:06:01 +08:00
xiaoxia 95c33f6604 fix: Canvas 内部分辨率不匹配导致画面变形 — 修复 ResizeObserver 未创建问题
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m18s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m30s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m48s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m4s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m5s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m18s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 3m42s
CI/CD Pipeline / Unit Tests (push) Successful in 14m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 6m3s
CI/CD Pipeline / CI Gate (push) Has been skipped
根因:ResizeObserver useEffect 依赖数组为空 [],组件挂载时 canPlay=false
导致 canvas 未渲染、canvasRef 为 null,effect 直接 return 后永远不会重新执行,
canvas 内部分辨率停留在浏览器默认值 300×150(横屏 2:1),而 CSS 显示为竖屏容器。

修复:
- useEffect 依赖改为 [useWebCodecs, canPlay],canPlay=true 时 canvas 已渲染,
  effect 重新执行并正确创建 ResizeObserver
- 创建 ResizeObserver 前立即通过 getBoundingClientRect 设置 canvas 初始像素分辨率,
  避免首帧使用默认 300×150 导致变形
- computeDrawRect 已正确基于 videoDimRef 做 letterbox 居中绘制,无需修改
2026-08-20 23:49:57 +08:00
xiaoxia 161c1a61b6 fix: HEVC 转码条件解耦 + 缩略图函数补全 + extra_meta 字段 (#1452)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m11s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m24s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m20s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m22s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 3m7s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m25s
CI/CD Pipeline / Unit Tests (push) Successful in 12m58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m59s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
AI Code Review / AI Code Review (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m35s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m4s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m56s
CI/CD Pipeline / CI Gate (pull_request) Successful in 8s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-20 21:41:01 +08:00
xiaoxia 8b30193f3e fix: Canvas 预览黑屏修复 - 增大帧缓冲 + 限制初始化预解码 + play 同步清空帧队列
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m53s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m21s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m17s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 3m12s
CI/CD Pipeline / Unit Tests (push) Failing after 12m58s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 5m37s
CI/CD Pipeline / CI Gate (push) Has been skipped
- FrameQueue maxSize 从 10 改为 60(约 2 秒 @30fps),给按需解码留出足够预缓冲空间
- 新增 MAX_INIT_FRAMES=60 常量,decodeSegment 支持 maxFrames 参数,初始化阶段只解码前 60 帧避免帧挤兑
- play() 重播时同步清空 frameQueue(之前只清 decodedSegments 标记没清帧缓冲)
- play() 启动渲染循环后立即触发 decodeAroundPosition,不等 200ms 节流延迟
2026-08-20 19:21:32 +08:00
xiaoxia 665228a58f refactor: 删除 uploadAsset,统一使用 uploadAssetDirect 直传
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m49s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m36s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m21s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m49s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m53s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 3m38s
CI/CD Pipeline / Unit Tests (push) Failing after 14m29s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 7m28s
CI/CD Pipeline / CI Gate (push) Has been skipped
前端:
- 删除 uploadAsset 函数(upload.ts)和 export(index.ts)
- 删除 UploadResult type export
- CloneModal/useCloneSubmit 改用 uploadAssetDirect
- DirectUploadCompleteResult 新增 url 字段
- 清理测试文件中 uploadAsset 引用

后端:
- DirectUploadCompleteResponse 新增 url 字段
- complete_direct_upload 返回 OSS 文件 URL
- 修复 FFmpeg 超时日志 300s → 900s
2026-08-20 16:22:56 +08:00
CI Bot 56d45c2d49 fix: 清理 create_asset 废弃后的 unused imports
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m0s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m50s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m3s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 57s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 删除 CreateAssetRequest import
- 删除 CreateAssetCommand/UseCase imports
- 删除 AssetStatus/ClassificationStatus imports
- 修复 ingest.py 日志信息 (300s → 900s)
2026-08-20 16:17:23 +08:00
CI Bot ab4a1b7262 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m45s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m49s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m0s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m20s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 3m6s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-08-20 08:07:14 +00:00
xiaoxia a39e5ea936 refactor: 清理前端 createAsset 死代码和未使用 import
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 56s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m40s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 删除 test/api/assets.test.ts 中 createAsset 的 import 和测试块
- 删除 GeneratePage.test.tsx 和 VoiceMaterialLibrary.test.tsx 中 createAsset mock
- 清理 voices/types.ts 和 voice-materials/types.ts 中 createAsset 注释
- 删除 useVoiceUpload.ts 中未使用的 IngestJob type import
2026-08-20 16:02:05 +08:00
CI Bot 2d0d8a2777 chore: 清理上传路径统一后的残留死代码
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
后端:
- create_asset 函数简化为仅返回 410,删除 46 行死代码
- 移除无用的依赖注入参数(asset_repository 等)

前端:
- 删除 createAsset 函数定义(assets.ts)
- 删除 createAsset 的 re-export(index.ts)

注: uploadAsset 函数保留(语音克隆 CloneModal 仍在使用)
2026-08-20 15:59:44 +08:00
CI Bot d3469d9799 refactor: 统一所有素材上传为单一 ingest 路径
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m22s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m18s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m10s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 5m40s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
前端修改:
1. useVoiceUpload.ts (voices): 删除 createAsset 调用,改为轮询 ingest_job_id
2. useVoiceUpload.ts (voice-materials): 同上,使用 result_asset_id 打标签

后端修改:
1. POST /assets 返回 410 Gone(已废弃,统一走 ingest-jobs)
2. 上传流程: uploadAssetDirect → completeDirectUpload → _submit_ingest_job → Worker

统一后的上传路径:
- 前端上传 OSS(直传)
- 调用 completeDirectUpload 创建 ingest job
- Worker 根据类型处理(视频转码/图片音频元数据提取)
- 创建 Asset 记录 → ready
- 前端轮询 job 状态,完成后刷新
2026-08-20 15:54:01 +08:00
CI Bot 068010b059 feat: 统一素材上传为单一 ingest 路径
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
后端改动:
1. POST /assets 拒绝视频类型(必须走 ingest-jobs)
2. HEVC 转码超时从 300s 改为 900s(支持大文件)
3. 添加 HDR→SDR 转换参数(colorspace/primaries/transfer=bt709)
4. 转码前检查磁盘空间(< 2GB 时拒绝任务)

前端无需修改:
- 当前 uploadAssetDirect 已统一走 ingest-jobs 流程
- completeDirectUpload → _submit_ingest_job → Worker 处理
- 视频上传后自动进入转码+入库流程
2026-08-20 15:48:17 +08:00
xiaoxia 231d5153bf fix: CSS 残留 6 处 aspect-ratio 16/9 改为 9/16
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m3s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m50s
CI/CD Pipeline / Unit Tests (push) Successful in 16m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
统一竖屏 9:16 比例,清理 generate.css 中所有残留的横屏比例:
- .xx-preview-video (894)
- .xx-smart-match-thumb (1519)
- .xx-cover-frame-placeholder (2147)
- .xx-cover-upload-area (2250)
- .xx-cover-preview-box (2311)
- .xx-preview-video-wrapper .xx-preview-video (2669)
2026-08-20 15:30:29 +08:00
xiaoxia f4af6ec8b7 fix: 删除用 mp4box.js 维度数据设置 videoDimRef 的代码
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m44s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m50s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 49s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m9s
CI/CD Pipeline / Unit Tests (push) Successful in 17m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 36m33s
CI/CD Pipeline / CI Gate (push) Has been skipped
- 删除 decoder configure 成功后的 videoDimRef 设置
- 删除 init 函数中的 videoDimRef 设置
- 唯一可靠的尺寸来源改为 VideoDecoder output 回调从 VideoFrame 获取的真实帧尺寸
2026-08-20 14:37:07 +08:00
xiaoxia 6a3f4e4127 fix: 统一竖屏 9:16 比例
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 58s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m17s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m59s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 48s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- generate.css: 7 处 aspect-ratio 从 16/9 改为 9/16
- generate.css: .xx-preview-video max-height 400px→600px
- generate.css: .xx-preview-video-wrapper max-width→max-height
- PreviewVideoPanel.tsx: fallback 从 16:9→9:16
- UploadCoverPicker.tsx: 提示文字从 建议16:9→建议9:16
2026-08-20 14:29:05 +08:00
CI Bot ec3ba19342 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 58s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m22s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m47s
CI/CD Pipeline / Unit Tests (push) Successful in 12m6s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-08-20 06:15:28 +00:00
CI Bot 26728dfffb fix: HEVC 转码支持竖屏视频自动旋转
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m3s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m34s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 转码前用 ffprobe 检测 rotation 元数据
- rotation=90/270 时添加 transpose=1 物理旋转画面
- 添加 -metadata:s:v:0 rotate=0 清除旋转元数据
- 添加 -level 4.0 确保 H.264 level 支持 1080p
- scale 滤镜在旋转之后应用

修复前端 VideoDecoder 解码竖屏 HEVC 视频报错的问题
2026-08-20 14:11:34 +08:00
xiaoxia cb903e3bd6 fix: 移除 rotation 元数据检测,从 VideoFrame 获取实际尺寸
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 删除 extractVideoRotation 函数
- 删除 demuxSegment 和 decodeSegment 中的旋转检测+宽高交换代码
- 删除 decoder.configure() 中的 codedWidth/codedHeight 参数
- 在 VideoDecoder output 回调中从第一帧获取实际帧尺寸
- 核心思路:不再依赖 mp4box.js 的 track_width/track_height 和 rotation 元数据,
  改为从 VideoDecoder 实际输出的 VideoFrame 获取真实尺寸
2026-08-20 14:11:13 +08:00
xiaoxia b447ad84ea fix: vitest 全局超时从 5s 提到 15s,防止 CI 高负载偶发超时 (#1451)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m43s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m3s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m39s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-20 14:06:00 +08:00
xiaoxia 2356f12f11 fix: 修复 VideoDecoder flush timeout 解码器超时卡死
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m0s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m11s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 51s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m24s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m8s
CI/CD Pipeline / Unit Tests (push) Successful in 11m8s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m31s
CI/CD Pipeline / CI Gate (push) Has been skipped
- decoder.decode(chunk) 改为 await,捕获异步 rejection
- 添加 decodeErrors 计数,连续 3 次解码失败跳过当前片段
- flush 之前检查 decoder.state,只在 configured 状态才执行
- 避免在 error/closed 状态调用 flush 导致卡死
2026-08-20 13:31:57 +08:00
xiaoxia 3c42541ff7 fix: 解析 MP4 rotation 元数据,修复竖屏视频显示为横屏
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m22s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m58s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m16s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 50s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m13s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m32s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m57s
CI/CD Pipeline / Unit Tests (push) Successful in 11m58s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
- 新增 extractVideoRotation 函数,从 tkhd box 解析旋转角度
- demuxSegment 中检测 90°/270° 旋转,交换宽高
- decodeSegment 中同样检测旋转,确保 configure 使用正确尺寸
- 修复 iPhone 竖屏视频在竖屏容器中显示为一小块横屏画面的问题
2026-08-20 13:08:24 +08:00
xiaoxia 85afadd166 feat: 上传时自动检测 HEVC 编码并转码为 1080p H.264 (#1449)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m27s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m35s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m44s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m33s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 2m42s
CI/CD Pipeline / Unit Tests (push) Successful in 11m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-08-20 12:10:15 +08:00
xiaoxia 634d05c010 fix: 修复 AI Code Review 阻塞级问题 v3(useCanvasPlayer 解码策略+资源泄漏+useStep6Cover 轮询超时) (#1450)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m30s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m54s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m11s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 59s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m40s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m39s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-20 12:01:01 +08:00
张宏杰 355431b867 feat: decodeSegment 增加解码进度日志和 flush 超时保护
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m6s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m30s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m20s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Unit Tests (push) Successful in 11m38s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
- 记录实际 decode 的 sample 数量、跳过数量、decoder 状态
- flush 增加 10 秒超时保护,防止无限卡住
- configure 成功后立即清除 isBuffering 状态,让 UI 不再显示'准备中'
2026-08-19 23:58:23 +08:00
张宏杰 041cec8670 fix: findCodecConfig 重写,正确处理 stsd 和容器 box 递归
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 36s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 43s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Integration Tests (push) Successful in 3m12s
CI/CD Pipeline / Unit Tests (push) Successful in 11m10s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
- 新增 MP4_CONTAINER_TYPES 常量,包含 trak/mdia/minf/stbl 等标准容器
- stsd 特殊处理:额外 8 字节头(version/flags + entry_count),从 offset+16 开始
- VisualSampleEntry 保持 offset+8+78(78 字节固定字段)
- 删除 fallback 双重搜索逻辑,改为明确的容器类型判断
2026-08-19 23:24:00 +08:00
张宏杰 472f371d2f fix: VisualSampleEntry 添加 fallback 搜索,兼容不同 MP4 结构
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 56s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m36s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 46s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 11m42s
CI/CD Pipeline / Integration Tests (push) Successful in 4m12s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
- 先尝试 offset+8+78(标准 VisualSampleEntry 结构)
- 未找到则 fallback 到 offset+8(兼容非标准 MP4)
- 添加调试日志追踪搜索过程
2026-08-19 22:51:25 +08:00
张宏杰 709d65ce52 fix: VisualSampleEntry 跳过78字节固定字段,修复 hvcC 提取位置
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m21s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m6s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m25s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 44s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Unit Tests (push) Successful in 11m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m16s
CI/CD Pipeline / CI Gate (push) Has been skipped
- findCodecConfigRecursive 区分 VisualSampleEntry 和普通容器 box
- avc1/avc3/hvc1/hev1 前 78 字节是固定字段(ISO 14496-12),子 box 从 offset+8+78 开始
- 普通容器 box(moov/trak/mdia/minf/stbl)仍从 offset+8 开始递归
- 修复 HEVC description 提取失败导致的黑屏问题
2026-08-19 22:38:01 +08:00
张宏杰 ae1b9f8ddb fix: findCodecConfig 改为纯递归搜索,修复 HEVC description 提取失败
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 47s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m22s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m55s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 44s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m20s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Integration Tests (push) Successful in 3m17s
CI/CD Pipeline / Unit Tests (push) Successful in 12m10s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
根因:旧 findCodecConfig 对 container box 的 fullbox 检测逻辑错误,
导致递归搜索时跳过了子 box 的起始位置,永远找不到 hvcC/avcC。

修复:
- 替换为 findCodecConfigRecursive,纯递归搜索所有嵌套 box
- 不区分 fullbox/非 fullbox,统一从 offset+8 开始搜索子 box
- 返回 hvcC/avcC 的内容(不含 8 字节 box header),符合 WebCodecs 要求
- 添加日志追踪找到的 box 类型和位置
2026-08-19 22:18:26 +08:00
张宏杰 1c9903574a fix: 修复预览播放器初始化流程断裂 + 确认生成改为直接创建任务
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 59s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m16s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m24s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m5s
CI/CD Pipeline / Unit Tests (push) Successful in 11m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m11s
CI/CD Pipeline / CI Gate (push) Has been skipped
修复一:useCanvasPlayer 初始化 useEffect 中,loadSegment 内的 setState
触发重渲染 → cleanup 调用 destroy() → isDestroyedRef=true → demuxSegment
永远不执行。改用局部 cancelled 变量替代 isDestroyedRef 做异步流程控制,
添加 6 处调试日志追踪完整初始化流程。

修复二:确认生成流程从 TODO 占位改为直接调用 createGenerationTask,
传入 template_id/asset_ids/output_width/output_height/cover_url/
custom_title/title_config/video_ratio 等完整参数。

修复三:扩展 CreateGenerationTaskRequest 类型,新增 output_width/
output_height/cover_url/custom_title/duration/video_ratio/title_config
可选字段,title_ids/voice_ids 改为可选。
2026-08-19 21:52:44 +08:00
张宏杰 cfedc06df0 refactor: 清理预览旧架构 — 删除 confirmGeneration 调用 + previewTaskId 链路
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m5s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m17s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m54s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m11s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m14s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Unit Tests (push) Successful in 13m45s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 5m1s
CI/CD Pipeline / CI Gate (push) Has been skipped
- useGenerateVideo: 删除 confirmGeneration 导入和调用,改为 TODO 占位
- GeneratePage: 删除 previewTaskId 硬编码空字符串
- types: 删除 previewTaskId 字段定义
- api/generation: 删除 confirmGeneration 导出和 ConfirmGeneration 类型导出
- createPreview/getPreviewStatus 保留(useStep6Cover 封面流程仍在使用)
2026-08-19 20:45:18 +08:00
张宏杰 4429784a79 refactor: 删除 createPreview 调用,确认生成不再触发后端预览渲染
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m22s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m39s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m26s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 删除 createPreview API 导入和调用
- 预览已改为前端实时播放(Canvas + WebCodecs)
- taskId 改为直接使用 props.previewTaskId
- 移除 useCallback 依赖中的 selectedTemplate
2026-08-19 20:35:07 +08:00
张宏杰 aa36e63591 refactor: 清理 descriptionCache 兜底代码
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
删除 4 处 descriptionCache 相关代码:
- 删除 useRef 声明
- 删除缓存兜底块
- 删除缓存写入
- 删除缓存清理

descriptionCache 不再需要,description 直接由 extractCodecDescription 提供
2026-08-19 20:33:16 +08:00
张宏杰 c3373de4f3 fix: findCodecConfig 动态检测 fullbox,修复 container box 偏移计算错误
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m8s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
问题根因:
- 代码假设所有 container box (trak/mdia/minf/stbl) 都是 fullbox
- 但实际 MP4 文件中这些 box 可能不是 fullbox,content 从 offset+8 开始而非 offset+12
- 导致递归查找时偏移错误,永远找不到 stsd → hvcC/avcC

修复方案:
- 动态检测 fullbox:检查 offset+8 处的 version/flags 字节是否为 0
- 如果是 fullbox,content 从 offset+12 开始;否则从 offset+8 开始
- 在 containerBoxes 列表中添加 'moov'
2026-08-19 20:29:46 +08:00
张宏杰 60d95e64ab fix: HEVC description偏移修正+调试日志 (#1450)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m0s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m41s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 45s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m51s
CI/CD Pipeline / Unit Tests (push) Successful in 14m48s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 5m41s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-19 20:06:44 +08:00
张宏杰 7775d5d118 fix: setExtractionOptions 改为 nbSamples: Infinity (#1449)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 58s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m12s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 43s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 48s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Unit Tests (push) Successful in 11m36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 5m49s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-19 19:53:35 +08:00
40 changed files with 1083 additions and 719 deletions
+8 -53
View File
@@ -18,7 +18,6 @@ from app.schemas.asset import (
BatchMarkRequest,
BatchOperationResponse,
BatchTagRequest,
CreateAssetRequest,
ListAssetsResponse,
SmartMatchItem,
SmartMatchRequest,
@@ -29,11 +28,6 @@ from app.schemas.asset import (
from app.schemas.tag import TagAssetsRequest
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from packages.application import (
CreateAssetCommand,
CreateAssetUseCase,
)
from packages.domain import AssetStatus, ClassificationStatus
from packages.domain.smart_match import smart_select_assets
logger = logging.getLogger(__name__)
@@ -677,51 +671,12 @@ def untag_asset(
@router.post("", response_model=AssetResponse)
def create_asset(
request: CreateAssetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
project_repository: Any = Depends(get_project_repository),
) -> AssetResponse:
# 先获取素材库,用于推导 project_id(前端可能不传)
library = asset_library_repository.get(request.library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
# project_id 自动推导:优先用请求值,否则从 library 关联的项目获取
project_id = request.project_id or library.project_id
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="Access denied to project")
# 确保 library 和 project 归属一致
if library.project_id != project_id:
raise HTTPException(status_code=400, detail="AssetLibrary does not belong to the specified project")
use_case = CreateAssetUseCase(asset_repository)
item = use_case.execute(
CreateAssetCommand(
project_id=project_id,
library_id=request.library_id,
name=request.name,
storage_key=request.storage_key,
mime_type=request.mime_type,
metadata=request.metadata,
file_size=request.file_size,
thumbnail_url=request.thumbnail_url,
duration=request.duration,
width=request.width,
height=request.height,
fps=request.fps,
codec=request.codec,
status=AssetStatus(request.status),
classification_status=ClassificationStatus(request.classification_status),
quality_score=request.quality_score,
uploaded_by_user_id=authenticated_user.user.id,
)
def create_asset() -> None:
"""
已废弃接口。
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。
"""
raise HTTPException(
status_code=410,
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。",
)
return _to_asset_response(item)
+2 -1
View File
@@ -206,6 +206,7 @@ async def complete_direct_upload(
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
url=storage_service.get_url(normalized_key),
)
job = _submit_ingest_job(
@@ -215,7 +216,7 @@ async def complete_direct_upload(
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
)
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id, url=storage_service.get_url(normalized_key))
@router.post(
+1
View File
@@ -39,6 +39,7 @@ class DirectUploadCompleteResponse(BaseModel):
ingest_job_id: str
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
url: str = Field(default="", description="Public URL of uploaded file")
class UploadAssetResponse(BaseModel):
+31 -39
View File
@@ -178,7 +178,7 @@ test.describe("素材库流程", () => {
expect(kinds).toContain("image")
})
test("创建素材记录", async ({ request }) => {
test("创建素材记录 — POST /assets 已废弃返回 410", async ({ request }) => {
const { headers, userId } = await createAuthedUser(request, "asset-create")
const projectId = await createProject(request, headers, Date.now().toString())
@@ -194,7 +194,7 @@ test.describe("素材库流程", () => {
expect(lib.ok()).toBeTruthy()
const libData = await lib.json()
// 创建素材记录
// POST /assets 已废弃,应返回 410 Gone
const response = await request.post(`${apiBase}/assets`, {
headers,
data: {
@@ -210,16 +210,9 @@ test.describe("素材库流程", () => {
},
})
expect(
response.ok(),
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy()
expect(response.status()).toBe(410)
const data = await response.json()
expect(data.id, "应返回素材 ID").toBeTruthy()
expect(data.name).toContain("test_video")
expect(data.mime_type).toBe("video/mp4")
expect(data.library_id).toBe(libData.id)
expect(data.error?.code).toBe("HTTP_410")
})
test("列出素材", async ({ request }) => {
@@ -232,51 +225,50 @@ test.describe("素材库流程", () => {
data: {
project_id: projectId,
name: `List Lib ${Date.now()}`,
kind: "video",
kind: "image",
},
})
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
const libData = await lib.json()
// 创建 2 个素材
await request.post(`${apiBase}/assets`, {
// 通过 multipart upload 上传 2 个小图片作为测试素材
// 创建一个 1x1 的 PNG buffer
const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
)
await request.post(`${apiBase}/upload`, {
headers,
data: {
multipart: {
project_id: projectId,
library_id: libData.id,
name: `clip_a_${Date.now()}.mp4`,
storage_key: `uploads/e2e/clip_a.mp4`,
mime_type: "video/mp4",
status: "ready",
uploaded_by_user_id: userId,
file: { name: "clip_a.png", mimeType: "image/png", buffer: tinyPng },
},
})
await request.post(`${apiBase}/assets`, {
await request.post(`${apiBase}/upload`, {
headers,
data: {
multipart: {
project_id: projectId,
library_id: libData.id,
name: `clip_b_${Date.now()}.mp4`,
storage_key: `uploads/e2e/clip_b.mp4`,
mime_type: "video/mp4",
status: "ready",
uploaded_by_user_id: userId,
file: { name: "clip_b.png", mimeType: "image/png", buffer: tinyPng },
},
})
// 列出素材
const response = await request.get(`${apiBase}/assets`, {
headers,
params: { library_id: libData.id },
})
// 列出素材(可能需要等待 ingest job 完成)
let items: any[] = []
for (let i = 0; i < 10; i++) {
const response = await request.get(`${apiBase}/assets`, {
headers,
params: { library_id: libData.id },
})
expect(response.ok(), `列出素材应返回 2xx`).toBeTruthy()
const data = await response.json()
items = data.items || []
if (items.length >= 2) break
await new Promise((r) => setTimeout(r, 2000))
}
expect(
response.ok(),
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy()
const data = await response.json()
const items = data.items || []
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
})
-12
View File
@@ -60,18 +60,6 @@ export const smartMatchAssets = async (libraryId: string): Promise<{ items: Asse
return response.data
}
/** 创建素材(上传文件后调用,附带 metadata) */
export const createAsset = async (data: {
library_id: string
name: string
storage_key: string
mime_type: string
metadata?: AssetMetadata
}): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data)
return response.data
}
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
+1 -3
View File
@@ -13,7 +13,6 @@ export type {
ClassificationJob,
AssetDiagnosis,
BatchOperationResult,
UploadResult,
DirectUploadPrepareResult,
DirectUploadCompleteResult,
} from "./types"
@@ -34,14 +33,13 @@ export {
getAssets,
getAssetsByKind,
smartMatchAssets,
createAsset,
updateAsset,
updateAssetReviewStatus,
deleteAsset,
} from "./assets"
// 上传
export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
export { prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
// 任务
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
+1
View File
@@ -135,4 +135,5 @@ export interface DirectUploadPrepareResult {
export interface DirectUploadCompleteResult {
storage_key: string
ingest_job_id: string
url: string
}
+1 -10
View File
@@ -3,16 +3,7 @@
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
/** 表单上传素材(小文件) */
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
const response = await apiClient.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 30 * 60 * 1000,
})
return response.data
}
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
/** 预签名直传准备 */
export const prepareDirectUpload = async (data: {
-4
View File
@@ -3,13 +3,9 @@ export type {
CreatePreviewRequest,
CreatePreviewResponse,
PreviewTaskResponse,
ConfirmGenerationRequest,
ConfirmGenerationResponse,
ConfirmGenerationTaskItem,
} from "./types"
export { createPreview, getPreviewStatus } from "./preview"
export { confirmGeneration } from "./confirm"
export { generateCover } from "./cover"
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
+25 -2
View File
@@ -57,8 +57,31 @@ export interface TaskListResponse {
export interface CreateGenerationTaskRequest {
template_id: string
asset_ids: string[]
title_ids: string[]
voice_ids: string[]
title_ids?: string[]
voice_ids?: string[]
/** 输出视频宽度 */
output_width?: number
/** 输出视频高度 */
output_height?: number
/** 自定义封面图片 URL */
cover_url?: string
/** 自定义视频标题 */
custom_title?: string
/** 视频时长(秒) */
duration?: number
/** 视频宽高比,如 "9:16" */
video_ratio?: string
/** 标题烧录配置 */
title_config?: {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
}
/** 创建生成任务响应(对齐后端 GenerationTaskResponse */
@@ -1,7 +1,8 @@
import React, { useState, useCallback, useRef, useEffect } from "react"
import { Modal, Button } from "@/components/ui"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAsset } from "@/api/assets"
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
import { validateFile } from "./utils"
import { useAudioRecorder } from "./hooks/useAudioRecorder"
@@ -181,9 +182,15 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
})
}
const formData = new FormData()
formData.append("file", fileToUpload)
const uploadResult = await uploadAsset(formData)
// 获取默认项目和素材库
const project = await getOrCreateDefaultProject()
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
// 直传到 OSS
const uploadResult = await uploadAssetDirect({
file: fileToUpload,
library_id: library.id,
})
// 组件已卸载则中止后续操作
if (!isMountedRef.current) return
@@ -1,6 +1,6 @@
import { useRef, useCallback, useEffect } from "react"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { VoiceClone } from "@/api/voice-clone"
@@ -62,15 +62,15 @@ export function useCloneSubmit({
})
}
// 获取默认项目和素材库(后端 /upload 接口必填)
// 获取默认项目和素材库
const project = await getOrCreateDefaultProject()
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
const formData = new FormData()
formData.append("file", fileToUpload)
formData.append("project_id", project.id)
formData.append("library_id", library.id)
const uploadResult = await uploadAsset(formData)
// 直传到 OSS
const uploadResult = await uploadAssetDirect({
file: fileToUpload,
library_id: library.id,
})
// 阶段 2:克隆
setPhase("cloning")
@@ -171,7 +171,6 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
generateCount,
previewTaskId: "",
})
/* ================================================================
@@ -250,9 +250,17 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── Canvas ResizeObserver ──
const canvasContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!useWebCodecs || !canPlay) return
const container = canvasContainerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
// 立即设置一次 canvas 像素分辨率,避免默认 300×150 导致首帧变形
const initRect = container.getBoundingClientRect()
if (initRect.width > 0 && initRect.height > 0) {
const dpr = window.devicePixelRatio || 1
canvas.width = initRect.width * dpr
canvas.height = initRect.height * dpr
}
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
@@ -264,7 +272,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
})
ro.observe(container)
return () => ro.disconnect()
}, [])
}, [useWebCodecs, canPlay])
// ── 未就绪 ──
if (!ready || !assets.length) {
@@ -193,7 +193,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
titleSettings,
voiceAudioUrl,
}) => {
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
return (
<div className="xx-generate-preview">
@@ -30,7 +30,7 @@ export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl,
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text"></span>
<span className="xx-cover-upload-hint"> JPG / PNG 16:9 </span>
<span className="xx-cover-upload-hint"> JPG / PNG 9:16 </span>
</div>
)}
<input
+7 -7
View File
@@ -891,7 +891,7 @@
/* ── 视频预览 ── */
.xx-preview-video {
aspect-ratio: 16 / 9;
aspect-ratio: 9 / 16;
max-height: 400px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
@@ -1516,7 +1516,7 @@
.xx-smart-match-thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
aspect-ratio: 9 / 16;
background: #f1f5f9;
overflow: hidden;
}
@@ -2144,7 +2144,7 @@
}
.xx-cover-frame-placeholder {
aspect-ratio: 16 / 9;
aspect-ratio: 9 / 16;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-md);
display: flex;
@@ -2247,7 +2247,7 @@
}
.xx-cover-upload-area {
aspect-ratio: 16 / 9;
aspect-ratio: 9 / 16;
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
display: flex;
@@ -2308,7 +2308,7 @@
.xx-cover-preview-box {
position: relative;
aspect-ratio: 16 / 9;
aspect-ratio: 9 / 16;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
overflow: hidden;
@@ -2666,7 +2666,7 @@
.xx-preview-video-wrapper .xx-preview-video {
max-width: 300px;
width: 100%;
aspect-ratio: 16 / 9;
aspect-ratio: 9 / 16;
margin-bottom: 0;
}
@@ -2772,7 +2772,7 @@
.xx-video-thumb {
position: relative;
aspect-ratio: 16 / 9;
aspect-ratio: 9 / 16;
background: var(--bg-tertiary);
overflow: hidden;
}
@@ -19,8 +19,6 @@ export interface UseGenerateVideoProps {
autoSubtitles: boolean
bgm: boolean
generateCount: number
/** 预览任务的 task_id(用于新确认生成 API */
previewTaskId: string
}
/** 生成阶段 */
@@ -8,16 +8,49 @@ import { useRef, useCallback, useEffect, useState } from "react"
import { createFile } from "mp4box"
import type { Movie, Sample } from "mp4box"
// ── 常量 ──
/** 初始化预解码最大帧数(约 2 秒 @30fps),后续帧通过 decodeAroundPosition 按需解码 */
const MAX_INIT_FRAMES = 60
// ── MP4 Box 解析辅助函数 ──
/** 在指定范围内查找 avcC / hvcC box,返回其数据 */
function findCodecConfig(buffer: ArrayBuffer, start: number, end: number): ArrayBuffer | undefined {
// MP4 标准容器 box 列表(递归时会进入这些 box 内部搜索子 box)
const MP4_CONTAINER_TYPES = [
"moov",
"trak",
"mdia",
"minf",
"stbl",
"stsd",
"dinf",
"edts",
"udta",
"meta",
"tref",
]
const VISUAL_SAMPLE_ENTRY_TYPES = ["avc1", "avc3", "hvc1", "hev1"]
/**
* 递归搜索 box 树,找到 hvcC 或 avcC box 并返回其配置数据(不含 8 字节 box header
*
* MP4 box 嵌套结构:moov → trak → mdia → minf → stbl → stsd → hev1 → hvcC
* - 普通容器 box 从 offset+8 开始递归
* - stsd 有额外 8 字节头(version/flags 4B + entry_count 4B),从 offset+16 开始
* - VisualSampleEntry (avc1/avc3/hvc1/hev1) 前 78 字节是固定字段,子 box 从 offset+8+78 开始
*/
function findCodecConfigRecursive(
buffer: ArrayBuffer,
start: number,
end: number,
): ArrayBuffer | undefined {
const view = new DataView(buffer)
let offset = start
while (offset < end - 8) {
const size = view.getUint32(offset)
if (size < 8) break
if (size < 8 || offset + size > end) break
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
@@ -25,37 +58,28 @@ function findCodecConfig(buffer: ArrayBuffer, start: number, end: number): Array
view.getUint8(offset + 7),
)
// 容器 boxfullbox 多 4 字节
const containerBoxes = ["trak", "mdia", "minf", "stbl"]
if (containerBoxes.includes(type)) {
// fullbox: size(4) + type(4) + version(1) + flags(3) = 12 bytes header
const contentStart = offset + 12
const result = findCodecConfig(buffer, contentStart, offset + size)
if (result) return result
} else if (type === "stsd") {
// SampleDescriptionBox 是 fullbox: 8 header + 4 version/flags + 4 entry_count
const entryCount = view.getUint32(offset + 12)
let entryOffset = offset + 16
for (let i = 0; i < entryCount && entryOffset < offset + size; i++) {
const entrySize = view.getUint32(entryOffset)
// 视觉样本条目: 8 header + 6 reserved + 2 data_ref_index + remaining
// 子 box 从 entryOffset + 16 + 62 开始 (skip reserved + data_ref_index + predefined)
// 实际结构: 8(header) + 6(reserved) + 2(data_ref_index) + 16(predefined+reserved) + 2(width) + 2(height) + ...
// box entryOffset + 8 + 6 + 2 + 16 + 2 + 2 + 2 + 2 + 4 + 2 + 2 + 2 + 2 = entryOffset + 78
// 更简单的做法:扫描 entry 内的子 box
const entryEnd = entryOffset + entrySize
const subBoxStart = entryOffset + 8 + 70 // VisualSampleEntry 固定字段共 70 字节
const result = findCodecConfig(buffer, subBoxStart, entryEnd)
if (result) return result
entryOffset += entrySize
}
} else if (type === "avcC" || type === "hvcC") {
// 找到目标 box,返回完整 box(含 header
// 返回完整 box(含 size + type header),WebCodecs HEVC decoder 需要
return buffer.slice(offset, offset + size)
// 找到目标 codec 配置 box,返回内容(不含 8 字节 header
if (type === "avcC" || type === "hvcC") {
console.log("[findCodecConfig] Found", type, "at offset", offset, "size", size)
return buffer.slice(offset + 8, offset + size)
}
// VisualSampleEntry:前 78 字节是固定字段,子 box 在 78 字节之后
if (VISUAL_SAMPLE_ENTRY_TYPES.includes(type)) {
const childResult = findCodecConfigRecursive(buffer, offset + 8 + 78, offset + size)
if (childResult) return childResult
}
// stsd:额外 8 字节头(version/flags 4B + entry_count 4B),子 box 在 offset+16
else if (type === "stsd") {
const childResult = findCodecConfigRecursive(buffer, offset + 8 + 8, offset + size)
if (childResult) return childResult
}
// 标准容器 boxoffset+8 开始递归
else if (MP4_CONTAINER_TYPES.includes(type)) {
const childResult = findCodecConfigRecursive(buffer, offset + 8, offset + size)
if (childResult) return childResult
}
if (size === 0) break
offset += size
}
return undefined
@@ -197,7 +221,11 @@ export function useCanvasPlayer(
// ── 内部引用 ──
const decoderRef = useRef<VideoDecoder | null>(null)
const frameQueueRef = useRef(new FrameQueue(10))
const frameQueueRef = useRef(new FrameQueue(600))
/** 已解码的片段索引集合,用于按需解码(先标记防重入,失败时移除允许重试) */
const decodedSegmentsRef = useRef(new Set<number>())
/** 解码代数计数器,seek 时递增以作废正在进行的异步解码 */
const decodeGenerationRef = useRef(0)
const rafRef = useRef<number>(0)
const playStartRef = useRef<number>(0)
const playStartOffsetRef = useRef<number>(0)
@@ -206,7 +234,6 @@ export function useCanvasPlayer(
const videoDimRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 })
const isDestroyedRef = useRef(false)
const lastProgressUpdateRef = useRef<number>(0)
const descriptionCache = useRef<Map<string, ArrayBuffer>>(new Map())
// 计算总时长
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
@@ -246,7 +273,15 @@ export function useCanvasPlayer(
view.getUint8(offset + 7),
)
if (type === "moov") {
return findCodecConfig(buffer, offset + 8, offset + size)
const result = findCodecConfigRecursive(buffer, offset + 8, offset + size)
console.log("[useCanvasPlayer] extractCodecDescription:", {
moovOffset: offset,
moovSize: size,
searchRange: [offset + 8, offset + size],
found: !!result,
resultByteLength: result?.byteLength,
})
return result
}
if (size === 0) break
offset += size
@@ -312,15 +347,7 @@ export function useCanvasPlayer(
}
// 提取编解码器配置数据(HEVC 必需,H.264 也需要)
let description = extractCodecDescription(buffer)
// 如果当前分片没有 description,尝试从缓存获取
if (!description) {
for (const cached of descriptionCache.current.values()) {
description = cached
break
}
}
const description = extractCodecDescription(buffer)
// ✅ 如果 description 缺失,无法解码 HEVC
if (!description) {
@@ -333,9 +360,6 @@ export function useCanvasPlayer(
return
}
// 缓存 description 供后续分片使用
descriptionCache.current.set(segment.assetId, description)
meta = {
assetId: segment.assetId,
videoUrl: segment.videoUrl,
@@ -352,7 +376,7 @@ export function useCanvasPlayer(
// 提取所有 samples
mp4File.setExtractionOptions(videoTrack.id ?? 1, null, {
nbSamples: videoTrack.nb_samples || 10000,
nbSamples: Infinity, // 提取所有 sample
})
mp4File.start()
}
@@ -429,7 +453,7 @@ export function useCanvasPlayer(
// ── 初始化 VideoDecoder 并解码指定片段 ──
const decodeSegment = useCallback(
async (_buffer: ArrayBuffer, meta: SegmentMeta): Promise<void> => {
async (_buffer: ArrayBuffer, meta: SegmentMeta, maxFrames?: number): Promise<void> => {
if (isDestroyedRef.current) return
let decoderReady = false
@@ -437,6 +461,13 @@ export function useCanvasPlayer(
// 配置解码器(每个片段可能需要不同的 codec/分辨率)
const decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
// 从第一帧获取实际尺寸
if (videoDimRef.current.width === 0 || videoDimRef.current.height === 0) {
videoDimRef.current = { width: frame.codedWidth, height: frame.codedHeight }
console.log(
`[useCanvasPlayer] Actual frame size: ${frame.codedWidth}x${frame.codedHeight}`,
)
}
const localTime = frame.timestamp / 1_000_000
const globalTime = localTime + meta.globalStartTime
frameQueueRef.current.push({
@@ -461,17 +492,12 @@ export function useCanvasPlayer(
try {
await decoder.configure({
codec: meta.codec,
codedWidth: meta.videoWidth,
codedHeight: meta.videoHeight,
...(meta.description ? { description: meta.description } : {}),
})
decoderRef.current = decoder
decoderReady = true
// 更新视频尺寸(用于 aspect ratio
if (meta.videoWidth > 0 && meta.videoHeight > 0) {
videoDimRef.current = { width: meta.videoWidth, height: meta.videoHeight }
}
// 标记缓冲结束,让 UI 开始渲染
setState((s) => ({ ...s, isBuffering: false }))
} catch (err) {
console.error("[useCanvasPlayer] Decoder configure failed for segment:", err)
return
@@ -481,17 +507,32 @@ export function useCanvasPlayer(
// 使用 demuxSegment 中已提取并过滤的 samples(前端切片)
const samplesCollected = meta.samples
console.log(
`[useCanvasPlayer] Segment ${meta.assetId}: ${samplesCollected.length} samples to decode`,
)
if (samplesCollected.length === 0) {
console.warn("[useCanvasPlayer] No samples to decode for segment", meta.assetId)
return
}
// 送入解码器
let decodedCount = 0
let skippedCount = 0
let decodeErrors = 0
for (const sample of samplesCollected) {
if (!sample.data || isDestroyedRef.current) continue
if (!sample.data || isDestroyedRef.current) {
skippedCount++
continue
}
if (decoder.state === "closed") break
// 初始化阶段限制解码帧数,避免帧缓冲溢出
if (maxFrames && decodedCount >= maxFrames) {
console.log(
`[useCanvasPlayer] Segment ${meta.assetId}: init decode limited to ${maxFrames} frames`,
)
break
}
if (!sample.data) continue
const chunk = new EncodedVideoChunk({
type: sample.is_sync ? "key" : "delta",
timestamp: ((sample.cts ?? 0) / (meta.timescale || 90000)) * 1_000_000,
@@ -500,22 +541,87 @@ export function useCanvasPlayer(
})
try {
decoder.decode(chunk)
await decoder.decode(chunk) // 修复:await 捕获异步错误
decodedCount++
} catch (e) {
console.warn("[useCanvasPlayer] Decode chunk error:", e)
decodeErrors++
console.warn(`[useCanvasPlayer] Decode chunk error (${decodeErrors}):`, e)
// 连续 3 次解码失败,放弃当前片段
if (decodeErrors >= 3) {
console.error("[useCanvasPlayer] Too many decode errors, aborting segment")
break
}
}
}
console.log(
`[useCanvasPlayer] Segment ${meta.assetId}: decoded ${decodedCount}, skipped ${skippedCount}, errors ${decodeErrors}, decoder.state=${decoder.state}`,
)
// flush 确保所有帧输出
try {
await decoder.flush()
} catch (e) {
console.warn("[useCanvasPlayer] Decoder flush error:", e)
// flush 仅在解码器状态正常时执行
if (decoder.state === "configured") {
try {
await decoder.flush()
console.log(`[useCanvasPlayer] Segment ${meta.assetId}: flush complete`)
} catch (e) {
console.warn("[useCanvasPlayer] Decoder flush error:", e)
}
}
},
[],
)
/**
* 按需解码当前播放位置 ±1 个片段。
* 在渲染循环中定期调用,避免一次性解码所有片段导致环形缓冲区溢出丢帧。
* 使用"先标记再解码"模式防止并发重复解码,失败时移除标记允许重试。
*/
const decodeAroundPosition = useCallback(
async (currentTime: number) => {
const metas = segmentMetaRef.current
if (!metas || metas.length === 0) return
// 记录当前代数,seek 后代数变化则中止
const gen = decodeGenerationRef.current
let targetIdx = -1
let acc = 0
for (let i = 0; i < metas.length; i++) {
const dur = metas[i].globalEndTime - metas[i].globalStartTime
if (currentTime < acc + dur) {
targetIdx = i
break
}
acc += dur
}
if (targetIdx === -1) targetIdx = metas.length - 1
for (
let i = Math.max(0, targetIdx - 1);
i <= Math.min(metas.length - 1, targetIdx + 1);
i++
) {
// seek 已作废当前解码任务
if (decodeGenerationRef.current !== gen) return
if (decodedSegmentsRef.current.has(i)) continue
const meta = metas[i]
const buffer = segmentDataRef.current.get(meta.assetId)
if (!buffer) continue
// 先标记为解码中,防止下一帧渲染时重复发起解码
decodedSegmentsRef.current.add(i)
try {
await decodeSegment(buffer, meta, 300)
} catch (e) {
// 解码失败则移除标记,允许后续重试
decodedSegmentsRef.current.delete(i)
console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e)
}
// await 后再次检查代数,seek 期间不更新标记
if (decodeGenerationRef.current !== gen) return
}
},
[decodeSegment],
)
// ── 标题绘制 ──
const drawTitle = useCallback(
(
@@ -617,6 +723,8 @@ export function useCanvasPlayer(
const elapsed = (performance.now() - playStartRef.current) / 1000
const currentTime = Math.min(playStartOffsetRef.current + elapsed, totalDuration)
// getCurrentFrame 返回 FrameQueue 内部引用,帧生命周期由 FrameQueue 管理
// push 淘汰旧帧时 close、clear 时全部 close),渲染层不应 close
const frame = frameQueueRef.current.getCurrentFrame(currentTime)
ctx.clearRect(0, 0, canvas.width, canvas.height)
@@ -640,6 +748,8 @@ export function useCanvasPlayer(
}
return s
})
// 按需解码当前 ±1 片段
decodeAroundPosition(currentTime)
}
if (currentTime >= totalDuration) {
@@ -648,18 +758,29 @@ export function useCanvasPlayer(
}
rafRef.current = requestAnimationFrame(renderFrame)
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect])
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition])
// ── 播放控制 ──
const play = useCallback(async () => {
if (!state.hasSupport || isDestroyedRef.current) return
// 重播场景:currentTime 已回到起点但 decodedSegmentsRef 仍有旧标记
// 此时 FrameQueue 中旧帧已被淘汰,需清空标记让 decodeAroundPosition 重新解码
if (state.currentTime <= 0.1 && decodedSegmentsRef.current.size > 0) {
decodeGenerationRef.current++
decodedSegmentsRef.current.clear()
// 同步清空帧缓冲,避免旧帧残留导致 getCurrentFrame 返回 null
frameQueueRef.current.clear()
}
setState((s) => ({ ...s, isPlaying: true }))
playStartRef.current = performance.now()
playStartOffsetRef.current = state.currentTime
lastProgressUpdateRef.current = 0
rafRef.current = requestAnimationFrame(renderFrame)
}, [state.hasSupport, state.currentTime, renderFrame])
// 立即触发一次按需解码,不等渲染循环 200ms 节流
decodeAroundPosition(state.currentTime)
}, [state.hasSupport, state.currentTime, renderFrame, decodeAroundPosition])
const pause = useCallback(() => {
setState((s) => ({ ...s, isPlaying: false }))
@@ -667,15 +788,19 @@ export function useCanvasPlayer(
}, [])
const seek = useCallback(
(time: number) => {
async (time: number) => {
const clampedTime = Math.max(0, Math.min(time, totalDuration))
setState((s) => ({ ...s, currentTime: clampedTime }))
playStartOffsetRef.current = clampedTime
playStartRef.current = performance.now()
// seek 后清空帧队列,等待新帧解码
// seek 时递增解码代数,作废正在进行的异步解码
decodeGenerationRef.current++
// 清空帧队列(clear 内部会 close 所有帧)+ 清空已解码标记
frameQueueRef.current.clear()
decodedSegmentsRef.current.clear()
await decodeAroundPosition(clampedTime)
},
[totalDuration],
[totalDuration, decodeAroundPosition],
)
const destroy = useCallback(() => {
@@ -686,10 +811,12 @@ export function useCanvasPlayer(
decoderRef.current.close()
}
// 递增代数中止进行中的异步解码,清空帧队列(clear 内部 close 所有帧)
decodeGenerationRef.current++
frameQueueRef.current.clear()
segmentDataRef.current.clear()
segmentMetaRef.current = []
descriptionCache.current.clear()
decodedSegmentsRef.current.clear()
}, [])
// ── 预加载下一个片段的数据 ──
@@ -706,7 +833,16 @@ export function useCanvasPlayer(
// ── 初始化:加载并解码所有片段 ──
useEffect(() => {
if (!state.hasSupport || segments.length === 0) return
if (!state.hasSupport || segments.length === 0) {
console.log("[useCanvasPlayer] Skip init:", {
hasSupport: state.hasSupport,
segmentCount: segments.length,
})
return
}
let cancelled = false
console.log("[useCanvasPlayer] Init start, segments:", segments.length)
const init = async () => {
setState((s) => ({ ...s, isBuffering: true }))
@@ -714,45 +850,76 @@ export function useCanvasPlayer(
// 1. 加载所有片段数据
for (const seg of segments) {
await loadSegment(seg)
if (cancelled) {
console.log("[useCanvasPlayer] Cancelled during loadSegment")
return
}
}
if (isDestroyedRef.current) return
// 验证 buffer 是否都已存入
const bufferCheck = segments.map((s) => ({
assetId: s.assetId,
hasBuffer: segmentDataRef.current.has(s.assetId),
}))
console.log("[useCanvasPlayer] Buffers loaded:", bufferCheck)
// 2. 解析每个片段的轨道元数据(await 等待 onSamples 回调完成)
const metas: SegmentMeta[] = []
for (let i = 0; i < segments.length; i++) {
const buffer = segmentDataRef.current.get(segments[i].assetId)
if (!buffer) continue
if (!buffer) {
console.warn("[useCanvasPlayer] No buffer for segment", i, segments[i].assetId)
continue
}
const meta = await demuxSegment(buffer, i)
if (cancelled) {
console.log("[useCanvasPlayer] Cancelled during demuxSegment")
return
}
if (meta) metas.push(meta)
}
if (isDestroyedRef.current || metas.length === 0) {
if (cancelled || metas.length === 0) {
console.warn("[useCanvasPlayer] Init failed:", { cancelled, metasCount: metas.length })
setState((s) => ({ ...s, isBuffering: false }))
return
}
segmentMetaRef.current = metas
// 3. 设置视频尺寸(用第一个片段的尺寸)
if (metas[0].videoWidth > 0 && metas[0].videoHeight > 0) {
videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight }
}
// 4. 依次解码每个片段
for (const meta of metas) {
// 3. 按需解码:初始只解码前 3 个片段,后续通过 decodeAroundPosition 动态加载
// 避免一次性全量解码导致 frameQueue 环形缓冲区旧帧被丢弃引发黑屏
decodedSegmentsRef.current.clear()
const initGen = decodeGenerationRef.current
const initialDecodeCount = Math.min(metas.length, 3)
for (let i = 0; i < initialDecodeCount; i++) {
if (cancelled) break
// seek 或 destroy 已作废当前初始化
if (decodeGenerationRef.current !== initGen) break
const meta = metas[i]
const buffer = segmentDataRef.current.get(meta.assetId)
if (!buffer) continue
await decodeSegment(buffer, meta)
if (isDestroyedRef.current) break
// 先标记为解码中,防止重复解码
decodedSegmentsRef.current.add(i)
try {
await decodeSegment(buffer, meta, MAX_INIT_FRAMES)
} catch (e) {
// 解码失败则移除标记,允许后续重试
decodedSegmentsRef.current.delete(i)
console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e)
}
}
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
if (!cancelled) {
console.log("[useCanvasPlayer] Init complete, isReady = true")
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
}
}
init()
return () => {
cancelled = true
destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -5,7 +5,7 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { GeneratedVideo } from "@/api/template-editor"
import { confirmGeneration, createPreview } from "@/api/generation"
import { createGenerationTask } from "@/api/tasks/tasks"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
@@ -55,7 +55,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
clearTimer()
try {
// 解析分辨率videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
// 解析分辨率
const ratio = props.videoRatio || "9:16"
let outputWidth: number
let outputHeight: number
@@ -87,20 +87,22 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
outputHeight = 1920
}
// 获取或创建后端任务 ID
// 预览改为前端播放后,不再有预览任务,需要在此处创建
let taskId = props.previewTaskId
if (!taskId) {
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
const previewResp = await createPreview({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: props.duration || undefined,
video_ratio: props.videoRatio,
voice_ids: undefined,
title_config: props.titleSettings?.title
? {
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
// 直接创建正式生成任务
await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: props.coverSettings?.upload_url || "",
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
@@ -109,17 +111,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
}
: undefined,
})
taskId = previewResp.task_id
}
await confirmGeneration(taskId, {
output_width: outputWidth,
output_height: outputHeight,
cover_url: props.coverSettings.upload_url || "",
custom_title: props.titleSettings.title || "",
},
}
: {}),
})
startPolling()
@@ -114,9 +114,22 @@ export function useStep6Cover({
const anyErr = err as any
const statusCode = anyErr?.response?.status
// 400 错误:后端缺少预览视频,自动创建后重试
if (statusCode === 400) {
console.log("[Step6] 后端返回 400,尝试自动创建预览渲染任务...")
// 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errCode = anyErr?.response?.data?.code as string | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errMsg = (anyErr?.response?.data?.message ||
anyErr?.response?.data?.detail ||
"") as string
const isPreviewMissing =
statusCode === 400 &&
(errCode?.includes("PREVIEW") ||
/预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
errMsg,
))
if (isPreviewMissing) {
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
message.info("正在准备预览视频,请稍候...")
try {
const previewResp = await createPreview({
@@ -124,23 +137,34 @@ export function useStep6Cover({
asset_ids: assetIds,
duration: duration || 30,
})
// 轮询等待预览渲染完成
// 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
await new Promise<void>((resolve, reject) => {
const poll = setInterval(async () => {
let finished = false
const done = (fn: () => void) => {
if (finished) return
finished = true
clearTimeout(timeoutId)
fn()
}
const timeoutId = setTimeout(() => {
done(() => reject(new Error("预览生成超时,请稍后重试")))
}, 120_000)
const poll = async () => {
if (finished) return
try {
const status = await getPreviewStatus(previewResp.task_id)
if (status.status === "completed") {
clearInterval(poll)
resolve()
done(() => resolve())
} else if (status.status === "failed") {
clearInterval(poll)
reject(new Error(status.error_message || "预览渲染失败"))
done(() => reject(new Error(status.error_message || "预览渲染失败")))
} else {
setTimeout(poll, 2000)
}
} catch (e) {
clearInterval(poll)
reject(e)
done(() => reject(e))
}
}, 3000)
}
poll()
})
message.success("预览视频就绪,重新生成封面...")
// 重试封面生成
@@ -2,14 +2,13 @@ import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
createAsset,
uploadAssetDirect,
getAssetLibraries,
getIngestJob,
type AssetLibraryItem,
} from "@/api/assets"
import { tagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
import { getAudioDuration } from "../../../utils/audio"
import { type VoiceGender, type VoiceMaterial } from "../../../types"
interface UseVoiceUploadOptions {
voiceLibrary?: { id: string; kind: string }
@@ -48,32 +47,34 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
if (!lib) throw new Error("无法创建配音库")
}
// 2. 上传文件(带进度)
const { storage_key } = await uploadAssetDirect({
// 2. 上传文件(带进度,后端自动创建 ingest job
const { ingest_job_id } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
// 3. 获取音频时长
const duration = await getAudioDuration(data.file)
// 3. 轮询 ingest job 状态
let job: Awaited<ReturnType<typeof getIngestJob>> | null = null
let retries = 0
const maxRetries = 60 // 最多等待 5 分钟
while (retries < maxRetries) {
await new Promise((r) => setTimeout(r, 5000))
job = await getIngestJob(ingest_job_id)
if (job.status === "completed" || job.status === "failed") break
retries++
}
// 4. 创建素材记录
const asset = await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
duration,
}),
})
if (!job || job.status === "failed") {
throw new Error("音频处理失败,请重试")
}
if (retries >= maxRetries) {
throw new Error("音频处理超时,请稍后在素材库查看")
}
// 5. 打标签(标签走独立 API
if (data.tagIds.length > 0) {
await tagAsset(asset.id, data.tagIds)
// 4. 打标签(标签走独立 API
if (data.tagIds.length > 0 && job.result_asset_id) {
await tagAsset(job.result_asset_id, data.tagIds)
}
} finally {
setUploadProgress(null)
+1 -1
View File
@@ -21,7 +21,7 @@ export interface VoiceMaterial {
fileUrl?: string
}
/** 配音素材上传元数据(传递给 createAsset 的 metadata */
/** 配音素材上传元数据(上传素材的 metadata */
export interface VoiceAssetMetadata {
gender: VoiceGender
description: string
@@ -1,8 +1,6 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
import { getAudioDuration } from "../utils/audio"
import { buildVoiceMetadata } from "../types"
import { uploadAssetDirect, getAssetLibraries, getIngestJob } from "@/api/assets"
/**
* 配音上传 Hook
@@ -33,27 +31,30 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
/* 直传文件 */
const { storage_key } = await uploadAssetDirect({
/* 直传文件(后端会自动创建 ingest job) */
const { ingest_job_id } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
/* 获取音频时长 */
const duration = await getAudioDuration(data.file)
/* 轮询 ingest job 状态,等待 Worker 处理完成 */
let jobStatus = ""
let retries = 0
const maxRetries = 60 // 最多等待 5 分钟(60 * 5秒)
while (jobStatus !== "ready" && jobStatus !== "failed" && retries < maxRetries) {
await new Promise((r) => setTimeout(r, 5000))
const job = await getIngestJob(ingest_job_id)
jobStatus = job.status
retries++
}
/* 创建素材记录 */
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildVoiceMetadata({
description: data.description,
duration,
}),
})
if (jobStatus === "failed") {
throw new Error("音频处理失败,请重试")
}
if (retries >= maxRetries) {
throw new Error("音频处理超时,请稍后在素材库查看")
}
} finally {
setUploadProgress(null)
}
+1 -1
View File
@@ -39,7 +39,7 @@ export interface ClonedVoiceDisplay {
sampleUrl?: string
}
/** 音色上传元数据(传递给 createAsset 的 metadata */
/** 音色上传元数据(上传素材的 metadata */
export interface VoiceUploadMetadata {
gender?: string
description?: string
-34
View File
@@ -7,11 +7,9 @@ import {
deleteAssetLibrary,
getAssets,
getAssetsByKind,
createAsset,
updateAsset,
updateAssetReviewStatus,
deleteAsset,
uploadAsset,
prepareDirectUpload,
completeDirectUpload,
uploadAssetDirect,
@@ -175,22 +173,6 @@ describe("assets API", () => {
})
})
describe("createAsset", () => {
it("should resolve successfully", async () => {
await expect(createAsset({ name: "test-item" })).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(createAsset({ name: "test-item" })).rejects.toThrow()
})
})
describe("updateAsset", () => {
it("should resolve successfully", async () => {
await expect(updateAsset("test-assetId")).resolves.not.toThrow()
@@ -239,22 +221,6 @@ describe("assets API", () => {
})
})
describe("uploadAsset", () => {
it("should resolve successfully", async () => {
await expect(uploadAsset(new FormData())).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(uploadAsset(new FormData())).rejects.toThrow()
})
})
describe("prepareDirectUpload", () => {
it("should resolve successfully", async () => {
await expect(prepareDirectUpload({ name: "test-item" })).resolves.not.toThrow()
@@ -10,7 +10,9 @@ vi.mock("@/api/voice-clone", () => ({
}))
vi.mock("@/api/assets", () => ({
uploadAsset: vi.fn(),
uploadAssetDirect: vi
.fn()
.mockResolvedValue({ storage_key: "test", ingest_job_id: "test", url: "http://test" }),
}))
vi.mock("@/components/ui", () => ({
@@ -180,7 +180,6 @@ vi.mock("@/api/assets", () => ({
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
smartMatchAssets: vi.fn().mockResolvedValue({ items: [] }),
createAsset: vi.fn().mockResolvedValue({}),
updateAsset: vi.fn().mockResolvedValue({}),
deleteAsset: vi.fn().mockResolvedValue({}),
uploadAssetDirect: vi.fn().mockResolvedValue({}),
@@ -165,7 +165,6 @@ vi.mock("@/api/assets", () => ({
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
createAsset: vi.fn().mockResolvedValue({}),
updateAsset: vi.fn().mockResolvedValue({}),
deleteAsset: vi.fn().mockResolvedValue({}),
uploadAssetDirect: vi.fn().mockResolvedValue({}),
@@ -93,5 +93,5 @@ describe("useStep5Voice smoke test", () => {
)
expect(result.current).toBeDefined()
expect(typeof result.current.handlePlayCloneSample).toBe("function")
})
}, 15_000)
})
+1
View File
@@ -13,6 +13,7 @@ export default defineConfig({
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
testTimeout: 15_000, // 全局 15 秒,防止 CI 高负载时偶发超时
},
plugins: [
react({
+4 -4
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
# OSS 上传配置
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死
OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒),防止网络慢时无限卡住
OSS_UPLOAD_TOTAL_TIMEOUT = 900 # 单文件上传总超时(秒),防止网络慢时无限卡住
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
@@ -127,10 +127,10 @@ def download_asset(asset_storage_key: str, local_path: Path) -> bool:
def _download_via_http(url: str, local_path: Path) -> bool:
"""通过 HTTP 下载文件(支持预签名 URL)。
使用流式下载避免大文件内存溢出,超时 300s。
使用流式下载避免大文件内存溢出,超时 900s。
"""
try:
resp = requests.get(url, stream=True, timeout=300)
resp = requests.get(url, stream=True, timeout=900)
resp.raise_for_status()
with open(local_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024):
@@ -146,7 +146,7 @@ def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公开 URL。
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
上传加总超时保护(默认 300s),防止网络异常时无限挂死。
上传加总超时保护(默认 900s),防止网络异常时无限挂死。
Args:
local_path: 本地文件路径(Path 或 str 均可)
@@ -134,3 +134,88 @@ def _format_seek_time(seconds: float) -> str:
m = int((seconds % 3600) // 60)
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:05.2f}"
def generate_and_upload_thumbnail(
video_path: str,
storage_key: str,
*,
seek_ratio: float = 0.15,
) -> str:
"""从视频中提取一帧缩略图并上传到 OSS。
Args:
video_path: 视频文件路径
storage_key: OSS 存储 key
seek_ratio: 抽帧位置比例(默认 0.15)
Returns:
上传后的 URL 字符串
Raises:
RuntimeError: 抽帧或上传失败
"""
from video_processing.oss_helpers import upload_to_oss
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
try:
frame_path = extract_first_frame(video_path, output_path=tmp.name, seek_ratio=seek_ratio)
url = upload_to_oss(frame_path, storage_key)
if not url:
raise RuntimeError(f"上传缩略图到 OSS 失败: {storage_key}")
return url
finally:
Path(tmp.name).unlink(missing_ok=True)
def extract_and_upload_cover_frames(
video_path: str,
plan_id: str,
*,
num_frames: int = 3,
title_text: str = "",
) -> list[dict]:
"""从视频中抽取多帧作为封面候选,上传到 OSS。
Args:
video_path: 视频文件路径
plan_id: 编辑计划 ID(用于生成 storage key
num_frames: 抽取帧数(默认 3
title_text: 标题文字(当前版本未叠加,预留参数)
Returns:
封面候选列表,每项包含 {"url": str, "position": float}
"""
from video_processing.ffmpeg_utils import probe_duration
from video_processing.oss_helpers import upload_to_oss
try:
duration = probe_duration(video_path)
except Exception:
duration = 0.0
candidates: list[dict] = []
# 均匀分布抽帧点:从 10% 到 90%
for i in range(num_frames):
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
try:
frame_path = extract_first_frame(
video_path,
output_path=tmp.name,
seek_ratio=ratio,
min_seek_seconds=0.5,
)
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
url = upload_to_oss(frame_path, storage_key)
if url:
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
candidates.append({"url": url, "position": round(seek_time, 2)})
except Exception as e:
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
finally:
Path(tmp.name).unlink(missing_ok=True)
return candidates
+158
View File
@@ -1,3 +1,4 @@
import shutil
import subprocess
import tempfile
from datetime import datetime, timezone
@@ -233,6 +234,163 @@ def ingest_asset(job_id: str) -> dict:
job_id,
thumb_err,
)
# ── HEVC 自动转码为 1080p H.264 ──────────────────────────────
# 浏览器 WebCodecs 硬件解码 HEVC 输出黑帧,上传时自动转码
# 失败时降级使用原始文件,不阻塞上传流程
if media_type == "video" and local_file and local_file.exists():
codec = (metadata.get("codec") or "").lower()
if codec in ("hevc", "h265", "hvh1"):
logger.info(
"检测到 HEVC 编码 (codec=%s),启动转码: job_id=%s",
codec,
job_id,
)
_tc_tmp = None
_needs_rotation = False
try:
# 检查磁盘空间(大文件转码需要至少 2GB 可用空间)
_disk_usage = shutil.disk_usage("/tmp")
_free_gb = _disk_usage.free / (1024**3)
if _free_gb < 2:
raise RuntimeError(f"磁盘空间不足 ({_free_gb:.1f}GB < 2GB),无法处理大文件")
# 检测视频是否有 rotation 元数据(竖屏视频)
_probe_cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"side_data=rotation",
"-show_entries",
"stream_tags=rotate",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(local_file),
]
_probe_result = subprocess.run(
_probe_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=10,
)
_rotation_str = (_probe_result.stdout or "").strip().split("\n")[0]
if _rotation_str in ("90", "270", "-90"):
_needs_rotation = True
logger.info(
"检测到竖屏视频 (rotation=%s),将物理旋转画面: job_id=%s",
_rotation_str,
job_id,
)
_tc_tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix="_h264.mp4")
_tc_tmp = Path(_tc_tmp_file.name)
_tc_tmp_file.close() # 关闭文件描述符,ffmpeg 会自己打开
# 构建 video filter:竖屏先旋转再缩放
if _needs_rotation:
_vf = "transpose=1,scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'"
else:
_vf = "scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'"
_cmd = [
"ffmpeg",
"-y",
"-i",
str(local_file),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"18",
"-vf",
_vf + ",format=yuv420p",
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trf",
"bt709",
"-pix_fmt",
"yuv420p",
"-level",
"4.0",
]
# 竖屏视频:清除旋转元数据
if _needs_rotation:
_cmd.extend(["-metadata:s:v:0", "rotate=0"])
_cmd.extend(
[
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
str(_tc_tmp),
]
)
_proc = subprocess.run(
_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
timeout=900,
)
if _proc.returncode == 0 and _tc_tmp.exists() and _tc_tmp.stat().st_size > 0:
from video_processing.oss_helpers import upload_to_oss
_p = Path(job.storage_key)
_new_key = str(_p.parent / (_p.stem + "_h264" + _p.suffix))
_url = upload_to_oss(_tc_tmp, _new_key)
if _url:
# 先提取元数据,确认成功后再更新 storage_key(避免脏数据)
_new_metadata, _new_extract_success = extract_media_metadata(
str(_tc_tmp),
media_type,
)
if _new_extract_success:
job.storage_key = _new_key
metadata = _new_metadata
extract_success = _new_extract_success
logger.info(
"HEVC→H.264 转码完成: job_id=%s key=%s",
job_id,
_new_key[:80],
)
else:
logger.warning(
"转码文件上传 OSS 失败,使用原始文件: job_id=%s",
job_id,
)
else:
_tail = _proc.stderr[-300:] if _proc.stderr else ""
logger.warning(
"FFmpeg 转码失败 rc=%s,降级原始文件: job_id=%s",
_proc.returncode,
job_id,
)
except subprocess.TimeoutExpired:
logger.warning(
"FFmpeg 转码超时 (900s),降级原始文件: job_id=%s",
job_id,
)
except Exception as _e:
logger.warning(
"HEVC 转码异常(降级原始文件): job_id=%s err=%s",
job_id,
_e,
)
finally:
if _tc_tmp and _tc_tmp.exists():
try:
_tc_tmp.unlink()
except OSError:
pass
finally:
if local_file and local_file.exists():
try:
+6
View File
@@ -121,6 +121,7 @@ class GenerationTask:
output_height: int = 720
cover_url: str = ""
custom_title: str = ""
extra_meta: dict = field(default_factory=dict)
logs: str = "[]"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -152,6 +153,7 @@ class GenerationTask:
output_height: int = 720,
cover_url: str = "",
custom_title: str = "",
extra_meta: dict | None = None,
) -> "GenerationTask":
if not project_id.strip() and not template_id.strip():
raise ValueError("project_id 或 template_id 至少需要提供一个")
@@ -182,6 +184,7 @@ class GenerationTask:
output_height=output_height,
cover_url=cover_url,
custom_title=custom_title,
extra_meta=dict(extra_meta) if extra_meta else {},
)
# ── 状态查询 ────────────────────────────────────────────────────────────
@@ -301,6 +304,7 @@ class GenerationTask:
*,
cover_url: str = "",
custom_title: str = "",
extra_meta: dict | None = None,
output_width: int = 0,
output_height: int = 0,
) -> None:
@@ -318,6 +322,8 @@ class GenerationTask:
self.output_width = output_width
if output_height > 0:
self.output_height = output_height
if extra_meta:
self.extra_meta.update(extra_meta)
self.updated_at = datetime.now(timezone.utc)
# ── 日志辅助 ────────────────────────────────────────────────────────────
+69 -173
View File
@@ -260,6 +260,30 @@ def _make_user(**overrides) -> User:
return User(**defaults)
def _direct_insert_asset(client, name="test-video.mp4", storage_key=None, mime_type="video/mp4", status=None):
"""Helper: insert asset directly into repo (bypass deprecated create_asset API)."""
import uuid as _uuid
app = client.app
asset_repo = app.dependency_overrides[get_asset_repository]()
kw = {}
if status is not None:
kw["status"] = status
else:
kw["status"] = AssetStatus.READY
asset = Asset(
id=_uuid.uuid4().hex,
project_id="proj-1",
library_id="lib-1",
name=name,
storage_key=storage_key or f"uploads/{name}",
mime_type=mime_type,
**kw,
)
asset_repo.create(asset)
return asset.id
def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
@@ -344,8 +368,8 @@ def client(mock_storage):
class TestCreateAsset:
"""创建素材端点测试。"""
def test_create_asset_success(self, client):
"""正常创建素材成功"""
def test_create_asset_returns_410_gone(self, client):
"""create_asset 已废弃,返回 410 Gone 提示使用 ingest-jobs"""
resp = client.post(
"/api/v1/assets",
json={
@@ -354,59 +378,23 @@ class TestCreateAsset:
"name": "new-video.mp4",
"storage_key": "uploads/new-video.mp4",
"mime_type": "video/mp4",
"file_size": 2048,
"duration": 15.0,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "new-video.mp4"
assert data["project_id"] == "proj-1"
assert data["library_id"] == "lib-1"
assert data["mime_type"] == "video/mp4"
assert "id" in data
assert data["status"] == "uploading"
assert resp.status_code == 410
def test_create_asset_project_not_found(self, client):
"""项目不存在返回 404"""
def test_create_asset_any_type_returns_410(self, client):
"""所有类型都返回 410 Gone(图片/音频也废弃)"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "nonexistent",
"project_id": "proj-1",
"library_id": "lib-1",
"name": "test.mp4",
"storage_key": "uploads/test.mp4",
"mime_type": "video/mp4",
"name": "photo.jpg",
"storage_key": "uploads/photo.jpg",
"mime_type": "image/jpeg",
},
)
assert resp.status_code == 404
assert "Project" in resp.json()["detail"]
def test_create_asset_library_not_found(self, client):
"""素材库不存在返回 404。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "nonexistent",
"name": "test.mp4",
"storage_key": "uploads/test.mp4",
"mime_type": "video/mp4",
},
)
assert resp.status_code == 404
assert "AssetLibrary" in resp.json()["detail"]
def test_create_asset_missing_required_fields(self, client):
"""缺少必填字段返回 422。"""
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"name": "test.mp4",
},
)
assert resp.status_code == 422
assert resp.status_code == 410
# ---------------------------------------------------------------------------
@@ -418,20 +406,26 @@ class TestListAssets:
"""获取素材列表端点测试。"""
def _create_test_assets(self, client, count: int = 3):
"""辅助方法:创建测试素材(status=ready)。"""
"""辅助方法:直接插入测试素材到 repository(绕过已废弃的 create_asset API)。"""
# 通过依赖覆盖获取 asset_repo
app = client.app
asset_repo = app.dependency_overrides.get(get_asset_repository, lambda: None)()
if asset_repo is None:
return
for i in range(count):
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": f"video-{i}.mp4",
"storage_key": f"uploads/video-{i}.mp4",
"mime_type": "video/mp4",
"file_size": 1024 * (i + 1),
"status": "ready",
},
import uuid
asset = Asset(
id=uuid.uuid4().hex,
project_id="proj-1",
library_id="lib-1",
name=f"video-{i}.mp4",
storage_key=f"uploads/video-{i}.mp4",
mime_type="video/mp4",
file_size=1024 * (i + 1),
status=AssetStatus.READY,
)
asset_repo.create(asset)
def test_empty_list(self, client):
"""无素材时返回空列表。"""
@@ -517,17 +511,7 @@ class TestListAssets:
def test_list_status_filter_uploading_visible(self, client):
"""uploading状态的素材默认能看到(上传后立即显示处理中)。"""
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "uploading-test.mp4",
"storage_key": "uploads/uploading-test.mp4",
"mime_type": "video/mp4",
"status": "uploading",
},
)
_direct_insert_asset(client, name="uploading-test.mp4", status=AssetStatus.UPLOADING)
resp = client.get("/api/v1/assets?library_id=lib-1")
assert resp.status_code == 200
@@ -537,28 +521,8 @@ class TestListAssets:
def test_list_with_keyword_filter(self, client):
"""按名称关键词过滤。"""
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "hello-world.mp4",
"storage_key": "uploads/hello.mp4",
"mime_type": "video/mp4",
"status": "ready",
},
)
client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "goodbye.mp4",
"storage_key": "uploads/goodbye.mp4",
"mime_type": "video/mp4",
"status": "ready",
},
)
_direct_insert_asset(client, name="hello-world.mp4")
_direct_insert_asset(client, name="goodbye.mp4", mime_type="video/mp4", status=AssetStatus.READY)
resp = client.get("/api/v1/assets?library_id=lib-1&keyword=hello")
assert resp.status_code == 200
@@ -576,22 +540,8 @@ class TestGetAsset:
"""获取单个素材详情端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "detail-test.mp4",
"storage_key": "uploads/detail-test.mp4",
"mime_type": "video/mp4",
"file_size": 5000,
"duration": 25.0,
"width": 1280,
"height": 720,
"fps": 30.0,
},
)
return resp.json()["id"]
"""Direct insert into repo (create_asset API is deprecated/410)."""
return _direct_insert_asset(client)
def test_get_asset_success(self, client):
"""获取存在的素材详情成功。"""
@@ -601,11 +551,7 @@ class TestGetAsset:
assert resp.status_code == 200
data = resp.json()
assert data["id"] == asset_id
assert data["name"] == "detail-test.mp4"
assert data["file_size"] == 5000
assert data["duration"] == 25.0
assert data["width"] == 1280
assert data["height"] == 720
assert data["name"] == "test-video.mp4"
assert "file_url" in data
assert "status" in data
@@ -625,17 +571,8 @@ class TestUpdateAsset:
"""更新素材端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "old-name.mp4",
"storage_key": "uploads/old-name.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
"""Direct insert into repo (create_asset API is deprecated/410)."""
return _direct_insert_asset(client)
def test_update_asset_name(self, client):
"""更新素材名称成功。"""
@@ -675,7 +612,7 @@ class TestUpdateAsset:
resp = client.put(f"/api/v1/assets/{asset_id}", json={})
assert resp.status_code == 200
assert resp.json()["name"] == "old-name.mp4"
assert resp.json()["name"] == "test-video.mp4"
# ---------------------------------------------------------------------------
@@ -687,17 +624,8 @@ class TestDeleteAsset:
"""删除素材端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "delete-test.mp4",
"storage_key": "uploads/delete-test.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
"""Direct insert into repo (create_asset API is deprecated/410)."""
return _direct_insert_asset(client)
def test_delete_asset_success(self, client):
"""删除存在的素材成功,返回 204。"""
@@ -737,17 +665,8 @@ class TestBatchDeleteAssets:
def _create_assets(self, client, count: int = 3) -> list[str]:
ids = []
for i in range(count):
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": f"batch-{i}.mp4",
"storage_key": f"uploads/batch-{i}.mp4",
"mime_type": "video/mp4",
},
)
ids.append(resp.json()["id"])
aid = _direct_insert_asset(client, name=f"batch-{i}.mp4")
ids.append(aid)
return ids
def test_batch_delete_success(self, client):
@@ -802,17 +721,8 @@ class TestAssetTags:
"""素材标签相关端点测试。"""
def _create_asset(self, client) -> str:
resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "tag-test.mp4",
"storage_key": "uploads/tag-test.mp4",
"mime_type": "video/mp4",
},
)
return resp.json()["id"]
"""Direct insert into repo (create_asset API is deprecated/410)."""
return _direct_insert_asset(client)
def test_add_tags_to_asset(self, client):
"""给素材打标签。需要先在 tag_repo 中创建标签。"""
@@ -847,22 +757,8 @@ class TestAssetsCRUDFlow:
def test_full_crud_flow(self, client):
"""测试完整的创建 → 列表 → 详情 → 更新 → 删除流程。"""
# 1. 创建
create_resp = client.post(
"/api/v1/assets",
json={
"project_id": "proj-1",
"library_id": "lib-1",
"name": "crud-flow.mp4",
"storage_key": "uploads/crud-flow.mp4",
"mime_type": "video/mp4",
"file_size": 8192,
"metadata": {"source": "test"},
"status": "ready",
},
)
assert create_resp.status_code == 200
asset_id = create_resp.json()["id"]
# 1. 创建 (direct insert since create_asset is 410)
asset_id = _direct_insert_asset(client, name="crud-flow.mp4")
# 2. 列表中应包含
list_resp = client.get("/api/v1/assets?library_id=lib-1")
+1
View File
@@ -147,6 +147,7 @@ def _build_app(
storage._normalize_storage_key = lambda key: key
storage.file_exists = lambda key: True
storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4")
storage.get_url = MagicMock(return_value="https://oss.example.com/file.mp4")
mock_user = MagicMock(spec=AuthenticatedUser)
mock_user.id = "user-1"
@@ -1,190 +0,0 @@
"""测试 create_asset 端点:project_id 可选,从 library 自动推导。"""
from unittest.mock import MagicMock, patch
import pytest
from app.api.routes.assets import create_asset
from app.auth import AuthenticatedUser
from app.schemas.asset import CreateAssetRequest
from fastapi import HTTPException
from packages.domain import AssetStatus, ClassificationStatus
@pytest.fixture
def mock_user():
user = MagicMock(spec=AuthenticatedUser)
user.user.id = "user-123"
return user
@pytest.fixture
def mock_library():
lib = MagicMock()
lib.id = "lib-abc"
lib.project_id = "proj-from-library"
return lib
@pytest.fixture
def mock_project():
proj = MagicMock()
proj.id = "proj-from-library"
proj.can_access.return_value = True
return proj
def _make_request(**overrides):
defaults = dict(
library_id="lib-abc",
name="test-audio.mp3",
storage_key="uploads/test.mp3",
mime_type="audio/mpeg",
file_size=1024,
status="uploading",
)
defaults.update(overrides)
return CreateAssetRequest(**defaults)
def test_project_id_derived_from_library_when_not_provided(mock_user, mock_library, mock_project):
"""前端不传 project_id 时,从 library.project_id 自动推导。"""
request = _make_request() # project_id 默认 None
asset_repo = MagicMock()
lib_repo = MagicMock()
lib_repo.get.return_value = mock_library
proj_repo = MagicMock()
proj_repo.find_by_id.return_value = mock_project
expected_asset = MagicMock()
expected_asset.id = "asset-1"
expected_asset.project_id = "proj-from-library"
expected_asset.library_id = "lib-abc"
expected_asset.name = "test-audio.mp3"
expected_asset.storage_key = ""
expected_asset.mime_type = "audio/mpeg"
expected_asset.metadata = {}
expected_asset.file_size = 1024
expected_asset.thumbnail_url = None
expected_asset.duration = None
expected_asset.width = None
expected_asset.height = None
expected_asset.fps = None
expected_asset.codec = None
expected_asset.status = AssetStatus.UPLOADING
expected_asset.classification_status = ClassificationStatus.PENDING
expected_asset.quality_score = None
expected_asset.created_at = None
expected_asset.uploaded_by_user_id = "user-123"
expected_asset.tag_ids = []
with patch("app.api.routes.assets.CreateAssetUseCase") as mock_uc:
mock_uc.return_value.execute.return_value = expected_asset
result = create_asset(
request=request,
authenticated_user=mock_user,
asset_repository=asset_repo,
asset_library_repository=lib_repo,
project_repository=proj_repo,
)
# 验证 project_id 被正确推导
proj_repo.find_by_id.assert_called_once_with("proj-from-library")
# 验证 use case 使用的是推导出的 project_id
cmd = mock_uc.return_value.execute.call_args[0][0]
assert cmd.project_id == "proj-from-library"
def test_explicit_project_id_used_when_provided(mock_user, mock_library, mock_project):
"""前端显式传 project_id 时,优先使用请求值。"""
mock_project.id = "proj-explicit"
mock_project.can_access.return_value = True
mock_library.project_id = "proj-explicit" # 匹配
request = _make_request(project_id="proj-explicit")
asset_repo = MagicMock()
lib_repo = MagicMock()
lib_repo.get.return_value = mock_library
proj_repo = MagicMock()
proj_repo.find_by_id.return_value = mock_project
mock_asset = MagicMock()
mock_asset.id = "asset-1"
mock_asset.storage_key = ""
mock_asset.mime_type = "audio/mpeg"
mock_asset.project_id = "proj-explicit"
mock_asset.library_id = "lib-abc"
mock_asset.name = "test"
mock_asset.metadata = {}
mock_asset.file_size = 0
mock_asset.thumbnail_url = None
mock_asset.duration = None
mock_asset.width = None
mock_asset.height = None
mock_asset.fps = None
mock_asset.codec = None
mock_asset.status = AssetStatus.UPLOADING
mock_asset.classification_status = ClassificationStatus.PENDING
mock_asset.quality_score = None
mock_asset.created_at = None
mock_asset.uploaded_by_user_id = "user-123"
mock_asset.tag_ids = []
with patch("app.api.routes.assets.CreateAssetUseCase") as mock_uc:
mock_uc.return_value.execute.return_value = mock_asset
create_asset(
request=request,
authenticated_user=mock_user,
asset_repository=asset_repo,
asset_library_repository=lib_repo,
project_repository=proj_repo,
)
proj_repo.find_by_id.assert_called_once_with("proj-explicit")
cmd = mock_uc.return_value.execute.call_args[0][0]
assert cmd.project_id == "proj-explicit"
def test_library_not_found_returns_404(mock_user):
"""素材库不存在时返回 404。"""
request = _make_request()
lib_repo = MagicMock()
lib_repo.get.return_value = None
proj_repo = MagicMock()
asset_repo = MagicMock()
with pytest.raises(HTTPException) as exc_info:
create_asset(
request=request,
authenticated_user=mock_user,
asset_repository=asset_repo,
asset_library_repository=lib_repo,
project_repository=proj_repo,
)
assert exc_info.value.status_code == 404
def test_library_project_mismatch_returns_400(mock_user, mock_library, mock_project):
"""当 library.project_id 与请求的 project_id 不一致时返回 400。"""
mock_library.project_id = "proj-A"
mock_project.id = "proj-B"
request = _make_request(project_id="proj-B")
lib_repo = MagicMock()
lib_repo.get.return_value = mock_library
proj_repo = MagicMock()
proj_repo.find_by_id.return_value = mock_project
asset_repo = MagicMock()
with pytest.raises(HTTPException) as exc_info:
create_asset(
request=request,
authenticated_user=mock_user,
asset_repository=asset_repo,
asset_library_repository=lib_repo,
project_repository=proj_repo,
)
assert exc_info.value.status_code == 400
+41
View File
@@ -545,3 +545,44 @@ class TestGenerationTaskTimestamps:
new_task.mark_pending_from_failed()
assert new_task.started_at is None
assert new_task.completed_at is None
class TestExtraMeta:
"""extra_meta 字段测试"""
def test_create_with_extra_meta(self):
"""create() 传入 extra_meta 应正确存储"""
task = GenerationTask.create(
project_id="proj-1",
asset_library_id="lib-1",
extra_meta={"source": "preview", "resolution": "1080p"},
)
assert task.extra_meta == {"source": "preview", "resolution": "1080p"}
def test_create_without_extra_meta_defaults_empty(self):
"""create() 不传 extra_meta 应为空 dict"""
task = GenerationTask.create(project_id="proj-1", asset_library_id="lib-1")
assert task.extra_meta == {}
def test_mark_confirmed_with_extra_meta(self):
"""mark_confirmed() 传入 extra_meta 应合并到已有字段"""
task = GenerationTask.create(
project_id="proj-1",
asset_library_id="lib-1",
extra_meta={"source": "preview"},
)
task.mark_confirmed(extra_meta={"confirmed_by": "user", "resolution": "1080p"})
assert task.extra_meta["source"] == "preview"
assert task.extra_meta["confirmed_by"] == "user"
assert task.extra_meta["resolution"] == "1080p"
def test_mark_confirmed_without_extra_meta_preserves_existing(self):
"""mark_confirmed() 不传 extra_meta 不应影响已有值"""
task = GenerationTask.create(
project_id="proj-1",
asset_library_id="lib-1",
extra_meta={"key": "value"},
)
task.mark_confirmed(cover_url="https://example.com/cover.jpg")
assert task.extra_meta == {"key": "value"}
assert task.cover_url == "https://example.com/cover.jpg"
+255
View File
@@ -0,0 +1,255 @@
"""HEVC 自动转码逻辑单元测试 (ingest.py)
测试覆盖:
- HEVC 编码检测逻辑
- 转码后文件命名规则
- 元数据提取失败时的脏数据防护
- FFmpeg 超时/错误降级策略
- 安全修复(tempfile、subprocess
- Scale filter 逻辑
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
class TestHEVCAutoTranscode:
"""测试 ingest_asset 中的 HEVC 自动转码逻辑"""
def test_hevc_detection_keywords(self):
"""验证 HEVC 编码的所有关键词"""
hevc_keywords = ("hevc", "h265", "hvh1")
assert "hevc" in hevc_keywords
assert "h265" in hevc_keywords
assert "hvh1" in hevc_keywords
assert "h264" not in hevc_keywords
assert "avc1" not in hevc_keywords
def test_h264_not_detected_as_hevc(self):
"""H.264 视频不应触发转码"""
codec = "h264"
hevc_keywords = ("hevc", "h265", "hvh1")
assert codec not in hevc_keywords, "H.264 不应触发转码"
def test_transcode_storage_key_naming(self):
"""验证转码后文件命名规则"""
original_key = "uploads/video_123/test.mp4"
p = Path(original_key)
new_key = str(p.parent / (p.stem + "_h264" + p.suffix))
assert new_key == "uploads/video_123/test_h264.mp4"
def test_transcode_storage_key_naming_complex_path(self):
"""验证复杂路径的命名规则"""
original_key = "uploads/2026/08/20/abc123/video_4k.mov"
p = Path(original_key)
new_key = str(p.parent / (p.stem + "_h264" + p.suffix))
assert new_key == "uploads/2026/08/20/abc123/video_4k_h264.mov"
def test_metadata_failure_no_dirty_data(self):
"""验证元数据提取失败时不更新 storage_key(避免脏数据)
这是 AI Code Review 发现的 BUG 修复:
- 旧逻辑:先更新 storage_key,再提取元数据 → 可能产生脏数据
- 新逻辑:先提取元数据,确认成功后再更新 storage_key
"""
original_storage_key = "uploads/test/video.mp4"
new_storage_key = "uploads/test/video_h264.mp4"
# 初始状态
job_storage_key = original_storage_key
metadata = {"codec": "hevc", "width": 3840, "height": 2160}
# 模拟转码成功
transcode_success = True
# 模拟元数据提取失败
new_metadata = {}
new_extract_success = False
# 修复后的逻辑:先提取元数据,确认成功后再更新
if transcode_success:
if new_extract_success:
job_storage_key = new_storage_key
metadata = new_metadata
# 如果元数据提取失败,不更新 job_storage_key
# 验证:storage_key 保持原值,没有脏数据
assert job_storage_key == original_storage_key
assert metadata["codec"] == "hevc" # 保持原始元数据
def test_metadata_success_updates_storage_key(self):
"""验证元数据提取成功时正确更新 storage_key"""
original_storage_key = "uploads/test/video.mp4"
new_storage_key = "uploads/test/video_h264.mp4"
job_storage_key = original_storage_key
metadata = {"codec": "hevc", "width": 3840, "height": 2160}
# 模拟转码成功
transcode_success = True
# 模拟元数据提取成功
new_metadata = {"codec": "h264", "width": 1920, "height": 1080}
new_extract_success = True
# 修复后的逻辑
if transcode_success:
if new_extract_success:
job_storage_key = new_storage_key
metadata = new_metadata
# 验证:storage_key 和 metadata 都更新为新值
assert job_storage_key == new_storage_key
assert metadata["codec"] == "h264"
assert metadata["width"] == 1920
@patch("subprocess.run")
def test_ffmpeg_timeout_degradation(self, mock_subprocess):
"""验证 FFmpeg 超时降级使用原始文件"""
mock_subprocess.side_effect = subprocess.TimeoutExpired(cmd="ffmpeg", timeout=300)
# 模拟降级逻辑
transcode_success = False
try:
raise subprocess.TimeoutExpired(cmd="ffmpeg", timeout=300)
except subprocess.TimeoutExpired:
transcode_success = False
assert not transcode_success, "超时应该导致转码失败"
@patch("subprocess.run")
def test_ffmpeg_error_degradation(self, mock_subprocess):
"""验证 FFmpeg 执行失败降级使用原始文件"""
mock_subprocess.return_value = MagicMock(
returncode=1,
stderr="Error: Invalid data found when processing input",
)
result = mock_subprocess.return_value
transcode_success = result.returncode == 0
assert not transcode_success, "FFmpeg 返回非零退出码应该导致转码失败"
def test_scale_filter_logic_4k_video(self):
"""验证 4K 视频会被缩放到 1080p"""
ih = 2160
should_scale = ih > 1080
assert should_scale, "4K 视频应该被缩放"
def test_scale_filter_logic_1080p_video(self):
"""验证 1080p 视频不会被缩放"""
ih = 1080
should_scale = ih > 1080
assert not should_scale, "1080p 视频不应该被缩放"
def test_scale_filter_logic_720p_video(self):
"""验证 720p 视频不会被缩放"""
ih = 720
should_scale = ih > 1080
assert not should_scale, "720p 视频不应该被缩放"
def test_tempfile_security_fix(self):
"""验证使用 NamedTemporaryFile 替代 mktemp(安全修复)
AI Code Review 发现的安全漏洞:
- tempfile.mktemp 存在 TOCTOU 竞态条件
- 应该使用 NamedTemporaryFile(delete=False)
"""
import tempfile
with patch("tempfile.NamedTemporaryFile") as mock_ntf:
mock_file = MagicMock()
mock_file.name = "/tmp/test_h264.mp4"
mock_ntf.return_value = mock_file
# 新代码的调用方式
_tc_tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix="_h264.mp4")
_tc_tmp = Path(_tc_tmp_file.name)
_tc_tmp_file.close()
# 验证使用了 NamedTemporaryFile
mock_ntf.assert_called_once_with(delete=False, suffix="_h264.mp4")
def test_subprocess_output_handling(self):
"""验证 subprocess 输出处理(避免内存溢出)
AI Code Review 发现的稳定性风险:
- capture_output=True 会将所有输出加载到内存
- 应该使用 stdout=DEVNULL, stderr=PIPE
"""
import subprocess as sp
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
# 新代码的调用方式
sp.run(
["ffmpeg", "-i", "input.mp4", "output.mp4"],
stdout=sp.DEVNULL,
stderr=sp.PIPE,
text=True,
timeout=300,
)
# 验证使用了 stdout=DEVNULL, stderr=PIPE
call_kwargs = mock_run.call_args[1]
assert call_kwargs.get("stdout") == sp.DEVNULL
assert call_kwargs.get("stderr") == sp.PIPE
assert call_kwargs.get("timeout") == 300
def test_ffmpeg_command_parameters(self):
"""验证 FFmpeg 命令参数正确性"""
expected_params = [
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"18",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
]
# 验证所有关键参数都在命令中
cmd = ["ffmpeg", "-y", "-i", "input.mp4"]
cmd.extend(expected_params)
cmd.append("output.mp4")
assert "-c:v" in cmd
assert "libx264" in cmd
assert "-crf" in cmd
assert "18" in cmd
assert "-pix_fmt" in cmd
assert "yuv420p" in cmd
assert "-movflags" in cmd
assert "+faststart" in cmd
def test_hevc_codec_case_insensitive(self):
"""验证 HEVC 检测不区分大小写"""
test_cases = ["hevc", "HEVC", "Hevc", "h265", "H265", "hvh1", "HVH1"]
hevc_keywords = ("hevc", "h265", "hvh1")
for codec in test_cases:
assert codec.lower() in hevc_keywords, f"{codec} 应该被检测为 HEVC"
def test_non_hevc_codecs(self):
"""验证非 HEVC 编码不会触发转码"""
non_hevc_codecs = ["h264", "avc1", "vp9", "av1", "mpeg4", ""]
hevc_keywords = ("hevc", "h265", "hvh1")
for codec in non_hevc_codecs:
assert codec.lower() not in hevc_keywords, f"{codec} 不应触发转码"