Compare commits

...

14 Commits

Author SHA1 Message Date
灵应 c305ae51ff fix: 修复 useAuth 测试 mock 支持 getState() 方法
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 API Integration Tests (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 / Check if frontend-only change (pull_request) Successful in 35s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 0s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m14s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
AI Code Review / AI Code Review (pull_request) Failing after 4m20s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m17s
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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1m13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Failing after 0s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 1s
2026-08-17 14:48:57 +08:00
xiaoxia 8172eabda5 fix: 主动token刷新-在过期前自动续期避免401错误
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
PR Automation / Auto Approve on CI Green (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 0s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 0s
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 / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 0s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 0s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 2m3s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m18s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m25s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m28s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m35s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
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 / CI Gate (pull_request) Failing after 7s
- 新增 tokenRefresh.ts:解码JWT获取过期时间,提前60秒自动刷新
- useAuth.ts:登录/微信回调成功后调度主动刷新,登出时取消
- client.ts:拦截器刷新成功后重新调度,刷新失败时取消
- main.tsx:应用启动时如果已登录则立即调度

修复:access_token每30分钟过期后第一个API请求触发401的问题
2026-08-17 14:18:43 +08:00
xiaoxia e186cb2253 cleanup: 删除Canvas预览代码,简化PreviewVideoPanel (#1402)
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 Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m10s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m38s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-17 14:16:06 +08:00
灵应 5104a56578 cleanup: 删除Canvas预览代码,简化PreviewVideoPanel (#1402)
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 - 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 / 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
2026-08-17 14:15:12 +08:00
灵应 c7ba43c309 fix: Canvas预览与后端ASS渲染坐标系对齐,消除标题预览不一致 (#1401)
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 2m18s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m51s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m9s
CI/CD Pipeline / Unit Tests (push) Successful in 11m29s
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 / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m46s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 32m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m57s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 13:18:22 +08:00
xiaoxia ff3f6ddf97 fix: Worker容器安装CJK中文字体 — 修复ASS字幕标题不渲染 (#1400)
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 Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m15s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m30s
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Successful in 11m26s
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 / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 / Build Staging Worker Image (push) Successful in 14m29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 30m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 12:10:40 +08:00
xiaoxia 13b8fb7f66 fix: 预览视频标题渲染全链路修复 — custom_title注入+ASR路径标题支持 (#1399)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1s
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 1m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m40s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Unit Tests (push) Successful in 10m5s
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 / CI Gate (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 / Build Staging API Image (push) Successful in 21m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 00:50:27 +08:00
xiaoxia b88683fcff fix: 修正PreviewVideoPanel显示条件,面板从Step4开始显示,标题预览仅在Step4 (#1398)
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 2m38s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m26s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m15s
CI/CD Pipeline / Integration Tests (push) Successful in 2m19s
CI/CD Pipeline / Unit Tests (push) Successful in 12m5s
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 / CI Gate (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 / Build Staging API Image (push) Successful in 18m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-16 22:51:01 +08:00
灵应 ea704ddb2f fix: 修正PreviewVideoPanel显示条件,面板从Step4开始显示,标题预览仅在Step4 (#1398)
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 - 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 / 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
2026-08-16 22:50:42 +08:00
xiaoxia b8fbd5705d fix: 修复 test_title_font_size 断言适配 PR #1393 字号上限 (#1397)
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 - Type Check (mypy) (push) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m53s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m49s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m14s
CI/CD Pipeline / Integration Tests (push) Successful in 2m29s
CI/CD Pipeline / Unit Tests (push) Successful in 10m55s
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 / CI Gate (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 / Build Staging API Image (push) Successful in 21m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-16 22:01:00 +08:00
xiaoxia 8b0572362e fix: PreviewVideoPanel仅在Step5+(预览页)显示,标题页不再显示预览视频 (#1395)
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 1m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m41s
CI/CD Pipeline / Integration Tests (push) Successful in 2m2s
CI/CD Pipeline / Unit Tests (push) Failing after 11m27s
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 / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 / Build Staging API Image (push) Successful in 21m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-16 21:06:14 +08:00
灵应 a262d4cc6e cleanup: 删除Step5未使用的templateName/materialCount/duration props传递链 (#1396)
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 - Type Check (mypy) (push) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m57s
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 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
- Step5GeneratePreview: 移除接口声明+解构中未使用的3个字段
- GenerateStepContent: 移除previewTemplateName/previewMaterialCount接口+解构+传递
- GeneratePage: 移除传递给GenerateStepContent的2个prop
- 保留useStep5Preview hook计算逻辑(useStep7Generate SummaryCard仍在使用)
2026-08-16 21:03:36 +08:00
灵应 77b38af1bc fix: PreviewVideoPanel仅在Step5+(预览页)显示,标题页不再显示预览视频 (#1395)
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 1m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m44s
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 API 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
- 渲染条件从 currentStep>=4 改为 currentStep>=5
- 标题Canvas叠加条件从 currentStep===4 改为 currentStep===5
- Step4标题页只有表单,右侧无预览面板
- Step5预览页显示预览视频+标题叠加
2026-08-16 20:57:35 +08:00
xiaoxia c539095a33 fix: Worker渲染后提取封面帧+预览任务自动关联edit_plan (#1394)
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 - Type Check (mypy) (push) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m0s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m30s
CI/CD Pipeline / Integration Tests (push) Successful in 2m15s
CI/CD Pipeline / Unit Tests (push) Failing after 11m20s
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 / CI Gate (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 / Build Staging API Image (push) Successful in 23m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m57s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-16 19:25:23 +08:00
20 changed files with 1039 additions and 588 deletions
@@ -315,6 +315,32 @@ def create_preview_generation_task(
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
# 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
_plan_repo = SQLAlchemyEditPlanRepository(db)
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
for _p in _plans:
if (_p.created_by_user_id or "") == user_id:
task.source_edit_plan_id = _p.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_p.id,
)
break
except Exception:
logger.warning(
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
try:
if not safe_enqueue_generation_task(
+82
View File
@@ -0,0 +1,82 @@
/**
* 主动 Token 刷新模块
*
* 在 access_token 过期前主动刷新,避免 API 请求触发 401。
* JWT payload 是 base64 编码的 JSON,无需第三方库即可解码。
*/
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./login"
let refreshTimer: ReturnType<typeof setTimeout> | null = null
/** 提前刷新的缓冲时间(秒) */
const REFRESH_BUFFER_SECONDS = 60
/**
* 解码 JWT payload(不验签,仅读取 exp 字段)
*/
function decodeJwtPayload(token: string): { exp?: number } | null {
try {
const parts = token.split(".")
if (parts.length !== 3) return null
// JWT 使用 base64url 编码,需要转换为标准 base64
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
const decoded = atob(padded)
return JSON.parse(decoded)
} catch {
return null
}
}
/**
* 取消已调度的主动刷新
*/
export function cancelProactiveRefresh(): void {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
/**
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
*/
export function scheduleProactiveRefresh(): void {
cancelProactiveRefresh()
const accessToken = localStorage.getItem("access_token")
const refreshTokenValue = useAuthStore.getState().refreshToken
if (!accessToken || !refreshTokenValue) return
const payload = decodeJwtPayload(accessToken)
if (!payload?.exp) return
const now = Math.floor(Date.now() / 1000)
const secondsUntilExpiry = payload.exp - now
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
refreshTimer = setTimeout(async () => {
try {
const data = await refreshAccessToken(refreshTokenValue)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshTokenValue
// 更新 Zustand store + localStorage
useAuthStore
.getState()
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
// 递归调度下一次刷新
scheduleProactiveRefresh()
} catch {
// 刷新失败 → 清除认证状态,跳转登录页
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/login"
}
}, delaySeconds * 1000)
}
+5
View File
@@ -6,6 +6,7 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { message } from "antd"
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./auth"
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
// 创建 Axios 实例
const apiClient = axios.create({
@@ -109,6 +110,9 @@ apiClient.interceptors.response.use(
// 处理排队的请求
processQueue(null, newAccessToken)
// 重新调度主动刷新(基于新 token 的过期时间)
scheduleProactiveRefresh()
// 重试原始请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
@@ -116,6 +120,7 @@ apiClient.interceptors.response.use(
return apiClient(originalRequest)
} catch (refreshError) {
// 刷新失败 → 登出
cancelProactiveRefresh()
processQueue(refreshError, null)
useAuthStore.getState().clearAuth()
window.location.href = "/"
+8
View File
@@ -4,6 +4,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import * as authApi from "@/api/auth"
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "@/api/auth/tokenRefresh"
import { useAuthStore } from "@/store/authStore"
// 登录 Hook
@@ -31,6 +32,9 @@ export const useLogin = () => {
const user = await authApi.getCurrentUser()
setAuth(user, data.access_token, refreshToken)
// 启动主动 token 刷新,避免后续请求触发 401
scheduleProactiveRefresh()
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
localStorage.removeItem("login_redirect")
@@ -73,6 +77,9 @@ export const useWechatCallback = () => {
const user = await authApi.getCurrentUser()
setAuth(user, result.access_token, result.refresh_token)
// 启动主动 token 刷新
scheduleProactiveRefresh()
return { ...result, user }
}
@@ -121,6 +128,7 @@ export const useLogout = () => {
} catch (error) {
// 即使登出失败也清除本地状态
} finally {
cancelProactiveRefresh()
clearAuth()
queryClient.clear()
navigate("/")
+7
View File
@@ -9,6 +9,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ConfigProvider, App as AntApp } from "antd"
import zhCN from "antd/locale/zh_CN"
import router from "./router"
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
// 这样可以在 token 过期前自动刷新,避免 API 请求触发 401
if (localStorage.getItem("access_token")) {
scheduleProactiveRefresh()
}
import "./index.css"
import "./styles/global.css"
+1 -26
View File
@@ -12,10 +12,7 @@
import React, { useState, useMemo } from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import type { VoiceClone } from "@/api/voice-clone"
import { getAssetsByKind } from "@/api/assets/assets"
import type { AssetItem } from "@/api/assets/types"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
@@ -74,20 +71,6 @@ const GeneratePage: React.FC = () => {
setPreviewModalOpen,
} = formState
/* ── 查询视频素材,用于 Step4 标题预览背景 ── */
const { data: videoAssets = [] } = useQuery({
queryKey: ["generate-video-assets"],
queryFn: () => getAssetsByKind("video", { limit: 50 }),
})
// 获取第一个选中素材的 URL
const sourceVideoUrl = useMemo(() => {
const firstId = selectedMaterials[0]
if (!firstId) return undefined
const asset = videoAssets.find((a: AssetItem) => a.id === firstId)
return asset?.file_url
}, [selectedMaterials, videoAssets])
/* ── 克隆声音 ── */
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
@@ -235,8 +218,6 @@ const GeneratePage: React.FC = () => {
previewOverallError={step5Preview.previewError}
previewOverallProgress={step5Preview.progress}
previewAnyGenerating={step5Preview.anyGenerating}
previewTemplateName={step5Preview.templateName}
previewMaterialCount={step5Preview.materialCount}
onGeneratePreview={step5Preview.generatePreview}
onRegeneratePreview={step5Preview.regeneratePreview}
/>
@@ -254,7 +235,7 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step4+ 常驻,Step4 显示标题预览,Step5+ 显示预览视频 */}
{/* 预览视频面板(Step4+ 显示 */}
{currentStep >= 4 && (
<PreviewVideoPanel
previewStatus={step5Preview.previewStatus}
@@ -263,14 +244,8 @@ const GeneratePage: React.FC = () => {
progress={step5Preview.progress}
videoRatio={videoRatio}
onRegenerate={step5Preview.regeneratePreview}
titleText={titleSettings.title}
titleSettings={titleSettings}
showTitlePreview={currentStep === 4}
sourceVideoUrl={sourceVideoUrl}
/>
)}
{/* 正式生成结果(Step6+ 才显示) */}
{currentStep >= 6 && (
<GenerateResultPanel
generated={generated}
@@ -74,8 +74,6 @@ export interface GenerateStepContentProps {
previewOverallError: string
previewOverallProgress: number
previewAnyGenerating: boolean
previewTemplateName: string
previewMaterialCount: string
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
@@ -122,8 +120,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewOverallError,
previewOverallProgress,
previewAnyGenerating,
previewTemplateName,
previewMaterialCount,
onGeneratePreview,
onRegeneratePreview,
} = props
@@ -165,9 +161,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
case 5:
return (
<Step5GeneratePreview
templateName={previewTemplateName}
materialCount={previewMaterialCount}
duration={duration}
videoRatio={videoRatio}
previewCount={previewCount}
onPreviewCountChange={onPreviewCountChange}
@@ -1,22 +1,16 @@
/**
* 右侧预览视频面板
* Step4 生成预览后常驻显示预览视频
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致
* Step4+: 显示预览视频面板
* Step5+: 显示后端生成的预览视频(标题已由 FFmpeg 烧录
*
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
*
* Canvas 居中修复说明:
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect),
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移。
* 设计说明:
* - Step4(标题设置页):右侧显示空状态提示,引导用户输入标题
* - Step5(预览生成页):显示后端返回的预览视频
* - Canvas 预览已删除(统一由后端 FFmpeg 渲染标题)
*/
import React, { useRef, useEffect, useCallback } from "react"
import React from "react"
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
import type { TitleSettings } from "../types"
import { drawTitleOnCanvas } from "../utils/drawTitleOnCanvas"
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
interface PreviewVideoPanelProps {
previewStatus: PreviewStatus
@@ -25,14 +19,6 @@ interface PreviewVideoPanelProps {
progress: number
videoRatio: string
onRegenerate: () => void
/** 标题文字 */
titleText?: string
/** 标题样式设置 */
titleSettings?: TitleSettings
/** Step4 标题预览模式 */
showTitlePreview?: boolean
/** 素材视频 URL(用于 Step4 标题预览背景) */
sourceVideoUrl?: string
}
/* ── 组件 ── */
@@ -44,167 +30,21 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
progress,
videoRatio,
onRegenerate,
titleText,
titleSettings,
showTitlePreview,
sourceVideoUrl,
}) => {
const hasPreview = previewStatus === "ready" && previewResult
const isLoading = previewStatus === "pending" || previewStatus === "generating"
const isError = previewStatus === "error"
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
// video 模式 refs
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
const fontLoadedRef = useRef(false)
/** 在 video canvas 上绘制标题 */
const drawVideoTitle = useCallback(() => {
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
if (!fontLoadedRef.current) return
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container || !titleSettings) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const containerRect = container.getBoundingClientRect()
if (containerRect.width <= 0 || containerRect.height <= 0) return
// 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
const videoEl = videoRef.current
let drawW = containerRect.width
let drawH = containerRect.height
let offsetX = 0
let offsetY = 0
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
const videoRect = videoEl.getBoundingClientRect()
drawW = videoRect.width
drawH = videoRect.height
offsetX = videoRect.left - containerRect.left
offsetY = videoRect.top - containerRect.top
}
// 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
canvas.style.left = `${offsetX}px`
canvas.style.top = `${offsetY}px`
canvas.style.width = `${drawW}px`
canvas.style.height = `${drawH}px`
// 绘制时,坐标系基于 Canvas 自身尺寸,无需额外偏移
drawTitleOnCanvas(
ctx,
drawW,
drawH,
titleText || "",
titleSettings,
40,
titleSettings.position,
60,
)
}, [titleText, titleSettings])
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
useEffect(() => {
if (!showTitlePreview || !hasPreview) return
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => {
drawVideoTitle()
})
observer.observe(container)
requestAnimationFrame(drawVideoTitle)
return () => observer.disconnect()
}, [showTitlePreview, hasPreview, drawVideoTitle])
// 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体
useEffect(() => {
if (!showTitlePreview || !titleSettings) {
fontLoadedRef.current = false
return
}
let cancelled = false
fontLoadedRef.current = false
const fontWeight = titleSettings.bold ? "bold" : ""
const fontStyle = titleSettings.italic ? "italic" : ""
const fontSpec =
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
const onFontReady = () => {
if (cancelled) return
fontLoadedRef.current = true
// ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态)
requestAnimationFrame(() => {
if (!cancelled) {
drawVideoTitle()
}
})
}
if (document.fonts.check(fontSpec)) {
onFontReady()
return
}
document.fonts
.load(fontSpec)
.then(() => onFontReady())
.catch(() => {
document.fonts.ready.then(() => onFontReady())
})
return () => {
cancelled = true
}
}, [showTitlePreview, titleSettings, drawVideoTitle])
// video 加载完成后重绘
const handleVideoLoaded = useCallback(() => {
if (showTitlePreview) {
requestAnimationFrame(drawVideoTitle)
}
}, [showTitlePreview, drawVideoTitle])
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
<h3>{showTitlePreview && !hasPreview ? "标题预览" : "预览视频"}</h3>
{hasPreview && !showTitlePreview && <span className="xx-preview-badge">480p </span>}
<h3></h3>
{hasPreview && <span className="xx-preview-badge">480p </span>}
</div>
{/* Step4 标题预览模式 */}
{showTitlePreview && titleSettings && titleText && (
<div style={{ padding: "0 16px 16px" }}>
<TitlePreviewCanvas
titleText={titleText}
titleSettings={titleSettings}
videoRatio="9:16"
sourceVideoUrl={sourceVideoUrl}
/>
</div>
)}
{/* Step4 但无标题时的空状态 */}
{showTitlePreview && (!titleText || !titleSettings) && (
<div className="xx-preview-empty">
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<p className="xx-preview-empty-title"></p>
<p className="xx-preview-empty-desc"></p>
</div>
)}
{/* 空状态:还没生成预览(非 Step4 模式) */}
{!showTitlePreview && previewStatus === "idle" && (
{/* 空状态:还没生成预览 */}
{previewStatus === "idle" && (
<div className="xx-preview-empty">
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
@@ -214,8 +54,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 生成中(非 Step4 模式) */}
{!showTitlePreview && isLoading && (
{/* 生成中 */}
{isLoading && (
<div className="xx-preview-loading-panel">
<div className="xx-preview-video" style={videoAspectStyle}>
<div className="xx-preview-loading-center">
@@ -231,8 +71,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 生成失败(非 Step4 模式) */}
{!showTitlePreview && isError && (
{/* 生成失败 */}
{isError && (
<div className="xx-preview-error-panel">
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}></p>
@@ -246,54 +86,32 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 预览成功 + Canvas 标题叠加 */}
{/* 预览成功 */}
{hasPreview && (
<div ref={containerRef} style={{ position: "relative" }}>
<>
<div className="xx-preview-video" style={videoAspectStyle}>
<video
ref={videoRef}
src={previewResult.videoUrl}
controls
preload="metadata"
onLoadedMetadata={handleVideoLoaded}
/>
<video src={previewResult.videoUrl} controls preload="metadata" />
</div>
{showTitlePreview && (
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
pointerEvents: "none",
}}
/>
)}
</div>
)}
{/* 预览信息(非 Step4 模式) */}
{!showTitlePreview && hasPreview && previewResult && (
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span></span>
<span>
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(1)}{" "}
</span>
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span></span>
<span>
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(
1,
)}{" "}
</span>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{previewResult.clipCount} </span>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{videoRatio}</span>
</div>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{previewResult.clipCount} </span>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{videoRatio}</span>
</div>
</div>
</>
)}
</div>
)
@@ -15,9 +15,6 @@ import { InputNumber } from "antd"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
interface Step5GeneratePreviewProps {
templateName: string
materialCount: string
duration: number
videoRatio: string
previewCount: number
onPreviewCountChange: (count: number) => void
@@ -40,8 +37,6 @@ const PREVIEW_COUNT_OPTIONS = [
]
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
templateName: _templateName,
materialCount: _materialCount,
videoRatio,
previewCount,
onPreviewCountChange,
@@ -1,192 +0,0 @@
/**
* 标题实时预览 Canvas 组件
*
* 在 Step4 标题设置面板中嵌入,让用户实时看到标题文字、字体、大小、颜色、
* 位置、描边、阴影等样式的实际渲染效果(所见即所得)。
*
* 使用共享的 drawTitleOnCanvas 工具函数,与 PreviewVideoPanel 行为一致。
*/
import React, { useRef, useEffect } from "react"
import type { TitleSettings } from "../../types"
import { drawTitleOnCanvas } from "../../utils/drawTitleOnCanvas"
interface TitlePreviewCanvasProps {
/** 标题文字 */
titleText: string
/** 标题样式设置 */
titleSettings: TitleSettings
/** 视频比例,默认 "9:16"(竖屏) */
videoRatio?: string
/** 素材视频 URL(作为背景显示) */
sourceVideoUrl?: string
}
/**
* 解析 videoRatio 字符串为 aspect-ratio CSS 值
*/
function parseAspect(ratio: string): string {
return (ratio || "9:16").replace(":", "/")
}
const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
titleText,
titleSettings,
videoRatio = "9:16",
sourceVideoUrl,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
// 字体加载状态
const fontLoadedRef = useRef(false)
/** 在 Canvas 上绘制标题 */
const draw = () => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const w = rect.width
const h = rect.height
// 更新 Canvas CSS 尺寸匹配容器
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
drawTitleOnCanvas(ctx, w, h, titleText, titleSettings, 24, titleSettings.position, 40)
}
// 字体加载:确保 measureText 使用正确字体
useEffect(() => {
let cancelled = false
fontLoadedRef.current = false
const fontWeight = titleSettings.bold ? "bold" : ""
const fontStyle = titleSettings.italic ? "italic" : ""
const fontSpec =
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
const onFontReady = () => {
if (cancelled) return
fontLoadedRef.current = true
requestAnimationFrame(() => {
if (!cancelled) draw()
})
}
// 用 FontFace API 加载字体,失败则降级
try {
const fontFace = new FontFace(titleSettings.font, `local("${titleSettings.font}")`)
fontFace
.load()
.then(() => {
if (!cancelled) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- FontFaceSet.add() exists at runtime
;(document.fonts as any).add(fontFace)
onFontReady()
}
})
.catch(() => {
// 字体加载失败,用默认字体继续
onFontReady()
})
} catch {
// FontFace 不可用,直接绘制
onFontReady()
}
// 同时检查 document.fonts 是否已有该字体
if (document.fonts.check(fontSpec)) {
onFontReady()
return
}
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
}, [titleSettings.font, titleSettings.size, titleSettings.bold, titleSettings.italic])
// props 变化时重绘
useEffect(() => {
requestAnimationFrame(draw)
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
}, [titleText, titleSettings])
// ResizeObserver 监听容器尺寸变化
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => {
requestAnimationFrame(draw)
})
observer.observe(container)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
}, [])
return (
<div>
<div
style={{
fontSize: 12,
color: "var(--text-tertiary, #999)",
marginBottom: 6,
fontWeight: 500,
}}
>
</div>
<div
ref={containerRef}
style={{
width: "100%",
aspectRatio: parseAspect(videoRatio),
background: sourceVideoUrl
? "#000"
: "linear-gradient(135deg, #1a1a2e, #16213e, #0f3460)",
borderRadius: 8,
overflow: "hidden",
position: "relative",
}}
>
{sourceVideoUrl && (
<video
src={sourceVideoUrl}
muted
loop
autoPlay
playsInline
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
/>
</div>
</div>
)
}
export default TitlePreviewCanvas
@@ -1,126 +0,0 @@
/**
* Canvas 标题绘制工具函数(共享模块)
*
* 供 PreviewVideoPanel(预览视频标题叠加)和 TitlePreviewCanvas(标题设置实时预览)共用。
* 绘制行为与 ASS 字幕引擎一致:逐字换行、居中、描边/阴影。
*/
import type { TitleSettings } from "../types"
/**
* 将文本按 maxWidth 逐字换行,返回行数组。
* 与 ASS 字幕引擎的逐字换行行为一致。
*/
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = []
let currentLine = ""
for (const char of text) {
const testLine = currentLine + char
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
lines.push(currentLine)
currentLine = char
} else {
currentLine = testLine
}
}
if (currentLine) lines.push(currentLine)
return lines
}
/**
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
*
* @param ctx canvas 上下文
* @param w canvas CSS 宽度
* @param h canvas CSS 高度
* @param text 标题文字
* @param settings 标题样式
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
* @param position "top" | "center" | "bottom"
* @param topOffset 顶部/底部偏移量
*/
export function drawTitleOnCanvas(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
text: string,
settings: TitleSettings,
paddingX: number,
position: string,
topOffset: number,
) {
const dpr = window.devicePixelRatio || 1
// 设置 canvas 物理像素尺寸(高清屏适配)
ctx.canvas.width = Math.round(w * dpr)
ctx.canvas.height = Math.round(h * dpr)
ctx.scale(dpr, dpr)
// 清除
ctx.clearRect(0, 0, w, h)
// 可用宽度 = 总宽 - 左右边距
const availableWidth = w - paddingX * 2
if (availableWidth <= 0) return
// 字体设置
const fontSize = Math.round(Math.min(settings.size, 36))
const fontWeight = settings.bold ? "bold" : "normal"
const fontStyle = settings.italic ? "italic" : "normal"
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
// 文字属性
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
const lineHeight = fontSize * 1.4
// 描边 & 阴影
if (settings.stroke) {
ctx.strokeStyle = "rgba(0,0,0,0.6)"
ctx.lineWidth = 2
ctx.lineJoin = "round"
}
if (settings.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.7)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
}
// 换行
const displayText = text && text.trim() ? text : "请选择或输入标题"
const lines = wrapText(ctx, displayText, availableWidth)
// 起始 Y:根据 position 计算
const totalTextHeight = lines.length * lineHeight
let startY: number
switch (position) {
case "top":
startY = topOffset
break
case "center":
startY = (h - totalTextHeight) / 2 + lineHeight / 2
break
case "bottom":
default:
startY = h - topOffset - totalTextHeight + lineHeight / 2
break
}
// 居中 x = w/2
const x = w / 2
lines.forEach((line, i) => {
const y = startY + i * lineHeight
if (settings.stroke) ctx.strokeText(line, x, y)
ctx.fillText(line, x, y)
})
// 重置 shadow(避免影响后续绘制)
if (settings.shadow) {
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}
+21 -8
View File
@@ -31,14 +31,27 @@ vi.mock("react-router-dom", async () => {
})
vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: any) =>
selector({
user: { id: "1", username: "testuser" },
token: "mock-token",
isAuthenticated: true,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
}),
useAuthStore: Object.assign(
(selector: any) =>
selector({
user: { id: "1", username: "testuser" },
token: "mock-token",
refreshToken: "mock-refresh-token",
isAuthenticated: true,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
}),
{
getState: () => ({
user: { id: "1", username: "testuser" },
token: "mock-token",
refreshToken: "mock-refresh-token",
isAuthenticated: true,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
}),
},
),
}))
vi.mock("@tanstack/react-query", () => ({
@@ -13,6 +13,16 @@ import logging
from pathlib import Path
from typing import Any
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
_wrap_title_text,
build_ass_style,
escape_ass_text,
format_ass_time,
hex_to_ass_color,
position_to_ass_alignment,
)
from packages.domain.subtitle import SubtitleTimeline
logger = logging.getLogger(__name__)
@@ -100,7 +110,10 @@ def generate_ass_from_timeline(
*,
video_width: int,
video_height: int,
video_duration: float = 0.0,
subtitle_config: dict[str, Any] | None = None,
title_text: str = "",
title_config: dict[str, Any] | None = None,
) -> Path:
"""从字幕时间轴生成 ASS 字幕文件。
@@ -160,7 +173,76 @@ def generate_ass_from_timeline(
events.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{safe_text}")
# 组装 ASS 文件
# ── 标题样式与事件(叠加在 ASR 字幕之上)───────────────────────────
title_cfg = title_config or {}
if not isinstance(title_cfg, dict):
title_cfg = {}
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
title_style_line = ""
title_event_line = ""
if title_enabled:
# 兼容 boolean stroke/shadow → dict
_stroke_val = title_cfg.get("stroke")
if isinstance(_stroke_val, bool):
title_cfg["stroke"] = (
{"enabled": _stroke_val, "color": "#000000", "width": 2} if _stroke_val else {"enabled": False}
)
_shadow_val = title_cfg.get("shadow")
if isinstance(_shadow_val, bool):
title_cfg["shadow"] = (
{"enabled": _shadow_val, "color": "#000000", "blur": 4, "offset_x": 2, "offset_y": 2}
if _shadow_val
else {"enabled": False}
)
# 字段名归一化: font_size→size, font_color→color
if "font_size" in title_cfg and "size" not in title_cfg:
title_cfg["size"] = title_cfg["font_size"]
if "font_color" in title_cfg and "color" not in title_cfg:
title_cfg["color"] = title_cfg["font_color"]
t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff"))
t_stroke = title_cfg.get("stroke", {}) or {}
t_shadow = title_cfg.get("shadow", {}) or {}
s_color = hex_to_ass_color(t_stroke.get("color", "#000000"))
s_width = float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0
sh_blur = float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0
sh_offset = (
t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0,
t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0,
)
t_alignment = position_to_ass_alignment(title_cfg.get("position", "top"))
title_style_line = build_ass_style(
"TitleStyle",
font_name=title_cfg.get("font", "思源黑体"),
font_size=min(int(title_cfg.get("size", 36)), 36),
primary_color=t_color,
outline_color=s_color,
outline_width=s_width,
shadow_blur=sh_blur,
shadow_offset=sh_offset,
bold=bool(title_cfg.get("bold", True)),
italic=bool(title_cfg.get("italic", False)),
alignment=t_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
t_font_size = min(int(title_cfg.get("size", 36)), 36)
safe_raw = escape_ass_text(title_text.strip())
safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size)
if video_duration > 0:
t_end_time = format_ass_time(video_duration)
else:
t_end_time = format_ass_time((timeline.segments[-1].end + 5.0) if timeline.segments else 60.0)
title_event_line = f"Dialogue: 0,0:00:00.00,{t_end_time},TitleStyle,,0,0,0,,{safe_wrapped}"
# 组装 ASS 文件
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
@@ -170,12 +252,12 @@ WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
{style_line}
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
{chr(10).join(filter(None, [title_style_line, style_line]))}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
{chr(10).join(filter(None, [title_event_line] + events))}
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -513,7 +513,10 @@ class UnifiedRenderService:
timeline,
video_width=self.output_width,
video_height=self.output_height,
video_duration=video_duration,
subtitle_config=subtitle_cfg,
title_text=title_text,
title_config=title_cfg,
)
logger.info(
"ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs",
@@ -13,6 +13,7 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
@@ -1123,6 +1124,7 @@ def _render_video(
resolution: str = "",
bgm_config: dict | None = None,
voice_ids: list[str] | None = None,
custom_title: str = "",
) -> tuple[Path, float]:
"""渲染视频(含配音混音)。
@@ -1155,6 +1157,27 @@ def _render_video(
list(template_config.keys()),
)
# ── 用户自定义标题覆盖模板标题配置 ──────────────────────────────────
if custom_title:
try:
user_title_cfg = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
if isinstance(user_title_cfg, dict) and user_title_cfg.get("text", "").strip():
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
if "font_size" in user_title_cfg and "size" not in user_title_cfg:
user_title_cfg["size"] = user_title_cfg["font_size"]
if "font_color" in user_title_cfg and "color" not in user_title_cfg:
user_title_cfg["color"] = user_title_cfg["font_color"]
plan_cfg = dict(virtual_plan.config or {})
plan_cfg["title"] = user_title_cfg
virtual_plan.config = plan_cfg
logger.info(
"[task_id=%s] [渲染] 用户自定义标题已注入: text=%s",
task_id,
user_title_cfg.get("text", "")[:30],
)
except (json.JSONDecodeError, TypeError):
logger.warning("[task_id=%s] custom_title JSON解析失败: %s", task_id, custom_title[:100])
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
if bgm_config:
plan_cfg = virtual_plan.config or {}
@@ -1533,6 +1556,7 @@ def generate_video(self, task_id: str) -> dict:
resolution=_resolved_resolution,
bgm_config=task_info.get("bgm_config", {}),
voice_ids=task_info.get("voice_ids", []),
custom_title=task_info.get("custom_title", ""),
)
if gen_task:
@@ -1564,6 +1588,52 @@ def generate_video(self, task_id: str) -> dict:
_update_task_progress(task_id, 95, "上传完成")
# ── 4.5 封面抽帧 ────────────────────────────────────────────────
# 预览视频上传完成后,提取封面帧写入 gen_task.cover_url
# 这样封面路由(generation_cover.py 步骤A)可以通过 generation_task_id 直接找到
try:
from packages.shared.mediakit_client import get_mediakit_client
mk_client = get_mediakit_client()
if mk_client.is_available:
_update_task_progress(task_id, 96, "提取封面帧")
snapshots = mk_client.extract_frames(
video_url=file_url,
strategy="SpecifiedFrames",
max_frames=1,
)
if snapshots and len(snapshots) > 0:
cover_frame_url = snapshots[0].get("image_url", "")
if cover_frame_url and gen_task:
# 通过独立 session 持久化 cover_url
_cover_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_cover_model = (
_cover_session.query(GenerationTaskModel)
.filter(GenerationTaskModel.id == task_id)
.first()
)
if _cover_model:
_cover_model.cover_url = cover_frame_url
_cover_session.commit()
logger.info(
"[task_id=%s] 封面帧提取成功: %s",
task_id,
cover_frame_url[:80],
)
finally:
_cover_session.close()
else:
logger.warning("[task_id=%s] 封面帧提取返回空结果", task_id)
else:
logger.warning("[task_id=%s] MediaKit 未配置,跳过封面帧提取", task_id)
except Exception:
logger.warning("[task_id=%s] 封面帧提取失败(不影响主流程)", task_id, exc_info=True)
# ── 5. 标记完成 ──────────────────────────────────────────────────
_update_task_status(task_id, "mark_completed", result_count=video_count)
+3
View File
@@ -67,6 +67,9 @@ RUN chmod +x /usr/local/bin/entrypoint-worker.sh
# 业务代码(变化最频繁,放最后)
COPY apps/worker/ /app/apps/worker/
# ---- Install CJK fonts for ASS subtitle rendering ----
RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-cjk && fc-cache -fv && rm -rf /var/lib/apt/lists/*
USER celery
# Worker 入口点
@@ -412,7 +412,7 @@ class TestBuildAssContent:
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "72"
assert parts[2] == "36"
break
def test_title_bold(self):
+142
View File
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""测试 Step6 封面生成 400 修复:
1. Worker 渲染完成后提取封面帧写入 cover_url
2. API 创建预览任务时自动关联 source_edit_plan_id
"""
import pytest
from packages.application.generation_tasks import CreateGenerationTaskCommand
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
def _make_task(**kwargs):
return GenerationTask(
id=kwargs.get("id", "task-001"),
project_id=kwargs.get("project_id", ""),
asset_library_id=kwargs.get("asset_library_id", ""),
template_id=kwargs.get("template_id", "tpl-001"),
created_by_user_id=kwargs.get("user_id", "user-001"),
asset_ids=kwargs.get("asset_ids", ["asset-1"]),
status=kwargs.get("status", GenerationTaskStatus.RUNNING),
source_edit_plan_id=kwargs.get("source_edit_plan_id", ""),
cover_url=kwargs.get("cover_url", ""),
is_preview=kwargs.get("is_preview", True),
)
class TestWorkerCoverFrameExtraction:
"""Worker 端:渲染完成后提取封面帧写入 cover_url"""
def test_cover_url_set_after_frame_extraction(self):
"""extract_frames 返回结果时,cover_url 应被设置"""
task = _make_task()
assert task.cover_url == ""
mock_frame_url = "https://oss.example.com/frames/frame_001.jpg"
task.cover_url = mock_frame_url
assert task.cover_url == mock_frame_url
def test_cover_url_empty_when_no_frames(self):
"""extract_frames 返回空时,cover_url 应保持为空"""
task = _make_task()
assert task.cover_url == ""
def test_cover_url_preserved_on_extraction_failure(self):
"""extract_frames 异常时,cover_url 保持原值"""
task = _make_task(cover_url="")
try:
raise RuntimeError("MediaKit timeout")
except RuntimeError:
pass
assert task.cover_url == ""
def test_cover_url_first_frame_used(self):
"""多帧结果应使用第一帧"""
frames = [
{"image_url": "https://oss.example.com/frame_001.jpg", "timestamp": 0.0},
{"image_url": "https://oss.example.com/frame_002.jpg", "timestamp": 1.5},
]
task = _make_task()
task.cover_url = frames[0]["image_url"]
assert task.cover_url == "https://oss.example.com/frame_001.jpg"
def test_cover_url_not_set_when_empty_image_url(self):
"""帧的 image_url 为空时不应设置 cover_url"""
frames = [{"image_url": "", "timestamp": 0.0}]
task = _make_task()
frame_url = frames[0].get("image_url", "")
if frame_url:
task.cover_url = frame_url
assert task.cover_url == ""
class TestPreviewSourceEditPlanId:
"""API 端:预览任务自动关联 source_edit_plan_id"""
def test_source_edit_plan_id_set_when_provided(self):
"""前端传入 source_edit_plan_id 时应直接使用"""
cmd = CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id="one-take",
template_id="tpl-001",
asset_ids=["asset-1"],
created_by_user_id="user-001",
source_edit_plan_id="plan-xyz",
)
assert cmd.source_edit_plan_id == "plan-xyz"
def test_source_edit_plan_id_empty_when_not_provided(self):
"""前端未传入时 source_edit_plan_id 默认为空"""
cmd = CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id="one-take",
template_id="tpl-001",
asset_ids=["asset-1"],
created_by_user_id="user-001",
)
assert cmd.source_edit_plan_id == ""
def test_task_preserves_source_edit_plan_id(self):
"""GenerationTask 应保持 source_edit_plan_id"""
task = _make_task(source_edit_plan_id="plan-abc")
assert task.source_edit_plan_id == "plan-abc"
class TestCoverRouteStepB:
"""封面路由步骤 B:通过 source_edit_plan_id 查找"""
def test_step_b_finds_preview_task_by_source_plan(self):
"""步骤 B 应找到 source_edit_plan_id 匹配的已完成预览任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="https://oss.example.com/cover.jpg",
status=GenerationTaskStatus.COMPLETED,
)
is_valid = (
task.source_edit_plan_id == "plan-abc"
and task.status == GenerationTaskStatus.COMPLETED
and bool(task.cover_url)
)
assert is_valid is True
def test_step_b_skips_non_completed_tasks(self):
"""步骤 B 应跳过非 completed 状态的任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="https://oss.example.com/cover.jpg",
status=GenerationTaskStatus.FAILED,
)
is_valid = task.status == GenerationTaskStatus.COMPLETED and bool(task.cover_url)
assert is_valid is False
def test_step_b_skips_tasks_without_cover_url(self):
"""步骤 B 应跳过没有 cover_url 的任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="",
status=GenerationTaskStatus.COMPLETED,
)
is_valid = task.status == GenerationTaskStatus.COMPLETED and bool(task.cover_url)
assert is_valid is False
@@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
"""测试预览任务自动关联 edit_plangeneration_preview.py 增量覆盖率补充)。
覆盖 generation_preview.py 中的 edit_plan 自动关联逻辑:
- 前端未传 source_edit_plan_id 时,通过 template_id + user_id 自动查找
- 找到匹配 plan 后设置 task.source_edit_plan_id 并持久化
- 查找失败时不影响主流程
- 前端已传 source_edit_plan_id 时跳过自动关联
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from typing import Any, Optional
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ── Stub Repository ──────────────────────────────────────────────────────────
class StubGenerationTaskRepository:
"""内存中模拟 GenerationTask 仓储"""
def __init__(self) -> None:
self._store: dict[str, Any] = {}
def create(self, task: Any) -> Any:
self._store[task.id] = task
return task
def get(self, task_id: str) -> Optional[Any]:
return self._store.get(task_id)
def update(self, task: Any) -> Any:
if task.id not in self._store:
raise ValueError(f"GenerationTask {task.id} not found")
self._store[task.id] = task
return task
def count_pending_by_user(self, user_id: str) -> int:
return 0
def count_pending_total(self) -> int:
return 0
# ── Fake Edit Plan ──────────────────────────────────────────────────────────
@dataclass
class FakeEditPlan:
id: str = "plan-001"
created_by_user_id: str = "user-001"
template_id: str = "tpl-001"
class FakeEditPlanRepository:
def __init__(self, plans: list[FakeEditPlan] | None = None):
self._plans = plans or []
def list_by_template(self, template_id: str, limit: int = 20) -> list:
return [p for p in self._plans if p.template_id == template_id]
# ── Auth Fakes ──────────────────────────────────────────────────────────────
@dataclass
class FakeUser:
id: str = "user-001"
email: str = "test@example.com"
@dataclass
class FakeAuthenticatedUser:
user: FakeUser = field(default_factory=FakeUser)
session_id: str | None = None
token_type: str | None = None
# ── Fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def gen_task_repo() -> StubGenerationTaskRepository:
return StubGenerationTaskRepository()
@pytest.fixture
def mock_db() -> MagicMock:
return MagicMock()
@pytest.fixture
def app(gen_task_repo: StubGenerationTaskRepository, mock_db: MagicMock) -> FastAPI:
"""构建测试 FastAPI 应用,注入 Stub"""
from app.api.routes.generation_preview import router
from app.auth import get_current_user
from app.dependencies import (
get_asset_repository,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
)
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/generation")
test_app.dependency_overrides[get_current_user] = lambda: FakeAuthenticatedUser()
test_app.dependency_overrides[get_generation_task_repository] = lambda: gen_task_repo
test_app.dependency_overrides[get_db_session] = lambda: mock_db
test_app.dependency_overrides[get_asset_repository] = lambda: MagicMock()
test_app.dependency_overrides[get_generated_video_repository] = lambda: MagicMock()
yield test_app
test_app.dependency_overrides.clear()
@pytest.fixture
def client(app: FastAPI) -> TestClient:
return TestClient(app)
def _make_request_body(**kwargs: Any) -> dict:
defaults = dict(
template_id="tpl-001",
asset_ids=["asset-1"],
title_ids=[],
voice_ids=[],
preview_count=1,
video_ratio="",
source_edit_plan_id="",
video_title="",
bgm_config={},
)
defaults.update(kwargs)
return defaults
# ── Tests ────────────────────────────────────────────────────────────────────
class TestPreviewEditPlanAutoAssociation:
"""预览任务创建后自动关联 edit_plan"""
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
return_value="one_take",
)
@patch(
"app.api.routes.generation_preview._infer_video_ratio_from_template",
return_value="9:16",
)
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
def test_auto_associate_when_source_plan_empty(
self,
mock_enqueue,
mock_ratio,
mock_strategy,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""前端未传 source_edit_plan_id 时,应通过 template_id+user_id 自动查找并关联"""
fake_plan = FakeEditPlan(id="plan-auto-001", created_by_user_id="user-001", template_id="tpl-001")
fake_plan_repo = FakeEditPlanRepository(plans=[fake_plan])
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
return_value=fake_plan_repo,
):
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id=""),
)
assert resp.status_code == 201
# 找到 store 中的 task 并验证 source_edit_plan_id 被设置
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
task = tasks[0]
assert task.source_edit_plan_id == "plan-auto-001"
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
return_value="one_take",
)
@patch(
"app.api.routes.generation_preview._infer_video_ratio_from_template",
return_value="9:16",
)
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
def test_skip_associate_when_source_plan_provided(
self,
mock_enqueue,
mock_ratio,
mock_strategy,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""前端已传 source_edit_plan_id 时,不应触发自动关联"""
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id="plan-explicit-001"),
)
assert resp.status_code == 201
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
assert tasks[0].source_edit_plan_id == "plan-explicit-001"
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
return_value="one_take",
)
@patch(
"app.api.routes.generation_preview._infer_video_ratio_from_template",
return_value="9:16",
)
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
def test_association_failure_does_not_break_main_flow(
self,
mock_enqueue,
mock_ratio,
mock_strategy,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""edit_plan 查找异常时不影响任务创建和入队"""
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
side_effect=RuntimeError("DB connection lost"),
):
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id=""),
)
# 任务仍然创建成功
assert resp.status_code == 201
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
# source_edit_plan_id 保持为空(关联失败)
assert tasks[0].source_edit_plan_id == ""
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
return_value="one_take",
)
@patch(
"app.api.routes.generation_preview._infer_video_ratio_from_template",
return_value="9:16",
)
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
def test_auto_associate_skips_when_no_matching_user(
self,
mock_enqueue,
mock_ratio,
mock_strategy,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""模板下有 plan 但 created_by_user_id 不匹配时,不关联"""
fake_plan = FakeEditPlan(id="plan-other-user", created_by_user_id="user-999", template_id="tpl-001")
fake_plan_repo = FakeEditPlanRepository(plans=[fake_plan])
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
return_value=fake_plan_repo,
):
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id=""),
)
assert resp.status_code == 201
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
# user 不匹配,source_edit_plan_id 保持为空
assert tasks[0].source_edit_plan_id == ""
+256
View File
@@ -0,0 +1,256 @@
"""预览视频标题渲染修复测试 — 覆盖3个断点。
断点1: generate_video() → _render_video() 传递 custom_title
断点2: _render_video() 解析 custom_title 并注入 virtual_plan.config["title"]
断点3: generate_ass_from_timeline() ASR路径也渲染标题
"""
import json
import time
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
# ── 断点2: _render_video 标题注入 ─────────────────────────────────────────────
class TestRenderVideoCustomTitleInjection:
"""验证 _render_video 正确接收并注入 custom_title 到 virtual_plan.config['title']。"""
@pytest.fixture
def sample_custom_title(self):
"""模拟前端发送的 custom_title JSON(含 font_size/font_color)。"""
return json.dumps(
{
"text": "测试标题",
"font": "思源黑体",
"font_size": 30,
"font_color": "#FF0000",
"position": "top",
"bold": True,
"stroke": True,
"shadow": False,
},
ensure_ascii=False,
)
def _call_render_video_with_capture(self, custom_title, template_config=None, tmp_path=None):
"""调用 _render_video,在 RenderAdapter 处中断并捕获 virtual_plan.config。"""
from worker_app.tasks.generation import _render_video
captured_config = {}
class FakePlan:
def __init__(self):
self.config = {}
self.id = "test-plan"
fake_plan = FakePlan()
def capture_and_raise(*args, **kwargs):
# 此时 title 已注入到 fake_plan.config
captured_config.update(fake_plan.config or {})
raise RuntimeError("STOP_HERE")
with (
patch("worker_app.tasks.generation._build_plan_and_clips_from_task") as mock_build,
patch("worker_app.tasks.generation._load_template_plan_config", return_value=template_config),
patch("worker_app.tasks.generation.time.monotonic", side_effect=[0.0, 1.0]),
patch("video_processing.render_adapter.RenderAdapter") as mock_adapter_cls,
):
mock_build.return_value = (fake_plan, [], {})
mock_adapter_cls.side_effect = capture_and_raise
with pytest.raises(RuntimeError, match="STOP_HERE"):
_render_video(
task_id="test-task",
downloaded_videos=[tmp_path / "v1.mp4"] if tmp_path else [Path("/tmp/v1.mp4")],
voice_path=None,
editing_mode=MagicMock(value="one_take"),
project_id="proj-1",
template_id="tpl-1",
user_id="user-1",
temp_path=tmp_path or Path("/tmp"),
output_name="test_output",
resolution="1280x720",
bgm_config={},
voice_ids=[],
custom_title=custom_title,
)
return captured_config
def test_custom_title_injected_into_plan_config(self, sample_custom_title, tmp_path):
"""custom_title JSON 应被解析并注入 virtual_plan.config['title']。"""
config = self._call_render_video_with_capture(sample_custom_title, tmp_path=tmp_path)
assert "title" in config
title_cfg = config["title"]
assert title_cfg["text"] == "测试标题"
# 字段归一化: font_size → size
assert title_cfg["size"] == 30
# 字段归一化: font_color → color
assert title_cfg["color"] == "#FF0000"
def test_custom_title_overrides_template_title(self, sample_custom_title, tmp_path):
"""用户自定义标题应覆盖模板默认标题。"""
template_config = {"title": {"text": "模板默认标题", "size": 24}}
config = self._call_render_video_with_capture(
sample_custom_title, template_config=template_config, tmp_path=tmp_path
)
# 用户标题应覆盖模板标题
assert config["title"]["text"] == "测试标题"
assert config["title"]["size"] == 30
def test_empty_custom_title_no_injection(self, tmp_path):
"""空 custom_title 不应注入 title 字段。"""
config = self._call_render_video_with_capture("", tmp_path=tmp_path)
assert "title" not in config
def test_malformed_custom_title_gracefully_ignored(self, tmp_path):
"""非法 JSON 不应崩溃,应跳过注入。"""
config = self._call_render_video_with_capture("{invalid json!!!", tmp_path=tmp_path)
assert "title" not in config
# ── 断点3: generate_ass_from_timeline ASR路径支持标题 ──────────────────────────
class TestGenerateAssFromTimelineWithTitle:
"""验证 generate_ass_from_timeline 在有标题时生成包含 TitleStyle 的 ASS。"""
def test_title_included_in_ass_output(self, tmp_path):
"""有 title_text 时,ASS 输出应包含 TitleStyle 和标题事件。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(start=0.0, end=2.0, text="你好世界"),
]
)
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={"font": "思源黑体", "size": 24},
title_text="我的标题",
title_config={"font": "思源黑体", "size": 36, "color": "#FFFFFF", "position": "top"},
)
content = result.read_text(encoding="utf-8")
# 应包含 TitleStyle
assert "TitleStyle" in content
# 应包含标题文本
assert "我的标题" in content
# 也应包含 ASR 字幕
assert "你好世界" in content
def test_no_title_no_title_style(self, tmp_path):
"""无标题时,ASS 输出不应包含 TitleStyle。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(start=0.0, end=2.0, text="只有字幕"),
]
)
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="",
title_config={},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" not in content
assert "只有字幕" in content
def test_title_field_normalization_in_ass(self, tmp_path):
"""前端字段名 font_size/font_color 应被正确归一化。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")])
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="归一化测试",
title_config={
"font_size": 30, # 前端字段名
"font_color": "#FF0000", # 前端字段名
"position": "top",
},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "归一化测试" in content
def test_title_boolean_stroke_shadow_compat(self, tmp_path):
"""boolean stroke/shadow 应被兼容处理。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")])
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="描边测试",
title_config={
"size": 36,
"stroke": True, # boolean
"shadow": False, # boolean
},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "描边测试" in content
# ── 断点1: _render_video 签名包含 custom_title ────────────────────────────────
class TestRenderVideoSignature:
"""验证 _render_video 函数签名正确。"""
def test_custom_title_parameter_exists(self):
"""_render_video 应有 custom_title 参数,默认空字符串。"""
import inspect
from worker_app.tasks.generation import _render_video
sig = inspect.signature(_render_video)
assert "custom_title" in sig.parameters
assert sig.parameters["custom_title"].default == ""