Compare commits

..

98 Commits

Author SHA1 Message Date
xiaoxia a27519287b #2034 fix: remove stray SHA footer from edit_plan_service.py
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
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 / 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 / Unit Tests (pull_request) Failing after 2m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m4s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m50s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m47s
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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 31s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m2s
2026-09-25 09:42:29 +08:00
xiaoxia 7c5c0009b2 #2034 fix: remove stray SHA footer from unified_render_service.py 2026-09-25 09:42:27 +08:00
xiaoxia c0f86ae959 #2034 fix: _build_variant_config_update 读 dedup_enabled,关开关时不注入 visual/pixel 扰动(保留节奏模板+BGM差异化)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m50s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m13s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m53s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m57s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 5m2s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 5m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m46s
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 / CI Gate (pull_request) Failing after 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m34s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 40s
2026-09-25 08:47:56 +08:00
xiaoxia 755100f18e #2034 fix: _get_visual_perturbation 加 dedup_enabled 守门,关开关时跳过视觉/像素扰动 2026-09-25 08:47:41 +08:00
xiaoxia a94c7ff3c8 release v0.1.137: 抖音多源轮询重构+ASR降级+文案库切换+积分系统+多个修复
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m31s
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 / Canary Release to Production (push) Waiting to run
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Integration Tests (push) Successful in 7m15s
CI/CD Pipeline / Validate - Style (push) Successful in 7m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m57s
CI/CD Pipeline / Unit Tests (push) Successful in 13m56s
CI/CD Pipeline / Validate - Security (push) Successful in 22m56s
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 / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 21s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m52s
API Base Image Build / Build API Base Image (push) Successful in 57m49s
Worker Base Image Build / Build Worker Base Image (push) Successful in 1h2m0s
gpu-check / check (push) Successful in 7s
2026-09-18 19:41:47 +08:00
xiaoxia 96b3d6ae12 Merge pull request 'release v0.1.136: 智能剪辑批量变体P0修复+AI数字人MOV支持+代码审查重构' (#1886) from release/v0.1.136 into main
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m46s
CI/CD Pipeline / Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Style (push) Successful in 3m35s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m23s
CI/CD Pipeline / Validate - Security (push) Successful in 7m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m31s
CI/CD Pipeline / Unit Tests (push) Successful in 9m50s
CI/CD Pipeline / Build Production Web Image (push) Successful in 13s
CI/CD Pipeline / Build Production API Image (push) Successful in 13s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 2m25s
CI/CD Pipeline / Deploy Production (push) Failing after 27s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
API Base Image Build / Build API Base Image (push) Successful in 32m3s
Worker Base Image Build / Build Worker Base Image (push) Successful in 37m20s
CI/CD Pipeline / Canary Release to Production (push) Failing after 30m24s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 53h9m4s
CI/CD Pipeline / PR Build API Image (push) Failing after 53h11m24s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 53h10m57s
CI/CD Pipeline / PR Build Web Image (push) Failing after 53h10m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 53h8m39s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 53h8m38s
CI/CD Pipeline / CI Gate (push) Failing after 53h1m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 53h10m54s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 53h11m0s
2026-09-14 10:12:18 +08:00
xiaoxia 9ed3694d99 release: 2026.09.14 智能剪辑批量变体P0修复+AI数字人MOV支持+代码审查重构 (#1885)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m48s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m17s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m26s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 26s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 53h12m45s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 53h20m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 53h19m44s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 53h19m46s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 53h12m21s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 53h19m48s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 53h19m48s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 53h19m49s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 53h19m49s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 53h19m49s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 53h19m51s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 53h12m21s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 53h12m21s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 53h12m19s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 53h19m42s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 53h19m43s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 53h19m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 53h19m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 53h55m6s
2026-09-14 10:02:34 +08:00
xiaoxia 58d57033ea Merge pull request 'fix(auth): 微信 unionid 账号打通逻辑修复(老账号自动关联 + 查找顺序优化 + 冲突保护)' (#1738) from fix/wechat-unionid-account-linking into main
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Check push changed paths (push) Successful in 1m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (push) Successful in 2m25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 47s
CI/CD Pipeline / Validate - Style (push) Successful in 3m13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 49s
CI/CD Pipeline / Validate - Security (push) Successful in 5m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m39s
CI/CD Pipeline / Unit Tests (push) Successful in 10m28s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 14s
CI/CD Pipeline / Build Production API Image (push) Successful in 14s
CI/CD Pipeline / Build Production Web Image (push) Successful in 15s
CI/CD Pipeline / Deploy Production (push) Successful in 40s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1m38s
CI/CD Pipeline / Canary Release to Production (push) Failing after 240h23m50s
CI/CD Pipeline / CI Gate (push) Failing after 240h24m7s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 240h29m2s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 240h29m3s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 240h29m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 240h29m58s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 240h33m9s
CI/CD Pipeline / PR Build API Image (push) Failing after 240h33m12s
CI/CD Pipeline / Frontend Lint (push) Failing after 240h34m28s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 240h35m2s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 241h3m39s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 241h4m34s
CI/CD Pipeline / PR Build Web Image (push) Failing after 241h7m46s
2026-09-06 14:48:42 +08:00
CI Bot 711d03a409 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 55s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m37s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
AI Code Review / AI Code Review (pull_request) Successful in 3m56s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m18s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 18s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 29s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 240h44m10s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 240h44m10s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 240h44m12s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 240h44m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 240h45m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 240h48m24s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 240h48m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 240h48m27s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 240h48m25s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 240h48m27s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 240h48m34s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 240h48m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 240h48m35s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 240h48m35s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 240h48m38s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 241h18m48s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 241h23m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 241h23m9s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 241h23m10s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 241h23m12s
2026-09-06 06:35:45 +00:00
xiaoxia 0554b4b1dd test(auth): 补充unionid补写/跨端绑定/冲突拒绝场景测试
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m6s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 240h50m48s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 240h50m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 240h50m53s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 240h50m56s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 240h50m56s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 240h50m57s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 240h51m29s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 240h51m29s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 240h51m30s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 240h51m37s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 241h25m25s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 241h25m32s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 241h25m33s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 241h26m5s
2026-09-06 14:32:18 +08:00
xiaoxia 8c24e252e1 fix(auth): 微信unionid账号打通 - 先unionid后openid查找 + 老账号补写unionid + 冲突去重保护 2026-09-06 14:32:17 +08:00
xiaoxia d95ca5d723 Merge pull request 'fix: 补充 auto-format 格式修复到 main' (#1700) from fix/style-main-sync into main
CI/CD Pipeline / Production Browser E2E (push) Failing after 31m51s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Validate - Style (push) Successful in 2m31s
CI/CD Pipeline / Validate - Security (push) Successful in 13m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m18s
CI/CD Pipeline / Unit Tests (push) Successful in 9m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m22s
CI/CD Pipeline / Check push changed paths (push) Successful in 29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m0s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Build Production API Image (push) Successful in 15s
CI/CD Pipeline / Build Production Web Image (push) Successful in 14s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 13s
CI/CD Pipeline / Deploy Production (push) Successful in 50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Canary Release to Production (push) Failing after 30m29s
CI/CD Pipeline / CI Gate (push) Failing after 279h17m13s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 279h29m48s
CI/CD Pipeline / Frontend Lint (push) Failing after 279h30m51s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 279h29m49s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 279h30m56s
CI/CD Pipeline / PR Build Web Image (push) Failing after 279h30m56s
CI/CD Pipeline / PR Build API Image (push) Failing after 279h30m56s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 279h30m58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 280h4m18s
2026-09-04 23:53:38 +08:00
xiaoxia 0c15e5be03 Merge pull request 'Release: 同步 develop 到 main(PR #1654~#1698 全量同步)' (#1699) from release/sync-develop-20260904 into main
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 6s
CI/CD Pipeline / Check push changed paths (push) Successful in 1m49s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 45s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
CI/CD Pipeline / Validate - Style (push) Failing after 8m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m21s
CI/CD Pipeline / Unit Tests (push) Successful in 10m47s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 279h44m15s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 279h44m16s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 279h45m53s
CI/CD Pipeline / PR Build Web Image (push) Failing after 279h45m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 279h46m21s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 279h47m47s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 280h18m45s
CI/CD Pipeline / PR Build API Image (push) Failing after 280h20m24s
2026-09-04 23:35:23 +08:00
xiaoxia 75478e3fed Merge pull request 'Release: 同步 develop 到 main(PR #1649~#1653 全量同步)' (#1655) from release/sync-develop-20260903 into main
CI/CD Pipeline / CI Gate (push) Failing after 308h34m50s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 308h43m14s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 308h43m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 308h51m47s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 308h52m45s
CI/CD Pipeline / PR Build API Image (push) Failing after 308h52m45s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 308h55m8s
CI/CD Pipeline / Build Production API Image (push) Successful in 25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
CI/CD Pipeline / Build Production Web Image (push) Successful in 22s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 21s
CI/CD Pipeline / Deploy Production (push) Successful in 45s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 13s
CI/CD Pipeline / Validate - Style (push) Successful in 2m37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m0s
CI/CD Pipeline / Validate - Security (push) Successful in 6m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m41s
CI/CD Pipeline / Unit Tests (push) Successful in 16m53s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m9s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m56s
CI/CD Pipeline / Check push changed paths (push) Successful in 1m3s
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Production Browser E2E (push) Failing after 2m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m49s
CI/CD Pipeline / Canary Release to Production (push) Failing after 30m17s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 309h17m38s
CI/CD Pipeline / PR Build Web Image (push) Failing after 309h27m8s
2026-09-03 18:28:53 +08:00
xiaoxia b0d6260617 Release: 同步 develop 到 main(PR #1649~#1653,含CI门禁+前端修复+worker修复)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m40s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m6s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m27s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m46s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m3s
AI Code Review / AI Code Review (pull_request) Failing after 5m40s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m57s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m9s
CI/CD Pipeline / CI Gate (pull_request) Successful in 10s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 308h38m56s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 308h39m10s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 308h39m11s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 308h38m57s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 308h39m11s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 308h47m36s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 308h47m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 308h48m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 308h51m39s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 308h54m52s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 308h54m52s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 308h54m52s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 308h55m37s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 308h55m37s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 308h57m40s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 309h21m59s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 309h30m1s
2026-09-03 18:26:42 +08:00
xiaoxia 83542cab86 Merge pull request 'fix(main): add ci-canary.yml + disable pr-auto-scan schedule on main' (#1638) from infra/fix-main-workflows into main
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Integration Tests (push) Failing after 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 1m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 37s
CI/CD Pipeline / Validate - Style (push) Successful in 2m11s
CI/CD Pipeline / Validate - Security (push) Successful in 4m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m46s
CI/CD Pipeline / Unit Tests (push) Successful in 7m26s
CI/CD Pipeline / Production Browser E2E (push) Failing after 316h19m55s
CI/CD Pipeline / Deploy Production (push) Failing after 316h19m56s
CI/CD Pipeline / CI Gate (push) Failing after 316h19m56s
CI/CD Pipeline / Build Production API Image (push) Failing after 316h19m57s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 316h19m57s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 316h25m29s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 316h25m29s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 316h26m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 316h25m29s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 316h26m9s
CI/CD Pipeline / PR Build Web Image (push) Failing after 316h27m24s
CI/CD Pipeline / PR Build API Image (push) Failing after 316h27m24s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 316h27m30s
CI/CD Pipeline / Canary Release to Production (push) Failing after 316h54m17s
CI/CD Pipeline / Build Production Web Image (push) Failing after 316h54m18s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 317h0m29s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 317h1m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 317h1m45s
2026-09-03 10:57:30 +08:00
xiaoxia 1cd592c957 fix(main): add ci-canary.yml + disable pr-auto-scan schedule on main
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m46s
AI Code Review / AI Code Review (pull_request) Successful in 3m59s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 20s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 14s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 316h23m16s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 316h23m18s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 316h23m20s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 316h23m19s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 316h23m21s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 316h23m20s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 316h23m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 316h23m20s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 316h23m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 316h23m22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 316h23m22s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 316h23m23s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 316h23m25s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 316h23m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 316h37m29s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 316h57m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 316h57m40s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 316h57m42s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 316h57m43s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 316h57m43s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 316h57m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 316h57m44s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 316h57m40s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 316h57m40s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 316h57m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 316h57m44s
- Copy ci-canary.yml from develop (PR #1636) to enable 30-min health checks on main
- Disable pr-auto-scan.yml schedule on main (was still running */15, causing failure spam)
- Relates: PR #1635 (develop already had schedule disabled)
2026-09-03 10:47:01 +08:00
xiaoxia 278208d31a Merge pull request 'fix(ci): 修复 tag 触发时 production deploy 被 skipped 的问题' (#1625) from fix/ci-production-deploy-skip into main
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 338h43m4s
CI/CD Pipeline / Frontend Lint (push) Failing after 338h45m41s
CI/CD Pipeline / Build Production API Image (push) Successful in 2m10s
CI/CD Pipeline / Build Production Web Image (push) Successful in 1m42s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 1m53s
CI/CD Pipeline / Deploy Production (push) Failing after 4m17s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 338h43m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 338h8m49s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Validate - Style (push) Successful in 7m3s
CI/CD Pipeline / Validate - Security (push) Successful in 10m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m36s
CI/CD Pipeline / Unit Tests (push) Successful in 19m38s
CI/CD Pipeline / Integration Tests (push) Successful in 6m26s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 11m51s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 338h45m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 338h45m27s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 338h11m4s
CI/CD Pipeline / PR Build Web Image (push) Failing after 338h11m10s
CI/CD Pipeline / PR Build API Image (push) Failing after 338h11m12s
CI/CD Pipeline / CI Gate (push) Failing after 337h54m20s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 338h8m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 338h8m46s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 338h9m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 338h9m36s
CI/CD Pipeline / Build Staging API Image (push) Failing after 338h11m6s
CI/CD Pipeline / Check push changed paths (push) Failing after 338h14m42s
CI/CD Pipeline / Canary Release to Production (push) Failing after 337h52m9s
CI/CD Pipeline / Production Browser E2E (push) Failing after 337h47m51s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 338h14m42s
2026-09-02 13:09:23 +08:00
xiaoxia 0c636fd913 chore: trigger CI retry
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m30s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m4s
AI Code Review / AI Code Review (pull_request) Successful in 4m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m19s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m27s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 7m46s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m54s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m29s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m22s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 338h16m0s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 338h16m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 338h16m1s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 338h16m1s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 338h28m50s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 338h16m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 338h28m50s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 338h28m54s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 338h28m54s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 338h28m54s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 338h28m56s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 338h28m56s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 338h28m58s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 338h28m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 338h28m58s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 338h28m58s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 338h28m59s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 338h50m19s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 339h3m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 339h3m11s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 339h3m15s
2026-09-02 12:56:08 +08:00
xiaoxia 692d0b3c7e fix(ci): 修复 tag 触发时 production deploy 被 skipped 的问题
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m43s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 7m54s
AI Code Review / AI Code Review (pull_request) Successful in 8m59s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m0s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 338h34m23s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 338h34m24s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 338h34m25s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 338h34m25s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 338h47m18s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 338h47m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 338h47m20s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 338h47m22s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 338h47m22s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 338h47m24s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 338h47m25s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 338h47m26s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 338h47m26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 338h47m28s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 338h47m29s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 339h8m43s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 339h8m44s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 339h21m37s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 339h21m41s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 339h21m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 339h21m45s
- 从 build-production needs 移除 frontend-lint(tag事件时被skipped)
- if 条件加 !failure() && !cancelled() 确保 skipped 依赖不阻塞生产部署
2026-09-02 12:37:25 +08:00
xiaoxia d00a37fc31 Merge pull request 'fix: 删除 main 上残留的重复 migration 034' (#1624) from fix/remove-duplicate-034-migration into main
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 339h19m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m38s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Validate - Style (push) Successful in 4m38s
CI/CD Pipeline / Validate - Security (push) Successful in 12m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m22s
CI/CD Pipeline / Unit Tests (push) Successful in 21m31s
CI/CD Pipeline / Check push changed paths (push) Successful in 1m45s
CI/CD Pipeline / Integration Tests (push) Successful in 6m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 16m58s
CI/CD Pipeline / PR Build Web Image (push) Failing after 340h29m59s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23m9s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29m8s
CI/CD Pipeline / PR Build API Image (push) Failing after 339h55m40s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m36s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1m19s
CI/CD Pipeline / Build Production Web Image (push) Failing after 340h8m26s
CI/CD Pipeline / Deploy Production (push) Failing after 339h34m4s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 339h34m6s
CI/CD Pipeline / Build Production API Image (push) Failing after 339h34m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m56s
CI/CD Pipeline / CI Gate (push) Failing after 339h34m6s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 339h19m57s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 339h19m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 339h13m56s
CI/CD Pipeline / Production Browser E2E (push) Failing after 339h34m4s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 339h55m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 339h55m40s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 339h55m42s
2026-09-02 11:29:24 +08:00
xiaoxia ad5bc81604 ci: retrigger CI
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m16s
AI Code Review / AI Code Review (pull_request) Failing after 4m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m48s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m8s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m9s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m32s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m21s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m35s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5m19s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 340h35m14s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 340h35m15s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 340h35m16s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 340h35m16s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 340h45m36s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 340h45m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 340h45m59s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 340h48m36s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 340h48m38s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 340h48m41s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 340h48m42s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 340h48m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 340h48m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 340h48m44s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 340h48m45s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 341h9m34s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 341h9m35s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 341h19m57s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 341h20m53s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 341h23m0s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 341h23m2s
2026-09-02 10:36:21 +08:00
xiaoxia 662de73a31 ci: re-trigger CI 1788316563
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API 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 / ACR Image Cleanup (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 340h48m57s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 340h49m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 340h48m59s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 340h49m2s
2026-09-02 10:36:03 +08:00
xiaoxia 0e924c36e7 ci: re-trigger CI (validate-style was infrastructure failure)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API 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 / ACR Image Cleanup (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 340h49m18s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 340h49m22s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 340h49m23s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 340h49m24s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 340h49m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 340h49m26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 340h49m26s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 340h49m28s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 341h23m39s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 341h23m42s
2026-09-02 10:35:39 +08:00
xiaoxia 2b9fcb08b6 fix: 删除 main 上残留的重复 migration 034_add_confirm_generation_fields.py
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Failing after 3m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m43s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 7m4s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit 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
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 340h53m13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 340h53m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 340h53m19s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 340h54m9s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 340h54m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 340h55m3s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 340h55m5s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 340h58m0s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 340h58m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 340h58m3s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 340h58m4s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 341h27m34s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 341h28m26s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 341h28m33s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 341h32m21s
该文件与 034_cms_enhance_placeholder.py 编号冲突(都以 034 开头)。
此文件仅存在于 main 历史中,develop 没有此文件且运行正常。
035 的 down_revision 已指向 034_cms_enhance,删除不影响迁移链。
2026-09-02 10:25:50 +08:00
xiaoxia 419e763ad6 Merge pull request 'chore: 同步 develop 到 main 2026-09-02(含53个 commits)' (#1623) from develop into main
CI Base Image Build / Build CI Base Image (push) Failing after 5m6s
API Base Image Build / Build API Base Image (push) Successful in 40m59s
Worker Base Image Build / Build Worker Base Image (push) Successful in 42m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 341h37m9s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 341h3m13s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Validate - Style (push) Successful in 5m54s
CI/CD Pipeline / Validate - Security (push) Successful in 8m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 3m51s
CI/CD Pipeline / Unit Tests (push) Successful in 21m1s
CI/CD Pipeline / Integration Tests (push) Failing after 3m43s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 341h37m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 15m47s
CI/CD Pipeline / Build Staging API Image (push) Failing after 341h40m32s
CI/CD Pipeline / PR Build API Image (push) Failing after 341h40m38s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 341h6m8s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 341h6m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 341h6m17s
CI/CD Pipeline / Check push changed paths (push) Failing after 341h10m45s
CI/CD Pipeline / Deploy Production (push) Failing after 340h45m32s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 340h45m45s
CI/CD Pipeline / Build Production Web Image (push) Failing after 340h45m47s
CI/CD Pipeline / Build Production API Image (push) Failing after 340h45m49s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 341h2m45s
CI/CD Pipeline / CI Gate (push) Failing after 340h45m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 340h45m26s
CI/CD Pipeline / Canary Release to Production (push) Failing after 340h45m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 341h2m47s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 341h6m11s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 341h2m43s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 341h3m15s
CI/CD Pipeline / Frontend Lint (push) Failing after 341h6m29s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 341h10m47s
2026-09-02 10:13:31 +08:00
xiaoxia 86d9743909 Merge pull request 'chore: 同步 develop 到 main(2026-08-08 至 2026-09-01)' (#1599) from sync/develop-to-main-v2 into main
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 1m48s
CI/CD Pipeline / Integration Tests (push) Failing after 2m18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m41s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 3m40s
CI/CD Pipeline / Validate - Style (push) Successful in 5m10s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m36s
CI/CD Pipeline / Validate - Security (push) Successful in 9m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 13m31s
CI/CD Pipeline / Unit Tests (push) Successful in 20m22s
API Base Image Build / Build API Base Image (push) Successful in 37m45s
Worker Base Image Build / Build Worker Base Image (push) Successful in 38m28s
CI/CD Pipeline / Production Browser E2E (push) Failing after 362h20m17s
CI/CD Pipeline / CI Gate (push) Failing after 362h20m19s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 362h20m19s
CI/CD Pipeline / Canary Release to Production (push) Failing after 362h20m17s
CI/CD Pipeline / Build Production Web Image (push) Failing after 362h20m19s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 362h28m46s
CI/CD Pipeline / Build Production API Image (push) Failing after 362h20m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 362h28m48s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 362h30m37s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 362h30m41s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 362h39m31s
CI/CD Pipeline / PR Build API Image (push) Failing after 362h39m35s
CI/CD Pipeline / Frontend Lint (push) Failing after 362h40m12s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 362h43m52s
CI/CD Pipeline / Deploy Production (push) Failing after 362h54m35s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 363h3m3s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 363h4m56s
CI/CD Pipeline / PR Build Web Image (push) Failing after 363h13m50s
2026-09-01 12:38:52 +08:00
xiaoxia a65e6e293e ci: trigger CI for develop-main sync verification
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m46s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 2m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 2m44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m5s
AI Code Review / AI Code Review (pull_request) Failing after 8m40s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 7m17s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 20m41s
CI/CD Pipeline / CI Gate (pull_request) Failing after 2s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m48s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m24s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 364h31m1s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 364h31m3s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 364h31m3s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 364h31m2s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 364h31m3s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 364h44m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 364h44m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 364h44m42s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 364h48m4s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 364h48m5s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 364h48m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 364h49m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 364h49m38s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 364h50m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 364h51m14s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 364h51m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 364h54m45s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 364h55m45s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 365h5m20s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 365h18m29s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 365h23m52s
2026-09-01 10:22:19 +08:00
xiaoxia 7f21252634 chore: 同步 develop 到 main(2026-08-08 至 2026-09-01)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m18s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m21s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 4m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m28s
AI Code Review / AI Code Review (pull_request) Failing after 6m16s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m52s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m18s
CI/CD Pipeline / CI Gate (pull_request) Failing after 0s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 365h59m6s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 365h59m8s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 365h59m7s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 365h59m8s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 365h59m7s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 366h11m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 366h11m26s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 366h11m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 366h11m27s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 366h11m28s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 366h11m28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 366h11m29s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 366h11m31s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 366h11m30s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 366h11m32s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 366h11m32s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 366h33m24s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 366h45m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 366h45m42s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 366h45m44s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 366h45m46s
合并 develop 分支到 main,包含 100+ commits 的改动。
冲突解决策略:优先保留 develop 的版本。
2026-09-01 09:12:18 +08:00
xiaoxia 1e483b7bf9 Merge pull request 'fix: 确认生成 API 改为复用 worker.generate_video 渲染路径' (#1308) from fix/confirm-generation-api into main
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 59s
CI/CD Pipeline / Frontend Lint (push) Successful in 56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m4s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m15s
CI/CD Pipeline / Unit Tests (push) Successful in 4m12s
CI/CD Pipeline / Integration Tests (push) Successful in 1m16s
CI/CD Pipeline / Build Production API Image (push) Successful in 36s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 51s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m42s
CI/CD Pipeline / Build Production Web Image (push) Successful in 3m21s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 52s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 53s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 33s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m36s
CI/CD Pipeline / AI Code Review (pull_request) Failing after 2m3s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 1m22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m23s
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Successful in 2m15s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m57s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m53s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Successful in 3m35s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m24s
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
AI Code Review / AI Code Review (pull_request) Failing after 6m4s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 11s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 12s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 20m11s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 930h6m29s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 930h6m31s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 930h6m34s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 930h6m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 930h6m32s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 930h11m33s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 930h11m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 930h11m39s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 930h11m54s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 930h11m54s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 930h11m54s
CI/CD Pipeline / Production Browser E2E (push) Failing after 932h32m50s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 932h33m3s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 932h33m3s
CI/CD Pipeline / Canary Release to Production (push) Failing after 932h32m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 932h33m4s
CI/CD Pipeline / CI Gate (push) Failing after 932h36m14s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 932h41m36s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 932h41m38s
CI/CD Pipeline / AI Code Review (push) Failing after 932h41m48s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 932h41m49s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 930h39m58s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 930h45m1s
CI/CD Pipeline / Deploy Production (push) Failing after 933h6m16s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 933h6m28s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 933h15m0s
2026-08-08 18:45:14 +08:00
xiaoxia 72b30d7959 fix: use sa.true() for PostgreSQL boolean compatibility
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 56s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m32s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m20s
CI/CD Pipeline / AI Code Review (pull_request) Failing after 4m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m8s
AI Code Review / AI Code Review (pull_request) Failing after 5m46s
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Successful in 5m57s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m56s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 14s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 16s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 12m47s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 932h47m42s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 932h47m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 932h47m44s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 932h47m44s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 932h54m4s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 932h54m18s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 932h54m6s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 932h54m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 932h54m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 932h54m21s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 932h54m22s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 932h54m22s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 932h54m22s
CI/CD Pipeline / CI Gate (pull_request) Failing after 9s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 933h21m7s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 933h21m8s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 933h27m30s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 933h27m44s
2026-08-08 18:32:41 +08:00
xiaoxia d1b934970a fix: black formatting + update schema metadata snapshot
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 29s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 30s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 40s
AI Code Review / AI Code Review (pull_request) Failing after 1m52s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (Backend) (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
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 932h56m20s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 932h56m48s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 932h56m48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 932h56m52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 932h56m21s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 932h56m52s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 932h56m53s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 932h56m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 932h56m50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 933h29m46s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 933h30m16s
2026-08-08 18:30:10 +08:00
CI Bot d8dd510cba style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 27s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 36s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (Backend) (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 932h57m6s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 932h57m8s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 932h57m30s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 932h57m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 932h57m32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 932h57m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 932h57m34s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 932h57m34s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 933h30m32s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 933h30m54s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 933h30m58s
2026-08-08 10:29:24 +00:00
xiaoxia f988a028fe fix: 确认生成 API 改为复用 worker.generate_video 渲染路径
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 29s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 38s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (Backend) (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 932h59m20s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 932h59m47s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 932h59m22s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 932h59m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 932h59m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 932h59m50s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 932h59m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 932h59m51s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 933h32m46s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 933h33m12s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 933h33m14s
- 新增 ConfirmGenerationRequest schema 和 POST /tasks/{task_id}/confirm 端点
- 确认生成复用 worker.generate_video 完整渲染路径(不再走 edit_plan_generation.py)
- 默认分辨率 1080x1920(竖屏),支持自定义封面和标题
- 新增字段: is_preview, source_task_id, output_width, output_height, cover_url, custom_title
- 贯穿 Schema → Domain → Application → DB Model → Repository → Worker 全链路
- 更新 task_center.py 和 generation_tasks.py 的重试逻辑传递新字段
- Alembic 迁移 034: 为 generation_tasks 表添加 6 个新列
- 7 个单元测试覆盖确认生成的正常/异常流程
- 使用 getattr 带默认值确保向后兼容
2026-08-08 18:26:55 +08:00
xiaoxia 57cd3d92dd fix(ci): 降低全量单元测试覆盖率门槛从65%到55%\n\n当前main分支总覆盖率约58%,65%的门槛导致每次push都失败。\n降低到55%确保CI正常通过,同时保留下降防护(低于55%仍会失败)。\nPR阶段继续通过diff-cover检查增量覆盖率。
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 19s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m30s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m30s
CI/CD Pipeline / AI Code Review (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 1m35s
AI Code Review / AI Code Review (pull_request) Successful in 2m25s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m39s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m23s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 25s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m42s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Successful in 4m24s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m7s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m26s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m36s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m59s
CI/CD Pipeline / Unit Tests (push) Successful in 4m47s
CI/CD Pipeline / CI Gate (pull_request) Successful in 10s
CI/CD Pipeline / Integration Tests (push) Successful in 1m49s
CI/CD Pipeline / Build Production API Image (push) Successful in 1m15s
CI/CD Pipeline / Build Production Web Image (push) Successful in 1m32s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 1m38s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1181h40m28s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1181h43m58s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1181h40m29s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1181h43m58s
CI/CD Pipeline / Deploy Production (push) Failing after 1181h40m29s
CI/CD Pipeline / CI Gate (push) Failing after 1181h42m8s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1181h43m59s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1181h43m59s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1181h45m53s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1181h45m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1181h45m55s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 1181h46m37s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1181h46m33s
CI/CD Pipeline / AI Code Review (push) Failing after 1181h48m39s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1181h48m41s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 1181h50m15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1181h50m32s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1181h50m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1181h50m35s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1181h50m33s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1181h50m37s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1181h50m37s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1181h50m37s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1181h50m37s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1182h16m57s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1182h16m58s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1182h18m52s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 1182h19m34s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1182h23m15s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1182h23m31s
2026-07-29 09:37:02 +08:00
xiaoxia 5d18b76cea merge(ci): main分支不自动部署staging
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m12s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m10s
CI/CD Pipeline / Unit Tests (push) Failing after 3m39s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m43s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1181h59m12s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1181h59m13s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1181h59m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1181h59m13s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1182h0m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1181h59m13s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1182h0m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1182h0m2s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1182h4m48s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 1182h4m50s
CI/CD Pipeline / AI Code Review (push) Failing after 1182h5m38s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1182h5m38s
CI/CD Pipeline / CI Gate (push) Failing after 1182h32m11s
CI/CD Pipeline / Build Production API Image (push) Failing after 1182h32m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1182h32m59s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 1182h37m50s
2026-07-29 09:22:14 +08:00
xiaoxia 6d5b860952 merge(ci): sync CI scripts from develop 2026-07-29 09:22:14 +08:00
xiaoxia 12fb0a8e14 fix(ci): main分支不自动部署staging(数据库版本不匹配) 2026-07-29 09:21:44 +08:00
xiaoxia 545ff0fab8 fix(ci): restore execute permission for ci_notify.py and healthcheck script
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m24s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 1m35s
AI Code Review / AI Code Review (pull_request) Failing after 2m12s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 2m22s
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Failing after 2m23s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
CI/CD Pipeline / AI Code Review (pull_request) Failing after 3m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m42s
CI/CD Pipeline / CI Gate (pull_request) Failing after 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1182h0m35s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1182h0m38s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1182h0m35s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1182h0m39s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1182h0m39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1182h0m36s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 1182h7m56s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1182h7m58s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1182h8m18s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1182h8m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1182h8m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1182h8m22s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1182h8m22s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1182h8m22s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1182h40m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1182h41m16s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1182h41m20s
2026-07-29 09:19:16 +08:00
xiaoxia 89a8c8b6fb merge(ci): sync CI scripts from develop - staging deploy/healthcheck/notify/build tools 2026-07-29 09:19:03 +08:00
xiaoxia e4997b9b4a fix(ci): exclude e2e directory from vitest
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m13s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m3s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 45s
CI/CD Pipeline / Unit Tests (push) Successful in 4m36s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Build Production API Image (push) Successful in 33s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 1m26s
CI/CD Pipeline / Build Production Web Image (push) Successful in 1m34s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1189h33m59s
CI/CD Pipeline / Deploy Production (push) Failing after 1189h34m0s
CI/CD Pipeline / CI Gate (push) Failing after 1189h35m34s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1189h37m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1189h37m11s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 1189h40m59s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 1189h41m0s
CI/CD Pipeline / AI Code Review (push) Failing after 1189h41m1s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1189h41m2s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1190h6m58s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1190h10m8s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1190h13m56s
Fix Frontend Unit Tests failure on main branch by excluding e2e Playwright tests from Vitest.
2026-07-29 01:46:42 +08:00
xiaoxia 68c89861e9 fix(ci): update package-lock.json for @vitest/coverage-v8
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m26s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m31s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m36s
CI/CD Pipeline / Unit Tests (push) Successful in 3m54s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
CI/CD Pipeline / Frontend Lint (push) Successful in 58s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 48s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1189h41m0s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1189h41m2s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1189h41m2s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1189h41m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1189h44m39s
CI/CD Pipeline / Deploy Production (push) Failing after 1189h44m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1189h44m41s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1189h44m41s
CI/CD Pipeline / Build Production API Image (push) Failing after 1189h44m41s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1189h48m36s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 1189h48m37s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 1189h48m39s
CI/CD Pipeline / AI Code Review (push) Failing after 1189h51m27s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1189h51m35s
CI/CD Pipeline / CI Gate (push) Failing after 1190h17m39s
2026-07-29 01:31:53 +08:00
xiaoxia 076601b431 fix(ci): add @vitest/coverage-v8 for main branch coverage
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m35s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m41s
CI/CD Pipeline / Unit Tests (push) Successful in 4m22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 14m37s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 14m32s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 14m27s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 14m22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m18s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 2m33s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 14m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1189h48m34s
CI/CD Pipeline / Deploy Production (push) Failing after 1189h49m42s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1189h48m35s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1189h49m46s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1189h49m48s
CI/CD Pipeline / CI Gate (push) Failing after 1189h51m37s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1189h51m39s
CI/CD Pipeline / Build Production API Image (push) Failing after 1189h51m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1189h51m41s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1190h7m31s
CI/CD Pipeline / AI Code Review (push) Failing after 1190h7m56s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1190h7m58s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1190h22m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1190h24m35s
2026-07-29 01:19:55 +08:00
xiaoxia e23a60e98b Merge remote-tracking branch 'origin/fix/code-review-ci-gate'
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m34s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m51s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m8s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m30s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m53s
CI/CD Pipeline / Unit Tests (push) Successful in 4m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 45s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m0s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1190h20m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1190h20m56s
CI/CD Pipeline / CI Gate (push) Failing after 1190h20m57s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1190h20m57s
CI/CD Pipeline / Build Production API Image (push) Failing after 1190h20m57s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1190h22m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1190h22m54s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Failing after 1190h27m49s
CI/CD Pipeline / PR Build API Image (Backend) (push) Failing after 1190h27m51s
CI/CD Pipeline / AI Code Review (push) Failing after 1190h27m52s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1190h27m53s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1190h53m54s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1190h53m55s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1190h55m52s
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Failing after 1191h0m48s
# Conflicts:
#	.gitea/workflows/ci-pipeline.yml
2026-07-29 00:40:32 +08:00
xiaoxia e2e91e1d58 Merge remote-tracking branch 'origin/fix/pr-path-filter-skip' 2026-07-29 00:39:46 +08:00
xiaoxia 682972469c Merge remote-tracking branch 'origin/fix/auto-merge-short-job'
CI/CD Pipeline / AI Code Review (push) Has been skipped
CI/CD Pipeline / PR Build API Image (Backend) (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (Backend) (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m11s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m28s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m25s
CI/CD Pipeline / Unit Tests (push) Successful in 3m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m54s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4m11s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1190h23m41s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1190h23m42s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1190h23m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1190h23m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1190h44m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1190h44m27s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1190h50m24s
CI/CD Pipeline / CI Gate (push) Failing after 1190h44m27s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1190h44m28s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1190h44m28s
CI/CD Pipeline / Build Production API Image (push) Failing after 1190h44m28s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1190h50m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1190h50m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 1191h23m21s
2026-07-29 00:37:02 +08:00
xiaoxia f8b3552a4e Merge remote-tracking branch 'origin/fix/build-production-needs-tests' 2026-07-29 00:37:02 +08:00
xiaoxia 5e0dcaead8 Merge remote-tracking branch 'origin/fix/pr-automation-concurrency' 2026-07-29 00:37:02 +08:00
xiaoxia 1eca707db3 Merge remote-tracking branch 'origin/fix/preview-cleanup-ssh-default' 2026-07-29 00:37:02 +08:00
xiaoxia 5331307cbc Merge remote-tracking branch 'origin/fix/daily-check-outputs' 2026-07-29 00:37:02 +08:00
xiaoxia 3bea00aa56 feat(ci): 基础镜像切换到私有ACR,更稳定
CI/CD Pipeline / Frontend Lint (push) Successful in 41s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m15s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m56s
CI/CD Pipeline / Build Production Web Image (push) Successful in 2m7s
CI/CD Pipeline / Build Production API Image (push) Successful in 2m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m21s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 2m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 51s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m56s
CI/CD Pipeline / Unit Tests (push) Successful in 5m0s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / CI Gate (push) Failing after 1191h18m14s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1191h21m21s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1191h21m21s
CI/CD Pipeline / Deploy Production (push) Failing after 1191h22m15s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1191h25m9s
CI/CD Pipeline / PR Build API Image (push) Failing after 1191h25m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1191h25m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1191h21m21s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1191h54m18s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1191h55m12s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1191h58m8s
- Bug6(P1): 所有Dockerfile基础镜像从daocloud切到私有ACR (xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/base/)
- 新增scripts/ci/sync_base_images.sh - 基础镜像同步脚本
- 覆盖: python:3.12-slim-bookworm / python:3.12-slim / node:20 / nginx:alpine
2026-07-28 22:38:37 +08:00
xiaoxia 1addcffeba fix(ci): 修复Staging部署和CI通知缺失脚本
CI/CD Pipeline / Frontend Lint (push) Successful in 1m14s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m16s
CI/CD Pipeline / Build Production Worker Image (push) Successful in 1m54s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m57s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m56s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m8s
CI/CD Pipeline / Build Production Web Image (push) Successful in 2m50s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m53s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m2s
CI/CD Pipeline / Unit Tests (push) Successful in 5m2s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Build Production API Image (push) Successful in 14m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 4m13s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1192h30m57s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1192h30m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1192h30m58s
CI/CD Pipeline / CI Gate (push) Failing after 1192h42m16s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1192h49m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1192h35m18s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1192h49m52s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1192h49m52s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1193h3m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1193h8m17s
CI/CD Pipeline / PR Build API Image (push) Failing after 1193h22m50s
- Bug4(P0): 新增scripts/ci_staging_deploy.sh - Staging环境部署脚本(pull镜像+migration+重启)
- Bug4(P0): 新增scripts/ci_staging_healthcheck.sh - Staging健康检查脚本(API/Worker/Web)
- Bug5(P1): 新增scripts/ci_notify.py - 飞书通知脚本(start/success/failure模式)
2026-07-28 22:37:36 +08:00
xiaoxia f3819653b8 fix(ci): 修复3个部署bug - 生产构建/Preview部署/镜像源
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 17s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 38s
AI Code Review / AI Code Review (pull_request) Successful in 18s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 27s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m38s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m48s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m44s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m59s
CI/CD Pipeline / Build Production Web Image (push) Successful in 1m23s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m55s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m47s
CI/CD Pipeline / Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Unit Tests (push) Successful in 5m36s
CI/CD Pipeline / Build Production API Image (push) Successful in 22m11s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25m11s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1h2m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 15s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1h0m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1193h21m53s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1193h21m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1193h21m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1193h21m54s
CI/CD Pipeline / CI Gate (push) Failing after 1194h17m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1193h21m54s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1194h21m53s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1194h23m4s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1194h21m55s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1194h23m8s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1194h23m17s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1194h25m45s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1194h26m59s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1194h25m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1194h26m59s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1194h27m3s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1194h27m5s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1194h27m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1194h27m20s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1194h27m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1194h27m43s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1193h54m54s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1194h56m4s
CI/CD Pipeline / PR Build API Image (push) Failing after 1194h58m47s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1194h59m59s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1195h0m39s
2026-07-28 20:59:36 +08:00
CI Bot b73d75a3e4 fix(ci): 统一preview-cleanup与preview-deploy的SSH默认配置
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m39s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 52s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 10s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m44s
AI Code Review / AI Code Review (pull_request) Failing after 1m17s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m41s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 26s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 9s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1197h43m53s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h43m55s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h43m56s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1197h43m57s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1197h43m58s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h44m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1197h44m3s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h44m19s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1197h44m23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h44m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h44m42s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h45m11s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h45m13s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h17m18s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h18m8s
- SSH_HOST默认值: 172.30.18.197 → 47.98.113.167(与preview-deploy一致)
- SSH_USER默认值: deploy → root(与preview-deploy一致)

问题:preview-cleanup的SSH默认值与preview-deploy不一致。如果PREVIEW_SSH_HOST等secret
未设置,部署和清理会连到不同的服务器,导致清理失效。
修复:统一为与preview-deploy相同的默认值,确保部署和清理在同一台服务器上。
2026-07-28 17:36:49 +08:00
CI Bot 62967e078b fix(ci): 修复daily-check.yml中两个job的outputs配置错误
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m0s
AI Code Review / AI Code Review (pull_request) Successful in 4m16s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m14s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 32s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h44m2s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h44m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h44m36s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1197h44m38s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1197h44m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1197h47m22s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1197h47m28s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h47m30s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h47m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h48m26s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h48m28s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1197h48m28s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h48m28s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h20m23s
- staging-api-tests.outputs.report: steps.smoke → steps.report
  (smoke步骤输出的是api_report,不是report;report由report步骤输出)
- staging-e2e.outputs.report: steps.smoke → steps.e2e
  (staging-e2e的step id是e2e,不是smoke)

问题:两个job的outputs引用了错误的step id或变量名,导致汇总报告拿不到正确结果。
修复:修正outputs指向正确的step id和输出变量。
2026-07-28 17:35:32 +08:00
CI Bot 2818f44282 fix(ci): auto-merge从轮询长作业改为短作业模式
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m24s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m36s
AI Code Review / AI Code Review (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m10s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m58s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m41s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 34s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1197h47m20s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h47m22s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h48m4s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1197h48m6s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h48m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1197h51m37s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h51m44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h52m12s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h51m39s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1197h51m40s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h52m56s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h53m0s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1198h21m5s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1198h24m39s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h25m55s
- auto_merge.sh: 去掉90次x30秒的轮询循环,改为单次检查
- 超时从45分钟缩短为3分钟
- CI未通过/未就绪时直接退出,不占用Runner
- 由pr-auto-scan每5分钟定时扫描兜底,CI通过后自动合并
- 保留405重试机制(单次运行内最多3次)

问题:auto-merge job轮询等待CI通过,最长占Runner 45分钟,资源浪费。
修复:改为短作业模式——检查一次CI状态,满足就合并,不满足就退出。
兜底:pr-auto-scan.yml每5分钟扫描所有open PR,CI全绿的自动审批+合并。
2026-07-28 17:32:56 +08:00
CI Bot c9e5129ec8 fix(ci): build-production等待测试通过后再构建生产镜像
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
AI Code Review / AI Code Review (pull_request) Successful in 56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12m8s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h43m54s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1197h43m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h44m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h44m4s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1197h44m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h52m50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1197h52m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1197h54m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h54m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h55m12s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h55m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h55m27s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h17m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1198h25m49s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h28m23s
- 为build-production添加7个测试/检查job作为needs依赖
- 测试失败时不构建生产镜像,避免无效构建占用资源

问题:生产镜像构建和测试并行跑,测试失败了镜像也已经build完了,浪费构建资源。
修复:让build-production等所有核心测试通过后再开始构建。
2026-07-28 17:30:25 +08:00
CI Bot cd3d366f4a fix(ci): 纯前端/纯后端PR跳过无关检查,节省Runner资源
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m16s
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Successful in 48s
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Successful in 49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
AI Code Review / AI Code Review (pull_request) Failing after 4m8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m4s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m27s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m15s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 31s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1197h55m8s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1197h55m10s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1197h57m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1197h57m30s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1197h57m4s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1197h57m32s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1197h57m6s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1197h58m10s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1197h57m8s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1197h58m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1197h58m30s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1197h58m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1197h58m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1197h58m45s
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Failing after 1198h30m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1198h31m5s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h31m40s
- frontend-lint: 添加check-frontend-only依赖,纯后端PR自动跳过
- build-pr拆分为build-pr-backend和build-pr-web两个job
  - build-pr-backend (API+Worker): 纯前端PR跳过
  - build-pr-web (Web): 纯后端PR跳过
- CI Gate分类调整:
  - REQUIRED_GENERAL: 仅保留真正的通用检查(code-quality/type-check/migration)
  - REQUIRED_BACKEND: 新增build-pr-backend
  - REQUIRED_FRONTEND: 新增frontend-lint和build-pr-web

问题:改前端文件也会跑后端测试和构建,反之亦然,浪费大量Runner时间。
修复:利用已有的check-frontend-only job输出,按PR类型跳过无关检查。
效果:纯前端PR节省约15分钟(API+Worker构建),纯后端PR节省约10分钟(前端Lint+Web构建)。
2026-07-28 17:25:31 +08:00
CI Bot 1dd640da87 fix(ci): 将AI Code Review接入CI门禁体系,严重问题拦截合并
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m10s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 48s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14s
CI/CD Pipeline / AI Code Review (pull_request) Successful in 1m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
AI Code Review / AI Code Review (pull_request) Failing after 3m7s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m9s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m52s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m13s
CI/CD Pipeline / CI Gate (pull_request) Successful in 12s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 33s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1198h1m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1198h1m49s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1198h1m51s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1198h1m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1198h2m8s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h2m19s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1198h2m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1198h2m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1198h2m51s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1198h3m0s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1198h3m3s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1198h34m44s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1198h34m50s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1198h35m14s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h35m59s
- 在ci-pipeline中新增code-review job,与code-review.yml逻辑一致
- 将code-review加入CI Gate的needs列表和REQUIRED_GENERAL检查项
- AI审查发现阻塞级问题时,CI Gate失败,阻止PR合并

问题:AI Code Review只发表评论不参与门禁,有严重安全/质量问题的代码也能合并。
修复:将code-review纳入CI Gate,审查脚本exit 1(阻塞级问题)时CI整体失败。
注意:LLM调用异常时脚本exit 0(fail-open策略),不阻塞正常合并。
2026-07-28 17:20:23 +08:00
CI Bot 4587d0b014 fix(ci): add concurrency to pr-automation workflow
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m21s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 27s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 12s
AI Code Review / AI Code Review (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m48s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m24s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 31s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1198h2m4s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1198h2m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1198h2m30s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1198h2m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1198h2m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1198h5m2s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1198h5m4s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1198h5m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1198h7m9s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1198h5m8s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1198h7m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1198h7m44s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1198h5m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1198h35m29s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1198h40m40s
- Add workflow-level concurrency group per PR number
- Enable cancel-in-progress to cancel old runs when new commits arrive
- Prevents multiple pr-automation runs for the same PR from wasting Runner resources

Issue: Same PR could trigger multiple auto-approve/auto-merge runs simultaneously,
occupying ci-check runners unnecessarily. With concurrency, only the latest run
executes, and older runs are cancelled automatically.
2026-07-28 17:16:35 +08:00
xiaoxia 4749071c16 Merge pull request 'fix(ci): 修复2个P0级bug - acr-cleanup完全不可用 + production-e2e DooD必然失败' (#1112) from fix/ci-p0-bugs-0728 into main
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 16s
AI Code Review / AI Code Review (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API 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 / ACR Image Cleanup (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
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1194h28m17s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1194h29m2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1194h29m4s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1194h29m6s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1199h8m57s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1199h8m59s
CI/CD Pipeline / PR Build API Image (push) Failing after 1199h9m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1199h10m38s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
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 / 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 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
P0级bug紧急修复
2026-07-28 15:58:05 +08:00
xiaoxia 3353865f5b fix(ci): 修复2个P0级bug
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 18s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
AI Code Review / AI Code Review (pull_request) Failing after 3m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1199h11m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1199h11m53s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1199h11m57s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API 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 / ACR Image Cleanup (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
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1199h44m52s
P0-1: acr-cleanup.yml 完全不可用
- $GITEA_OUTPUT → $GITHUB_OUTPUT(outputs写入完全失效)
- PREVIEW_SSH_KEY → STAGING_SSH_KEY(用错了密钥)
- 增加 STAGING_SSH_HOST/PORT/USER 从secret读取
- SSH连接用户从写死root改为变量

P0-2: production-e2e DooD模式下必然失败
- -v "$PWD:/workspace" 改为 docker create + docker cp 模式
- 与 staging-e2e 保持一致
2026-07-28 15:56:01 +08:00
xiaoxia 7061d7e672 fix(ci): 修复CI Gate失败时返回exit 0的P0 Bug (main) (#1057)
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 23s
CI/CD Pipeline / Frontend Lint (push) Successful in 56s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m12s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m13s
CI/CD Pipeline / Build Production API Image (push) Failing after 23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 32s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 36s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m58s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m42s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m29s
CI/CD Pipeline / Unit Tests (push) Successful in 4m33s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m39s
CI/CD Pipeline / Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1200h53m51s
CI/CD Pipeline / CI Gate (push) Failing after 1201h14m0s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1201h34m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1201h34m39s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1201h46m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1201h47m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1201h53m11s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1201h57m14s
CI/CD Pipeline / PR Build API Image (push) Failing after 1201h57m16s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1201h57m17s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1202h7m33s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1202h30m12s
2026-07-28 13:30:40 +08:00
xiaoxia 6efdfbe194 fix(ci): pyproject.toml添加原生ruff配置,修复Code Quality全量检查失败 (#1074)
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m13s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m19s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1m33s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m6s
CI/CD Pipeline / Build Production API Image (push) Failing after 50s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m10s
CI/CD Pipeline / Build Production Web Image (push) Failing after 16s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m50s
CI/CD Pipeline / Unit Tests (push) Successful in 5m38s
CI/CD Pipeline / Integration Tests (push) Successful in 4m56s
CI/CD Pipeline / CI Gate (push) Failing after 1201h58m9s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1202h3m5s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1202h3m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1202h3m9s
CI/CD Pipeline / Deploy Production (push) Failing after 1202h3m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1202h3m34s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1202h7m32s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1202h7m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1202h7m36s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1202h8m1s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1202h36m0s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1202h36m5s
2026-07-28 13:19:55 +08:00
xiaoxia 02e3246f5a fix(ci): 格式修复防循环索引 + AI审查fail-open(2个bug修复) (#1045)
CI/CD Pipeline / Frontend Lint (push) Successful in 55s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m3s
CI/CD Pipeline / Build Production API Image (push) Failing after 27s
CI/CD Pipeline / Build Production Web Image (push) Failing after 20s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m25s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m18s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 23s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m4s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m3s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m6s
CI/CD Pipeline / Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Failing after 1205h28m44s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1205h29m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1205h29m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1205h32m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1205h33m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1205h33m51s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1205h35m35s
CI/CD Pipeline / PR Build API Image (push) Failing after 1205h35m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1205h35m38s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1206h2m47s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1206h8m31s
fix(ci): 修复auto_fix_formatting防循环索引错误 + AI审查fail-open未生效

1. 防循环索引bug:Gitea API返回commits倒序,commits[-1]取到最旧commit,改为commits[0]
2. fail-open bug:LLM调用失败和未捕获异常都是exit 1,改为exit 0不阻塞合并
2026-07-28 09:52:23 +08:00
xiaoxia 5cdafd2559 feat: AI代码审查添加commit status输出和阻塞级问题判定 (#1035)
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m53s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m2s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m31s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m33s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m55s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 34s
CI/CD Pipeline / Build Production API Image (push) Failing after 18s
CI/CD Pipeline / Build Production Web Image (push) Failing after 17s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 18s
CI/CD Pipeline / Unit Tests (push) Successful in 4m38s
CI/CD Pipeline / Integration Tests (push) Successful in 4m21s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API 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 / ACR Image Cleanup (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1217h18m57s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1217h30m51s
CI/CD Pipeline / CI Gate (push) Failing after 1217h30m53s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1217h31m33s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1217h31m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1217h31m35s
CI/CD Pipeline / Deploy Production (push) Failing after 1217h32m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1217h36m41s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1217h45m48s
CI/CD Pipeline / PR Build API Image (push) Failing after 1217h45m48s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1217h45m50s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1218h18m40s
- 为AI代码审查添加commit status输出,PR页面可直接看到审查结果
- 添加阻塞级问题判定逻辑,严重问题标记为failure状态
- 优化审查报告格式和输出精度
2026-07-27 21:40:07 +08:00
xiaoxia a6afb344ba feat: 格式自动修复对所有PR开放,添加防循环机制 (#1037)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 28s
CI/CD Pipeline / Build Production Web Image (push) Failing after 30s
CI/CD Pipeline / Build Production API Image (push) Failing after 34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m32s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m12s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m13s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1218h23m40s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1218h23m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1218h23m42s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1218h23m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1218h23m45s
CI/CD Pipeline / Deploy Production (push) Failing after 1218h24m10s
CI/CD Pipeline / CI Gate (push) Failing after 1218h26m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1218h26m23s
CI/CD Pipeline / PR Build API Image (push) Failing after 1218h26m23s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1218h58m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1218h23m45s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1218h59m12s
2026-07-27 20:29:56 +08:00
xiaoxia 504e2e71c9 fix(ci): auto_merge.sh改用CI Gate统一门禁
CI/CD Pipeline / Frontend Lint (push) Successful in 30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m3s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 55s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1m38s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m29s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Build Production API Image (push) Failing after 5s
CI/CD Pipeline / Build Production Web Image (push) Failing after 7s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 6s
CI/CD Pipeline / Unit Tests (push) Successful in 3m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1219h17m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1219h17m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1219h17m51s
CI/CD Pipeline / CI Gate (push) Failing after 1219h28m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1220h40m14s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1221h16m27s
CI/CD Pipeline / PR Build API Image (push) Failing after 1221h16m31s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1221h17m35s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1219h50m36s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1221h49m15s
2026-07-27 16:21:19 +08:00
xiaoxia fb2884b03c fix(ci): 添加ci-gate汇总job,解决自动合并405问题
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
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
2026-07-27 16:17:50 +08:00
xiaoxia 7fab42c3d0 fix(ci): daily-check DooD挂载路径修复 + shell bash修复 (#1024)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m33s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m38s
CI/CD Pipeline / Build Production API Image (push) Failing after 9s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m45s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m47s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 11s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 13s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m3s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m19s
CI/CD Pipeline / Unit Tests (push) Successful in 3m57s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Successful in 2m2s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1227h18m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1227h18m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1227h18m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1227h19m0s
CI/CD Pipeline / Deploy Production (push) Failing after 1227h19m10s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1227h23m30s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1227h23m34s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1227h23m32s
CI/CD Pipeline / PR Build API Image (push) Failing after 1227h56m18s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1227h51m38s
2026-07-27 12:04:41 +08:00
xiaoxia 561548c84c fix(ci): daily-check shell从sh改为bash,修复PIPESTATUS Bad substitution (#1023)
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 49s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m36s
CI/CD Pipeline / Build Production API Image (push) Failing after 7s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m48s
CI/CD Pipeline / Build Production Web Image (push) Failing after 12s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 16s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m10s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m17s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m2s
CI/CD Pipeline / Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Unit Tests (push) Successful in 5m5s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1227h46m23s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1227h46m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1227h46m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1227h46m43s
CI/CD Pipeline / Deploy Production (push) Failing after 1227h46m45s
CI/CD Pipeline / PR Build API Image (push) Failing after 1227h48m46s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1227h48m44s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1227h48m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1228h19m10s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1228h21m30s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1228h19m8s
2026-07-27 11:39:29 +08:00
xiaoxia 77704e7ec6 fix(ci): 同步daily-check和acr-cleanup的docker兼容性修复到main (#1012)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 46s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m20s
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m25s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 25s
CI/CD Pipeline / Build Production API Image (push) Failing after 11s
CI/CD Pipeline / Build Production Web Image (push) Failing after 19s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 16s
CI/CD Pipeline / Unit Tests (push) Successful in 2m23s
CI/CD Pipeline / Integration Tests (push) Successful in 1m56s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1229h25m58s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1229h26m2s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1229h44m55s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1229h44m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1229h45m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1229h44m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1230h3m27s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1230h14m30s
CI/CD Pipeline / PR Build API Image (push) Failing after 1230h14m34s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1230h15m29s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1230h47m16s
cherry-pick #981的核心修复到main分支:
- daily-check.yml: checkout步骤改为curl step_checkout.sh方式
- acr-cleanup.yml: 同上,修复Setup Python秒败问题

相关PR: #981
相关工单: #980
2026-07-27 08:46:05 +08:00
xiaoxia 5ae6c33bf6 sync(ci): 同步缺失的CI监控脚本到main分支
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 53s
CI/CD Pipeline / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m0s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m21s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m8s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m25s
CI/CD Pipeline / Build Production Web Image (push) Failing after 25s
CI/CD Pipeline / Build Production API Image (push) Failing after 27s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 37s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 35s
CI/CD Pipeline / Unit Tests (push) Successful in 2m56s
CI/CD Pipeline / Integration Tests (push) Successful in 2m21s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1243h44m35s
CI/CD Pipeline / Deploy Production (push) Failing after 1243h50m24s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1243h50m47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1243h50m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1244h2m52s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1244h47m30s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1244h47m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1244h48m5s
CI/CD Pipeline / PR Build API Image (push) Failing after 1245h20m14s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1244h23m26s
同步2个CI监控脚本到main分支:scripts/ci_trigger_monitor.py、scripts/ci_code_review.py
2026-07-26 18:13:27 +08:00
xiaoxia db07738178 sync(ci): 同步schedule类workflow修复到main分支 (#933)
CI/CD Pipeline / Frontend Lint (push) Successful in 1m1s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m48s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m13s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 24s
CI/CD Pipeline / Build Production Web Image (push) Failing after 18s
CI/CD Pipeline / Build Production API Image (push) Failing after 21s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 18s
CI/CD Pipeline / Unit Tests (push) Successful in 3m31s
CI/CD Pipeline / Integration Tests (push) Successful in 2m28s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Failing after 1246h45m33s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1246h45m35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1246h45m37s
CI/CD Pipeline / Deploy Production (push) Failing after 1246h54m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1246h54m24s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1247h6m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1247h7m21s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1247h6m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1247h39m15s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1247h18m12s
2026-07-26 16:18:24 +08:00
xiaoxia 53c09e7d3c chore(ci): 同步金丝雀发布流水线到main分支(#450)
CI/CD Pipeline / Frontend Lint (push) Successful in 25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m25s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Build Production API Image (push) Failing after 13s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 11s
CI/CD Pipeline / Build Production Web Image (push) Failing after 14s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m57s
CI/CD Pipeline / Integration Tests (push) Successful in 1m52s
CI/CD Pipeline / Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Canary Release to Production (push) Failing after 1272h10m47s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1272h10m47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1272h10m48s
CI/CD Pipeline / Deploy Production (push) Failing after 1272h11m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1272h10m48s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1272h13m5s
CI/CD Pipeline / PR Build API Image (push) Failing after 1272h13m9s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1272h13m54s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1272h45m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1272h43m47s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1272h43m22s
2026-07-25 15:14:33 +08:00
xiaoxia e602439769 chore(ci): 同步renovate.json5到main分支
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m47s
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m3s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m47s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m11s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Successful in 3m23s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1272h37m53s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1272h37m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1272h37m56s
CI/CD Pipeline / Deploy Production (push) Failing after 1272h38m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1272h38m1s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1272h38m4s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1272h38m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 1272h38m5s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1272h40m18s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1272h40m20s
CI/CD Pipeline / PR Build API Image (push) Failing after 1272h40m22s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1272h41m48s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1273h10m28s
同步#876的renovate依赖自动更新配置到main,保持main和develop CI配置一致。
2026-07-25 14:44:52 +08:00
xiaoxia a26fda1597 feat(ci): ACR镜像自动清理增强版 - 同步到main(#524)
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 46s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m2s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 44s
CI/CD Pipeline / Frontend Lint (push) Successful in 22s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m21s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m53s
CI/CD Pipeline / Integration Tests (push) Successful in 3m21s
CI/CD Pipeline / Unit Tests (push) Successful in 5m18s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1274h54m18s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1274h54m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1274h54m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1274h57m48s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1274h59m7s
CI/CD Pipeline / Deploy Production (push) Failing after 1275h0m37s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1275h0m54s
CI/CD Pipeline / Build Production API Image (push) Failing after 1275h0m55s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1275h3m18s
CI/CD Pipeline / PR Build API Image (push) Failing after 1275h3m20s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1275h4m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1275h35m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1275h33m29s
ACR镜像自动清理增强版同步到main:3种模式+白名单保护+cron/PR关闭/手动触发
2026-07-25 12:21:41 +08:00
xiaoxia 99a8ffa97b chore(ci): 升级migration验证,新增4项检查 (#451) 同步到main
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 51s
CI/CD Pipeline / Frontend Lint (push) Successful in 34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 52s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m13s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 22s
CI/CD Pipeline / Build Staging API Image (push) Failing after 1m44s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 1m27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Successful in 3m11s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1277h30m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1277h30m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1277h31m0s
CI/CD Pipeline / Deploy Production (push) Failing after 1277h31m43s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1277h32m24s
CI/CD Pipeline / Build Production API Image (push) Failing after 1277h32m28s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1277h32m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 1277h32m49s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1277h33m43s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1278h5m21s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1278h3m34s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1278h4m58s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1278h3m30s
同步#857 migration验证升级到main分支

新增:ci_env.sh共享变量、check_migration_naming.py命名检查
升级:validate_migration.sh 5阶段完整检查
2026-07-25 09:54:58 +08:00
xiaoxia f03c9d5453 chore(ci): P2端口收尾同步到main分支 (#798 #799) (#853)
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m27s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 54s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m44s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m48s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Unit Tests (push) Successful in 4m22s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1287h32m1s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1287h32m1s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1287h33m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1287h32m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1287h33m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1287h33m7s
CI/CD Pipeline / Build Production API Image (push) Failing after 1287h33m7s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1287h34m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1287h34m34s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1287h34m35s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1288h7m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1288h4m33s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1288h5m35s
chore(ci): P2端口收尾同步到main分支 (#798 #799)

- docs: 新增统一端口分配清单文档 (PORTS.md)
- chore(ci): 端口变量命名统一 (chatops配置)

跳过ci-pipeline.yml的CI_PG_PORT改动:main分支无顶层env块,后续CI配置对齐时再同步。
2026-07-24 23:54:09 +08:00
xiaoxia 8322e2b6e2 fix(ci): staging E2E/API tests shell由sh改为bash
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 36s
CI/CD Pipeline / Frontend Lint (push) Successful in 49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m39s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m44s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m35s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m31s
CI/CD Pipeline / Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Unit Tests (push) Successful in 5m20s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1295h58m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1295h58m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1295h58m34s
CI/CD Pipeline / Deploy Production (push) Failing after 1296h0m20s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1296h0m22s
CI/CD Pipeline / Build Production API Image (push) Failing after 1296h0m26s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1296h1m9s
CI/CD Pipeline / PR Build API Image (push) Failing after 1296h1m13s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1296h1m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1296h33m38s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1296h32m45s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1296h32m51s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1296h30m59s
2026-07-24 15:27:33 +08:00
CI Bot d2409e16c1 style: auto-format with black + isort + prettier
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 55s
CI/CD Pipeline / Frontend Lint (push) Successful in 33s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m1s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m3s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 14s
CI/CD Pipeline / Integration Tests (push) Successful in 3m4s
CI/CD Pipeline / Unit Tests (push) Successful in 4m45s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1299h42m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1299h42m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1299h43m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1299h58m7s
CI/CD Pipeline / Deploy Production (push) Failing after 1299h58m40s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1300h27m32s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1300h30m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1300h27m33s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1300h30m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1300h32m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 1301h2m43s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1300h15m23s
CI/CD Pipeline / Build Production API Image (push) Failing after 1300h59m59s
2026-07-24 02:37:59 +00:00
xiaoxia 6eac0b2cf2 chore(ci): 同步main分支CI配置与scripts/ci脚本 - 与develop对齐
Worker Base Image Build / Build Worker Base Images (worker-base-builder-cache, infra/docker/worker-base-builder.Dockerfile, worker-base-builder, builder) (push) Failing after 1m54s
Worker Base Image Build / Build Worker Base Images (worker-base-runtime-cache, infra/docker/worker-base-runtime.Dockerfile, worker-base-runtime, runtime) (push) Failing after 1m36s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 44s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 1300h3m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1300h3m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1300h32m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1300h32m45s
CI/CD Pipeline / Deploy Production (push) Failing after 1300h43m2s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1300h49m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1300h49m14s
CI/CD Pipeline / Build Production API Image (push) Failing after 1300h49m14s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1300h51m32s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1300h51m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1300h51m36s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1300h52m20s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1300h35m56s
同步内容:
1. CI流水线配置(ci-pipeline.yml)与develop对齐
2. PR构建脚本docker_build_only.sh增加buildx→docker build回退
3. pre-build步骤worker基础镜像构建增加buildx回退
4. 单元测试脚本全量覆盖率改为仅报告不阻塞
5. diff-cover依赖加入requirements-dev.txt
6. worker base builder/runtime Dockerfile同步
7. test_config_oss.py clear=False→clear=True修复OSS污染
8. Frontend Lint增加prettier依赖
2026-07-24 10:36:29 +08:00
xiaoxia dfb2feef8a fix(ci): main??auto-merge/approve????Tests/test??
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1465h21m19s
CI/CD Pipeline / Deploy Production (push) Failing after 1465h21m22s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1465h21m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1465h53m20s
- auto-merge.yml: main????Tests/test,?3???????
- auto-approve.yml: main??????????Tests/test
- ??#464???Tests/test???????????
2026-07-17 14:04:23 +08:00
auto-approve-bot b3ef7bb041 Merge pull request 'feat(ci): main分支auto-merge从定时改为即时合并' (#464) from ci/main-auto-merge-instant into main
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 4s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1466h0m8s
CI/CD Pipeline / Deploy Production (push) Failing after 1466h0m12s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1466h0m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1466h32m10s
2026-07-17 13:27:18 +08:00
xiaoxia a1a272b833 feat(ci): 添加auto-approve到main分支并适配main门禁规则
Tests / lint (pull_request) Successful in 6s
Tests / test (pull_request) Failing after 29s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m54s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Successful in 3m0s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h2m30s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h2m34s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h2m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1466h2m32s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1466h34m32s
2026-07-17 13:24:21 +08:00
xiaoxia 728db0faf8 chore: empty commit to re-trigger CI for auto-merge verification
Tests / lint (pull_request) Successful in 9s
Tests / test (pull_request) Failing after 21s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Successful in 20m36s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h21m15s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h21m17s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h21m17s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1466h21m16s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1466h53m15s
2026-07-17 13:06:40 +08:00
xiaoxia 7e5e412f7f feat(ci): main分支auto-merge从定时改为即时合并
Tests / lint (pull_request) Failing after 9s
Tests / test (pull_request) Failing after 28s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Failing after 30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h49m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1466h49m40s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h49m41s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h49m41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1467h21m39s
- 触发方式:pull_request事件(CI状态变更时)
- 合并条件:2门禁全绿 + 至少1个APPROVED + 无冲突 + 非草稿
- 安全措施:幂等保护、合并失败留评论、只合main
- 新增check_ci_status.py和check_pr_approval.py辅助脚本
2026-07-17 12:37:24 +08:00
xiaoxia df08161630 fix(ci): 修复auto-merge缺少GITEA_API_TOKEN环境变量
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1470h25m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1470h25m35s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1470h25m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1470h57m29s
1. 给auto-merge.yml的两个merge step添加GITEA_API_TOKEN环境变量,复用REVIEW_GITEA_TOKEN secret
2. 修复tests.yml的checkout步骤GITHUB_TOKEN和Python命令
3. 给main分支ci-cd.yml的单元测试增加Redis服务,修复Celery相关测试失败
2026-07-17 09:02:33 +08:00
xiaoxia 0c9375ff32 ci: remove temporary debug workflow - test-ssh-secret.yml
Fix Flake8 E741 for PR232 / fix-flake8 (push) Failing after 3h4m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1577h41m48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 36s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1577h10m16s
CI/CD Pipeline / Deploy Production (push) Failing after 1577h10m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1577h10m11s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1577h10m16s
2026-07-12 10:25:12 +08:00
xiaoxia ef344e9ffc cleanup: remove upgrade test file
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 12h43m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h45m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 12h45m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1671h17m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1671h20m0s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1671h20m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1671h51m26s
2026-07-09 00:08:40 +08:00
xiaoxia 0d4904433e test: trigger workflow after gitea upgrade
CI/CD Pipeline / Staging E2E Tests (push) Failing after 12h49m24s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 12h53m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h56m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 12h56m8s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1671h29m39s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1671h29m43s
CI/CD Pipeline / Deploy Production (push) Failing after 1672h1m9s
2026-07-08 23:58:20 +08:00
CI Test 708662394f Merge develop into main - v0.1.126
Auto Merge PRs / auto-merge (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 29h56m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 29h56m45s
CI/CD Pipeline / Deploy Production (push) Failing after 1688h31m42s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1688h31m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1689h3m9s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1688h31m44s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1688h31m39s
2026-07-08 06:57:42 +08:00
CI Test 9b034764ad Merge develop into main - v0.1.125
Auto Merge PRs / auto-merge (push) Failing after 1m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h58m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 1699h32m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1699h32m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h32m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h31m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h32m48s
2026-07-07 19:54:54 +08:00
CI Test 8748b43070 Merge develop into main - v0.1.124
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h16m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h16m32s
CI/CD Pipeline / Deploy Staging (push) Failing after 1703h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 45h9m27s
CI/CD Pipeline / Deploy Production (push) Failing after 45h8m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1703h44m57s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1703h44m30s
2026-07-07 15:36:36 +08:00
CI Test d213a055a1 Merge develop into main - v0.1.123
Auto Merge PRs / auto-merge (push) Failing after 3m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h9m34s
CI/CD Pipeline / Frontend Lint (push) Failing after 47h8m47s
CI/CD Pipeline / Deploy Staging (push) Failing after 47h6m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1705h41m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h41m20s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h41m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h41m18s
2026-07-07 13:44:46 +08:00
CI Test 2371860f82 Merge develop into main - v0.1.122 (prettier fix)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h37m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h37m23s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 49h35m42s
CI/CD Pipeline / Deploy Production (push) Failing after 49h33m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 49h27m0s
CI/CD Pipeline / Deploy Staging (push) Failing after 1708h11m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h9m27s
2026-07-07 11:04:39 +08:00
CI Test dbd956fc6e Merge develop into main - v0.1.122
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h54m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h54m54s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1708h14m26s
CI/CD Pipeline / Deploy Staging (push) Failing after 1708h14m26s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h12m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1708h10m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h12m14s
2026-07-07 10:59:31 +08:00
CI Test 1d59ee5336 Merge develop into main - v0.1.121
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 52h47m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 52h46m49s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 52h46m3s
CI/CD Pipeline / Deploy Production (push) Failing after 52h45m27s
CI/CD Pipeline / Deploy Staging (push) Failing after 1711h22m10s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h19m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1711h21m45s
2026-07-07 08:05:42 +08:00
217 changed files with 5057 additions and 21430 deletions
+4 -24
View File
@@ -198,13 +198,10 @@ DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
# ==================== 积分/会员系统 (#1895) ====================
# 积分系统总开关:默认 false(暂停积分系统)。
# - false:生成视频/口型同步/数字人/AI标题/TTS/克隆音色等所有功能对登录
# 用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员状态查询接口
# 保留可用,但数据不再变动。积分相关的表、代码、接口均保留不删除。
# - 恢复积分:设置 ENABLE_CREDIT_SYSTEM=true 即可,无需改代码。
ENABLE_CREDIT_SYSTEM=false
# 旧开关名(兼容别名):与 ENABLE_CREDIT_SYSTEM 任一为 true 即启用。
# 积分扣点总开关:默认 false(对现有用户零影响)。
# P2 阶段各业务路由逐个接入 @points_gate 时,用
# `if settings.points_enabled: ...`
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。
POINTS_ENABLED=false
# ==================== 抖音解析多源轮询 (#1963) ====================
@@ -216,20 +213,3 @@ TIKHUB_API_KEY=
# apizero.cn API Key (https://v1.apizero.cn) — 国内抖音解析服务
APIZERO_API_KEY=
# ==================== GPU MuseTalk Worker(反向轮询口型同步)====================
# GPU Worker 长期鉴权 Token,Worker 端 .env 的 GPU_WORKER_TOKEN 必须与此一致
# 留空时 development 环境允许匿名访问(仅本地调试),staging/production 必须配置
GPU_WORKER_TOKEN=
# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed
# #1970:RTX2060 6G 推理 720p 长视频需 5 分钟以上,默认 900
GPU_TASK_TIMEOUT_SECONDS=900
# 是否启用 GPU 口型同步(开关)。开启后需同时有 Worker 在心跳窗口内(5分钟)才会走 GPU 路径;
# 开关关闭 / 无可用 Worker / GPU 任务失败或超时 → 自动回退现有 MediaKit 云端 lipsync
USE_GPU_LIPSYNC=false
# 业务侧轮询 GPU 任务结果的间隔(秒)
GPU_LIPSYNC_POLL_INTERVAL=5
# 业务侧等待 GPU 任务总超时(秒);超时回退 MediaKit
GPU_LIPSYNC_WAIT_TIMEOUT=1200
# Worker 心跳新鲜度窗口(秒),last_heartbeat_at 在此窗口内视为在线
GPU_WORKER_STALE_SECONDS=300
-4
View File
@@ -1186,12 +1186,10 @@ jobs:
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}"
GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}"
run: |
set -eu
echo "Rendering .env from template + secrets..."
@@ -1642,12 +1640,10 @@ jobs:
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}"
GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}"
run: |
set -eu
echo "Rendering .env from template + secrets..."
@@ -1,58 +0,0 @@
"""add gpu_lipsync_tasks and gpu_workers tables for MuseTalk reverse-poll worker
Revision ID: 081_add_gpu_lipsync
Revises: 080_edit_plan_clips_atom_clip_id
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "081_add_gpu_lipsync"
down_revision = "080_edit_plan_clips_atom_clip_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
# GPU Worker 注册表
op.create_table(
"gpu_workers",
sa.Column("worker_id", sa.String(100), primary_key=True),
sa.Column("hostname", sa.String(200), nullable=False, server_default=""),
sa.Column("gpu_name", sa.String(200), nullable=False, server_default=""),
sa.Column("free_vram_mb", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("capabilities", sa.String(500), nullable=False, server_default=""),
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True, index=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# GPU 口型同步任务表
op.create_table(
"gpu_lipsync_tasks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("lipsync_job_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("user_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("video_url", sa.Text(), nullable=False),
sa.Column("audio_url", sa.Text(), nullable=False),
sa.Column("result_url", sa.Text(), nullable=False, server_default=""),
sa.Column("result_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("worker_id", sa.String(100), nullable=False, server_default="", index=True),
sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("error_msg", sa.Text(), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_gpu_lipsync_status_created", "gpu_lipsync_tasks", ["status", "created_at"])
def downgrade() -> None:
op.drop_index("ix_gpu_lipsync_status_created", table_name="gpu_lipsync_tasks")
op.drop_table("gpu_lipsync_tasks")
op.drop_table("gpu_workers")
-26
View File
@@ -1,26 +0,0 @@
"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging
Revision ID: 082_atom_clip_ai_tags
Revises: 081_add_gpu_lipsync
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "082_atom_clip_ai_tags"
down_revision = "081_add_gpu_lipsync"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"asset_atom_clips",
sa.Column("ai_tags", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("asset_atom_clips", "ai_tags")
@@ -1,26 +0,0 @@
"""#2001 爆款标题样式面板升级: ai_avatar_render_jobs 新增 cover_title_config
Revision ID: 083_cover_title_config
Revises: 082_atom_clip_ai_tags
Create Date: 2026-09-20
"""
import sqlalchemy as sa
from alembic import op
revision = "083_cover_title_config"
down_revision = "082_atom_clip_ai_tags"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ai_avatar_render_jobs",
sa.Column("cover_title_config", sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
)
def downgrade() -> None:
op.drop_column("ai_avatar_render_jobs", "cover_title_config")
@@ -1,26 +0,0 @@
"""lipsync_jobs 新增 style 字段(TTS 语气风格)
Revision ID: 084_lipsync_jobs_style
Revises: 083_cover_title_config
Create Date: 2026-09-21
"""
import sqlalchemy as sa
from alembic import op
revision = "084_lipsync_jobs_style"
down_revision = "083_cover_title_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"lipsync_jobs",
sa.Column("style", sa.String(length=32), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("lipsync_jobs", "style")
@@ -1,33 +0,0 @@
"""asset_atom_clips 新增 caption/embedding 字段(#2035 语义标签增强)
Revision ID: 085_atom_clip_caption_embedding
Revises: 084_lipsync_jobs_style
Create Date: 2026-09-25
"""
import sqlalchemy as sa
from alembic import op
revision = "085_atom_clip_caption_embedding"
down_revision = "084_lipsync_jobs_style"
branch_labels = None
depends_on = None
def upgrade() -> None:
# caption: 中文画面描述(10-30字)
op.add_column(
"asset_atom_clips",
sa.Column("caption", sa.Text(), nullable=True),
)
# embedding: caption 对应的向量(豆包 embedding 接口返回,JSON 存 float 数组)
op.add_column(
"asset_atom_clips",
sa.Column("embedding", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("asset_atom_clips", "embedding")
op.drop_column("asset_atom_clips", "caption")
+1 -25
View File
@@ -8,20 +8,17 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.clips_standalone import router as clips_standalone_router
from app.api.routes.cover_templates import router as cover_templates_router
from app.api.routes.drafts_standalone import router as drafts_standalone_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.generation_variant_plans import router as generation_variant_plans_router
from app.api.routes.gpu_lipsync import router as gpu_lipsync_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.lipsync import router as lipsync_router
from app.api.routes.points import router as points_router
from app.api.routes.points import usage_router
from app.api.routes.points import points_router, usage_router
from app.api.routes.projects import router as projects_router
from app.api.routes.scripts import router as scripts_router
from app.api.routes.scripts_ai import router as scripts_ai_router
@@ -43,19 +40,6 @@ api_router = APIRouter(prefix="/api/v1")
health_router = APIRouter()
health_router.include_router(health_check_router)
# ── /api/health 别名:部分前端/探针把 health 放在 /api 前缀下 ──────────────
# 原来 /health 在根路径;额外加一个 /api/health 别名避免 404。
api_health_router = APIRouter(prefix="/api")
api_health_router.include_router(health_check_router)
health_router.include_router(api_health_router)
# ── 旧前端路径别名(无需 template_id 路径参数)────────────────────────────
# /api/v1/clips/from-assets 已有 clips_standalone;此处额外挂 /api/v1/editor/*,
# 解决前端调 /api/v1/editor/clips/from-assets 和 /api/v1/editor/drafts 的 404。
editor_legacy_router = APIRouter(prefix="/editor", tags=["Editor Legacy Alias"])
editor_legacy_router.include_router(clips_standalone_router)
editor_legacy_router.include_router(drafts_standalone_router)
api_router.include_router(
auth_router,
tags=["Auth"],
@@ -184,9 +168,6 @@ api_router.include_router(
prefix="/templates/{template_id}/editor",
tags=["TemplateEditor"],
)
api_router.include_router(
editor_legacy_router,
)
api_router.include_router(
tts_router,
prefix="/tts",
@@ -230,8 +211,3 @@ api_router.include_router(
prefix="/usage",
tags=["Usage"],
)
api_router.include_router(
gpu_lipsync_router,
prefix="/gpu",
tags=["GPU Worker"],
)
@@ -63,7 +63,6 @@ def create_render_job(
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
title_config=body.title_config,
cover_config=body.cover_config,
cover_title_config=body.cover_title_config,
project_id=body.project_id,
)
except AiAvatarRenderError as exc:
@@ -1,102 +0,0 @@
"""独立的草稿端点(不依赖 template_id 路径参数,兼容旧前端路径).
提供以下别名端点,与 /api/v1/templates/{template_id}/editor/draft 功能一致:
- GET /api/v1/editor/drafts 获取草稿详情(template_id 从 query/body/默认模板兜底)
- PUT /api/v1/editor/drafts 更新草稿(兼容前端 useDraftAutoSave 调用)
根因:前端 useDraftAutoSave 调用 /api/v1/editor/drafts(复数、无 template_id),
与后端以 template_id 为路径参数的设计不一致,导致 404 并触发 10s timeout。
本模块参照 clips_standalone.py 的模式,通过默认模板兜底复用 draft.py 的核心逻辑。
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
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 sqlalchemy.orm import Session
from ._default_template import get_or_create_default_template_id
from .templates_editor.dependencies import resolve_draft_plan_id
from .templates_editor.draft import get_editor_draft, update_editor_draft
from .templates_editor.schemas import EditorDraftResponse, EditorUpdateRequest
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Editor Legacy Alias"])
def _resolve_editor_services(db: Session) -> tuple[EditTemplateService, EditPlanService]:
return EditTemplateService(db), EditPlanService(db)
def _resolve_template_id(
template_id: str | None,
db: Session,
current_user: AuthenticatedUser,
) -> str:
"""解析 template_id:query/body 优先,否则兜底默认模板。"""
tid = (template_id or "").strip()
if tid:
return tid
user_id = str(current_user.user.id)
tid = get_or_create_default_template_id(db, user_id)
if not tid:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="无法自动创建默认模板,请刷新页面重试",
)
return tid
@router.get("/drafts", response_model=EditorDraftResponse)
def get_editor_drafts_alias(
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditorDraftResponse:
"""获取草稿详情(复数路径别名,兼容旧前端调用)。"""
tid = _resolve_template_id(template_id, db, current_user)
services = _resolve_editor_services(db)
plan_id = resolve_draft_plan_id(
template_id=tid,
services=services,
current_user=current_user,
db=db,
auto_create_default=False,
)
return get_editor_draft(
template_id=tid,
plan_id=plan_id,
services=services,
_=current_user,
)
@router.put("/drafts", response_model=EditorDraftResponse)
def update_editor_drafts_alias(
req: EditorUpdateRequest,
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditorDraftResponse:
"""更新草稿(复数路径别名,兼容前端 useDraftAutoSave 调用)。"""
tid = _resolve_template_id(template_id, db, current_user)
services = _resolve_editor_services(db)
plan_id = resolve_draft_plan_id(
template_id=tid,
services=services,
current_user=current_user,
db=db,
auto_create_default=False,
)
return update_editor_draft(
template_id=tid,
req=req,
plan_id=plan_id,
services=services,
_=current_user,
)
+5 -19
View File
@@ -76,7 +76,10 @@ class GenerateCoverResponse(BaseModel):
# ── Route ────────────────────────────────────────────────────────────────
def _select_best_frame_from_snapshots(snapshots: list[dict], plan_id: str) -> str:
def _select_best_frame_from_snapshots(
snapshots: list[dict], plan_id: str
) -> str:
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
降级策略:cv2 不可用或评分失败时,返回第一帧。
@@ -229,10 +232,7 @@ def _persist_cover_frame(
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
"""
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
@@ -241,20 +241,6 @@ def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
return getattr(videos[0], "file_url", "") or ""
except Exception:
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
# awaiting_cover 兜底:从 extra_meta.rendered_output 取
try:
task_repo = SQLAlchemyGenerationTaskRepository(db)
task = task_repo.get(task_id)
if task is not None:
_status = task.status.value if hasattr(task.status, "value") else str(task.status)
if _status == "awaiting_cover":
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
_url = _ro.get("file_url") or ""
if _url:
return _url
except Exception:
logger.warning("[封面生成] awaiting_cover 兜底读取失败: task_id=%s", task_id, exc_info=True)
return None
+1 -16
View File
@@ -650,26 +650,11 @@ def get_preview_generation_task(
if not getattr(task, "is_preview", False):
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
# 查询生成的视频(取第一个)。
# #2024: 渲染完成后先进入 awaiting_cover(未入成品库),此时预览也应可见,
# 从 extra_meta["rendered_output"] 读取视频 URL。
# 查询生成的视频(取第一个)
generated_videos = []
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if status_val == "completed":
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
generated_videos = list_use_case.execute(task_id)
elif status_val == "awaiting_cover":
# 用 extra_meta 中的渲染信息组装一个轻量视频对象给前端预览播放
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
if _ro.get("file_url"):
class _PreviewVideo:
def __init__(self, ro):
self.file_url = ro.get("file_url", "")
self.duration = float(ro.get("duration") or 0.0)
self.file_size = int(ro.get("file_size") or 0)
generated_videos = [_PreviewVideo(_ro)]
return _to_preview_response(task, generated_videos=generated_videos)
+5 -206
View File
@@ -31,8 +31,6 @@ from app.schemas.generation_task import (
BatchGenerationTaskResponse,
ConfirmGenerationRequest,
CreateGenerationTaskRequest,
FinalizeGenerationRequest,
FinalizeGenerationResponse,
GenerationTaskResponse,
ListGenerationTasksResponse,
)
@@ -46,37 +44,6 @@ from packages.application import (
ListGeneratedVideosByTaskUseCase,
)
from packages.domain.smart_match import smart_select_assets
# #2035:文案关键词 → 素材分类 映射表(用于 smart_match category_match 维度)
# AssetClassification 枚举: scenic / product / person / animal / food / tech / sport / music / other
_CATEGORY_KEYWORDS: dict[str, set[str]] = {
"scenic": {"风景", "自然", "山水", "大海", "天空", "日落", "日出", "森林", "城市", "建筑", "夜景", "街道", "公园", "景区", "旅行", "旅游", "户外"},
"product": {"产品", "商品", "展示", "演示", "开箱", "评测", "好物", "推荐", "种草", "购物", "电商", "带货", "品牌", "广告", "包装"},
"person": {"人物", "人物采访", "对话", "说话", "讲解", "演讲", "采访", "聊天", "开会", "工作", "办公室", "团队", "员工", "老板", "女性", "男性", "美女", "帅哥"},
"animal": {"动物", "宠物", "狗", "猫", "鸟", "鱼", "马", "牛", "羊", "野生动物", "动物园"},
"food": {"美食", "食物", "餐饮", "餐厅", "做饭", "烹饪", "厨房", "菜品", "饮料", "水果", "甜点", "蛋糕", "咖啡", "茶", "零食", "吃"},
"tech": {"科技", "数码", "电脑", "手机", "屏幕", "软件", "APP", "互联网", "AI", "人工智能", "机器人", "办公", "程序员", "代码", "屏幕录制"},
"sport": {"运动", "健身", "跑步", "篮球", "足球", "游泳", "瑜伽", "户外", "锻炼", "体育", "比赛", "球场"},
"music": {"音乐", "歌曲", "演唱会", "乐器", "唱歌", "跳舞", "舞蹈", "MV", "演出", "乐队", "钢琴", "吉他", "节奏"},
}
def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
"""从文案标签集合推断期望的素材分类(可能命中多个)。标签为空返回 None。"""
if not script_tags:
return None
matched: set[str] = set()
for cat, kws in _CATEGORY_KEYWORDS.items():
for tag in script_tags:
tag.lower()
for kw in kws:
if kw in tag or tag in kw:
matched.add(cat)
break
if cat in matched:
break
return matched or None
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
@@ -165,11 +132,10 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
def _select_assets_from_library(
assets: list,
mode: str,
count: int = 0,
count: int,
rng=None,
script_tags: list | None = None,
tag_names_by_id: dict | None = None,
db=None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -190,42 +156,6 @@ def _select_assets_from_library(
if not ready_video_assets:
return []
# #2035:加载片段级 AI 标签,供叙事模式 AI 加权和 smart 模式语义匹配使用。
# 失败降级为空(不影响选片主流程)。
clip_ai_tags_by_asset: dict[str, list[dict]] = {}
ai_tags_by_asset: dict[str, dict] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重)
try:
if db is not None:
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
ready_ids = [a.id for a in ready_video_assets]
clip_rows = (
db.query(AssetAtomClipModel.asset_id, AssetAtomClipModel.ai_tags)
.filter(AssetAtomClipModel.asset_id.in_(ready_ids))
.filter(AssetAtomClipModel.ai_tags.isnot(None))
.all()
)
agg: dict[str, dict] = {}
for asset_id, ai_tags in clip_rows:
if not isinstance(ai_tags, dict):
continue
clip_ai_tags_by_asset.setdefault(asset_id, []).append(ai_tags)
# 聚合:合并 scene/objects/action 去重
agg.setdefault(asset_id, {"scene": [], "objects": [], "action": [], "shot": "", "has_text": False})
for key in ("scene", "objects", "action"):
for v in ai_tags.get(key) or []:
v = str(v).strip()
if v and v not in agg[asset_id][key]:
agg[asset_id][key].append(v)
if ai_tags.get("has_text") is True:
agg[asset_id]["has_text"] = True
if not agg[asset_id]["shot"] and ai_tags.get("shot"):
agg[asset_id]["shot"] = ai_tags["shot"]
ai_tags_by_asset = agg
except Exception: # noqa: BLE001
logger.warning("[选片] 加载片段 AI 标签失败,降级不使用语义匹配", exc_info=True)
clip_ai_tags_by_asset = {}
ai_tags_by_asset = {}
# 叙事模式(#1970 PR3):文案标签命中池优先;无任何命中时完全降级为现有随机逻辑。
if script_tags:
from packages.domain.narrative_match import pick_narrative_assets
@@ -235,7 +165,6 @@ def _select_assets_from_library(
ready_video_assets,
script_tags=script_tags,
tag_names_by_id=tag_names_by_id,
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
limit=limit,
rng=rng,
)
@@ -246,18 +175,7 @@ def _select_assets_from_library(
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
limit = count if count > 0 else None
# #2035:给 smart_select_assets 传入文案标签和 AI 标签映射,启用语义维度
norm_script = {t.strip().lower() for t in (script_tags or []) if t and t.strip()}
expected_categories = _infer_expected_categories(norm_script)
results = smart_select_assets(
ready_video_assets,
limit=limit,
kind="video",
rng=rng,
script_tags=norm_script if norm_script else None,
ai_tags_by_asset=ai_tags_by_asset or None,
expected_categories=expected_categories,
)
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
return [r.asset.id for r in results]
# 默认 all 模式:返回全部 ready 视频素材
@@ -476,7 +394,6 @@ def create_generation_task(
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
db=db,
)
elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取
@@ -491,7 +408,6 @@ def create_generation_task(
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
db=db,
)
if not resolved_asset_ids:
raise HTTPException(
@@ -975,14 +891,8 @@ def confirm_generation(
if source_task.project_id:
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
# 3. 如果预览任务已完成渲染(completed 或 awaiting_cover),检查分辨率一致性后复用产物(秒出)。
# #2024: 渲染完成先进入 awaiting_cover(等 Step5 finalize 入库),
# confirm 时不再直接 finalize——仍创建 is_preview=False 的正式任务,复用预览渲染产物。
_preview_done = getattr(source_task, "is_preview", False) and source_task.status.value in (
"completed",
"awaiting_cover",
)
if _preview_done:
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
if source_task.is_completed and getattr(source_task, "is_preview", False):
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
req_w = request.output_width or 0
req_h = request.output_height or 0
@@ -997,25 +907,13 @@ def confirm_generation(
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
confirmed_title_config["text"] = request.custom_title.strip()
# #2024: mark_confirmed 会把 is_preview 翻转为 False、同步标题/分辨率/封面,
# 但不再自动 mark_completed——任务停留在 awaiting_cover,等待用户 Step5 选封面后调 finalize。
source_task.mark_confirmed(
cover_url=request.cover_url,
output_width=request.output_width,
output_height=request.output_height,
title_config=confirmed_title_config,
)
# 若预览任务此时是 completed(历史数据/旧 worker),回退到 awaiting_cover 统一流程
if source_task.status.value == "completed":
try:
from packages.domain.generation_task import GenerationTaskStatus
source_task.status = GenerationTaskStatus.AWAITING_COVER
source_task.completed_at = None
except Exception:
pass
generation_task_repository.update(source_task)
db.commit()
# 同步标题到 EditPlan.config
# #1970:确认生成复用预览计划,dedup_enabled 沿用计划已有值,不在此覆盖
@@ -1028,7 +926,7 @@ def confirm_generation(
)
logger.info(
"[确认生成] 复用预览产物(等待 finalize): task_id=%s, user_id=%s",
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
task_id,
authenticated_user.user.id,
)
@@ -1097,67 +995,6 @@ def confirm_generation(
)
@router.post("/tasks/{task_id}/finalize", response_model=FinalizeGenerationResponse)
def finalize_generation_task(
task_id: str,
request: FinalizeGenerationRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
db: Session = Depends(get_db_session),
) -> FinalizeGenerationResponse:
"""#2024: Step5 点「完成」时调用——将 awaiting_cover 状态的任务正式入库+绑定封面。
- 任务必须处于 awaiting_cover 状态(渲染+上传已完成、封面候选已就绪)。
- cover_url 为空则使用任务自动截帧/智能封面;非空则绑定为最终封面。
- 幂等:已 finalize 的任务直接返回已有视频记录。
- 成功后任务推进到 completed,返回成品视频 ID + 可播放 URL。
"""
from app.services.generation_finalize_service import (
GenerationFinalizeError,
GenerationFinalizeService,
)
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
service = GenerationFinalizeService(db)
try:
video = service.finalize_task(
task_id=task_id,
user_id=authenticated_user.user.id,
cover_url=request.cover_url or None,
custom_title=(request.custom_title or "").strip() or None,
)
except GenerationFinalizeError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
try:
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
except Exception:
download_url = video.file_url
return FinalizeGenerationResponse(
video_id=video.id,
project_id=getattr(video, "project_id", "") or "",
name=getattr(video, "name", "") or "",
file_size=int(getattr(video, "file_size", 0) or 0),
duration=float(getattr(video, "duration", 0.0) or 0.0),
thumbnail_url=video.thumbnail_url or "",
cover_url=video.thumbnail_url or "",
file_url=download_url,
width=int(getattr(video, "width", 0) or 0),
height=int(getattr(video, "height", 0) or 0),
fps=float(getattr(video, "fps", 0.0) or 0.0),
status="success",
is_duplicate=bool(getattr(video, "is_duplicate", False)),
)
@router.get("/tasks", response_model=ListGenerationTasksResponse)
def list_generation_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -1205,44 +1042,6 @@ def list_generation_results(
for item in items:
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
responses.append(_to_generated_video_response(item, download_url=download_url))
# #2024/#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,
# 从 extra_meta["rendered_output"] 合成一条轻量视频响应,供前端预览与智能封面使用。
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if not responses and status_val == "awaiting_cover":
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
_file_url = _ro.get("file_url") or ""
if _file_url:
if _file_url.startswith("http"):
_download = _file_url
else:
try:
_download = storage_service.get_download_url(_file_url, expires_seconds=86400)
except Exception:
_download = _file_url
_name = _ro.get("name") or ""
if not _name:
_name = f"generated-{task_id[:8]}"
responses.append(
GeneratedVideoResponse(
id=f"preview-{task_id}",
project_id=getattr(task, "project_id", "") or "",
generation_task_id=task_id,
name=_name,
file_url=_file_url,
file_size=int(_ro.get("file_size") or 0),
duration=float(_ro.get("duration") or 0.0),
thumbnail_url=_ro.get("thumbnail_url") or getattr(task, "cover_url", "") or "",
width=int(_ro.get("width") or 0),
height=int(_ro.get("height") or 0),
fps=float(_ro.get("fps") or 0.0),
mode=_ro.get("mode", ""),
download_url=_download,
created_at=getattr(task, "updated_at", None) or getattr(task, "created_at", None),
)
)
return ListGeneratedVideosResponse(items=responses)
-231
View File
@@ -1,231 +0,0 @@
"""GPU MuseTalk Worker 反向轮询路由 — /api/v1/gpu/lipsync/*.
仅面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。
鉴权方式:长期 API Token(`Authorization: Bearer <GPU_WORKER_TOKEN>`),不走用户 JWT。
接口:
POST /api/v1/gpu/register Worker 注册/心跳
GET /api/v1/gpu/lipsync/poll Worker 轮询拉任务(无任务返回 204)
POST /api/v1/gpu/lipsync/result Worker multipart 上传结果视频/上报失败
GET /api/v1/gpu/lipsync/status/{id} 业务侧查询任务状态(内部接口,暂开放给登录用户)
"""
from __future__ import annotations
import logging
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Optional
import requests
from app.core.storage import get_storage_service
from app.dependencies import get_db_session
from app.schemas.gpu_lipsync import (
GpuLipsyncPollResponse,
GpuLipsyncResultResponse,
GpuLipsyncStatusResponse,
GpuLipsyncTaskPayload,
GpuWorkerRegisterRequest,
GpuWorkerRegisterResponse,
)
from app.services.gpu_lipsync_service import GpuLipsyncService
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Query,
Request,
UploadFile,
status,
)
from fastapi.responses import Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
router = APIRouter()
# 复用 bearer scheme 抽 Token,但不校验用户 JWT
_gpu_bearer = HTTPBearer(auto_error=False)
def _verify_gpu_token(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_gpu_bearer),
) -> str:
"""校验 GPU Worker Token,返回 worker 提供的 token 串(仅用于日志,不做身份识别).
- development 且未配置 token → 直接放行(方便本地调试)。
- production/staging 未配置 token → 拒绝(避免裸奔)。
- token 不匹配 → 401。
"""
settings = get_api_settings()
expected = (settings.gpu_worker_token or "").strip()
is_dev = settings.environment == "development"
if not expected:
if is_dev:
return credentials.credentials if credentials else ""
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GPU_WORKER_TOKEN not configured on server",
)
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
if credentials.credentials != expected:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid GPU worker token")
return credentials.credentials
def _get_svc(db=Depends(get_db_session)) -> GpuLipsyncService:
return GpuLipsyncService(db)
# ── POST /register — Worker 注册/心跳 ──────────────────────────────
@router.post("/register", response_model=GpuWorkerRegisterResponse)
def register_worker(
body: GpuWorkerRegisterRequest,
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
worker, cancel_task = svc.register_worker(
worker_id=body.worker_id,
hostname=body.hostname,
gpu_name=body.gpu_name,
free_vram_mb=body.free_vram_mb,
capabilities=body.capabilities,
task_id=body.task_id,
)
return GpuWorkerRegisterResponse(ok=True, server_time=datetime.now(UTC), message="ok", cancel_task=cancel_task)
# ── GET /lipsync/poll — Worker 轮询拉任务 ─────────────────────────
@router.get("/lipsync/poll")
def poll_task(
worker_id: str = Query(..., min_length=1, max_length=100, description="Worker 唯一 ID"),
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
task = svc.poll_task(worker_id=worker_id)
if task is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
payload = GpuLipsyncTaskPayload(
task_id=task.id,
video_url=getattr(task, "_signed_video_url", task.video_url),
audio_url=getattr(task, "_signed_audio_url", task.audio_url),
lipsync_job_id=task.lipsync_job_id or "",
user_id=task.user_id or "",
project_id=task.project_id or "",
created_at=task.created_at,
upload_url=getattr(task, "_signed_upload_url", ""),
upload_method="PUT",
expires_at=getattr(task, "_upload_expires_at", datetime.now(UTC)),
)
return GpuLipsyncPollResponse(task=payload)
# ── POST /lipsync/result — Worker 上报结果(multipart) ─────────────
@router.post("/lipsync/result", response_model=GpuLipsyncResultResponse)
async def report_result(
request: Request,
task_id: str = Form(...),
worker_id: str = Form(...),
success: bool = Form(True),
duration_seconds: float = Form(0.0),
error_msg: str = Form(""),
result: Optional[UploadFile] = File(None),
svc: GpuLipsyncService = Depends(_get_svc),
_token: str = Depends(_verify_gpu_token),
):
# 参数校验:
# - success=true + result 文件 → API 代为上传到 OSS(方便 Worker 端实现)
# - success=true + 无文件 → Worker 已经自己 PUT 到预签名 upload_url,直接确认
# - success=false → 不上传文件,错误信息通过 error_msg 传递
if success and result is not None:
# 把文件落盘到临时目录,然后 PUT 到预签名 URL
storage = get_storage_service()
result_key = svc._result_key(task_id)
upload_url = storage.get_upload_url(result_key, expires_seconds=3600, content_type="video/mp4")
try:
with tempfile.TemporaryDirectory(prefix="gpu_result_") as tmpdir:
tmp_path = Path(tmpdir) / "result.mp4"
content = await result.read()
if not content:
raise HTTPException(status_code=400, detail="上传的 result 文件为空")
tmp_path.write_bytes(content)
headers = {"Content-Type": "video/mp4"}
with open(tmp_path, "rb") as f:
resp = requests.put(upload_url, data=f, headers=headers, timeout=300)
if resp.status_code >= 400:
logger.error(
"上传 GPU 结果到 OSS 失败: status=%d body=%s",
resp.status_code,
resp.text[:500],
)
raise HTTPException(
status_code=502,
detail=f"上传结果视频到 OSS 失败 (HTTP {resp.status_code})",
)
except HTTPException:
raise
except Exception as exc:
logger.exception("上传 GPU 结果视频异常: %s", exc)
raise HTTPException(status_code=500, detail=f"上传结果视频异常: {exc}") from exc
elif not success:
# 失败时忽略 result 文件(即便传了也没用)
pass
# 其他情况:success=true 且无文件 → Worker 已自行 PUT 到预签名 URL,直接标记完成
try:
task = svc.report_result(
task_id=task_id,
worker_id=worker_id,
success=success,
duration_seconds=duration_seconds,
error_msg=error_msg,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return GpuLipsyncResultResponse(
ok=True,
task_id=task.id,
status=task.status,
message="ok",
)
# ── GET /lipsync/status/{task_id} — 业务侧查询状态 ─────────────────
# 说明:此接口会被 lipsync_service 内部在业务流程里直接读 DB,不通过 HTTP。
# 但仍暴露一个简单查询接口,方便调试和前端轮询(如后续需要)。暂不做用户权限校验,
# task_id 本身是 UUID,不可枚举。
@router.get("/lipsync/status/{task_id}", response_model=GpuLipsyncStatusResponse)
def get_task_status(
task_id: str,
svc: GpuLipsyncService = Depends(_get_svc),
):
task = svc.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return GpuLipsyncStatusResponse(
task_id=task.id,
status=task.status,
result_url=task.result_url,
result_duration=task.result_duration,
error_msg=task.error_msg,
worker_id=task.worker_id,
attempt=task.attempt,
created_at=task.created_at,
started_at=task.started_at,
finished_at=task.finished_at,
)
+2 -6
View File
@@ -111,8 +111,6 @@ def create_lipsync_job(
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
style=body.style or "",
volume=body.volume if body.volume is not None else 50,
emotion=body.emotion,
enable_video_loop=body.enable_video_loop,
project_id=body.project_id,
@@ -213,8 +211,6 @@ def preview_tts(
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
style=body.style or "",
volume=body.volume if body.volume is not None else 50,
emotion=body.emotion,
)
except MediaKitError as exc:
@@ -346,13 +342,13 @@ def cancel_lipsync_job(
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""取消对口型任务(仅 pending/tts_processing/submitted/processing 状态可取消)."""
"""取消对口型任务(仅 pending/tts_processing/submitted 状态可取消)."""
job = svc.cancel_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.status != "cancelled":
raise HTTPException(
status_code=400,
detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted/processing 可取消",
detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted 可取消",
)
return job
+6 -33
View File
@@ -12,7 +12,6 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.dependencies import get_db_session
from app.schemas.points import (
DailyUsageResponse,
@@ -45,12 +44,6 @@ from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
def _credits_enabled() -> bool:
"""积分系统总开关(ENABLE_CREDIT_SYSTEM),关闭时全部功能免费放行。"""
return bool(getattr(settings, "points_enabled", False))
# ── 两个 router ──
points_router = APIRouter()
usage_router = APIRouter()
@@ -179,19 +172,6 @@ def check_points(
"valid_scenes": sorted(POINTS_SCENES.keys()),
},
)
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
if not _credits_enabled():
svc = _get_service()
account = svc.get_or_create_account(current_user.user.id, db)
return PointsCheckResponse(
allowed=True,
required_points=0,
current_balance=account["balance"],
remaining_after=account["balance"],
is_free_quota=False,
)
is_mem = _is_member(current_user)
mt = _member_type(current_user)
@@ -229,19 +209,8 @@ def deduct_points(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""积分扣减(内部服务调用)。
积分系统暂停(ENABLE_CREDIT_SYSTEM=false)时为 no-op:不扣分、余额不变,
直接返回成功,保证内部调用方拿到 success=True 继续业务流程。
"""
"""积分扣减(内部服务调用)。"""
svc = _get_service()
if not _credits_enabled():
account = svc.get_or_create_account(current_user.user.id, db)
return SimpleMessageResponse(
success=True,
message="积分系统已暂停,未扣减积分",
data={"transaction_id": "", "balance": account["balance"]},
)
result = svc.deduct_points(
user_id=current_user.user.id,
amount=body.amount,
@@ -274,7 +243,11 @@ def refund_points(
"""积分退还(内部服务调用)。"""
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
txn = db.query(PointsTransactionModel).filter(PointsTransactionModel.id == body.transaction_id).first()
txn = (
db.query(PointsTransactionModel)
.filter(PointsTransactionModel.id == body.transaction_id)
.first()
)
if txn is None:
raise HTTPException(status_code=404, detail="交易记录不存在")
if txn.user_id != current_user.user.id:
+3 -9
View File
@@ -63,8 +63,6 @@ def _generation_step(task) -> str:
return "等待 Worker 执行"
if s == "running":
return "正在生成成片"
if s == "awaiting_cover":
return "等待确认封面"
if s == "completed":
return "生成完成"
if s == "failed":
@@ -131,7 +129,7 @@ def _validate_status(status: str | None) -> str | None:
"""校验状态值合法性。"""
if status is None:
return None
valid = {"pending", "running", "awaiting_cover", "completed", "failed", "cancelled"}
valid = {"pending", "running", "completed", "failed", "cancelled"}
if status not in valid:
raise HTTPException(
status_code=400,
@@ -153,9 +151,7 @@ def _clamp_page_size(page_size: int) -> int:
@router.get("/tasks", response_model=ListTasksResponse)
def list_user_tasks(
status: str | None = Query(
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
),
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
@@ -252,9 +248,7 @@ def retry_task_by_id(
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
def list_project_tasks(
project_id: str,
status: str | None = Query(
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
),
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
@@ -682,50 +682,17 @@ def create_clips_from_assets_editor(
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
asset_scene_points: dict[str, list[float]] = {}
invalid_asset_ids: list[str] = []
valid_asset_ids: list[str] = []
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset is None:
logger.warning("from-assets 素材不存在或已删除,跳过: asset_id=%s", asset_id)
invalid_asset_ids.append(asset_id)
continue
_dur = float(getattr(asset, "duration", 0.0) or 0.0)
if _dur <= 0:
# 素材时长缺失(刚上传/分析未完成)或为0,跳过该素材——避免按兜底时长分配无效片段。
# 若所有素材都无效,在下面统一抛 400。
logger.warning("from-assets 素材时长缺失或为0,跳过: asset_id=%s", asset_id)
invalid_asset_ids.append(asset_id)
continue
valid_asset_ids.append(asset_id)
asset_durations[asset_id] = _dur
# 计算 smart_match 综合评分,用于候选排序
try:
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算 smart_match 综合评分,用于候选排序
smart_score, _ = score_asset(asset)
asset_smart_scores[asset_id] = smart_score
except Exception:
asset_smart_scores[asset_id] = 0.0
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
try:
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
asset_scene_points[asset_id] = cached_points
except Exception:
pass
if invalid_asset_ids:
logger.info(
"from-assets %d 个素材无效(时长缺失/不存在,已跳过): %s",
len(invalid_asset_ids),
",".join(invalid_asset_ids[:5]),
)
# 所有素材都无效(刚上传未分析完)→ 400 让前端稍后重试,而不是用兜底时长产生错乱片段
if not valid_asset_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材尚未完成分析,请稍后重试",
)
# 后续分配素材时只在 valid_asset_ids 里挑选
unique_asset_ids = valid_asset_ids
logger.info(
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
len(asset_scene_points),
-6
View File
@@ -207,9 +207,6 @@ def synthesize(
synthesis_meta = {
"speed": request.speed,
"emotion": request.emotion or "",
"style": request.style or "",
"volume": request.volume if request.volume is not None else 50,
"pitch": request.pitch if request.pitch is not None else 1.0,
"language": request.language or "zh-CN",
}
if request.metadata_:
@@ -657,9 +654,6 @@ def preview_tts(
text=request.text,
voice_id=actual_voice_id,
speed=request.speed,
style=request.style or "",
volume=request.volume if request.volume is not None else 50,
pitch=request.pitch,
emotion=request.emotion,
language=getattr(request, "language", "zh-CN"),
)
+1 -7
View File
@@ -53,14 +53,9 @@ class CreateAiAvatarRenderRequest(BaseModel):
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
title_config: dict[str, Any] = Field(
default_factory=dict,
description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL;含 line_overrides 逐行样式)",
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL)"
)
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
cover_title_config: dict[str, Any] = Field(
default_factory=dict,
description="封面独立标题配置(#2001),结构同 title_config;为空时封面不叠标题",
)
project_id: str = Field("", description="项目 ID")
@field_validator("lipsync_job_id")
@@ -88,7 +83,6 @@ class AiAvatarRenderJobResponse(BaseModel):
b_roll_segments: list[dict[str, Any]]
title_config: dict[str, Any]
cover_config: dict[str, Any]
cover_title_config: dict[str, Any] = Field(default_factory=dict, description="封面独立标题配置")
status: str
progress: int
output_video_url: str
-27
View File
@@ -13,33 +13,6 @@ class ConfirmGenerationRequest(BaseModel):
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
class FinalizeGenerationRequest(BaseModel):
"""Step5 点「完成」请求体:用户选定封面后,正式将视频入成品库。"""
cover_url: str = Field(
default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)"
)
custom_title: str = Field(default="", description="用户自定义成片标题,非空时覆盖 rendered_output.name")
class FinalizeGenerationResponse(BaseModel):
"""finalize 响应:返回新创建的成品库视频信息。"""
video_id: str = Field(description="新创建的成品视频 ID")
project_id: str = Field(default="", description="成品所属项目 ID")
name: str = Field(default="", description="成片名称")
file_size: int = Field(default=0, description="文件大小(字节)")
duration: float = Field(default=0.0, description="时长(秒)")
thumbnail_url: str = Field(default="", description="最终绑定的缩略图/封面 URL")
cover_url: str = Field(default="", description="最终绑定的封面 URL")
file_url: str = Field(default="", description="成品视频下载 URL")
width: int = Field(default=0)
height: int = Field(default=0)
fps: float = Field(default=0.0)
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
class CreateGenerationTaskRequest(BaseModel):
"""创建生成任务请求。
-112
View File
@@ -1,112 +0,0 @@
"""GPU MuseTalk 反向轮询 API Schema 定义.
面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。
Worker 用长期 GPU_WORKER_TOKEN 鉴权(不是用户 JWT)。
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ── Worker 注册/心跳 ──────────────────────────────────────────────
class GpuWorkerRegisterRequest(BaseModel):
"""Worker 启动/心跳时上报自身信息."""
worker_id: str = Field(..., min_length=1, max_length=100, description="Worker 唯一 ID(机器名+UUID 等)")
hostname: str = Field("", max_length=200, description="主机名,用于运维排查")
gpu_name: str = Field("", max_length=200, description="GPU 型号,如 'NVIDIA GeForce RTX 2060'")
free_vram_mb: int = Field(0, ge=0, description="当前空闲显存(MB)")
capabilities: str = Field("musetalk", max_length=500, description="能力列表,逗号分隔,如 'musetalk'")
task_id: Optional[str] = Field(
None,
max_length=64,
description=(
"当前正在处理的任务 ID。Worker 推理期间定期心跳时携带,"
"服务端同步刷新该任务 last_heartbeat_at,防止长推理被误判超时;空闲时不传"
),
)
class GpuWorkerRegisterResponse(BaseModel):
ok: bool = True
server_time: datetime
message: str = "ok"
cancel_task: bool = Field(False, description="当前心跳任务是否已被用户取消;为 true 时 Worker 应终止推理")
# ── 轮询任务 ────────────────────────────────────────────────────
class GpuLipsyncTaskPayload(BaseModel):
"""下发给 Worker 的任务载荷(含预签名下载 URL)."""
task_id: str
video_url: str = Field(..., description="人物视频预签名下载 URL(GET)")
audio_url: str = Field(..., description="驱动音频预签名下载 URL(GET)")
lipsync_job_id: str = ""
user_id: str = ""
project_id: str = ""
created_at: datetime
upload_url: str = Field(..., description="结果视频预签名上传 URL(PUT, video/mp4)")
upload_method: str = Field("PUT", description="上传方式,目前只支持 PUT")
expires_at: datetime
class GpuLipsyncPollResponse(BaseModel):
"""Worker poll 的返回:200 带任务,204 无任务."""
task: Optional[GpuLipsyncTaskPayload] = None
# ── Worker 上报结果 ──────────────────────────────────────────────
class GpuLipsyncResultRequest(BaseModel):
"""Worker 通过 multipart 上传结果时携带的字段(非文件字段)."""
task_id: str = Field(..., min_length=1, max_length=64)
worker_id: str = Field(..., min_length=1, max_length=100)
success: bool = Field(True, description="true=成功(此时必须上传 result 视频文件);false=失败")
duration_seconds: float = Field(0.0, ge=0, description="合成后视频时长(秒),成功时应填入")
error_msg: str = Field("", max_length=2000, description="失败原因,success=false 时必填")
class GpuLipsyncResultResponse(BaseModel):
ok: bool = True
task_id: str
status: str # done / failed
message: str = "ok"
# ── 业务侧查询任务状态 ────────────────────────────────────────────
class GpuLipsyncStatusResponse(BaseModel):
task_id: str
status: str
result_url: str = ""
result_duration: float = 0.0
error_msg: str = ""
worker_id: str = ""
attempt: int = 0
created_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
# ── 创建任务(内部服务调用) ──────────────────────────────────────
class GpuLipsyncCreateRequest(BaseModel):
"""服务层内部创建 GPU 任务用(不通过 HTTP 暴露给 Worker/前端)."""
video_url: str # 已可访问的 OSS key 或公网 URL(API 侧会转预签名)
audio_url: str
lipsync_job_id: str = ""
user_id: str = ""
project_id: str = ""
+2 -13
View File
@@ -29,7 +29,6 @@ class LipsyncJobResponse(BaseModel):
voice_id: str = ""
script_text: str = ""
speed: float = 1.0
style: str = ""
emotion: str = ""
mediakit_task_id: str
status: str
@@ -68,14 +67,9 @@ class CreateLipsyncJobRequest(BaseModel):
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field(
"",
description="[deprecated] 旧情绪参数,内部映射为 style",
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
)
enable_video_loop: bool = Field(
@@ -129,15 +123,10 @@ class AiAvatarTtsPreviewRequest(BaseModel):
voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID")
script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field(
"neutral",
max_length=32,
description="[deprecated] 旧情绪参数,内部映射为 style;默认 neutral",
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral)",
)
+3 -14
View File
@@ -16,15 +16,9 @@ class TTSSynthesizeRequest(BaseModel):
output_name: str = Field("", description="输出文件名")
language: str = Field("zh-CN", description="语言")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
pitch: Optional[float] = Field(None, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
emotion: str = Field(
"",
description="[deprecated] 旧情绪参数,内部映射为 style;新接入请使用 style",
description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)",
)
voice_model: str = Field("", description="语音模型名称")
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
@@ -119,14 +113,9 @@ class TTSPreviewRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
voice_id: str = Field(..., min_length=1, description="音色 ID")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
style: Optional[str] = Field(
None,
description="语气风格(natural/sweet/excited/professional/news/livestream),可选;优先级高于 emotion",
)
volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50")
emotion: str = Field("", description="[deprecated] 旧情绪参数,内部映射为 style")
emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)")
language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(0.5-2.0),默认 1.0")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
class TTSPreviewResponse(BaseModel):
@@ -61,7 +61,6 @@ class AiAvatarRenderService:
b_roll_segments: list[dict[str, Any]] | None = None,
title_config: dict[str, Any],
cover_config: dict[str, Any],
cover_title_config: dict[str, Any] | None = None,
project_id: str = "",
) -> AiAvatarRenderJob:
"""创建渲染任务.
@@ -113,7 +112,6 @@ class AiAvatarRenderService:
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])],
title_config=title_config,
cover_config=cover_config,
cover_title_config=cover_title_config or {},
status="pending",
)
self.db.add(job)
@@ -1,142 +0,0 @@
"""视频生成任务 finalize 服务(#2024)。
Worker 渲染+上传完成后不再自动入库,标记为 awaiting_cover;用户在 Step5 选好封面
点「完成」时由 API 调用本服务:创建 GeneratedVideo 成品库记录(复用 worker 预计算
的查重结果)、绑定封面、推进任务到 completed。
与 AI 数字人 ``ai_avatar_render_service.finalize_job`` 模式一致,
只是走 GenerationTask 而非 AiAvatarRenderJob。
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
class GenerationFinalizeError(Exception):
"""finalize 业务错误,code 供 API 层映射 HTTP 状态码。"""
def __init__(self, message: str, code: str = "FinalizeError", status_code: int = 400):
super().__init__(message)
self.code = code
self.status_code = status_code
class GenerationFinalizeService:
def __init__(self, db: Session):
self.db = db
def finalize_task(
self,
task_id: str,
user_id: str,
cover_url: Optional[str] = None,
custom_title: Optional[str] = None,
):
"""执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。
Returns:
GeneratedVideo 领域对象
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
from packages.application.generated_video_finalize import finalize_generated_video
task_repo = SQLAlchemyGenerationTaskRepository(self.db)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
task = task_repo.get(task_id)
if task is None:
raise GenerationFinalizeError(f"任务 {task_id} 不存在", "TaskNotFound", 404)
# ── 幂等:已入库直接返回 ─────────────────────────────────
existing = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == task_id).first()
if existing is not None:
logger.info("[finalize] 幂等命中 task=%s video=%s", task_id, existing.id)
_changed = False
if cover_url and cover_url.strip() and existing.thumbnail_url != cover_url.strip():
existing.thumbnail_url = cover_url.strip()
task.cover_url = cover_url.strip()
_changed = True
if custom_title and custom_title.strip() and (getattr(existing, "name", "") or "") != custom_title.strip():
existing.name = custom_title.strip()
_changed = True
if _changed:
self.db.commit()
if task.status.value != "completed":
try:
task.mark_completed(result_count=1)
if cover_url and cover_url.strip():
task.cover_url = cover_url.strip()
task_repo.update(task)
self.db.commit()
except Exception as e:
logger.warning("[finalize] 幂等补 mark_completed 失败: %s", e)
self.db.rollback()
return video_repo.get(existing.id)
# ── 状态校验 ─────────────────────────────────────────────
if task.status.value != "awaiting_cover":
raise GenerationFinalizeError(
f"任务当前状态 {task.status.value},无法 finalize(需 awaiting_cover)",
"InvalidTaskStatus",
400,
)
# ── 封面 ─────────────────────────────────────────────────
effective_cover = (cover_url or "").strip() if cover_url else (task.cover_url or "").strip()
# ── 入库+查重(复用 worker 预计算结果) ──────────────────
try:
result = finalize_generated_video(
task=task,
session=self.db,
effective_cover_url=effective_cover,
custom_name=custom_title,
)
except ValueError as e:
raise GenerationFinalizeError(str(e), "RenderedOutputMissing", 400) from e
video_id = result["video_id"]
# 应用自定义标题
if custom_title and custom_title.strip():
try:
_v = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
if _v is not None:
_v.name = custom_title.strip()
self.db.flush()
except Exception:
logger.warning("[finalize] 更新标题失败: video_id=%s", video_id, exc_info=True)
# ── 推进任务 ─────────────────────────────────────────────
task.mark_completed(result_count=1)
task.cover_url = effective_cover
# 清理 rendered_output(体积较大,入库后不再需要)
meta = dict(task.extra_meta or {})
meta.pop("rendered_output", None)
task.extra_meta = meta
task.updated_at = datetime.now(UTC)
task_repo.update(task)
self.db.commit()
video = video_repo.get(video_id)
logger.info(
"[finalize] task=%s finalized -> video=%s cover=%s dup=%s",
task_id,
video_id,
bool(effective_cover),
result.get("is_duplicate", False),
)
return video
@@ -1,396 +0,0 @@
"""GPU MuseTalk 口型同步服务 — 反向轮询模式.
职责:
1. 创建任务(由 lipsync 业务流程调用),为输入/输出生成预签名 URL,任务入队;
2. Worker 心跳注册(register):登记/刷新 worker 状态;
3. Worker 轮询拉任务(poll):原子地 CLAIM 一条 pending 任务,返回预签名 URL;
4. Worker 上报结果(report_result):标记 done/failed,失败可重试;
5. 业务侧查询状态(get_status)。
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Optional
from app.core.storage import get_storage_service
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel, GpuWorkerModel
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
# 任务在 processing 超过此时长仍未完成 → 超时回退 pending 或置 failed
MAX_ATTEMPTS = 3
class GpuLipsyncService:
"""GPU 口型同步服务(无状态方法,每次调用从 DI 拿 db/storage)."""
RESULT_PREFIX = "gpu-lipsync/results/"
INPUT_SIGN_EXPIRES_PAD = 600 # 输入预签名 URL 在任务超时基础上再加 10min 余量
# ── 公共入口 ────────────────────────────────────────────────────
def __init__(self, db: Session):
self.db = db
self.settings = get_api_settings()
self.storage = get_storage_service()
# ── Worker 注册/心跳 ────────────────────────────────────────────
def register_worker(
self,
worker_id: str,
hostname: str = "",
gpu_name: str = "",
free_vram_mb: int = 0,
capabilities: str = "musetalk",
task_id: Optional[str] = None,
) -> tuple[GpuWorkerModel, bool]:
"""Worker 注册/心跳。
task_id 非空时(Worker 推理期间的任务级心跳),同步把对应 processing
任务的 last_heartbeat_at 续到当前时间,使长推理不会被
``_recover_timed_out_tasks`` 误回退。任务已结束 / 不属于该 worker
(如已被超时回收重新派发)时忽略,不报错。
返回 ``(worker, cancel_task)``:当心跳任务已被用户取消时
``cancel_task=True``,Worker 应尽快终止推理并释放 GPU。
"""
now = datetime.now(UTC)
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
if worker is None:
worker = GpuWorkerModel(
worker_id=worker_id,
hostname=hostname,
gpu_name=gpu_name,
free_vram_mb=free_vram_mb,
capabilities=capabilities,
last_heartbeat_at=now,
created_at=now,
)
self.db.add(worker)
else:
worker.hostname = hostname or worker.hostname
worker.gpu_name = gpu_name or worker.gpu_name
worker.free_vram_mb = free_vram_mb
worker.capabilities = capabilities or worker.capabilities
worker.last_heartbeat_at = now
cancel_task = False
if task_id:
cancel_task = self._touch_task_heartbeat(task_id, worker_id, now)
self.db.commit()
return worker, cancel_task
# ── 轮询拉任务(Worker 调用) ──────────────────────────────────
def poll_task(self, worker_id: str) -> Optional[GpuLipsyncTaskModel]:
"""原子地认领一条最早的 pending 任务,返回给 worker;无任务返回 None.
同时会:
- 把 processing 状态且真正超时(任务心跳停滞超过
gpu_task_timeout_seconds;Worker 推理期会通过 register(task_id=...)
续心跳,长推理不会误判)的任务回退为 pending(attempt++,超过
MAX_ATTEMPTS 置 failed),让其它 worker 认领。
- 刷新 worker 心跳。
"""
now = datetime.now(UTC)
self._recover_timed_out_tasks(now)
# 更新 worker 心跳
self._touch_worker(worker_id, now)
# 选一条最早 pending 任务(FOR UPDATE SKIP LOCKED 语义:简单起见先查再锁状态)
task = (
self.db.query(GpuLipsyncTaskModel)
.filter(GpuLipsyncTaskModel.status == "pending")
.order_by(GpuLipsyncTaskModel.created_at.asc())
.first()
)
if task is None:
self.db.commit()
return None
# 原子 claim:用 UPDATE WHERE status=pending 避免并发
upd_rows = (
self.db.query(GpuLipsyncTaskModel)
.filter(
GpuLipsyncTaskModel.id == task.id,
GpuLipsyncTaskModel.status == "pending",
)
.update(
{
GpuLipsyncTaskModel.status: "processing",
GpuLipsyncTaskModel.worker_id: worker_id,
GpuLipsyncTaskModel.started_at: now,
GpuLipsyncTaskModel.last_heartbeat_at: now,
GpuLipsyncTaskModel.attempt: GpuLipsyncTaskModel.attempt + 1,
GpuLipsyncTaskModel.updated_at: now,
},
synchronize_session=False,
)
)
self.db.commit()
if upd_rows == 0:
# 被其它 worker 抢先了
return None
self.db.refresh(task)
# 生成预签名输入/输出 URL(在 claim 时动态生成,避免长时间过期)
expires = self.settings.gpu_task_timeout_seconds + self.INPUT_SIGN_EXPIRES_PAD
task._signed_video_url = self.storage.get_download_url(task.video_url, expires_seconds=expires)
task._signed_audio_url = self.storage.get_download_url(task.audio_url, expires_seconds=expires)
task._signed_upload_url = self.storage.get_upload_url(
self._result_key(task.id),
expires_seconds=expires,
content_type="video/mp4",
)
task._upload_expires_at = now + timedelta(seconds=expires)
return task
# ── 上报结果 ──────────────────────────────────────────────────
def report_result(
self,
task_id: str,
worker_id: str,
success: bool,
duration_seconds: float = 0.0,
error_msg: str = "",
) -> GpuLipsyncTaskModel:
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
raise KeyError(f"task {task_id} not found")
now = datetime.now(UTC)
if success:
task.status = "done"
task.result_url = self._result_key(task_id)
task.result_duration = duration_seconds or 0.0
task.error_msg = ""
task.finished_at = now
elif task.status == "cancelled":
# 用户已取消的任务,Worker 终止后上报失败,保持 cancelled 状态不回退
task.finished_at = now
task.error_msg = (error_msg or "用户取消")[:2000]
logger.info("GPU 任务 %s 已被用户取消,保持 cancelled 状态", task_id)
else:
# 失败:若仍可重试(已尝试次数 < MAX_ATTEMPTS)→ 回退 pending;否则 → failed
if task.attempt < MAX_ATTEMPTS:
task.status = "pending"
task.worker_id = ""
task.started_at = None
task.error_msg = error_msg[:2000]
logger.warning(
"GPU 任务 %s 在 worker %s 上失败,回退 pending 等待重试(attempt=%d): %s",
task_id,
worker_id,
task.attempt,
error_msg[:200],
)
else:
task.status = "failed"
task.error_msg = error_msg[:2000]
task.finished_at = now
logger.error(
"GPU 任务 %s 失败达到最大重试次数 %d,置为 failed: %s",
task_id,
MAX_ATTEMPTS,
error_msg[:200],
)
task.updated_at = now
task.last_heartbeat_at = now
self._touch_worker(worker_id, now)
self.db.commit()
self.db.refresh(task)
return task
# ── 业务侧查询 ────────────────────────────────────────────────
def get_task(self, task_id: str) -> Optional[GpuLipsyncTaskModel]:
return self.db.get(GpuLipsyncTaskModel, task_id)
def get_by_lipsync_job(self, lipsync_job_id: str) -> Optional[GpuLipsyncTaskModel]:
return (
self.db.query(GpuLipsyncTaskModel)
.filter(GpuLipsyncTaskModel.lipsync_job_id == lipsync_job_id)
.order_by(GpuLipsyncTaskModel.created_at.desc())
.first()
)
# ── 创建任务(业务侧调用) ────────────────────────────────────
def create_task(
self,
video_url: str,
audio_url: str,
lipsync_job_id: str = "",
user_id: str = "",
project_id: str = "",
) -> GpuLipsyncTaskModel:
task_id = str(uuid.uuid4())
now = datetime.now(UTC)
task = GpuLipsyncTaskModel(
id=task_id,
lipsync_job_id=lipsync_job_id,
user_id=user_id,
project_id=project_id,
video_url=video_url,
audio_url=audio_url,
status="pending",
attempt=0,
created_at=now,
updated_at=now,
)
self.db.add(task)
self.db.commit()
self.db.refresh(task)
logger.info(
"创建 GPU 口型任务 %s (lipsync_job=%s, user=%s)",
task_id,
lipsync_job_id,
user_id,
)
return task
# ── 内部辅助 ──────────────────────────────────────────────────
def _result_key(self, task_id: str) -> str:
return f"{self.RESULT_PREFIX}{task_id}.mp4"
def _touch_task_heartbeat(self, task_id: str, worker_id: str, now: datetime) -> bool:
"""Worker 推理期间的任务级心跳:只刷新属于该 worker 且仍在 processing 的任务。
任务不存在 / 已被超时回收重新派发 / 已完成 → 静默忽略(此时旧 worker 的
结果上报会被结果接口按最终态处理)。
返回 ``cancel_task``:任务已被用户取消时为 True,Worker 应终止推理。
"""
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
return False
# 任务已被用户取消 → 通知 Worker 终止推理
if task.status == "cancelled":
logger.info("任务心跳检测到已取消 task=%s worker=%s,通知 Worker 终止", task_id, worker_id)
return True
if task.status != "processing" or task.worker_id != worker_id:
logger.info(
"忽略过期任务心跳 task=%s worker=%s(status=%s owner=%s)",
task_id,
worker_id,
task.status,
task.worker_id,
)
return False
task.last_heartbeat_at = now
task.updated_at = now
self.db.flush()
return False
def _touch_worker(self, worker_id: str, now: datetime) -> None:
if not worker_id:
return
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
if worker is not None:
worker.last_heartbeat_at = now
self.db.flush()
else:
# 自注册(poll 时允许自动建一个空 worker 记录,运维可见)
worker = GpuWorkerModel(
worker_id=worker_id,
hostname="",
gpu_name="",
free_vram_mb=0,
capabilities="musetalk",
last_heartbeat_at=now,
created_at=now,
)
self.db.add(worker)
self.db.flush()
def _recover_timed_out_tasks(self, now: datetime) -> None:
"""扫描 processing 状态且真正超时的任务,回退 pending 或失败。
判定只看任务自身 last_heartbeat_at:claim 时写入,Worker 推理期间通过
/gpu/register(task_id=...) 每 30s 续期。因此仅在 Worker 崩溃/断网
(任务心跳停滞超过 gpu_task_timeout_seconds)时才回收,
不会因 Worker 主循环忙于推理而误回退。
"""
timeout = self.settings.gpu_task_timeout_seconds
cutoff = now - timedelta(seconds=timeout)
stuck_tasks = (
self.db.query(GpuLipsyncTaskModel)
.filter(
GpuLipsyncTaskModel.status == "processing",
GpuLipsyncTaskModel.last_heartbeat_at < cutoff,
)
.all()
)
for t in stuck_tasks:
if t.attempt >= MAX_ATTEMPTS:
t.status = "failed"
t.error_msg = f"worker 心跳超时({timeout}s),重试次数已耗尽"
t.finished_at = now
else:
t.status = "pending"
t.worker_id = ""
t.started_at = None
t.error_msg = f"worker 心跳超时({timeout}s),等待重试"
logger.warning("GPU 任务 %s 心跳超时,回退 pending(attempt=%d)", t.id, t.attempt)
t.updated_at = now
if stuck_tasks:
self.db.flush()
# ── 业务侧辅助 ──────────────────────────────────────────────────
def has_available_worker(self) -> bool:
"""判断是否有 Worker 在心跳新鲜窗口内可用."""
stale_cutoff = datetime.now(UTC) - timedelta(seconds=self.settings.gpu_worker_stale_seconds)
return (
self.db.query(GpuWorkerModel).filter(GpuWorkerModel.last_heartbeat_at >= stale_cutoff).first() is not None
)
def wait_for_result(
self,
task_id: str,
timeout_seconds: Optional[int] = None,
poll_interval: Optional[float] = None,
) -> Optional[GpuLipsyncTaskModel]:
"""同步轮询等待 GPU 任务完成。
Args:
task_id: 任务 ID(由 create_task 返回)
timeout_seconds: 总超时,默认取 settings.gpu_lipsync_wait_timeout
poll_interval: 轮询间隔秒,默认取 settings.gpu_lipsync_poll_interval
Returns:
终态 task(status=done/failed);超时返回 None(此时调用方应回退 MediaKit)。
等待期间会自动调用 _recover_timed_out_tasks 做超时回收。
"""
import time
timeout = timeout_seconds if timeout_seconds is not None else self.settings.gpu_lipsync_wait_timeout
interval = poll_interval if poll_interval is not None else self.settings.gpu_lipsync_poll_interval
deadline = time.monotonic() + timeout
while True:
now = datetime.now(UTC)
# 顺手回收超时任务
try:
self._recover_timed_out_tasks(now)
self.db.commit()
except Exception as exc: # noqa: BLE001 - 回收失败不阻塞主流程
logger.warning("wait_for_result 回收超时任务异常: %s", exc)
self.db.rollback()
task = self.db.get(GpuLipsyncTaskModel, task_id)
if task is None:
return None
if task.status in ("done", "failed", "cancelled"):
return task
# pending/processing 继续等
if time.monotonic() >= deadline:
logger.warning("GPU 任务 %s 等待超时(%ds),回退 MediaKit", task_id, timeout)
return None
time.sleep(interval)
+4 -225
View File
@@ -29,7 +29,6 @@ from app.services.mediakit_client import (
MediaKitError,
get_mediakit_client,
)
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径)
from app.tasks.lipsync_tts import tts_synthesize_and_submit
@@ -37,7 +36,6 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError
from packages.config import get_api_settings
from packages.domain.sentence_timings import (
compute_sentence_timings,
probe_audio_duration,
@@ -65,7 +63,6 @@ class LipsyncService:
self.client = client or get_mediakit_client()
self._cosyvoice = cosyvoice_service
self._voice_clone_repo = voice_clone_repo
self.settings = get_api_settings()
def _get_cosyvoice(self):
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
@@ -111,8 +108,6 @@ class LipsyncService:
script_text: str,
speed: float,
emotion: str,
style: str = "",
volume: int = 50,
) -> str:
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
@@ -126,9 +121,7 @@ class LipsyncService:
text=script_text,
voice_id=actual_voice_id,
speed=speed,
style=style,
volume=volume,
emotion=emotion,
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
language="zh",
)
except CosyVoiceError as exc:
@@ -222,57 +215,7 @@ class LipsyncService:
if timings:
job.sentence_timings = timings
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
use_gpu = False
if self.settings.use_gpu_lipsync:
try:
from app.services.gpu_lipsync_service import GpuLipsyncService
gpu_svc = GpuLipsyncService(self.db)
if gpu_svc.has_available_worker():
use_gpu = True
logger.info("[lipsync] 检测到可用 GPU Worker,优先走 MuseTalk 本地推理: job_id=%s", job.id)
else:
logger.info("[lipsync] GPU 开关已开但无可用 Worker(心跳过期),回退 MediaKit: job_id=%s", job.id)
except Exception as exc:
logger.warning("[lipsync] GPU 服务初始化失败,回退 MediaKit: job_id=%s err=%s", job.id, exc)
if use_gpu:
try:
gpu_task = self._submit_to_gpu_create(job=job, gpu_svc=gpu_svc)
if gpu_task is not None:
# GPU 任务已创建,设为 processing 并异步等待结果
job.mediakit_task_id = f"gpu:{gpu_task.id}"
job.status = "processing"
job.updated_at = datetime.now(UTC)
self.db.commit()
# 派发 Celery 异步任务处理 GPU 等待+结果回写
try:
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
logger.info(
"[lipsync] GPU 任务已异步派发: job_id=%s gpu_task=%s",
job.id,
gpu_task.id,
)
except Exception as celery_exc:
logger.warning(
"[lipsync] Celery 派发失败,降级同步等待: job_id=%s err=%s",
job.id,
celery_exc,
)
self._submit_to_gpu_wait(job=job, gpu_svc=gpu_svc, gpu_task=gpu_task)
return
# create 失败 → 回退 MediaKit
logger.warning("[lipsync] GPU 任务创建失败,回退 MediaKit: job_id=%s", job.id)
self.db.rollback()
except Exception as exc:
logger.exception("[lipsync] GPU 路径异常,回退 MediaKit: job_id=%s err=%s", job.id, exc)
try:
self.db.rollback()
except Exception:
pass
# 5. 签名 URL 并提交 MediaKit(兜底路径)
# 4. 签名 URL 并提交 MediaKit
video_url = self._sign_media_url(job.video_url)
signed_audio_url = self._sign_media_url(job.audio_url)
job.audio_url = signed_audio_url
@@ -301,120 +244,6 @@ class LipsyncService:
self.db.commit()
raise
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
def _is_own_oss_url(self, url: str, storage) -> bool:
"""判断 URL / 存储 key 是否属于自家 OSS。
- 裸存储 key(无 scheme):自家对象
- host 与 storage.public_url host 一致:自家对象
- 其余 http(s) 公网链接(如 dashscope-result 临时地址):外部对象
"""
if not url:
return False
parsed = urlparse(url)
if not parsed.scheme:
return True # 裸存储 key
public_base = getattr(storage, "public_url", "")
own_host = urlparse(public_base).netloc.lower() if public_base else ""
return bool(own_host) and parsed.netloc.lower() == own_host
def _persist_external_audio_for_gpu(self, *, job, storage) -> Optional[str]:
"""GPU 任务创建前,把外部域名的预合成 TTS 音频转存到自家 OSS。
Worker 部署在用户家庭网络,dashscope-result 等第三方临时 OSS 地址
可能无法访问;转存后 gpu_svc 在 poll 时会签自家预签名 URL 给 Worker。
已是自家 OSS 对象(含裸 key)直接返回 None(无需转存);
转存失败返回 None,调用方回退使用原始 URL(最坏情况是 Worker 拉取失败,
服务端重试耗尽后回退 MediaKit,不阻断业务)。
"""
if self._is_own_oss_url(job.audio_url, storage):
return None
try:
audio_data = safe_download_bytes(
job.audio_url,
purpose="lipsync_gpu_tts_audio",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
storage_key = f"lipsync-tts/{job.user_id}/{job.id}.mp3"
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info(
"[lipsync] GPU 任务外部音频已转存自家 OSS: job_id=%s key=%s",
job.id,
storage_key,
)
return permanent_url
except Exception as exc:
logger.warning(
"[lipsync] GPU 任务外部音频转存 OSS 失败,回退原始 URL: job_id=%s err=%s",
job.id,
exc,
)
return None
def _submit_to_gpu_create(self, *, job, gpu_svc) -> Optional[object]:
"""创建 GPU 任务并立即返回(异步模式)。
成功返回 gpu_task 对象;创建失败返回 None。
不再同步等待结果,结果由 Celery 异步任务 lipsync_gpu_process_async 回写。
"""
storage = get_shared_storage_service()
persisted_audio_url = self._persist_external_audio_for_gpu(job=job, storage=storage)
audio_url_for_task = persisted_audio_url or job.audio_url
gpu_task = gpu_svc.create_task(
video_url=job.video_url,
audio_url=audio_url_for_task,
lipsync_job_id=job.id,
user_id=job.user_id,
project_id=job.project_id,
)
logger.info(
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
job.id,
gpu_task.id,
)
return gpu_task
def _submit_to_gpu_wait(self, *, job, gpu_svc, gpu_task) -> None:
"""同步等待 GPU 结果(Celery 派发失败时的降级路径)。"""
final_task = gpu_svc.wait_for_result(gpu_task.id)
if final_task is None:
logger.warning("[lipsync] GPU 同步等待超时,回退 MediaKit: gpu_task=%s", gpu_task.id)
return
if final_task.status != "done":
logger.warning(
"[lipsync] GPU 同步等待失败: gpu_task=%s status=%s",
gpu_task.id,
final_task.status,
)
return
try:
storage = get_shared_storage_service()
signed_result_url = storage.get_download_url(
final_task.result_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS
)
if signed_result_url:
final_task.result_url = signed_result_url
except Exception as exc:
logger.warning(
"[lipsync] GPU 结果签名失败: gpu_task=%s err=%s",
gpu_task.id,
exc,
)
job.mediakit_task_id = ""
job.status = STATUS_COMPLETED
job.output_video_url = final_task.result_url
job.output_duration = final_task.result_duration or 0.0
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
self.db.commit()
logger.info(
"[lipsync] GPU 同步等待完成: job_id=%s duration=%.2f",
job.id,
job.output_duration,
)
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
@@ -428,8 +257,6 @@ class LipsyncService:
voice_id: str = "",
script_text: str = "",
speed: float = 1.0,
style: str = "",
volume: int = 50,
emotion: str = "",
enable_video_loop: bool = True,
project_id: str = "",
@@ -478,7 +305,6 @@ class LipsyncService:
voice_id=voice_id or "",
script_text=script_text or "",
speed=speed,
style=style or "",
emotion=emotion or "",
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
status="tts_processing" if is_tts_mode else "pending",
@@ -500,8 +326,6 @@ class LipsyncService:
voice_id,
script_text,
speed,
style or "",
volume,
emotion or "",
)
)
@@ -536,8 +360,6 @@ class LipsyncService:
voice_id: str,
script_text: str,
speed: float = 1.0,
style: str = "",
volume: int = 50,
emotion: str = "neutral",
) -> dict:
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
@@ -656,29 +478,6 @@ class LipsyncService:
if job.status in (STATUS_COMPLETED, "failed"):
return job
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
if job.status in ("processing", "gpu_processing"):
_now = datetime.now(UTC)
_upd = job.updated_at
if _upd is not None and _upd.tzinfo is None:
_upd = _upd.replace(tzinfo=UTC)
stale_minutes = 30
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
logger.warning(
"GPU 异步任务超时(>%d 分钟),标记失败: job_id=%s",
stale_minutes,
job_id,
)
job.status = "failed"
job.error_message = f"GPU 处理超时(>{stale_minutes} 分钟)"
job.error_code = "GpuTimeout"
job.completed_at = _now
job.updated_at = _now
self.db.commit()
return job
# 未提交的任务不轮询
if not job.mediakit_task_id:
return job
@@ -783,32 +582,12 @@ class LipsyncService:
# ── 取消任务 ──────────────────────────────────────────────────────────
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""取消任务(pending/tts_processing/submitted/processing 状态可取消).
当 job 走 GPU 路径(mediakit_task_id 以 "gpu:" 开头)且状态为 processing 时,
同步将关联的 GpuLipsyncTask 标记为 cancelled,以便 Worker 心跳时检测到取消信号。
"""
"""取消任务(仅 pending/tts_processing/submitted 状态可取消)."""
job = self.get_job(job_id, user_id)
if job is None:
return None
if job.status in ("pending", "tts_processing", "submitted", "processing"):
# GPU 路径:同步标记关联的 GPU 任务为 cancelled
if job.status == "processing" and job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
gpu_task_id = job.mediakit_task_id[4:] # 去掉 "gpu:" 前缀
try:
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel
gpu_task = self.db.get(GpuLipsyncTaskModel, gpu_task_id)
if gpu_task and gpu_task.status == "processing":
gpu_task.status = "cancelled"
gpu_task.error_msg = "用户取消"
gpu_task.updated_at = datetime.now(UTC)
gpu_task.finished_at = datetime.now(UTC)
logger.info("GPU 任务 %s 已被用户取消(通过 job_id=%s)", gpu_task_id, job_id)
except Exception as exc:
logger.warning("标记 GPU 任务取消失败(不影响 job 取消): %s", exc)
if job.status in ("pending", "tts_processing", "submitted"):
job.status = "cancelled"
job.updated_at = datetime.now(UTC)
self.db.commit()
-198
View File
@@ -1,198 +0,0 @@
"""GPU MuseTalk 异步推理任务 — 将 GPU 推理等待从 HTTP 请求移至 Celery 后台执行.
优化目标:将 POST /lipsync/jobs 的 API 响应时间从 >200s 降到 <1s。
任务流程:
1. 加载 LipsyncJob,获取 gpu_task_id
2. 调用 GpuLipsyncService.wait_for_result 轮询等待 GPU 完成
3. 签名结果 URL(7 天),更新 job 为 completed
4. 失败/超时时:尝试 MediaKit 兜底,若仍失败则标记 job 为 failed
使用 @shared_task 确保被 Worker 侧 celery_app 正确注册。
"""
import logging
from datetime import UTC, datetime
from celery import shared_task
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.shared.storage import get_shared_storage_service
logger = logging.getLogger(__name__)
# 与 LipsyncService 保持一致
_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
def _get_db_session() -> Session:
"""获取 DB session(兼容 API 和 Worker 两种运行时)."""
try:
from worker_app.db import SessionLocal # type: ignore
except ImportError:
from app.db import SessionLocal # type: ignore
return SessionLocal()
def _sign_media_url(url: str) -> str:
"""对自家 OSS URL 签 7 天预签名。"""
if not url:
return url
try:
from urllib.parse import urlparse
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url
return storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
except Exception:
return url
@shared_task(
name="lipsync_gpu_process_async",
bind=True,
max_retries=0,
acks_late=True,
)
def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str) -> None:
"""异步处理 GPU MuseTalk 推理。
Args:
job_id: LipsyncJob 的 ID
user_id: 用户 ID
gpu_task_id: GpuLipsyncTask 的 ID
"""
db: Session = _get_db_session()
try:
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
if job is None:
logger.error("[lipsync_gpu_async] job 不存在: job_id=%s", job_id)
return
# 确保状态为 processing
if job.status not in ("processing", "gpu_processing"):
logger.warning(
"[lipsync_gpu_async] job 状态异常,跳过: job_id=%s status=%s",
job_id,
job.status,
)
return
from app.services.gpu_lipsync_service import GpuLipsyncService
gpu_svc = GpuLipsyncService(db)
final_task = gpu_svc.wait_for_result(gpu_task_id)
if final_task is None:
logger.warning(
"[lipsync_gpu_async] GPU 超时,回退 MediaKit: job_id=%s gpu_task=%s",
job_id,
gpu_task_id,
)
_fallback_to_mediakit(db, job)
return
if final_task.status == "cancelled":
# 用户已取消任务,不回退 MediaKit,直接标记 job 为 cancelled
job.status = "cancelled"
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[lipsync_gpu_async] GPU 任务已被用户取消: job_id=%s", job_id)
return
if final_task.status != "done":
logger.warning(
"[lipsync_gpu_async] GPU 失败,回退 MediaKit: job_id=%s gpu_task=%s status=%s",
job_id,
gpu_task_id,
final_task.status,
)
_fallback_to_mediakit(db, job)
return
# 签名结果 URL
result_url = final_task.result_url or ""
try:
storage = get_shared_storage_service()
signed = storage.get_download_url(result_url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
if signed:
result_url = signed
except Exception as exc:
logger.warning(
"[lipsync_gpu_async] 签名失败,用原 URL: job_id=%s err=%s",
job_id,
exc,
)
job.status = "completed"
job.output_video_url = result_url
job.output_duration = final_task.result_duration or 0.0
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[lipsync_gpu_async] GPU 完成: job_id=%s duration=%.2f",
job_id,
job.output_duration,
)
except Exception as exc:
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
try:
job = db.query(LipsyncJobModel).filter_by(id=job_id).first()
if job:
job.status = "failed"
job.error_message = f"GPU 异步处理异常: {exc}"
job.error_code = "GpuAsyncError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
finally:
db.close()
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
"""GPU 失败时回退到 MediaKit 云端渲染。"""
try:
from app.services.mediakit_client import MediaKitError, get_mediakit_client
client = get_mediakit_client()
video_url = _sign_media_url(job.video_url)
audio_url = _sign_media_url(job.audio_url)
result = client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=job.enable_video_loop,
client_token=job.id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[lipsync_gpu_async] 已回退 MediaKit: job_id=%s task_id=%s",
job.id,
result["task_id"],
)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[lipsync_gpu_async] MediaKit 也失败: job_id=%s err=%s", job.id, exc)
except Exception as exc:
job.status = "failed"
job.error_message = f"GPU+MediaKit 均失败: {exc}"
job.error_code = "FallbackFailed"
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[lipsync_gpu_async] 兜底异常: job_id=%s err=%s", job.id, exc)
+1 -5
View File
@@ -82,9 +82,7 @@ def tts_synthesize_and_submit(
voice_id: str,
script_text: str,
speed: float,
style: str = "",
volume: int = 50,
emotion: str = "",
emotion: str,
):
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
@@ -161,8 +159,6 @@ def tts_synthesize_and_submit(
text=script_text,
voice_id=voice_id,
speed=speed,
style=style,
volume=volume,
emotion=emotion,
language="zh",
)
-117
View File
@@ -1,117 +0,0 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
for (let i = 0; i <= 2; i++) {
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (r.status() !== 429) {
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
return (await r.json()).access_token as string
}
console.log(`[douyin] 429 retry ${i + 1}/2`)
await new Promise((res) => setTimeout(res, 65000))
}
throw new Error("Login retries exhausted")
}
/**
* #1972 抖音文案提取冒烟
*
* 路径:文案库页面 → 点「🎬 从抖音提取」→ 粘贴分享文案 → 点「开始提取」
* → mock /api/v1/scripts/extract-from-douyin 返回稳定文案 → 断言「新建文案」弹窗中预填了非空文案
*/
test.describe("Douyin Script Extraction (#1972)", () => {
test("extract flow: open modal, paste link, text prefilled in create modal", async ({
page,
request,
}) => {
test.setTimeout(180_000)
await page.setViewportSize({ width: 1440, height: 900 })
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-douyin-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_dy_${suffix}` },
})
const token = await loginWithRetry(request, email, PASSWORD)
const authHeader = { Authorization: `Bearer ${token}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: authHeader,
data: { name: `Smoke Douyin ${suffix}` },
})
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
await request.post(`${apiBase}/asset-libraries`, {
headers: authHeader,
data: { project_id: projectId, name: "Smoke", kind: "video" },
})
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// Mock 抖音提取接口返回稳定文案
const extractedText = "大家好,今天给大家推荐一款超好用的产品,性价比非常高,快来看看吧!"
await page.route("**/api/v1/scripts/extract-from-douyin", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ text: extractedText, duration_seconds: 15 }),
}),
)
// 文案列表空态
await page.route(
(url) => url.pathname.endsWith("/scripts") && !url.pathname.includes("extract-from-douyin"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], total: 0, page: 1, page_size: 20 }),
}),
)
await page.goto("/app/scripts")
// 文案库页面加载
await expect(page.getByText(/文案库|文案/).first()).toBeVisible({ timeout: 30000 })
// 点「🎬 从抖音提取」按钮
await page.getByRole("button", { name: /从抖音提取/ }).click()
await expect(page.getByText("从抖音视频提取文案")).toBeVisible({ timeout: 5000 })
// 在 TextArea 粘贴"抖音分享文案"
const textarea = page.locator(".ant-modal textarea").first()
await expect(textarea).toBeVisible()
await textarea.fill("8.88 复制打开抖音,看看【推荐视频】https://v.douyin.com/abcDEF/")
// 点「开始提取」
await page.getByRole("button", { name: "开始提取" }).click()
await expect(page.getByText(/提取中/)).toBeVisible({ timeout: 3000 })
// 等待抖音弹窗关闭,「新建文案」弹窗打开并预填提取文案
await expect(page.getByText("从抖音视频提取文案")).not.toBeVisible({ timeout: 15000 })
await expect(page.getByText("新建文案")).toBeVisible({ timeout: 5000 })
const createTextarea = page.locator(".ant-modal textarea").first()
await expect(createTextarea).toBeVisible()
await expect(createTextarea).toHaveValue(new RegExp(extractedText.slice(0, 10)))
console.log("[douyin] Extraction flow completed ✓, text length:", extractedText.length)
})
})
+238 -321
View File
@@ -1,4 +1,4 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
import { expect, test, type APIRequestContext } from "@playwright/test"
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
@@ -8,8 +8,7 @@ const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
async function routeBrowserApiToTestApi(page: Page) {
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
@@ -25,358 +24,276 @@ async function loginWithRetry(
email: string,
password: string,
maxRetries = 2,
): Promise<string> {
) {
for (let i = 0; i <= maxRetries; i++) {
const resp = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (resp.status() !== 429) {
expect(resp.ok(), `Login should succeed: ${await resp.text()}`).toBeTruthy()
const data = await resp.json()
return data.access_token
}
console.log(`[login] 429 rate limited, retry ${i + 1}/${maxRetries} after 65s`)
const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password },
})
if (response.status() !== 429) return response
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000))
}
throw new Error("Login failed after retries")
return request.post(`${apiBase}/auth/login`, {
data: { email, password },
})
}
/**
* 注册新用户 + 建项目/视频库/上传 sample.mp4,等素材 ready。返回 { token, projectId, libraryId, assetId }。
*/
async function setupFreshUser(
request: APIRequestContext,
label: string,
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-${label}-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_${label}_${suffix}` },
})
const token = await loginWithRetry(request, email, PASSWORD)
const auth = { Authorization: `Bearer ${token}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: auth,
data: { name: `Smoke ${label} ${suffix}` },
})
expect(proj.ok(), `create project: ${await proj.text()}`).toBeTruthy()
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
const lib = await request.post(`${apiBase}/asset-libraries`, {
headers: auth,
data: { project_id: projectId, name: "Smoke", kind: "video" },
})
expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy()
const libraryId = (await lib.json()).id
const samplePath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleBuf = fs.readFileSync(samplePath)
const up = await request.post(`${apiBase}/upload`, {
headers: auth,
multipart: {
project_id: projectId,
library_id: libraryId,
file: {
name: "sample.mp4",
mimeType: "video/mp4",
buffer: sampleBuf,
},
},
})
expect(up.ok(), `upload sample: ${await up.text()}`).toBeTruthy()
const assetId = (await up.json()).asset_id
await expect
.poll(
async () => {
const r = await request.get(`${apiBase}/assets/${assetId}`, { headers: auth })
return r.ok() ? (await r.json()).status : "pending"
},
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
)
.toBe("ready")
return { token, libraryId, assetId, suffix }
type ProjectResponse = { id: string }
type LibraryResponse = { id: string }
type AssetListResponse = {
items: Array<{
id: string
name: string
status: string
}>
}
/**
* #1970 智能剪辑核心冒烟(新 5 步向导)
*
* 新流程:选择模式 → 选择素材 → 选择标题 → 确认生成 → 选择封面
*
* 两条路径:
* 1) 随机混剪(默认)→ Step1 下一步 → 配音选择弹窗 → Step2 选素材 → 数量弹窗
* → Step3 标题 → Step4 确认生成 → 断言任务创建
* 2) 叙事剪辑 → Step1 切模式 → 下一步 → 文案选择弹窗 → TTS 弹窗选音色(mock 合成)
* → Step2 AI 提示卡可见 + 选素材 → 数量弹窗 → Step3 标题 → Step4 确认生成
* → 断言任务创建
*/
test.describe("Core Smart-Edit Flow (#1970)", () => {
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, suffix } = await setupFreshUser(request, "random")
const authHeader = { Authorization: `Bearer ${token}` }
test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 360_000 })
// 确保默认模板存在(智能剪辑页依赖模板)
const tmpls = await request.get(`${apiBase}/templates`, { headers: authHeader })
const tmplsJson = await tmpls.json()
const templates = Array.isArray(tmplsJson)
? tmplsJson
: Array.isArray(tmplsJson.items)
? tmplsJson.items
: []
expect(templates.length).toBeGreaterThan(0)
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
test.setTimeout(360_000)
// 注入登录态 + 路由 API
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36)
const email = `e2e-gen-${suffix}@example.com`
const username = `e2e_gen_${suffix}`
const libraryName = `E2E Gen Lib ${suffix}`
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ──
await page.route(
(url) => url.pathname.endsWith("/assets") && url.searchParams.get("kind") === "voice",
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: `asset-voice-${suffix}`,
name: "测试配音.mp3",
file_url: "data:audio/mpeg;base64,",
duration: 10,
file_size: 1024,
kind: "voice",
status: "ready",
},
],
total: 1,
// Register
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
})
expect(register.status()).toBe(201)
const registerData = (await register.json()) as { user_id: string }
// Login
const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }
// Create project
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `E2E Gen Proj ${suffix}` },
})
expect(project.status()).toBe(200)
const projectData = (await project.json()) as ProjectResponse
// Create asset library
const library = await request.post(`${apiBase}/asset-libraries`, {
headers,
data: { project_id: projectData.id, name: libraryName, kind: "video" },
})
expect(library.status()).toBe(200)
const libraryData = (await library.json()) as LibraryResponse
// Upload source video
const sourceFileName = "e2e-gen-source.mp4"
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, {
headers,
multipart: {
project_id: projectData.id,
library_id: libraryData.id,
file: {
name: sourceFileName,
mimeType: "video/mp4",
buffer: sampleVideoBuffer,
},
},
})
expect(upload.status()).toBe(200)
// Wait for asset to be ready
await expect
.poll(
async () => {
const assets = await request.get(`${apiBase}/assets`, {
headers,
params: { library_id: libraryData.id },
})
if (!assets.ok()) return `http_${assets.status()}`
const data = (await assets.json()) as AssetListResponse
const asset = data.items.find((a) => a.name === sourceFileName)
if (!asset) return "missing"
return asset.status
},
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
)
.toBe("ready")
// GET /templates auto-creates a default template for new users
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
const templatesData = (await templatesResp.json()) as {
items: Array<{ id: string }>
}
expect(Array.isArray(templatesData.items)).toBe(true)
expect(templatesData.items.length).toBeGreaterThan(0)
const templateId = templatesData.items[0].id
expect(templateId).toBeTruthy()
// Set auth in localStorage
await page.addInitScript(
({ token, user }) => {
localStorage.setItem("access_token", token)
localStorage.setItem(
"auth-storage",
JSON.stringify({
state: { user, isAuthenticated: true },
version: 0,
}),
}),
)
},
{
token: loginData.access_token,
user: {
id: registerData.user_id,
user_id: registerData.user_id,
email,
username,
display_name: username,
is_email_verified: true,
email_verified: true,
},
},
)
// Navigate to generate page
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
timeout: 20_000,
})
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await expect(page.getByText("随机混剪")).toBeVisible()
await page.getByRole("button", { name: /下一步/ }).click()
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
// ── 配音选择弹窗:选第一个配音 → 确认 ─────────────────────────
await expect(page.getByText("🎙️ 选择配音")).toBeVisible({ timeout: 5000 })
await page.getByText("测试配音.mp3").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
// ── Step 1: 素材选择 ──
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName })
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
await expect(materialCard).toBeVisible({ timeout: 10_000 })
await materialCard.click({ position: { x: 15, y: 15 } })
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 2:选择素材 ──────────────────────────────────────────
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗:默认 1 个 → 确认 ───────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
// ── 数量弹窗(PreviewCountModal) ──
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
timeout: 5_000,
})
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题 ──────────────────────────────────────────
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
// ── Step 2: 配音(新注册用户无配音素材,跳过) ──
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// ── Step 3: 标题设置 ──
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
await page.waitForTimeout(2000)
const titleInput = page.locator(".ant-select-auto-complete input")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`测试随机剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
await titleInput.fill(`E2E Test ${suffix}`)
// ── Step 4:确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("随机混剪")).toBeVisible()
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
// Step 3 底部是「下一步 →」,点击进入 Step 4(确认生成)
await page.getByRole("button", { name: "下一步" }).click()
const createTask = page.waitForResponse(
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
{ timeout: 30000 },
// ── Step 4: 确认生成 ──
// 等待实时预览就绪(占位消失)
await page
.getByText("准备预览素材")
.waitFor({ state: "detached", timeout: 30_000 })
.catch(() => {})
// Step 4 底部是「✨ 确认生成视频」
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
// 先挂 API 监听再点击
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
},
{ timeout: 30_000 },
)
await confirmBtn.click()
const taskResp = await createTask
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
console.log("[random] Generation task created:", taskId)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[random] Wizard flow completed ✓")
// 验证生成 API 被调用
const genResp = await generatePromise.catch(() => null)
if (!genResp) {
// staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过
console.log(
"[E2E] Generation API not triggered (preview not ready) — wizard navigation verified",
)
} else if (genResp.ok()) {
const genData = (await genResp.json()) as {
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
// race:渲染完成 vs 生成失败/超时
const downloadReady = page
.getByText("视频生成完成")
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "completed" : null))
const generationFailed = page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "failed" : null))
const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout")
if (outcome === "completed") {
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 30_000,
})
} else {
console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`)
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
}
// 验证成品库页面加载
await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/)
await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 })
await page.unrouteAll({ behavior: "ignoreErrors" })
})
test("narrative mode: select script + mock TTS, create generation task", async ({
page,
request,
}) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, suffix } = await setupFreshUser(request, "narrative")
test("generation task API creates and lists tasks", async ({ request }) => {
const suffix = Date.now().toString(36)
const email = `e2e-gen-api-${suffix}@example.com`
const username = `e2e_gen_api_${suffix}`
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
const mockScriptId = `script-mock-${suffix}`
const mockVoiceId = `preset-voice-${suffix}`
const mockJobId = `tts-job-${suffix}`
// 文案列表(ScriptSelectModal 查询 /scripts)
await page.route("**/api/v1/scripts**", (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes("/extract-from-douyin")) {
route.continue()
return
}
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: mockScriptId,
title: "测试带货文案",
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
tags: ["带货"],
title_category: "daihuo",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
total: 1,
page: 1,
page_size: 200,
}),
})
const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username },
})
expect(register.status()).toBe(201)
// 预设音色(TtsVoiceModal 查询 GET /voices/presets)
await page.route("**/api/v1/voices/presets**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
voice_id: mockVoiceId,
name: "晓晓(女声)",
description: "温柔女声",
gender: "female",
language: "zh-CN",
preview_url: null,
tags: ["温柔"],
},
],
total: 1,
}),
}),
)
const login = await loginWithRetry(request, email, PASSWORD)
expect(login.status()).toBe(200)
const loginData = (await login.json()) as { access_token: string }
const headers = { Authorization: `Bearer ${loginData.access_token}` }
// 克隆音色:空列表
await page.route(
(url) => url.pathname.endsWith("/voice-clones"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
)
// TTS 合成:直接返回 completed 任务
await page.route("**/api/v1/tts/synthesize", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
job_id: mockJobId,
status: "completed",
progress: 100,
audio_url: "data:audio/mpeg;base64,",
duration: 5,
}),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
}),
)
await page.goto("/app/generate")
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
const project = await request.post(`${apiBase}/projects`, {
headers,
data: { name: `E2E API Proj ${suffix}` },
})
expect(project.status()).toBe(200)
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await page.getByText("叙事剪辑").click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
await page.getByText("测试带货文案").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
await page.getByText("晓晓(女声)").first().click()
await page.getByRole("button", { name: "🎧 合成配音" }).click()
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗 ─────────────────────────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("叙事剪辑")).toBeVisible()
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
const createTask2 = page.waitForResponse(
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
{ timeout: 30000 },
)
await confirmBtn2.click()
const taskResp2 = await createTask2
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[narrative] Wizard flow completed ✓")
const tasks = await request.get(`${apiBase}/tasks`, { headers })
expect(tasks.status()).toBe(200)
const tasksData = await tasks.json()
expect(Array.isArray(tasksData.items)).toBe(true)
})
})
-105
View File
@@ -1,105 +0,0 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
const PASSWORD = "SmokePass123!"
const apiBase = process.env.E2E_API_BASE || "/api/v1"
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
for (let i = 0; i <= 2; i++) {
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
if (r.status() !== 429) {
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
return (await r.json()).access_token as string
}
console.log(`[nav] 429 retry ${i + 1}/2`)
await new Promise((res) => setTimeout(res, 65000))
}
throw new Error("Login retries exhausted")
}
/**
* 核心页面导航冒烟:侧边栏主要入口能访问、文案库/配音库页面能正常加载(不出白屏/无致命 js error)
*/
test.describe("Core Navigation", () => {
let authToken: string
test.beforeAll(async ({ request }) => {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-nav-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username: `e2e_nav_${suffix}` },
})
authToken = await loginWithRetry(request, email, PASSWORD)
const authHeader = { Authorization: `Bearer ${authToken}` }
const proj = await request.post(`${apiBase}/projects`, {
headers: authHeader,
data: { name: `Smoke Nav ${suffix}` },
})
if (proj.ok()) {
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
await request.post(`${apiBase}/asset-libraries`, {
headers: authHeader,
data: { project_id: projectId, name: "Nav Lib", kind: "video" },
})
}
})
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, authToken)
await routeBrowserApiToTestApi(page)
})
const navCases = [
{ path: "/app/dashboard", marker: /概览|工作台|最近/i, name: "概览" },
{ path: "/app/generate", marker: /智能剪辑|剪辑/, name: "智能剪辑" },
{ path: "/app/assets", marker: /视频库|素材/, name: "视频库" },
{ path: "/app/scripts", marker: /文案/, name: "文案库" },
{ path: "/app/voices", marker: /配音|我的音色|配音库/, name: "配音库" },
{ path: "/app/products", marker: /成品|作品/, name: "成品库" },
{ path: "/app/history", marker: /历史|任务/, name: "任务历史" },
{ path: "/app/tasks", marker: /任务中心|任务列表/, name: "任务中心" },
{ path: "/app/points", marker: /积分|我的积分/, name: "积分中心" },
]
for (const c of navCases) {
test(`visit ${c.name} (${c.path}) loads without fatal pageerror`, async ({ page }) => {
const errors: Error[] = []
page.on("pageerror", (e) => errors.push(e))
await page.goto(c.path)
await expect(page.locator("body")).not.toBeEmpty({ timeout: 20000 })
// 过滤掉常见第三方/非致命错误
const fatal = errors.filter(
(e) =>
!/ResizeObserver|Loading chunk|network error|Failed to fetch|chunkLoadError/i.test(
e.message,
),
)
expect(fatal, `${c.name} pageerrors: ${fatal.map((e) => e.message).join("; ")}`).toHaveLength(
0,
)
await expect(
page.getByText(c.marker).first(),
`${c.name} should show relevant text`,
).toBeVisible({ timeout: 15000 })
console.log(`[nav] ${c.name} loaded ✓`)
})
}
})
+3 -5
View File
@@ -12,8 +12,6 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -3007,9 +3005,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
-2
View File
@@ -23,8 +23,6 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+115 -137
View File
@@ -18,16 +18,7 @@ importers:
version: 5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
axios:
specifier: ^1.7.2
version: 1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
classnames:
specifier: ^2.5.1
version: 2.5.1
dayjs:
specifier: ^1.11.23
version: 1.11.23
mp4box:
specifier: ^2.4.1
version: 2.4.1
version: 1.18.1
react:
specifier: ^18.3.1
version: 18.3.1
@@ -52,7 +43,7 @@ importers:
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
"@testing-library/user-event":
specifier: ^14.5.2
version: 14.6.7(@testing-library/dom@10.4.1)
version: 14.6.1(@testing-library/dom@10.4.1)
"@types/node":
specifier: ^20.14.9
version: 20.19.43
@@ -64,34 +55,34 @@ importers:
version: 18.3.7(@types/react@18.3.31)
"@typescript-eslint/eslint-plugin":
specifier: ^7.13.1
version: 7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
version: 7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)
"@typescript-eslint/parser":
specifier: ^7.13.1
version: 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
version: 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@vitejs/plugin-react":
specifier: ^4.3.1
version: 4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))
version: 4.3.1(vite@5.3.1(@types/node@20.19.43))
"@vitest/coverage-v8":
specifier: ^1.6.1
version: 1.6.1(supports-color@7.2.0)(vitest@1.6.0)
version: 1.6.1(vitest@1.6.0)
"@vitest/ui":
specifier: ^1.6.0
version: 1.6.0(vitest@1.6.0)
eslint:
specifier: ^8.57.0
version: 8.57.0(supports-color@7.2.0)
version: 8.57.0
eslint-config-prettier:
specifier: ^9.1.2
version: 9.1.2(eslint@8.57.0(supports-color@7.2.0))
version: 9.1.2(eslint@8.57.0)
eslint-plugin-react-hooks:
specifier: ^4.6.2
version: 4.6.2(eslint@8.57.0(supports-color@7.2.0))
version: 4.6.2(eslint@8.57.0)
eslint-plugin-react-refresh:
specifier: ^0.4.7
version: 0.4.26(eslint@8.57.0(supports-color@7.2.0))
version: 0.4.26(eslint@8.57.0)
jsdom:
specifier: ^24.1.0
version: 24.1.0(supports-color@7.2.0)
version: 24.1.0
prettier:
specifier: ^3.9.5
version: 3.9.5
@@ -103,7 +94,7 @@ importers:
version: 5.3.1(@types/node@20.19.43)
vitest:
specifier: ^1.6.0
version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
packages:
"@adobe/css-tools@4.5.0":
@@ -1136,10 +1127,10 @@ packages:
"@types/react-dom":
optional: true
"@testing-library/user-event@14.6.7":
"@testing-library/user-event@14.6.1":
resolution:
{
integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==,
integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==,
}
engines: { node: ">=12", npm: ">=6" }
peerDependencies:
@@ -1681,10 +1672,10 @@ packages:
}
engines: { node: ">=18" }
dayjs@1.11.23:
dayjs@1.11.21:
resolution:
{
integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==,
integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==,
}
debug@4.4.3:
@@ -2555,13 +2546,6 @@ packages:
integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==,
}
mp4box@2.4.1:
resolution:
{
integrity: sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==,
}
engines: { node: ">=20.8.1" }
mrmime@2.0.1:
resolution:
{
@@ -3877,20 +3861,20 @@ snapshots:
"@babel/compat-data@7.29.7": {}
"@babel/core@7.29.7(supports-color@7.2.0)":
"@babel/core@7.29.7":
dependencies:
"@babel/code-frame": 7.29.7
"@babel/generator": 7.29.7
"@babel/helper-compilation-targets": 7.29.7
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7)
"@babel/helpers": 7.29.7
"@babel/parser": 7.29.7
"@babel/template": 7.29.7
"@babel/traverse": 7.29.7(supports-color@7.2.0)
"@babel/traverse": 7.29.7
"@babel/types": 7.29.7
"@jridgewell/remapping": 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -3915,19 +3899,19 @@ snapshots:
"@babel/helper-globals@7.29.7": {}
"@babel/helper-module-imports@7.29.7(supports-color@7.2.0)":
"@babel/helper-module-imports@7.29.7":
dependencies:
"@babel/traverse": 7.29.7(supports-color@7.2.0)
"@babel/traverse": 7.29.7
"@babel/types": 7.29.7
transitivePeerDependencies:
- supports-color
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)":
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)":
dependencies:
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/helper-module-imports": 7.29.7(supports-color@7.2.0)
"@babel/core": 7.29.7
"@babel/helper-module-imports": 7.29.7
"@babel/helper-validator-identifier": 7.29.7
"@babel/traverse": 7.29.7(supports-color@7.2.0)
"@babel/traverse": 7.29.7
transitivePeerDependencies:
- supports-color
@@ -3948,14 +3932,14 @@ snapshots:
dependencies:
"@babel/types": 7.29.7
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))":
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)":
dependencies:
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/core": 7.29.7
"@babel/helper-plugin-utils": 7.29.7
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))":
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)":
dependencies:
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/core": 7.29.7
"@babel/helper-plugin-utils": 7.29.7
"@babel/runtime@7.29.7": {}
@@ -3966,7 +3950,7 @@ snapshots:
"@babel/parser": 7.29.7
"@babel/types": 7.29.7
"@babel/traverse@7.29.7(supports-color@7.2.0)":
"@babel/traverse@7.29.7":
dependencies:
"@babel/code-frame": 7.29.7
"@babel/generator": 7.29.7
@@ -3974,7 +3958,7 @@ snapshots:
"@babel/parser": 7.29.7
"@babel/template": 7.29.7
"@babel/types": 7.29.7
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -4060,17 +4044,17 @@ snapshots:
"@esbuild/win32-x64@0.21.3":
optional: true
"@eslint-community/eslint-utils@4.9.1(eslint@8.57.0(supports-color@7.2.0))":
"@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)":
dependencies:
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
eslint-visitor-keys: 3.4.3
"@eslint-community/regexpp@4.12.2": {}
"@eslint/eslintrc@2.1.4(supports-color@7.2.0)":
"@eslint/eslintrc@2.1.4":
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
espree: 9.6.1
globals: 13.19.0
ignore: 5.2.0
@@ -4083,10 +4067,10 @@ snapshots:
"@eslint/js@8.57.0": {}
"@humanwhocodes/config-array@0.11.14(supports-color@7.2.0)":
"@humanwhocodes/config-array@0.11.14":
dependencies:
"@humanwhocodes/object-schema": 2.0.3
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -4376,7 +4360,7 @@ snapshots:
"@types/react": 18.3.31
"@types/react-dom": 18.3.7(@types/react@18.3.31)
"@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)":
"@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)":
dependencies:
"@testing-library/dom": 10.4.1
@@ -4436,15 +4420,15 @@ snapshots:
dependencies:
"@types/yargs-parser": 21.0.3
"@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
"@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)":
dependencies:
"@eslint-community/regexpp": 4.12.2
"@typescript-eslint/parser": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/parser": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@typescript-eslint/scope-manager": 7.13.1
"@typescript-eslint/type-utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/type-utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
"@typescript-eslint/visitor-keys": 7.13.1
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
graphemer: 1.4.0
ignore: 5.3.1
natural-compare: 1.4.0
@@ -4454,14 +4438,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
"@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
"@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
dependencies:
"@typescript-eslint/scope-manager": 7.13.1
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
"@typescript-eslint/visitor-keys": 7.13.1
debug: 4.4.3(supports-color@7.2.0)
eslint: 8.57.0(supports-color@7.2.0)
debug: 4.4.3
eslint: 8.57.0
optionalDependencies:
typescript: 5.5.3
transitivePeerDependencies:
@@ -4472,12 +4456,12 @@ snapshots:
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/visitor-keys": 7.13.1
"@typescript-eslint/type-utils@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
"@typescript-eslint/type-utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
dependencies:
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
debug: 4.4.3(supports-color@7.2.0)
eslint: 8.57.0(supports-color@7.2.0)
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
debug: 4.4.3
eslint: 8.57.0
ts-api-utils: 1.3.0(typescript@5.5.3)
optionalDependencies:
typescript: 5.5.3
@@ -4486,11 +4470,11 @@ snapshots:
"@typescript-eslint/types@7.13.1": {}
"@typescript-eslint/typescript-estree@7.13.1(supports-color@7.2.0)(typescript@5.5.3)":
"@typescript-eslint/typescript-estree@7.13.1(typescript@5.5.3)":
dependencies:
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/visitor-keys": 7.13.1
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.9
@@ -4501,13 +4485,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
"@typescript-eslint/utils@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
"@typescript-eslint/utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
dependencies:
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0))
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0)
"@typescript-eslint/scope-manager": 7.13.1
"@typescript-eslint/types": 7.13.1
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
eslint: 8.57.0(supports-color@7.2.0)
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
eslint: 8.57.0
transitivePeerDependencies:
- supports-color
- typescript
@@ -4519,25 +4503,25 @@ snapshots:
"@ungap/structured-clone@1.3.3": {}
"@vitejs/plugin-react@4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))":
"@vitejs/plugin-react@4.3.1(vite@5.3.1(@types/node@20.19.43))":
dependencies:
"@babel/core": 7.29.7(supports-color@7.2.0)
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
"@babel/core": 7.29.7
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7)
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7)
"@types/babel__core": 7.20.5
react-refresh: 0.14.2
vite: 5.3.1(@types/node@20.19.43)
transitivePeerDependencies:
- supports-color
"@vitest/coverage-v8@1.6.1(supports-color@7.2.0)(vitest@1.6.0)":
"@vitest/coverage-v8@1.6.1(vitest@1.6.0)":
dependencies:
"@ampproject/remapping": 2.3.0
"@bcoe/v8-coverage": 0.2.3
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0)
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.5
magicast: 0.3.3
@@ -4545,7 +4529,7 @@ snapshots:
std-env: 3.5.0
strip-literal: 2.1.1
test-exclude: 6.0.0
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
transitivePeerDependencies:
- supports-color
@@ -4580,7 +4564,7 @@ snapshots:
pathe: 1.1.2
picocolors: 1.1.1
sirv: 2.0.4
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
"@vitest/utils@1.6.0":
dependencies:
@@ -4599,21 +4583,21 @@ snapshots:
acorn@8.17.0: {}
agent-base@6.0.0(supports-color@7.2.0):
agent-base@6.0.0:
dependencies:
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
transitivePeerDependencies:
- supports-color
agent-base@7.0.2(supports-color@7.2.0):
agent-base@7.0.2:
dependencies:
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
transitivePeerDependencies:
- supports-color
agent-base@7.1.0(supports-color@7.2.0):
agent-base@7.1.0:
dependencies:
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -4648,7 +4632,7 @@ snapshots:
"@rc-component/trigger": 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
classnames: 2.5.1
copy-to-clipboard: 3.3.3
dayjs: 1.11.23
dayjs: 1.11.21
rc-cascader: 3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-checkbox: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-collapse: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4664,7 +4648,7 @@ snapshots:
rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-picker: 4.11.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-picker: 4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4704,11 +4688,11 @@ snapshots:
asynckit@0.4.0: {}
axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0):
axios@1.18.1:
dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
follow-redirects: 1.16.0
form-data: 4.0.6
https-proxy-agent: 5.0.1(supports-color@7.2.0)
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
@@ -4812,13 +4796,11 @@ snapshots:
whatwg-mimetype: 4.0.0
whatwg-url: 14.0.0
dayjs@1.11.23: {}
dayjs@1.11.21: {}
debug@4.4.3(supports-color@7.2.0):
debug@4.4.3:
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
decimal.js@10.6.0: {}
@@ -4901,17 +4883,17 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@9.1.2(eslint@8.57.0(supports-color@7.2.0)):
eslint-config-prettier@9.1.2(eslint@8.57.0):
dependencies:
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
eslint-plugin-react-hooks@4.6.2(eslint@8.57.0(supports-color@7.2.0)):
eslint-plugin-react-hooks@4.6.2(eslint@8.57.0):
dependencies:
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
eslint-plugin-react-refresh@0.4.26(eslint@8.57.0(supports-color@7.2.0)):
eslint-plugin-react-refresh@0.4.26(eslint@8.57.0):
dependencies:
eslint: 8.57.0(supports-color@7.2.0)
eslint: 8.57.0
eslint-scope@7.2.2:
dependencies:
@@ -4920,20 +4902,20 @@ snapshots:
eslint-visitor-keys@3.4.3: {}
eslint@8.57.0(supports-color@7.2.0):
eslint@8.57.0:
dependencies:
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0))
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0)
"@eslint-community/regexpp": 4.12.2
"@eslint/eslintrc": 2.1.4(supports-color@7.2.0)
"@eslint/eslintrc": 2.1.4
"@eslint/js": 8.57.0
"@humanwhocodes/config-array": 0.11.14(supports-color@7.2.0)
"@humanwhocodes/config-array": 0.11.14
"@humanwhocodes/module-importer": 1.0.1
"@nodelib/fs.walk": 1.2.8
"@ungap/structured-clone": 1.3.3
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -5037,9 +5019,7 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)):
optionalDependencies:
debug: 4.4.3(supports-color@7.2.0)
follow-redirects@1.16.0: {}
form-data@4.0.6:
dependencies:
@@ -5135,24 +5115,24 @@ snapshots:
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2(supports-color@7.2.0):
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.0(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
agent-base: 7.1.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@5.0.1(supports-color@7.2.0):
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.0(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
agent-base: 6.0.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.4(supports-color@7.2.0):
https-proxy-agent@7.0.4:
dependencies:
agent-base: 7.0.2(supports-color@7.2.0)
debug: 4.4.3(supports-color@7.2.0)
agent-base: 7.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -5206,10 +5186,10 @@ snapshots:
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6(supports-color@7.2.0):
istanbul-lib-source-maps@5.0.6:
dependencies:
"@jridgewell/trace-mapping": 0.3.31
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -5229,15 +5209,15 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@24.1.0(supports-color@7.2.0):
jsdom@24.1.0:
dependencies:
cssstyle: 4.0.1
data-urls: 5.0.0
decimal.js: 10.6.0
form-data: 4.0.6
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2(supports-color@7.2.0)
https-proxy-agent: 7.0.4(supports-color@7.2.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.4
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.24
parse5: 7.1.2
@@ -5351,8 +5331,6 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
mp4box@2.4.1: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -5633,7 +5611,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
rc-picker@4.11.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
rc-picker@4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
"@babel/runtime": 7.29.7
"@rc-component/trigger": 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -5644,7 +5622,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
dayjs: 1.11.23
dayjs: 1.11.21
rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@@ -6030,10 +6008,10 @@ snapshots:
dependencies:
react: 18.3.1
vite-node@1.6.0(@types/node@20.19.43)(supports-color@7.2.0):
vite-node@1.6.0(@types/node@20.19.43):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.3.1(@types/node@20.19.43)
@@ -6056,7 +6034,7 @@ snapshots:
"@types/node": 20.19.43
fsevents: 2.3.3
vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0):
vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0):
dependencies:
"@vitest/expect": 1.6.0
"@vitest/runner": 1.6.0
@@ -6065,7 +6043,7 @@ snapshots:
"@vitest/utils": 1.6.0
acorn-walk: 8.3.5
chai: 4.3.10
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
execa: 8.0.1
local-pkg: 0.5.0
magic-string: 0.30.5
@@ -6076,12 +6054,12 @@ snapshots:
tinybench: 2.5.1
tinypool: 0.8.3
vite: 5.3.1(@types/node@20.19.43)
vite-node: 1.6.0(@types/node@20.19.43)(supports-color@7.2.0)
vite-node: 1.6.0(@types/node@20.19.43)
why-is-node-running: 2.3.0
optionalDependencies:
"@types/node": 20.19.43
"@vitest/ui": 1.6.0(vitest@1.6.0)
jsdom: 24.1.0(supports-color@7.2.0)
jsdom: 24.1.0
transitivePeerDependencies:
- less
- lightningcss
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

+1 -1
View File
@@ -11,7 +11,7 @@ import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh
// 创建 Axios 实例
const apiClient = axios.create({
baseURL: "/api/v1",
timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
+9 -2
View File
@@ -3,7 +3,7 @@
* 后端路由: /api/v1/cover-templates
*/
import apiClient from "./client"
import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover"
import type { CoverTemplate } from "@/pages/generate/types/cover"
export interface CoverTemplateListResponse {
items: CoverTemplate[]
@@ -12,7 +12,14 @@ export interface CoverTemplateListResponse {
export interface CoverTemplateCreateRequest {
name: string
config?: CoverEditorConfig
config?: {
background_enabled?: boolean
background_color?: string
portrait_enabled?: boolean
title_text?: string
subtitle_text?: string
mask_enabled?: boolean
}
}
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
+2 -13
View File
@@ -7,19 +7,8 @@ export interface GenerateCoverTitleConfig {
font_color?: string
position?: string
bold?: boolean
italic?: boolean
stroke?: boolean | { enabled?: boolean; width?: number; color?: string }
shadow?:
| boolean
| { enabled?: boolean; offset_x?: number; offset_y?: number; blur?: number; color?: string }
line_height?: number
margin_top?: number
max_chars_per_line?: number
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
line_overrides?: Array<Record<string, unknown>>
cover_title_config?: Record<string, unknown>
pos_x?: number
pos_y?: number
stroke?: boolean
shadow?: boolean
}
export interface GenerateCoverRequest {
-37
View File
@@ -1,37 +0,0 @@
import apiClient from "../client"
/** #2024 Step5 「完成」入库 —— 将 awaiting_cover 任务正式写入成品库 */
export interface FinalizeGenerationRequest {
/** 用户选定的封面图片 URL;为空则使用任务默认封面(自动截帧/智能封面) */
cover_url?: string
/** 用户自定义成片标题,非空时覆盖 rendered_output.name */
custom_title?: string
}
export interface FinalizeGenerationResponse {
video_id: string
project_id: string
name: string
file_size: number
duration: number
thumbnail_url: string
cover_url: string
file_url: string
width: number
height: number
fps: number
/** success=新建成功;already_finalized=幂等返回已有记录 */
status: string
is_duplicate: boolean
}
export const finalizeGeneration = async (
taskId: string,
params: FinalizeGenerationRequest = {},
): Promise<FinalizeGenerationResponse> => {
const response = await apiClient.post<FinalizeGenerationResponse>(
`/generation/tasks/${taskId}/finalize`,
params,
)
return response.data
}
+3 -29
View File
@@ -81,7 +81,7 @@ export interface CreateGenerationTaskRequest {
tts_voice_source?: "preset" | "clone"
/** #1970:智能降重开关(默认 true) */
dedup_enabled?: boolean
/** 标题烧录配置(#2001 扩展:描边/阴影参数/行距/自动换行/背景/逐行/封面) */
/** 标题烧录配置 */
title_config?: {
text?: string
font?: string
@@ -89,34 +89,8 @@ export interface CreateGenerationTaskRequest {
font_color?: string
position?: string
bold?: boolean
italic?: boolean
stroke?: boolean | { enabled?: boolean; width?: number; color?: string }
shadow?:
| boolean
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
line_height?: number
margin_top?: number
max_chars_per_line?: number
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
line_overrides?: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
cover_title_config?: Record<string, unknown>
pos_x?: number
pos_y?: number
stroke?: boolean
shadow?: boolean
}
/** 关联的草稿 ID(编辑流程数据链路用) */
source_edit_plan_id?: string
@@ -6,20 +6,17 @@ import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`, { timeout: 30_000 })
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消;超时 60s 防止大 config 写入失败) */
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
signal?: AbortSignal,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data, {
signal,
timeout: 60_000,
})
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
return response.data
}
@@ -54,8 +54,6 @@ export interface SegmentTtsConfig {
pitch: number
volume: number
subtitle_sync: boolean
/** 配音风格预设(natural/excited/professional/sweet/news/livestream) */
style?: string
}
/** 片段裁剪配置 */
-3
View File
@@ -18,9 +18,6 @@ export type {
TTSPreviewResponse,
} from "./types"
export type { TtsStyle, TtsStyleOption } from "./styles"
export { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, getTtsStyle } from "./styles"
// API 函数
export {
synthesizeSpeech,
-71
View File
@@ -1,71 +0,0 @@
/**
* TTS 配音风格预设(情感/语气风格)
* - key:传给后端的 style 标识,便于后端按策略合成
* - 未传 style 时后端默认自然亲切
*
* 注:与原 emotion(CosyVoice 7 种基础情绪枚举)解耦;
* style 是更高层的"说话风格预设",后端可能映射到 emotion + speed + prompt 组合。
*/
export interface TtsStyleOption {
/** 传给后端的风格标识 */
value: string
/** 展示名 */
label: string
/** emoji 图标 */
emoji: string
/** 给用户/后端的风格描述(prompt 风格) */
description: string
}
export const TTS_STYLE_OPTIONS: readonly TtsStyleOption[] = [
{
value: "natural",
label: "自然亲切",
emoji: "😊",
description: "亲切自然,像朋友聊天",
},
{
value: "excited",
label: "激动兴奋",
emoji: "🤩",
description: "激动兴奋,语速稍快,充满活力",
},
{
value: "professional",
label: "沉稳专业",
emoji: "🧑‍💼",
description: "沉稳专业,语速适中,正式可靠",
},
{
value: "sweet",
label: "温柔甜美",
emoji: "🌸",
description: "温柔甜美,语速轻柔",
},
{
value: "news",
label: "新闻播报",
emoji: "📰",
description: "字正腔圆,严肃正式",
},
{
value: "livestream",
label: "直播带货",
emoji: "🎤",
description: "热情有感染力,有节奏感",
},
] as const
export type TtsStyle = (typeof TTS_STYLE_OPTIONS)[number]["value"]
/** 默认风格:自然亲切 */
export const DEFAULT_TTS_STYLE: TtsStyle = "natural"
/** 根据 value 查找风格选项(容错:找不到回退 natural) */
export function getTtsStyle(value: string | null | undefined): TtsStyleOption {
return (
(TTS_STYLE_OPTIONS as readonly TtsStyleOption[]).find((o) => o.value === value) ??
(TTS_STYLE_OPTIONS as readonly TtsStyleOption[])[0]
)
}
-4
View File
@@ -17,8 +17,6 @@ export interface TTSSynthesizeRequest {
output_name?: string
language?: string
emotion?: string
/** 配音风格预设(自然亲切/激动兴奋/沉稳专业/温柔甜美/新闻播报/直播带货),不传默认 natural */
style?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
@@ -108,8 +106,6 @@ export interface TTSPreviewRequest {
pitch?: number
language?: string
emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签)
/** 配音风格预设 */
style?: string
}
/** TTS 试听响应 */
@@ -17,7 +17,6 @@ import {
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import { usePointsStore } from "@/store/pointsStore"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import "./PointsBadge.css"
const { Text, Paragraph } = Typography
@@ -33,13 +32,9 @@ const PointsBadge: React.FC = () => {
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
useEffect(() => {
if (!ENABLE_CREDIT_SYSTEM) return
if (!balance) init()
}, [balance, init])
// 功能开关:积分系统关闭时直接隐藏徽章
if (!ENABLE_CREDIT_SYSTEM) return null
// 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance
const bal = membership?.points_balance ?? balance?.balance ?? 0
const lowBalance = bal > 0 && bal < 10
@@ -15,7 +15,6 @@ import React, { useMemo } from "react"
import { Tooltip } from "antd"
import { WarningOutlined } from "@ant-design/icons"
import { usePointsStore } from "@/store/pointsStore"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import type { PointsSource } from "@/api/points/types"
import "./PointsCost.css"
@@ -54,7 +53,7 @@ const PointsCost: React.FC<Props> = ({
compact = false,
showRechargeHint = true,
className = "",
}: Props) => {
}) => {
const { balance, dailyUsage, rules, membership } = usePointsStore()
const qty = quantity ?? units ?? 1
@@ -119,9 +118,6 @@ const PointsCost: React.FC<Props> = ({
}
}, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes])
// 积分系统关闭时不展示消耗提示(组件保留,hooks 必须在 return 前调用)
if (!ENABLE_CREDIT_SYSTEM) return null
if (!rule || !balance) {
return <span className={`xx-points-cost ${className}`} />
}
-563
View File
@@ -1,563 +0,0 @@
/**
* 共享封面编辑器样式(智能剪辑 generate + AI数字人 ai-avatar 共用)
* #2033:从 generate.css 抽取 xx-ce-* / xx-cover-template-* / xx-cover-modal-* 规则
*/
.xx-cover-modal-toolbar {
display: flex;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.xx-cover-template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.xx-cover-template-card {
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s;
}
.xx-cover-template-card:hover {
border-color: var(--primary-color);
}
.xx-cover-template-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.xx-cover-template-thumb {
aspect-ratio: 9/16;
background: linear-gradient(135deg, #f0f0f0, #e0e0e0);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: #ccc;
position: relative;
}
.xx-cover-template-info {
padding: 8px;
}
.xx-cover-template-name {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.xx-cover-template-badge {
font-size: 11px;
color: #7c3aed;
background: rgba(124, 58, 237, 0.1);
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
.xx-cover-template-date {
font-size: 11px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.xx-cover-template-actions {
display: flex;
gap: 4px;
}
.xx-ce-header {
margin-bottom: 16px;
}
.xx-ce-name-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-sm, 6px);
font-size: 14px;
margin-bottom: 12px;
outline: none;
}
.xx-ce-name-input:focus {
border-color: #7c3aed;
}
.xx-ce-header-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.xx-ce-layout {
display: flex;
gap: 20px;
min-height: 500px;
}
.xx-ce-left {
width: 300px;
flex-shrink: 0;
max-height: 70vh;
overflow-y: auto;
}
.xx-ce-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
min-height: 480px;
}
.xx-ce-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
}
.xx-ce-section-header {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4ff;
user-select: none;
}
.xx-ce-section-header:hover {
background: #e8edf8;
}
.xx-ce-section-body {
padding: 12px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.xx-ce-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-status-text {
font-size: 11px;
font-weight: 400;
color: #3b82f6;
}
.xx-ce-row {
margin: 12px 0;
}
.xx-ce-label {
display: block;
font-size: 12px;
color: #374151;
margin-bottom: 4px;
}
.xx-ce-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.xx-ce-sub-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.xx-ce-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-ce-switch-item {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.xx-ce-switch-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.xx-ce-color-picker {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-color-picker input[type="color"] {
width: 32px;
height: 24px;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
background: none;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.xx-ce-color-hex {
width: 70px;
padding: 2px 6px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
.xx-ce-position {
display: flex;
gap: 8px;
}
.xx-ce-position .ant-input-number {
flex: 1;
}
.xx-ce-radio-group {
display: flex;
gap: 0;
}
.xx-ce-radio-btn {
padding: 4px 14px;
font-size: 12px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
.xx-ce-radio-btn:first-child {
border-radius: 4px 0 0 4px;
}
.xx-ce-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
.xx-ce-radio-btn + .xx-ce-radio-btn {
border-left: none;
}
.xx-ce-radio-btn.active {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
border-left: 1px solid #d1d5db;
}
.xx-ce-font-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.xx-ce-font-dot--preset {
background: #10b981;
}
.xx-ce-font-dot--system {
background: #3b82f6;
}
.xx-ce-shadow-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.xx-ce-add-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-add-shadow-btn:hover {
background: #6d28d9;
}
.xx-ce-preset-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-text-bg-section {
margin-top: 8px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
.xx-ce-readonly-text {
padding: 6px 10px;
background: #eff6ff;
border-radius: 4px;
font-size: 13px;
color: #1e40af;
margin-bottom: 4px;
}
.xx-ce-file-row {
display: flex;
gap: 6px;
align-items: center;
}
.xx-ce-file-name {
flex: 1;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: #f9fafb;
color: #6b7280;
}
.xx-ce-file-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.xx-ce-file-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.xx-ce-canvas-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.xx-ce-canvas {
width: 225px;
height: 400px;
background: #ddd;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.xx-ce-anchor-dot {
position: absolute;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
z-index: 5;
}
.xx-ce-el-portrait {
position: absolute;
background: #a8d4f0;
border: 2px solid #333;
z-index: 2;
}
.xx-ce-handle {
position: absolute;
width: 8px;
height: 8px;
background: #3b82f6;
border: 1px solid #fff;
z-index: 10;
}
.xx-ce-handle--0 {
top: -4px;
left: -4px;
}
.xx-ce-handle--1 {
top: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--2 {
top: -4px;
right: -4px;
}
.xx-ce-handle--3 {
top: 50%;
right: -4px;
margin-top: -4px;
}
.xx-ce-handle--4 {
bottom: -4px;
right: -4px;
}
.xx-ce-handle--5 {
bottom: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--6 {
bottom: -4px;
left: -4px;
}
.xx-ce-handle--7 {
top: 50%;
left: -4px;
margin-top: -4px;
}
.xx-ce-el-bg {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
.xx-ce-el-mask {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
.xx-ce-text-bg {
position: absolute;
z-index: -1;
}
.xx-cover-template-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
background: #7c3aed;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
z-index: 2;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
.xx-cover-template-thumb {
position: relative;
}
.xx-ce-preview-tip {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: #6b7280;
}
.xx-ce-canvas {
background: #1a1a2e;
}
.xx-ce-section-body .ant-slider {
margin: 4px 0 8px;
}
.xx-ce-section-body .ant-slider-rail {
background: #e5e7eb;
}
.xx-ce-section-body .ant-slider-track {
background: #3b82f6;
}
.xx-ce-section-body .ant-slider-handle::after {
box-shadow: 0 0 0 2px #3b82f6;
}
.xx-ce-section-body .ant-slider-mark-text {
font-size: 10px;
}
.xx-ce-font-select-dropdown .ant-select-item-option-content {
display: flex;
align-items: center;
}
.xx-ce-canvas > div {
pointer-events: none;
}
/* Text panel wrapper */
.xx-ce-text-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Canvas base gradient layer (behind all elements) */
.xx-ce-canvas-base {
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%);
}
-2
View File
@@ -1,2 +0,0 @@
export { useSharedCover } from "./useSharedCover"
export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover"
@@ -1,226 +0,0 @@
/**
* 共享封面选择 Hook(供智能剪辑 generate 与 AI 数字人 ai-avatar 共同使用)
*
* 能力:
* - 封面模板列表加载 / 选择 / 创建 / 编辑 / 删除(调用 /cover-templates 接口)
* - 自动生成封面按钮点击 → 调用调用方传入的 generateFn
* - 封面编辑器弹窗状态
* - 本地封面上传文件选择
*/
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { message } from "antd"
import type { CoverTemplate } from "@/pages/generate/types/cover"
import {
fetchCoverTemplates,
createCoverTemplate,
updateCoverTemplate,
deleteCoverTemplate,
} from "@/api/cover-templates"
export interface UseSharedCoverOptions {
canGenerate: boolean
disabledHint?: string
generateFn: (templateId: string) => Promise<string | null | undefined>
initialTemplateId?: string
}
export interface UseSharedCoverReturn {
templates: CoverTemplate[]
templatesLoading: boolean
templatesError: string | null
selectedTemplateId: string
selectedTemplateName: string
handleSelectTemplate: (id: string) => void
reloadTemplates: () => void
showCoverSettings: boolean
setShowCoverSettings: (v: boolean) => void
showCoverEditor: boolean
setShowCoverEditor: (v: boolean) => void
editingTemplate: CoverTemplate | null
handleEditTemplate: (tpl: CoverTemplate) => void
handleCreateTemplate: () => void
handleSaveTemplate: (tpl: CoverTemplate) => Promise<void>
handleDeleteTemplate: (id: string) => Promise<void>
generating: boolean
generateAutoCover: () => Promise<void>
uploadInputRef: React.RefObject<HTMLInputElement>
handleUploadClick: () => void
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
setOnUploadFile: (fn: (file: File) => Promise<string | null> | string | null) => void
}
export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverReturn {
const { canGenerate, disabledHint, generateFn, initialTemplateId = "default" } = opts
const [generating, setGenerating] = useState(false)
const [showCoverSettings, setShowCoverSettings] = useState(false)
const [showCoverEditor, setShowCoverEditor] = useState(false)
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(initialTemplateId)
const [editingTemplate, setEditingTemplate] = useState<CoverTemplate | null>(null)
const [templates, setTemplates] = useState<CoverTemplate[]>([])
const [templatesLoading, setTemplatesLoading] = useState(false)
const [templatesError, setTemplatesError] = useState<string | null>(null)
const uploadInputRef = useRef<HTMLInputElement>(null)
const onUploadFileRef = useRef<
((file: File) => Promise<string | null> | string | null) | undefined
>(undefined)
const setOnUploadFile = useCallback(
(fn: (file: File) => Promise<string | null> | string | null) => {
onUploadFileRef.current = fn
},
[],
)
const reloadTemplates = useCallback(async () => {
setTemplatesLoading(true)
setTemplatesError(null)
try {
const res = await fetchCoverTemplates()
// 兼容两种响应:{items:[...]} 或直接数组
const rawList = (res as unknown as { items?: CoverTemplate[] }).items ?? []
// 确保每个模板都有 config 字段(避免编辑器打开时访问 cfg.title.text 崩溃)
const list: CoverTemplate[] = rawList.map((t) => ({
...t,
config: t.config,
}))
setTemplates(list)
} catch (err) {
console.error("[SharedCover] 加载封面模板失败:", err)
setTemplatesError("加载模板失败,请稍后重试")
} finally {
setTemplatesLoading(false)
}
}, [])
useEffect(() => {
if (showCoverSettings) {
void reloadTemplates()
}
}, [showCoverSettings, reloadTemplates])
const handleSelectTemplate = useCallback((id: string) => {
setSelectedTemplateId(id)
}, [])
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
setEditingTemplate(tpl)
setShowCoverEditor(true)
}, [])
const handleCreateTemplate = useCallback(() => {
setEditingTemplate(null)
setShowCoverEditor(true)
}, [])
const handleSaveTemplate = useCallback(
async (tpl: CoverTemplate) => {
try {
if (tpl.id && templates.some((t) => t.id === tpl.id)) {
const updated = await updateCoverTemplate(tpl.id, { name: tpl.name, config: tpl.config })
setTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
} else {
const created = await createCoverTemplate({ name: tpl.name, config: tpl.config })
setTemplates((prev) => [...prev, created])
setSelectedTemplateId(created.id || tpl.id)
}
setShowCoverEditor(false)
} catch (err) {
console.error("[SharedCover] 保存模板失败:", err)
message.error("保存模板失败,请稍后重试")
}
},
[templates],
)
const handleDeleteTemplate = useCallback(
async (id: string) => {
try {
await deleteCoverTemplate(id)
setTemplates((prev) => prev.filter((t) => t.id !== id))
if (selectedTemplateId === id) {
setSelectedTemplateId("default")
}
} catch (err) {
console.error("[SharedCover] 删除模板失败:", err)
message.error("删除模板失败,请稍后重试")
}
},
[selectedTemplateId],
)
const generateAutoCover = useCallback(async () => {
if (generating) {
message.warning("封面正在生成中,请稍候…")
return
}
if (!canGenerate) {
if (disabledHint) message.warning(disabledHint)
return
}
setGenerating(true)
try {
const url = await generateFn(selectedTemplateId || "default")
if (!url) {
message.warning("封面生成未返回图片,请重试")
}
} catch (err) {
console.error("[SharedCover] 自动生成封面失败:", err)
const anyErr = err as { __msgShown?: boolean; message?: string }
if (!anyErr?.__msgShown) {
message.error(anyErr?.message || "封面生成失败")
}
} finally {
setGenerating(false)
}
}, [generating, canGenerate, disabledHint, generateFn, selectedTemplateId])
const handleUploadClick = useCallback(() => {
uploadInputRef.current?.click()
}, [])
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (!file) return
if (onUploadFileRef.current) {
const ret = onUploadFileRef.current(file)
if (ret instanceof Promise) {
ret.catch((err) => {
console.error("[SharedCover] 上传封面失败:", err)
})
}
}
}, [])
const selectedTemplateName =
templates.find((t) => t.id === selectedTemplateId)?.name ||
(selectedTemplateId === "default" ? "默认模板" : "自定义")
return {
templates,
templatesLoading,
templatesError,
selectedTemplateId,
selectedTemplateName,
handleSelectTemplate,
reloadTemplates,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
setShowCoverEditor,
editingTemplate,
handleEditTemplate,
handleCreateTemplate,
handleSaveTemplate,
handleDeleteTemplate,
generating,
generateAutoCover,
uploadInputRef,
handleUploadClick,
handleFileInputChange,
setOnUploadFile,
}
}
export default useSharedCover
+25 -38
View File
@@ -21,7 +21,6 @@ import { useLogout } from "@/hooks/useAuth"
import type { MenuProps } from "antd"
import { NAV_ITEMS } from "@/config/navigation"
import PointsBadge from "@/components/common/PointsBadge"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import { usePointsStore } from "@/store/pointsStore"
import "./Header.css"
@@ -58,36 +57,30 @@ const Header: React.FC = () => {
label: "订阅管理",
onClick: () => navigate("/app/subscription"),
},
// 积分系统开关关闭时隐藏积分相关菜单项(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points-center",
icon: <ThunderboltOutlined />,
label: (
<Space>
我的积分
{balance && (
<span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>
)}
</Space>
),
onClick: () => navigate("/app/points"),
},
{
key: "points-history",
icon: <HistoryOutlined />,
label: "积分明细",
onClick: () => navigate("/app/points/transactions"),
},
{
key: "recharge",
icon: <WalletOutlined />,
label: "充值积分",
onClick: () => navigate("/app/points/recharge"),
},
]
: []),
// v2: 我的积分入口
{
key: "points-center",
icon: <ThunderboltOutlined />,
label: (
<Space>
我的积分
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
</Space>
),
onClick: () => navigate("/app/points"),
},
{
key: "points-history",
icon: <HistoryOutlined />,
label: "积分明细",
onClick: () => navigate("/app/points/transactions"),
},
{
key: "recharge",
icon: <WalletOutlined />,
label: "充值积分",
onClick: () => navigate("/app/points/recharge"),
},
{ type: "divider" },
{
key: "logout",
@@ -137,13 +130,7 @@ const Header: React.FC = () => {
{/* v2: 升级会员入口(仅免费用户显示) */}
{!isMember && (
<Tooltip
title={
ENABLE_CREDIT_SYSTEM
? "升级会员解锁无限混剪、批量导出,积分 8 折起"
: "升级会员解锁无限混剪、批量导出"
}
>
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
<Button
type="primary"
size="small"
@@ -1,391 +0,0 @@
/* ============================================================
标题模板系统 v3(按 sketch 重构)
- 大卡片网格(图片背景 + 透明 Canvas 叠字 + 始终可见操作按钮)
- 编辑器弹窗(左竖屏预览 + 右参数 Tab)
============================================================ */
/* ── 面板容器(模板模式) ── */
.ttv3-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.ttv3-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.ttv3-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary, #1f2937);
}
.ttv3-new-btn.ant-btn {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
border: none;
border-radius: 20px;
font-size: 12px;
height: 28px;
padding: 0 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(108, 92, 231, 0.25);
}
.ttv3-new-btn.ant-btn:hover {
background: linear-gradient(135deg, #5b4cdb, #8c83f5) !important;
color: #fff !important;
}
.ttv3-section-label {
font-size: 12px;
color: #888;
font-weight: 500;
margin-bottom: 8px;
}
.ttv3-section {
margin-bottom: 6px;
}
/* ── 空状态 ── */
.ttv3-empty {
background: #f8f8fc;
border-radius: 12px;
padding: 28px 16px;
text-align: center;
color: #aaa;
}
.ttv3-empty-icon {
font-size: 28px;
margin-bottom: 6px;
}
.ttv3-empty-text {
font-size: 12px;
}
/* ── 卡片网格:minmax(180px,1fr) 自适应 ── */
.ttv3-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
/* ── 卡片:3:4 竖版,圆角 14px ── */
.ttv3-card {
background: #fff;
border-radius: 14px;
border: 3px solid #e8e8ed;
cursor: pointer;
transition: all 0.15s ease;
overflow: hidden;
padding: 0;
font-family: inherit;
text-align: left;
}
.ttv3-card:hover {
border-color: #c5c0f0;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
}
.ttv3-card.selected {
border-color: #6c5ce7;
box-shadow: 0 4px 16px rgba(108, 92, 231, 0.25);
}
/* ── 卡片预览区(3:4) ── */
.ttv3-preview {
width: 100%;
aspect-ratio: 3/4;
position: relative;
overflow: hidden;
border-radius: 11px 11px 0 0;
}
.ttv3-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 暗色渐变遮罩:顶部15%半透明黑 + 中部透明 + 底部45%黑 */
.ttv3-vignette {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.45) 0%,
rgba(0, 0, 0, 0.15) 15%,
transparent 30%,
transparent 55%,
rgba(0, 0, 0, 0.6) 100%
);
z-index: 1;
pointer-events: none;
}
/* 透明 Canvas 标题填充整个预览区 */
.ttv3-preview .tt-fill-canvas-wrap {
position: absolute;
inset: 0;
z-index: 2;
}
.ttv3-preview .tt-fill-canvas-wrap canvas {
width: 100% !important;
height: 100% !important;
border-radius: 0;
display: block;
}
/* ── 左上角角标(系统/我的) ── */
.ttv3-badge {
position: absolute;
top: 8px;
left: 8px;
font-size: 10px;
padding: 2px 8px;
border-radius: 8px;
font-weight: 500;
color: #fff;
z-index: 3;
backdrop-filter: blur(4px);
}
.ttv3-badge--sys {
background: rgba(108, 92, 231, 0.88);
}
.ttv3-badge--mine {
background: rgba(0, 184, 148, 0.88);
}
/* ── 右上角勾选圆圈 ── */
.ttv3-check {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
border: 2px solid rgba(255, 255, 255, 0.7);
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: transparent;
transition: all 0.15s;
}
.ttv3-check.on {
background: #6c5ce7;
border-color: #fff;
color: #fff;
}
/* ── 卡片底栏(名称 + 操作按钮) ── */
.ttv3-footer {
padding: 10px 10px 12px;
}
.ttv3-name-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
color: #1f2937;
}
.ttv3-emoji {
font-size: 14px;
flex-shrink: 0;
}
.ttv3-name {
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.ttv3-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 6px;
font-weight: 500;
flex-shrink: 0;
}
.ttv3-tag--sys {
background: #f0ecff;
color: #6c5ce7;
}
.ttv3-tag--mine {
background: #e6f9f4;
color: #00b894;
}
/* ── 操作按钮:始终可见,等宽排列 ── */
.ttv3-actions {
display: flex;
gap: 5px;
}
.ttv3-act {
flex: 1;
padding: 5px 0;
border: 1px solid #e8e8ed;
background: #fff;
border-radius: 7px;
font-size: 11px;
cursor: pointer;
color: #666;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
transition: all 0.15s;
font-family: inherit;
}
.ttv3-act:hover:not(:disabled) {
background: #f5f5fa;
border-color: #d5d3e8;
}
.ttv3-act--primary {
background: #6c5ce7;
color: #fff;
border-color: #6c5ce7;
}
.ttv3-act--primary:hover:not(:disabled) {
background: #5b4cdb;
border-color: #5b4cdb;
}
.ttv3-act--danger {
color: #e74c3c;
}
.ttv3-act--danger:hover:not(:disabled) {
background: #fef2f2;
}
.ttv3-act:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* ── FillPreview 公共容器 ── */
.tt-fill-canvas-wrap {
position: relative;
}
/* ── params-only(编辑器右侧)去掉多余 margin ── */
.ttv3-params-only {
padding: 0;
}
.ttv3-params-only .ant-tabs {
margin: 0;
}
/* ============================================================
编辑器 Modal(v3)
============================================================ */
.ttv3-modal .ant-modal-content {
border-radius: 16px;
overflow: hidden;
padding: 0;
}
.ttv3-modal .ant-modal-header {
padding: 16px 20px;
margin: 0;
border-bottom: 1px solid #f0f0f0;
}
.ttv3-modal .ant-modal-body {
padding: 0;
max-height: 75vh;
}
.ttv3-modal .ant-modal-footer {
padding: 14px 20px;
margin: 0;
border-top: 1px solid #f0f0f0;
}
/* 编辑器两栏布局 */
.ttv3-editor {
display: flex;
min-height: 500px;
}
.ttv3-editor-left {
width: 300px;
padding: 20px;
background: #f8f8fc;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
border-right: 1px solid #f0f0f0;
}
.ttv3-editor-canvas {
width: 200px;
aspect-ratio: 9/16;
border-radius: 12px;
overflow: hidden;
position: relative;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
flex-shrink: 0;
}
.ttv3-editor-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.ttv3-editor-canvas-inner {
position: absolute;
inset: 0;
z-index: 2;
}
.ttv3-editor-canvas-inner canvas {
width: 100% !important;
height: 100% !important;
border-radius: 0;
display: block;
}
.ttv3-editor-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.ttv3-form-row {
display: flex;
align-items: center;
gap: 10px;
}
.ttv3-form-row label {
font-size: 12px;
font-weight: 600;
color: #333;
white-space: nowrap;
min-width: 44px;
}
.ttv3-form-row--grow {
flex: 1;
}
.ttv3-form-row--grow .ant-input {
flex: 1;
}
.ttv3-editor-right {
flex: 1;
padding: 16px 20px;
overflow-y: auto;
min-width: 0;
}
/* 侧栏较窄时(380px 侧栏):强制 2 列,卡片稍微紧凑 */
@media (max-width: 540px) {
.ttv3-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.ttv3-act {
font-size: 10px;
padding: 4px 0;
}
.ttv3-act .anticon {
display: none;
}
}
@@ -1,174 +0,0 @@
/**
* 标题模板编辑器(v3 重构)
*
* - Modal 弹窗 860px 宽
* - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式
* - 底部:取消 / 保存模板 按钮
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理)
*/
import React, { useEffect, useMemo, useState } from "react"
import { Modal, Button, Input, message } from "antd"
import TitleStylePanel from "../../pages/generate/components/title/TitleStylePanel"
import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview"
import { POSITION_OPTIONS } from "../../pages/generate/constants"
import { FONT_OPTIONS } from "./constants"
import type { TitleSettings } from "../../pages/generate/types"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleConfig } from "./types"
interface Props {
open: boolean
template: TitleTemplate
onClose: () => void
/** 用户点击保存:将编辑结果回调给父组件(父组件统一做 CRUD,避免双 hook 实例不同步) */
onSave: (data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => void
}
/** 编辑器预览用的背景图(复用卡片池第一张) */
const EDITOR_BG = "/title-templates/portrait1.jpg"
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
const [settings, setSettings] = useState<TitleSettings>(() => ({
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
title: "预览标题文字",
}))
const [formName, setFormName] = useState(template.name || "")
const [formEmoji, setFormEmoji] = useState(template.emoji || "✨")
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open) {
setSettings({
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
title: "预览标题文字",
})
setFormName(template.name || "")
setFormEmoji(template.emoji || "✨")
}
}, [open, template])
const upd = (patch: Partial<TitleSettings>) => setSettings((s) => ({ ...s, ...patch }))
const handleSave = () => {
const name = formName.trim()
if (!name) {
message.warning("请填写模板名称")
return
}
setSaving(true)
try {
const snake = camelToTitleStyleConfig(settings)
onSave({ name, emoji: formEmoji, style: snake })
} finally {
setSaving(false)
}
}
// 编辑器内的预览用 settings:字号适配竖屏
const previewSettings = useMemo<TitleSettings>(() => {
// 竖屏宽度 200px,按比例缩放字号,让预览看起来协调
return { ...settings, size: Math.round(settings.size * 0.55) }
}, [settings])
return (
<Modal
title={
!template.id
? "新建模板"
: template.isBuiltin
? `基于「${template.name}」创建模板`
: `编辑模板:${template.name}`
}
open={open}
onCancel={onClose}
width={860}
footer={
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" loading={saving} onClick={handleSave}>
保存模板
</Button>
</div>
}
destroyOnClose
className="ttv3-modal"
styles={{ body: { padding: 0 } }}
>
<div className="ttv3-editor">
{/* 左侧:竖屏预览 + 名称 */}
<div className="ttv3-editor-left">
<div className="ttv3-editor-canvas">
<img className="ttv3-editor-bg" src={EDITOR_BG} alt="" />
<div className="ttv3-vignette" />
<div className="ttv3-editor-canvas-inner">
<TitleMiniPreview
settings={previewSettings}
width={200}
sampleText="预览标题文字"
transparent
portrait
/>
</div>
</div>
<div className="ttv3-editor-form">
<div className="ttv3-form-row">
<label>图标</label>
<Input
value={formEmoji}
maxLength={2}
style={{ textAlign: "center", width: 64 }}
onChange={(e) => setFormEmoji(e.target.value)}
/>
</div>
<div className="ttv3-form-row ttv3-form-row--grow">
<label>
模板名称<span style={{ color: "#ef4444" }}>*</span>
</label>
<Input
placeholder="给模板起个名字"
value={formName}
maxLength={20}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
</div>
</div>
{/* 右侧:参数 Tab */}
<div className="ttv3-editor-right">
<TitleStylePanel
settings={settings}
onUpdatePosition={(p) => upd({ position: p, posX: null, posY: null })}
onUpdateFont={(f) => upd({ font: f })}
onUpdateSize={(v) => upd({ size: v })}
onToggleBold={() => upd({ bold: !settings.bold })}
onToggleItalic={() => upd({ italic: !settings.italic })}
onToggleStroke={() =>
upd({
stroke: !settings.stroke,
strokeWidth:
!settings.stroke && (settings.strokeWidth ?? 0) < 2 ? 4 : settings.strokeWidth,
})
}
onToggleShadow={() => upd({ shadow: !settings.shadow })}
onApplyPreset={() => {
/* 编辑器内不使用系统预设快捷键 */
}}
onUpdateStyle={(patch) => upd(patch)}
activePreset={null}
titlePresets={[]}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
paramsOnly
/>
</div>
</div>
</Modal>
)
}
export default TitleTemplateEditor
-362
View File
@@ -1,362 +0,0 @@
/**
* 标题样式相关常量(#2001)
* - 字体列表(新增4款爆款字体)
* - 色板(常用标题字色/描边色/背景色)
* - 预设样式方案(10 个,含抖音爆款黄)
*/
import type { TitleStyleConfig } from "./types"
/* ── 字体选项(#2001:新增优设标题黑/阿里普惠体Bold/抖音美好体/思源黑体Heavy) ── */
export interface FontOption {
value: string
label: string
/** CSS font-family 栈 */
family: string
/** 爆款/常用标签 */
tag?: "hot" | "new"
}
export const FONT_OPTIONS: FontOption[] = [
{
value: "优设标题黑",
label: "优设标题黑",
family:
'"YouShe Title Black","YouSheBiaoTiHei","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "阿里普惠体Bold",
label: "阿里普惠体Bold",
family:
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "抖音美好体",
label: "抖音美好体",
family: '"Douyin Sans","DouyinSans","Source Han Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "思源黑体Heavy",
label: "思源黑体Heavy",
family:
'"Source Han Sans SC Heavy","Noto Sans SC","Source Han Sans CN Heavy","PingFang SC",sans-serif',
tag: "new",
},
{
value: "思源黑体",
label: "思源黑体",
family: '"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
},
{
value: "思源宋体",
label: "思源宋体",
family: '"Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif',
},
{
value: "苹方",
label: "苹方",
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
},
{
value: "微软雅黑",
label: "微软雅黑",
family: '"Microsoft YaHei","PingFang SC",sans-serif',
},
{
value: "楷体",
label: "楷体",
family: '"KaiTi","STKaiti","DFKai-SB",serif',
},
]
/** 根据中文名取 font-family 栈(找不到回退思源黑体) */
export function getFontFamily(font: string): string {
const f = FONT_OPTIONS.find((x) => x.value === font)
if (f) return f.family
return FONT_OPTIONS[4].family // 思源黑体
}
/* ── 色板 ── */
/** 标题字色(常用爆款色) */
export const TITLE_COLOR_PALETTE: string[] = [
"#ffffff",
"#000000",
"#ffd700", // 抖音黄
"#ff2d55", // 抖音红
"#ff4081",
"#00e5ff",
"#d4a843",
"#ffa500",
"#52c41a",
"#1890ff",
"#7c3aed",
"#ff6b35",
]
/** 描边色(黑/白/灰为主) */
export const STROKE_COLOR_PALETTE: string[] = [
"#000000",
"#ffffff",
"#333333",
"#555555",
"#8b0000",
"#001f3f",
]
/** 背景色(带透明度) */
export const BG_COLOR_PALETTE: string[] = [
"rgba(0,0,0,0.5)",
"rgba(0,0,0,0.7)",
"rgba(0,0,0,0.3)",
"rgba(255,215,0,0.9)",
"rgba(255,45,85,0.85)",
"rgba(124,58,237,0.85)",
"rgba(24,144,255,0.85)",
"rgba(82,196,26,0.85)",
]
/* ── 预设样式方案(10 个,含抖音爆款黄) ── */
export interface TitlePreset {
key: string
label: string
emoji: string
/** 应用时覆盖到 TitleStyleConfig 的字段(其他字段保持当前值) */
style: Partial<TitleStyleConfig>
}
const BASE: Partial<TitleStyleConfig> = {
position: "bottom",
line_overrides: [],
cover_title_config: null,
}
export const TITLE_PRESETS: TitlePreset[] = [
{
key: "douyin_hot",
label: "抖音爆款黄",
emoji: "🔥",
style: {
...BASE,
font: "优设标题黑",
size: 80,
color: "#ffd700",
bold: true,
italic: false,
stroke: true,
stroke_width: 8,
stroke_color: "#000000",
shadow: true,
shadow_offset_x: 3,
shadow_offset_y: 3,
shadow_blur: 6,
shadow_color: "rgba(0,0,0,0.6)",
bg_enabled: false,
line_height: 1.25,
max_chars_per_line: 8,
},
},
{
key: "classic_white",
label: "经典白字黑描边",
emoji: "⚪",
style: {
...BASE,
font: "思源黑体Heavy",
size: 56,
color: "#ffffff",
bold: true,
italic: false,
stroke: true,
stroke_width: 5,
stroke_color: "#000000",
shadow: false,
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 10,
},
},
{
key: "red_bold",
label: "醒目红字",
emoji: "🔴",
style: {
...BASE,
font: "优设标题黑",
size: 72,
color: "#ff2d55",
bold: true,
italic: false,
stroke: true,
stroke_width: 6,
stroke_color: "#ffffff",
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 5,
shadow_color: "rgba(0,0,0,0.5)",
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 9,
},
},
{
key: "black_gold",
label: "黑金质感",
emoji: "🟡",
style: {
...BASE,
font: "思源宋体",
size: 52,
color: "#d4a843",
bold: true,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 8,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
line_height: 1.25,
max_chars_per_line: 10,
},
},
{
key: "neon_blue",
label: "霓虹发光",
emoji: "💙",
style: {
...BASE,
font: "阿里普惠体Bold",
size: 60,
color: "#00e5ff",
bold: true,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 0,
shadow_offset_y: 0,
shadow_blur: 16,
shadow_color: "#00e5ff",
bg_enabled: false,
line_height: 1.2,
max_chars_per_line: 10,
},
},
{
key: "bg_black",
label: "黑底白字",
emoji: "⬛",
style: {
...BASE,
font: "思源黑体Heavy",
size: 52,
color: "#ffffff",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: true,
bg_color: "rgba(0,0,0,0.7)",
bg_padding: 16,
bg_radius: 8,
line_height: 1.3,
max_chars_per_line: 10,
},
},
{
key: "bg_yellow",
label: "黄底黑字",
emoji: "🟨",
style: {
...BASE,
font: "抖音美好体",
size: 56,
color: "#000000",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: true,
bg_color: "rgba(255,215,0,0.95)",
bg_padding: 14,
bg_radius: 6,
line_height: 1.2,
max_chars_per_line: 9,
},
},
{
key: "sweet_pink",
label: "温柔甜美粉",
emoji: "🌸",
style: {
...BASE,
font: "阿里普惠体Bold",
size: 50,
color: "#ff4081",
bold: false,
italic: false,
stroke: true,
stroke_width: 4,
stroke_color: "#ffffff",
shadow: true,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(255,64,129,0.4)",
bg_enabled: false,
line_height: 1.3,
max_chars_per_line: 11,
},
},
{
key: "business_dark",
label: "商务深色",
emoji: "💼",
style: {
...BASE,
font: "思源黑体",
size: 44,
color: "#ffffff",
bold: false,
italic: false,
stroke: false,
shadow: true,
shadow_offset_x: 1,
shadow_offset_y: 1,
shadow_blur: 3,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: true,
bg_color: "rgba(24,144,255,0.85)",
bg_padding: 12,
bg_radius: 4,
line_height: 1.3,
max_chars_per_line: 12,
},
},
{
key: "minimal_clean",
label: "极简无描边",
emoji: "✨",
style: {
...BASE,
font: "苹方",
size: 48,
color: "#ffffff",
bold: true,
italic: false,
stroke: false,
shadow: false,
bg_enabled: false,
line_height: 1.3,
max_chars_per_line: 10,
},
},
]
/** 根据 key 获取预设 */
export function getTitlePreset(key: string): TitlePreset | undefined {
return TITLE_PRESETS.find((p) => p.key === key)
}
@@ -1,28 +0,0 @@
/**
* 标题模板系统类型(#2003)
*
* 设计:
* - 内置系统模板(从 TITLE_PRESETS 自动派生,不可编辑/删除,可"复制为我的")
* - 用户自定义模板(保存在 localStorage,可编辑/复制/导出/删除)
* - 模板存完整 TitleStyleConfig(snake_case),与后端契约一致
*/
import type { TitleStyleConfig } from "./types"
export interface TitleTemplate {
/** 唯一 ID:系统模板 `sys:<presetKey>`,用户模板 `usr:<uuid>` */
id: string
/** 模板名称(必填,保存时校验) */
name: string
/** 模板描述(可选) */
description?: string
/** 是否为系统内置(不可删除/编辑源) */
isBuiltin: boolean
/** emoji(展示用,可选) */
emoji?: string
/** 创建时间(ISO 字符串,系统模板=固定值) */
createdAt: string
/** 更新时间 */
updatedAt: string
/** 完整样式配置(snake_case,与后端 title_config 对齐) */
style: Partial<TitleStyleConfig>
}
-118
View File
@@ -1,118 +0,0 @@
/**
* 共享标题样式配置类型(#2001 爆款标题样式配置面板升级)
*
* 设计原则:
* 1. 向后兼容:保留旧的 bold/stroke/shadow 布尔字段,新增细粒度字段
* (stroke_width/stroke_color/shadow_offset_x-y-blur-color/bg_enabled-color-padding-radius/line_height/margin_top/max_chars_per_line)。
* 2. 后端契约:字段名使用 snake_case,与 title_config dict 直接对齐。
* 3. line_overrides 支持逐行覆盖(选中某行单独设置颜色/字号/关键词高亮/加粗/斜体)。
* 4. cover_title_config 为封面独立标题样式,null 表示封面沿用主标题样式。
*/
/** 关键词高亮配置 */
export interface TitleKeywordHighlight {
/** 要高亮的词 */
word: string
/** 高亮颜色(可选,默认主色反转) */
color?: string
/** 是否加粗(默认 true) */
bold?: boolean
/** 额外字号放大倍数(1.0=不变,1.3=放大 30%) */
scale?: number
}
/** 单行覆盖配置 */
export interface TitleLineOverride {
/** 行索引(0-based,按 / 或自动换行后的行序) */
line_index: number
/** 覆盖后的文字(可选,默认沿用原行) */
text?: string
/** 覆盖字号(可选) */
size?: number
/** 覆盖字色(可选) */
color?: string
/** 覆盖加粗(可选) */
bold?: boolean
/** 覆盖斜体(可选) */
italic?: boolean
/** 覆盖描边开关(可选) */
stroke?: boolean
/** 关键词高亮列表 */
highlights?: TitleKeywordHighlight[]
}
/**
* 标题样式配置(不含 title 文字本身,不含 auto_subtitle)。
*
* cover_title_config 使用 Partial<Omit<...,"cover_title_config">> 递归避免无限类型。
*/
export interface TitleStyleConfig {
/* ── 基础 ── */
font: string
size: number
color: string
bold: boolean
italic: boolean
position: "top" | "center" | "bottom" | "custom"
pos_x?: number
pos_y?: number
/* ── 排版(P0) ── */
/** 行距倍数(默认 1.2) */
line_height: number
/** 顶部边距(position=top 时距画面顶部距离,px @720p,默认 24) */
margin_top: number
/** 每行最大字符数(4-20,超出自动换行;0=不自动换行,使用 / 手动分行) */
max_chars_per_line: number
/* ── 描边参数化(P0) ── */
stroke: boolean
stroke_width: number
stroke_color: string
/* ── 阴影参数化(P1) ── */
shadow: boolean
shadow_offset_x: number
shadow_offset_y: number
shadow_blur: number
shadow_color: string
/* ── 背景色块(P1) ── */
bg_enabled: boolean
bg_color: string
bg_padding: number
bg_radius: number
/* ── 逐行独立样式(P1) ── */
line_overrides: TitleLineOverride[]
/* ── 封面独立标题配置(P1):null=沿用主标题样式 ── */
cover_title_config: null | Partial<Omit<TitleStyleConfig, "cover_title_config">>
}
/** 默认样式(经典白字黑描边,保持老版本观感) */
export const DEFAULT_TITLE_STYLE: TitleStyleConfig = {
font: "思源黑体",
size: 48,
color: "#ffffff",
bold: true,
italic: false,
position: "bottom",
line_height: 1.2,
margin_top: 24,
max_chars_per_line: 0,
stroke: true,
stroke_width: 4,
stroke_color: "#000000",
shadow: false,
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
bg_color: "rgba(0,0,0,0.5)",
bg_padding: 12,
bg_radius: 8,
line_overrides: [],
cover_title_config: null,
}
@@ -1,213 +0,0 @@
/**
* 标题模板 CRUD Hook(#2003)
*
* - 内存态 + localStorage 持久化
* - 系统模板(来自 TITLE_PRESETS)始终前置、不可删除/编辑源
* - 用户模板 CRUD:新增/复制/更新/删除/导出/导入
*/
import { useCallback, useEffect, useMemo, useState } from "react"
import { TITLE_PRESETS } from "./constants"
import type { TitleStyleConfig } from "./types"
import type { TitleTemplate } from "./template-types"
const STORAGE_KEY = "xiaoxia.title.templates.v1"
function uid(): string {
return "usr:" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
}
/** 把 TITLE_PRESETS 转为内置 TitleTemplate 列表 */
function buildBuiltinTemplates(): TitleTemplate[] {
const now = "2026-09-22T00:00:00+08:00"
return TITLE_PRESETS.map((p) => ({
id: `sys:${p.key}`,
name: p.label,
description: "系统内置模板",
isBuiltin: true,
emoji: p.emoji,
createdAt: now,
updatedAt: now,
style: { ...p.style },
}))
}
function loadUserTemplates(): TitleTemplate[] {
if (typeof window === "undefined") return []
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((t) => t && !t.isBuiltin && t.id.startsWith("usr:"))
} catch {
return []
}
}
function saveUserTemplates(list: TitleTemplate[]) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
} catch {
/* ignore quota */
}
}
export interface UseTitleTemplatesResult {
templates: TitleTemplate[]
builtin: TitleTemplate[]
custom: TitleTemplate[]
/** 新建自定义模板:传入样式(snake_case),返回新建模板 id */
createTemplate: (input: {
name: string
description?: string
emoji?: string
style: Partial<TitleStyleConfig>
}) => TitleTemplate
/** 复制模板(内置模板也可复制,自动加"副本"后缀) */
duplicateTemplate: (id: string) => TitleTemplate | null
/** 更新自定义模板(系统模板不可改) */
updateTemplate: (id: string, patch: Partial<Omit<TitleTemplate, "id" | "isBuiltin">>) => void
/** 删除自定义模板 */
deleteTemplate: (id: string) => void
/** 导出单个模板为 JSON 字符串 */
exportTemplate: (id: string) => string | null
/** 导入 JSON 字符串作为新模板,返回新建模板 */
importTemplate: (json: string) => TitleTemplate | null
/** 根据 id 查询模板 */
getById: (id: string) => TitleTemplate | undefined
}
export function useTitleTemplates(): UseTitleTemplatesResult {
const [custom, setCustom] = useState<TitleTemplate[]>(() => loadUserTemplates())
// 跨 tab 同步
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) setCustom(loadUserTemplates())
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
}, [])
const builtin = useMemo(() => buildBuiltinTemplates(), [])
const templates = useMemo(() => [...builtin, ...custom], [builtin, custom])
const persist = useCallback((next: TitleTemplate[]) => {
setCustom(next)
saveUserTemplates(next)
}, [])
const getById = useCallback((id: string) => templates.find((t) => t.id === id), [templates])
const createTemplate = useCallback<UseTitleTemplatesResult["createTemplate"]>(
({ name, description, emoji, style }) => {
const now = new Date().toISOString()
const t: TitleTemplate = {
id: uid(),
name: name.trim() || "未命名模板",
description: description?.trim() || undefined,
emoji: emoji || "✨",
isBuiltin: false,
createdAt: now,
updatedAt: now,
style: { ...style },
}
persist([...custom, t])
return t
},
[custom, persist],
)
const duplicateTemplate = useCallback<UseTitleTemplatesResult["duplicateTemplate"]>(
(id) => {
const src = templates.find((t) => t.id === id)
if (!src) return null
const now = new Date().toISOString()
const t: TitleTemplate = {
id: uid(),
name: `${src.name} 副本`,
description: src.description,
emoji: src.emoji,
isBuiltin: false,
createdAt: now,
updatedAt: now,
style: { ...src.style },
}
persist([...custom, t])
return t
},
[templates, custom, persist],
)
const updateTemplate = useCallback<UseTitleTemplatesResult["updateTemplate"]>(
(id, patch) => {
if (id.startsWith("sys:")) return
const next = custom.map((t) =>
t.id === id
? { ...t, ...patch, id: t.id, isBuiltin: false, updatedAt: new Date().toISOString() }
: t,
)
persist(next)
},
[custom, persist],
)
const deleteTemplate = useCallback<UseTitleTemplatesResult["deleteTemplate"]>(
(id) => {
if (id.startsWith("sys:")) return
persist(custom.filter((t) => t.id !== id))
},
[custom, persist],
)
const exportTemplate = useCallback<UseTitleTemplatesResult["exportTemplate"]>(
(id) => {
const t = templates.find((x) => x.id === id)
if (!t) return null
return JSON.stringify(
{
name: t.name,
description: t.description,
emoji: t.emoji,
style: t.style,
exportedAt: new Date().toISOString(),
},
null,
2,
)
},
[templates],
)
const importTemplate = useCallback<UseTitleTemplatesResult["importTemplate"]>(
(json) => {
try {
const data = JSON.parse(json)
if (!data || typeof data !== "object" || !data.style) return null
return createTemplate({
name: data.name || "导入模板",
description: data.description,
emoji: data.emoji || "✨",
style: data.style,
})
} catch {
return null
}
},
[createTemplate],
)
return {
templates,
builtin,
custom,
createTemplate,
duplicateTemplate,
updateTemplate,
deleteTemplate,
exportTemplate,
importTemplate,
getById,
}
}
-125
View File
@@ -1,125 +0,0 @@
/**
* 标题样式工具(#2001 / 模板系统 #2003)
*
* - snake_case TitleStyleConfig ↔ camelCase TitleSettings 互转
* - preset 归一化预览(修复"标题"两字大小不一)
*/
import type { TitleStyleConfig } from "./types"
import type { TitleSettings } from "../../pages/generate/types"
import { TITLE_PRESETS } from "./constants"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import type { TitleTemplate } from "./template-types"
/** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleSettings> {
const out: Partial<TitleSettings> = {}
if (s.font != null) out.font = s.font
if (s.size != null) out.size = s.size
if (s.color != null) out.color = s.color
if (s.bold != null) out.bold = s.bold
if (s.italic != null) out.italic = s.italic
if (s.position != null) out.position = s.position as TitleSettings["position"]
if (s.pos_x != null) out.posX = s.pos_x
if (s.pos_y != null) out.posY = s.pos_y
if (s.line_height != null) out.lineHeight = s.line_height
if (s.margin_top != null) out.marginTop = s.margin_top
if (s.max_chars_per_line != null) out.maxCharsPerLine = s.max_chars_per_line
if (s.stroke != null) out.stroke = s.stroke
if (s.stroke_width != null) out.strokeWidth = s.stroke_width
if (s.stroke_color != null) out.strokeColor = s.stroke_color
if (s.shadow != null) out.shadow = s.shadow
if (s.shadow_offset_x != null) out.shadowOffsetX = s.shadow_offset_x
if (s.shadow_offset_y != null) out.shadowOffsetY = s.shadow_offset_y
if (s.shadow_blur != null) out.shadowBlur = s.shadow_blur
if (s.shadow_color != null) out.shadowColor = s.shadow_color
if (s.bg_enabled != null) out.bgEnabled = s.bg_enabled
if (s.bg_color != null) out.bgColor = s.bg_color
if (s.bg_padding != null) out.bgPadding = s.bg_padding
if (s.bg_radius != null) out.bgRadius = s.bg_radius
if (s.line_overrides != null) out.lineOverrides = s.line_overrides
return out
}
/** camelCase TitleSettings patch → snake_case TitleStyleConfig patch */
export function camelToTitleStyleConfig(p: Partial<TitleSettings>): Partial<TitleStyleConfig> {
const out: Partial<TitleStyleConfig> = {}
if (p.font != null) out.font = p.font
if (p.size != null) out.size = p.size
if (p.color != null) out.color = p.color
if (p.bold != null) out.bold = p.bold
if (p.italic != null) out.italic = p.italic
if (p.position != null) out.position = p.position as TitleStyleConfig["position"]
if (p.posX != null) out.pos_x = p.posX
if (p.posY != null) out.pos_y = p.posY
if (p.lineHeight != null) out.line_height = p.lineHeight
if (p.marginTop != null) out.margin_top = p.marginTop
if (p.maxCharsPerLine != null) out.max_chars_per_line = p.maxCharsPerLine
if (p.stroke != null) out.stroke = p.stroke
if (p.strokeWidth != null) out.stroke_width = p.strokeWidth
if (p.strokeColor != null) out.stroke_color = p.strokeColor
if (p.shadow != null) out.shadow = p.shadow
if (p.shadowOffsetX != null) out.shadow_offset_x = p.shadowOffsetX
if (p.shadowOffsetY != null) out.shadow_offset_y = p.shadowOffsetY
if (p.shadowBlur != null) out.shadow_blur = p.shadowBlur
if (p.shadowColor != null) out.shadow_color = p.shadowColor
if (p.bgEnabled != null) out.bg_enabled = p.bgEnabled
if (p.bgColor != null) out.bg_color = p.bgColor
if (p.bgPadding != null) out.bg_padding = p.bgPadding
if (p.bgRadius != null) out.bg_radius = p.bgRadius
if (p.lineOverrides != null) out.line_overrides = p.lineOverrides
return out
}
/**
* 把 preset style(snake_case)归一化为固定字号的 TitleSettings,
* 用于"预设卡片"缩略预览——所有卡片视觉上"标题"两字大小一致,便于辨识。
* 描边/阴影/背景padding 按 fixedSize / 原始 size 比例缩放,避免粗描边爆框。
*/
export function buildPresetPreviewSettings(
base: TitleSettings,
presetKey: string,
fixedSize = 56,
): TitleSettings {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return base
const origSize = preset.style.size ?? fixedSize
const ratio = fixedSize / origSize
const scale = (v: number | undefined, fallback: number): number =>
v != null ? Math.round(v * ratio) : fallback
return {
...base,
...titleStyleConfigToCamel(preset.style),
size: fixedSize,
strokeWidth: scale(preset.style.stroke_width, base.strokeWidth) ?? base.strokeWidth,
shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX) ?? base.shadowOffsetX,
shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY) ?? base.shadowOffsetY,
shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur) ?? base.shadowBlur,
bgPadding: scale(preset.style.bg_padding, base.bgPadding) ?? base.bgPadding,
lineOverrides: [],
}
}
/**
* 把 TitleTemplate 渲染为完整 TitleSettings(带默认值),用于卡片预览。
* 与模板选择器中保持一致,抽出共用。
*/
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleSettings {
const base: TitleSettings = {
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(t.style),
}
// 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放
const origSize = t.style.size ?? base.size
if (origSize && origSize !== fixedSize) {
const ratio = fixedSize / origSize
base.size = fixedSize
if (base.strokeWidth != null)
base.strokeWidth = Math.max(1, Math.round(base.strokeWidth * ratio))
if (base.shadowOffsetX != null) base.shadowOffsetX = Math.round(base.shadowOffsetX * ratio)
if (base.shadowOffsetY != null) base.shadowOffsetY = Math.round(base.shadowOffsetY * ratio)
if (base.shadowBlur != null) base.shadowBlur = Math.round(base.shadowBlur * ratio)
if (base.bgPadding != null) base.bgPadding = Math.round(base.bgPadding * ratio)
}
base.lineOverrides = []
return base
}
@@ -1,151 +0,0 @@
/**
* TTS 配音风格选择器
* - 6 种预设风格卡片(自然亲切 / 激动兴奋 / 沉稳专业 / 温柔甜美 / 新闻播报 / 直播带货)
* - 卡片单选,选中高亮紫色
* - 默认 natural
*
* 复用方式:
* <TtsStyleSelector value={style} onChange={setStyle} />
* <TtsStyleSelector value={style} onChange={setStyle} compact /> // 紧凑模式(小尺寸)
*/
import React from "react"
import { TTS_STYLE_OPTIONS, DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
export interface TtsStyleSelectorProps {
value?: TtsStyle | string
onChange: (style: TtsStyle) => void
/** 紧凑模式(小卡片),适合与其他参数并排 */
compact?: boolean
/** 是否显示"配音风格"标签 */
showLabel?: boolean
}
const TtsStyleSelector: React.FC<TtsStyleSelectorProps> = ({
value,
onChange,
compact = false,
showLabel = true,
}) => {
const current = value || DEFAULT_TTS_STYLE
if (compact) {
return (
<div>
{showLabel && (
<div
style={{
fontSize: 13,
color: "var(--text-secondary, #6b7280)",
marginBottom: 6,
}}
>
配音风格
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 6,
}}
>
{TTS_STYLE_OPTIONS.map((opt) => {
const selected = current === opt.value
return (
<button
type="button"
key={opt.value}
onClick={() => onChange(opt.value as TtsStyle)}
title={opt.description}
style={{
padding: "6px 4px",
borderRadius: 6,
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: selected ? "#faf5ff" : "#fff",
color: selected ? "#6d28d9" : "#374151",
cursor: "pointer",
fontSize: 12,
fontWeight: selected ? 600 : 400,
textAlign: "center",
transition: "all 0.15s",
lineHeight: 1.3,
}}
>
<span style={{ marginRight: 3 }}>{opt.emoji}</span>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
return (
<div>
{showLabel && (
<div
style={{
fontSize: 13,
color: "var(--text-secondary, #6b7280)",
marginBottom: 8,
fontWeight: 500,
}}
>
配音风格
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 8,
}}
>
{TTS_STYLE_OPTIONS.map((opt) => {
const selected = current === opt.value
return (
<button
type="button"
key={opt.value}
onClick={() => onChange(opt.value as TtsStyle)}
title={opt.description}
style={{
padding: "10px 8px",
borderRadius: 8,
border: selected ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: selected ? "#faf5ff" : "#fff",
color: selected ? "#6d28d9" : "#111",
cursor: "pointer",
textAlign: "center",
transition: "all 0.15s",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<span style={{ fontSize: 22, lineHeight: 1 }}>{opt.emoji}</span>
<span style={{ fontSize: 13, fontWeight: selected ? 600 : 500 }}>{opt.label}</span>
<span
style={{
fontSize: 10,
color: "#9ca3af",
lineHeight: 1.2,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{opt.description}
</span>
</button>
)
})}
</div>
</div>
)
}
export default TtsStyleSelector
-13
View File
@@ -1,13 +0,0 @@
/**
* 功能开关配置
* 集中管理前端特性的启用/隐藏,便于灰度与回滚。
* 注意:仅控制 UI 展示与前端校验,后端扣减逻辑由后端对应开关控制。
*/
/**
* 积分系统 UI 开关(默认 false = 隐藏)
* - false:隐藏所有积分相关入口/余额/消耗提示/不足弹窗/充值入口;会员标识保留;
* 功能流程不做积分预校验,直接走生成。
* - true:展示完整积分系统 UI。
*/
export const ENABLE_CREDIT_SYSTEM = false
+12 -23
View File
@@ -3,7 +3,6 @@
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
*/
import React from "react"
import { ENABLE_CREDIT_SYSTEM } from "./features"
import {
DashboardOutlined,
FileOutlined,
@@ -106,17 +105,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
: []),
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
/** 侧边栏导航分组(Sidebar 分组列表使用) */
@@ -206,17 +200,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
...(ENABLE_CREDIT_SYSTEM
? [
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
: []),
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
],
},
]
+19 -67
View File
@@ -30,7 +30,11 @@ import {
} from "./api/aiAvatar"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { RenderJob, SentenceTiming } from "./types"
import { buildTitleConfigPayload, buildCoverConfigPayload } from "./utils/contract"
import {
normalizeEmotion,
buildTitleConfigPayload,
buildCoverConfigPayload,
} from "./utils/contract"
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
/** 面板折叠状态 */
@@ -61,10 +65,6 @@ const AiAvatarPage: React.FC = () => {
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ── 对口型耗时计时(秒) ── */
const [lipsyncElapsed, setLipsyncElapsed] = useState(0)
const lipsyncStartAtRef = useRef<number>(0)
const lipsyncTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -94,7 +94,7 @@ const AiAvatarPage: React.FC = () => {
state.resetTtsPreview()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.style])
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion])
const _clearTtsProgressTimer = useCallback(() => {
if (ttsProgressTimerRef.current) {
@@ -148,7 +148,7 @@ const AiAvatarPage: React.FC = () => {
voice_id: state.selectedVoice!.voice_id,
script_text: state.scriptText,
speed: state.speed,
style: state.style,
emotion: normalizeEmotion(state.emotion),
})
_clearTtsProgressTimer()
setTtsProgress(100)
@@ -175,7 +175,7 @@ const AiAvatarPage: React.FC = () => {
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.style])
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
const handleRetryTts = useCallback(() => {
handleGenerateTts()
@@ -226,13 +226,6 @@ const AiAvatarPage: React.FC = () => {
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
// 启动计时器
lipsyncStartAtRef.current = Date.now()
setLipsyncElapsed(0)
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = setInterval(() => {
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
}, 1000)
const asset = await getAssetById(video.id)
const videoUrl = asset?.file_url
@@ -261,7 +254,7 @@ const AiAvatarPage: React.FC = () => {
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed,
style: state.style,
emotion: normalizeEmotion(state.emotion),
}
}
const job = await createLipsyncJob(payload)
@@ -276,11 +269,6 @@ const AiAvatarPage: React.FC = () => {
state.setLipsyncJob(updated)
if (updated.status === "completed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
setLipsyncStatus("completed")
setTimeout(() => {
setShowLipsyncModal(false)
@@ -288,10 +276,6 @@ const AiAvatarPage: React.FC = () => {
}, 1000)
} else if (updated.status === "failed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setLipsyncStatus("failed")
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
}
@@ -305,10 +289,6 @@ const AiAvatarPage: React.FC = () => {
data: (err as { response?: { data?: unknown } })?.response?.data,
message: err instanceof Error ? err.message : String(err),
})
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setShowLipsyncModal(false)
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
}
@@ -318,8 +298,7 @@ const AiAvatarPage: React.FC = () => {
state.selectedVoice,
state.scriptText,
state.speed,
state.style,
state.emotion,
state.ttsPreview,
])
@@ -329,21 +308,15 @@ const AiAvatarPage: React.FC = () => {
clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = null
}
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setShowLipsyncModal(false)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
setLipsyncElapsed(0)
}, [])
// 清理轮询
useEffect(() => {
return () => {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
}
}, [])
@@ -625,8 +598,8 @@ const AiAvatarPage: React.FC = () => {
onVoiceSourceChange={state.setVoiceSource}
selectedVoice={state.selectedVoice}
onSelectVoice={state.setSelectedVoice}
style={state.style}
onStyleChange={state.setStyle}
emotion={state.emotion}
onEmotionChange={state.setEmotion}
speed={state.speed}
onSpeedChange={state.setSpeed}
language={state.language}
@@ -726,11 +699,17 @@ const AiAvatarPage: React.FC = () => {
<div className="aa-panel__body">
{currentRenderJob?.status !== "completed" ? (
<PanelCoverAndGenerate
variant="setup"
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
renderJob={currentRenderJob}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
renderJob={currentRenderJob}
summary={summary}
/>
) : (
@@ -987,19 +966,6 @@ const AiAvatarPage: React.FC = () => {
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成中…
</div>
<div
style={{
marginTop: 12,
fontSize: 28,
fontWeight: 700,
fontVariantNumeric: "tabular-nums",
color: "#7c3aed",
}}
>
{`${Math.floor(lipsyncElapsed / 60)
.toString()
.padStart(2, "0")}:${(lipsyncElapsed % 60).toString().padStart(2, "0")}`}
</div>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
请勿关闭页面,完成后将自动提示
</div>
@@ -1011,20 +977,6 @@ const AiAvatarPage: React.FC = () => {
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成完成
</div>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#10b981",
fontVariantNumeric: "tabular-nums",
}}
>
总耗时{" "}
{Math.floor(lipsyncElapsed / 60)
.toString()
.padStart(2, "0")}
:{(lipsyncElapsed % 60).toString().padStart(2, "0")}
</div>
</>
)}
{lipsyncStatus === "failed" && (
+5 -12
View File
@@ -41,13 +41,10 @@ export const createLipsyncJob = async (data: {
speed?: number
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgusted(TTS 直生模式用;前端经 normalizeEmotion 归一化) */
emotion?: string
/** 配音风格预设(natural/excited/professional/sweet/news/livestream) */
style?: string
enable_video_loop?: boolean
project_id?: string
}): Promise<LipsyncJob> => {
// GPU 口型同步推理约 20s,留足余量到 120s 防止 10s 默认超时
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data, { timeout: 120_000 })
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
return response.data
}
@@ -57,7 +54,6 @@ export const previewTts = async (data: {
script_text: string
speed?: number
emotion?: string
style?: string
}): Promise<{
audio_url: string
duration: number
@@ -99,18 +95,15 @@ export const cancelRenderJob = async (jobId: string): Promise<void> => {
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
}
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ──
* #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧)
*/
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
export const generateRenderSmartCover = async (
jobId: string,
templateId: string = "default",
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
`/ai-avatar/render/${jobId}/smart-cover`,
templateId && templateId !== "default" ? { template_id: templateId } : {},
// 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s
{ timeout: templateId && templateId !== "default" ? 180000 : 120000 },
{},
// 抽帧+评分+转存 OSS 链路较长,120s 超时
{ timeout: 120000 },
)
return response.data
}
@@ -1,25 +1,11 @@
/**
* AI数字人 — 封面选择弹窗(#2033 共享封面组件重构)
*
* 复用智能剪辑的 CoverSettingsModal(模板选择)+ CoverEditorModal(7 面板自定义编辑器)
* + 智能生成 / 本地上传 / 封面预览,与智能剪辑侧 UI 一致。
*
* 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构:
* - 智能生成封面:mode="auto_frame",thumbnail_url/smart_cover_url 指向后端返回的 cover_url
* - 本地上传封面:mode="upload",upload_url/thumbnail_url 指向 blob 预览 URL
*
* 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。
* AI数字人 — 封面选择弹窗
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
*/
import React, { useCallback, useEffect, useMemo } from "react"
import { Modal as AntModal, Spin, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "@/pages/generate/components/cover-settings/CoverSettingsModal"
import CoverEditorModal from "@/pages/generate/components/cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar"
import React from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
interface ModalCoverSelectProps {
open: boolean
@@ -27,13 +13,7 @@ interface ModalCoverSelectProps {
renderJob: RenderJob | null
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
/**
* 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入
* 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。
*/
onGenerateRenderSmartCover?: (
renderId: string,
) => Promise<{ cover_url: string; message?: string }>
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
onUploadCover?: (file: File) => void
onCoverSelected: (coverUrl: string) => void
}
@@ -48,302 +28,31 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
onUploadCover,
onCoverSelected,
}) => {
const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id
const generateFn = useCallback(
async (templateId: string): Promise<string | null> => {
if (!renderJob || !isRenderCompleted) return null
try {
let coverUrl = ""
if (onGenerateRenderSmartCover) {
const res = await onGenerateRenderSmartCover(renderJob.id)
coverUrl = res.cover_url
} else {
const res = await apiGenerateSmartCover(renderJob.id, templateId)
coverUrl = res.cover_url
if (!coverUrl && res.message) {
const err = new Error(res.message) as Error & { __msgShown?: boolean }
err.__msgShown = true
message.error(res.message)
throw err
}
}
if (coverUrl) {
onCoverConfigChange({
mode: "auto_frame",
thumbnail_url: coverUrl,
smart_cover_url: coverUrl,
})
onCoverSelected(coverUrl)
message.success("智能封面已生成")
}
return coverUrl || null
} catch (err) {
const anyErr = err as { __msgShown?: boolean; message?: string }
if (!anyErr?.__msgShown) {
message.error(anyErr?.message || "智能封面生成失败")
}
throw err
}
},
[
renderJob,
isRenderCompleted,
onGenerateRenderSmartCover,
onCoverConfigChange,
onCoverSelected,
],
)
const shared = useSharedCover({
canGenerate: isRenderCompleted,
disabledHint: "请先完成视频生成再选择封面",
initialTemplateId: "default",
generateFn,
})
// 父层 onUploadCover 走 onUploadFile 回调(兼容老父组件)
useEffect(() => {
shared.setOnUploadFile((file: File) => {
if (onUploadCover) {
onUploadCover(file)
} else {
const url = URL.createObjectURL(file)
onCoverConfigChange({
mode: "upload",
upload_url: url,
thumbnail_url: url,
})
onCoverSelected(url)
}
return null
})
}, [shared, onUploadCover, onCoverConfigChange, onCoverSelected])
// 打开时同步刷新模板列表
useEffect(() => {
if (open) void shared.reloadTemplates()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
/** 当前预览 URL:智能封面 > 自定义上传 */
const previewUrl = useMemo(
() => coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "",
[coverConfig.smart_cover_url, coverConfig.thumbnail_url, coverConfig.upload_url],
)
if (!open) return null
return (
<Modal
open={open}
onCancel={onClose}
title="选择封面"
width={560}
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确定
</Button>
<div className="aa-modal-overlay" onClick={onClose}>
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
<div className="aa-modal__header">
<span className="aa-modal__title">选择封面</span>
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
}
>
<div style={{ padding: "8px 0" }}>
{renderJob && (
<div
style={{
padding: "8px 12px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 12,
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 从渲染成片中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div
style={{
display: "flex",
gap: 12,
alignItems: "flex-start",
}}
>
{/* 左:封面预览 */}
<div
style={{
width: 180,
flexShrink: 0,
}}
>
<div
className="xx-ce-canvas"
style={{
position: "relative",
width: "100%",
aspectRatio: "9 / 16",
borderRadius: 8,
overflow: "hidden",
background: "linear-gradient(135deg, #1e3a8a 0%, #312e81 100%)",
border: previewUrl ? "none" : "1px dashed #d9d9d9",
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt="封面预览"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
gap: 6,
opacity: 0.7,
}}
>
<span style={{ fontSize: 28 }}>🖼️</span>
<span>
{isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}
</span>
</div>
)}
{shared.generating && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
flexDirection: "column",
gap: 8,
}}
>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>AI 选帧中…</span>
</div>
)}
</div>
<div
style={{
marginTop: 6,
textAlign: "center",
fontSize: 11,
color: "#8c8ca1",
}}
>
9:16 竖版封面
</div>
</div>
{/* 右:操作按钮 */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!isRenderCompleted || shared.generating}
loading={shared.generating}
style={{ width: "100%" }}
>
✨ 自动生成封面
</Button>
<Button
buttonType="ghost"
onClick={() => shared.setShowCoverSettings(true)}
style={{ width: "100%" }}
>
⚙️ 封面模板
</Button>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={!isRenderCompleted || shared.generating}
style={{ width: "100%" }}
>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<div
style={{
fontSize: 11,
color: "#8c8ca1",
lineHeight: 1.5,
marginTop: 4,
padding: "6px 8px",
background: "#f7f8fa",
borderRadius: 6,
}}
>
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
</div>
</div>
<div className="aa-modal__body" style={{ padding: 20 }}>
<PanelCoverAndGenerate
variant="select-cover"
coverConfig={coverConfig}
onCoverConfigChange={onCoverConfigChange}
renderJob={renderJob}
onGenerateRenderSmartCover={onGenerateRenderSmartCover}
onUploadCover={onUploadCover}
onClose={onClose}
onCoverSelected={onCoverSelected}
/>
</div>
</div>
{/* 模板选择弹窗 */}
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => shared.setShowCoverSettings(false)}
templates={shared.templates}
loading={shared.templatesLoading}
error={shared.templatesError}
selectedTemplateId={shared.selectedTemplateId}
onSelectTemplate={shared.handleSelectTemplate}
onEditTemplate={shared.handleEditTemplate}
onDeleteTemplate={shared.handleDeleteTemplate}
onCreateNew={shared.handleCreateTemplate}
/>
{/* 自定义编辑器弹窗 */}
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
{/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */}
<AntModal open={shared.generating} closable={false} footer={null} centered width={320}>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</AntModal>
</Modal>
</div>
)
}
@@ -1,19 +1,36 @@
/**
* AI数字人 — 面板5 / 生成配置面板(渲染前)
* #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮)
* 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件)
* AI数字人 — 面板5 / 封面选择弹窗内容:
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
*
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
*/
import React from "react"
import type { RenderJob } from "../types"
import React, { useRef, useState } from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
type PanelVariant = "setup" | "select-cover"
interface PanelCoverAndGenerateProps {
variant?: PanelVariant
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
resolution?: string
onResolutionChange?: (r: string) => void
isGenerating?: boolean
onGenerate?: () => void
/** 当前渲染任务 */
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */
renderJob: RenderJob | null
/** 配置汇总信息 */
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
onUploadCover?: (file: File) => void
/** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */
onClose?: () => void
/** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */
onCoverSelected?: (coverUrl: string) => void
/** 配置汇总信息(仅 variant="setup" 使用) */
summary?: {
videoName: string | null
voiceName: string | null
@@ -21,6 +38,7 @@ interface PanelCoverAndGenerateProps {
lipsyncStatus: string | null
brollCount: number
hasTitle: boolean
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
coverStatus: "not_ready" | "pending" | "selected"
}
}
@@ -40,15 +58,89 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
}
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
variant = "setup",
coverConfig,
onCoverConfigChange,
resolution = "720p",
onResolutionChange,
isGenerating = false,
onGenerate,
renderJob: _renderJob,
renderJob,
onGenerateRenderSmartCover,
onUploadCover,
onClose,
onCoverSelected,
summary,
}) => {
const uploadInputRef = useRef<HTMLInputElement>(null)
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/** 自定义上传封面 */
const handleUploadClick = () => {
uploadInputRef.current?.click()
}
const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => {
const partial: Partial<AiAvatarCoverConfig> = {
mode,
thumbnail_url: url,
}
if (mode === "auto_frame") {
partial.smart_cover_url = url
} else {
partial.upload_url = url
}
onCoverConfigChange(partial)
onCoverSelected?.(url)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (onUploadCover) {
onUploadCover(file)
e.target.value = ""
return
}
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
const url = URL.createObjectURL(file)
_applyCoverUrl(url, "upload")
e.target.value = ""
}
/** 智能获取封面(从最终成片抽帧;必须等 render 完成) */
const handleSmartCover = async () => {
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
setSmartCoverLoading(true)
try {
const res = await onGenerateRenderSmartCover(renderJob.id)
if (res.cover_url) {
_applyCoverUrl(res.cover_url, "auto_frame")
} else {
// 失败由父组件 message 提示,这里不重复弹窗
console.warn("[智能封面] 返回空 cover_url:", res.message)
}
} catch (err) {
console.error("[智能封面] 调用失败:", err)
} finally {
setSmartCoverLoading(false)
}
}
const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
// 渲染已完成 → 封面区可用
const isRenderCompleted = renderJob?.status === "completed"
const canSmartCover = isRenderCompleted && !smartCoverLoading
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
const coverUrl =
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
const hasCoverImage = Boolean(coverUrl)
/** 封面区占位文字 */
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
/** 配置摘要中的封面状态标签 */
const coverSummaryNode = (() => {
@@ -62,6 +154,69 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
return <span className="aa-config-summary__empty">生成视频后可选</span>
})()
// ── 封面选择区(两种 variant 共用) ─────────────────────────────────
const coverSection = (
<div className="aa-cover-section" style={{ marginTop: variant === "select-cover" ? 0 : 16 }}>
<div className="aa-label" style={{ marginBottom: 8 }}>
{variant === "select-cover" ? "选择封面" : "封面"}
</div>
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : (
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
)}
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
</div>
<div className="aa-cover-actions">
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={!canSmartCover}
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
disabled={!isRenderCompleted || smartCoverLoading}
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
>
📷 自定义上传
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
// ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ──
if (variant === "select-cover") {
return (
<div className="aa-cover-generate">
{coverSection}
{onClose && (
<div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
<button type="button" className="aa-btn aa-btn--primary" onClick={onClose}>
确定
</button>
</div>
)}
</div>
)
}
// ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ──
return (
<div className="aa-cover-generate">
{/* 分辨率选择 */}
@@ -29,7 +29,15 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`
}
import { getFontFamily as getFontFamilyByKey } from "@/components/title/constants"
/** 字体名 → CSS font-family 映射(与 titleCanvas 字体链对齐) */
const FONT_FAMILY_MAP: Record<string, string> = {
思源黑体:
"'Noto Sans CJK SC', 'Source Han Sans CN', 'PingFang SC', 'Microsoft YaHei', sans-serif",
思源宋体: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
楷体: "KaiTi, 'STKaiti', serif",
黑体: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
}
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
export function PanelLipsyncPreview({
lipsyncJob,
@@ -75,105 +83,63 @@ export function PanelLipsyncPreview({
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale])
/** 标题叠加样式(新字段全支持:描边宽色/阴影参数化/背景块/行距/顶部边距/自动换行) */
const titleOverlayData =
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */
const titleOverlayStyle: React.CSSProperties | null =
titleConfig?.title && containerWidth > 0
? (() => {
const c = titleConfig as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
cover_title_config?: Record<string, unknown> | null
line_overrides?: unknown[]
}
const baseSize = titleConfig.size || 48
const fontSize = ps(baseSize)
const strokeW = c.stroke ? ps(c.stroke_width ?? 4) : 0
const strokeC = c.stroke_color || "#000000"
const shBlur = ps(c.shadow_blur ?? 4)
const shOffX = ps(c.shadow_offset_x ?? 2)
const shOffY = ps(c.shadow_offset_y ?? 2)
const shColor = c.shadow_color || "rgba(0,0,0,0.8)"
const lh = c.line_height ?? 1.2
const mTop = ps(c.margin_top ?? 24)
const bgPad = ps(c.bg_padding ?? 12)
const bgR = ps(c.bg_radius ?? 8)
const maxChars = c.max_chars_per_line ?? 0
const rawText = titleConfig.title || ""
const lines = (() => {
const manual = rawText
.split(/[//]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
manual.forEach((seg) => {
for (let i = 0; i < seg.length; i += maxChars) out.push(seg.slice(i, i + maxChars))
})
return out
})()
const padV = ps(16) * 0.5
const textShadow = titleConfig.shadow
? `${shOffX}px ${shOffY}px ${shBlur}px ${shColor}`
: undefined
// 描边宽度基准 ≈ size * 0.06,最小 1.5px @720p
const strokeW = Math.max(ps(1.5), +(baseSize * 0.06 * previewScale).toFixed(2))
// 阴影按比例缩放
const shadowBlur = ps(4)
const shadowOffsetY = ps(2)
// padding / top 边距按比例(基准 8px 对应预览小窗,成片基准 16px,这里 8px 对应约 0.33 缩放)
const padV = ps(16) * 0.5 // ≈ 8px in ~240px container
const padH = ps(24) * 0.5
const style: React.CSSProperties = {
position: "absolute",
color: titleConfig.color || "#ffffff",
fontFamily: getFontFamilyByKey(titleConfig.font || "source_sans_sc"),
fontFamily: getFontFamily(titleConfig.font || "思源黑体"),
fontSize: `${fontSize}px`,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
lineHeight: lh,
WebkitTextStroke:
titleConfig.stroke && strokeW > 0 ? `${strokeW}px ${strokeC}` : undefined,
paintOrder: "stroke fill",
textShadow,
whiteSpace: "pre-wrap",
padding: c.bg_enabled ? `${bgPad}px ${bgPad}px` : 0,
background: c.bg_enabled ? c.bg_color || "rgba(0,0,0,0.5)" : "transparent",
borderRadius: c.bg_enabled ? `${bgR}px` : 0,
width: "90%",
lineHeight: 1.2,
padding: `${ps(4)}px ${padH}px`,
textShadow: titleConfig.shadow
? `0 ${shadowOffsetY}px ${shadowBlur}px rgba(0,0,0,0.8), 0 0 ${ps(2)}px rgba(0,0,0,0.5)`
: undefined,
WebkitTextStroke: titleConfig.stroke ? `${strokeW}px #000` : undefined,
boxSizing: "border-box",
display: "inline-block",
maxWidth: "94%",
}
const wrap: React.CSSProperties = {
position: "absolute",
left: "50%",
width: "100%",
display: "flex",
justifyContent: "center",
pointerEvents: onTitlePositionChange ? "auto" : "none",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}
if (
titleConfig.position === "custom" &&
titleConfig.pos_x != null &&
titleConfig.pos_y != null
) {
wrap.left = `${titleConfig.pos_x}%`
wrap.top = `${titleConfig.pos_y}%`
wrap.transform = "translate(-50%, -50%)"
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translateX(-50%) translateY(-50%)"
} else if (titleConfig.position === "top") {
wrap.top = `${padV + mTop}px`
wrap.transform = "translateX(-50%)"
style.left = "50%"
style.top = padV
style.transform = "translateX(-50%)"
} else if (titleConfig.position === "bottom") {
wrap.bottom = `${padV}px`
wrap.transform = "translateX(-50%)"
style.left = "50%"
style.bottom = padV
style.transform = "translateX(-50%)"
} else {
wrap.top = "50%"
wrap.transform = "translate(-50%, -50%)"
style.left = "50%"
style.top = "50%"
style.transform = "translateX(-50%) translateY(-50%)"
}
return { style, wrap, lines }
return style
})()
: null
@@ -286,23 +252,25 @@ export function PanelLipsyncPreview({
{isDone && lipsyncJob?.output_video_url ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<video src={lipsyncJob.output_video_url} controls />
{titleOverlayData && (
{titleOverlayStyle && (
<div
ref={titleDragRef}
style={{
...titleOverlayData.wrap,
...titleOverlayStyle,
cursor: onTitlePositionChange ? "grab" : "default",
pointerEvents: onTitlePositionChange ? "auto" : "none",
}}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
>
<div style={titleOverlayData.style}>
{titleOverlayData.lines.map((part: string, i: number) => (
<div key={i}>{part}</div>
))}
</div>
{titleConfig!.title.split(/[//]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</div>
)}
</div>
@@ -10,13 +10,10 @@
import React, { useMemo, useState, useEffect } from "react"
import { Input } from "antd"
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
import type { TitleTemplate } from "@/components/title/template-types"
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
import type { TitleOption } from "@/pages/generate/components/title/TitleLibraryAutoComplete"
import type { TitleSettings } from "@/pages/generate/types"
import { POSITION_OPTIONS } from "@/pages/generate/constants"
import { FONT_OPTIONS, TITLE_PRESETS } from "@/components/title/constants"
import { POSITION_OPTIONS, FONT_OPTIONS, TITLE_PRESETS } from "@/pages/generate/constants"
import type { AiAvatarTitleConfig } from "../types"
// #1894: 标题数据源切换到文案库,取 script.title 作为候选
import { getScripts } from "@/api/scripts"
@@ -31,7 +28,6 @@ interface PanelTitleConfigProps {
const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpdate }) => {
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
const [activePreset, setActivePreset] = useState<string | null>(null)
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
/** 标题库选项(#1894:从文案库 scripts[].title 取候选) */
const [titleOptions, setTitleOptions] = useState<TitleOption[]>([])
@@ -53,60 +49,9 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
.catch(() => setTitleOptions([]))
}, [])
/** AiAvatarTitleConfig (snake_case) → TitleSettings (camelCase) */
const titleSettings: TitleSettings = useMemo(() => {
const c = titleConfig as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
cover_title_config?: {
title?: string
font?: string
size?: number
font_size?: number
color?: string
font_color?: string
bold?: boolean
italic?: boolean
position?: string
stroke?: { enabled: boolean; width?: number; color?: string } | boolean
stroke_width?: number
stroke_color?: string
shadow?:
| {
enabled: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
background?: { enabled: boolean; color?: string; padding?: number; radius?: number }
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_height?: number
margin_top?: number
max_chars_per_line?: number
} | null
line_overrides?: unknown[]
}
return {
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
const titleSettings: TitleSettings = useMemo(
() => ({
aiAutoSelect: false,
title: titleConfig.title,
position: titleConfig.position,
@@ -119,250 +64,21 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
color: titleConfig.color,
posX: null,
posY: null,
lineHeight: c.line_height ?? 1.2,
marginTop: c.margin_top ?? 24,
maxCharsPerLine: c.max_chars_per_line ?? 0,
strokeWidth: c.stroke_width ?? 4,
strokeColor: c.stroke_color ?? "#000000",
shadowOffsetX: c.shadow_offset_x ?? 2,
shadowOffsetY: c.shadow_offset_y ?? 2,
shadowBlur: c.shadow_blur ?? 4,
shadowColor: c.shadow_color ?? "rgba(0,0,0,0.8)",
bgEnabled: !!c.bg_enabled,
bgColor: c.bg_color ?? "rgba(0,0,0,0.5)",
bgPadding: c.bg_padding ?? 12,
bgRadius: c.bg_radius ?? 8,
lineOverrides: Array.isArray(c.line_overrides) ? c.line_overrides : [],
coverTitle: (() => {
const ct = c.cover_title_config as
| null
| (AiAvatarTitleConfig & {
font_size?: number
font_color?: string
stroke?: { enabled?: boolean; width?: number; color?: string } | boolean
stroke_width?: number
stroke_color?: string
shadow?:
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
background?: { enabled?: boolean; color?: string; padding?: number; radius?: number }
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
})
if (!ct) return null
const ctStroke = ct.stroke as
{ enabled?: boolean; width?: number; color?: string } | boolean | undefined
const ctShadow = ct.shadow as
| {
enabled?: boolean
offset_x?: number
offset_y?: number
blur?: number
color?: string
}
| boolean
| undefined
const ctBg = ct.background as
{ enabled?: boolean; color?: string; padding?: number; radius?: number } | undefined
return {
title: ct.title,
font: ct.font,
size: ct.font_size ?? ct.size,
color: ct.font_color ?? ct.color,
bold: ct.bold,
italic: ct.italic,
position: ct.position,
stroke:
typeof ctStroke === "object" && ctStroke ? ctStroke.enabled !== false : !!ctStroke,
strokeWidth:
(typeof ctStroke === "object" && ctStroke ? ctStroke.width : undefined) ??
ct.stroke_width ??
4,
strokeColor:
(typeof ctStroke === "object" && ctStroke ? ctStroke.color : undefined) ??
ct.stroke_color ??
"#000000",
shadow:
typeof ctShadow === "object" && ctShadow ? ctShadow.enabled !== false : !!ctShadow,
shadowOffsetX:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_x : undefined) ??
ct.shadow_offset_x ??
2,
shadowOffsetY:
(typeof ctShadow === "object" && ctShadow ? ctShadow.offset_y : undefined) ??
ct.shadow_offset_y ??
2,
shadowBlur:
(typeof ctShadow === "object" && ctShadow ? ctShadow.blur : undefined) ??
ct.shadow_blur ??
4,
shadowColor:
(typeof ctShadow === "object" && ctShadow ? ctShadow.color : undefined) ??
ct.shadow_color ??
"rgba(0,0,0,0.8)",
bgEnabled: ctBg?.enabled ?? !!ct.bg_enabled,
bgColor: ctBg?.color ?? ct.bg_color ?? "rgba(0,0,0,0.5)",
bgPadding: ctBg?.padding ?? ct.bg_padding ?? 12,
bgRadius: ctBg?.radius ?? ct.bg_radius ?? 8,
}
})(),
}
}, [titleConfig])
}),
[titleConfig],
)
/** 应用预设:覆盖新细粒度字段(颜色/描边/阴影/字号/字体等) */
/** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
const handleApplyPreset = (presetKey: string) => {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return
setActivePreset(presetKey)
const st = preset.style || {}
onUpdate({
font: st.font,
size: st.size,
color: st.color,
bold: st.bold,
italic: st.italic,
stroke: st.stroke,
stroke_width: st.stroke_width,
stroke_color: st.stroke_color,
shadow: st.shadow,
shadow_offset_x: st.shadow_offset_x,
shadow_offset_y: st.shadow_offset_y,
shadow_blur: st.shadow_blur,
shadow_color: st.shadow_color,
bg_enabled: st.bg_enabled,
bg_color: st.bg_color,
bg_padding: st.bg_padding,
bg_radius: st.bg_radius,
line_overrides: [],
cover_title_config: null,
})
}
/** 字段 patch 透传:TitleStylePanel 的 onUpdateStyle(camelCase → snake_case) */
const handleUpdateStyle = (patch: Partial<TitleSettings>) => {
const snake: Record<string, unknown> = {}
const map: Record<string, string> = {
lineHeight: "line_height",
marginTop: "margin_top",
maxCharsPerLine: "max_chars_per_line",
strokeWidth: "stroke_width",
strokeColor: "stroke_color",
shadowOffsetX: "shadow_offset_x",
shadowOffsetY: "shadow_offset_y",
shadowBlur: "shadow_blur",
shadowColor: "shadow_color",
bgEnabled: "bg_enabled",
bgColor: "bg_color",
bgPadding: "bg_padding",
bgRadius: "bg_radius",
lineOverrides: "line_overrides",
coverTitle: "cover_title_config",
}
Object.entries(patch).forEach(([k, v]) => {
if (k === "coverTitle" && v && typeof v === "object") {
const ct = v as {
title?: string
font?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
position?: string
stroke?: boolean
strokeWidth?: number
strokeColor?: string
shadow?: boolean
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineHeight?: number
marginTop?: number
maxCharsPerLine?: number
}
snake.cover_title_config = {
title: ct.title,
font: ct.font,
font_size: ct.size,
font_color: ct.color,
bold: ct.bold,
italic: ct.italic,
position: ct.position,
stroke: ct.stroke
? { enabled: true, width: ct.strokeWidth ?? 4, color: ct.strokeColor ?? "#000" }
: { enabled: false },
shadow: ct.shadow
? {
enabled: true,
offset_x: ct.shadowOffsetX ?? 2,
offset_y: ct.shadowOffsetY ?? 2,
blur: ct.shadowBlur ?? 4,
color: ct.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
background: ct.bgEnabled
? { enabled: true, color: ct.bgColor, padding: ct.bgPadding, radius: ct.bgRadius }
: { enabled: false },
line_height: ct.lineHeight,
margin_top: ct.marginTop,
max_chars_per_line: ct.maxCharsPerLine,
}
} else if (map[k]) {
snake[map[k]] = v
} else {
snake[k] = v
}
})
onUpdate(snake)
}
/** 应用模板(来自内联网格 onApplyTemplate):camelCase TitleSettings → snake_case AiAvatarTitleConfig */
const handleApplyTemplate = (settings: TitleSettings, tpl: TitleTemplate) => {
setActivePreset(null)
setSelectedTemplateId(tpl.id)
onUpdate({
title: titleConfig.title,
font: settings.font,
size: settings.size,
color: settings.color,
bold: settings.bold,
italic: settings.italic,
position: settings.position,
stroke: settings.stroke,
stroke_width: settings.strokeWidth,
stroke_color: settings.strokeColor,
shadow: settings.shadow,
shadow_offset_x: settings.shadowOffsetX,
shadow_offset_y: settings.shadowOffsetY,
shadow_blur: settings.shadowBlur,
shadow_color: settings.shadowColor,
bg_enabled: settings.bgEnabled,
bg_color: settings.bgColor,
bg_padding: settings.bgPadding,
bg_radius: settings.bgRadius,
line_height: settings.lineHeight,
margin_top: settings.marginTop,
max_chars_per_line: settings.maxCharsPerLine,
line_overrides: [],
pos_x: settings.posX ?? undefined,
pos_y: settings.posY ?? undefined,
cover_title_config: null,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
})
}
@@ -409,16 +125,8 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })}
onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })}
onApplyPreset={handleApplyPreset}
onUpdateStyle={handleUpdateStyle}
showCoverToggle
previewWidth={280}
enableTemplates
selectedTemplateId={selectedTemplateId}
onApplyTemplate={handleApplyTemplate}
activePreset={activePreset}
titlePresets={
TITLE_PRESETS as unknown as React.ComponentProps<typeof TitleStylePanel>["titlePresets"]
}
titlePresets={TITLE_PRESETS}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
@@ -1,17 +1,18 @@
/**
* AI数字人 — 配音库面板(面板3)
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、风格/语速/语言参数
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
*/
import { useEffect, useRef, useState } from "react"
import { message } from "antd"
import { fetchVoices } from "@/api/voices/voices"
import { previewTts } from "@/api/tts"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import type { TtsStyle } from "@/api/tts/styles"
import { normalizeEmotion } from "../utils/contract"
import type { UnifiedVoiceItem } from "@/api/voices/types"
import {
type VoiceSource,
type VoiceEmotion,
type VoiceLanguage,
VOICE_EMOTION_OPTIONS,
PRESET_VOICE_LANGUAGE_OPTIONS,
CLONE_VOICE_LANGUAGE_OPTIONS,
} from "../types"
@@ -21,8 +22,8 @@ interface PanelVoiceSelectorProps {
onVoiceSourceChange: (source: VoiceSource) => void
selectedVoice: UnifiedVoiceItem | null
onSelectVoice: (voice: UnifiedVoiceItem) => void
style: TtsStyle
onStyleChange: (s: TtsStyle) => void
emotion: VoiceEmotion
onEmotionChange: (e: VoiceEmotion) => void
speed: number
onSpeedChange: (s: number) => void
language: VoiceLanguage
@@ -34,8 +35,8 @@ export function PanelVoiceSelector({
onVoiceSourceChange,
selectedVoice,
onSelectVoice,
style,
onStyleChange,
emotion,
onEmotionChange,
speed,
onSpeedChange,
language,
@@ -138,30 +139,31 @@ export function PanelVoiceSelector({
/* 克隆音色:preview_url/audio_url 通常为空,需走 POST /tts/preview
* 现合成示例文案再播放,对齐配音库 useAudioPlayer 行为 */
if (voice.type === "clone") {
const cacheKey = `${voice.voice_clone_profile_id || voice.id}::${style}`
const cached = previewCacheRef.current.get(cacheKey)
const cached = previewCacheRef.current.get(voice.voice_clone_profile_id || voice.id)
if (cached) {
playAudioUrl(voice.id, cached)
return
}
const targetId = voice.voice_clone_profile_id || voice.id
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
setPreviewingId(voice.id)
try {
const res = await previewTts({
text: VOICE_PREVIEW_TEXT,
voice_id: targetId,
speed: speed, // 透传用户选择的语速(#1822)
style,
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
})
if (!res.audio_url) {
setPreviewingId(null)
message.error("合成试听失败:未返回音频")
return
}
previewCacheRef.current.set(cacheKey, res.audio_url)
previewCacheRef.current.set(targetId, res.audio_url)
playAudioUrl(voice.id, res.audio_url)
} catch (err) {
setPreviewingId(null)
// DEBUG: 打印详细错误信息
console.error("[AI数字人-克隆试听] previewTts 失败:", {
status: (err as { response?: { status?: number } })?.response?.status,
data: (err as { response?: { data?: unknown } })?.response?.data,
@@ -270,6 +272,23 @@ export function PanelVoiceSelector({
{/* 配音参数 */}
<div className="aa-voice-params">
<div className="aa-voice-params__row">
<div className="aa-voice-params__field">
<label className="aa-label" htmlFor="aa-voice-emotion">
情绪
</label>
<select
id="aa-voice-emotion"
className="aa-select"
value={emotion}
onChange={(e) => onEmotionChange(e.target.value as VoiceEmotion)}
>
{VOICE_EMOTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="aa-voice-params__field">
<label className="aa-label" htmlFor="aa-voice-language">
语言
@@ -305,9 +324,6 @@ export function PanelVoiceSelector({
onChange={(e) => handleSpeedChange(e.target.value)}
/>
</div>
<div className="aa-voice-params__field">
<TtsStyleSelector value={style} onChange={onStyleChange} compact />
</div>
</div>
</div>
)
@@ -6,6 +6,7 @@ import type { AssetItem } from "@/api/assets"
import type { UnifiedVoiceItem } from "@/api/voices/types"
import {
type VoiceSource,
type VoiceEmotion,
type VoiceLanguage,
type Script,
type LipsyncJob,
@@ -16,7 +17,6 @@ import {
DEFAULT_TITLE_CONFIG,
DEFAULT_COVER_CONFIG,
} from "../types"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
const DEFAULT_TTS_PREVIEW: TtsPreviewResult = {
audioUrl: null,
@@ -34,7 +34,7 @@ export function useAiAvatar() {
/* ── 面板2:配音库 ── */
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
const [style, setStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const [emotion, setEmotion] = useState<VoiceEmotion>("neutral")
const [speed, setSpeed] = useState(1.0)
const [language, setLanguage] = useState<VoiceLanguage>("zh")
@@ -113,8 +113,8 @@ export function useAiAvatar() {
setVoiceSource,
selectedVoice,
setSelectedVoice,
style,
setStyle,
emotion,
setEmotion,
speed,
setSpeed,
language,
+2 -53
View File
@@ -100,7 +100,7 @@ export interface BRollSegment {
pip_scale: number
}
/* ── 标题配置(#2001 升级:细粒度描边/阴影/背景/排版/逐行/封面独立标题) ── */
/* ── 标题配置 ── */
export interface AiAvatarTitleConfig {
title: string
position: string
@@ -115,42 +115,6 @@ export interface AiAvatarTitleConfig {
/** 自定义位置坐标(position=custom 时生效,百分比 0-100) */
pos_x?: number
pos_y?: number
/* ── 排版 ── */
line_height: number
margin_top: number
max_chars_per_line: number
/* ── 描边参数化 ── */
stroke_width: number
stroke_color: string
/* ── 阴影参数化 ── */
shadow_offset_x: number
shadow_offset_y: number
shadow_blur: number
shadow_color: string
/* ── 背景色块 ── */
bg_enabled: boolean
bg_color: string
bg_padding: number
bg_radius: number
/* ── 逐行覆盖 ── */
line_overrides: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
/* ── 封面独立标题(null=沿用主标题) ── */
cover_title_config: null | Partial<AiAvatarTitleConfig>
}
/* ── 封面配置 ── */
@@ -185,27 +149,12 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
size: 48,
bold: true,
italic: false,
stroke: true,
stroke: false,
shadow: false,
color: "#ffffff",
auto_subtitle: true,
pos_x: undefined,
pos_y: undefined,
line_height: 1.2,
margin_top: 24,
max_chars_per_line: 0,
stroke_width: 4,
stroke_color: "#000000",
shadow_offset_x: 2,
shadow_offset_y: 2,
shadow_blur: 4,
shadow_color: "rgba(0,0,0,0.8)",
bg_enabled: false,
bg_color: "rgba(0,0,0,0.5)",
bg_padding: 12,
bg_radius: 8,
line_overrides: [],
cover_title_config: null,
}
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
+2 -85
View File
@@ -67,37 +67,6 @@ export function buildTitleConfigPayload(
const text = (cfg.title || "").trim()
if (!text) return {}
const position = cfg.position || "bottom"
const anyCfg = cfg as AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_overrides?: unknown[]
cover_title_config?: Record<string, unknown> | null
}
const strokeWidth = anyCfg.stroke_width != null ? anyCfg.stroke_width : 4
const strokeColor = anyCfg.stroke_color || "#000000"
const shadowOffsetX = anyCfg.shadow_offset_x != null ? anyCfg.shadow_offset_x : 2
const shadowOffsetY = anyCfg.shadow_offset_y != null ? anyCfg.shadow_offset_y : 2
const shadowBlur = anyCfg.shadow_blur != null ? anyCfg.shadow_blur : 4
const shadowColor = anyCfg.shadow_color || "rgba(0,0,0,0.8)"
const lineHeight = anyCfg.line_height != null ? anyCfg.line_height : 1.2
const marginTop = anyCfg.margin_top != null ? anyCfg.margin_top : 24
const maxCharsPerLine = anyCfg.max_chars_per_line ?? 0
const bgEnabled = !!anyCfg.bg_enabled
const bgColor = anyCfg.bg_color || "rgba(0,0,0,0.5)"
const bgPadding = anyCfg.bg_padding != null ? anyCfg.bg_padding : 12
const bgRadius = anyCfg.bg_radius != null ? anyCfg.bg_radius : 8
const payload: Record<string, unknown> = {
text,
enabled: true,
@@ -106,68 +75,16 @@ export function buildTitleConfigPayload(
font_color: cfg.color || "#ffffff",
position,
bold: !!cfg.bold,
italic: !!cfg.italic,
stroke: cfg.stroke
? { enabled: true, width: strokeWidth, color: strokeColor }
: { enabled: false },
stroke: cfg.stroke ? { enabled: true, width: 2, color: "#000000" } : { enabled: false },
shadow: cfg.shadow
? {
enabled: true,
color: shadowColor,
offset_x: shadowOffsetX,
offset_y: shadowOffsetY,
blur: shadowBlur,
}
? { enabled: true, color: "#000000", offset_x: 2, offset_y: 2 }
: { enabled: false },
line_height: lineHeight,
margin_top: marginTop,
max_chars_per_line: maxCharsPerLine,
background: bgEnabled
? { enabled: true, color: bgColor, padding: bgPadding, radius: bgRadius }
: { enabled: false },
line_overrides: Array.isArray(anyCfg.line_overrides) ? anyCfg.line_overrides : [],
}
// 自定义坐标(custom 位置)
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
payload.pos_x = cfg.pos_x
payload.pos_y = cfg.pos_y
}
// 封面独立标题配置
if (anyCfg.cover_title_config) {
const ctc = anyCfg.cover_title_config
payload.cover_title_config = {
title: ctc.title,
font: ctc.font,
font_size: ctc.size,
font_color: ctc.color,
position: ctc.position,
bold: ctc.bold,
italic: ctc.italic,
stroke: ctc.stroke
? { enabled: true, width: ctc.stroke_width ?? 4, color: ctc.stroke_color ?? "#000000" }
: { enabled: false },
shadow: ctc.shadow
? {
enabled: true,
color: ctc.shadow_color ?? shadowColor,
offset_x: ctc.shadow_offset_x ?? 2,
offset_y: ctc.shadow_offset_y ?? 2,
blur: ctc.shadow_blur ?? 4,
}
: { enabled: false },
line_height: ctc.line_height ?? lineHeight,
margin_top: ctc.margin_top ?? marginTop,
max_chars_per_line: ctc.max_chars_per_line ?? maxCharsPerLine,
background: ctc.bg_enabled
? {
enabled: true,
color: ctc.bg_color ?? bgColor,
padding: ctc.bg_padding ?? bgPadding,
radius: ctc.bg_radius ?? bgRadius,
}
: { enabled: false },
}
}
// 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层)
if (titleImageDataUrl) {
payload.title_image_dataurl = titleImageDataUrl
+76 -211
View File
@@ -9,78 +9,37 @@
* 按 videoWidth / 720 得到 scale,所有长度类参数乘以 scale,
* 保证 1080p / 4K 成片里标题视觉大小与预览一致。
*/
import { getFontFamily } from "@/components/title/constants"
import type { AiAvatarTitleConfig } from "../types"
export interface RenderTitlePngOptions {
/** 标题配置 */
titleConfig: AiAvatarTitleConfig
/** 视频宽度(像素),默认 720 */
videoWidth?: number
/** 视频高度(像素),默认 1280 */
videoHeight?: number
useCoverTitle?: boolean
}
function autoWrapLines(rawTitle: string, maxCharsPerLine: number): string[] {
const manual = rawTitle
/**
* 将标题渲染为透明背景 PNG 的 dataURL(data:image/png;base64,...)
* Canvas 尺寸与视频一致,保证叠加时 1:1 像素对齐。
*
* 标题为空时返回 null。
*/
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
const { titleConfig, videoWidth = 720, videoHeight = 1280 } = opts
if (!titleConfig) return null
const rawTitle = (titleConfig.title || "").trim()
if (!rawTitle) return null
// 按 / 或 / 分割为多行
const lines = rawTitle
.split(/[//]/)
.map((l) => l.trim())
.filter((l) => l.length > 0)
if (!maxCharsPerLine || maxCharsPerLine <= 0) return manual
const out: string[] = []
manual.forEach((seg) => {
for (let i = 0; i < seg.length; i += maxCharsPerLine) {
out.push(seg.slice(i, i + maxCharsPerLine))
}
})
return out
}
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
const { titleConfig, videoWidth = 720, videoHeight = 1280, useCoverTitle } = opts
if (!titleConfig) return null
type TitleCfgExt = AiAvatarTitleConfig & {
stroke_width?: number
stroke_color?: string
shadow_offset_x?: number
shadow_offset_y?: number
shadow_blur?: number
shadow_color?: string
line_height?: number
margin_top?: number
max_chars_per_line?: number
bg_enabled?: boolean
bg_color?: string
bg_padding?: number
bg_radius?: number
line_overrides?: Array<{
line_index: number
text?: string
size?: number
color?: string
bold?: boolean
italic?: boolean
stroke?: boolean
highlights?: Array<{ word: string; color?: string; bold?: boolean; scale?: number }>
}>
cover_title_config?: Partial<AiAvatarTitleConfig> | null
pos_x?: number
pos_y?: number
}
const cfg: TitleCfgExt =
useCoverTitle && titleConfig.cover_title_config
? ({
...(titleConfig as TitleCfgExt),
...(titleConfig.cover_title_config as object),
} as TitleCfgExt)
: (titleConfig as TitleCfgExt)
const rawTitle = (cfg.title || "").trim()
if (!rawTitle) return null
const maxCharsPerLine = cfg.max_chars_per_line ?? 0
const lines = autoWrapLines(rawTitle, maxCharsPerLine)
if (lines.length === 0) return null
// 分辨率缩放系数:基准 720p,所有长度类参数乘以 scale
const scale = videoWidth / 720
const r = (v: number) => Math.round(v * scale)
@@ -90,183 +49,86 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
const ctx = canvas.getContext("2d")
if (!ctx) return null
const baseSize = Math.max(12, Math.round(cfg.size || 48))
const baseSize = Math.max(12, Math.round(titleConfig.size || 48))
const size = r(baseSize)
const bold = !!cfg.bold
const italic = !!cfg.italic
const color = cfg.color || "#ffffff"
const stroke = !!cfg.stroke
const shadow = !!cfg.shadow
const bold = !!titleConfig.bold
const italic = !!titleConfig.italic
const color = titleConfig.color || "#ffffff"
const stroke = !!titleConfig.stroke
const shadow = !!titleConfig.shadow
const strokeWidthBase = cfg.stroke_width != null ? cfg.stroke_width : 4
const strokeColor = cfg.stroke_color || "#000000"
const shadowOffsetXBase = cfg.shadow_offset_x != null ? cfg.shadow_offset_x : 2
const shadowOffsetYBase = cfg.shadow_offset_y != null ? cfg.shadow_offset_y : 2
const shadowBlurBase = cfg.shadow_blur != null ? cfg.shadow_blur : 4
const shadowColor = cfg.shadow_color || "rgba(0,0,0,0.8)"
const lineHeightScale = cfg.line_height != null ? cfg.line_height : 1.2
const marginTopBase = cfg.margin_top != null ? cfg.margin_top : 24
const bgEnabled = !!cfg.bg_enabled
const bgColor = cfg.bg_color || "rgba(0,0,0,0.5)"
const bgPaddingBase = cfg.bg_padding != null ? cfg.bg_padding : 12
const bgRadiusBase = cfg.bg_radius != null ? cfg.bg_radius : 8
const fontKey = cfg.font || "思源黑体"
const fontFamily = getFontFamily(fontKey)
const setFont = (sz: number, bd: boolean, it: boolean) => {
const parts: string[] = []
if (it) parts.push("italic")
if (bd) parts.push("bold")
parts.push(`${sz}px`, fontFamily)
ctx.font = parts.join(" ")
}
setFont(size, bold, italic)
// 字体族 fallback 链:优先中文字体
const fontFamily =
'"Noto Sans CJK SC","Source Han Sans CN","PingFang SC","Microsoft YaHei",sans-serif'
const fontParts: string[] = []
if (italic) fontParts.push("italic")
if (bold) fontParts.push("bold")
fontParts.push(`${size}px`, fontFamily)
ctx.font = fontParts.join(" ")
ctx.fillStyle = color
ctx.textAlign = "center"
ctx.textBaseline = "middle"
const lineGap = size * lineHeightScale
const totalTextH = lines.length * lineGap - (lineGap - size)
let maxLineW = 0
lines.forEach((l: string) => {
const m = ctx.measureText(l).width
if (m > maxLineW) maxLineW = m
})
// 阴影(shadow=true 时开启)——按 scale 缩放
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(4)
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = r(2)
}
// 位置计算:与 PanelLipsyncPreview 的 CSS 对齐(按 scale 缩放 PAD)
const PAD = r(16)
let centerX = videoWidth / 2
const position = cfg.position || "bottom"
const position = titleConfig.position || "bottom"
const lineGap = size * 1.2
const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度
// 文本块顶部 y(textBaseline=middle 时首行基线)
let firstLineY: number
if (position === "custom" && typeof cfg.pos_x === "number" && typeof cfg.pos_y === "number") {
centerX = (Math.max(0, Math.min(100, cfg.pos_x)) / 100) * videoWidth
const centerY = (Math.max(0, Math.min(100, cfg.pos_y)) / 100) * videoHeight
if (
position === "custom" &&
typeof titleConfig.pos_x === "number" &&
typeof titleConfig.pos_y === "number"
) {
centerX = (Math.max(0, Math.min(100, titleConfig.pos_x)) / 100) * videoWidth
const centerY = (Math.max(0, Math.min(100, titleConfig.pos_y)) / 100) * videoHeight
firstLineY = centerY - totalTextH / 2 + size / 2
} else if (position === "top") {
firstLineY = size / 2 + PAD + r(marginTopBase)
// 顶部:y = size/2 + PAD
firstLineY = size / 2 + PAD
} else if (position === "center") {
firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2
} else {
// bottom(默认)
firstLineY = videoHeight - totalTextH - PAD + size / 2
}
if (shadow) {
ctx.shadowColor = shadowColor
ctx.shadowBlur = r(shadowBlurBase)
ctx.shadowOffsetX = r(shadowOffsetXBase)
ctx.shadowOffsetY = r(shadowOffsetYBase)
} else {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
const bgPad = r(bgPaddingBase)
const bgR = r(bgRadiusBase)
const bgW = maxLineW + bgPad * 2
const bgH = totalTextH + bgPad * 2
const bgX = centerX - bgW / 2
const bgY = firstLineY - size / 2 - bgPad
if (bgEnabled) {
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.fillStyle = bgColor
if (
bgR > 0 &&
(
ctx as CanvasRenderingContext2D & {
roundRect?: (x: number, y: number, w: number, h: number, r: number) => void
}
).roundRect
) {
;(
ctx as CanvasRenderingContext2D & {
roundRect?: (x: number, y: number, w: number, h: number, r: number) => void
}
).roundRect(bgX, bgY, bgW, bgH, bgR)
ctx.fill()
} else {
ctx.fillRect(bgX, bgY, bgW, bgH)
}
ctx.restore()
}
const sw = stroke ? Math.max(r(1), r(strokeWidthBase)) : 0
const lineOverrides = cfg.line_overrides || []
lines.forEach((line: string, idx: number) => {
// 描边参数:描边 lineWidth 按 scale 缩放(基准 size * 0.06,最小 2px @720p)
const doStroke = stroke
const strokeWidth = Math.max(r(2), Math.round(size * 0.06))
// 逐行绘制
lines.forEach((line, idx) => {
const y = firstLineY + idx * lineGap
const override = lineOverrides.find((lo) => lo.line_index === idx)
const lineSize = override?.size ? r(Math.max(12, Math.round(override.size))) : size
const lineColor = override?.color || color
const lineBold = override?.bold != null ? !!override.bold : bold
const lineItalic = override?.italic != null ? !!override.italic : italic
const lineStroke = override?.stroke != null ? !!override.stroke : stroke
setFont(lineSize, lineBold, lineItalic)
ctx.fillStyle = lineColor
if (shadow) {
ctx.shadowColor = shadowColor
ctx.shadowBlur = r(shadowBlurBase)
ctx.shadowOffsetX = r(shadowOffsetXBase)
ctx.shadowOffsetY = r(shadowOffsetYBase)
} else {
if (doStroke) {
const prevShadowColor = ctx.shadowColor
const prevShadowBlur = ctx.shadowBlur
// 描边不要带阴影(避免黑色描边发虚)
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
const lineSw = override?.size
? Math.max(r(1), Math.round(lineSize * (strokeWidthBase / baseSize)))
: sw
if (lineStroke && lineSw > 0) {
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.lineWidth = lineSw
ctx.strokeStyle = strokeColor
ctx.lineWidth = strokeWidth
ctx.strokeStyle = "#000000"
ctx.lineJoin = "round"
ctx.strokeText(line, centerX, y)
ctx.restore()
// 恢复阴影
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(4)
} else {
ctx.shadowColor = prevShadowColor
ctx.shadowBlur = prevShadowBlur
}
}
ctx.fillText(line, centerX, y)
if (override?.highlights?.length) {
const fullW = ctx.measureText(line).width
const charW = line.length > 0 ? fullW / line.length : lineSize
override.highlights.forEach((hl) => {
if (!hl.word) return
const pos = line.indexOf(hl.word)
if (pos < 0) return
const hlX = centerX - fullW / 2 + pos * charW + (charW * hl.word.length) / 2
const hlColor = hl.color || "#ffd700"
const hlScale = hl.scale || 1
const hlSize = lineSize * hlScale
const hlBold = hl.bold != null ? !!hl.bold : true
ctx.save()
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
setFont(hlSize, hlBold, lineItalic)
ctx.fillStyle = hlColor
if (lineStroke && lineSw > 0) {
ctx.lineWidth = Math.max(r(1), Math.round(hlSize * (strokeWidthBase / baseSize)))
ctx.strokeStyle = strokeColor
ctx.lineJoin = "round"
ctx.strokeText(hl.word, hlX, y)
}
ctx.fillText(hl.word, hlX, y)
ctx.restore()
})
}
})
try {
@@ -276,6 +138,9 @@ export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | n
}
}
/**
* 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。
*/
export function getVideoResolution(
videoUrl: string,
timeoutMs = 3000,
+226 -136
View File
@@ -8,12 +8,15 @@ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
import { useQuery } from "@tanstack/react-query"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import VoiceSelectModal from "./components/VoiceSelectModal"
import ScriptSelectModal from "./components/ScriptSelectModal"
import TtsVoiceModal from "./components/TtsVoiceModal"
import GenerateHeader from "./components/GenerateHeader"
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
import PreviewCountModal from "./components/PreviewCountModal"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
@@ -21,13 +24,15 @@ import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { finalizeGeneration } from "@/api/generation/finalize"
import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
import { usePointsStore } from "@/store/pointsStore"
import { hasEnoughPoints } from "./hooks/pointsCost"
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
import "./generate.css"
import "./generate-points.css"
@@ -36,9 +41,6 @@ const GeneratePage: React.FC = () => {
/* ── 表单状态 ── */
const formState = useGenerateFormState()
/* ── 标题模板(#2003)当前选中模板 id ── */
const [selectedTitleTemplateId, setSelectedTitleTemplateId] = useState<string | null>(null)
/* ── 积分状态 ── */
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
useEffect(() => {
@@ -74,8 +76,6 @@ const GeneratePage: React.FC = () => {
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsStyle,
setTtsStyle,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
@@ -95,6 +95,8 @@ const GeneratePage: React.FC = () => {
setPreviewTaskId,
storedSourceEditPlanId,
setStoredSourceEditPlanId,
serverClips,
setServerClips,
previewCount,
setPreviewCount,
previewTitles,
@@ -140,9 +142,6 @@ const GeneratePage: React.FC = () => {
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── Step5 保存中状态 ── */
const [finishing, setFinishing] = useState(false)
/* ── #1970 流程重构:分支弹窗 ── */
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
const [scriptModalOpen, setScriptModalOpen] = useState(false)
@@ -154,6 +153,62 @@ const GeneratePage: React.FC = () => {
onTitleSettingsChange: setTitleSettings,
})
/* ── 配音素材库(TTS 试听)── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
const ttsAbortRef = useRef<AbortController | null>(null)
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
useEffect(() => {
if (isBatch) return
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
if (voiceAsset?.file_url) {
setPreviewVoiceAudioUrl(voiceAsset.file_url)
return
}
const ttsTitle = titleSettings.title
const voiceId = selectedClonedVoice || selectedVoice
if (!voiceId || !ttsTitle) {
setPreviewVoiceAudioUrl(null)
return
}
ttsAbortRef.current?.abort()
const controller = new AbortController()
ttsAbortRef.current = controller
let cancelled = false
previewTts({ text: ttsTitle, voice_id: voiceId })
.then((res) => {
if (!cancelled && res.audio_url) {
setPreviewVoiceAudioUrl(res.audio_url)
}
})
.catch((err) => {
if (!cancelled) {
console.warn("[预览配音生成失败]", err)
setPreviewVoiceAudioUrl(null)
}
})
return () => {
cancelled = true
controller.abort()
}
}, [
selectedVoice,
selectedClonedVoice,
titleSettings.title,
variant0Title,
isBatch,
voiceMaterials,
])
/* ── 克隆声音 ── */
const { addClone } = useCloneProgress()
@@ -178,11 +233,30 @@ const GeneratePage: React.FC = () => {
[bgm],
)
/* ── 加载素材详情 ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const {
assets: previewAssets,
ready: previewAssetsReady,
ensureAssets,
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
/* ── 预览就绪 ── */
// #1899: 不再依赖 currentTemplate,素材加载完即可预览
const previewReady = previewAssetsReady
/* ── 批量变体真实片段(#1744) ── */
const batchVoiceLibraryId =
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
const { planIdsByVariant: variantPlanIds } = useBatchVariantPlans({
enabled: isBatch && currentStep === 3,
const {
clipsByVariant: variantClips,
planIdsByVariant: variantPlanIds,
voiceDurationsByVariant: variantVoiceDurations,
loading: variantClipsLoading,
error: variantClipsError,
retry: retryVariantClips,
} = useBatchVariantPlans({
enabled: isBatch && currentStep === 3 && previewAssetsReady,
count: previewCount,
templateId: selectedTemplate || "",
assetIds: previewAssetIds,
@@ -192,6 +266,39 @@ const GeneratePage: React.FC = () => {
voiceModePerVideo,
})
/* ── 批量变体配音预览 URL ── */
const variantVoiceAudioUrls = useVariantVoicePreview({
enabled: isBatch,
count: previewCount,
perVideo: voiceModePerVideo,
sharedVoiceId: selectedVoice || "",
clonedVoiceId: selectedClonedVoice || "",
variantVoiceIds: voiceLibraryIds || [],
titles: previewTitles || [],
})
/* ── 变体 clips 引用素材补拉 ── */
const clipAssetIds = useMemo(() => {
if (!isBatch || !variantClips?.length) return []
const ids = new Set<string>()
variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id)))
return Array.from(ids)
}, [isBatch, variantClips])
useEffect(() => {
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds)
}, [clipAssetIds, ensureAssets])
/* ── 勾选变体 ── */
const toggleVariantSelect = useCallback(
(index: number) => {
setSelectedVariantIds((prev) => {
const list = prev || []
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
})
},
[setSelectedVariantIds],
)
/* ── 视频生成核心逻辑 ── */
const {
generating,
@@ -221,7 +328,6 @@ const GeneratePage: React.FC = () => {
selectedScript,
ttsVoiceId,
ttsVoiceSource,
ttsStyle,
ttsVoiceAssetId,
dedupEnabled,
style,
@@ -303,15 +409,9 @@ const GeneratePage: React.FC = () => {
)
const handleTtsSynthesized = useCallback(
(payload: {
voiceAssetId: string
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
ttsStyle?: string
}) => {
(payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => {
setTtsVoiceId(payload.ttsVoiceId)
setTtsVoiceSource(payload.ttsVoiceSource)
if (payload.ttsStyle) setTtsStyle(payload.ttsStyle)
setTtsVoiceAssetId(payload.voiceAssetId)
if (payload.ttsVoiceSource === "clone") {
setSelectedClonedVoice(payload.ttsVoiceId)
@@ -327,7 +427,6 @@ const GeneratePage: React.FC = () => {
[
setTtsVoiceId,
setTtsVoiceSource,
setTtsStyle,
setTtsVoiceAssetId,
setSelectedVoice,
setSelectedClonedVoice,
@@ -338,22 +437,19 @@ const GeneratePage: React.FC = () => {
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
if (ENABLE_CREDIT_SYSTEM) {
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
check = hasEnoughPoints(
balance ?? null,
units,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
}
// 积分预检查
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const check = hasEnoughPoints(
balance ?? null,
units,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
if (!check.sufficient) {
message.error(check.reason ?? "积分不足,请充值")
return
}
if (isBatch) {
if (selectedVariantIds.length === 0) {
@@ -371,6 +467,10 @@ const GeneratePage: React.FC = () => {
return
}
}
if (!previewReady) {
message.warning("预览素材正在加载,请稍候")
return
}
const ok = await handleGenerate()
if (ok) {
setCurrentStep(4)
@@ -381,6 +481,7 @@ const GeneratePage: React.FC = () => {
previewTitles,
titleSettings.aiAutoSelect,
titleSettings.title,
previewReady,
handleGenerate,
setCurrentStep,
balance,
@@ -408,99 +509,30 @@ const GeneratePage: React.FC = () => {
},
})
/* ── 最终成片(单视频) ── */
/* ── 最终成片 ── */
const finalVideo = generatedVideos[0]
/* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */
const handleFinish = useCallback(async () => {
if (finishing) return
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
if (isBatch) {
if (generatedVideos.length === 0) {
message.warning("请等待视频生成完成")
return
}
} else {
if (!finalVideo) {
message.warning("请等待视频生成完成")
return
}
}
setFinishing(true)
const hide = message.loading("正在保存到视频库...", 0)
try {
const taskIds =
batchTasks && batchTasks.length > 0
? batchTasks.map((t) => t.taskId).filter(Boolean)
: finalVideo?.generation_task_id
? [finalVideo.generation_task_id]
: []
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题)
if (isBatch && previewCovers.length > 0) {
await Promise.all(
taskIds.map(async (taskId, idx) => {
const coverUrl = previewCovers[idx] || ""
return finalizeGeneration(taskId, {
cover_url: coverUrl || undefined,
custom_title: previewTitles[idx] || titleSettings.title || "",
})
}),
)
} else if (finalVideo?.generation_task_id) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
await finalizeGeneration(finalVideo.generation_task_id, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || "",
})
}
hide()
message.success("已保存到视频库")
navigate("/app/products")
} catch (err) {
hide()
console.error("[保存失败]", err)
const detail =
(err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data
?.detail ||
(err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data
?.message ||
"保存失败,请稍后在任务历史查看"
message.error(detail)
} finally {
setFinishing(false)
}
}, [
finishing,
isBatch,
finalVideo,
generatedVideos,
batchTasks,
previewCovers,
previewTitles,
titleSettings.title,
coverSettings,
navigate,
])
/* ── 布局 class ── */
const layoutClassName = "xx-generate-layout full-width"
const layoutClassName = useMemo(() => {
if (currentStep === 3) return "xx-generate-layout step4-layout"
return "xx-generate-layout full-width"
}, [currentStep])
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(() => {
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
return hasEnoughPoints(
balance ?? null,
unitsForCost,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
)
}, [unitsForCost, balance, dailyUsage, rules])
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
const pointsEstimate = useMemo(
() =>
hasEnoughPoints(
balance ?? null,
unitsForCost,
dailyUsage ?? null,
[],
"free",
rules?.free_user_multiplier ?? 1.15,
),
[unitsForCost, balance, dailyUsage, rules],
)
const insufficientPoints = !pointsEstimate.sufficient
/* ================================================================
渲染
@@ -513,7 +545,66 @@ const GeneratePage: React.FC = () => {
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={layoutClassName}>
{/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
{/* ════ 步骤3:左侧预览大区域 ════ */}
{currentStep === 3 && (
<div className="xx-generate-preview-col">
{!isBatch ? (
<FrontendPreviewPlayer
assets={previewAssets}
videoRatio={videoRatio as "9:16" | "16:9"}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
) : (
<div className="xx-form-section">
<div className="xx-preview-header">
<h3>🎬 {previewCount} 个视频预览</h3>
<span
style={{
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
实时预览,勾选要生成的视频
</span>
</div>
<CanvasPreviewGrid
count={previewCount}
assets={previewAssets}
videoRatio={videoRatio as "9:16" | "16:9"}
titles={previewTitles}
titleSettings={titleSettings}
voiceAudioUrls={variantVoiceAudioUrls}
voiceDurations={variantVoiceDurations}
variantClips={variantClips}
clipsLoading={variantClipsLoading}
clipsError={variantClipsError}
onRetryClips={retryVariantClips}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelect}
selectable={!generating}
/>
</div>
)}
</div>
)}
{/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
<div className="xx-generate-form">
<GenerateStepContent
currentStep={currentStep}
@@ -536,15 +627,8 @@ const GeneratePage: React.FC = () => {
onToggleStroke={styleUpdaters.toggleStroke}
onToggleShadow={styleUpdaters.toggleShadow}
onApplyPreset={styleUpdaters.applyPreset}
onUpdateStyle={styleUpdaters.updateStyle}
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
enableTemplates
selectedTemplateId={selectedTitleTemplateId}
onApplyTemplate={(settings, tpl) => {
styleUpdaters.applyTemplate(settings)
setSelectedTitleTemplateId(tpl.id)
}}
bgm={bgm}
bgmConfig={bgmConfig}
coverSettings={coverSettings}
@@ -557,7 +641,11 @@ const GeneratePage: React.FC = () => {
onPreviewCountChange={setPreviewCount}
videoRatio={videoRatio as "9:16" | "16:9"}
onVideoRatioChange={(r) => setVideoRatio(r)}
selectedScript={selectedScript}
ttsVoiceId={ttsVoiceId}
ttsVoiceSource={ttsVoiceSource}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
generating={generating}
generated={generated}
generateError={generateError}
@@ -639,6 +727,12 @@ const GeneratePage: React.FC = () => {
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗 分享
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁 前往成片库
</button>
</div>
</div>
</div>
@@ -658,8 +752,6 @@ const GeneratePage: React.FC = () => {
pointsInsufficient={insufficientPoints}
insufficientReason={pointsEstimate.reason}
onRecharge={() => navigate("/app/points/recharge")}
onFinish={handleFinish}
finishing={finishing}
/>
</div>
</div>
@@ -696,8 +788,6 @@ const GeneratePage: React.FC = () => {
open={ttsModalOpen}
scriptText={selectedScript?.content ?? ""}
scriptTitle={selectedScript?.title ?? ""}
style={ttsStyle}
onStyleChange={setTtsStyle}
onCancel={() => setTtsModalOpen(false)}
onSynthesized={handleTtsSynthesized}
/>
@@ -37,9 +37,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-preview-header">
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
完成{" "}
{tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "}
{tasks.length}
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
</span>
</div>
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
@@ -51,7 +49,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" || task.status === "awaiting_cover" ? (
{task.status === "completed" ? (
<CheckCircleFilled
className="xx-batch-gen-card-icon"
style={{ color: "#52c41a" }}
@@ -85,7 +83,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
</>
)}
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
{task.status === "completed" && video && (
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
<div
@@ -113,7 +111,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
/>
</div>
)}
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
{task.status === "completed" && !video && (
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
)}
{task.status === "failed" && (
@@ -0,0 +1,156 @@
/**
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划)
*
* N 个 FrontendPreviewPlayer 网格排列:
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口;
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
* 加载中 → 9:16 占位防塌陷;
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
* - 勾选框决定提交时生成哪些变体;
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
*/
import React, { useState } from "react"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"
import { Button } from "antd"
import type { TitleSettings } from "../types"
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
interface CanvasPreviewGridProps {
count: number
assets: AssetItem[]
videoRatio: string
titles: string[]
titleSettings: TitleSettings
/**
* 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条;
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
*/
voiceAudioUrls?: (string | null)[]
/**
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined,
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
* 长度=count,undefined 项表示该变体未提供后端时长。
*/
voiceDurations?: (number | undefined)[]
/**
* 各变体的后端真实片段(#1744/#1750):长度=count。
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
*/
variantClips?: EditPlanClip[][]
/** 是否正在向后端申请变体计划 */
clipsLoading?: boolean
/** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */
clipsError?: boolean
/** 用户点击错误态「重试」 */
onRetryClips?: () => void
/** 勾选的变体序号 */
selectedIds: number[]
onToggleSelect: (index: number) => void
/** 生成中禁止勾选 */
selectable?: boolean
}
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
count,
assets,
videoRatio,
titles,
titleSettings,
voiceAudioUrls,
voiceDurations,
variantClips,
clipsLoading = false,
clipsError = false,
onRetryClips,
selectedIds,
onToggleSelect,
selectable = true,
}) => {
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750) ──
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
return (
<div className="xx-canvas-grid">
{Array.from({ length: count }, (_, i) => {
const checked = selectedIds.includes(i)
return (
<div
key={i}
className={`xx-canvas-grid-card${checked ? " selected" : ""}`}
data-variant={i}
>
<div className="xx-canvas-grid-card-bar">
<label className="xx-canvas-grid-check">
<input
type="checkbox"
checked={checked}
disabled={!selectable}
onChange={() => onToggleSelect(i)}
/>
<span>视频 {i + 1}</span>
</label>
</div>
{clipsError ? (
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
<div className="xx-variant-clips-status" role="alert">
<span className="xx-variant-clips-error-text">预览加载失败,请重试</span>
{i === 0 && onRetryClips ? (
<Button
size="small"
icon={<ReloadOutlined />}
onClick={onRetryClips}
style={{ marginTop: 12 }}
>
重试
</Button>
) : null}
</div>
) : clipsLoading || !variantClips?.[i]?.length ? (
// ── 加载态:9:16 占位防塌陷 ──
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
<LoadingOutlined />
<span style={{ marginTop: 8 }}>独立选片中…</span>
</div>
) : (
<FrontendPreviewPlayer
assets={assets}
videoRatio={videoRatio}
ready={assets.length > 0}
playToken={i}
serverClips={variantClips[i]}
variantTitle={titles[i] || ""}
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
voiceDurationHint={voiceDurations?.[i]}
activePlayToken={activePlayToken}
onPlayTokenChange={setActivePlayToken}
compact
titleSettings={{
title: titles[i] || "",
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
/>
)}
</div>
)
})}
</div>
)
}
export default CanvasPreviewGrid
@@ -0,0 +1,602 @@
/**
* 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI)
*
* 架构:
* - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度),
* 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。
* UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。
* - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除),
* 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。
*
* 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。
*/
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { usePreviewAudio } from "../hooks/usePreviewAudio"
import { PreviewControls } from "./PreviewControls"
import { getFontFamily } from "../constants"
interface FrontendPreviewPlayerProps {
assets: AssetItem[]
videoRatio: string
ready: boolean
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
serverClips?: EditPlanClip[]
voiceAudioUrl?: string
titleSettings?: {
title: string
size: number
font: string
color: string
position: "top" | "center" | "bottom" | "custom"
bold?: boolean
italic?: boolean
stroke?: boolean
shadow?: boolean
posX?: number | null
posY?: number | null
}
onTitlePositionChange?: (posX: number, posY: number) => void
/**
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
* 持有播放权的实例播放,其余自动暂停。
*/
playToken?: number
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
variantTitle?: string
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
compact?: boolean
/**
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
*/
activePlayToken?: number | null
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
onPlayTokenChange?: (token: number | null) => void
/**
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
* 未提供则在 audio loadedmetadata 后自测兜底。
*/
voiceDurationHint?: number
}
/**
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback)
*
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
*/
function buildPlaybackSegments(
assets: AssetItem[],
serverClips?: EditPlanClip[],
/** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */
speedFactor = 1,
): PlaybackSegment[] {
if (!assets.length || !serverClips || serverClips.length === 0) return []
const assetMap = new Map(assets.map((a) => [a.id, a]))
const segments: PlaybackSegment[] = []
// speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速
const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1
for (const clip of serverClips) {
const asset = assetMap.get(clip.asset_id)
if (!asset) continue
const assetDuration = asset.duration || asset.metadata?.duration || 0
const startTime = clip.start_time || 0
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
const rawClipDuration = clip.duration || 0
// #1754:按 speedFactor 缩放片段时长,使总时长匹配配音
const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration
const endTime =
assetDuration > 0
? Math.min(startTime + clipDuration, assetDuration)
: startTime + clipDuration
const videoUrl = asset.file_url || asset.storage_key
segments.push({
assetId: asset.id,
videoUrl,
startTime,
endTime,
order: clip.order,
playbackRate: invSpeed !== 1 ? speedFactor : undefined,
})
}
return segments.sort((a, b) => a.order - b.order)
}
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
assets,
videoRatio,
ready,
serverClips,
voiceAudioUrl,
voiceDurationHint,
titleSettings,
onTitlePositionChange,
playToken,
variantTitle,
compact = false,
activePlayToken = null,
onPlayTokenChange,
}) => {
// #1754→P0:配音时长作为音画时长锚点。
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
)
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
const rawClipsDuration = useMemo(() => {
if (!serverClips?.length) return 0
return serverClips.reduce((sum, c) => sum + (c.duration || 0) + (c.transition_duration || 0), 0)
}, [serverClips])
// #1754→#1756:配音时长可用且与 clips+转场 总时长偏差 > 5% 时,按比例调速
const speedFactor = useMemo(() => {
if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1
const ratio = rawClipsDuration / voiceDuration
return Math.abs(ratio - 1) > 0.05 ? ratio : 1
}, [voiceDuration, rawClipsDuration])
const segments = useMemo(
() => buildPlaybackSegments(assets, serverClips, speedFactor),
[assets, serverClips, speedFactor],
)
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
const effectiveTitle = variantTitle ?? titleSettings?.title
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
const TITLE_MARGIN_TOP = 180
const TITLE_MARGIN_BOTTOM = 100
const TITLE_MARGIN_SIDE = 40
const playRes = (() => {
switch (videoRatio) {
case "16:9":
return { width: 1920, height: 1080 }
case "1:1":
return { width: 1080, height: 1080 }
case "9:16":
default:
return { width: 1080, height: 1920 }
}
})()
const customTitleXPct =
titleSettings?.posX != null && playRes.width > 0
? (titleSettings.posX / playRes.width) * 100
: null
const customTitleYPct =
titleSettings?.posY != null && playRes.height > 0
? (titleSettings.posY / playRes.height) * 100
: null
// ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)──
const draggingTitleRef = useRef(false)
const titleDragRef = useRef<HTMLDivElement>(null)
const playerContainerRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
;(e.target as Element).setPointerCapture(e.pointerId)
draggingTitleRef.current = true
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
},
[onTitlePositionChange],
)
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
if (titleDragRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const xpct = (relX / rect.width) * 100
const ypct = (relY / rect.height) * 100
titleDragRef.current.style.left = `${xpct}%`
titleDragRef.current.style.top = `${ypct}%`
}
}, [])
const handleTitlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
if (onTitlePositionChange && playerContainerRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const posX = Math.round((relX / rect.width) * playRes.width)
const posY = Math.round((relY / rect.height) * playRes.height)
onTitlePositionChange(posX, posY)
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
try {
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
}
} catch {
/* ignore */
}
},
[onTitlePositionChange, playRes.width, playRes.height],
)
const [containerHeight, setContainerHeight] = useState(0)
useEffect(() => {
const el = playerContainerRef.current
if (!el) return
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = entry.contentRect.height
if (h > 0) setContainerHeight(h)
}
})
ro.observe(el)
const rect = el.getBoundingClientRect()
if (rect.height > 0) setContainerHeight(rect.height)
return () => ro.disconnect()
}, [])
// 标题字号按容器高度与 PlayResY 的比例缩放
const titleFontSizePx =
containerHeight > 0
? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight
: (titleSettings?.size ?? 36)
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1
const titleStrokeWidth = Math.max(1, 2 * titleScale)
const titleShadowBlur = 4 * titleScale
const titleShadowOffset = 2 * titleScale
// ── Video 播放器(默认路径,浏览器原生硬件解码) ──
const {
isPlaying,
currentTime,
totalDuration,
currentSegmentIndex,
canPlay,
togglePlayPause,
seekTo,
pause,
videoRefs,
} = useSegmentScheduler(segments)
// P0 fix:以配音时长为音画同步锚点。
// 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。
const effectiveTotalDuration =
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
const [muted, setMuted] = useState(false)
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
const hasVoice = !!voiceAudioUrl
// 音频 ended:兜底触发暂停与释放播放权
const handleAudioEnded = useCallback(() => {
if (!isPlaying) return
pause()
if (playToken != null) onPlayTokenChange?.(null)
}, [isPlaying, pause, playToken, onPlayTokenChange])
const {
seekTo: audioSeekTo,
ensurePlayingAt: audioEnsurePlayingAt,
pause: audioPause,
} = usePreviewAudio({
voiceAudioUrl,
voiceDurationHint,
muted,
isPlaying,
currentTime,
onVoiceDurationChange: setVoiceDuration,
onEnded: handleAudioEnded,
})
// 片段切换时同步音频时间(video fallback)
useEffect(() => {
if (!isPlaying) return
audioSeekTo(currentTime)
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSegmentIndex, isPlaying])
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
// 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
const tailStartRef = useRef<number>(0)
const tailBaseRef = useRef<number>(0)
const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause })
tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }
useEffect(() => {
const needTail =
!!voiceAudioUrl &&
voiceDuration > 0 &&
!isPlaying &&
typeof currentTime === "number" &&
currentTime >= totalDuration - 0.1 &&
currentTime < voiceDuration - 0.1
if (needTail && tailCurrentTime === null) {
tailBaseRef.current = currentTime
tailStartRef.current = performance.now()
setTailCurrentTime(currentTime)
tailAudioRef.current.ensurePlayingAt(currentTime)
return
}
if (!needTail && tailCurrentTime !== null) {
setTailCurrentTime(null)
}
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime])
useEffect(() => {
if (tailCurrentTime === null) return
let raf = 0
const tick = () => {
const elapsed = (performance.now() - tailStartRef.current) / 1000
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
setTailCurrentTime(t)
tailAudioRef.current.ensurePlayingAt(t)
if (t >= (voiceDuration || 0) - 0.05) {
tailAudioRef.current.pause()
if (playToken != null) onPlayTokenChange?.(null)
setTailCurrentTime(null)
return
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
const handleSeekTo = useCallback(
(time: number) => {
setTailCurrentTime(null)
seekTo(time)
audioSeekTo(time)
},
[seekTo, audioSeekTo],
)
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ──
useEffect(() => {
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
if (isPlaying) {
pause()
}
// isPlaying 不放依赖:只在 token 变化时执行一次暂停
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePlayToken, playToken])
const handleTogglePlay = useCallback(() => {
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
togglePlayPause()
}, [togglePlayPause, isPlaying, playToken, onPlayTokenChange])
// P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结)
useEffect(() => {
if (!isPlaying) return
if (!voiceAudioUrl || voiceDuration <= 0) return
if (displayCurrentTime < voiceDuration - 0.08) return
pause()
audioPause()
if (playToken != null) onPlayTokenChange?.(null)
}, [
isPlaying,
displayCurrentTime,
voiceAudioUrl,
voiceDuration,
pause,
audioPause,
playToken,
onPlayTokenChange,
])
// ── 未就绪 ──
if (!ready || !assets.length) {
return (
<div
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<SoundOutlined style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }} />
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
准备预览素材...
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
加载素材后即可预览播放
</p>
</div>
)
}
// ── 无播放片段 ──
if (!canPlay) {
return (
<div
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
暂无可播放素材
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
请先在左侧选择素材
</p>
</div>
)
}
return (
<div
ref={playerContainerRef}
style={{
position: "relative",
width: "100%",
maxWidth: compact ? "100%" : 280,
margin: compact ? 0 : "0 auto",
aspectRatio: "9 / 16",
background: compact ? "transparent" : "#0a0a0a",
borderRadius: compact ? 10 : 24,
overflow: "hidden",
boxShadow: compact
? "inset 0 0 0 1px rgba(255,255,255,0.06)"
: "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
}}
>
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
{segments.map((seg, i) => (
<video
key={seg.assetId}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === currentSegmentIndex ? 1 : 0,
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
}}
muted={hasVoice || muted}
playsInline
/>
))}
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{titleSettings?.title && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 5,
pointerEvents: "none",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
width: `${100 - 2 * titleSidePct}%`,
maxWidth: `${100 - 2 * titleSidePct}%`,
...(customTitleXPct != null && customTitleYPct != null
? {
left: `${customTitleXPct}%`,
top: `${customTitleYPct}%`,
transform: "translate(-50%, -50%)",
textAlign: "center" as const,
}
: {
left: "50%",
transform: "translateX(-50%)",
textAlign: "center" as const,
...(titleSettings.position === "top"
? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translate(-50%, -50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
padding: "8px 12px",
boxShadow: "inset 0 0 0 16px transparent",
}}
ref={titleDragRef}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
>
<span
style={{
fontSize: `${titleFontSizePx}px`,
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
color: titleSettings.color || "#ffffff",
fontWeight: titleSettings.bold ? 700 : 400,
fontStyle: titleSettings.italic ? "italic" : "normal",
lineHeight: 1.05,
wordBreak: "break-word",
WebkitTextStroke: titleSettings.stroke
? `${titleStrokeWidth}px #000000`
: undefined,
textShadow: titleSettings.shadow
? `${titleShadowOffset}px ${titleShadowOffset}px ${titleShadowBlur}px rgba(0,0,0,0.8)`
: undefined,
}}
>
{(effectiveTitle || "").split(/[//]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</span>
</div>
</div>
)}
<PreviewControls
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
muted={muted}
onToggleMute={() => setMuted((m) => !m)}
hasSegments={segments.length > 0}
segmentIndex={currentSegmentIndex}
segmentCount={segments.length}
currentTime={displayCurrentTime}
totalDuration={effectiveTotalDuration}
onSeek={handleSeekTo}
compact={compact}
/>
</div>
)
}
export default FrontendPreviewPlayer
@@ -5,7 +5,7 @@
* 步骤 1~3:上一步 / 下一步
* 步骤 4(确认生成/进度):未开始 →「✨ 确认生成视频」;生成中 →「⏳ 视频渲染中…」;
* 失败 →「🔄 重新生成」;全部完成 →「下一步:选择封面 →」
* 步骤 5(选择封面):上一步 + 完成按钮
* 步骤 5(选择封面):仅上一步,无主按钮
*/
import React from "react"
import { Tooltip } from "antd"
@@ -32,10 +32,6 @@ export interface GenerateStepActionsProps {
freeClipsUsedThisTime?: number
/** 前往充值 */
onRecharge?: () => void
/** Step5 点击完成(保存入库并跳转) */
onFinish?: () => void | Promise<void>
/** Step5 保存中 */
finishing?: boolean
}
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
@@ -52,8 +48,6 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
insufficientReason,
freeClipsUsedThisTime,
onRecharge,
onFinish,
finishing,
}) => {
const renderPrimaryButton = () => {
/* 步骤 1~3:上一步 / 下一步 */
@@ -132,18 +126,8 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 5(封面,最后一步):完成按钮 */
return (
<button
className="xx-btn xx-btn-primary"
onClick={() => {
if (onFinish && !finishing) void onFinish()
}}
disabled={finishing}
>
{finishing ? "⏳ 保存中…" : "✅ 完成"}
</button>
)
/* 步骤 5(封面,最后一步):无主按钮 */
return null
}
return (
@@ -4,8 +4,10 @@
* 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。
*/
import React from "react"
import type { EditPlanClip } from "@/api/template-editor"
import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types"
import type { ScriptItem } from "@/api/scripts"
import Step1EditMode from "./Step1EditMode"
import type { EditMode } from "./Step1EditMode"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
@@ -14,7 +16,6 @@ import Step6CoverSettings from "../components/Step6CoverSettings"
import BatchGenerationGrid from "./BatchGenerationGrid"
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleTemplate } from "@/components/title/template-types"
export interface GenerateStepContentProps {
currentStep: number
@@ -50,23 +51,15 @@ export interface GenerateStepContentProps {
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
activePreset: string | null
titlePresets: Array<{
key: string
label: string
emoji?: string
style: Record<string, unknown>
}>
enableTemplates?: boolean
selectedTemplateId?: string | null
onApplyTemplate?: (settings: import("../types").TitleSettings, template: TitleTemplate) => void
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* ── 封面 ── */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/* ── 配音 ── */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
/* ── 生成 ── */
generating: boolean
generated: boolean
@@ -89,6 +82,10 @@ export interface GenerateStepContentProps {
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
selectedVariantIds?: number[]
/* ── 摘要信息(#1970 Step4 展示用) ── */
selectedScript: ScriptItem | null
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -122,14 +119,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onToggleStroke,
onToggleShadow,
onApplyPreset,
onUpdateStyle,
enableTemplates,
selectedTemplateId,
onApplyTemplate,
activePreset,
titlePresets,
coverSettings,
onCoverSettingsChange,
onServerClipsChange,
generating,
generateError,
progress,
@@ -142,8 +136,18 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
selectedScript,
ttsVoiceId,
ttsVoiceSource,
} = props
const handleClipsChange = React.useCallback(
(clips: EditPlanClip[], _templateId?: string) => {
onServerClipsChange(clips)
},
[onServerClipsChange],
)
switch (currentStep) {
case 1:
return (
@@ -171,6 +175,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
clipCount={clipCount}
onClipCountChange={onClipCountChange}
selectedTemplate={selectedTemplate}
onServerClipsChange={handleClipsChange}
/>
)
case 3:
@@ -186,12 +191,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
activePreset={activePreset}
titlePresets={titlePresets}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
@@ -200,6 +201,40 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
case 4:
return (
<div className="xx-form-section">
{/* 配置摘要(#1970) */}
<div
style={{
padding: 14,
background: "#f9fafb",
borderRadius: 8,
marginBottom: 16,
fontSize: 13,
lineHeight: 1.8,
color: "#374151",
}}
>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6, color: "#111" }}>
📋 生成配置
</div>
<div>🎬 剪辑模式:{editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}</div>
{editMode === "random" ? (
<div>🎙️ 配音来源:配音库音频</div>
) : (
<>
<div>📝 文案:{selectedScript?.title ?? "未选择"}</div>
<div>
🎙️ 合成配音音色:
{ttsVoiceId
? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}(${ttsVoiceId.slice(0, 8)}...)`
: "未选择"}
</div>
</>
)}
<div>📱 视频比例:{videoRatio}</div>
<div>🎯 智能降重:{dedupEnabled ? "已开启" : "已关闭"}</div>
{previewCount > 1 && <div>📦 生成数量:{previewCount} 个</div>}
</div>
{previewCount > 1 ? (
<BatchGenerationGrid
tasks={batchTasks}
@@ -0,0 +1,195 @@
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import { PreviewProgressBar } from "./PreviewProgressBar"
interface PreviewControlsProps {
isPlaying: boolean
onTogglePlay: () => void
muted: boolean
onToggleMute: () => void
hasSegments: boolean
segmentIndex: number
segmentCount: number
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条)
*/
export const PreviewControls: React.FC<PreviewControlsProps> = ({
isPlaying,
onTogglePlay,
muted,
onToggleMute,
hasSegments,
segmentIndex,
segmentCount,
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
return (
<>
{/* 静音/有声切换(#1741):左上角 */}
{hasSegments && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={onToggleMute}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${segmentIndex + 1} / ${segmentCount}`}
</div>
{/* 中央播放按钮 */}
{!isPlaying && (
<button
onClick={onTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={onTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<PreviewProgressBar
currentTime={currentTime}
totalDuration={totalDuration}
onSeek={onSeek}
compact={compact}
/>
</div>
</>
)
}
@@ -0,0 +1,108 @@
import React, { useCallback, useEffect, useRef, useState } from "react"
import { formatDuration } from "../utils/formatDuration"
interface PreviewProgressBarProps {
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 进度条组件:点击/拖拽 seek
*/
export const PreviewProgressBar: React.FC<PreviewProgressBarProps> = ({
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)
const seekByClientX = useCallback(
(clientX: number) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
onSeek(ratio * totalDuration)
},
[totalDuration, onSeek],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
seekByClientX(e.clientX)
},
[seekByClientX],
)
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX)
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, seekByClientX])
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
return (
<>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</>
)
}
@@ -1,19 +1,23 @@
/**
* Step 4 选择标题(Issue #1677 批量生成)
*
* 布局:全宽区域,标题输入 + 标题模板卡片网格。
* - 单视频:TitleLibraryAutoComplete 标题库输入
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
* 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格),
* 右侧边栏标题设置。本组件渲染在右侧边栏:
* - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归)
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成
* (一次生成 N 个标题,分别填入各变体,可单独换一个)
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
*/
import React, { useMemo } from "react"
import React, { useMemo, useState } from "react"
import { Input, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS } from "../constants"
import { FONT_OPTIONS } from "@/components/title/constants"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import { useStep4Title } from "../hooks/useStep4Title"
import AiTitleGenerator from "./title/AiTitleGenerator"
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
import TitleStylePanel from "./title/TitleStylePanel"
import type { TitleTemplate } from "@/components/title/template-types"
import { AI_TITLE_TEMPLATES } from "../constants"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
@@ -29,24 +33,44 @@ interface Step4TitleSettingsProps {
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
activePreset: string | null
titlePresets: Array<{
key: string
label: string
emoji?: string
style?: Record<string, unknown>
previewStyle?: React.CSSProperties
}>
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* ── 批量生成(#1677)── */
/** 生成数量 */
previewCount?: number
/** 每个变体的标题文字(长度=previewCount) */
previewTitles?: string[]
onPreviewTitlesChange?: (titles: string[]) => void
enableTemplates?: boolean
selectedTemplateId?: string | null
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
}
/** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */
function buildBatchAiTitles(topic: string, count: number): string[] {
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
const pool: string[] = []
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style] || []
templates.forEach((tpl) => pool.push(tpl.replace(/\{topic\}/g, topic)))
})
// 洗牌后取前 count 个;不足则轮转补齐
const shuffled = [...pool].sort(() => Math.random() - 0.5)
const out: string[] = []
for (let i = 0; i < count; i++) {
out.push(shuffled[i % shuffled.length] || "")
}
return out
}
function extractTopic(text: string): string {
const keywords = text
.replace(/[,。!?、,.!?]/g, " ")
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return "这个话题"
return keywords.slice(0, 3).join("")
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -60,18 +84,16 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onToggleStroke,
onToggleShadow,
onApplyPreset,
onUpdateStyle,
activePreset,
titlePresets,
previewCount = 1,
previewTitles,
onPreviewTitlesChange,
enableTemplates,
selectedTemplateId,
onApplyTemplate,
} = props
const isBatch = previewCount > 1
const [batchAiLoading, setBatchAiLoading] = useState(false)
const [batchAiTopic, setBatchAiTopic] = useState("")
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
const updateVariantTitle = (index: number, val: string) => {
@@ -84,31 +106,117 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
}
}
/** 批量 AI 生成:按主题词生成标题,分别填入 N 个变体 */
const handleBatchAiGenerate = async (onlyEmpty = false) => {
if (!onPreviewTitlesChange || !previewTitles) return
const topic = (batchAiTopic || t.aiTitleInput || "").trim()
if (!topic) {
message.warning("请先输入主题词,例如:萌宠日常、旅行vlog")
return
}
setBatchAiLoading(true)
try {
// 与单视频一致:本地模板模拟 AI 生成(1200ms 体验延迟)
await new Promise((resolve) => setTimeout(resolve, 800))
const picked = buildBatchAiTitles(extractTopic(topic), previewCount)
const next = [...previewTitles]
for (let i = 0; i < previewCount; i++) {
if (onlyEmpty && next[i]?.trim()) continue
if (picked[i]) next[i] = picked[i]
}
onPreviewTitlesChange(next)
if (next[0]) t.updateTitle(next[0])
message.success(`已为 ${previewCount} 个视频生成标题,可单独修改`)
} finally {
setBatchAiLoading(false)
}
}
const titleOptions = useMemo(
() => t.userTitles.map((ut) => ({ label: ut.content, value: ut.content })),
[t.userTitles],
)
return (
<div className="xx-form-section">
<div className="xx-form-section xx-title-sidebar">
<h3>📝 选择标题</h3>
{!isBatch ? (
/* ── 单视频:标题输入框 ── */
<div className="xx-form-field" style={{ maxWidth: 640 }}>
<label>标题</label>
<TitleLibraryAutoComplete
placeholder="输入或从标题库选择"
value={previewTitles?.[0] ?? t.titleSettings.title}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={titleOptions}
/>
</div>
/* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */
<>
{t.titleSettings.aiAutoSelect ? (
<>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label>当前 AI 选定标题</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
</span>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄 换一个
</button>
</div>
</div>
</>
) : (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label>标题</label>
<TitleLibraryAutoComplete
placeholder="输入或从标题库选择"
value={previewTitles?.[0] ?? t.titleSettings.title}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={titleOptions}
/>
</div>
</>
)}
</>
) : (
/* ── 批量:N 个独立标题输入框 ── */
/* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */
<div className="xx-batch-titles">
<div
style={{
@@ -118,11 +226,42 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
lineHeight: 1.6,
}}
>
为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。
</div>
{/* 批量 AI 标题 */}
<div className="xx-batch-ai-row">
<Input
placeholder="主题词,如:萌宠日常、旅行vlog"
value={batchAiTopic || t.aiTitleInput}
onChange={(e) => {
setBatchAiTopic(e.target.value)
t.setAiTitleInput(e.target.value)
}}
maxLength={30}
size="small"
style={{ flex: 1 }}
/>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
disabled={batchAiLoading}
onClick={() => handleBatchAiGenerate(false)}
>
{batchAiLoading ? <LoadingOutlined /> : "✨"} 一键生成 {previewCount} 个标题
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
disabled={batchAiLoading}
onClick={() => handleBatchAiGenerate(true)}
>
补填空标题
</button>
</div>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i} style={{ maxWidth: 640 }}>
<div className="xx-form-field" key={i}>
<label>视频 {i + 1} 标题</label>
<TitleLibraryAutoComplete
placeholder={`输入或选择视频 ${i + 1} 的标题`}
@@ -146,15 +285,10 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
showCoverToggle
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
/>
</div>
)
@@ -1,115 +1,80 @@
/**
* Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传)
* Step 5 选择封面(Issue #1677 批量生成改造)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
*
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
*/
import React, { useEffect } from "react"
import React, { useRef } from "react"
import { Modal, Spin } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { CoverConfig } from "../types/cover"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import { useBatchCovers } from "../hooks/useBatchCovers"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateCover as apiGenerateCover } from "@/api/generation"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/** 当前选中的模板 ID */
selectedTemplate?: string
/** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
/** 确认生成步骤产出的最终视频列表 */
generatedVideos: GeneratedVideo[]
/* ── 批量生成(#1677)── */
previewCount?: number
/** 每个变体的标题文字 */
previewTitles?: string[]
/** 每个变体的封面URL(按变体索引) */
previewCovers?: string[]
onPreviewCoversChange?: (urls: string[]) => void
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
selectedVariantIndexes?: number[]
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const {
coverSettings,
generating,
generateAutoCover,
finalVideo,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
setShowCoverEditor,
selectedTemplateId,
editingTemplate,
coverTemplates,
templatesLoading,
templatesError,
handleSelectTemplate,
handleEditTemplate,
handleSaveTemplate,
handleDeleteTemplate,
} = useStep6Cover({
coverSettings: props.coverSettings,
onCoverSettingsChange: props.onCoverSettingsChange,
selectedTemplate: props.selectedTemplate,
titleSettings: props.titleSettings,
generatedVideos: props.generatedVideos,
})
const previewCount = props.previewCount || 1
const isBatch = previewCount > 1
const previewTitles = props.previewTitles || []
const previewCovers = props.previewCovers || []
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
const cardIndexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: previewCount }, (_, i) => i)
const uploadInputRef = useRef<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
/** 最终成片:取第一个已完成视频(单视频场景) */
const finalVideo =
props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") ||
props.generatedVideos[0]
const shared = useSharedCover({
canGenerate: !!finalVideo,
disabledHint: "请先生成视频再选择封面",
initialTemplateId: "default",
generateFn: async (tplId) => {
if (!finalVideo) return null
const response = await apiGenerateCover(tplId, {
generated_video_id: finalVideo.id,
video_url: finalVideo.file_url || finalVideo.download_url || "",
cover_type: "ai_frame",
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
},
}
: {}),
})
const url = response.cover?.image_url || ""
if (url) {
props.onCoverSettingsChange({
...props.coverSettings,
thumbnail_url: url,
ai_suggested_time: response.cover?.frame_time ?? null,
})
}
return url
},
})
useEffect(() => {
shared.setOnUploadFile(() => null)
}, [shared])
const batchUploadRef = React.useRef<HTMLInputElement>(null)
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
const handleBatchUploadClick = (cardPos: number) => {
setBatchUploadCard(cardPos)
batchUploadRef.current?.click()
}
const handleBatchUploadChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
const cardPos = batchUploadCard
setBatchUploadCard(null)
if (!file || cardPos == null) return
const url = URL.createObjectURL(file)
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
const nextCardView = [...prevCardView]
nextCardView[cardPos] = url
const next = [...(props.previewCovers || [])]
cardIndexes.forEach((vi, i) => {
next[vi] = nextCardView[i] || ""
})
props.onPreviewCoversChange?.(next)
}
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
const batchCovers = useBatchCovers({
@@ -127,6 +92,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
},
covers: batchCoversList,
onCoversChange: (updater) => {
// 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖)
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
const next = [...(props.previewCovers || [])]
@@ -137,11 +103,22 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
},
})
const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
const completedVideos = props.generatedVideos.filter(
(v) => v.status === "completed" || v.status === "awaiting_cover",
)
const handleUploadClick = (variantIndex: number) => {
uploadTargetRef.current = variantIndex
uploadInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) {
const variantIndex = uploadTargetRef.current
const cardPos = cardIndexes.indexOf(variantIndex)
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
}
}
/* ── 批量封面 ── */
if (isBatch) {
@@ -232,7 +209,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => handleBatchUploadClick(cardPos)}
onClick={() => handleUploadClick(variantIndex)}
disabled={isLoading || isUploading}
>
📤 上传
@@ -242,15 +219,24 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
)
})}
</div>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
)
}
/* ── 单视频 ── */
/* ── 单视频:原有流程保持不变 ── */
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{/* 最终成片信息 */}
{finalVideo && (
<div
style={{
@@ -264,55 +250,16 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
}}
>
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div className="xx-cover-actions">
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!finalVideo || shared.generating}
loading={shared.generating}
>
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
✨ 自动生成封面
</Button>
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
⚙️ 封面设置
</Button>
<Button buttonType="ghost" onClick={shared.handleUploadClick}>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
e.target.value = ""
if (!file) return
const url = URL.createObjectURL(file)
props.onCoverSettingsChange({
...props.coverSettings,
upload_url: url,
thumbnail_url: url,
mode: "upload",
})
}}
/>
<input
ref={batchUploadRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleBatchUploadChange}
/>
</div>
<div className="xx-section-title">封面预览</div>
@@ -329,26 +276,30 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
</div>
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => shared.setShowCoverSettings(false)}
templates={shared.templates}
loading={shared.templatesLoading}
error={shared.templatesError}
selectedTemplateId={shared.selectedTemplateId}
onSelectTemplate={shared.handleSelectTemplate}
onEditTemplate={shared.handleEditTemplate}
onDeleteTemplate={shared.handleDeleteTemplate}
onCreateNew={shared.handleCreateTemplate}
open={showCoverSettings}
onClose={() => setShowCoverSettings(false)}
templates={coverTemplates}
loading={templatesLoading}
error={templatesError}
selectedTemplateId={selectedTemplateId}
onSelectTemplate={handleSelectTemplate}
onEditTemplate={handleEditTemplate}
onDeleteTemplate={handleDeleteTemplate}
onCreateNew={() => {
setShowCoverSettings(false)
setShowCoverEditor(true)
}}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
open={showCoverEditor}
onClose={() => setShowCoverEditor(false)}
template={editingTemplate}
onSave={handleSaveTemplate}
/>
<Modal open={shared.generating} closable={false} footer={null} centered>
{/* 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" }}>
@@ -19,8 +19,6 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import { VOICE_GENDER_ICON } from "../constants"
import TtsStyleSelector from "@/components/voice/TtsStyleSelector"
import { DEFAULT_TTS_STYLE, type TtsStyle } from "@/api/tts/styles"
interface TtsVoiceModalProps {
open: boolean
@@ -33,11 +31,7 @@ interface TtsVoiceModalProps {
voiceAssetId: string
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
ttsStyle: TtsStyle
}) => void
/** 当前风格 */
style?: TtsStyle
onStyleChange?: (s: TtsStyle) => void
}
type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error"
@@ -48,15 +42,7 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
scriptTitle,
onCancel,
onSynthesized,
style: externalStyle,
onStyleChange,
}) => {
const [internalStyle, setInternalStyle] = useState<TtsStyle>(DEFAULT_TTS_STYLE)
const currentStyle: TtsStyle = externalStyle ?? internalStyle
const handleStyleChange = (s: TtsStyle) => {
setInternalStyle(s)
onStyleChange?.(s)
}
const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset")
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
const [status, setStatus] = useState<TtsSynthStatus>("idle")
@@ -91,7 +77,6 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
setStatus("idle")
setError(null)
setActiveTab("preset")
setInternalStyle(externalStyle ?? DEFAULT_TTS_STYLE)
} else {
if (timerRef.current) {
clearInterval(timerRef.current)
@@ -106,7 +91,6 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handlePreview = useCallback(
@@ -159,7 +143,6 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
text: textToSynth,
speed: 1.0,
language: "zh-CN",
style: currentStyle,
}
if (isClone) {
payload.voice_clone_profile_id = selectedVoiceId
@@ -204,14 +187,13 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
voiceAssetId: jobId,
ttsVoiceId: selectedVoiceId,
ttsVoiceSource: isClone ? "clone" : "preset",
ttsStyle: currentStyle,
})
} catch (err: unknown) {
setStatus("error")
const msg = err instanceof Error ? err.message : "合成失败,请稍后重试"
setError(msg)
}
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized, currentStyle])
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized])
const renderVoiceCard = (v: {
id: string
@@ -411,10 +393,6 @@ const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
{textToSynth.length} 字
</div>
<div style={{ marginBottom: 12 }}>
<TtsStyleSelector value={currentStyle} onChange={handleStyleChange} compact />
</div>
<Tabs
activeKey={activeTab}
onChange={(k) => {
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,6 @@ import React from "react"
import type { CoverTemplate } from "../../types/cover"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import "@/components/cover/cover.css"
interface CoverSettingsModalProps {
open: boolean
@@ -41,26 +40,12 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
onCreateNew,
}) => {
return (
<Modal
open={open}
onCancel={onClose}
width={800}
title="封面设置"
centered
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确认应用
</Button>
</div>
}
>
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
<div className="xx-cover-modal-toolbar">
<Button buttonType="primary">选择素材文件</Button>
<Button buttonType="ghost">导出全部</Button>
<Button buttonType="primary" onClick={onCreateNew}>
+ 创建新模板
创建新模板
</Button>
</div>
@@ -74,78 +59,52 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
)}
{!loading && !error && templates.length === 0 && (
<div
style={{
textAlign: "center",
padding: "40px 0",
color: "var(--text-secondary)",
fontSize: 13,
}}
>
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
</div>
)}
{!loading && !error && templates.length > 0 && (
{!loading && !error && (
<div className="xx-cover-template-grid">
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id
return (
{templates.map((tpl) => (
<div
key={tpl.id}
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
<div
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
className="xx-cover-template-thumb"
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
>
<div
className="xx-cover-template-thumb"
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
>
{isSelected && <span className="xx-cover-template-check">✓</span>}
🖼️
🖼️
</div>
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统模板</span>}
</div>
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统</span>}
</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
编辑
<div className="xx-cover-template-date">{tpl.created_at}</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
编辑
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
)}
</div>
)}
<Button buttonType="ghost" buttonSize="sm">
导出
</Button>
</div>
</div>
)
})}
</div>
))}
</div>
)}
<div
style={{
marginTop: 12,
padding: "8px 12px",
background: "rgba(124,58,237,0.06)",
borderRadius: 6,
fontSize: 12,
color: "#6d28d9",
}}
>
💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面
</div>
</Modal>
)
}
@@ -0,0 +1,35 @@
/**
* 单个 AI 标题卡片
*/
import React from "react"
import { CheckCircleFilled } from "@ant-design/icons"
interface AiTitleCardProps {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
selected: boolean
onClick: () => void
}
const AiTitleCard: React.FC<AiTitleCardProps> = ({
title,
highlight,
style,
selected,
onClick,
}) => {
return (
<div className={`xx-ai-title-card ${selected ? "selected" : ""} ${style}`} onClick={onClick}>
<div className="xx-ai-title-card-text">{title}</div>
<div className="xx-ai-title-card-tag">{highlight}</div>
{selected && (
<div className="xx-ai-title-card-check">
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
</div>
)}
</div>
)
}
export default AiTitleCard
@@ -0,0 +1,105 @@
/**
* AI 智能生成标题
* 输入框 + 生成按钮 + 结果列表 + 加载状态
*/
import React from "react"
import { LoadingOutlined } from "@ant-design/icons"
import AiTitleCard from "./AiTitleCard"
interface AiTitleItem {
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
}
interface AiTitleGeneratorProps {
inputValue: string
onInputChange: (value: string) => void
generating: boolean
onGenerate: () => void
results: AiTitleItem[]
hasGenerated: boolean
onSelect: (title: string) => void
selectedTitle: string
onRefresh: () => void
}
const AiTitleGenerator: React.FC<AiTitleGeneratorProps> = ({
inputValue,
onInputChange,
generating,
onGenerate,
results,
hasGenerated,
onSelect,
selectedTitle,
onRefresh,
}) => {
return (
<div className="xx-ai-title-section">
<div className="xx-ai-title-header">
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
</div>
<div className="xx-ai-title-input-row">
<input
className="xx-ai-title-input"
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onGenerate()
}}
/>
<button
type="button"
className="xx-btn xx-btn-primary"
onClick={onGenerate}
disabled={generating || !inputValue.trim()}
>
{generating ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
生成中
</>
) : (
"生成标题"
)}
</button>
</div>
{/* 生成结果 */}
{hasGenerated && !generating && results.length > 0 && (
<div className="xx-ai-title-results">
<div className="xx-ai-title-results-header">
<span className="xx-ai-title-results-count">为你生成 {results.length} 个标题</span>
<button type="button" className="xx-link-btn" onClick={onRefresh} disabled={generating}>
🔄 换一批
</button>
</div>
<div className="xx-ai-title-list">
{results.map((item, idx) => (
<AiTitleCard
key={idx}
title={item.title}
highlight={item.highlight}
style={item.style}
selected={selectedTitle === item.title}
onClick={() => onSelect(item.title)}
/>
))}
</div>
</div>
)}
{/* 生成中 */}
{generating && (
<div className="xx-ai-title-loading">
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
AI 正在为你创作标题…
</div>
)}
</div>
)
}
export default AiTitleGenerator
@@ -1,230 +0,0 @@
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleSettings } from "../../types"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
background?: string
/** 高度(可选,默认按 portrait 选比例) */
height?: number
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
transparent?: boolean
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
portrait?: boolean
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
transparent = false,
portrait = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
useEffect(() => {
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景(transparent 时跳过,用于叠加在图片上)
if (!transparent) {
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
}
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半)
const scale = width / 360
const r = (v: number) => Math.round(v * scale)
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
const botMargin = portrait ? r(24) : r(16)
startY = h - totalH - botMargin + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}, [settings, width, h, text, transparent, portrait, background])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background: transparent ? "transparent" : background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -4,7 +4,7 @@
* 避免 -webkit-text-stroke 在 Chromium 中吞掉填充色的问题
*/
import React from "react"
import { getFontFamily } from "@/components/title/constants"
import { getFontFamily } from "../../constants"
interface TitlePresetItem {
key: string
@@ -190,269 +190,3 @@
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
#2001 爆款标题样式面板升级 — 新增样式(ts- 前缀)
============================================================ */
.ts-panel {
position: relative;
}
/* 预览 */
.ts-preview-wrap {
margin-bottom: 14px;
display: flex;
justify-content: center;
padding: 10px;
background: #0f172a;
border-radius: 8px;
}
/* 表单字段 */
.ts-form-field {
margin-bottom: 12px;
}
.ts-form-field label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-primary, #1f2937);
}
.ts-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ts-field-value {
font-size: 12px;
font-weight: 600;
color: var(--primary-color, #7c3aed);
}
.ts-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.ts-half {
margin-bottom: 0;
}
.ts-select {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
background: var(--bg-primary, #fff);
padding: 0 10px;
font-size: 13px;
outline: 0;
color: var(--text-primary, #1f2937);
}
.ts-select:focus {
border-color: var(--primary-color, #7c3aed);
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1);
}
.ts-input {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
outline: 0;
}
.ts-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #e5e7eb;
border-radius: 2px;
outline: none;
}
.ts-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.ts-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
}
/* 样式按钮 B/I/S/☁ */
.ts-style-btns {
display: flex;
gap: 6px;
}
.ts-style-btn {
width: 34px;
height: 34px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: 0.15s;
color: #374151;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ts-style-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.ts-style-btn.active {
background: #faf5ff;
color: #6d28d9;
border-color: #7c3aed;
font-weight: 700;
}
/* 色板 */
.ts-color-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ts-color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e5e7eb;
cursor: pointer;
padding: 0;
transition: 0.15s;
}
.ts-color-swatch:hover {
transform: scale(1.1);
}
.ts-color-swatch.active {
box-shadow: 0 0 0 2px #7c3aed;
transform: scale(1.1);
}
.ts-color-custom {
background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px;
color: #666;
font-size: 14px;
line-height: 20px;
}
.ts-color-native {
width: 0;
height: 0;
border: 0;
padding: 0;
}
/* 预设网格 10个 - 5列 */
.ts-presets-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.ts-preset-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
padding: 4px;
cursor: pointer;
transition: 0.15s;
display: flex;
flex-direction: column;
gap: 4px;
}
.ts-preset-card:hover {
border-color: #7c3aed;
}
.ts-preset-card.active {
border-color: #7c3aed;
background: #faf5ff;
box-shadow: 0 0 0 1px #7c3aed;
}
.ts-preset-preview {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: #0f172a;
}
.ts-preset-preview canvas {
max-width: 100%;
max-height: 100%;
}
.ts-preset-meta {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #4b5563;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 2px 2px;
}
.ts-preset-emoji {
font-size: 11px;
}
.ts-preset-label {
overflow: hidden;
text-overflow: ellipsis;
}
.ts-toggle-row label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 10px;
}
.ts-toggle-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #7c3aed;
}
/* Tabs 紧凑样式 */
.xx-title-style-section .ant-tabs-nav {
margin-bottom: 10px;
}
.xx-title-style-section .ant-tabs-tab {
font-size: 12px !important;
padding: 6px 8px !important;
}
/* 标题模板入口按钮(#2003) */
.ts-template-btn {
border: none;
background: transparent;
color: var(--primary-color, #7c3aed);
font-size: 12px;
cursor: pointer;
padding: 2px 0;
font-weight: 500;
}
.ts-template-btn:hover {
text-decoration: underline;
}
@@ -1,61 +1,23 @@
/**
* 标题样式设置面板(v3 重构)
*
* 三种模式(mode):
* - 「默认」(不传):顶部实时预览 + 爆款预设网格 + 参数 Tab(基础/描边/阴影/背景/排版/封面)
* - 「模板网格」(enableTemplates=true, paramsOnly=false):
* 仅显示顶部标题栏「标题模板」+ 我的模板/系统模板两个分组的大卡片网格,
* 卡片用图片背景 + 真实标题渲染(Canvas 叠加透明渲染),底部始终可见
* 编辑/复制/导出/删除 4 个按钮,选中卡片紫边紫影。
* - 「仅参数」(paramsOnly=true):仅渲染参数 Tab(给编辑器右侧用,不显示预览/预设/模板网格)
*
* 历史:#2001 6 Tab 参数面板;#2003 初版内联模板网格;v3 按 sketch 重写大卡片 + 图片背景
* 标题样式设置区
* 位置/字体/字号/样式按钮/预设
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Tabs, Button, message, Popconfirm } from "antd"
import {
PlusOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
ExportOutlined,
CheckOutlined,
} from "@ant-design/icons"
import React from "react"
import type { TitleSettings } from "../../types"
import {
FONT_OPTIONS as NEW_FONT_OPTIONS,
TITLE_PRESETS,
TITLE_COLOR_PALETTE,
STROKE_COLOR_PALETTE,
BG_COLOR_PALETTE,
} from "@/components/title/constants"
import { buildPresetPreviewSettings } from "@/components/title/utils"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "@/components/title/TitleTemplateEditor"
import { useTitleTemplates } from "@/components/title/useTitleTemplates"
import {
titleStyleConfigToCamel,
templateToPreviewSettings,
camelToTitleStyleConfig,
} from "@/components/title/utils"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../types"
import type { TitleTemplate } from "@/components/title/template-types"
import type { TitleStyleConfig } from "@/components/title/types"
import TitlePresetsGrid from "./TitlePresetsGrid"
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
import "./TitleStylePanel.css"
import "@/components/title/TitleTemplate.css"
interface PositionOption {
value: string
label: string
}
interface LegacyPreset {
interface TitlePresetItem {
key: string
label: string
emoji?: string
style?: Record<string, unknown>
previewStyle?: React.CSSProperties
previewStyle: React.CSSProperties
}
interface TitleStylePanelProps {
@@ -69,151 +31,11 @@ interface TitleStylePanelProps {
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: LegacyPreset[]
titlePresets: TitlePresetItem[]
POSITION_OPTIONS: PositionOption[]
FONT_OPTIONS?: Array<{ value: string; label: string; family?: string; tag?: string }>
onUpdateStyle?: (patch: Partial<TitleSettings>) => void
showCoverToggle?: boolean
previewWidth?: number
/** 启用「标题模板」模式:显示大卡片网格,隐藏参数 Tab 和旧预览 */
enableTemplates?: boolean
/** 当前选中模板 id */
selectedTemplateId?: string | null
/** 应用模板回调(父组件负责合并到 state) */
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
/** 仅渲染参数 Tab(编辑器右侧用,不显示预览/预设/模板网格) */
paramsOnly?: boolean
/** 编辑器模式下,模板编辑器组件不在这里渲染(由父组件承载) */
hideEditor?: boolean
FONT_OPTIONS: string[]
}
/* ── Slider 行 ── */
const SliderRow: React.FC<{
label: string
value: number
min: number
max: number
step?: number
unit?: string
onChange: (v: number) => void
}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => (
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>{label}</label>
<span className="ts-field-value">
{value}
{unit}
</span>
</div>
<input
type="range"
className="ts-slider"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
)
/* ── 色板 ── */
const ColorPicker: React.FC<{
label?: string
value: string
palette: string[]
onChange: (c: string) => void
}> = ({ label, value, palette, onChange }) => {
const [customOpen, setCustomOpen] = useState(false)
return (
<div className="ts-form-field">
{label && <label>{label}</label>}
<div className="ts-color-row">
{palette.map((c) => (
<button
key={c}
type="button"
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
style={{ background: c }}
onClick={() => onChange(c)}
title={c}
/>
))}
<button
type="button"
className="ts-color-swatch ts-color-custom"
onClick={() => setCustomOpen((v) => !v)}
title="自定义颜色"
>
+
</button>
<input
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => {
onChange(e.target.value)
setCustomOpen(false)
}}
style={{
opacity: customOpen ? 1 : 0,
position: customOpen ? "static" : "absolute",
pointerEvents: customOpen ? "auto" : "none",
width: customOpen ? 28 : 0,
height: customOpen ? 28 : 0,
border: "none",
padding: 0,
cursor: "pointer",
background: "transparent",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
当前:<code style={{ fontSize: 11 }}>{value}</code>
</div>
</div>
)
}
/* ── 卡片预览:用 ref 测量容器宽度后再渲染透明 Canvas,保证文字清晰 ── */
const FillPreview: React.FC<{
settings: TitleSettings
sampleText: string
portrait?: boolean
}> = ({ settings, sampleText, portrait }) => {
const wrapRef = useRef<HTMLDivElement>(null)
const [w, setW] = useState(0)
useEffect(() => {
const el = wrapRef.current
if (!el) return
const update = () => setW(Math.floor(el.clientWidth))
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
return (
<div ref={wrapRef} className="tt-fill-canvas-wrap">
{w > 0 && (
<TitleMiniPreview
settings={settings}
width={w}
sampleText={sampleText}
transparent
portrait={portrait}
/>
)}
</div>
)
}
/* ── 模板卡片预览背景图池:按 index 轮换 ── */
const PREVIEW_BG_IMAGES = [
"/title-templates/portrait1.jpg",
"/title-templates/portrait2.jpg",
"/title-templates/scene1.jpg",
]
const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
settings,
onUpdatePosition,
@@ -225,658 +47,107 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
onToggleShadow,
onApplyPreset,
activePreset,
titlePresets: _titlePresets,
titlePresets,
POSITION_OPTIONS,
showCoverToggle = false,
previewWidth = 220,
enableTemplates = false,
selectedTemplateId,
onApplyTemplate,
onUpdateStyle,
paramsOnly = false,
hideEditor = false,
FONT_OPTIONS,
}) => {
const upd = (patch: Partial<TitleSettings>) => {
onUpdateStyle?.(patch)
}
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
/* ── 模板相关 hook + 编辑器 state ── */
const {
templates,
createTemplate,
duplicateTemplate,
updateTemplate,
deleteTemplate,
exportTemplate,
} = useTitleTemplates()
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const grouped = useMemo(
() => ({
builtin: templates.filter((t) => t.isBuiltin),
custom: templates.filter((t) => !t.isBuiltin),
}),
[templates],
)
const handleRequestCreate = useCallback(() => {
// 新建模板:若已有选中模板,以其样式为起点;否则以当前 settings 为起点
// 注意:这里只构造「草稿」对象(不写入 localStorage),保存时再由 handleEditorSave 真正 createTemplate
let base: TitleSettings = settings
if (selectedTemplateId) {
const sel = templates.find((t) => t.id === selectedTemplateId)
if (sel) {
base = {
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(sel.style),
title: settings.title,
aiAutoSelect: settings.aiAutoSelect,
coverTitle: settings.coverTitle,
}
}
}
const draft: TitleTemplate = {
id: "", // 空 id 表示新建草稿
name: "我的标题模板",
emoji: "✨",
isBuiltin: false,
style: camelToTitleStyleConfig({
...base,
position: base.position === "custom" ? "bottom" : base.position,
} as TitleSettings),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setEditingTemplate(draft)
setEditorOpen(true)
}, [settings, selectedTemplateId, templates])
const handleRequestEdit = useCallback((tpl: TitleTemplate) => {
setEditingTemplate(tpl)
setEditorOpen(true)
}, [])
const handleDuplicate = useCallback(
(t: TitleTemplate) => {
const dup = duplicateTemplate(t.id)
if (dup) message.success(`已复制:${dup.name}`)
},
[duplicateTemplate],
)
const handleDelete = useCallback(
(t: TitleTemplate) => {
deleteTemplate(t.id)
message.success("已删除模板")
},
[deleteTemplate],
)
const handleExport = useCallback(
(t: TitleTemplate) => {
const json = exportTemplate(t.id)
if (!json) return
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${t.name}.title-template.json`
a.click()
URL.revokeObjectURL(url)
},
[exportTemplate],
)
const handleSelectTemplate = useCallback(
(tpl: TitleTemplate) => {
const full: TitleSettings = {
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(tpl.style),
title: settings.title,
aiAutoSelect: settings.aiAutoSelect,
coverTitle: settings.coverTitle,
}
onApplyTemplate?.(full, tpl)
},
[onApplyTemplate, settings],
)
const handleEditorSave = useCallback(
(data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => {
if (!editingTemplate) return
let saved: TitleTemplate
if (editingTemplate.isBuiltin || !editingTemplate.id) {
// 系统模板编辑 → 创建副本;新草稿(空 id)→ 新建
saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style })
} else {
// 已有自定义模板 → 更新
updateTemplate(editingTemplate.id, {
name: data.name,
emoji: data.emoji,
style: data.style,
})
saved = {
...editingTemplate,
name: data.name,
emoji: data.emoji,
style: data.style,
updatedAt: new Date().toISOString(),
}
}
setEditorOpen(false)
setEditingTemplate(null)
message.success(`已保存:${data.name}`)
handleSelectTemplate(saved)
},
[editingTemplate, createTemplate, updateTemplate, handleSelectTemplate],
)
/* ── 渲染模板大卡片(v3:图片背景 + 透明 Canvas 叠字 + 底栏始终可见操作按钮) ── */
const renderTemplateCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => {
const isSelected = selectedTemplateId === t.id
const bgIdx = idx % PREVIEW_BG_IMAGES.length
const bgImg = PREVIEW_BG_IMAGES[bgIdx]
// 卡片用的预览 settings:字号按 3:4 竖版比例缩放
const preview = templateToPreviewSettings(t, 42)
return (
<div
key={t.id}
className={`ttv3-card${isSelected ? " selected" : ""}`}
onClick={() => handleSelectTemplate(t)}
>
<div className="ttv3-preview">
<img className="ttv3-bg" src={bgImg} alt="" />
<div className="ttv3-vignette" />
<FillPreview settings={preview} sampleText="预览标题文字" portrait />
<span className={`ttv3-badge ttv3-badge--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
<span className={`ttv3-check${isSelected ? " on" : ""}`}>
{isSelected && <CheckOutlined />}
</span>
</div>
<div className="ttv3-footer">
<div className="ttv3-name-row">
<span className="ttv3-emoji">{t.emoji || "✨"}</span>
<span className="ttv3-name" title={t.name}>
{t.name}
</span>
<span className={`ttv3-tag ttv3-tag--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
</div>
<div className="ttv3-actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="ttv3-act ttv3-act--primary"
disabled={t.isBuiltin}
onClick={() => handleRequestEdit(t)}
title={t.isBuiltin ? "系统模板不可编辑,点击复制后可编辑" : "编辑"}
>
<EditOutlined /> 编辑
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleDuplicate(t)}
title="复制"
>
<CopyOutlined /> 复制
</button>
<button type="button" className="ttv3-act" onClick={() => handleExport(t)} title="导出">
<ExportOutlined /> 导出
</button>
<Popconfirm title="删除该模板?" onConfirm={() => handleDelete(t)}>
<button
type="button"
className="ttv3-act ttv3-act--danger"
disabled={t.isBuiltin}
title={t.isBuiltin ? "系统模板不可删除" : "删除"}
>
<DeleteOutlined /> 删除
</button>
</Popconfirm>
</div>
</div>
</div>
)
}
/* ── 旧爆款预设网格(paramsOnly 或 enableTemplates 时不显示) ── */
const renderPresetGrid = () => (
<div className="ts-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = activePreset === p.key
const previewStyle = buildPresetPreviewSettings(settings, p.key, 56)
return (
<button
key={p.key}
type="button"
className={`ts-preset-card${isActive ? " active" : ""}`}
onClick={() => onApplyPreset(p.key)}
title={p.label}
>
<div className="ts-preset-preview">
<TitleMiniPreview settings={previewStyle} width={120} sampleText="标题" />
</div>
<div className="ts-preset-meta">
<span className="ts-preset-emoji">{p.emoji}</span>
<span className="ts-preset-label">{p.label}</span>
</div>
</button>
)
})}
</div>
)
/* ── 参数 Tab 面板(编辑器右侧复用) ── */
const renderParamsTabs = () => (
<Tabs
size="small"
defaultActiveKey="basic"
items={[
{
key: "basic",
label: "基础",
children: (
<>
<div className="ts-row-2">
<div className="ts-form-field ts-half">
<label>位置</label>
<select
className="ts-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{POSITION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="ts-form-field ts-half">
<label>字体</label>
<select
className="ts-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{NEW_FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
{f.label}
</option>
))}
</select>
</div>
</div>
<SliderRow
label="字号"
value={settings.size}
min={16}
max={120}
onChange={onUpdateSize}
/>
<div className="ts-form-field">
<label>样式</label>
<div className="ts-style-btns">
<button
type="button"
className={`ts-style-btn${settings.bold ? " active" : ""}`}
onClick={onToggleBold}
>
<b>B</b>
</button>
<button
type="button"
className={`ts-style-btn${settings.italic ? " active" : ""}`}
onClick={onToggleItalic}
>
<i>I</i>
</button>
<button
type="button"
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
onClick={() => {
onToggleStroke()
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2)
upd({ strokeWidth: 4 })
}}
title="描边"
>
S
</button>
<button
type="button"
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
onClick={() => {
onToggleShadow()
if (!settings.shadow) {
upd({
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
})
}
}}
title="阴影"
>
☁
</button>
</div>
</div>
<ColorPicker
label="字色"
value={settings.color}
palette={TITLE_COLOR_PALETTE}
onChange={(c) => upd({ color: c })}
/>
</>
),
},
{
key: "stroke",
label: "描边",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
启用描边
</label>
</div>
{settings.stroke && (
<>
<SliderRow
label="描边宽度"
value={settings.strokeWidth ?? 4}
min={0}
max={20}
onChange={(v) => upd({ strokeWidth: v })}
/>
<ColorPicker
label="描边颜色"
value={settings.strokeColor ?? "#000000"}
palette={STROKE_COLOR_PALETTE}
onChange={(c) => upd({ strokeColor: c })}
/>
</>
)}
</>
),
},
{
key: "shadow",
label: "阴影",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
启用阴影
</label>
</div>
{settings.shadow && (
<>
<SliderRow
label="X偏移"
value={settings.shadowOffsetX ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetX: v })}
/>
<SliderRow
label="Y偏移"
value={settings.shadowOffsetY ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetY: v })}
/>
<SliderRow
label="模糊半径"
value={settings.shadowBlur ?? 4}
min={0}
max={30}
onChange={(v) => upd({ shadowBlur: v })}
/>
<div className="ts-form-field">
<label>阴影颜色</label>
<input
type="text"
className="ts-input"
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
onChange={(e) => upd({ shadowColor: e.target.value })}
placeholder="rgba(0,0,0,0.8)"
/>
</div>
</>
)}
</>
),
},
{
key: "bg",
label: "背景",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={settings.bgEnabled}
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
/>
启用背景色块
</label>
</div>
{settings.bgEnabled && (
<>
<ColorPicker
label="背景颜色(含透明度)"
value={settings.bgColor}
palette={BG_COLOR_PALETTE}
onChange={(c) => upd({ bgColor: c })}
/>
<SliderRow
label="内边距"
value={settings.bgPadding}
min={0}
max={40}
onChange={(v) => upd({ bgPadding: v })}
/>
<SliderRow
label="圆角"
value={settings.bgRadius}
min={0}
max={30}
onChange={(v) => upd({ bgRadius: v })}
/>
</>
)}
</>
),
},
{
key: "layout",
label: "排版",
children: (
<>
<SliderRow
label="每行最大字符数"
value={settings.maxCharsPerLine ?? 0}
min={0}
max={20}
unit=""
onChange={(v) => upd({ maxCharsPerLine: v })}
/>
<div
className="ts-form-field"
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
>
0 = 不自动换行(按 / 手动分行)
</div>
<SliderRow
label="行距倍数"
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
min={1}
max={2}
step={0.05}
unit=""
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
/>
<SliderRow
label="顶部边距"
value={settings.marginTop ?? 24}
min={0}
max={200}
onChange={(v) => upd({ marginTop: v })}
/>
</>
),
},
...(showCoverToggle
? [
{
key: "cover",
label: "封面",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={coverOpen}
onChange={(e) => {
setCoverOpen(e.target.checked)
if (!e.target.checked) {
upd({ coverTitle: null })
} else {
upd({
coverTitle: {
font: settings.font,
size: Math.round(settings.size * 0.9),
color: settings.color,
bold: settings.bold,
},
})
}
}}
/>
封面使用独立标题样式
</label>
</div>
{coverOpen && settings.coverTitle && (
<div style={{ fontSize: 12, color: "#6b7280", lineHeight: 1.6 }}>
封面样式已开启。可在「封面设置」面板单独调整封面标题的字体/字号/颜色。
</div>
)}
</>
),
},
]
: []),
]}
/>
)
/* ── 模板模式:只显示网格 ── */
if (enableTemplates && !paramsOnly) {
return (
<div className="xx-title-style-section ttv3-panel">
<div className="ttv3-header">
<span className="ttv3-title">标题模板</span>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleRequestCreate}
className="ttv3-new-btn"
>
新建模板
</Button>
</div>
<div className="ttv3-section">
<div className="ttv3-section-label">我的模板</div>
{grouped.custom.length === 0 ? (
<div className="ttv3-empty">
<div className="ttv3-empty-icon">✨</div>
<div className="ttv3-empty-text">还没有自定义模板,点右上角「新建模板」创建</div>
</div>
) : (
<div className="ttv3-grid">
{grouped.custom.map((t, i) => renderTemplateCard(t, i, "mine"))}
</div>
)}
</div>
<div className="ttv3-section">
<div className="ttv3-section-label">系统模板</div>
<div className="ttv3-grid">
{grouped.builtin.map((t, i) => renderTemplateCard(t, i, "sys"))}
</div>
</div>
{!hideEditor && editorOpen && editingTemplate && (
<TitleTemplateEditor
open={editorOpen}
template={editingTemplate}
onClose={() => {
setEditorOpen(false)
setEditingTemplate(null)
}}
onSave={handleEditorSave}
/>
)}
</div>
)
}
/* ── paramsOnly:仅参数 Tab ── */
if (paramsOnly) {
return <div className="xx-title-style-section ttv3-params-only">{renderParamsTabs()}</div>
}
/* ── 默认模式:顶部预览 + 预设 + 参数 Tab ── */
return (
<div className="xx-title-style-section ts-panel">
{/* 实时迷你预览 */}
<div className="ts-preview-wrap">
<TitleMiniPreview
settings={settings}
width={previewWidth}
sampleText={settings.title || "预览标题文字"}
/>
</div>
<div className="xx-title-style-section">
<h4 className="xx-section-subtitle">标题样式</h4>
{/* 爆款预设 */}
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>爆款预设</label>
{activePreset && <span className="ts-field-value">已选</span>}
{/* 位置 + 字体 一行 */}
<div className="xx-title-style-row">
<div className="xx-form-field xx-half-field">
<label>位置</label>
<select
className="xx-form-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="xx-form-field xx-half-field">
<label>字体</label>
<select
className="xx-form-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
{renderPresetGrid()}
</div>
{renderParamsTabs()}
{!hideEditor && editorOpen && editingTemplate && (
<TitleTemplateEditor
open={editorOpen}
template={editingTemplate}
onClose={() => {
setEditorOpen(false)
setEditingTemplate(null)
}}
onSave={handleEditorSave}
{/* 字号滑块 */}
<div className="xx-form-field">
<div className="xx-field-label-row">
<label>字号</label>
<span className="xx-field-value">{settings.size}px</span>
</div>
<input
className="xx-slider"
type="range"
min={12}
max={128}
value={settings.size}
onChange={(e) => onUpdateSize(Number(e.target.value))}
/>
)}
</div>
{/* 预设样式 */}
<div className="xx-form-field">
<label>预设样式</label>
<TitlePresetsGrid
presets={titlePresets}
activePreset={activePreset}
onApply={onApplyPreset}
fontFamily={settings.font}
/>
</div>
{/* 样式按钮:粗体/斜体/描边/阴影 */}
<div className="xx-form-field">
<label>样式</label>
<div className="xx-style-btns">
<button
className={`xx-style-btn ${settings.bold ? "active" : ""}`}
onClick={onToggleBold}
title="粗体"
>
<b>B</b>
</button>
<button
className={`xx-style-btn ${settings.italic ? "active" : ""}`}
onClick={onToggleItalic}
title="斜体"
>
<i>I</i>
</button>
<button
className={`xx-style-btn ${settings.stroke ? "active" : ""}`}
onClick={onToggleStroke}
title="描边"
>
S
</button>
<button
className={`xx-style-btn ${settings.shadow ? "active" : ""}`}
onClick={onToggleShadow}
title="阴影"
>
☁
</button>
</div>
</div>
</div>
)
}
+41 -2
View File
@@ -54,8 +54,22 @@ export const POSITION_OPTIONS = [
{ value: "custom", label: "自定义" },
]
/* ── 标题字体:统一使用公共层定义(#2001) ── */
export { getFontFamily } from "@/components/title/constants"
/* ── 标题字体选项 ── */
export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"]
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
export const FONT_FAMILY_MAP: Record<string, string> = {
思源黑体: '"Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
思源宋体: '"Source Han Serif SC", "Noto Serif SC", "Songti SC", "SimSun", serif',
苹方: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
PingFang: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
微软雅黑: '"Microsoft YaHei", "PingFang SC", sans-serif',
楷体: '"KaiTi", "STKaiti", "DFKai-SB", serif',
}
export function getFontFamily(font: string): string {
return FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
}
/* ── 标题样式预设 ── */
export const TITLE_PRESETS = [
@@ -535,6 +549,31 @@ export const SMART_MATCH_REASONS = [
"人物表情生动",
]
/* ── AI 标题模板 ── */
export const AI_TITLE_TEMPLATES: Record<string, string[]> = {
catchy: [
"震惊!{topic}居然还能这样操作",
"99%的人都不知道的{topic}秘诀",
"{topic}的终极指南,看完直接封神",
"别再走弯路了!{topic}看这一篇就够",
"一个视频讲透{topic},建议收藏",
],
emotional: [
"致每一个在{topic}路上坚持的人",
"关于{topic},我想说句真心话",
"{topic}背后的故事,看完沉默了",
"为什么我劝你一定要了解{topic}",
"这才是{topic}最动人的样子",
],
informative: [
"{topic}完整科普:从入门到精通",
"深度解析{topic}的核心原理",
"{topic}行业趋势报告|2026最新版",
"三分钟带你全面了解{topic}",
"{topic}常见问题与解决方案汇总",
],
}
/* ── 默认封面设置 ── */
export const DEFAULT_COVER_SETTINGS: CoverConfig = {
enabled: true,
+150 -449
View File
@@ -2739,7 +2739,6 @@
justify-content: center;
font-size: 32px;
color: #ccc;
position: relative;
}
/* 卡片信息区 */
@@ -2881,6 +2880,27 @@
Issue #1677 多视频批量生成
================================================================ */
/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr 380px;
align-items: start;
}
.xx-generate-preview-col {
min-width: 0;
position: sticky;
top: 16px;
}
.xx-generate-preview-col .xx-form-section {
margin: 0;
}
.xx-title-sidebar {
max-height: calc(100vh - 140px);
overflow-y: auto;
}
/* ── 数量选择弹窗 ── */
.xx-modal-mask {
position: fixed;
@@ -3220,6 +3240,135 @@
border-radius: 4px;
}
/* ── 响应式:窄屏 Step4 回退单列 ── */
@media (max-width: 960px) {
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr;
}
.xx-generate-preview-col {
position: static;
}
.xx-title-sidebar {
max-height: none;
}
}
/* ============================================================
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
============================================================ */
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
.xx-canvas-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
justify-content: center;
gap: 14px;
}
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
@media (max-width: 960px) {
.xx-canvas-grid {
grid-template-columns: minmax(0, 320px);
}
}
.xx-canvas-grid-card {
position: relative;
border: 2px solid var(--border-primary, #e2e8f0);
border-radius: 12px;
overflow: hidden;
background: transparent;
transition: border-color 0.2s ease;
min-width: 0;
}
.xx-canvas-grid-card.selected {
border-color: var(--primary-color, #1677ff);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
}
.xx-canvas-grid-card-bar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
padding: 6px 10px;
background: var(--bg-surface, #fff);
border-bottom: 1px solid var(--border-primary, #e2e8f0);
}
.xx-canvas-grid-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
cursor: pointer;
user-select: none;
}
.xx-canvas-grid-check input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary-color, #1677ff);
}
/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */
.xx-variant-clips-status {
position: relative;
width: 100%;
aspect-ratio: 9 / 16;
max-height: 70vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
background: #0a0a0a;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 20px 50px -12px rgba(0, 0, 0, 0.5),
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.xx-variant-clips-status .anticon {
font-size: 22px;
}
.xx-variant-clips-error-text {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
}
/* ============================================================
批量标题:AI 一键生成行(Issue #1677)
============================================================ */
.xx-batch-ai-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: var(--bg-secondary, #f7f8fa);
border: 1px dashed var(--border-primary, #d9d9d9);
border-radius: 10px;
}
.xx-batch-ai-row .xx-form-field {
margin: 0;
flex: 1;
min-width: 140px;
}
.xx-batch-titles {
display: flex;
flex-direction: column;
@@ -3336,451 +3485,3 @@
grid-template-columns: minmax(0, 360px);
}
}
/* ================================================================
自定义封面编辑器 (Cover Editor Modal) — xx-ce-*
================================================================ */
/* Header */
.xx-ce-header {
margin-bottom: 16px;
}
.xx-ce-name-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-sm, 6px);
font-size: 14px;
margin-bottom: 12px;
outline: none;
}
.xx-ce-name-input:focus {
border-color: #7c3aed;
}
.xx-ce-header-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Layout */
.xx-ce-layout {
display: flex;
gap: 20px;
min-height: 500px;
}
.xx-ce-left {
width: 300px;
flex-shrink: 0;
max-height: 70vh;
overflow-y: auto;
}
.xx-ce-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
min-height: 480px;
}
/* Section / collapsible panels */
.xx-ce-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
}
.xx-ce-section-header {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4ff;
user-select: none;
}
.xx-ce-section-header:hover {
background: #e8edf8;
}
.xx-ce-section-body {
padding: 12px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.xx-ce-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-status-text {
font-size: 11px;
font-weight: 400;
color: #3b82f6;
}
/* Rows / labels */
.xx-ce-row {
margin: 12px 0;
}
.xx-ce-label {
display: block;
font-size: 12px;
color: #374151;
margin-bottom: 4px;
}
.xx-ce-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.xx-ce-sub-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.xx-ce-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-ce-switch-item {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.xx-ce-switch-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
/* Color picker */
.xx-ce-color-picker {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-color-picker input[type="color"] {
width: 32px;
height: 24px;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
background: none;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.xx-ce-color-hex {
width: 70px;
padding: 2px 6px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
/* Position pair */
.xx-ce-position {
display: flex;
gap: 8px;
}
.xx-ce-position .ant-input-number {
flex: 1;
}
/* Radio button group */
.xx-ce-radio-group {
display: flex;
gap: 0;
}
.xx-ce-radio-btn {
padding: 4px 14px;
font-size: 12px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
.xx-ce-radio-btn:first-child {
border-radius: 4px 0 0 4px;
}
.xx-ce-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
.xx-ce-radio-btn + .xx-ce-radio-btn {
border-left: none;
}
.xx-ce-radio-btn.active {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
border-left: 1px solid #d1d5db;
}
/* Font select dots */
.xx-ce-font-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.xx-ce-font-dot--preset {
background: #10b981;
}
.xx-ce-font-dot--system {
background: #3b82f6;
}
/* Shadow actions */
.xx-ce-shadow-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.xx-ce-add-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-add-shadow-btn:hover {
background: #6d28d9;
}
.xx-ce-preset-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
}
/* Text background sub-section */
.xx-ce-text-bg-section {
margin-top: 8px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
/* Readonly text display */
.xx-ce-readonly-text {
padding: 6px 10px;
background: #eff6ff;
border-radius: 4px;
font-size: 13px;
color: #1e40af;
margin-bottom: 4px;
}
/* Mask file row */
.xx-ce-file-row {
display: flex;
gap: 6px;
align-items: center;
}
.xx-ce-file-name {
flex: 1;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: #f9fafb;
color: #6b7280;
}
.xx-ce-file-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.xx-ce-file-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
/* ── Canvas / Preview ── */
.xx-ce-canvas-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.xx-ce-canvas {
width: 225px;
height: 400px;
background: #ddd;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.xx-ce-anchor-dot {
position: absolute;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
z-index: 5;
}
/* Portrait element */
.xx-ce-el-portrait {
position: absolute;
background: #a8d4f0;
border: 2px solid #333;
z-index: 2;
}
/* 8 handles: 0=TL 1=T 2=TR 3=R 4=BR 5=B 6=BL 7=L */
.xx-ce-handle {
position: absolute;
width: 8px;
height: 8px;
background: #3b82f6;
border: 1px solid #fff;
z-index: 10;
}
.xx-ce-handle--0 {
top: -4px;
left: -4px;
}
.xx-ce-handle--1 {
top: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--2 {
top: -4px;
right: -4px;
}
.xx-ce-handle--3 {
top: 50%;
right: -4px;
margin-top: -4px;
}
.xx-ce-handle--4 {
bottom: -4px;
right: -4px;
}
.xx-ce-handle--5 {
bottom: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--6 {
bottom: -4px;
left: -4px;
}
.xx-ce-handle--7 {
top: 50%;
left: -4px;
margin-top: -4px;
}
/* Background element */
.xx-ce-el-bg {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
/* Mask overlay */
.xx-ce-el-mask {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
/* Text background shape in canvas */
.xx-ce-text-bg {
position: absolute;
z-index: -1;
}
/* Cover template selected check */
.xx-cover-template-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
background: #7c3aed;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
z-index: 2;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
.xx-cover-template-thumb {
position: relative;
}
/* Preview tip */
.xx-ce-preview-tip {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: #6b7280;
}
/* Cover editor modal base gradient */
.xx-ce-canvas {
background: #1a1a2e;
}
/* Antd Slider overrides for editor */
.xx-ce-section-body .ant-slider {
margin: 4px 0 8px;
}
.xx-ce-section-body .ant-slider-rail {
background: #e5e7eb;
}
.xx-ce-section-body .ant-slider-track {
background: #3b82f6;
}
.xx-ce-section-body .ant-slider-handle::after {
box-shadow: 0 0 0 2px #3b82f6;
}
.xx-ce-section-body .ant-slider-mark-text {
font-size: 10px;
}
/* Antd Select dropdown font dots */
.xx-ce-font-select-dropdown .ant-select-item-option-content {
display: flex;
align-items: center;
}
/* Canvas text elements — ensure proper stacking */
.xx-ce-canvas > div {
pointer-events: none;
}
@@ -22,8 +22,6 @@ export interface UseGenerateVideoProps {
ttsVoiceId?: string
/** TTS 音色来源 */
ttsVoiceSource?: "preset" | "clone"
/** TTS 配音风格 */
ttsStyle?: string
/** 合成后保存到配音库的 asset id / job id(叙事模式) */
ttsVoiceAssetId?: string
/** 智能降重开关(默认 true) */
@@ -10,7 +10,7 @@ export interface BatchTaskState {
taskId: string
/** 变体序号(0-based,与标题/封面数组对齐) */
variantIndex: number
status: "running" | "completed" | "awaiting_cover" | "failed"
status: "running" | "completed" | "failed"
progress: number
error: string | null
/** 完成后的成片视频 */
@@ -96,7 +96,7 @@ export function useGenerationPolling({
runId: number,
callbacks?: {
onTaskProgress?: (pct: number) => void
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
onTaskCompleted?: (videos: unknown[]) => void
onTaskFailed?: (msg: string) => void
},
): Promise<unknown[]> => {
@@ -111,7 +111,7 @@ export function useGenerationPolling({
if (cancelledRef.current || done) return
consecutiveErrors = 0
if (task.status === "completed" || task.status === "awaiting_cover") {
if (task.status === "completed") {
done = true
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
@@ -121,7 +121,7 @@ export function useGenerationPolling({
reject(new Error(msg))
return
}
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
callbacks?.onTaskCompleted?.(videos)
resolve(videos)
return
}
@@ -259,11 +259,10 @@ export function useGenerationPolling({
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
reportAggregateProgress()
},
onTaskCompleted: (videos, taskStatus) => {
onTaskCompleted: (videos) => {
progressMap.set(taskId, 100)
resultMap.set(taskId, videos)
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
reportAggregateProgress()
checkAllSettled()
},
@@ -294,9 +293,8 @@ export function useGenerationPolling({
}
pollSingleTask(taskId, Date.now(), {
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
onTaskCompleted: (videos, taskStatus) => {
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
onTaskCompleted: (videos) => {
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
message.success(`视频 ${variantIndex + 1} 重试成功`)
},
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
@@ -30,30 +30,15 @@ interface UseBatchCoversOptions {
color: string
position: string
bold: boolean
italic?: boolean
stroke: boolean
strokeWidth?: number
strokeColor?: string
shadow: boolean
shadowOffsetX?: number
shadowOffsetY?: number
shadowBlur?: number
shadowColor?: string
lineHeight?: number
marginTop?: number
maxCharsPerLine?: number
bgEnabled?: boolean
bgColor?: string
bgPadding?: number
bgRadius?: number
lineOverrides?: unknown[]
}
covers: string[]
onCoversChange: CoversChangeFn
}
export function useBatchCovers({
selectedTemplate: _selectedTemplate,
selectedTemplate,
generatedVideos,
titles,
titleStyle,
@@ -93,12 +78,7 @@ export function useBatchCovers({
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
const generateOne = useCallback(
async (index: number): Promise<boolean> => {
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" ||
v.status === "awaiting_cover" ||
v.status === "awaiting_cover",
)
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
@@ -107,7 +87,7 @@ export function useBatchCovers({
addBusy(index)
try {
const titleText = titles[index] || ""
const response = await generateCover("default", {
const response = await generateCover(selectedTemplate, {
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
@@ -120,37 +100,8 @@ export function useBatchCovers({
font_color: titleStyle.color,
position: titleStyle.position,
bold: titleStyle.bold,
italic: titleStyle.italic,
stroke: titleStyle.stroke
? {
enabled: true,
width: titleStyle.strokeWidth ?? 4,
color: titleStyle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: titleStyle.shadow
? {
enabled: true,
offset_x: titleStyle.shadowOffsetX ?? 2,
offset_y: titleStyle.shadowOffsetY ?? 2,
blur: titleStyle.shadowBlur ?? 4,
color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: titleStyle.lineHeight ?? 1.2,
margin_top: titleStyle.marginTop ?? 24,
max_chars_per_line: titleStyle.maxCharsPerLine ?? 0,
background: titleStyle.bgEnabled
? {
enabled: true,
color: titleStyle.bgColor,
padding: titleStyle.bgPadding,
radius: titleStyle.bgRadius,
}
: { enabled: false },
line_overrides: (titleStyle.lineOverrides ?? []) as Array<
Record<string, unknown>
>,
stroke: titleStyle.stroke,
shadow: titleStyle.shadow,
},
}
: {}),
@@ -171,7 +122,7 @@ export function useBatchCovers({
removeBusy(index)
}
},
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
)
/** 为第 index 个视频上传自定义封面 */
@@ -208,10 +159,7 @@ export function useBatchCovers({
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
)
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const total = finalVideos.length
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包

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