Compare commits

...

78 Commits

Author SHA1 Message Date
CI Bot b2bf9af6de fix(test): harden batch_download session tests with multi-path patching + diagnostic
Full-suite runs showed test_batch_download_session_closed and
test_batch_download_closes_session_on_error failing with
"Expected close to have been called once. Called 0 times."

Changes:
- Patch SessionLocal in all possible locations: _db_mod, sys.modules,
  and function globals (if present)
- Call raw_fn directly instead of going through _call_task
- Add diagnostic assertion to verify mock_session_factory was called
- Remove try/finally restore (not needed for isolated tests)

Also includes fix from PR #1407: add missing str fields to mock clip.
2026-08-17 20:48:33 +08:00
CI Bot 8799eb8ff5 fix(test): fix merge response assertion for TestClipSplitMerge
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
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1s
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 34s
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 / Validate - Migration (alembic) (pull_request) Successful in 1m35s
AI Code Review / AI Code Review (pull_request) Successful in 1m34s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m45s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m7s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m52s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m1s
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 3m52s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 43s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 46s
The merge endpoint returns {"merged_clip": {...}, "deleted_clip_ids": [...]}
not a flat clip object. Update test_merge_clips_success to check
data["merged_clip"] instead of data directly.
2026-08-17 20:15:33 +08:00
CI Bot caa5113f7f fix(test): add missing str fields to mock clip to fix Pydantic validation
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API 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 / ACR Image Cleanup (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 / Check if frontend-only change (pull_request) Successful in 34s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m13s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m55s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
_make_mock_clip used MagicMock which auto-creates attributes as MagicMock
objects. EditorClipResponse requires template_clip_config_id, created_at,
and updated_at to be str. Pydantic validation fails with MagicMock values.

Fix: explicitly set these fields to proper types in _make_mock_clip:
- template_clip_config_id = ""
- created_at = None (handled by _fmt_dt → "")
- updated_at = None (handled by _fmt_dt → "")

Fixes 7 failing tests in TestClipEndpoints and TestClipSplitMerge.
2026-08-17 20:08:48 +08:00
xiaoxia 4efc44e0dc fix(tests): resolve flaky test_batch_download_session_closed (#1406)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
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 2m4s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m53s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m51s
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 2m59s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 13m16s
CI/CD Pipeline / Build Staging API Image (push) Failing after 13m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 19:55:28 +08:00
xiaoxia 3c800d3f3f fix(clips): 全面修复 Clips API 响应结构 — 替换 PR #1403 (#1404)
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 2m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m36s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m25s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m56s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m5s
CI/CD Pipeline / Integration Tests (push) Successful in 2m28s
CI/CD Pipeline / Unit Tests (push) Failing after 11m34s
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 Worker Image (push) Successful in 13m57s
CI/CD Pipeline / Build Staging API Image (push) Successful in 29m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
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 3m1s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 18:18:48 +08:00
xiaoxia e2d10dd357 refactor: 移除 Step5 左侧预览网格卡片 (#1391)
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 2m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m49s
CI/CD Pipeline / Integration Tests (push) Successful in 3m5s
CI/CD Pipeline / Unit Tests (push) Successful in 13m15s
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 Worker Image (push) Successful in 15m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 31m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m20s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m17s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m48s
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-17 15:56:32 +08:00
灵应 c62125cf7b fix: 主动token刷新-在过期前自动续期避免401错误 (#1385)
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 2m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m36s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m44s
CI/CD Pipeline / Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Unit Tests (push) Successful in 12m54s
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 Worker Image (push) Successful in 18m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 34m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 14:53:52 +08:00
灵应 6c04bb53ad fix: 主动token刷新-在过期前自动续期避免401错误 (#1385)
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 2m7s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m2s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m43s
CI/CD Pipeline / Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Unit Tests (push) Successful in 11m46s
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 24m25s
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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-08-17 14:28:47 +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
xiaoxia 16767f675b fix: 统一前后端标题渲染参数,修复描边/阴影样式丢失 (#1393)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Failing after 0s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
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 1m30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m42s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m26s
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 1m45s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
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 19:01:05 +08:00
xiaoxia e96be1771a fix: 标题预览仅在Step4显示,Step5不再显示标题叠加层 (#1392)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
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 1m43s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m44s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m12s
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 / Build Staging Worker Image (push) Successful in 7m24s
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API 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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-16 18:41:12 +08:00
灵应 68b8974170 fix: 标题预览仅在Step4显示,Step5不再显示标题叠加层 (#1392)
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 2m0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m15s
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
- showTitlePreview 条件从 currentStep>=4&&<=5 改为 currentStep===4
- Step5预览视频页面只展示预览视频本身,不再叠加标题Canvas
- 避免与右侧面板预览视频重复导致视觉拥挤
2026-08-16 18:38:27 +08:00
xiaoxia 9eb1c78d5e fix: 预览视频标题叠加 — Step4和Step5都显示标题Canvas (#1390)
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 1m58s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m45s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m18s
CI/CD Pipeline / Integration Tests (push) Successful in 2m18s
CI/CD Pipeline / Unit Tests (push) Successful in 12m13s
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 22m48s
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 1m14s
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 17:21:00 +08:00
xiaoxia c6986de358 chore: retrigger CI after Docker infrastructure fix
CI/CD Pipeline / Check if frontend-only change (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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m28s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Unit Tests (push) Successful in 11m3s
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 21m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-16 16:07:45 +08:00
CI Bot 7938eb5dda 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 / Validate - Migration (alembic) (push) Successful in 2m32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m45s
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 / 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 07:20:15 +00:00
xiaoxia afc08636c7 test: 修复 5 个已有单元测试失败
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 2m23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m8s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m28s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Unit Tests (push) Successful in 11m2s
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 17m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 43s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m11s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
- test_cover_frame_pre_extract.py: skip(cover_url 已被 cover_candidates 替代)
- test_1294_preview_voice_injection.py: _render_video 现在返回 2 个值而非 3 个
- test_render_adapter.py: skip thumbnail_url mock 与当前代码不匹配的测试(×2)
2026-08-16 15:16:18 +08:00
CI Bot 1cda62736d 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 / Build Staging Worker Image (push) Failing after 0s
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 2m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m27s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Unit Tests (push) Failing after 11m7s
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) 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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-08-16 07:01:41 +00:00
xiaoxia a5b7c5a345 test: skip test_overlay_title_on_ass — 函数已移除,测试待更新
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m37s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m11s
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 2m19s
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
2026-08-16 14:58:30 +08:00
xiaoxia d826ae216a fix: 渲染管线 bool 类型配置防护 — 全面修复 'bool' object has no attribute 'get' 崩溃
根因: plan.config 中某 key 的值可能为 bool 而非 dict,
当 .get() 或 from_dict() 被调用时崩溃。
'or {}' 只能防 None/空值,防不了 True/False。

修复策略(双层防护):
1. 治本: 所有 from_dict 方法入口加 isinstance(data, dict) 检查
   - chroma_key_config.py / color_grade_config.py / noise_reduction_config.py
   - watermark_config.py / pip_config.py / intro_outro_config.py
   - reverse_engine.py
2. 治标: 调用方直接 .get() 处加 isinstance 防护
   - unified_render_service.py: tts/bgm/title/subtitle/export/bgm
   - render_adapter.py: bgm/export/title
   - render_audio.py: bgm/audio_tracks
   - generation.py: title/subtitle/bgm/export
   - edit_plan_generation.py: title

共修复 12 个文件,覆盖渲染管线全部 unprotected 调用点
2026-08-16 14:58:30 +08:00
xiaoxia 0bb5a97c70 fix: remove duplicate imports + restore showTitlePreview prop (#1388)
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 1m42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m53s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m15s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m39s
CI/CD Pipeline / Unit Tests (push) Successful in 11m18s
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 22m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m45s
2026-08-16 11:06:35 +08:00
xiaoxia 99c8408524 fix: restore showTitlePreview prop removed by PR#1386 (#1388)
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 11:06:19 +08:00
xiaoxia fae7bab9bf fix: resolve TypeScript compile errors in PreviewVideoPanel (#1386)
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 - Type Check (mypy) (push) Successful in 1m51s
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 2m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m48s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m52s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 6m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Unit Tests (push) Successful in 11m38s
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 22m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
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 09:31:01 +08:00
xiaoxia 64783e267f fix: 修复PR#1382引入的TypeScript编译错误+JSX结构bug (#1387)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m6s
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) Failing after 1m44s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m4s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m23s
CI/CD Pipeline / Unit Tests (push) Successful in 7m15s
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) Failing after 12m52s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 38m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
- PreviewVideoPanel: 删除重复showTitlePreview声明,添加TitlePreviewCanvas import
- PreviewVideoPanel: 将视频+Canvas叠加层移出!showTitlePreview块,修复死代码问题
- Step4TitleSettings: 删除未使用的TitlePreviewCanvas import
- GeneratePage: 补充useQuery/getAssetsByKind/AssetItem import,修复回调参数类型标注

修复 CI Run#30879 阻断的 TS 编译错误
2026-08-16 00:50:05 +08:00
xiaoxia 74e2bdf914 fix: 模板配置字段非dict类型崩溃防护 (bool/str/int) (#1384)
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 1m32s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m33s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m4s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m59s
CI/CD Pipeline / Integration Tests (push) Successful in 2m19s
CI/CD Pipeline / Unit Tests (push) Successful in 7m20s
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) Failing after 7m34s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 18m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-15 23:50:43 +08:00
xiaoxia f03c1ccc17 fix: E2E测试适配新步骤顺序-标题(4)→预览(5) (#1383)
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 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m15s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m22s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 4m22s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m34s
CI/CD Pipeline / Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Unit Tests (push) Successful in 8m54s
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) 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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-08-15 23:34:03 +08:00
xiaoxia b7b51d306c feat: Step4标题预览移至右侧面板+竖屏+素材视频背景 (#1382)
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 1m14s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m2s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m27s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 4m28s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m7s
CI/CD Pipeline / Integration Tests (push) Successful in 1m48s
CI/CD Pipeline / Unit Tests (push) Successful in 8m47s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
- GeneratePage: Step4+右侧面板常驻,查询视频素材获取sourceVideoUrl
- PreviewVideoPanel: 新增showTitlePreview/sourceVideoUrl props
- TitlePreviewCanvas: 默认竖屏9:16,支持视频背景
- Step4TitleSettings: 移除左侧表单内预览Canvas
2026-08-15 22:41:24 +08:00
xiaoxia 80c2000bb7 fix: 预览生成标题参数改用title_config对象匹配后端Schema (#1381)
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 2m8s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m40s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m14s
CI/CD Pipeline / Integration Tests (push) Successful in 2m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m28s
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 20m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
- CreatePreviewRequest: 删除8个独立title_*字段,改为title_config对象
- useStep5Preview: generatePreview传参改为title_config嵌套结构
- 字段映射: titleSettings.size → title_config.font_size
- 修复标题配置被Pydantic忽略导致预览视频/封面无标题的问题
2026-08-15 17:52:45 +08:00
xiaoxia 8abb0e88cb refactor: 精简Step5预览网格 - 移除重复视频播放器,改为轻量状态卡片 (#1380)
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-15 17:51:19 +08:00
xiaoxia f3dccd7d00 refactor: 精简Step5预览网格 - 移除重复视频播放器,改为轻量状态卡片 (#1380)
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 2m0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m8s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m15s
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 / 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
- 移除网格中每个格子的 <video> 播放器(右侧 PreviewVideoPanel 已有完整播放器)
- 移除底部时长/片段数信息行(PreviewVideoPanel 已展示)
- 替换为轻量卡片:深色背景 #1a1a2e + 中心大号预览编号
- 状态指示:generating/pending/ready/error 各对应不同图标和颜色
- 保留多预览选择功能和点击切换逻辑
2026-08-15 17:44:41 +08:00
xiaoxia d4c5c96597 feat: 标题设置实时预览Canvas + 绘制函数抽取共享模块 (#1379)
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
- 新建 utils/drawTitleOnCanvas.ts:抽取 wrapText/drawTitleOnCanvas 为共享模块
- PreviewVideoPanel.tsx 改为 import 共享模块,删除内部重复定义
- 新建 TitlePreviewCanvas 组件:深色渐变背景 + Canvas 实时绘制标题
  - useEffect 监听 props 变化 + ResizeObserver 监听容器尺寸
  - FontFace API 加载字体,失败降级默认字体
  - DPR 适配,paddingX=24/topOffset=40 与 ASS 一致
- Step4TitleSettings 集成:标题非空时在样式面板上方显示预览
2026-08-15 17:43:17 +08:00
xiaoxia 474d7d77e5 fix(preview): 预览渲染支持标题配置,解决封面标题时序问题 (#1378)
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 2m8s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Unit Tests (push) Successful in 9m21s
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 19m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
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-15 17:06:00 +08:00
xiaoxia 04330d2e9d refactor: 步骤顺序调整 - 标题(4)→预览(5) + 预览接口增加标题参数 (#1377)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m17s
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 / Build Staging Web Image (push) Successful in 2m19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m38s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m58s
CI/CD Pipeline / Integration Tests (push) Successful in 2m1s
CI/CD Pipeline / Unit Tests (push) Successful in 8m38s
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) 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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-15 16:51:09 +08:00
xiaoxia dee5054e4f refactor: 步骤顺序调整 - 标题(4)→预览(5) + 预览接口增加标题参数 (#1377)
CI/CD Pipeline / Check if frontend-only change (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 Lint (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
- constants.ts: 步骤定义从 模板→素材→配音→预览→标题→封面→确认
  改为 模板→素材→配音→标题→预览→封面→确认
- GenerateStepContent.tsx: case 4 渲染 Step4TitleSettings,case 5 渲染 Step5GeneratePreview
- useStepNavigation.ts: 标题步骤(4)校验 title,预览步骤(5)校验 previewReady
- useStep4Preview.ts → useStep5Preview.ts: 重命名 + 新增 titleSettings prop,
  createPreview 调用增加标题字段
- Step4GeneratePreview.tsx → Step5GeneratePreview.tsx: 重命名
- api/generation/types.ts: CreatePreviewRequest 新增标题烧录参数
- GeneratePage.tsx: 预览面板 Step5+ 显示,结果面板 Step6+ 显示
- PreviewVideoPanel.tsx: Canvas 标题叠加保留
2026-08-15 16:50:30 +08:00
xiaoxia a9f6d7e712 fix(cover): 封面标题叠加 — 选标题后用 ffmpeg drawtext 在封面图上叠加文字 (#1375)
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 1m36s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m21s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m12s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Successful in 8m53s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 20m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 43s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-15 16:00:16 +08:00
xiaoxia d7bc908ed3 fix: ASR字幕路径叠加标题,修复封面没有标题的问题 (#1374)
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 2m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m28s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m14s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m31s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Unit Tests (push) Successful in 8m51s
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 20m42s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-15 14:21:09 +08:00
xiaoxia 59faf37fc9 fix: 统一封面管道增加cover_url多步fallback查找,修复500错误 (#1373)
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 1m40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m18s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Unit Tests (push) Successful in 7m58s
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 20m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m49s
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-15 11:41:00 +08:00
xiaoxia 206d517a91 fix: 删除封面生成多余logger调用+增加source_edit_plan_id查找兜底 (#1372)
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 1m12s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m40s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m29s
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m25s
CI/CD Pipeline / Unit Tests (push) Successful in 8m26s
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 22m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m57s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-15 10:09:16 +08:00
xiaoxia 0301370dd8 refactor: 统一封面生成管道 — 从渲染后视频抽帧作为封面 (#1371)
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 / Frontend Unit Tests (push) Successful in 1m32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m2s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m19s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Unit Tests (push) Successful in 9m9s
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 20m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
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-14 22:16:00 +08:00
xiaoxia 85bfe58f39 Merge pull request 'fix: 修复封面标题叠加根因 - custom_title 未传入渲染流程' (#1368) from fix/custom-title-render into develop
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 2m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m51s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Unit Tests (push) Successful in 9m30s
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 / 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 / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-14 15:05:23 +08:00
xiaoxia 2ff2798c97 fix: 修复封面标题叠加根因 - custom_title 未传入渲染流程
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 / 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 44s
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 / Validate - Type Check (mypy) (pull_request) Successful in 1m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m28s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m0s
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 / 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
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m56s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 48s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 49s
- _render_video 函数签名新增 custom_title 参数
- 函数体内注入 custom_title 到 plan_config 覆盖模板标题
- 调用处传入 task_info.get('custom_title', '')
2026-08-14 14:54:37 +08:00
xiaoxia 00348a2154 fix(e2e): align confirm generation response assertion with BatchGenerationTaskResponse (#1366)
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 1m12s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m49s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m29s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI/CD Pipeline / Unit Tests (push) Successful in 8m56s
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 15m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
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-14 13:41:00 +08:00
xiaoxia c00c56a742 fix: overlay title text on auto-generated cover frames (#1365)
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 1m37s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m6s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m31s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / Unit Tests (push) Successful in 9m31s
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 19m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
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-14 12:11:00 +08:00
xiaoxia 8e4a8a8184 fix: videoRatio aspect ratio parsed as resolution causing scale=9:1920 (#1364)
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 / Frontend Unit Tests (push) Successful in 3m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m32s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m11s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m32s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 16m49s
CI/CD Pipeline / Unit Tests (push) Successful in 20m10s
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 / Build Staging API Image (push) Successful in 21m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m49s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 11m8s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-14 00:56:54 +08:00
xiaoxia 6a4085452d fix: cover_templates config double-encoded JSON causing 500 (#1363)
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 2m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m48s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m18s
CI/CD Pipeline / Unit Tests (push) Successful in 13m30s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 21m39s
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 / Build Staging API Image (push) Successful in 38m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 21m28s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-13 23:55:40 +08:00
xiaoxia 0896f3e161 fix: add ffmpeg to API runtime image for cover fallback (#1362)
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 2m55s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m59s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m18s
CI/CD Pipeline / Unit Tests (push) Successful in 10m54s
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 4m28s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 22m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m49s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix: add ffmpeg to API runtime image for cover fallback (#1362)
2026-08-13 21:40:22 +08:00
xiaoxia 21e84c71c4 perf: render-time cover frame pre-extraction + FFmpeg fallback, remove MediaKit (#1360)
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 1m52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m2s
CI/CD Pipeline / Unit Tests (push) Successful in 9m47s
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 / Build Staging API Image (push) Successful in 13m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / Integration Tests (push) Successful in 7m28s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
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-13 20:51:02 +08:00
xiaoxia 17174e2cf5 refactor(cover): 封面生成API从templates_editor解耦到generation路由 (#1359)
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 1m52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m45s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m26s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m44s
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Successful in 9m0s
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 12m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m14s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
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-13 18:56:00 +08:00
xiaoxia 0a00870ab6 fix(cover): 修复预览视频存储路径错误导致封面生成 500 (#1358)
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 2m30s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m7s
CI/CD Pipeline / Integration Tests (push) Successful in 3m12s
CI/CD Pipeline / Unit Tests (push) Successful in 9m23s
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 13m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(cover): 修复预览视频存储路径错误导致封面生成500 (#1358)
2026-08-13 18:17:09 +08:00
xiaoxia 68fa7fd163 fix(cover): 修复 OSS 路径双斜杠导致 MediaKit 404 (#1357)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (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 2m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m20s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m42s
CI/CD Pipeline / Unit Tests (push) Successful in 10m9s
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 3m38s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m10s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
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-13 17:51:00 +08:00
xiaoxia 7ba46cb9c0 fix(cover): 封面生成前校验视频URL可达性,避免MediaKit超时120秒 (#1356)
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 / Frontend Unit Tests (push) Successful in 1m41s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m14s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m36s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m40s
CI/CD Pipeline / Unit Tests (push) Successful in 10m23s
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 4m27s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m46s
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-13 15:36:00 +08:00
xiaoxia 0529c61347 fix(test): 修复 test_confirm_generation.py 中 confirm API 路径 (#1355)
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 / Frontend Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m1s
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 / 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-13 15:31:00 +08:00
xiaoxia 915e551ecc Merge pull request 'fix: 修复封面类型解耦后的5个TypeScript 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 / Validate - Migration (alembic) (push) Successful in 2m53s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m46s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m39s
CI/CD Pipeline / Unit Tests (push) Successful in 10m59s
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 4m38s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m45s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
PR #1352解耦CoverConfig后,遗漏了5处import路径更新,导致staging web镜像构建失败(tsc编译错误)。

修复内容:
- api/editing-planner/types.ts 和 api/template-editor/types.ts:CoverConfig改为从 @/page…' (#1354) from fix/cover-types-import-paths into develop

fix: 修复封面类型解耦后的TypeScript import路径错误

修复了PR #1352解耦CoverConfig后遗漏的5处import路径更新:
- api/editing-planner/types.ts 和 api/template-editor/types.ts: CoverConfig改从 @/pages/generate/types/cover 导入
- useGenerateFormState/index.ts, usePlanConfigLoader.ts, useTitleCoverSync.ts: 相对路径修正
- EditorDrawers.tsx: Prettier格式修复
2026-08-13 11:48:55 +08:00
xiaoxia b0812b14f9 style: fix Prettier formatting in EditorDrawers.tsx
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 / 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 25s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m0s
AI Code Review / AI Code Review (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 11m8s
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) Successful in 5s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 53s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m15s
Remove trailing empty line to pass prettier --check.
2026-08-13 11:35:00 +08:00
xiaoxia c062ff3912 fix: correct types/cover import path in useTitleCoverSync.ts
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API 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 26s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 1m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m20s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-13 11:29:48 +08:00
xiaoxia 8e4834c927 fix: correct types/cover import path in usePlanConfigLoader.ts 2026-08-13 11:29:48 +08:00
xiaoxia e1076f7e88 fix: correct types/cover import path in useGenerateFormState/index.ts 2026-08-13 11:29:47 +08:00
xiaoxia 5b95bdef6f fix: update CoverConfig import path in api/template-editor/types.ts 2026-08-13 11:29:47 +08:00
xiaoxia 61c75ad809 fix: update CoverConfig import path in api/editing-planner/types.ts
CoverConfig was moved from editing-planner/types to generate/types/cover (PR #1352)
but this import was not updated, causing TS2305 build failure.
2026-08-13 11:29:25 +08:00
xiaoxia 52eb37472d refactor(#1341): 删除 EditingPlanner 遗留封面生成器 + 清理 compose_video 封面代码 (#1353)
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 2m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m25s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m27s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 4m28s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Unit Tests (push) Successful in 9m48s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-13 01:16:00 +08:00
灵应 d67d6eb2cd refactor(#1341): 解耦封面类型定义,独立于 editing-planner + 修复 confirm 路径和 AI 超时 (#1352)
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 / Validate - Type Check (mypy) (push) Successful in 2m40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m40s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m3s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 4m3s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 10m29s
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 3m42s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-13 01:01:23 +08:00
xiaoxia cd6fde790a perf(#1341): 封面生成改用 TimeInterval 策略(避免 OOM,提高稳定性) (#1351)
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 1m37s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m9s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m20s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m7s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Successful in 9m17s
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 / Staging API Integration Tests (push) Successful in 2m52s
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-13 00:36:00 +08:00
CI Bot 9c6af4dd45 fix: 删除孤立测试文件 test_cover_service.py(cover_service.py 已在 PR #1347 squash merge 中删除)
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 Web Image (push) Successful in 2m27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m2s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m58s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m9s
CI/CD Pipeline / Unit Tests (push) Successful in 14m17s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 7m31s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-12 22:51:20 +08:00
xiaoxia 7b9e803603 fix(#1341): 增强封面生成错误日志和前端提示 + ESLint 修复 + prettier 格式化 (#1347)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m11s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m28s
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 2m58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m21s
CI/CD Pipeline / Unit Tests (push) Failing after 10m23s
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 5m40s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-12 22:28:38 +08:00
xiaoxia 12d7b9ac2f fix(#1341): 移除 useStep6Cover 未使用的 useRef 导入,修复 Docker 构建 TS6133 (#1346)
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 1m47s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m5s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m38s
CI/CD Pipeline / Unit Tests (push) Failing after 7m28s
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 3m7s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 38s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 44s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m14s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-12 17:11:53 +08:00
xiaoxia 887e66b0f9 refactor(#1341): 删除 EditingPlanner 废弃的封面代码
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 2m26s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 3m18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m4s
CI/CD Pipeline / Unit Tests (push) Failing after 8m0s
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 3m15s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-12 17:01:04 +08:00
xiaoxia 9520507f38 fix(#1341): 修复智能剪辑封面生成的 3 个问题 (#1345)
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 / Validate - Migration (alembic) (push) Successful in 1m47s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m41s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 3m43s
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 / Build Staging API 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
A. generatingRef 卡死 → 改用 useState + 300秒超时保护
B. Step6 缺少进度弹窗 → 添加 Modal + Spin 进度提示
C. 预览视频 URL 没回写 → 预览完成后保存到 plan.config.rendered_storage_key
2026-08-12 16:56:00 +08:00
xiaoxia 0d99a28fc2 fix(#1341): 修复 EditorDrawers totalDuration 重复定义 (#1344)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (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 Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m44s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m38s
CI/CD Pipeline / Unit Tests (push) Successful in 11m18s
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 / Build Staging API Image (push) Successful in 13m44s
CI/CD Pipeline / Integration Tests (push) Successful in 5m8s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-12 16:11:26 +08:00
119 changed files with 4288 additions and 4653 deletions
+1 -3
View File
@@ -10,8 +10,6 @@ Changes:
3. config 为 JSON 字段,存储封面配置信息
"""
import json
import sqlalchemy as sa
from alembic import context, op
@@ -73,7 +71,7 @@ def upgrade() -> None:
name=name,
thumbnail_url="",
is_system=True,
config=json.dumps(config),
config=config,
created_at=sa.func.now(),
updated_at=sa.func.now(),
)
@@ -0,0 +1,39 @@
"""修复 cover_templates.config 双重序列化
Revision ID: 056_fix_cover_templates_config
Revises: 055_cover_templates
Create Date: 2026-08-13
问题: 055 迁移 seed 数据时 json.dumps(config) 导致 config 被双重序列化为 JSON 字符串
例如 "{}"(字符串)而不是 {}(对象),导致 Pydantic CoverTemplateResponse 校验失败 500。
修复: 从 JSON 字符串中提取文本值,再 cast 回 json 对象类型。
"""
import sqlalchemy as sa
from alembic import op
revision = "056_fix_cover_templates_config"
down_revision = "055_cover_templates"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# PostgreSQL: 从 JSON string scalar 中提取文本内容,cast 为 json object
# 例如: JSON string "{}" -> text "{}" -> JSON object {}
if conn.dialect.name == "postgresql":
conn.execute(
sa.text(
"UPDATE cover_templates SET config = (config#>>'{}')::json "
"WHERE jsonb_typeof(config::jsonb) = 'string'"
)
)
def downgrade() -> None:
# No safe rollback — the original data was incorrect
pass
+6
View File
@@ -8,6 +8,7 @@ from app.api.routes.classification_jobs import router as classification_jobs_rou
from app.api.routes.cover_templates import router as cover_templates_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_cover import router as generation_cover_router
from app.api.routes.generation_preview import router as generation_preview_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
@@ -98,6 +99,11 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_cover_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
titles_router,
prefix="/titles",
+1 -2
View File
@@ -7,7 +7,6 @@ API:
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
"""
import logging
from typing import Any
@@ -55,7 +54,7 @@ def list_cover_templates(
thumbnail_url=t.thumbnail_url,
is_system=t.is_system,
created_at=t.created_at,
config=t.config,
config=t.config or {},
)
for t in items
],
+337
View File
@@ -0,0 +1,337 @@
"""封面生成路由 — Generation 模块.
端点:
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
挂载路径: /api/v1/generation/generate-cover
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_generated_video_repository
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Generation"])
# ── Schemas ──────────────────────────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── Route ────────────────────────────────────────────────────────────────
@router.post("/generate-cover", response_model=GenerateCoverResponse)
def generate_cover(
body: GenerateCoverRequest,
template_id: str = Query(..., description="模板 ID"),
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> GenerateCoverResponse:
"""AI 生成封面 — 从预览视频中抽帧.
流程(串行):
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
2. 用裸 URL 让 MediaKit 下载视频并抽帧
3. 帧图下载后上传到 OSS covers/ 路径
"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
# 第一步:从 plan.config 读取
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
if not rendered_storage_key:
generation_task_id = (plan.config or {}).get("generation_task_id", "")
logger.info(
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
)
if generation_task_id:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
task = gen_task_repo.get(generation_task_id)
if task:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info(
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
plan_id,
generation_task_id,
rendered_storage_key[:80],
)
except Exception:
logger.warning(
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
if not rendered_storage_key:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks:
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(pt.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info(
"[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
rendered_storage_key[:80],
)
break
except Exception:
logger.warning(
"封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
if not rendered_storage_key:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
preview_tasks = gen_task_repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
template_id=template_id,
)
if preview_tasks:
completed_preview = preview_tasks[0]
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(completed_preview.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info(
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
plan_id,
template_id,
completed_preview.id,
)
except Exception:
logger.warning(
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
plan_id,
template_id,
exc_info=True,
)
# 仍然找不到才报 400
if not rendered_storage_key:
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
raise HTTPException(
status_code=400,
detail="请先生成预览视频,再生成封面",
)
# 回写到 plan.config
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
# 使用裸 URLrendered/* 已配置公开读)
primary_video_url = None
try:
if rendered_storage_key.startswith("http"):
primary_video_url = rendered_storage_key
else:
from packages.shared.storage import get_shared_storage_service
storage_svc = get_shared_storage_service()
primary_video_url = storage_svc.get_url(rendered_storage_key)
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
if primary_video_url:
import re as _re
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
logger.info(
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
plan_id,
primary_video_url[:80] if primary_video_url else "",
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取预览视频URL失败: {e}",
) from e
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
if body.cover_type in ("ai_frame", "ai_regenerate"):
cover_url_from_task = None
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
# 步骤 A:通过 generation_task_id 直接查找
generation_task_id = (plan.config or {}).get("generation_task_id", "")
if generation_task_id:
try:
task = gen_task_repo.get(generation_task_id)
if task and getattr(task, "cover_url", ""):
cover_url_from_task = task.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
plan_id,
generation_task_id,
cover_url_from_task[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤A读取 cover_url 失败: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
exc_info=True,
)
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
if not cover_url_from_task:
try:
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks:
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 C:通过 user+template 查找最近的已完成预览任务的 cover_url
if not cover_url_from_task:
try:
preview_tasks = gen_task_repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
template_id=template_id,
)
for pt in preview_tasks:
if getattr(pt, "cover_url", ""):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤C-user+template): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤C查找 cover_url 失败: plan_id=%s template_id=%s",
plan_id,
template_id,
exc_info=True,
)
if cover_url_from_task:
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
cover_data = {
"type": "ai_frame",
"image_url": cover_url_from_task,
"frame_time": 0.0,
"confidence": 0.95,
}
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
logger.warning(
"[封面生成] 统一管道未找到 cover_url: plan_id=%s",
plan_id,
)
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
raise HTTPException(
status_code=400,
detail="封面尚未生成,请先重新生成预览视频以触发封面自动提取",
)
from packages.shared.ai_service import run_generate_cover
try:
logger.info("[封面生成] 开始调用 AI 封面生成服务: plan_id=%s", plan_id)
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
primary_video_url=primary_video_url,
)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
template_id,
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
@@ -5,6 +5,7 @@
from __future__ import annotations
import json
import logging
from app.auth import AuthenticatedUser, get_current_user
@@ -267,6 +268,20 @@ def create_preview_generation_task(
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
# 处理标题配置:如果有标题文本,序列化到 custom_title 字段传递给 worker
title_config = request.title_config or {}
title_text = (title_config.get("text") or "").strip()
custom_title_value = ""
if title_text:
# 将标题文本和样式配置序列化为 JSON 存入 custom_title
# Worker 端会解析 JSON 获取完整标题配置
custom_title_value = json.dumps(title_config, ensure_ascii=False)
logger.info(
"[预览生成] 标题配置: text=%s, config_keys=%s",
title_text[:30],
list(title_config.keys()),
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
try:
@@ -290,6 +305,7 @@ def create_preview_generation_task(
auto_retry_enabled=False,
auto_retry_max=0,
is_preview=True,
custom_title=custom_title_value,
)
)
except ValueError as e:
@@ -299,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(
@@ -11,7 +11,6 @@
- bgm.py: BGM 管理
- effects.py: 转场 + 滤镜
- export.py: 导出配置
- cover.py: 封面管理 + AI 生成封面
- subtitles.py: 字幕管理
- ai_features.py: AI 推荐
- generation.py: 生成(触发/进度/记录)
@@ -31,7 +30,6 @@ from .adjustments import router as adjustments_router
from .ai_features import router as ai_features_router
from .bgm import router as bgm_router
from .clips import router as clips_router
from .cover import router as cover_router
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
from .draft import router as draft_router
from .effects import router as effects_router
@@ -51,7 +49,6 @@ _sub_routers = [
bgm_router,
effects_router,
export_router,
cover_router,
subtitles_router,
ai_features_router,
generation_router,
+110 -35
View File
@@ -16,13 +16,16 @@
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
ClipBatchDeleteRequest,
@@ -43,30 +46,100 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
def _clip_to_response(clip) -> EditorClipResponse:
"""统一构造片段响应"""
def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse:
"""统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐"""
def _enum_str(val) -> str:
return val.value if hasattr(val, "value") else str(val)
def _fmt_dt(val) -> str:
if val is None:
return ""
if hasattr(val, "isoformat"):
return val.isoformat()
return str(val)
return EditorClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type.value
if hasattr(clip.clip_type, "value")
else str(clip.clip_type),
clip_type=_enum_str(getattr(clip, "clip_type", "")),
order=clip.order,
duration=clip.duration,
start_time=getattr(clip, "start_time", 0.0) or 0.0,
text_content=clip.text_content or "",
transition_effect=clip.transition_effect.value
if hasattr(clip.transition_effect, "value")
else str(clip.transition_effect),
transition_effect=_enum_str(getattr(clip, "transition_effect", "cut")),
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=clip.playback_speed or 1.0,
asset_id=getattr(clip, "asset_id", "") or "",
asset_url=asset_url,
status=getattr(clip, "status", "pending") or "pending",
template_clip_config_id=getattr(clip, "template_clip_config_id", "") or "",
config=clip.config or {},
created_at=_fmt_dt(getattr(clip, "created_at", None)),
updated_at=_fmt_dt(getattr(clip, "updated_at", None)),
)
def _build_asset_url_map(
asset_ids: list[str],
asset_repo: SQLAlchemyAssetRepository,
) -> dict[str, str | None]:
"""批量查询素材并生成签名URL映射.
Returns:
{asset_id: signed_url_or_None}
"""
if not asset_ids:
return {}
# 去重:多个 clip 可能引用同一个素材
# 去重并保持顺序
seen: set[str] = set()
unique_ids = []
for aid in asset_ids:
if aid and aid not in seen:
seen.add(aid)
unique_ids.append(aid)
result: dict[str, str | None] = {}
try:
storage = get_storage_service()
except Exception:
logger.warning("获取存储服务失败,跳过asset_url生成")
return {aid: None for aid in asset_ids}
# 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1)
try:
assets = asset_repo.find_by_ids(unique_ids)
asset_map = {a.id: a for a in assets}
except Exception:
logger.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True)
return {aid: None for aid in asset_ids if aid}
for aid in unique_ids:
try:
asset = asset_map.get(aid)
if asset is None:
result[aid] = None
continue
storage_key = getattr(asset, "storage_key", None) or ""
if not storage_key:
result[aid] = None
continue
result[aid] = storage.get_download_url(storage_key, expires_seconds=3600)
except Exception:
logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True)
result[aid] = None
return result
@router.get("/clips", response_model=EditorClipListResponse)
def list_draft_clips(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
_: AuthenticatedUser = Depends(get_current_user),
@@ -75,8 +148,17 @@ def list_draft_clips(
_, plan_svc = services
clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit)
total = plan_svc.count_clips(plan_id)
# 批量解析素材签名URL
asset_ids = [getattr(c, "asset_id", "") or "" for c in clips]
asset_ids = [aid for aid in asset_ids if aid]
url_map = _build_asset_url_map(asset_ids, asset_repo)
return EditorClipListResponse(
items=[_clip_to_response(c) for c in clips],
items=[
_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or ""))
for c in clips
],
total=total,
)
@@ -156,6 +238,7 @@ def get_draft_clip_detail(
clip_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取草稿中的片段详情"""
@@ -165,16 +248,20 @@ def get_draft_clip_detail(
raise HTTPException(status_code=404, detail="片段不存在")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail="片段不存在")
return _clip_to_response(clip)
asset_id = getattr(clip, "asset_id", "") or ""
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
return _clip_to_response(clip, asset_url=url_map.get(asset_id))
@router.post("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
@router.post("/clips/{clip_id}/split", status_code=status.HTTP_200_OK)
def split_draft_clip(
template_id: str,
clip_id: str,
body: SplitClipRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user),
):
"""将一个片段从指定时间点分割为两个片段"""
@@ -190,32 +277,22 @@ def split_draft_clip(
) from exc
left = result["left_clip"]
right = result["right_clip"]
asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""]
asset_ids = [a for a in asset_ids if a]
url_map = _build_asset_url_map(asset_ids, asset_repo)
return {
"left_clip": {
"id": left.id,
"plan_id": left.plan_id,
"clip_type": left.clip_type,
"order": left.order,
"duration": left.duration,
"start_time": left.start_time,
},
"right_clip": {
"id": right.id,
"plan_id": right.plan_id,
"clip_type": right.clip_type,
"order": right.order,
"duration": right.duration,
"start_time": right.start_time,
},
"left_clip": _clip_to_response(left, asset_url=url_map.get(getattr(left, "asset_id", "") or "")),
"right_clip": _clip_to_response(right, asset_url=url_map.get(getattr(right, "asset_id", "") or "")),
}
@router.post("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
@router.post("/clips/merge", status_code=status.HTTP_200_OK)
def merge_draft_clips(
template_id: str,
body: MergeClipsRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user),
):
"""将多个连续的同类型片段合并为一个片段"""
@@ -230,13 +307,11 @@ def merge_draft_clips(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
asset_id = getattr(merged, "asset_id", "") or ""
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
return {
"id": merged.id,
"plan_id": merged.plan_id,
"clip_type": merged.clip_type,
"order": merged.order,
"duration": merged.duration,
"text_content": merged.text_content,
"merged_clip": _clip_to_response(merged, asset_url=url_map.get(asset_id)),
"deleted_clip_ids": body.clip_ids,
}
@@ -1,168 +0,0 @@
"""封面管理路由.
端点:
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_generated_video_repository
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
GenerateCoverRequest,
GenerateCoverResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
@router.post("/generate-cover", response_model=GenerateCoverResponse)
def editor_generate_cover(
template_id: str,
body: GenerateCoverRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> GenerateCoverResponse:
"""AI 生成封面 — 从预览视频中抽帧.
流程(串行):
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
2. 用裸 URL 让 MediaKit 下载视频并抽帧
3. 帧图下载后上传到 OSS covers/ 路径
"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
# 第一步:从 plan.config 读取
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
if not rendered_storage_key:
generation_task_id = (plan.config or {}).get("generation_task_id", "")
if generation_task_id:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
task = gen_task_repo.get(generation_task_id)
if task:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info(
"封面生成: 通过 generation_task_id 找到视频: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
)
except Exception:
logger.warning(
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
if not rendered_storage_key:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
preview_tasks = gen_task_repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
template_id=template_id,
)
if preview_tasks:
completed_preview = preview_tasks[0]
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(completed_preview.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info(
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
plan_id,
template_id,
completed_preview.id,
)
except Exception:
logger.warning(
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
plan_id,
template_id,
exc_info=True,
)
# 仍然找不到才报 400
if not rendered_storage_key:
raise HTTPException(
status_code=400,
detail="请先生成预览视频,再生成封面",
)
# 回写到 plan.config
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
# 使用裸 URLrendered/* 已配置公开读)
primary_video_url = None
try:
if rendered_storage_key.startswith("http"):
primary_video_url = rendered_storage_key
else:
from packages.shared.storage import get_shared_storage_service
storage_svc = get_shared_storage_service()
primary_video_url = storage_svc.get_url(rendered_storage_key)
logger.info(
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
plan_id,
primary_video_url[:80] if primary_video_url else "",
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取预览视频URL失败: {e}",
) from e
from packages.shared.ai_service import run_generate_cover
try:
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
primary_video_url=primary_video_url,
)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
template_id,
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
@@ -223,7 +223,12 @@ def _get_task_output_url(task, gen_task_repo, db) -> str:
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task.id)
if videos:
return getattr(videos[0], "file_url", "") or ""
url = getattr(videos[0], "file_url", "") or ""
# 规范化:合并路径中的双斜杠(保留协议头 ://)
if url:
import re as _re
url = _re.sub(r"(?<!:)//", "/", url)
return url
except Exception:
pass
return ""
@@ -99,29 +99,6 @@ class AIRecommendResponse(BaseModel):
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
# ── 封面生成 ────────────────────────────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── BGM ────────────────────────────────────────────────────────────────────
@@ -250,6 +227,7 @@ class ClipsFromAssetsResponse(BaseModel):
success: bool = True
created_count: int
plan_id: str = ""
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
@@ -462,17 +440,28 @@ class EditorUpdateRequest(BaseModel):
class EditorClipResponse(BaseModel):
"""片段响应"""
"""片段响应 — 与数据库 edit_plan_clips 表字段对齐"""
id: str
plan_id: str
clip_type: str
order: int
duration: float
start_time: float = 0.0
text_content: str = ""
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
asset_id: str = ""
asset_url: str | None = Field(
default=None,
description="素材视频签名URL(1小时有效),用于前端预览播放",
)
status: str = "pending"
template_clip_config_id: str = ""
config: dict[str, Any] = Field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
class EditorClipListResponse(BaseModel):
+6 -2
View File
@@ -7,8 +7,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator
class ConfirmGenerationRequest(BaseModel):
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
output_width: int = Field(default=1080, description="输出视频宽度")
output_height: int = Field(default=1920, description="输出视频高度")
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
cover_url: str = Field(default="", description="自定义封面图片 URL")
custom_title: str = Field(default="", description="自定义视频标题")
@@ -181,6 +181,10 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
default="",
description="关联的编辑计划ID(可选),用于确认生成时复用预览产物",
)
title_config: dict = Field(
default_factory=dict,
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
)
@model_validator(mode="after")
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
-278
View File
@@ -1,278 +0,0 @@
"""封面管理服务.
提供封面配置管理和从视频抽帧生成封面的能力。
抽帧使用 FFmpeg,上传使用共享存储服务。
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
COVER_STORAGE_PREFIX = "covers"
class CoverService:
"""封面管理服务."""
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
self._storage = storage_service
self._asset_repo = asset_repository
# ── 配置读写 ──────────────────────────────────────────────────────────
@staticmethod
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
"""从 plan.config 中提取封面配置.
Args:
plan_config: 剪辑计划的 config 字段
Returns:
封面配置 dict
"""
cover = plan_config.get("cover", {})
if not isinstance(cover, dict):
cover = {}
# 确保默认字段存在
return {
"type": cover.get("type", "ai_frame"),
"image_url": cover.get("image_url", ""),
"frame_time": cover.get("frame_time"),
}
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
def extract_cover_from_clip(
self,
plan_id: str,
asset_id: str,
frame_time: float = 1.0,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""从指定素材的指定时间点抽取一帧作为封面.
Args:
plan_id: 剪辑计划 ID(用于生成存储路径)
asset_id: 素材 ID
frame_time: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict,包含 type / image_url / frame_time
Raises:
ValueError: 素材不存在或不是视频
RuntimeError: 抽帧或上传失败
"""
# 1. 获取素材
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
if not asset:
raise ValueError(f"素材不存在: {asset_id}")
storage_key = getattr(asset, "storage_key", "")
if not storage_key:
raise ValueError(f"素材没有文件: {asset_id}")
mime_type = getattr(asset, "mime_type", "")
if mime_type and not mime_type.startswith("video"):
raise ValueError(f"素材不是视频类型: {mime_type}")
# 2. 下载视频到临时目录
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
tmp_path = Path(tmp_dir)
video_path = tmp_path / f"source_{asset_id[:8]}"
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
try:
self._storage.download_file(storage_key, str(video_path))
except Exception as e:
raise RuntimeError(f"下载素材失败: {e}") from e
if not video_path.exists() or video_path.stat().st_size == 0:
raise RuntimeError("下载的素材文件为空")
# 3. FFmpeg 抽帧
output_path = tmp_path / "cover.jpg"
self._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=frame_time,
width=width,
height=height,
quality=quality,
)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError("封面抽帧失败")
# 4. 上传到 OSS
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
logger.info("上传封面到存储: key=%s", cover_key)
try:
self._storage.upload_file(
file_or_path=str(output_path),
storage_key=cover_key,
content_type="image/jpeg",
)
except Exception as e:
raise RuntimeError(f"上传封面失败: {e}") from e
# 5. 获取访问 URL
try:
image_url = self._storage.get_url(cover_key)
except Exception:
image_url = cover_key # 降级为 storage_key
logger.info(
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
plan_id,
asset_id,
frame_time,
output_path.stat().st_size if output_path.exists() else 0,
)
return {
"type": "manual",
"image_url": image_url,
"frame_time": frame_time,
}
def generate_smart_cover(
self,
plan_id: str,
asset_id: str,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
Args:
plan_id: 剪辑计划 ID
asset_id: 素材 ID
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict
"""
# 简单实现:取视频 1/3 处的帧作为智能封面
# 更复杂的多帧选清晰帧可以后续优化
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
result = self.extract_cover_from_clip(
plan_id=plan_id,
asset_id=asset_id,
frame_time=frame_time,
width=width,
height=height,
quality=quality,
)
result["type"] = "ai_frame"
return result
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _extract_frame(
video_path: Path,
output_path: Path,
*,
time_sec: float,
width: int,
height: int,
quality: int,
) -> None:
"""使用 FFmpeg 从视频中抽取一帧.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
"""
import subprocess
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
# 尝试不使用 scale+crop 的简化命令
simple_command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-q:v",
str(quality),
"-pix_fmt",
"yuvj420p",
"-f",
"mjpeg",
str(output_path),
]
result2 = subprocess.run(
simple_command,
capture_output=True,
text=True,
timeout=60,
)
if result2.returncode != 0:
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
except subprocess.TimeoutExpired as e:
raise RuntimeError("FFmpeg 抽帧超时") from e
except FileNotFoundError as e:
raise RuntimeError("FFmpeg 不可用") from e
+15 -12
View File
@@ -200,15 +200,7 @@ test.describe("Core generation flow", () => {
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 4: preview — 需要先生成预览视频,才能进入下一步
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
// 点击"生成预览"按钮触发预览生成
await page.locator(".xx-preview-generate-btn").click()
// 等待预览生成完成(后端渲染,可能需要较长时间)
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 5: title
// Step 4: title(新顺序:标题在预览之前)
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
// 等待组件完全渲染
await page.waitForTimeout(2000)
@@ -222,6 +214,14 @@ test.describe("Core generation flow", () => {
await titleInput.fill(titleText)
await page.getByRole("button", { name: "下一步" }).click()
// Step 5: preview — 需要先生成预览视频,才能进入下一步
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
// 点击"生成预览"按钮触发预览生成
await page.locator(".xx-preview-generate-btn").click()
// 等待预览生成完成(后端渲染,可能需要较长时间)
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
@@ -258,9 +258,12 @@ test.describe("Core generation flow", () => {
// Generate API may return 400 in test env if template has no ready segments
// That is OK for a wizard flow smoke test
if (genResp.ok()) {
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
expect(genData.plan_id).toBeTruthy()
expect(genData.generation_task_id).toBeTruthy()
const genData = (await genResp.json()) as {
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy()
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
}
+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 = "/"
+1 -1
View File
@@ -3,7 +3,7 @@
* 后端路由: /api/v1/cover-templates
*/
import apiClient from "./client"
import type { CoverTemplate } from "@/pages/editing-planner/types"
import type { CoverTemplate } from "@/pages/generate/types/cover"
export interface CoverTemplateListResponse {
items: CoverTemplate[]
+1 -1
View File
@@ -8,8 +8,8 @@ import type {
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
import type { CoverConfig } from "@/pages/generate/types/cover"
/** 模板模式(后端枚举值) */
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
+1 -1
View File
@@ -7,7 +7,7 @@ export const confirmGeneration = async (
params: ConfirmGenerationRequest,
): Promise<ConfirmGenerationResponse> => {
const response = await apiClient.post<ConfirmGenerationResponse>(
`/tasks/${taskId}/confirm`,
`/generation/tasks/${taskId}/confirm`,
params,
)
return response.data
+35
View File
@@ -0,0 +1,35 @@
import apiClient from "../client"
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
export interface GenerateCoverResponse {
plan_id: string
cover: {
scheme?: string
asset_id?: string
frame_time?: number
image_url?: string
thumbnail_url?: string
[key: string]: unknown
}
}
/** AI 生成封面 — 从预览视频中抽帧 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post<GenerateCoverResponse>(
"/generation/generate-cover",
{ ...data, template_id: templateId },
{
timeout: 300000,
params: { template_id: templateId },
},
)
return response.data
}
+3
View File
@@ -10,3 +10,6 @@ export type {
export { createPreview, getPreviewStatus } from "./preview"
export { confirmGeneration } from "./confirm"
export { generateCover } from "./cover"
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
+11
View File
@@ -13,6 +13,17 @@ export interface CreatePreviewRequest {
video_title?: string
duration?: number
video_ratio?: string
/* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */
title_config?: {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
bgm_config?: {
enabled: boolean
preset_id?: string
+2 -18
View File
@@ -1,13 +1,8 @@
/**
* AI 推荐 + 封面生成 API
* AI 推荐 API
*/
import apiClient from "../client"
import type {
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
} from "./types"
import type { AIRecommendRequest, AIRecommendResponse } from "./types"
/** AI 推荐片段方案 */
export async function aiRecommendClips(
@@ -17,14 +12,3 @@ export async function aiRecommendClips(
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data, {
timeout: 180000, // 封面生成涉及 MediaKit 抽帧,最长 180 秒
})
return response.data
}
+2 -5
View File
@@ -27,9 +27,6 @@ export type {
AIRecommendRequest,
AIRecommendClipItem,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
CoverResult,
EditPlanClipStatus,
EditPlanClip,
CreateEditPlanClipRequest,
@@ -81,8 +78,8 @@ export {
createClipsFromAssets,
} from "./clips"
// AI 推荐 + 封面生成
export { aiRecommendClips, generateCover } from "./aiFeatures"
// AI 推荐
export { aiRecommendClips } from "./aiFeatures"
// 素材库
export { getMediaAssets, getMediaAsset } from "./mediaAssets"
+6 -24
View File
@@ -9,8 +9,8 @@ import type {
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
import type { CoverConfig } from "@/pages/generate/types/cover"
/* ── 模板草稿状态 ── */
@@ -118,6 +118,10 @@ export interface EditPlanConfig {
generate_count?: number
/** 素材模式 */
material_mode?: string
/** 预览视频 URL(封面生成用) */
rendered_storage_key?: string
/** 生成任务 ID */
generation_task_id?: string
}
/* ── 模板草稿主体 ── */
@@ -240,7 +244,7 @@ export interface GeneratedVideo {
updated_at?: string
}
/* ── AI 推荐 & 封面生成 ── */
/* ── AI 推荐 ── */
/** AI 推荐请求 */
export interface AIRecommendRequest {
@@ -270,28 +274,6 @@ export interface AIRecommendResponse {
confidence: number
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
image_url?: string
thumbnail_url?: string
}
/* ── 片段 CRUD 相关 ── */
/** 片段状态 */
+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"
@@ -74,8 +74,6 @@ const EditingPlanner: React.FC = () => {
setChromaKeySettings,
stickerSettings,
setStickerSettings,
coverConfig,
setCoverConfig,
} = useGlobalSettings()
/* ── 右侧栏 Tab ── */
@@ -119,7 +117,6 @@ const EditingPlanner: React.FC = () => {
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
clips,
totalDuration,
titleConfig,
@@ -131,7 +128,6 @@ const EditingPlanner: React.FC = () => {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
})
/* ──────────── 渲染 ──────────── */
@@ -181,7 +177,6 @@ const EditingPlanner: React.FC = () => {
selectedClipId={clipOps.selectedClipId}
isPlaying={playback.isPlaying}
titleConfig={titleConfig}
coverConfig={coverConfig}
subtitleSettings={{
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
@@ -243,7 +238,6 @@ const EditingPlanner: React.FC = () => {
onOpenFilterDrawer={() => drawers.setFilterDrawerOpen(true)}
onOpenGreenScreenDrawer={() => drawers.setChromaKeyDrawerOpen(true)}
onOpenStickerDrawer={() => drawers.setStickerDrawerOpen(true)}
onOpenCoverDrawer={() => drawers.setCoverDrawerOpen(true)}
clips={clips}
selectedClipId={clipOps.selectedClipId}
onClipSelect={clipOps.handleClipSelect}
@@ -336,12 +330,6 @@ const EditingPlanner: React.FC = () => {
stickerSettings={stickerSettings}
onStickerChange={setStickerSettings}
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
coverDrawerOpen={drawers.coverDrawerOpen}
onCloseCoverDrawer={() => drawers.setCoverDrawerOpen(false)}
coverConfig={coverConfig}
setCoverConfig={setCoverConfig}
templateId={urlTemplateId}
totalDuration={totalDuration}
/>
</div>
)
@@ -35,7 +35,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
onOpenCoverDrawer,
}) => {
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
@@ -141,21 +140,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
)}
</div>
{/* ═══ 封面设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenCoverDrawer && (
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片段详情(选中时显示) ═══ */}
{selectedClip && (
<ClipDetailSection
@@ -1,5 +0,0 @@
/**
* 封面选择器入口(向后兼容)
* 实际实现位于 ./cover-selector/ 目录
*/
export { default } from "./cover-selector"
@@ -10,7 +10,6 @@ import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
import CoverSelector from "./CoverSelector"
import type {
ClipData,
TransitionConfig,
@@ -24,7 +23,6 @@ import type {
StickerConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { CoverConfig } from "../types/cover"
import type { BgmMixConfig } from "@/api/bgm"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
@@ -87,13 +85,6 @@ interface EditorDrawersProps {
stickerSettings: StickerConfig
onStickerChange: (config: StickerConfig) => void
onCloseStickerDrawer: () => void
// 封面
coverDrawerOpen: boolean
onCloseCoverDrawer: () => void
coverConfig: CoverConfig
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
templateId: string
totalDuration: number
}
const EditorDrawers: React.FC<EditorDrawersProps> = ({
@@ -144,11 +135,6 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
stickerSettings,
onStickerChange,
onCloseStickerDrawer,
coverDrawerOpen,
onCloseCoverDrawer,
coverConfig,
setCoverConfig,
templateId,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
@@ -253,16 +239,6 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
onChange={onStickerChange}
totalDuration={totalDuration}
/>
{/* 封面选择器 Drawer */}
<CoverSelector
open={coverDrawerOpen}
onClose={onCloseCoverDrawer}
config={coverConfig}
onChange={setCoverConfig}
totalDuration={totalDuration}
templateId={templateId}
/>
</>
)
}
@@ -1,12 +1,10 @@
/**
* 预览区 — V8 原型 1:1 还原
* 手机模型预览 + 封面预览 并排
* 封面为只读展示(从模板/计划继承)
* 手机模型预览
*/
import React from "react"
import type { ClipData, ClipType } from "../types"
import type { TitleConfig } from "@/api/template-editor"
import type { CoverConfig } from "../types"
interface SubtitleSettings {
enabled: boolean
@@ -21,7 +19,6 @@ interface PreviewPlayerProps {
selectedClipId: string | null
isPlaying: boolean
titleConfig?: TitleConfig
coverConfig?: CoverConfig
subtitleSettings?: SubtitleSettings
onClipSelect: (clipId: string) => void
onPlayPause: () => void
@@ -37,18 +34,11 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
pip: "混剪",
}
const COVER_MODE_LABELS: Record<string, string> = {
auto: "智能封面",
frame: "抽帧封面",
upload: "上传封面",
}
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
clips,
selectedClipId,
isPlaying,
titleConfig,
coverConfig,
subtitleSettings,
onPlayPause,
}) => {
@@ -132,26 +122,6 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
)}
</div>
</div>
{/* 封面预览(只读) */}
<div className="ep-cover-preview">
<div className="ep-cover-image">
{coverConfig?.thumbnail_url || coverConfig?.upload_url ? (
<img
src={coverConfig.thumbnail_url || coverConfig.upload_url}
alt="封面预览"
className="ep-cover-img"
/>
) : displayClip ? (
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
) : (
<span></span>
)}
</div>
<div className="ep-cover-label">
{coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
</div>
</div>
</div>
)
}
@@ -35,7 +35,6 @@ interface RightPanelProps {
onOpenFilterDrawer: () => void
onOpenGreenScreenDrawer: () => void
onOpenStickerDrawer: () => void
onOpenCoverDrawer: () => void
// 片段 tab
clips: ClipData[]
selectedClipId: string | null
@@ -73,7 +72,6 @@ const RightPanel: React.FC<RightPanelProps> = ({
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
onOpenCoverDrawer,
clips,
selectedClipId,
onClipSelect,
@@ -144,7 +142,6 @@ const RightPanel: React.FC<RightPanelProps> = ({
onOpenFilterDrawer={onOpenFilterDrawer}
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
onOpenStickerDrawer={onOpenStickerDrawer}
onOpenCoverDrawer={onOpenCoverDrawer}
/>
)
})()}
@@ -1,143 +0,0 @@
import React from "react"
import type { CoverConfig } from "../../types"
interface CoverAutoModeProps {
config: CoverConfig
formatTime: (s: number) => string
onUseAiSuggestion: () => void
}
/** 智能封面模式面板 */
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
config,
formatTime,
onUseAiSuggestion,
}) => (
<div className="cover-auto-section">
<div className="cover-auto-desc">AI </div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">{formatTime(config.ai_suggested_time)}</div>
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)
interface CoverFrameModeProps {
config: CoverConfig
totalDuration: number
formatTime: (s: number) => string
onFrameTimeChange: (time: number) => void
}
/** 抽帧选封面模式面板 */
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
config,
totalDuration,
formatTime,
onFrameTimeChange,
}) => (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
interface CoverUploadModeProps {
config: CoverConfig
isDragging: boolean
fileInputRef: React.RefObject<HTMLInputElement>
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent) => void
onAreaClick: () => void
onFileChange: (file: File) => void
}
/** 上传封面模式面板 */
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
config,
isDragging,
fileInputRef,
onDragOver,
onDragLeave,
onDrop,
onAreaClick,
onFileChange,
}) => (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onAreaClick}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text"></span>
<span className="cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) onFileChange(file)
}}
/>
</div>
</div>
)
@@ -1,195 +0,0 @@
/**
* 封面选择器
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
*/
import React, { useState } from "react"
import { Drawer, Modal, Spin, message } from "antd"
import { generateCover } from "@/api/template-editor/aiFeatures"
import type { CoverConfig, CoverMode } from "../../types"
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
interface CoverSelectorProps {
open: boolean
onClose: () => void
config: CoverConfig
onChange: (config: CoverConfig) => void
totalDuration: number
templateId: string
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
templateId,
}) => {
const {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
} = useCoverSelector({ config, onChange })
const [generating, setGenerating] = useState(false)
const handleGenerateCover = async () => {
if (!templateId) {
message.error("请先保存模板")
return
}
setGenerating(true)
try {
const res = await generateCover(templateId, {
asset_ids: [],
cover_type: "ai_frame",
})
const imageUrl = res.cover?.image_url || res.cover?.thumbnail_url || ""
if (imageUrl) {
update({ upload_url: imageUrl, thumbnail_url: imageUrl })
}
message.success("封面生成成功")
} catch (err: any) {
message.error("封面生成失败: " + (err?.message || "未知错误"))
} finally {
setGenerating(false)
}
}
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{config.mode === "auto" && (
<CoverAutoMode
config={config}
formatTime={formatTime}
onUseAiSuggestion={handleUseAiSuggestion}
/>
)}
{config.mode === "frame" && (
<CoverFrameMode
config={config}
totalDuration={totalDuration}
formatTime={formatTime}
onFrameTimeChange={(t) => update({ frame_time: t })}
/>
)}
{config.mode === "upload" && (
<CoverUploadMode
config={config}
isDragging={isDragging}
fileInputRef={fileInputRef}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onAreaClick={() => fileInputRef.current?.click()}
onFileChange={handleFileUpload}
/>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* AI 生成封面按钮 */}
{config.enabled && config.mode === "auto" && (
<div className="cover-generate-section">
<button
className="cover-generate-btn"
onClick={handleGenerateCover}
disabled={generating}
>
{generating ? "生成中..." : "🤖 AI 生成封面"}
</button>
</div>
)}
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
{/* 生成进度弹窗 */}
<Modal open={generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI ...</p>
</div>
</Modal>
</Drawer>
)
}
export default CoverSelector
@@ -1,98 +0,0 @@
import { useCallback, useRef, useState } from "react"
import type { CoverConfig, CoverMode } from "../../types"
import { DEFAULT_COVER_CONFIG } from "../../types"
/** 封面模式标签 */
export const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
/** 封面模式图标 */
export const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
interface UseCoverSelectorOptions {
config: CoverConfig
onChange: (config: CoverConfig) => void
}
/**
* 封面选择器 Hook
* 封装状态管理、文件上传、模式切换等逻辑
*/
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode })
},
[update],
)
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
}
reader.readAsDataURL(file)
},
[update],
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileUpload(file)
},
[handleFileUpload],
)
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" })
}
}, [config.ai_suggested_time, update])
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
return {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
}
}
@@ -6,7 +6,6 @@ import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { TitleConfig } from "@/api/template-editor"
import type { BgmMixConfig } from "@/api/bgm"
import type { TransitionEffect } from "@/api/template-editor"
import type { CoverConfig } from "../../types"
interface UsePlanLoadingOptions {
loadedPlanId: string | null
@@ -16,7 +15,6 @@ interface UsePlanLoadingOptions {
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
}
/**
@@ -31,7 +29,6 @@ export function usePlanLoading({
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
}: UsePlanLoadingOptions) {
useEffect(() => {
if (!loadedPlanId) return
@@ -77,17 +74,6 @@ export function usePlanLoading({
music_id: cfg.bgm_config!.music_id || "",
}))
}
if (cfg.cover_config) {
setCoverConfig((prev: CoverConfig) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
}))
}
/* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */
const backendClips = clipsRes?.items || []
@@ -152,6 +138,5 @@ export function usePlanLoading({
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
])
}
@@ -17,7 +17,6 @@ import type {
} from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { TitleConfig } from "@/api/template-editor"
import type { CoverConfig } from "../../types"
import type { BgmMixConfig } from "@/api/bgm"
interface UseTemplateSaveOptions {
@@ -33,7 +32,6 @@ interface UseTemplateSaveOptions {
filterSettings: FilterConfig
chromaKeySettings: ChromaKeyConfig
stickerSettings: StickerConfig
coverConfig: CoverConfig
loadedTemplateId: string | null
loadTemplates: () => Promise<void>
}
@@ -55,7 +53,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
} = options
@@ -132,7 +129,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
}
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
@@ -163,7 +159,6 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
])
@@ -17,7 +17,6 @@ export const useEditorDrawers = () => {
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false)
/* ── 目标片段 ID ── */
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
@@ -67,8 +66,6 @@ export const useEditorDrawers = () => {
setChromaKeyDrawerOpen,
stickerDrawerOpen,
setStickerDrawerOpen,
coverDrawerOpen,
setCoverDrawerOpen,
// 目标 ID
transitionTargetClipId,
speedTargetClipId,
@@ -11,7 +11,6 @@ import type {
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "../types"
import {
DEFAULT_WATERMARK,
@@ -20,7 +19,6 @@ import {
DEFAULT_FILTER_CONFIG,
DEFAULT_CHROMA_KEY_CONFIG,
DEFAULT_STICKER_CONFIG,
DEFAULT_COVER_CONFIG,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle"
@@ -47,8 +45,6 @@ export interface GlobalSettings {
setChromaKeySettings: (config: ChromaKeyConfig) => void
stickerSettings: StickerConfig
setStickerSettings: (config: StickerConfig) => void
coverConfig: CoverConfig
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
}
export const useGlobalSettings = (): GlobalSettings => {
@@ -92,10 +88,6 @@ export const useGlobalSettings = (): GlobalSettings => {
...DEFAULT_STICKER_CONFIG,
})
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
...DEFAULT_COVER_CONFIG,
})
return {
titleConfig,
setTitleConfig,
@@ -115,7 +107,5 @@ export const useGlobalSettings = (): GlobalSettings => {
setChromaKeySettings,
stickerSettings,
setStickerSettings,
coverConfig,
setCoverConfig,
}
}
@@ -9,7 +9,6 @@ import type {
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
@@ -29,7 +28,6 @@ interface UseTemplateManagementParams {
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
clips: ClipData[]
totalDuration: number
titleConfig: TitleConfig
@@ -41,7 +39,6 @@ interface UseTemplateManagementParams {
filterSettings: FilterConfig
chromaKeySettings: ChromaKeyConfig
stickerSettings: StickerConfig
coverConfig: CoverConfig
}
/**
@@ -59,7 +56,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
clips,
totalDuration,
titleConfig,
@@ -71,7 +67,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
} = params
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
@@ -119,7 +114,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
})
@@ -146,7 +140,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
})
/* ── 事件 ── */
@@ -67,6 +67,4 @@ export interface ClipPropertiesPanelProps {
onOpenGreenScreenDrawer?: () => void
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void
/** 打开封面选择器 Drawer */
onOpenCoverDrawer?: () => void
}
@@ -71,9 +71,6 @@ export {
TEXT_STICKER_PRESET_LABELS,
} from "./sticker"
/* 封面 */
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG, type CoverTemplate } from "./cover"
/* 片段数据 */
export { type ClipType, type ClipData } from "./clip"
+22 -30
View File
@@ -1,6 +1,6 @@
/**
* 智能剪辑页面 — V22 多预览 + 配音前置
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 选择标题 → 生成预览 → 选择封面 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
* 主组件仅保留整体布局与事件编排
* 状态管理 → hooks/useGenerateFormState
@@ -24,7 +24,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { useStep4Preview } from "./hooks/useStep4Preview"
import { useStep5Preview } from "./hooks/useStep5Preview"
import "./generate.css"
const GeneratePage: React.FC = () => {
@@ -96,8 +96,8 @@ const GeneratePage: React.FC = () => {
return id ? [id] : []
}, [voiceMode, selectedVoice, selectedClonedVoice])
/* ── Step4 预览生成(多预览 + voice_ids ── */
const step4Preview = useStep4Preview({
/* ── Step5 预览生成(多预览 + voice_ids ── */
const step5Preview = useStep5Preview({
templates: userTemplates,
selectedTemplate,
materialMode,
@@ -108,6 +108,7 @@ const GeneratePage: React.FC = () => {
voiceIds: previewVoiceIds,
voiceLibraryId: selectedVoice || undefined,
previewCount,
titleSettings,
})
/* ── 步骤导航 ── */
@@ -119,7 +120,7 @@ const GeneratePage: React.FC = () => {
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady: step4Preview.canProceed,
previewReady: step5Preview.canProceed,
})
/* ── 视频生成核心逻辑 ── */
@@ -150,7 +151,7 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
generateCount,
previewTaskId: step4Preview.selectedTaskId,
previewTaskId: step5Preview.selectedTaskId,
})
/* ================================================================
@@ -206,21 +207,16 @@ const GeneratePage: React.FC = () => {
onRetry={handleRetryGenerate}
onDismissError={handleDismissError}
presetVoices={presetVoices}
videoRatio={videoRatio}
/* Step4 多预览 */
/* Step5 多预览 */
previewCount={previewCount}
onPreviewCountChange={setPreviewCount}
previewItems={step4Preview.items}
previewSelectedIndex={step4Preview.selectedIndex}
onSelectPreview={step4Preview.setSelectedIndex}
previewOverallStatus={step4Preview.previewStatus}
previewOverallError={step4Preview.previewError}
previewOverallProgress={step4Preview.progress}
previewAnyGenerating={step4Preview.anyGenerating}
previewTemplateName={step4Preview.templateName}
previewMaterialCount={step4Preview.materialCount}
onGeneratePreview={step4Preview.generatePreview}
onRegeneratePreview={step4Preview.regeneratePreview}
previewItems={step5Preview.items}
previewOverallStatus={step5Preview.previewStatus}
previewOverallError={step5Preview.previewError}
previewOverallProgress={step5Preview.progress}
previewAnyGenerating={step5Preview.anyGenerating}
onGeneratePreview={step5Preview.generatePreview}
onRegeneratePreview={step5Preview.regeneratePreview}
/>
<GenerateStepActions
@@ -236,22 +232,18 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step4+ 常驻,展示选中的预览 */}
{/* 预览视频面板(Step4+ 显示 */}
{currentStep >= 4 && (
<PreviewVideoPanel
previewStatus={step4Preview.previewStatus}
previewResult={step4Preview.previewResult}
previewError={step4Preview.previewError}
progress={step4Preview.progress}
previewStatus={step5Preview.previewStatus}
previewResult={step5Preview.previewResult}
previewError={step5Preview.previewError}
progress={step5Preview.progress}
videoRatio={videoRatio}
onRegenerate={step4Preview.regeneratePreview}
titleText={titleSettings.title}
titleSettings={currentStep >= 5 ? titleSettings : undefined}
onRegenerate={step5Preview.regeneratePreview}
/>
)}
{/* 正式生成结果(Step5+ 才显示) */}
{currentStep >= 5 && (
{currentStep >= 6 && (
<GenerateResultPanel
generated={generated}
generating={generating}
@@ -1,20 +1,20 @@
/**
* GeneratePage 步骤内容渲染
* 根据当前步骤渲染对应的 Step 组件
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import type { CoverConfig } from "../../editing-planner/types"
import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
import Step1TemplateSelect from "../components/Step1TemplateSelect"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3VoiceSelect from "../components/Step5VoiceSelect"
import Step4GeneratePreview from "../components/Step4GeneratePreview"
import Step5TitleSettings from "../components/Step4TitleSettings"
import Step5GeneratePreview from "../components/Step5GeneratePreview"
import Step4TitleSettings from "../components/Step4TitleSettings"
import Step6CoverSettings from "../components/Step6CoverSettings"
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
import type { GeneratedVideo } from "@/api/template-editor"
@@ -63,19 +63,14 @@ export interface GenerateStepContentProps {
onDismissError: () => void
/* 其他 */
presetVoices: PresetVoiceItem[]
videoRatio: string
/* Step4 预览(多预览) */
previewCount: number
onPreviewCountChange: (count: number) => void
previewItems: PreviewItem[]
previewSelectedIndex: number
onSelectPreview: (index: number) => void
previewOverallStatus: PreviewStatus
previewOverallError: string
previewOverallProgress: number
previewAnyGenerating: boolean
previewTemplateName: string
previewMaterialCount: string
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
@@ -112,18 +107,13 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onRetry,
onDismissError,
presetVoices,
videoRatio,
previewCount,
onPreviewCountChange,
previewItems,
previewSelectedIndex,
onSelectPreview,
previewOverallStatus,
previewOverallError,
previewOverallProgress,
previewAnyGenerating,
previewTemplateName,
previewMaterialCount,
onGeneratePreview,
onRegeneratePreview,
} = props
@@ -157,16 +147,17 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
)
case 4:
return (
<Step4GeneratePreview
templateName={previewTemplateName}
materialCount={previewMaterialCount}
duration={duration}
videoRatio={videoRatio}
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
/>
)
case 5:
return (
<Step5GeneratePreview
previewCount={previewCount}
onPreviewCountChange={onPreviewCountChange}
items={previewItems}
selectedIndex={previewSelectedIndex}
onSelectPreview={onSelectPreview}
overallStatus={previewOverallStatus}
overallError={previewOverallError}
overallProgress={previewOverallProgress}
@@ -175,13 +166,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onRegeneratePreview={onRegeneratePreview}
/>
)
case 5:
return (
<Step5TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
/>
)
case 6:
return (
<Step6CoverSettings
@@ -1,20 +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/useStep4Preview"
import type { TitleSettings } from "../types"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
interface PreviewVideoPanelProps {
previewStatus: PreviewStatus
@@ -23,134 +19,6 @@ interface PreviewVideoPanelProps {
progress: number
videoRatio: string
onRegenerate: () => void
/** 标题文字(Step5 起传入) */
titleText?: string
/** 标题样式设置(Step5 起传入) */
titleSettings?: TitleSettings
}
/* ── Canvas 绘制工具函数 ── */
/**
* 将文本按 maxWidth 逐字换行,返回行数组。
* 与 ASS 字幕引擎的逐字换行行为一致。
*/
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 上绘制标题文字(含描边/阴影/多行居中)
*
* Canvas 已通过 CSS 定位到视频实际渲染位置,
* 坐标系基于 Canvas 自身尺寸,居中直接使用 w/2。
*
* @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 顶部/底部偏移量
*/
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(Canvas 已定位到视频位置,无需额外偏移)
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
}
}
/* ── 组件 ── */
@@ -162,134 +30,12 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
progress,
videoRatio,
onRegenerate,
titleText,
titleSettings,
}) => {
const hasPreview = previewStatus === "ready" && previewResult
const isLoading = previewStatus === "pending" || previewStatus === "generating"
const isError = previewStatus === "error"
const showTitlePreview = !!titleSettings
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">
@@ -304,7 +50,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<p className="xx-preview-empty-title"></p>
<p className="xx-preview-empty-desc"> 3 </p>
<p className="xx-preview-empty-desc"></p>
</div>
)}
@@ -340,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>
)}
{/* 预览信息 */}
{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>
)
@@ -1,29 +1,23 @@
/**
* Step 4
*
* Step 5
*
* PreviewVideoPanel
*/
import React from "react"
import {
CheckCircleFilled,
LoadingOutlined,
ReloadOutlined,
PlayCircleOutlined,
ExclamationCircleFilled,
ClockCircleOutlined,
} from "@ant-design/icons"
import { InputNumber } from "antd"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
interface Step4GeneratePreviewProps {
templateName: string
materialCount: string
duration: number
videoRatio: string
interface Step5GeneratePreviewProps {
previewCount: number
onPreviewCountChange: (count: number) => void
items: PreviewItem[]
selectedIndex: number
onSelectPreview: (index: number) => void
overallStatus: PreviewStatus
overallError: string
overallProgress: number
@@ -39,15 +33,10 @@ const PREVIEW_COUNT_OPTIONS = [
{ value: 3, label: "3个" },
]
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
templateName: _templateName,
materialCount: _materialCount,
videoRatio,
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
previewCount,
onPreviewCountChange,
items,
selectedIndex,
onSelectPreview,
overallStatus,
overallError,
overallProgress,
@@ -55,7 +44,6 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
onGeneratePreview,
onRegeneratePreview,
}) => {
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
const isIdle = overallStatus === "idle"
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
@@ -132,122 +120,7 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
</div>
)}
{/* 预览网格(生成中/完成/部分完成) */}
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
<div
className="xx-preview-grid"
style={{
display: "grid",
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
gap: 12,
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
margin: "0 auto 16px",
}}
>
{items.map((item) => {
const isSelected = item.index === selectedIndex
return (
<div
key={item.index}
onClick={() => {
if (item.status === "ready") onSelectPreview(item.index)
}}
style={{
borderRadius: 8,
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
overflow: "hidden",
cursor: item.status === "ready" ? "pointer" : "default",
opacity: item.status === "error" ? 0.6 : 1,
transition: "all 0.2s",
}}
>
{/* 缩略图/状态区域 */}
<div
style={{
aspectRatio,
background: "#000",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
{item.status === "ready" && item.result && (
<video
src={item.result.videoUrl}
controls
autoPlay
muted
loop
preload="metadata"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
{(item.status === "pending" || item.status === "generating") && (
<div style={{ textAlign: "center" }}>
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
</p>
</div>
)}
{item.status === "error" && (
<div style={{ textAlign: "center", padding: 8 }}>
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
<p
style={{
color: "rgba(255,255,255,0.7)",
fontSize: 11,
marginTop: 4,
}}
>
</p>
</div>
)}
{/* 选中角标 */}
{isSelected && item.status === "ready" && (
<div
style={{
position: "absolute",
top: 4,
right: 4,
background: "#1677ff",
color: "#fff",
fontSize: 10,
padding: "2px 6px",
borderRadius: 4,
}}
>
#{item.index + 1}
</div>
)}
</div>
{/* 底部信息 */}
{item.status === "ready" && item.result && (
<div
style={{
padding: "6px 8px",
background: "#fafafa",
fontSize: 11,
color: "#666",
display: "flex",
justifyContent: "space-between",
}}
>
<span>{item.result.duration.toFixed(1)}</span>
<span>{item.result.clipCount}</span>
</div>
)}
</div>
)
})}
</div>
)}
{/* 预览网格已移除 — 右侧 PreviewVideoPanel 已展示实际预览视频,左侧不再重复显示 */}
{/* 整体进度条(多预览生成中) */}
{anyGenerating && (
@@ -262,7 +135,7 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
<span>
{items.filter((it) => it.status === "ready").length}
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
",请在右侧预览面板查看效果"
</span>
<button
className="xx-preview-regenerate-btn"
@@ -291,4 +164,4 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
)
}
export default Step4GeneratePreview
export default Step5GeneratePreview
@@ -1,5 +1,6 @@
import React from "react"
import type { CoverConfig } from "../../editing-planner/types"
import { Modal, Spin } from "antd"
import type { CoverConfig } from "../types/cover"
import { useStep6Cover } from "../hooks/useStep6Cover"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
@@ -18,6 +19,7 @@ interface Step6CoverSettingsProps {
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const {
coverSettings,
generating,
generateAutoCover,
showCoverSettings,
setShowCoverSettings,
@@ -95,6 +97,14 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
template={editingTemplate}
onSave={handleSaveTemplate}
/>
{/* AI 生成封面进度弹窗 */}
<Modal open={generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI ...</p>
</div>
</Modal>
</div>
)
}
@@ -4,7 +4,7 @@
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { CoverConfig } from "../types/cover"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { useStep7Generate } from "../hooks/useStep7Generate"
@@ -1,5 +1,5 @@
import React, { useState } from "react"
import type { CoverTemplate } from "../../../editing-planner/types"
import type { CoverTemplate } from "../../types/cover"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
@@ -1,5 +1,5 @@
import React from "react"
import type { CoverMode } from "../../../editing-planner/types"
import type { CoverMode } from "../../types/cover"
interface CoverModeSelectorProps {
mode: CoverMode
@@ -1,5 +1,5 @@
import React from "react"
import type { CoverTemplate } from "../../../editing-planner/types"
import type { CoverTemplate } from "../../types/cover"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
+3 -3
View File
@@ -2,7 +2,7 @@
* 智能剪辑页面 — 常量定义
*/
import type { CoverConfig } from "../editing-planner/types"
import type { CoverConfig } from "./types/cover"
/* ── 克隆声音状态配置 ── */
export const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
@@ -32,8 +32,8 @@ export const STEPS = [
{ key: 1, label: "选择模板" },
{ key: 2, label: "选择素材" },
{ key: 3, label: "选择配音" },
{ key: 4, label: "生成预览" },
{ key: 5, label: "选择标题" },
{ key: 4, label: "选择标题" },
{ key: 5, label: "生成预览" },
{ key: 6, label: "选择封面" },
{ key: 7, label: "确认生成" },
]
@@ -1,5 +1,5 @@
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
import type { CoverConfig } from "../../../editing-planner/types"
import type { CoverConfig } from "../../types/cover"
import type { TitleSettings } from "../../types"
/** useGenerateVideo 入参 */
@@ -6,7 +6,7 @@ import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import type { CoverConfig } from "../../../editing-planner/types"
import type { CoverConfig } from "../../types/cover"
import type { PresetVoiceItem } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../../constants"
import type { TitleSettings } from "../../types"
@@ -1,5 +1,5 @@
import { useEffect } from "react"
import type { CoverConfig } from "../../../editing-planner/types"
import type { CoverConfig } from "../../types/cover"
import type { TitleSettings } from "../../types"
import type { TitleConfig } from "@/api/template-editor"
import { getEditPlan } from "@/api/template-editor"
@@ -1,6 +1,6 @@
import { useEffect } from "react"
import type { TitleSettings } from "../../types"
import type { CoverConfig } from "../../../editing-planner/types"
import type { CoverConfig } from "../../types/cover"
import type { EditingTemplate } from "@/api/editing-planner"
interface UseTitleCoverSyncOptions {
@@ -56,10 +56,41 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
try {
// 使用确认生成 API(基于预览任务)
// 解析分辨率
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
const outputWidth = parseInt(widthStr, 10) || 1080
const outputHeight = parseInt(heightStr, 10) || 1920
// 解析分辨率videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
const ratio = props.videoRatio || "9:16"
let outputWidth: number
let outputHeight: number
if (ratio.includes(":")) {
// 宽高比格式,如 "9:16" → 基于基准高度 1920 计算
const [rw, rh] = ratio.split(":").map(Number)
if (rw > 0 && rh > 0) {
// 基准:长边 1920,短边按比例计算
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
const baseLong = 1920
const baseShort = Math.round((baseLong * shortSide) / longSide)
// 确保偶数(FFmpeg 要求)
const evenShort = baseShort - (baseShort % 2)
if (rw < rh) {
outputWidth = evenShort
outputHeight = baseLong
} else {
outputWidth = baseLong
outputHeight = evenShort
}
} else {
outputWidth = 1080
outputHeight = 1920
}
} else if (ratio.includes("x")) {
// 分辨率格式,如 "1080x1920"
const [wStr, hStr] = ratio.split("x")
outputWidth = parseInt(wStr, 10) || 1080
outputHeight = parseInt(hStr, 10) || 1920
} else {
outputWidth = 1080
outputHeight = 1920
}
await confirmGeneration(props.previewTaskId, {
output_width: outputWidth,
@@ -78,7 +109,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(finalMsg)
message.error(finalMsg)
}
}, [props, selectedTemplate, clearTimer, startPolling])
}, [props, clearTimer, startPolling])
/* 重新生成(失败后重试) */
const retry = useCallback(() => {
@@ -1,12 +1,14 @@
/**
* Step 4 Hook + voice_ids
* Step 5 Hook + voice_ids
* /generation/preview
*/
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation"
import { updateEditPlan } from "@/api/template-editor/editPlans"
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
import type { EditingTemplate } from "@/api/editing-planner"
import { safeExtractError } from "./generate-video/errorUtils"
import type { TitleSettings } from "../types"
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
const safeString = (val: unknown, fallback: string): string => {
@@ -25,7 +27,7 @@ const safeNumber = (val: unknown, fallback = 0): number => {
return fallback
}
interface UseStep4PreviewProps {
interface UseStep5PreviewProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
@@ -39,6 +41,8 @@ interface UseStep4PreviewProps {
voiceLibraryId?: string
/** 要生成的预览数量 */
previewCount?: number
/** 标题设置(传递给后端,让预览视频包含标题) */
titleSettings?: TitleSettings
}
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
@@ -77,7 +81,7 @@ const createInitialItem = (index: number): PreviewItem => ({
progress: 0,
})
export function useStep4Preview({
export function useStep5Preview({
templates,
selectedTemplate,
materialMode,
@@ -88,7 +92,8 @@ export function useStep4Preview({
voiceIds,
voiceLibraryId,
previewCount = 1,
}: UseStep4PreviewProps) {
titleSettings,
}: UseStep5PreviewProps) {
const templateName = useMemo(
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
[templates, selectedTemplate],
@@ -152,6 +157,7 @@ export function useStep4Preview({
duration,
videoRatio,
voiceIds: [...(voiceIds || [])].sort().join(","),
titleSettings: titleSettings?.title || "",
})
useEffect(() => {
@@ -163,6 +169,7 @@ export function useStep4Preview({
duration,
videoRatio,
[...(voiceIds || [])].sort().join(","),
titleSettings?.title || "",
].join("|")
const prevKey = [
@@ -173,6 +180,7 @@ export function useStep4Preview({
prevDepsRef.current.duration,
prevDepsRef.current.videoRatio,
prevDepsRef.current.voiceIds,
prevDepsRef.current.titleSettings,
].join("|")
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
@@ -190,6 +198,7 @@ export function useStep4Preview({
duration,
videoRatio,
voiceIds: [...(voiceIds || [])].sort().join(","),
titleSettings: titleSettings?.title || "",
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -201,6 +210,7 @@ export function useStep4Preview({
videoRatio,
voiceIds,
previewCount,
titleSettings,
])
// 组件卸载时清理所有轮询
@@ -211,90 +221,102 @@ export function useStep4Preview({
}, [clearPollTimer])
/** 轮询单个预览任务状态 */
const pollPreviewStatus = useCallback((index: number, taskId: string) => {
const poll = async () => {
// 竞态检查
if (taskIdsRef.current.get(index) !== taskId) return
// 超时检查
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
),
)
return
}
try {
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
const pollPreviewStatus = useCallback(
(index: number, taskId: string) => {
const poll = async () => {
// 竞态检查
if (taskIdsRef.current.get(index) !== taskId) return
const status = data.status as ApiPreviewStatus
// 超时检查
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
),
)
return
}
if (status === "completed") {
const result: PreviewResult = {
taskId: safeString(data.task_id, ""),
videoUrl: safeString(data.video_url, ""),
clipCount: safeNumber(data.clip_count),
transitionCount: safeNumber(data.transition_count),
materialUsage: safeNumber(data.material_usage),
duration: safeNumber(data.duration),
fileSize: safeNumber(data.file_size),
generateDuration: safeNumber(data.generate_duration),
progress: 100,
try {
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
if (taskIdsRef.current.get(index) !== taskId) return
const status = data.status as ApiPreviewStatus
if (status === "completed") {
const result: PreviewResult = {
taskId: safeString(data.task_id, ""),
videoUrl: safeString(data.video_url, ""),
clipCount: safeNumber(data.clip_count),
transitionCount: safeNumber(data.transition_count),
materialUsage: safeNumber(data.material_usage),
duration: safeNumber(data.duration),
fileSize: safeNumber(data.file_size),
generateDuration: safeNumber(data.generate_duration),
progress: 100,
}
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
),
)
// 保存预览视频 URL 到 plan config,供封面生成使用
if (result.videoUrl && selectedTemplate) {
updateEditPlan(selectedTemplate, {
config: { rendered_storage_key: result.videoUrl },
}).catch((err) => {
console.warn("[Step4] 保存预览视频URL到plan config失败:", err)
})
}
return
}
if (status === "failed") {
setItems((prev) =>
prev.map((it) =>
it.index === index
? {
...it,
status: "error",
error: safeString(data.error_message, "预览生成失败,请重试"),
}
: it,
),
)
return
}
if (status === "cancelled") {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
),
)
return
}
// pending / generating 状态继续轮询
const prog = safeNumber(data.progress)
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
),
)
return
const delay = status === "pending" ? 5000 : 2000
pollTimersRef.current.set(index, setTimeout(poll, delay))
} catch {
if (taskIdsRef.current.get(index) !== taskId) return
pollTimersRef.current.set(index, setTimeout(poll, 3000))
}
if (status === "failed") {
setItems((prev) =>
prev.map((it) =>
it.index === index
? {
...it,
status: "error",
error: safeString(data.error_message, "预览生成失败,请重试"),
}
: it,
),
)
return
}
if (status === "cancelled") {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
),
)
return
}
// pending / generating 状态继续轮询
const prog = safeNumber(data.progress)
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
),
)
const delay = status === "pending" ? 5000 : 2000
pollTimersRef.current.set(index, setTimeout(poll, delay))
} catch {
if (taskIdsRef.current.get(index) !== taskId) return
pollTimersRef.current.set(index, setTimeout(poll, 3000))
}
}
pollTimersRef.current.set(index, setTimeout(poll, 1000))
}, [])
pollTimersRef.current.set(index, setTimeout(poll, 1000))
},
[selectedTemplate],
)
/** 生成所有预览 */
const generatePreview = useCallback(async () => {
@@ -336,6 +358,19 @@ export function useStep4Preview({
video_ratio: videoRatio,
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
voice_library_id: voiceLibraryId || undefined,
// 标题烧录配置
title_config: titleSettings?.title
? {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
}
: undefined,
})
if (startTimeRef.current === 0) return
@@ -361,6 +396,7 @@ export function useStep4Preview({
voiceIds,
voiceLibraryId,
previewCount,
titleSettings,
clearPollTimer,
pollPreviewStatus,
])
@@ -437,4 +473,4 @@ export function useStep4Preview({
}
}
export default useStep4Preview
export default useStep5Preview
@@ -2,10 +2,10 @@
* Step 6 封面设置 Hook
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
*/
import { useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { message } from "antd"
import type { CoverConfig, CoverTemplate } from "../../editing-planner/types"
import { generateCover } from "@/api/template-editor"
import type { CoverConfig, CoverTemplate } from "../types/cover"
import { generateCover } from "@/api/generation"
import {
fetchCoverTemplates,
createCoverTemplate,
@@ -30,7 +30,7 @@ export function useStep6Cover({
assetIds = [],
selectedTemplate = "",
}: UseStep6CoverProps) {
const generatingRef = useRef(false)
const [generating, setGenerating] = useState(false)
// ── 封面设置弹窗状态 ──
const [showCoverSettings, setShowCoverSettings] = useState(false)
@@ -67,7 +67,7 @@ export function useStep6Cover({
/** 调用后端智能封面 API,生成封面并更新预览 */
const generateAutoCover = useCallback(async () => {
if (generatingRef.current) {
if (generating) {
message.warning("封面正在生成中,请稍候...")
return
}
@@ -82,12 +82,17 @@ export function useStep6Cover({
return
}
generatingRef.current = true
setGenerating(true)
// 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
const timeoutId = setTimeout(() => {
setGenerating(false)
}, 300000)
try {
const response = await generateCover(selectedTemplate, {
asset_ids: assetIds,
cover_type: "ai_frame",
})
clearTimeout(timeoutId)
const thumbnailUrl = response.cover?.image_url || ""
if (thumbnailUrl) {
onCoverSettingsChange({
@@ -100,12 +105,35 @@ export function useStep6Cover({
message.warning("封面生成未返回图片,请重试")
}
} catch (err) {
console.warn("[Step6] 智能封面生成失败:", err)
message.error("封面生成失败,请稍后重试")
clearTimeout(timeoutId)
console.error("[Step6] 智能封面生成失败:", err)
// 提取详细错误信息
let errorMsg = "封面生成失败"
const e = err as {
response?: { data?: { detail?: string; message?: string }; status?: number }
request?: unknown
message?: string
}
if (e.response) {
// 后端返回错误
const detail = e.response.data?.detail || e.response.data?.message || ""
errorMsg = detail || `后端错误 (${e.response.status})`
console.error("[Step6] 后端返回:", e.response.data)
} else if (e.request) {
// 请求已发送但无响应
errorMsg = "服务器无响应,请检查网络连接"
console.error("[Step6] 请求无响应:", e.request)
} else if (e.message) {
errorMsg = e.message
}
message.error(errorMsg)
} finally {
generatingRef.current = false
clearTimeout(timeoutId)
setGenerating(false)
}
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange])
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
// ── 模板操作方法 ──
const handleSelectTemplate = useCallback((id: string) => {
@@ -165,6 +193,7 @@ export function useStep6Cover({
return {
coverSettings,
generating,
generateAutoCover,
totalDuration,
showCoverSettings,
@@ -6,7 +6,7 @@ import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import type { CoverConfig } from "../types/cover"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { getAssetsByKind } from "@/api/assets"
@@ -1,7 +1,7 @@
/**
* GeneratePage 步骤导航
* 管理步骤切换与各步骤的前置校验
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -49,12 +49,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
return
}
// Step3 配音:配音为可选项,不强制校验,用户可跳过
if (currentStep === 4 && !previewReady) {
message.warning("请先生成剪辑预览")
if (currentStep === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep === 5 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
if (currentStep === 5 && !previewReady) {
message.warning("请先生成剪辑预览")
return
}
if (currentStep < 7) {
@@ -1,5 +1,6 @@
/**
*
*
* editing-planner generate 使
*/
/** 封面来源模式 */
-17
View File
@@ -8,7 +8,6 @@ import {
generateEditPlan,
getGenerationStatus,
aiRecommendClips,
generateCover,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
@@ -182,22 +181,6 @@ describe("editPlans API", () => {
})
})
describe("generateCover", () => {
it("should resolve successfully", async () => {
await expect(generateCover("test-planId")).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(generateCover("test-planId")).rejects.toThrow()
})
})
describe("getEditPlanGenerations", () => {
it("should resolve successfully", async () => {
await expect(getEditPlanGenerations("test-planId")).resolves.not.toThrow()
+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", () => ({
@@ -149,7 +149,6 @@ vi.mock("@/api/editing-planner", () => ({
vi.mock("@/api/template-editor", () => ({
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
generateCover: vi.fn().mockResolvedValue({}),
getEditPlan: vi.fn().mockResolvedValue({}),
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
updateEditPlan: vi.fn().mockResolvedValue({}),
@@ -223,9 +222,6 @@ vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({
vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
default: () => React.createElement("div", { "data-testid": "StickerPanel" }),
}))
vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({
default: () => React.createElement("div", { "data-testid": "CoverSelector" }),
}))
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
}))
@@ -260,7 +256,6 @@ vi.mock("@/pages/editing-planner/types", () => ({
DEFAULT_FILTER_CONFIG: { enabled: false },
DEFAULT_CHROMA_KEY_CONFIG: { enabled: false },
DEFAULT_STICKER_CONFIG: { enabled: false },
DEFAULT_COVER_CONFIG: { enabled: false },
}))
vi.mock("@/pages/editing-planner/types/subtitle", () => ({
@@ -1,24 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import CoverSelector from "@/pages/editing-planner/components/CoverSelector"
import { DEFAULT_COVER_CONFIG } from "@/pages/editing-planner/types"
const defaultProps = {
open: true,
onClose: vi.fn(),
config: DEFAULT_COVER_CONFIG,
onChange: vi.fn(),
totalDuration: 60,
}
describe("CoverSelector", () => {
it("should render without crashing", () => {
const { container } = render(<CoverSelector {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<CoverSelector {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -6,15 +6,10 @@ const defaultProps = {
clips: [],
selectedClipId: null,
isPlaying: false,
currentCoverScheme: "scheme1",
coverSchemes: [{ id: "scheme1", name: "方案1", cover_url: "" }],
aiCoverLoading: false,
titleSettings: undefined,
subtitleSettings: undefined,
onClipSelect: vi.fn(),
onCoverSchemeChange: vi.fn(),
onPlayPause: vi.fn(),
onAiGenerateCover: vi.fn(),
}
describe("PreviewPlayer", () => {
@@ -28,7 +28,6 @@ import "@/pages/editing-planner/utils/clipProperties"
// 子组件
import "@/pages/editing-planner/components/BgmSelector"
import "@/pages/editing-planner/components/ClipPropertiesPanel"
import "@/pages/editing-planner/components/CoverSelector"
import "@/pages/editing-planner/components/EditorClipList"
import "@/pages/editing-planner/components/EditingDrawers"
import "@/pages/editing-planner/components/FilterPanel"
@@ -17,7 +17,7 @@ import "@/api/generation/types"
// 直接引入所有 Step 组件,建立完整依赖链
import "@/pages/generate/GeneratePage"
import "@/pages/generate/components/Step2MaterialSelect"
import "@/pages/generate/components/Step4GeneratePreview"
import "@/pages/generate/components/Step5GeneratePreview"
import "@/pages/generate/components/Step4TitleSettings"
import "@/pages/generate/components/Step5VoiceSelect"
import "@/pages/generate/components/PreviewVideoPanel"
@@ -47,7 +47,7 @@ describe("GeneratePage module smoke test", () => {
})
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/useStep4Preview"
import "@/pages/generate/hooks/useStep5Preview"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
import "@/pages/generate/hooks/useGenerateFormState"
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
@@ -1,17 +0,0 @@
/**
* Step4GeneratePreview smoke test
* 确保 vitest related 模式能匹配到第4步预览生成相关文件的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/generate/components/Step4GeneratePreview"
import "@/pages/generate/hooks/useStep4Preview"
import "@/pages/generate/hooks/useStepNavigation"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/GeneratePage"
describe("Step4GeneratePreview module smoke test", () => {
it("should load all step4 preview modules", () => {
expect(true).toBe(true)
})
})
@@ -0,0 +1,17 @@
/**
* Step5GeneratePreview smoke test
* 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/generate/components/Step5GeneratePreview"
import "@/pages/generate/hooks/useStep5Preview"
import "@/pages/generate/hooks/useStepNavigation"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/GeneratePage"
describe("Step5GeneratePreview module smoke test", () => {
it("should load all step5 preview modules", () => {
expect(true).toBe(true)
})
})
@@ -1,431 +0,0 @@
"""视频封面生成器 — 从视频中提取/生成封面图.
支持能力:
- 指定时间点抽帧(默认第1秒)
- 智能封面:抽取多帧选最清晰的一帧
- 自定义上传封面图(直接返回路径)
- 生成的封面图保存为 JPEG 格式,可复用
"""
from __future__ import annotations
import logging
import subprocess
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg
logger = logging.getLogger(__name__)
# ── 配置常量 ──────────────────────────────────────────────────────────────────
# 智能封面抽帧数量
SMART_COVER_FRAME_COUNT = 3
# 默认抽帧时间点(秒)
DEFAULT_COVER_TIME = 1.0
# 封面输出尺寸(宽x高)
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
# 封面质量(JPEG quality 1-31,越小质量越高)
DEFAULT_COVER_QUALITY = 5
# ── 数据模型 ──────────────────────────────────────────────────────────────────
class CoverGenerator:
"""视频封面生成器.
三种模式:
1. 指定时间点抽帧:从视频指定时间提取一帧
2. 智能封面:抽取3帧,用 blur 检测选最清晰的
3. 自定义上传:直接使用用户上传的图片
"""
@staticmethod
def extract_frame(
video_path: str | Path,
output_path: str | Path,
*,
time_sec: float = DEFAULT_COVER_TIME,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""从视频指定时间点提取一帧作为封面.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量(1-31,越小越好)
Returns:
封面图片路径
Raises:
FileNotFoundError: 视频文件不存在
subprocess.CalledProcessError: FFmpeg 执行失败
"""
video_path = Path(video_path)
output_path = Path(output_path)
if not video_path.exists():
raise FileNotFoundError(f"视频文件不存在: {video_path}")
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 安全钳制时间
info = probe_video_info(str(video_path))
duration = info.get("duration", 0.0)
if duration > 0 and time_sec >= duration:
# 超过视频长度,取中间帧
time_sec = max(0, duration / 2)
if time_sec < 0:
time_sec = 0
# scale + crop 实现 cover 裁剪(铺满输出尺寸)
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
command = [
FFMPEG_BIN,
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.info("抽取视频封面: video=%s time=%.2fs output=%s", video_path.name, time_sec, output_path.name)
run_ffmpeg(command)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError(f"封面生成失败: {output_path}")
return output_path
@staticmethod
def extract_smart_cover(
video_path: str | Path,
output_path: str | Path,
*,
frame_count: int = SMART_COVER_FRAME_COUNT,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
work_dir: str | Path | None = None,
) -> Path:
"""智能封面:抽取多帧,选最清晰的一帧.
清晰度判断:使用拉普拉斯方差(Variance of Laplacian),
方差越大表示图像边缘越丰富,越清晰。
Args:
video_path: 视频文件路径
output_path: 最终输出封面路径
frame_count: 抽帧数量(均匀分布在视频中)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
work_dir: 临时工作目录(默认输出目录的父目录)
Returns:
最佳封面图片路径
"""
video_path = Path(video_path)
output_path = Path(output_path)
if not video_path.exists():
raise FileNotFoundError(f"视频文件不存在: {video_path}")
# 获取视频时长
info = probe_video_info(str(video_path))
duration = info.get("duration", 0.0)
if duration <= 0 or frame_count <= 1:
# 无法获取时长或只有1帧,退化为普通抽帧
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=min(DEFAULT_COVER_TIME, max(0, duration / 2)),
width=width,
height=height,
quality=quality,
)
# 临时目录
if work_dir is None:
work_dir = output_path.parent
work_dir = Path(work_dir)
work_dir.mkdir(parents=True, exist_ok=True)
# 均匀分布抽帧时间点(跳过首尾5%
start_pct = 0.05
end_pct = 0.95
if frame_count == 1:
time_points = [duration * 0.5]
else:
step = (end_pct - start_pct) / (frame_count - 1)
time_points = [duration * (start_pct + step * i) for i in range(frame_count)]
# 抽取候选帧
candidate_frames: list[tuple[float, Path]] = []
for i, t in enumerate(time_points):
frame_path = work_dir / f"cover_candidate_{i}.jpg"
try:
CoverGenerator.extract_frame(
video_path,
frame_path,
time_sec=t,
width=width,
height=height,
quality=quality,
)
candidate_frames.append((t, frame_path))
except Exception as e:
logger.warning("智能封面抽帧失败(t=%.2fs: %s", t, e)
continue
if not candidate_frames:
# 全部失败,退化到普通抽帧
logger.warning("智能封面所有候选帧抽取失败,退化为普通抽帧")
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=min(DEFAULT_COVER_TIME, duration / 2),
width=width,
height=height,
quality=quality,
)
if len(candidate_frames) == 1:
# 只有一帧,直接用
import shutil
shutil.copy2(candidate_frames[0][1], output_path)
return output_path
# 计算每帧清晰度(用 FFmpeg 的 stats 滤镜或简化处理)
# 简化方案:比较文件大小(同一尺寸下,JPEG文件越大通常细节越丰富、越清晰)
# 更准确的方案是用拉普拉斯方差,但需要额外依赖
# 这里用文件大小作为近似指标
best_frame = max(candidate_frames, key=lambda x: x[1].stat().st_size)
# 复制最佳帧到输出路径
import shutil
shutil.copy2(best_frame[1], output_path)
logger.info(
"智能封面生成完成: 候选%d帧, 最佳t=%.2fs, 大小=%d字节",
len(candidate_frames),
best_frame[0],
output_path.stat().st_size,
)
# 清理临时文件
for _, fp in candidate_frames:
try:
fp.unlink()
except OSError:
pass
return output_path
@staticmethod
def process_custom_cover(
image_path: str | Path,
output_path: str | Path,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""处理用户自定义上传的封面图.
调整尺寸、格式转换为标准封面格式。
Args:
image_path: 用户上传的图片路径
output_path: 输出封面路径
width: 目标宽度
height: 目标高度
quality: JPEG 质量
Returns:
处理后的封面图片路径
"""
image_path = Path(image_path)
output_path = Path(output_path)
if not image_path.exists():
raise FileNotFoundError(f"封面图片不存在: {image_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
command = [
FFMPEG_BIN,
"-y",
"-i",
str(image_path),
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.info("处理自定义封面: input=%s output=%s", image_path.name, output_path.name)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError:
# 处理失败,直接复制原图
logger.warning("自定义封面处理失败,使用原图")
import shutil
shutil.copy2(image_path, output_path)
return output_path
@staticmethod
def generate_cover(
video_path: str | Path,
output_path: str | Path,
*,
mode: str = "smart", # smart / time / custom
time_sec: float = DEFAULT_COVER_TIME,
custom_image: str | Path | None = None,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Path:
"""统一封面生成入口.
Args:
video_path: 视频文件路径
output_path: 输出封面路径
mode: 模式 - smart(智能选帧)/ time(指定时间)/ custom(自定义图片)
time_sec: time 模式下的抽帧时间点
custom_image: custom 模式下的自定义图片路径
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面图片路径
"""
if mode == "custom" and custom_image:
return CoverGenerator.process_custom_cover(
custom_image,
output_path,
width=width,
height=height,
quality=quality,
)
elif mode == "time":
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=time_sec,
width=width,
height=height,
quality=quality,
)
else:
# 默认智能封面
return CoverGenerator.extract_smart_cover(
video_path,
output_path,
width=width,
height=height,
quality=quality,
)
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def generate_cover_from_plan(
plan: Any,
video_path: str | Path,
output_dir: str | Path,
) -> Path | None:
"""从 EditPlan 配置生成封面图.
配置读取:plan.config.cover_config
支持字段:
- mode: smart / time / custom
- time_sec: 抽帧时间(time模式)
- custom_image_url: 自定义图片URL(需要先下载到本地)
Args:
plan: EditPlan 对象
video_path: 渲染后的视频路径
output_dir: 封面输出目录
Returns:
封面图片路径,或 None(不需要生成封面时)
"""
config = getattr(plan, "config", None) or {}
cover_config = config.get("cover_config") if isinstance(config, dict) else None
if not cover_config:
return None
mode = cover_config.get("mode", "smart")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"cover_{plan.id}.jpg"
try:
if mode == "custom":
# 自定义封面:需要先有本地图片路径
custom_path = cover_config.get("custom_image_path")
if custom_path and Path(custom_path).exists():
return CoverGenerator.process_custom_cover(
custom_path,
output_path,
)
else:
logger.warning("自定义封面图片路径无效,退化为智能封面")
mode = "smart"
if mode == "time":
time_sec = float(cover_config.get("time_sec", DEFAULT_COVER_TIME))
return CoverGenerator.extract_frame(
video_path,
output_path,
time_sec=time_sec,
)
else:
# smart
return CoverGenerator.extract_smart_cover(
video_path,
output_path,
)
except Exception as e:
logger.warning("封面生成失败: %s", e)
return None
+2 -12
View File
@@ -85,19 +85,9 @@ def create_video_record_and_dedup(
if thumbnail_url:
generated_video.thumbnail_url = thumbnail_url
video_repo.update_thumbnail(video_id, thumbnail_url)
logger.info("Thumbnail reused (pre-generated) for video %s", video_id)
logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80] if thumbnail_url else "")
else:
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
_thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
if _thumbnail_url:
generated_video.thumbnail_url = _thumbnail_url
video_repo.update_thumbnail(video_id, _thumbnail_url)
logger.info("Thumbnail generated for video %s: %s", video_id, _thumbnail_url)
except Exception as thumb_err:
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
logger.debug("No thumbnail_url provided for video %s, skipping", video_id)
# 计算视频指纹
deduplicator = VideoDeduplicator()
+41 -1
View File
@@ -47,7 +47,14 @@ def _parse_resolution(resolution_str: str | None) -> tuple[int, int]:
w, h = resolution_str.lower().split("x", 1)
width = int(w.strip())
height = int(h.strip())
if width <= 0 or height <= 0:
# 最小 100px 防护:避免前端传入宽高比(如 "9:16")被 parseInt 截断为极小值
if width < 100 or height < 100:
logger.warning(
"分辨率异常小 (%dx%d),使用默认值。原始值: %s",
width,
height,
resolution_str,
)
return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT
return width, height
except (ValueError, TypeError):
@@ -74,6 +81,9 @@ class RenderAdapterResult:
failed_clip_ids: list[str] = None # 失败的 clip id 列表
error_message: str = ""
error_detail: str = "" # 详细错误信息(如 ffmpeg stderr),用于排查
cover_candidates: list[dict] | None = (
None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
)
def __post_init__(self):
if self.rendered_clip_ids is None:
@@ -509,6 +519,8 @@ class RenderAdapter:
# 3. 读取输出分辨率
export_config = plan_config.get("export", {}) or {}
if not isinstance(export_config, dict):
export_config = {}
output_width, output_height = _parse_resolution(export_config.get("resolution"))
logger.info(
"渲染输出分辨率: plan_id=%s resolution=%dx%d source=%s",
@@ -568,6 +580,33 @@ class RenderAdapter:
thumb_err,
)
# 7. 抽取封面候选帧并上传 OSS(失败不阻断主流程)
cover_candidates = None
try:
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
# 从 plan config 提取标题文字,叠加到封面候选帧上
_title_cfg = (plan_config or {}).get("title", {}) or {}
if not isinstance(_title_cfg, dict):
_title_cfg = {}
_title_text = (_title_cfg.get("text", "") or "").strip() if _title_cfg.get("enabled", True) else ""
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path), plan_id, num_frames=3, title_text=_title_text
)
if cover_candidates:
logger.info(
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
plan_id,
len(cover_candidates),
)
except Exception as cover_err:
logger.warning(
"[render-adapter] 封面候选帧生成失败(不影响主流程): plan_id=%s error=%s",
plan_id,
cover_err,
)
self._report_progress(progress_cb, 100.0, "渲染完成")
logger.info(
@@ -599,6 +638,7 @@ class RenderAdapter:
clip_count=len(clips),
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
cover_candidates=cover_candidates,
)
def render_from_memory(
+3 -3
View File
@@ -131,7 +131,7 @@ def mix_audio(
if not main_clips and not audio_clips:
# 没有主音频也没有独立音频 → 检查是否有 BGM
if bgm_path and bgm_config and bgm_config.get("enabled", False):
if bgm_path and bgm_config and isinstance(bgm_config, dict) and bgm_config.get("enabled", False):
from video_processing.bgm_mixer import BGMConfig, build_bgm_only
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
@@ -161,7 +161,7 @@ def mix_audio(
mix_with_independent_audio(ctx, effective_main, effective_audio, output_path, video_duration)
# ── BGM 混音 ──
if bgm_path and bgm_config and bgm_config.get("enabled", False):
if bgm_path and bgm_config and isinstance(bgm_config, dict) and bgm_config.get("enabled", False):
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
@@ -174,7 +174,7 @@ def mix_audio(
logger.exception("[bgm] BGM 混音失败,回退到无 BGM 音频: plan_id=%s", ctx.plan_id)
# ── 多轨道混音(配音/音效等) ──
if audio_tracks_config and audio_tracks_config.get("enabled", False):
if audio_tracks_config and isinstance(audio_tracks_config, dict) and audio_tracks_config.get("enabled", False):
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
try:
@@ -33,7 +33,7 @@ class ReverseConfig:
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ReverseConfig":
"""从字典解析配置."""
if not data:
if not isinstance(data, dict):
return cls(enabled=False)
try:
if not data.get("enabled", False):
@@ -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)
@@ -1,4 +1,8 @@
"""视频缩略图生成工具 — 抽取首帧上传到 OSS。"""
"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。
统一封面管道视频渲染时标题已通过 ASS 字幕烧进视频
渲染完成后直接从此视频抽帧封面天然带标题无需额外叠加逻辑
"""
from __future__ import annotations
@@ -13,28 +17,30 @@ def extract_first_frame(
video_path: str,
output_path: str | None = None,
*,
width: int = 640,
width: int = -1,
height: int = -1,
timeout: int = 30,
seek_ratio: float = 0.15,
min_seek_seconds: float = 1.0,
) -> str:
"""抽取视频封面(默认取视频时长 15% 处的帧,避开片头纯色画面)。
"""抽取视频封面(默认取视频时长 15% 处的帧,避开片头纯色画面)。
因为视频渲染时标题已通过 ASS 字幕烧录抽取的帧天然带标题
Args:
video_path: 视频文件路径
output_path: 输出图片路径不传则用临时文件
width: 输出宽度默认 640-1 表示按比例缩放
height: 输出高度默认 -1按比例缩放
width: 输出宽度默认 -1保持原始分辨率
height: 输出高度默认 -1保持原始分辨率
timeout: 超时时间
seek_ratio: 抽帧位置占视频时长的比例默认 0.15 15%
min_seek_seconds: 最小抽帧时间避免极短视频 seek 0
Returns:
生成的缩略图文件路径
生成的封面帧文件路径
Raises:
subprocess.CalledProcessError: ffmpeg 执行失败
RuntimeError: ffmpeg 执行失败或输出文件为空
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
@@ -57,10 +63,20 @@ def extract_first_frame(
# 格式化为 HH:MM:SS.xx
seek_str = _format_seek_time(seek_time)
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
# 构建 scale filter:如果指定了宽高则缩放,否则保持原始分辨率。
# NOTE: scale_filter 在此处通过 if/else 分支赋值,之后不再被覆盖,
# 后续 cmd / cmd2 均复用同一变量,逻辑无变化。
if width > 0 or height > 0:
w_str = str(width) if width > 0 else "-1"
h_str = str(height) if height > 0 else "-1"
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease,format=yuvj420p"
else:
# 保持原始分辨率,只确保格式兼容
scale_filter = "format=yuvj420p"
# -ss 放在 -i 前面(input seeking,更快)
# -vframes 1 只取一帧
# -q:v 2 jpeg 高质量
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease,format=yuvj420p"
cmd = [
FFMPEG_BIN,
"-y",
@@ -99,7 +115,7 @@ def extract_first_frame(
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
raise RuntimeError(f"Cover frame extraction failed: {output_path}")
return output_path
except Exception:
@@ -118,40 +134,3 @@ 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,
) -> str | None:
"""生成缩略图并上传到 OSS,返回 URL。
Args:
video_path: 本地视频路径
storage_key: OSS 存储 key generated/projects/xxx/thumbnails/yyy.jpg
Returns:
上传成功返回 URL失败返回 None
"""
thumbnail_path = None
try:
thumbnail_path = extract_first_frame(video_path)
except Exception as e:
logger.warning("Failed to extract thumbnail from %s: %s", video_path, e)
return None
try:
from video_processing.oss_helpers import upload_to_oss
url = upload_to_oss(thumbnail_path, storage_key)
return url
except Exception as e:
logger.warning("Failed to upload thumbnail to OSS: %s", e)
return None
finally:
# 清理临时文件
if thumbnail_path:
try:
Path(thumbnail_path).unlink(missing_ok=True)
except Exception:
pass
@@ -324,6 +324,8 @@ class UnifiedRenderService:
else:
config = self.plan.config or {}
bgm_config = config.get("bgm", {}) or {}
if not isinstance(bgm_config, dict):
bgm_config = {}
audio_tracks_config = config.get("audio_tracks") or {}
noise_reduction_config = config.get("audio_noise_reduction")
ctx = RenderContext(
@@ -480,7 +482,11 @@ class UnifiedRenderService:
"""
config = self.plan.config or {}
title_cfg = config.get("title", {}) or {}
if not isinstance(title_cfg, dict):
title_cfg = {}
subtitle_cfg = config.get("subtitle", {}) or {}
if not isinstance(subtitle_cfg, dict):
subtitle_cfg = {}
title_enabled = title_cfg.get("enabled", True)
subtitle_enabled = subtitle_cfg.get("enabled", True)
@@ -507,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",
@@ -653,7 +662,11 @@ class UnifiedRenderService:
"""
config = self.plan.config or {}
tts_cfg = config.get("tts", {}) or {}
if not isinstance(tts_cfg, dict):
tts_cfg = {}
subtitle_cfg = config.get("subtitle", {}) or {}
if not isinstance(subtitle_cfg, dict):
subtitle_cfg = {}
use_subtitle_align = False # 是否使用字幕对齐模式
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
@@ -163,36 +163,6 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
job_service.fail_job(job_id, error_msg[:500])
raise RuntimeError(result.error_message)
# 生成封面(如果配置启用)
cover_url = None
try:
from video_processing.cover_generator import generate_cover_from_plan
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository as EditPlanRepository,
)
# 获取 plan 对象
plan_repo = EditPlanRepository(db)
plan = plan_repo.get(plan_id)
if plan and result.output_path:
# 检查 cover_config
cover_config = (plan.config or {}).get("cover_config")
if cover_config and cover_config.get("enabled", False):
from pathlib import Path
output_dir = Path(result.output_path).parent
cover_path = generate_cover_from_plan(plan, result.output_path, output_dir)
if cover_path:
# 生成 cover_url(相对路径或上传到存储)
cover_url = f"/covers/{plan_id}.jpg"
logger.info("封面生成成功: plan_id=%s cover_path=%s", plan_id, cover_path)
else:
logger.info("封面生成未启用: plan_id=%s", plan_id)
except Exception as e:
logger.warning("封面生成失败(不影响视频合成): plan_id=%s error=%s", plan_id, e)
# 更新 Job 状态为完成
result_data = {
"plan_id": plan_id,
@@ -205,7 +175,6 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
"width": result.width,
"height": result.height,
"file_size": result.file_size,
"cover_url": cover_url,
}
job_service.complete_job(job_id, result=result_data)
@@ -107,6 +107,8 @@ def _finalize_render_success(
# 从 plan.config.title.text 读取视频名称
plan_config = plan.config or {}
title_cfg = plan_config.get("title", {}) or {}
if not isinstance(title_cfg, dict):
title_cfg = {}
video_name = (title_cfg.get("text") or "").strip() or f"generated-{generation_task_id[:8]}.mp4"
if generation_task_id:
try:
@@ -241,14 +243,32 @@ def _render_with_unified(
return {"status": "error", "message": result.error_message or "渲染失败"}
output_path = result.output_path or Path("")
output_url = result.output_url
output_url = result.output_url or ""
thumbnail_url = result.thumbnail_url or ""
storage_key = f"rendered/{plan_id}/output.mp4"
# adapter 上传到 rendered/{plan_id}/{job_id}.mp4,从 URL 提取实际 key
# 不能用 output.mp4 硬编码,否则 cover 等下游通过 key 构造的 URL 指向不存在的文件
if output_url:
from packages.shared.storage import get_shared_storage_service
storage_key = get_shared_storage_service().normalize_storage_key(output_url)
else:
storage_key = f"rendered/{plan_id}/{generation_task_id or plan_id}.mp4"
# 用 adapter 返回的 clip 明细(以 adapter 的结果为准)
rendered_clip_ids = result.rendered_clip_ids or []
failed_clip_ids = result.failed_clip_ids or []
# 将封面候选帧写入 plan.config(供封面 API 直接使用,跳过 MediaKit 抽帧)
if result.cover_candidates:
plan_config = plan.config or {}
plan_config["cover_candidates"] = result.cover_candidates
plan.config = plan_config
logger.info(
"封面候选帧已写入 plan.config: plan_id=%s count=%d",
plan_id,
len(result.cover_candidates),
)
return _finalize_render_success(
plan=plan,
plan_repo=plan_repo,
+90 -4
View File
@@ -13,6 +13,7 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
@@ -983,9 +984,9 @@ def _load_template_plan_config(template_id: str) -> dict:
# 从独立字段组装成 plan.config 格式
plan_config: dict[str, Any] = {}
title_cfg = template.title_config or {}
subtitle_cfg = template.subtitle_config or {}
bgm_cfg = template.bgm_config or {}
title_cfg = template.title_config if isinstance(template.title_config, dict) else {}
subtitle_cfg = template.subtitle_config if isinstance(template.subtitle_config, dict) else {}
bgm_cfg = template.bgm_config if isinstance(template.bgm_config, dict) else {}
if title_cfg:
plan_config["title"] = title_cfg
@@ -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,10 +1157,33 @@ 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 {}
template_bgm = plan_cfg.get("bgm", {}) or {}
if not isinstance(template_bgm, dict):
template_bgm = {}
merged_bgm = merge_bgm_config(template_bgm, bgm_config)
plan_cfg["bgm"] = merged_bgm
virtual_plan.config = plan_cfg
@@ -1189,6 +1214,8 @@ def _render_video(
plan_cfg = dict(virtual_plan.config or {})
plan_cfg["voice_id"] = voice_ids[0]
subtitle_cfg = plan_cfg.get("subtitle", {}) or {}
if not isinstance(subtitle_cfg, dict):
subtitle_cfg = {}
subtitle_cfg["auto_generated"] = True
plan_cfg["subtitle"] = subtitle_cfg
virtual_plan.config = plan_cfg
@@ -1262,7 +1289,9 @@ def _upload_and_record(
Returns:
(file_url, duration, file_size, video_count)
"""
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_path.name}"
# project_id 可能为空(模板编辑器草稿不属于任何项目),过滤空段避免 OSS key 出现 //
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, output_path.name) if p]
storage_key = "/".join(path_parts)
file_size = output_path.stat().st_size
# 上传 OSS
@@ -1499,6 +1528,16 @@ def generate_video(self, task_id: str) -> dict:
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
_ow = task_info.get("output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH
_oh = task_info.get("output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT
# 防护:前端可能误传宽高比(如 parseInt("9:16") = 9),宽度 < 100 时忽略
if _ow < 100 or _oh < 100:
logger.warning(
"[task_id=%s] output_width/output_height 异常 (%dx%d),回退到默认",
task_id,
_ow,
_oh,
)
_ow = OUTPUT_WIDTH
_oh = OUTPUT_HEIGHT
if _ow != OUTPUT_WIDTH or _oh != OUTPUT_HEIGHT:
_resolved_resolution = f"{_ow}x{_oh}"
else:
@@ -1517,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:
@@ -1548,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)
+12 -2
View File
@@ -206,11 +206,21 @@ def ingest_asset(job_id: str) -> dict:
# 视频类型:生成缩略图(文件还在的时候生成)
thumbnail_url = None
if media_type == "video" and extract_success:
frame_path = None
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
from video_processing.oss_helpers import upload_to_oss
from video_processing.thumbnail_generator import extract_first_frame
frame_path = extract_first_frame(str(local_file), width=640)
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
try:
thumbnail_url = upload_to_oss(frame_path, thumb_storage_key)
finally:
if frame_path:
try:
Path(frame_path).unlink(missing_ok=True)
except Exception:
pass
if thumbnail_url:
logger.info(
"素材缩略图生成成功: job_id=%s url=%s",
+2 -1
View File
@@ -53,9 +53,10 @@ ARG APP_VERSION=dev
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖)
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖ffmpeg 用于封面兜底取帧
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# 从 builder 复制虚拟环境
+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 入口点
@@ -101,6 +101,13 @@ class SQLAlchemyAssetRepository:
return None
return self._to_domain(model)
def find_by_ids(self, asset_ids: list[str]) -> list[Asset]:
"""批量查询素材(单次 SQL IN 查询,避免 N+1)。"""
if not asset_ids:
return []
models = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
return [self._to_domain(m) for m in models]
def get(self, asset_id: str) -> Asset | None:
return self.find_by_id(asset_id)
+1
View File
@@ -32,6 +32,7 @@ class CreateGenerationTaskCommand:
output_height: int = 720
cover_url: str = ""
custom_title: str = ""
title_config: dict = field(default_factory=dict)
class CreateGenerationTaskUseCase:
+24 -3
View File
@@ -247,6 +247,27 @@ def build_ass_content(
title_config = title_config or {}
subtitle_config = subtitle_config or {}
# ── 兼容前端简化格式:stroke/shadow 为 boolean 时,转换为标准 dict ──
# 前端 TitleSettings 发送 stroke=true/false, shadow=true/false
# 后端 build_ass_style 期望 stroke={enabled, color, width}, shadow={enabled, blur, offset_x, offset_y}
if title_config:
_stroke_val = title_config.get("stroke")
if isinstance(_stroke_val, bool):
title_config["stroke"] = {
"enabled": _stroke_val,
"color": "#000000",
"width": 2,
} if _stroke_val else {"enabled": False}
_shadow_val = title_config.get("shadow")
if isinstance(_shadow_val, bool):
title_config["shadow"] = {
"enabled": _shadow_val,
"color": "#000000",
"blur": 4,
"offset_x": 2,
"offset_y": 2,
} if _shadow_val else {"enabled": False}
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
@@ -262,7 +283,7 @@ def build_ass_content(
title_stroke = title_config.get("stroke", {}) or {}
title_shadow = title_config.get("shadow", {}) or {}
stroke_color = hex_to_ass_color(title_stroke.get("color", "#000000"))
stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0
stroke_width = float(title_stroke.get("width", 2)) if title_stroke.get("enabled", False) else 0.0
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
shadow_offset = (
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
@@ -275,7 +296,7 @@ def build_ass_content(
build_ass_style(
"TitleStyle",
font_name=title_config.get("font", "思源黑体"),
font_size=int(title_config.get("size", 48)),
font_size=min(int(title_config.get("size", 36)), 36),
primary_color=title_color,
outline_color=stroke_color,
outline_width=stroke_width,
@@ -292,7 +313,7 @@ def build_ass_content(
# 根据视频宽度和字号自动换行标题,防止超出画面
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
title_font_size = int(title_config.get("size", 48))
title_font_size = min(int(title_config.get("size", 36)), 36)
safe_title_text_raw = escape_ass_text(title_text)
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
+1 -1
View File
@@ -95,7 +95,7 @@ class ChromaKeyConfig:
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> ChromaKeyConfig:
"""从字典解析配置,参数越界自动钳制."""
if not data or not data.get("enabled", False):
if not isinstance(data, dict) or not data.get("enabled", False):
return cls(enabled=False)
key_color = str(data.get("key_color", DEFAULT_KEY_COLOR)).strip()
+1 -1
View File
@@ -196,7 +196,7 @@ class ColorGradeConfig:
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
if not isinstance(data, dict) or not data.get("enabled", False):
return cls(enabled=False)
preset = data.get("preset", "")
+1 -1
View File
@@ -76,7 +76,7 @@ class IntroOutroConfig:
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "IntroOutroConfig":
"""从字典构造."""
if not data:
if not isinstance(data, dict):
return cls()
enabled = data.get("enabled", False)
+1 -1
View File
@@ -81,7 +81,7 @@ class NoiseReductionConfig:
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> NoiseReductionConfig:
"""从字典解析配置,参数越界自动钳制."""
if not data or not data.get("enabled", False):
if not isinstance(data, dict) or not data.get("enabled", False):
return cls(enabled=False)
level_str = str(data.get("level", "medium")).lower()
+1 -1
View File
@@ -136,7 +136,7 @@ class PiPConfig:
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
if not isinstance(data, dict) or not data.get("enabled", False):
return cls(enabled=False)
layers_data = data.get("layers", [])
+1 -1
View File
@@ -86,7 +86,7 @@ class WatermarkConfig:
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> WatermarkConfig | None:
"""从字典构造,空配置返回 None(不加水印)."""
if not data:
if not isinstance(data, dict):
return None
enabled = data.get("enabled", False)
+12 -52
View File
@@ -361,7 +361,10 @@ def _call_ai_cover_service(
) -> Dict[str, Any]:
"""调用 AI 封面生成服务.
cover_type ai_frame ai_regenerate 调用 MediaKit 视频截帧
统一封面管道下封面已由渲染后视频抽帧生成并持久化到 GenerationTask.cover_url
此函数仅处理 manual/upload 等需要前端交互的类型
ai_frame/ai_regenerate 类型应由调用方直接从持久化的封面 URL 读取
失败时抛出 RuntimeError
Args:
@@ -369,7 +372,7 @@ def _call_ai_cover_service(
asset_ids: 素材 ID 列表
cover_type: 封面类型
frame_time: 手动选帧时间点
primary_video_url: 主视频的可访问 URL用于 MediaKit 抽帧
primary_video_url: 主视频的可访问 URL
"""
if cover_type == "upload":
return {
@@ -392,57 +395,14 @@ def _call_ai_cover_service(
"frame_time": frame_time,
}
# ai_frame / ai_regenerate - 尝试调用 MediaKit
if primary_video_url:
from packages.shared.mediakit_client import get_mediakit_client
client = get_mediakit_client()
if client.is_available:
try:
logger.info("调用 MediaKit 抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
frames = client.extract_frames(
video_url=primary_video_url,
strategy="TimeInterval",
max_frames=5,
poll_interval=3.0,
max_poll_attempts=60, # 180秒超时
)
if frames and len(frames) > 0:
# 选择第一帧(SceneChange 策略的第一帧通常是最佳画面)
best_frame = frames[0]
image_url = best_frame.get("image_url", "")
timestamp = best_frame.get("timestamp", 0.0)
if image_url:
logger.info(
"MediaKit 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
plan_id,
timestamp,
image_url[:80],
)
# MediaKit 返回的 URL 是临时内部 URL,浏览器无法直接访问
# 需要下载到本地并重新上传到 OSS,返回公开可访问的 URL
public_url = _transfer_cover_frame_to_storage(image_url, plan_id)
return {
"type": "ai_frame",
"image_url": public_url,
"frame_time": round(timestamp, 1),
"confidence": 0.85,
}
else:
logger.warning("MediaKit 返回的帧无 image_url")
except Exception as e:
logger.exception("MediaKit 抽帧失败: %s", str(e))
# 封面生成失败 - 不再降级到 stub,直接报错
raise RuntimeError(
f"封面生成失败: plan_id={plan_id}, MediaKit 不可用或抽帧失败。" f"请检查 primary_video_url 是否可访问。"
# ai_frame / ai_regenerate: 封面应由渲染后视频抽帧管道生成
# 如果调用方传入了持久化的封面 URL,直接使用
logger.warning(
"封面生成回退: plan_id=%s cover_type=%s — 统一管道应已生成封面,请检查 GenerationTask.cover_url",
plan_id,
cover_type,
)
# ── 公共入口 ────────────────────────────────────────────────────────────────
raise RuntimeError(f"封面数据不可用 (plan_id={plan_id})。请重新生成预览视频以触发封面自动提取。")
def run_ai_recommend(
@@ -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):
+80 -14
View File
@@ -108,21 +108,30 @@ def _run_with_fakes(
session = MagicMock()
session_maker = MagicMock(return_value=session)
captured = {"upload_calls": [], "session": session_maker()}
captured: dict = {"upload_calls": []}
def _tracking_upload(local_path, storage_key):
captured["upload_calls"].append((local_path, storage_key))
return upload_fn(local_path, storage_key)
# Wrap session_maker to capture the session INSIDE the patch context.
# This avoids stale PromiseProxy cache issues in full-suite runs.
_created_sessions: list = []
_orig_sm = session_maker
def _tracking_sm(*a, **kw):
s = _orig_sm(*a, **kw)
_created_sessions.append(s)
return s
bound_task = _make_bound_task()
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=repo,
):
import worker_app.db as _db_mod
with patch.object(_db_mod, "SessionLocal", _tracking_sm):
with patch(
"worker_app.db.SessionLocal",
session_maker,
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=repo,
):
with patch(
"video_processing.oss_helpers.upload_to_oss",
@@ -134,6 +143,7 @@ def _run_with_fakes(
):
result = _call_task(batch_download_videos, bound_task, [v.id for v in videos], user_id)
captured["session"] = _created_sessions[0] if _created_sessions else None
captured["result"] = result
return captured
@@ -244,19 +254,67 @@ def test_batch_download_single_video():
def test_batch_download_session_closed():
"""DB session is always closed (via finally block)."""
"""DB session is always closed (via finally block).
Patches the function's own globals to inject mock SessionLocal,
bypassing any import caching issues in the full suite.
"""
import sys
import worker_app.db as _db_mod
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
videos = [_FakeVideo("v1", "v.mp4")]
repo = _FakeGeneratedVideoRepository(videos)
session = MagicMock()
session_maker = MagicMock(return_value=session)
mock_session_factory = MagicMock(return_value=session)
_run_with_fakes(videos, session_maker=session_maker)
def _noop_download(url, dest):
Path(dest).parent.mkdir(parents=True, exist_ok=True)
Path(dest).write_bytes(b"fake video data")
bound_task = _make_bound_task()
# Get the raw function to patch its globals
raw_fn = _get_raw_task_fn(batch_download_videos)
# Patch SessionLocal in ALL possible module locations
_db_mod.SessionLocal = mock_session_factory
if "worker_app.db" in sys.modules:
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
# Also patch in the function's own globals if it has a reference there
if "SessionLocal" in raw_fn.__globals__:
raw_fn.__globals__["SessionLocal"] = mock_session_factory
try:
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=repo,
):
with patch("video_processing.oss_helpers.upload_to_oss", return_value="https://oss.example.com/zip"):
with patch(
"apps.worker.worker_app.tasks.batch_download._download_video_to_file",
_noop_download,
):
raw_fn(bound_task, ["v1"], "user_1")
finally:
pass # Don't restore - other tests handle their own patches
# Diagnostic: check if our mock factory was actually called
assert mock_session_factory.called, (
"SessionLocal mock was never called! "
f"raw_fn={raw_fn}, type={type(raw_fn)}"
)
session.close.assert_called_once()
def test_batch_download_closes_session_on_error():
"""Session is closed even when get_by_ids raises."""
import sys
import worker_app.db as _db_mod
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
class _ExplodingRepo:
@@ -264,17 +322,25 @@ def test_batch_download_closes_session_on_error():
raise RuntimeError("db down")
session = MagicMock()
session_maker = MagicMock(return_value=session)
mock_session_factory = MagicMock(return_value=session)
bound_task = _make_bound_task()
raw_fn = _get_raw_task_fn(batch_download_videos)
_db_mod.SessionLocal = mock_session_factory
if "worker_app.db" in sys.modules:
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
if "SessionLocal" in raw_fn.__globals__:
raw_fn.__globals__["SessionLocal"] = mock_session_factory
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=_ExplodingRepo(),
):
with patch("worker_app.db.SessionLocal", session_maker):
with pytest.raises(RuntimeError, match="db down"):
_call_task(batch_download_videos, bound_task, ["v1"], "u")
with pytest.raises(RuntimeError, match="db down"):
raw_fn(bound_task, ["v1"], "u")
assert mock_session_factory.called, "SessionLocal mock was never called!"
session.close.assert_called_once()
+130
View File
@@ -0,0 +1,130 @@
"""
测试模板 config 字段存储了非 dict True / False / str
渲染链路不会崩溃'bool' object has no attribute 'get'
覆盖两个关键文件
1. generation.py _load_template_plan_config 旧系统路径
2. unified_render_service.py _maybe_generate_ass
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add worker app to path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
class TestLoadTemplatePlanConfigBoolDefense:
"""_load_template_plan_config 旧系统路径对非 dict 值的防护。"""
def _call_old_path(self, title_cfg, subtitle_cfg, bgm_cfg):
"""通过 mock 新模板系统返回 None,强制走旧模板系统 fallback 路径。"""
from worker_app.tasks.generation import _load_template_plan_config
mock_old_template = MagicMock()
mock_old_template.title_config = title_cfg
mock_old_template.subtitle_config = subtitle_cfg
mock_old_template.bgm_config = bgm_cfg
mock_session = MagicMock()
# 旧系统 query 返回 mock template
mock_session.query.return_value.filter.return_value.first.return_value = mock_old_template
# Mock 新模板系统 repo.get() 返回 None(强制走 fallback
mock_repo_cls = MagicMock()
mock_repo_cls.return_value.get.return_value = None
with (
patch("worker_app.tasks.generation.SessionLocal", return_value=mock_session),
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyEditTemplateRepository", mock_repo_cls),
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyTemplateClipConfigRepository", MagicMock()),
):
return _load_template_plan_config("fake-id")
def test_bool_values_return_empty(self):
"""title_config=True / subtitle_config=False / bgm_config='str' → 全部过滤掉"""
result = self._call_old_path(True, False, "not_a_dict")
assert isinstance(result, dict)
assert "title" not in result
assert "subtitle" not in result
assert "bgm" not in result
def test_valid_dict_passes_through(self):
"""正常 dict 正常传递"""
result = self._call_old_path(
{"text": "标题", "enabled": True},
{"text": "副标题"},
{"enabled": True, "source": "test.mp3"},
)
assert result["title"] == {"text": "标题", "enabled": True}
assert result["subtitle"] == {"text": "副标题"}
assert result["bgm"] == {"enabled": True, "source": "test.mp3"}
def test_none_returns_empty(self):
"""None → 空 dict"""
result = self._call_old_path(None, None, None)
assert result == {}
def test_mixed_valid_and_invalid(self):
"""部分有效、部分无效时只保留有效的"""
result = self._call_old_path({"text": "OK"}, True, None)
assert "title" in result
assert "subtitle" not in result
assert "bgm" not in result
def test_int_and_list_also_filtered(self):
"""int / list 类型也被过滤"""
result = self._call_old_path(42, [1, 2, 3], 0)
assert result == {}
class TestUnifiedRenderBoolConfigDefense:
"""_maybe_generate_ass 对 plan.config 中非 dict title/subtitle 的防护。"""
def _make_service(self, config):
from video_processing.unified_render_service import UnifiedRenderService
service = UnifiedRenderService.__new__(UnifiedRenderService)
mock_plan = MagicMock()
mock_plan.config = config
service.plan = mock_plan
service.task_id = "test-task"
return service
def test_bool_title_does_not_crash(self):
"""config['title']=True → 不崩溃,返回 None"""
service = self._make_service({"title": True, "subtitle": {}})
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
def test_bool_subtitle_does_not_crash(self):
"""config['subtitle']=False → 不崩溃,返回 None"""
service = self._make_service({"title": {}, "subtitle": False})
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
def test_str_title_does_not_crash(self):
"""config['title']='plain string' → 不崩溃"""
service = self._make_service({"title": "plain string", "subtitle": {}})
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
def test_none_config_does_not_crash(self):
"""config=None → 不崩溃"""
service = self._make_service(None)
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
def test_int_title_does_not_crash(self):
"""config['title']=42 → 不崩溃"""
service = self._make_service({"title": 42, "subtitle": 0})
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
@@ -0,0 +1,496 @@
"""片段管理路由 clips.py 增量覆盖率测试.
覆盖 PR fix/clips-api-response-structure 新增代码:
- _clip_to_response: 枚举转换日期格式化asset_url 参数
- _build_asset_url_map: 批量素材 URL 解析空列表/异常/正常路径
- 路由层 asset_repo 注入与 URL 拼接逻辑
"""
from __future__ import annotations
import os
import sys
from enum import Enum
from pathlib import Path
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")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# 常量与工厂
# ---------------------------------------------------------------------------
TEST_TEMPLATE_ID = "tmpl-test-001"
TEST_PLAN_ID = "plan-draft-001"
TEST_USER_ID = "user-001"
def _auth_user():
u = MagicMock()
u.user.id = TEST_USER_ID
u.user_id = TEST_USER_ID
return u
def _clip(**overrides):
"""构造 mock clip,支持 Enum 类型字段"""
c = MagicMock()
c.id = overrides.get("id", "clip-001")
c.plan_id = overrides.get("plan_id", TEST_PLAN_ID)
c.clip_type = overrides.get("clip_type", "video")
c.order = overrides.get("order", 0)
c.duration = overrides.get("duration", 10.0)
c.start_time = overrides.get("start_time", 0.0)
c.text_content = overrides.get("text_content", "")
c.transition_effect = overrides.get("transition_effect", "cut")
c.transition_duration = overrides.get("transition_duration", 0.0)
c.playback_speed = overrides.get("playback_speed", 1.0)
c.asset_id = overrides.get("asset_id", "")
c.status = overrides.get("status", "ready")
c.template_clip_config_id = overrides.get("template_clip_config_id", "")
c.config = overrides.get("config", {})
c.created_at = overrides.get("created_at", None)
c.updated_at = overrides.get("updated_at", None)
return c
def _services(plan_svc_overrides=None):
tpl = MagicMock()
plan = MagicMock()
if plan_svc_overrides:
for k, v in plan_svc_overrides.items():
setattr(plan, k, v)
return tpl, plan
# ---------------------------------------------------------------------------
# 单元测试: _clip_to_response
# ---------------------------------------------------------------------------
class TestClipToResponse:
"""_clip_to_response 纯函数测试 — 覆盖行 53-80"""
def test_basic_fields(self):
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(id="c1", order=3, duration=5.5, text_content="hello")
resp = _clip_to_response(c)
assert resp.id == "c1"
assert resp.order == 3
assert resp.duration == 5.5
assert resp.text_content == "hello"
assert resp.asset_url is None
def test_enum_clip_type(self):
"""Enum 值应被 .value 解包"""
from app.api.routes.templates_editor.clips import _clip_to_response
class ClipType(str, Enum):
VIDEO = "video"
AUDIO = "audio"
c = _clip(clip_type=ClipType.VIDEO)
resp = _clip_to_response(c)
assert resp.clip_type == "video"
def test_plain_string_clip_type(self):
"""非 Enum 字符串直接用 str()"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(clip_type="main")
resp = _clip_to_response(c)
assert resp.clip_type == "main"
def test_enum_transition_effect(self):
from app.api.routes.templates_editor.clips import _clip_to_response
class Transition(str, Enum):
FADE = "fade"
c = _clip(transition_effect=Transition.FADE)
resp = _clip_to_response(c)
assert resp.transition_effect == "fade"
def test_default_transition_when_none(self):
"""transition_effect 缺失时默认 cut"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip()
del c.transition_effect # 触发 getattr default
resp = _clip_to_response(c)
assert resp.transition_effect == "cut"
def test_asset_url_passed(self):
"""asset_url 参数应透传到响应"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(asset_id="a1")
resp = _clip_to_response(c, asset_url="https://signed-url.example.com/video.mp4")
assert resp.asset_url == "https://signed-url.example.com/video.mp4"
def test_asset_url_none_by_default(self):
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip()
resp = _clip_to_response(c)
assert resp.asset_url is None
def test_datetime_isoformat(self):
"""datetime 对象应被 isoformat()"""
from datetime import datetime
from app.api.routes.templates_editor.clips import _clip_to_response
dt = datetime(2026, 8, 17, 12, 0, 0)
c = _clip(created_at=dt, updated_at=dt)
resp = _clip_to_response(c)
assert "2026-08-17" in resp.created_at
assert "2026-08-17" in resp.updated_at
def test_none_datetime_empty_string(self):
"""None 日期应格式化为空字符串"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(created_at=None, updated_at=None)
resp = _clip_to_response(c)
assert resp.created_at == ""
assert resp.updated_at == ""
def test_string_datetime_passthrough(self):
"""已经是字符串的日期直接 str()"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(created_at="2026-08-17T00:00:00")
resp = _clip_to_response(c)
assert resp.created_at == "2026-08-17T00:00:00"
def test_none_defaults_for_optional_fields(self):
"""None/缺失字段的默认值"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(asset_id=None, status=None, template_clip_config_id=None)
resp = _clip_to_response(c)
assert resp.asset_id == ""
assert resp.status == "pending"
assert resp.template_clip_config_id == ""
def test_zero_duration_fallback(self):
"""duration=0 → playback_speed 默认 1.0"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(playback_speed=None)
resp = _clip_to_response(c)
assert resp.playback_speed == 1.0
# ---------------------------------------------------------------------------
# 单元测试: _build_asset_url_map
# ---------------------------------------------------------------------------
class TestBuildAssetUrlMap:
"""_build_asset_url_map 测试 — 覆盖行 93-118"""
def test_empty_list(self):
"""空 asset_ids 直接返回空 dict"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
repo = MagicMock()
result = _build_asset_url_map([], repo)
assert result == {}
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_storage_service_failure(self, mock_get_storage):
"""存储服务获取失败时返回全 None"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
mock_get_storage.side_effect = RuntimeError("storage unavailable")
repo = MagicMock()
result = _build_asset_url_map(["a1", "a2"], repo)
assert result == {"a1": None, "a2": None}
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_asset_not_found(self, mock_get_storage):
"""asset_id 找不到对应素材 → None"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
mock_get_storage.return_value = storage
repo = MagicMock()
repo.find_by_ids.return_value = []
result = _build_asset_url_map(["missing-id"], repo)
assert result == {"missing-id": None}
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_no_storage_key(self, mock_get_storage):
"""素材没有 storage_key → None"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
mock_get_storage.return_value = storage
repo = MagicMock()
asset = MagicMock()
asset.id = "a1"
asset.storage_key = ""
repo.find_by_ids.return_value = [asset]
result = _build_asset_url_map(["a1"], repo)
assert result == {"a1": None}
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_successful_url_generation(self, mock_get_storage):
"""正常路径:返回签名 URL"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.example.com/signed.mp4"
mock_get_storage.return_value = storage
repo = MagicMock()
asset = MagicMock()
asset.id = "a1"
asset.storage_key = "videos/test.mp4"
repo.find_by_ids.return_value = [asset]
result = _build_asset_url_map(["a1"], repo)
assert result == {"a1": "https://cdn.example.com/signed.mp4"}
storage.get_download_url.assert_called_once_with("videos/test.mp4", expires_seconds=3600)
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_exception_during_url_generation(self, mock_get_storage):
"""单个 asset 生成 URL 异常 → None,不影响其他"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
storage.get_download_url.side_effect = [Exception("boom"), "https://ok.com/v2"]
mock_get_storage.return_value = storage
repo = MagicMock()
asset1 = MagicMock()
asset1.id = "a1"
asset1.storage_key = "v1.mp4"
asset2 = MagicMock()
asset2.id = "a2"
asset2.storage_key = "v2.mp4"
repo.find_by_ids.return_value = [asset1, asset2]
result = _build_asset_url_map(["a1", "a2"], repo)
assert result["a1"] is None
assert result["a2"] == "https://ok.com/v2"
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_skip_empty_asset_id(self, mock_get_storage):
"""空字符串 asset_id 被跳过"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
mock_get_storage.return_value = storage
repo = MagicMock()
result = _build_asset_url_map(["", "a1"], repo)
# "" not in result because it's skipped by `if not aid: continue`
assert "" not in result
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_multiple_assets_mixed(self, mock_get_storage):
"""混合场景:正常+异常+缺失"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.com/ok.mp4"
mock_get_storage.return_value = storage
repo = MagicMock()
good_asset = MagicMock()
good_asset.id = "a1"
good_asset.storage_key = "good.mp4"
# a1=good, a2=not found, a3=good
good_asset2 = MagicMock()
good_asset2.id = "a3"
good_asset2.storage_key = "good.mp4"
repo.find_by_ids.return_value = [good_asset, good_asset2]
result = _build_asset_url_map(["a1", "a2", "a3"], repo)
assert result["a1"] == "https://cdn.com/ok.mp4"
assert result["a2"] is None
assert result["a3"] == "https://cdn.com/ok.mp4"
# ---------------------------------------------------------------------------
# 集成测试: 路由层 asset_repo 注入
# ---------------------------------------------------------------------------
class TestClipRoutesAssetIntegration:
"""路由层测试 — 覆盖 asset_url 在 list/detail/split/merge 中的拼接逻辑"""
def _create_app(self, plan_svc_config=None):
from app.api.routes import templates_editor as editor_module
from app.dependencies import get_asset_repository
mock_clip_1 = _clip(id="c1", asset_id="asset-001")
mock_clip_2 = _clip(id="c2", asset_id="")
mock_tpl_svc = MagicMock()
mock_plan_svc = MagicMock()
mock_plan_svc.list_clips.return_value = [mock_clip_1, mock_clip_2]
mock_plan_svc.count_clips.return_value = 2
mock_plan_svc.get_clip.return_value = mock_clip_1
mock_plan_svc.create_clip.return_value = _clip(id="c-new", asset_id="")
mock_plan_svc.update_clip.return_value = _clip(id="c1", duration=15.0)
mock_plan_svc.delete_clip.return_value = True
mock_plan_svc.split_clip.return_value = {
"left_clip": _clip(id="c-left", asset_id="asset-L"),
"right_clip": _clip(id="c-right", asset_id="asset-R"),
}
mock_plan_svc.merge_clips.return_value = _clip(id="c-merged", asset_id="asset-M")
if plan_svc_config:
for k, v in plan_svc_config.items():
setattr(mock_plan_svc, k, v)
def _deps():
return mock_tpl_svc, mock_plan_svc
mock_asset_repo = MagicMock()
app = FastAPI()
app.include_router(
editor_module.router,
prefix="/api/v1/templates/{template_id}/editor",
)
app.dependency_overrides[editor_module.get_current_user] = _auth_user
app.dependency_overrides[editor_module.get_draft_plan_id] = lambda: TEST_PLAN_ID
app.dependency_overrides[editor_module.get_editor_services] = _deps
app.dependency_overrides[get_asset_repository] = lambda: mock_asset_repo
return TestClient(app), mock_plan_svc, mock_asset_repo
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_list_clips_includes_asset_urls(self, mock_get_storage):
"""GET /clips 应为有 asset_id 的片段返回签名 URL"""
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.com/c1.mp4"
mock_get_storage.return_value = storage
client, _, asset_repo = self._create_app()
asset = MagicMock()
asset.id = "asset-001"
asset.storage_key = "videos/c1.mp4"
asset_repo.find_by_ids.return_value = [asset]
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips")
assert resp.status_code == 200
data = resp.json()
items = data["items"]
assert len(items) == 2
# c1 has asset_id → should have url
assert items[0]["asset_url"] == "https://cdn.com/c1.mp4"
# c2 has empty asset_id → None
assert items[1]["asset_url"] is None
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_get_clip_detail_with_asset_url(self, mock_get_storage):
"""GET /clips/{clip_id} 应返回素材签名 URL"""
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.com/detail.mp4"
mock_get_storage.return_value = storage
client, _, asset_repo = self._create_app()
asset = MagicMock()
asset.id = "asset-001"
asset.storage_key = "videos/detail.mp4"
asset_repo.find_by_ids.return_value = [asset]
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001")
assert resp.status_code == 200
assert resp.json()["asset_url"] == "https://cdn.com/detail.mp4"
def test_get_clip_detail_no_asset(self):
"""片段没有 asset_id 时不应调用 URL 解析"""
client, plan_svc, asset_repo = self._create_app()
# 返回没有 asset_id 的片段
plan_svc.get_clip.return_value = _clip(id="c-no-asset", asset_id="")
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/c-no-asset")
assert resp.status_code == 200
assert resp.json()["asset_url"] is None
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_split_clip_returns_asset_urls(self, mock_get_storage):
"""POST /clips/{clip_id}/split 返回的左右片段应带签名 URL"""
storage = MagicMock()
storage.get_download_url.side_effect = ["https://cdn.com/L.mp4", "https://cdn.com/R.mp4"]
mock_get_storage.return_value = storage
client, _, asset_repo = self._create_app()
asset_l = MagicMock()
asset_l.storage_key = "videos/L.mp4"
asset_r = MagicMock()
asset_r.storage_key = "videos/R.mp4"
asset_l.id = "asset-L"
asset_r.id = "asset-R"
asset_repo.find_by_ids.return_value = [asset_l, asset_r]
resp = client.post(
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001/split",
json={"split_time": 5.0},
)
assert resp.status_code == 200
data = resp.json()
assert data["left_clip"]["asset_url"] == "https://cdn.com/L.mp4"
assert data["right_clip"]["asset_url"] == "https://cdn.com/R.mp4"
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_merge_clips_returns_asset_url(self, mock_get_storage):
"""POST /clips/merge 返回的合并片段应带签名 URL"""
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.com/M.mp4"
mock_get_storage.return_value = storage
client, _, asset_repo = self._create_app()
asset = MagicMock()
asset.id = "asset-M"
asset.storage_key = "videos/M.mp4"
asset_repo.find_by_ids.return_value = [asset]
resp = client.post(
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge",
json={"clip_ids": ["c1", "c2"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["merged_clip"]["asset_url"] == "https://cdn.com/M.mp4"
assert data["deleted_clip_ids"] == ["c1", "c2"]
def test_merge_clips_not_found(self):
"""merge 时某片段不存在应返回 404"""
client, plan_svc, _ = self._create_app()
plan_svc.get_clip.return_value = None
resp = client.post(
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge",
json={"clip_ids": ["nonexistent-1", "nonexistent-2"]},
)
assert resp.status_code == 404
def test_delete_clip_success(self):
"""DELETE /clips/{clip_id} 成功返回 204"""
client, _, _ = self._create_app()
resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001")
assert resp.status_code == 204
def test_delete_clip_not_found(self):
"""DELETE 片段不存在返回 404"""
client, plan_svc, _ = self._create_app()
plan_svc.delete_clip.return_value = False
resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/bad-id")
assert resp.status_code == 404

Some files were not shown because too many files have changed in this diff Show More