Compare commits

..

109 Commits

Author SHA1 Message Date
CI Bot bdab390918 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
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 / Check push changed paths (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m40s
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) Waiting to run
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Waiting to run
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m19s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m2s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has started running
CI/CD Pipeline / Validate - Security (pull_request) Successful in 12m12s
CI/CD Pipeline / Build Production API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Worker Image (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) Waiting to run
2026-09-15 15:33:20 +00:00
xiaoxia-agent 8d743c3c23 feat(points): P2 step 2.4 - wire generation_tasks points gate (ai_video scene)
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 / Check push changed paths (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m7s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m19s
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 / PR Build API Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 12m19s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 13m36s
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 / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
2026-09-15 22:48:36 +08:00
xiaoxia-agent 0f8511e72a fix(points): @points_gate decorator filter internal kwargs to avoid TypeError on routes without **kwargs 2026-09-15 22:48:36 +08:00
CI Bot b0b597caa8 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
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 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m46s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
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 API Image (pull_request) Successful in 10s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12s
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 / Validate - Style (pull_request) Successful in 13m26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m44s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 11m54s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 12m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 16m11s
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 / Validate - Security (pull_request) Successful in 37m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
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 / Deploy Production (pull_request) Waiting to run
CI/CD Pipeline / Canary Release to Production (pull_request) Waiting to run
2026-09-15 22:48:36 +08:00
xiaoxia-agent 73efe79357 feat(points): P2 step 2.3 - wire scripts_ai 3 endpoints points gate
- extract_from_douyin: @points_gate("douyin_extract") 1分/次
- ai_rewrite: @points_gate("ai_rewrite") 1分/次
- ai_generate_titles: @points_gate("ai_title") 1分/次
- All gated by settings.points_enabled (default false)
- Refund on exception handled by decorator
- Renamed authenticated_user → current_user in route + tests for decorator compat
- 4 new points tests; 32+4=36 scripts_ai tests pass; 15472 total passed
2026-09-15 22:48:36 +08:00
xiaoxia-agent 42c122690d fix(points): @points_gate decorator filter internal kwargs to avoid TypeError on routes without **kwargs 2026-09-15 22:48:35 +08:00
xiaoxia-agent a082090edf feat(points): P2 step 2.2 - wire lipsync points gate
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m47s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 11s
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 / PR Build Worker Image (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- create_lipsync_job(): deduct ai_digital_human points (15/min), estimated from audio_duration (if provided) or script_text (chars/240), min 1 min; refund on ValueError/MediaKitError/Exception; refund on failed job
- preview_tts(): deduct ai_digital_human points by script_text length; refund on MediaKitError/Exception
- Both gated by settings.points_enabled (default false)
- Raises HTTP 402 INSUFFICIENT_POINTS on insufficient balance
- 14 new unit tests; 15468 total passed
2026-09-15 22:48:22 +08:00
xiaoxia-agent 26776b63e6 test(points): add unit tests for TTS + voice_clone preview points gating
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 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m53s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 11s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 12s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 11m42s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m26s
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 / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- Estimate minutes (ceil(chars/240), min 1) parametrized
- Disabled gate (zero cost), min cost, insufficient (402), member discount
- Refund called on exception
- voice_clone_synth scene cost validation
2026-09-15 22:47:41 +08:00
xiaoxia-agent 7c9fe76c54 feat(points): P2 step 2.1 - wire TTS + voice_clone preview points gate
- synthesize(): deduct ai_voice points (1/min, est ceil(chars/240)), refund on workflow/Celery failure
- preview_tts(): deduct ai_voice points, refund on CosyVoiceError/ValueError
- get_voice_clone_preview(): deduct voice_clone_synth points (1/min), refund on CosyVoiceError/ValueError
- All points logic gated by settings.points_enabled (default false)
- Raises HTTP 402 INSUFFICIENT_POINTS when balance insufficient
2026-09-15 22:47:41 +08:00
xiaoxia-agent 61d998bb01 fix(points): make @points_gate wrapper inherit decorated module globals
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
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 / PR Build API Image (pull_request) Successful in 17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 18s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m43s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 11m36s
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 / 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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Previously the async/sync wrappers were defined inside the points_gate
module, so wrapper.__globals__ pointed to packages.middleware.points_gate
instead of the route module. When FastAPI/Pydantic resolved ForwardRef
type annotations (e.g. Pydantic models imported only in the route
module), they hit PydanticUndefinedAnnotation in environments where
typing.get_type_hints didn't walk __wrapped__ (Python 3.12 fast path).

Fix: clone the wrapper code via types.FunctionType into the decorated
function's module globals (with internal helpers injected), and use
_filter_kwargs consistently in all call paths (async passthrough and
gate-enabled paths included).
2026-09-15 22:47:34 +08:00
xiaoxia-agent 8343a1211e chore: trigger CI
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
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 / 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 / PR Build API Image (pull_request) Successful in 54s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
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 56s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 2h3m11s
AI Code Review / AI Code Review (pull_request) Failing after 2h3m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
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 / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-09-15 18:51:08 +08:00
CI Bot 7aa5e56cd6 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been 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 / 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 / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 28s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 44s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 55s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m46s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m1s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
2026-09-15 10:44:27 +00:00
xiaoxia-agent f587b7a44d feat(points): P2 step 1 - add POINTS_ENABLED safety gate with default false
CI/CD Pipeline / Check push changed paths (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 / 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 / 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 / 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 / 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 1m33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
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 / PR Build Worker Image (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 Merge on CI Green + Approved (pull_request) Has been cancelled
- Add points_enabled: bool = False to SharedSettings (packages/config/base.py)
- Add POINTS_ENABLED=false to .env.example with documentation
- Add _points_gate_enabled() module-level helper in points_gate middleware
- When disabled (default), @points_gate decorator is pure pass-through (zero side effects)
- When enabled, existing deduct/refund/402/401/500 logic activates as before
- Add 4 unit tests covering: default false / env override / disabled passthrough / enabled enforcement
- Fix existing points_gate tests to monkeypatch _points_gate_enabled=True (since default is now off)
- Total: 15442 passed, 28 skipped; black/ruff clean
2026-09-15 18:40:11 +08:00
xiaoxia dc0a5834f1 feat(scripts): #1893 文案库对接三个AI能力(抖音提取/AI改写/AI生成标题) (#1935)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m42s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 5m10s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m43s
CI/CD Pipeline / Validate - Style (push) Successful in 5m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m5s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m9s
CI/CD Pipeline / Unit Tests (push) Successful in 11m39s
CI/CD Pipeline / Validate - Security (push) Successful in 14m30s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat(scripts): #1893 AI能力对接(抖音提取/AI改写/AI生成标题)
2026-09-15 16:49:05 +08:00
xiaoxia 59fbbd8e26 fix(tts): P1 TTS emotion instruction format per voice type + missing CN aliases (#1934)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m54s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m22s
CI/CD Pipeline / Integration Tests (push) Successful in 4m52s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m47s
CI/CD Pipeline / Validate - Style (push) Successful in 5m48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m32s
CI/CD Pipeline / Unit Tests (push) Successful in 10m38s
CI/CD Pipeline / Validate - Security (push) Successful in 15m27s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(tts): P1 TTS emotion instruction format per voice type + missing CN aliases

- Add CN aliases 中性→neutral/伤心→sad/吃惊→surprised per 灵应 spec
- build_emotion_instruction(voice_id, enum): cloned voices use English "Speak in a {emotion} tone.", system voices with emotion Instruct (longanyang/longanhuan/longhuhu_v3) use strict Chinese format "你说话的情感是{emotion}。", default voice longxiaochun_v3 (no Instruct support) skips instruction entirely
- Expand preview emotion whitelist; 210 emotion tests passing, 15438 total unit tests passing
- E2E templates fix already in PR#1931 (0e5127df), no additional change needed
2026-09-15 16:00:58 +08:00
xiaoxia 81180d16e5 fix(#1898): TTS 情绪映射修正为 CosyVoice v3 官方英文枚举(P1 更正) (#1933)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m9s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m16s
CI/CD Pipeline / Retag skipped Staging Worker Image (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 / Integration Tests (push) Successful in 3m39s
CI/CD Pipeline / Validate - Style (push) Successful in 4m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m21s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m59s
CI/CD Pipeline / Unit Tests (push) Successful in 9m13s
CI/CD Pipeline / Validate - Security (push) Successful in 11m27s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(tts): P1 TTS emotion English enum mapping per CosyVoice v3 official spec

- EMOTION_MAP maps 7 CN labels (中立/开心/难过/生气/惊讶/恐惧/厌恶) + CN aliases + old 4 enums → 7 official English enums (neutral/happy/sad/angry/surprised/fearful/disgusted)
- instruction strictly matches official format "你说话的情感是{emotion_enum}。" (verified against 阿里云百炼 docs)
- Unknown emotion defaults to "neutral" with warning (was empty, could cause TTS failure)
- Frontend CN labels & old enums backward-compatible; case-insensitive
- Add normalize_emotion unit tests (89 cases) covering all mappings, unknown→neutral, empty→"", ASCII enum assertion in instruction
- Fix existing test assertions from Chinese descriptors to English enums
- Fixes wrong direction in PR#1932 which mapped to Chinese descriptive phrases
2026-09-15 15:08:55 +08:00
xiaoxia 19eeb1b475 fix(#1898): TTS 情绪枚举统一为7种标准英文标签(P1) (#1932)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 26s
CI/CD Pipeline / Build Staging API Image (push) Successful in 30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 35s
CI/CD Pipeline / Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m1s
CI/CD Pipeline / Validate - Style (push) Successful in 3m44s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m3s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m15s
CI/CD Pipeline / Validate - Security (push) Successful in 6m32s
CI/CD Pipeline / Unit Tests (push) Successful in 8m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 14:02:49 +08:00
xiaoxia 0e5127df05 fix(e2e): 替换已删除的 POST /templates 为 GET /templates 获取默认模板 (#1931)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 11s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m58s
CI/CD Pipeline / Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Validate - Style (push) Successful in 3m43s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m47s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m36s
CI/CD Pipeline / Validate - Security (push) Successful in 7m16s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m42s
CI/CD Pipeline / Unit Tests (push) Successful in 10m52s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 13:49:32 +08:00
xiaoxia 0c59f83a7f feat(#1893): scripts AI capability (douyin extract/rewrite/titles) (#1930)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 / Frontend Lint (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 21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 36s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m51s
CI/CD Pipeline / Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m25s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m21s
CI/CD Pipeline / Validate - Style (push) Successful in 4m37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m59s
CI/CD Pipeline / Validate - Security (push) Successful in 6m54s
CI/CD Pipeline / Unit Tests (push) Successful in 9m27s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Worker Base Image Build / Build Worker Base Image (push) Failing after 20m47s
API Base Image Build / Build API Base Image (push) Failing after 24m44s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 13:24:38 +08:00
xiaoxia 53fb25efcf fix(#1834): 批量修复 UP 系列静态分析警告(UP007/UP006/UP017/UP035) (#1928)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m30s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Security (push) Successful in 7m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (push) Successful in 10m11s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 12:59:17 +08:00
xiaoxia 352221f199 fix(p0): #1922 from-assets 空 templateId 双斜杠 404 兜底 (#1926)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m35s
CI/CD Pipeline / Validate - Style (push) Successful in 4m42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m29s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m5s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m10s
CI/CD Pipeline / Validate - Security (push) Successful in 11m12s
CI/CD Pipeline / Unit Tests (push) Successful in 11m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 11:54:06 +08:00
xiaoxia a25aa7baed fix(generate): #1921 from-assets空templateId双斜杠404修复(P0前端) (#1925)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m56s
CI/CD Pipeline / Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / Validate - Style (push) Successful in 4m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m48s
CI/CD Pipeline / Validate - Security (push) Successful in 9m39s
CI/CD Pipeline / Unit Tests (push) Successful in 9m40s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 11:13:58 +08:00
xiaoxia 493d175e87 feat(scripts): #1811 文案库独立管理页面 v2 (#1924)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 48s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 47s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m36s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m38s
CI/CD Pipeline / Validate - Style (push) Successful in 4m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Validate - Security (push) Successful in 10m3s
CI/CD Pipeline / Unit Tests (push) Successful in 12m44s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 10:53:55 +08:00
xiaoxia c2997a11c2 feat: #1895 会员+积分系统前端(两档会员 + 积分充值/明细/扣费提示) (#1920)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m52s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m44s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 4m2s
CI/CD Pipeline / Validate - Style (push) Successful in 4m8s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m38s
CI/CD Pipeline / Validate - Security (push) Successful in 10m8s
CI/CD Pipeline / Unit Tests (push) Successful in 10m26s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 10:05:44 +08:00
xiaoxia 90cc0026b1 feat: 会员+积分系统后端 (#1895) (#1919)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m51s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m22s
CI/CD Pipeline / Validate - Security (push) Successful in 7m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m26s
CI/CD Pipeline / Unit Tests (push) Successful in 12m4s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 09:23:26 +08:00
xiaoxia 2b12c055dc Merge pull request 'feat(#1899,#1900): clip_count 参数 + 模板管理 API 冗余清理' (#1918) from feat/1899-1900-clip-count-template-cleanup into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 3m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m26s
CI/CD Pipeline / Integration Tests (push) Successful in 4m48s
CI/CD Pipeline / Validate - Security (push) Successful in 5m59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m45s
CI/CD Pipeline / Unit Tests (push) Successful in 8m21s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 23m0s
feat(#1899,#1900): clip_count参数 + 模板管理API冗余清理 (PR#1918)

- ClipsFromAssetsRequest新增clip_count(1-10,默认3),旧字段required_clips_count兼容
- from-assets接口按clip_count循环复用segments生成N片段
- 模板API精简: 删11个CRUD/复制/收藏/校验/分类/标签端点, 仅保留GET /templates + 默认模板兜底
- 清理9个无用schema + 1个已废弃集成测试文件
- 修复list_templates分页total字段(CountTemplatesUseCase)
- 新增scripts/cleanup_e2e_test_data.sql清理测试数据
- 15128单测全过
2026-09-15 08:04:21 +08:00
xiaoxia-agent 8fadb28ea3 fix(#1900): list_templates 返回 total 总数(修复分页)
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 / 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
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m59s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m7s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m53s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m3s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m21s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 6m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m38s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 14m39s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 33s
子代理初版用 len(items) 当 total,分页场景下第二页 total 会变小。
改为调用 CountTemplatesUseCase 走 count_by_user 返回真实总数。
2026-09-15 07:43:07 +08:00
saas-backend 8237d170a5 feat(#1899,#1900): clip_count 参数 + 模板管理 API 冗余清理
[skip ci-format-check]

#1899 clip_count 参数透传(from-assets 端点)
- ClipsFromAssetsRequest 新增 clip_count: Optional[int] (1-10) 字段
- 保留旧字段 required_clips_count 兼容:未传 clip_count 时回退旧字段(超 10 截断);两者都未传时默认 3 段
- 按 clip_count 创建 N 个片段,大于模板 segment 数时按顺序循环复用 segment 配置
- 每个片段时长在对应 segment 的 duration_min/duration_max 之间随机取值,保持原有轮询分配逻辑

#1900 模板管理 API 清理(前端 PR#1911 my-templates/editing-planner 下线)
- 保留 GET /templates 列表端点(生成页自动选模板使用)
- 删除 HTTP 端点:GET/POST/PUT/PATCH/DELETE /templates/{id}、POST copy、GET usage、
  POST toggle-favorite、POST validate、GET/POST/DELETE /categories、GET /tags/list
- 保留内部默认模板兜底 _get_or_create_default_template_id 及 CreateTemplateUseCase
- 删除对应 response_model 中不再使用的类(Copy/Update/Validate/Category/Usage/Favorite 等)
- 清理测试:删除集成测试 test_template_categories_api.py,更新 3 个单测适配新 clip_count 语义

DB:未涉及 schema 变更(models 本就无 is_favorite/usage_count 等列),跳过 migration。
新增 scripts/cleanup_e2e_test_data.sql 供运维在 staging DB 手动清理 e2e-gen-*/asset-create_* 账号残留模板数据。

单测:15128 passed, 28 skipped(全量 tests/unit/)
2026-09-15 07:32:06 +08:00
xiaoxia 201a3f0af5 feat(#1898): TTS 参数适配 CosyVoice v3 官方 API (#1917)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 44s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m57s
CI/CD Pipeline / Integration Tests (push) Successful in 3m59s
CI/CD Pipeline / Validate - Style (push) Successful in 4m4s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m46s
CI/CD Pipeline / Validate - Security (push) Successful in 8m21s
CI/CD Pipeline / Unit Tests (push) Successful in 8m51s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 04:19:58 +08:00
xiaoxia 695a491c5d fix(backend): #1867 对口型卡running三层防御修复(rebase到最新develop) (#1916)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m25s
CI/CD Pipeline / Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m28s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / Validate - Style (push) Successful in 6m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m23s
CI/CD Pipeline / Validate - Security (push) Successful in 10m16s
CI/CD Pipeline / Unit Tests (push) Successful in 10m35s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 03:35:30 +08:00
xiaoxia 5d4e07d4f4 feat(web): TTS 情绪扩展为 7 种 + 系统音色语言限 zh/en + 清理残留代码 (#1915)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m5s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m6s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m8s
CI/CD Pipeline / Validate - Style (push) Successful in 6m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m15s
CI/CD Pipeline / Unit Tests (push) Successful in 10m1s
CI/CD Pipeline / Validate - Security (push) Successful in 11m26s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 01:45:51 +08:00
xiaoxia a56b3f7b42 fix(backend): P0 #1911后自动创建默认模板避免剪辑页卡死 (#1914)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m52s
CI/CD Pipeline / Unit Tests (push) Successful in 9m2s
CI/CD Pipeline / Validate - Security (push) Successful in 12m14s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 01:23:30 +08:00
xiaoxia b51fffd9b5 fix(frontend): #1913 智能剪辑无有效模板时后端自动兜底,移除空template硬拦截 (#1913)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m26s
CI/CD Pipeline / Validate - Style (push) Successful in 3m27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m12s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m0s
CI/CD Pipeline / Integration Tests (push) Successful in 6m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m23s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m8s
CI/CD Pipeline / Unit Tests (push) Successful in 9m59s
CI/CD Pipeline / Validate - Security (push) Successful in 10m20s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m42s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 00:58:05 +08:00
xiaoxia d8effd8e77 refactor(frontend): #1899 #1900 删除模板选择简化剪辑流程 (#1911)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 25s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m29s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m34s
CI/CD Pipeline / Integration Tests (push) Successful in 7m22s
CI/CD Pipeline / Validate - Style (push) Successful in 7m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m51s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 9m9s
CI/CD Pipeline / Unit Tests (push) Successful in 12m8s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-14 23:25:21 +08:00
xiaoxia 2fb987bbee Merge pull request 'fix(backend): #1896 修复字体映射补充开源字体文件' (#1912) from fix/1896-font-mapping-open-source into develop
Worker Base Image Build / Build Worker Base Image (push) Successful in 42m25s
API Base Image Build / Build API Base Image (push) Successful in 56m24s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m39s
CI/CD Pipeline / Integration Tests (push) Successful in 7m40s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 9m30s
CI/CD Pipeline / Unit Tests (push) Successful in 12m28s
CI/CD Pipeline / Validate - Style (push) Successful in 16m37s
CI/CD Pipeline / Validate - Security (push) Successful in 17m53s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 34m40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 46m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 49m1s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m38s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
2026-09-14 21:43:29 +08:00
saas-backend-agent dc5165bd5f fix(backend): #1896 修复字体映射补充开源字体文件
CI/CD Pipeline / Check push changed paths (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 / 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
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m10s
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 API Image (pull_request) Successful in 11s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m19s
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 9m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 10m8s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 10m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 12m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m23s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 33m11s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 20s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m44s
问题:DRAWTEXT_FONT_MAP把所有前端字体(思源宋体/苹方/微软雅黑/楷体/华康俪金黑)全部映射到 NotoSansSC,导致无论选什么字体成片都渲染为思源黑体;FONT_NAME_MAP 将楷体错误映射到 Noto Serif CJK SC(宋体)。

修复:
- packages/domain/video_filter_builder.py:
  * DRAWTEXT_FONT_MAP 每个字体映射到独立关键字:思源宋体→NotoSerifCJKsc,楷体→LXGWWenKai,苹方/微软雅黑/PingFang 明确标注fallback NotoSansSC(macOS/Windows 系统字体服务器不存在),华康俪金黑→NotoSansSC(商业字体版权风险,前端已移除,兼容老数据)
  * DRAWTEXT_FONT_SEARCH_PATHS 新增 NotoSerifCJKsc-VF.otf 和 LXGWWenKai-Regular.ttf 路径
- packages/domain/ass_subtitle_builder.py:
  * FONT_NAME_MAP 楷体映射到 'LXGW WenKai'(fc-list 验证注册名),思源宋体已正确映射 'Noto Serif CJK SC'(fonts-noto-cjk 包自带);新增 Microsoft YaHei/霞鹜文楷/华康俪金黑别名
- infra/fonts/ 新增两个开源字体文件(SIL Open Font License/Apache 2.0 免费可商用):
  * NotoSerifCJKsc-VF.otf(53MB,思源宋体可变字体,含多字重)
  * LXGWWenKai-Regular.ttf(25MB,霞鹜文楷开源楷体,v1.522)
- infra/docker/worker-base.Dockerfile + api-base.Dockerfile:
  * 保留 Serif .ttc(宋体fallback),仅删除 Sans .ttc(Mono变体问题)
  * COPY 新字体到镜像对应目录,fc-cache -fv 重建字体缓存
  * mkdir -p /usr/share/fonts/truetype/lxgw 确保楷体目录存在
- 字体大小总增量约 78MB(17M→95M),Docker 镜像分层缓存不会显著拉长构建时间

验证:
- 视频合成/ASS字幕/字幕滤镜三条渲染路径字体映射均独立正确
- fc-list 在本地实测可正确识别 'LXGW WenKai' / 'Noto Serif CJK SC' 注册名
- 苹方/微软雅黑等系统字体通过 NotoSansSC fallback 正常显示中文
- 存量数据(老数据选华康俪金黑/苹方等)继续渲染不中断
- 899 个字幕/滤镜/合成相关单测全通过,190 个writeback/filter/compose单测全通过
2026-09-14 20:54:26 +08:00
xiaoxia 937f751f68 Merge pull request 'fix(backend): #1901 统一title_config字段名修复渲染不一致' (#1909) from fix/1901-title-config-unify into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 8m40s
CI/CD Pipeline / Validate - Style (push) Successful in 9m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 10m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI/CD Pipeline / Unit Tests (push) Successful in 14m57s
CI/CD Pipeline / Validate - Security (push) Successful in 29m26s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-14 20:17:19 +08:00
xiaoxia afcfcf45ee fix(frontend): #1896 删除侵权华康字体修复PreviewPlayer字体映射 (#1908)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 46s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m50s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + 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 / 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
fix(frontend): #1896 删除侵权华康字体修复PreviewPlayer字体映射

问题:
1. "华康俪金黑"@font-face字体文件缺失(404)且有版权风险
2. PreviewPlayer直接用中文font_preset/font作为fontFamily,浏览器不识别

修改:
- 删除"华康俪金黑"字体选项(FONT_OPTIONS+FONT_FAMILY_MAP)和@font-face声明
- 统一 editing-planner/constants/clipProperties.ts 和 subtitleStyle.ts 从 generate/constants 重新导出 FONT_OPTIONS,避免多处维护
- PreviewPlayer.tsx 标题/字幕 fontFamily 通过 getFontFamily() 映射为浏览器可识别字体栈
- FrontendPreviewPlayer.tsx 标题 fontFamily 同样通过 getFontFamily() 映射
- TitleStylePanel/PanelTitleConfig/SubtitleStylePanel 等组件因复用统一常量自动生效
- 最终保留5个字体:思源黑体、思源宋体、苹方、微软雅黑、楷体
- PingFang 别名保留在 FONT_FAMILY_MAP 中以兼容旧数据,但不在 UI 选项中显示

Refs #1896
2026-09-14 20:12:21 +08:00
CI Bot ffb40038d3 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 API Image (pull_request) Successful in 8s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8s
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 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m28s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m3s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 25m33s
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 / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13m46s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 14m59s
2026-09-14 11:38:53 +00:00
saas-backend-agent 30094bc591 fix(backend): #1901 统一title字段名修复标题渲染不一致
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 / Check if frontend-only change (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m20s
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 API Image (pull_request) Successful in 1m7s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
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 / PR Build Worker Image (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m12s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 8m19s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
问题:plan.config中标题配置字段名双轨不一致——Worker sync_configs_to_plan 写 plan.config['title'](归一化 size/font/color),API writeback_edit_plan_config 写 plan.config['title_config'](不归一化),三个渲染引擎读取不一致:
- video_compose_service(FFmpeg drawtext):已读 title+fallback title_config 
- unified_render_service(ASS字幕):只 title 无 fallback 
- subtitle_render_engine(字幕渲染):只读 title_config 

修复:
- packages/application/generation_common.py writeback_edit_plan_config:统一写 plan.config['title'](而非 title_config),并做字段名归一化 font_size→size/font_preset→font/font_color→color,与 Worker sync_configs_to_plan 行为一致
- apps/worker/video_processing/subtitle_render_engine.py:_resolve_title_config 先读 plan.config['title'],fallback plan.config['title_config'](兼容存量老数据)
- apps/worker/video_processing/unified_render_service.py:同样先读 'title',text 为空时 fallback 'title_config'
- apps/api/app/api/routes/generation_cover.py:封面E2标题配置读取加 title_config fallback
- 单测适配:test_generation_common.py/test_writeback_edit_plan_config.py 断言改 'title' 并验证归一化
- 所有渲染路径(智能剪辑/AI数字人/封面E2)统一读 'title',老数据通过 fallback 继续渲染
2026-09-14 19:27:29 +08:00
xiaoxia 939a49d1d4 fix(frontend): #1897 声音克隆页增加情绪语速设置 (#1906)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m32s
CI/CD Pipeline / Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Validate - Style (push) Successful in 3m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m45s
CI/CD Pipeline / Validate - Security (push) Successful in 6m12s
CI/CD Pipeline / Unit Tests (push) Successful in 8m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 37m7s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(frontend): #1897 声音克隆页增加情绪语速设置

- getVoiceClonePreview 增加 speed/emotion 参数透传
- 新增 VoiceClonePreviewPanel:音色选择/试听文本/SpeedControl/EmotionControl
- 复用 TTS SpeedControl 和 EmotionControl 组件
- 声音克隆页在无加载且有就绪音色时展示试听面板

Refs #1897
2026-09-14 19:10:59 +08:00
xiaoxia 8ad44ad045 fix(backend): #1897 声音克隆preview接口支持情绪语速参数 (#1907)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m39s
CI/CD Pipeline / Validate - Style (push) Successful in 3m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Validate - Security (push) Successful in 6m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m30s
CI/CD Pipeline / Unit Tests (push) Successful in 8m12s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:56:52 +08:00
xiaoxia 26f3abab72 fix(backend): #1892 画面插入按句号分句 (#1905)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m23s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / Validate - Style (push) Successful in 3m41s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Security (push) Successful in 8m48s
CI/CD Pipeline / Unit Tests (push) Successful in 9m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:15:38 +08:00
xiaoxia 4f377d4fd3 fix(frontend): #1898 配音库AI配音增加情绪语言选择 (#1904)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 12s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m30s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Validate - Style (push) Successful in 4m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m12s
CI/CD Pipeline / Unit Tests (push) Successful in 10m22s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:05:01 +08:00
xiaoxia 35b18e5e16 docs: #1895 会员+积分替代订阅制方案设计 (#1903)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m45s
CI/CD Pipeline / Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / Validate - Style (push) Successful in 4m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
CI/CD Pipeline / Unit Tests (push) Successful in 10m43s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 17:53:57 +08:00
xiaoxia e9dd33e2f6 Merge pull request 'fix(frontend): #1892 画面插入按句号分句' (#1902) from fix/1892-sentence-split-no-comma into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m17s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m16s
CI/CD Pipeline / Validate - Style (push) Successful in 5m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m38s
CI/CD Pipeline / Validate - Security (push) Successful in 10m41s
CI/CD Pipeline / Unit Tests (push) Successful in 12m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-14 17:39:59 +08:00
saas-frontend-agent 7f18b1b35a fix(frontend): #1892 画面插入按句号分句,去掉逗号分句
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 / Check if frontend-only change (pull_request) Successful in 2s
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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped 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 / 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 / Frontend Unit Tests (pull_request) Successful in 44s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m12s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m35s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 12m34s
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 / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 10s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 37s
2026-09-14 17:25:36 +08:00
xiaoxia 880a7106d4 fix(ci): staging deploy SSH host to internal IP for Docker runner (#1891)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m36s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m9s
CI/CD Pipeline / Integration Tests (push) Successful in 3m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Validate - Style (push) Successful in 4m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Unit Tests (push) Successful in 9m23s
CI/CD Pipeline / Validate - Security (push) Successful in 12m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 15:18:31 +08:00
xiaoxia d478a9ffbc fix(ci): push :dev tag for watchtower + fix staging deploy host to 116 (#1889)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 51s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 56s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m23s
CI/CD Pipeline / Integration Tests (push) Successful in 4m31s
CI/CD Pipeline / Validate - Style (push) Successful in 4m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m23s
CI/CD Pipeline / Unit Tests (push) Successful in 9m50s
CI/CD Pipeline / Validate - Security (push) Successful in 13m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 14:42:46 +08:00
xiaoxia 628fc6c4e6 fix(ci): push :dev tag to registry for watchtower auto-update (#1888)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 50s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 56s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Validate - Security (push) Successful in 10m34s
CI/CD Pipeline / Unit Tests (push) Successful in 12m4s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 14:26:05 +08:00
xiaoxia f651422582 fix(deploy): 修复生产部署脚本502——worker/web容器启动失败 (#1887)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 39s
CI/CD Pipeline / Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m21s
CI/CD Pipeline / Validate - Style (push) Successful in 3m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m16s
CI/CD Pipeline / Validate - Security (push) Successful in 8m17s
CI/CD Pipeline / Unit Tests (push) Successful in 8m42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18m14s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 5m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m1s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 11:29:58 +08:00
xiaoxia 14580f536a refactor(backend): 智能剪辑后端公共逻辑抽取+解耦 (#1884)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m27s
CI/CD Pipeline / Integration Tests (push) Successful in 3m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m45s
CI/CD Pipeline / Validate - Style (push) Successful in 4m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m58s
CI/CD Pipeline / Validate - Security (push) Successful in 8m1s
CI/CD Pipeline / Unit Tests (push) Successful in 9m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
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 / Check if frontend-only change (pull_request) Successful in 3s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (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 / PR Build 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 / Validate - Style (pull_request) Successful in 1m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m36s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m31s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m48s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 51s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m4s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 03:15:00 +08:00
xiaoxia 6ff8d48c15 refactor(web): 智能剪辑 & AI数字人 前端代码审查与重构 (#1883)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m45s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m10s
CI/CD Pipeline / Validate - Style (push) Successful in 4m33s
CI/CD Pipeline / Integration Tests (push) Successful in 5m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Security (push) Successful in 10m36s
CI/CD Pipeline / Unit Tests (push) Successful in 10m43s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 02:44:41 +08:00
LingYing Agent da1d55ef4e chore: 触发staging部署(批量变体P0修复)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 57s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m41s
CI/CD Pipeline / Validate - Style (push) Successful in 4m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m34s
CI/CD Pipeline / Validate - Security (push) Successful in 8m42s
CI/CD Pipeline / Unit Tests (push) Successful in 10m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-14 02:15:43 +08:00
xiaoxia 00f8c0b566 fix(web): 修复批量变体预览3个P0 bug (#1881)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 45s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m3s
CI/CD Pipeline / Validate - Style (push) Successful in 4m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m7s
CI/CD Pipeline / Validate - Security (push) Successful in 9m9s
CI/CD Pipeline / Unit Tests (push) Successful in 11m6s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 01:50:15 +08:00
xiaoxia 1f01d6df25 fix(worker): 修复批量变体3个P0 bug (#1882)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m14s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 3m16s
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Validate - Style (push) Successful in 4m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m37s
CI/CD Pipeline / Validate - Security (push) Successful in 10m15s
CI/CD Pipeline / Unit Tests (push) Successful in 11m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 01:29:57 +08:00
xiaoxia 5b931438e0 Merge pull request 'fix(worker): 修复像素扰动color_balance滤镜名错误导致模板编辑器FFmpeg exit=8' (#1880) from fix/colorbalance-filter-name-0913 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m11s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m26s
CI/CD Pipeline / Validate - Style (push) Successful in 4m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m47s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m27s
CI/CD Pipeline / Validate - Security (push) Successful in 7m57s
CI/CD Pipeline / Unit Tests (push) Successful in 8m35s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
AI Code Review / AI Code Review (pull_request) Successful in 24s
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 / 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 / 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
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
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-09-14 00:07:35 +08:00
灵应 b9acf8c86f fix(worker): 修复像素扰动color_balance滤镜名错误导致ffmpeg exit=8
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
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 / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Failing after 13m45s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m21s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m49s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m39s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 15m14s
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 / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m4s
根因:像素扰动功能(bb98620)在ffmpeg滤镜链中使用了color_balance滤镜名,
但ffmpeg的实际滤镜名是colorbalance(无下划线),导致滤镜解析失败,
报错'No such filter: color_balance',ffmpeg退出码8。

影响:任何启用了像素扰动的变体渲染(默认批量变体会随机启用)都会失败,
表现为进度卡在56%,error_message只显示ffmpeg版本banner。

修复:将ffmpeg滤镜名从color_balance=改为colorbalance=。
内部config key保持color_balance不变(避免兼容问题)。
2026-09-13 23:36:02 +08:00
xiaoxia 6f4a95e00f Merge pull request 'fix(worker): 改进FFmpeg错误信息可见性便于exit=8排查' (#1879) from fix/ffmpeg-error-visibility-0913 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m12s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m39s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m59s
CI/CD Pipeline / Validate - Style (push) Successful in 7m13s
CI/CD Pipeline / Integration Tests (push) Successful in 7m45s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m31s
CI/CD Pipeline / Unit Tests (push) Successful in 12m18s
CI/CD Pipeline / Validate - Security (push) Successful in 21m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-13 23:30:09 +08:00
灵应 ccef935ca7 fix(worker): 改进ffmpeg错误信息可见性,stderr尾部优先+error_detail不丢弃
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
AI Code Review / AI Code Review (pull_request) Successful in 6m27s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m11s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m43s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m45s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m9s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m0s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 22m33s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
1. error_message显示stderr尾部500字符(而非头部ffmpeg banner)
2. error_detail保留stderr尾部5000字符
3. generation任务RuntimeError保留error_detail
4. 缩略图ffmpeg错误同理
2026-09-13 23:11:53 +08:00
xiaoxia d7f2e68707 Merge pull request 'fix(lipsync): video_url扩展名校验放宽,支持MOV/M4V/WebM等容器' (#1878) from fix/lipsync-video-ext-0913 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m53s
CI/CD Pipeline / Integration Tests (push) Successful in 5m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m1s
CI/CD Pipeline / Validate - Style (push) Successful in 6m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m59s
CI/CD Pipeline / Unit Tests (push) Successful in 11m20s
CI/CD Pipeline / Validate - Security (push) Successful in 22m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 21m15s
2026-09-13 22:51:08 +08:00
灵应 78a618f7d7 fix(lint): B904 raise from exc for finalize 500 error handler
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m34s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m39s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 22m44s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 1m24s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m52s
2026-09-13 22:25:04 +08:00
灵应 5ab8a66e1b fix(test): 更新smart cover轮询参数断言以匹配新值2s/30次
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 1s
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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web 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 / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m37s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m47s
AI Code Review / AI Code Review (pull_request) Successful in 6m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m34s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 8m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m47s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m36s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 25m38s
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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-09-13 21:58:12 +08:00
CI Bot a7fc67b822 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
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 / Build Staging Worker 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 / Check if frontend-only change (pull_request) Successful in 3s
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 / 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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging 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 / PR Build API Image (pull_request) Successful in 1m21s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m43s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m58s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 7m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m28s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 11m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m6s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 23m43s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
2026-09-13 13:31:12 +00:00
灵应 fc925ef3b3 fix(lipsync): video_url扩展名校验放宽,支持MOV/M4V/WebM/AVI/MKV/3GP
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 / Build Staging Worker 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 / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m56s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m44s
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 / 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 Merge on CI Green + Approved (pull_request) Has been cancelled
iPhone等设备拍摄的视频转h264后容器保留.MOV扩展名,
原校验仅允许.mp4导致对口型接口返回422。
同步更新单测。
2026-09-13 21:23:19 +08:00
CI Bot b69595bd28 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / 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 37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 30s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m24s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Failing after 12m7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m53s
CI/CD Pipeline / Integration Tests (push) Successful in 5m57s
CI/CD Pipeline / Validate - Style (push) Failing after 7m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m1s
CI/CD Pipeline / Unit Tests (push) Failing after 11m3s
CI/CD Pipeline / Validate - Security (push) Successful in 23m12s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-13 11:37:46 +00:00
xiaoxia 5c91bb21a7 Merge pull request 'fix(ai-avatar): 智能封面抽帧超时修复 + 封面流程改造(选封面后点完成才入库)' (#1877) from fix/ai-avatar-cover-flow-0913 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m52s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m15s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
CI/CD Pipeline / Integration Tests (push) Successful in 4m30s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-13 19:32:43 +08:00
CI Bot 135e422d45 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 51s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 51s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
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 / Validate - Python (mypy + alembic) (pull_request) Successful in 1m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m11s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 2m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m2s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8m51s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m47s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 36s
2026-09-13 11:16:39 +00:00
灵应 60d19704bb fix(ai-avatar): 抽帧超时延长至60s + 封面选完才入库流程
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 / Check if frontend-only change (pull_request) Successful in 2s
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 / Frontend Lint (pull_request) Failing after 1m25s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m11s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m49s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m0s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (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 / 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 Merge on CI Green + Approved (pull_request) Has been cancelled
- ai_avatar_cover_service: COVER_POLL_INTERVAL=2s × COVER_MAX_POLL_ATTEMPTS=30 (最长 60s),适配合成视频(叠加B-roll/标题)较大的下载+抽帧耗时
- ai_avatar_render_service: execute_render 完成后停留在 completed 状态不再自动入库成片库;新增 _persist_to_library(cover_url 可选覆盖) 与 finalize_job(job_id, user_id, cover_url?) 方法,供用户点「完成」时显式入库,幂等(通过 generation_task_id=render_job_id 判重)
- 路由: 新增 POST /{job_id}/finalize 接口,含 404/400/幂等/异常分支
- Schema: 新增 FinalizeRenderResponse
- 前端:
  - aiAvatar.ts 新增 finalizeRenderJob API
  - PanelCoverAndGenerate 拆分 variant=setup(主页面配置+生成按钮,不含封面区) 与 select-cover(弹窗内封面选择);新增 onClose/onCoverSelected props,智能/上传成功后回调通知父组件
  - 新建 ModalCoverSelect 弹窗组件
  - AiAvatarPage: render 完成后不再自动跳转/generated,而是停留在主页面显示封面预览+「🎬选择封面」「完成」按钮;弹窗内选封面、点「完成」才调 finalize 入库并跳转到成片库
- 单元测试: 更新原有 execute_render 自动入库测试为不入库断言,新增 finalize_job 入库/幂等/状态校验 3 个测试
2026-09-13 19:09:39 +08:00
xiaoxia c613f35662 Merge pull request 'fix(ai-avatar): 修复三个bug——封面重影/标题字号缩放/对口型音频截断' (#1876) from fix/ai-avatar-three-bugs-0913 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (push) Successful in 1m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / Validate - Style (push) Successful in 2m39s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Validate - Security (push) Successful in 5m37s
CI/CD Pipeline / Unit Tests (push) Successful in 8m32s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-13 14:15:31 +08:00
CI Bot 9be89484e6 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Check push changed paths (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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging 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 / PR Build Worker Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m16s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Failing after 12m8s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m43s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m28s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m34s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 10s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 17s
2026-09-13 05:32:51 +00:00
灵应 5f128d175d fix(ai-avatar): 修复三个bug——封面重影/标题字号缩放/对口型音频截断
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 / 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 / PR Build API Image (pull_request) Successful in 1m12s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m28s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
1. 删除封面展示区重复叠加的标题div(成片帧已含标题,不再CSS叠字)
2. 标题字号按视频分辨率等比缩放,预览容器动态测量宽度计算scale
3. enable_video_loop默认改为true,防止TTS音频长于出镜视频时被截断
2026-09-13 13:28:51 +08:00
xiaoxia 053b00634a feat(ai-avatar): 配音前置 (#1875)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Validate - Style (push) Successful in 4m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m26s
CI/CD Pipeline / Unit Tests (push) Successful in 8m55s
CI/CD Pipeline / Validate - Security (push) Successful in 9m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-13 04:22:21 +08:00
xiaoxia 3858acf377 Merge pull request 'fix(ai-avatar): B-roll时间戳根因——逗号分句+优先后端时间戳' (#1874) from fix/ai-avatar-broll-comma-split into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m32s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m22s
CI/CD Pipeline / Validate - Style (push) Successful in 4m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m7s
CI/CD Pipeline / Validate - Security (push) Successful in 9m30s
CI/CD Pipeline / Unit Tests (push) Successful in 9m39s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(ai-avatar): B-roll逗号分句+标题PNG图层+TTS竞态修复+死代码清理

- B-roll分句正则加入中文逗号,放宽后端时间戳校验直接使用
- 标题改为Canvas渲染透明PNG,FFmpeg overlay图片图层替代drawtext,所见即所得
- Celery事务竞态修复:先commit后发任务+worker侧retry防御
- TTS任务超时防护(180s soft/200s hard)+入口日志
- 删除封面死代码(POST /smart-cover裸视频抽帧,所有封面从成片获取)
- 删除DRAWTEXT_BOLD_FONT_SEARCH_PATHS冗余代码
2026-09-13 02:49:04 +08:00
xiaoxia f607b0cec9 fix(test): mock storage.get_download_url,修复_sign_media_url内部调用mock泄漏问题
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 / PR Build Web Image (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m20s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m46s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m51s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m28s
AI Code Review / AI Code Review (pull_request) Successful in 6m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 9m9s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m12s
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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m35s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 32s
2026-09-13 02:21:08 +08:00
xiaoxia 688b35efa8 fix(test): 统一test_lipsync_tts持久化测试upload_url参数与key一致,消除flaky
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 / Check if frontend-only change (pull_request) Successful in 2s
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 / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 49s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m3s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m8s
AI Code Review / AI Code Review (pull_request) Successful in 6m19s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8m28s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m29s
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 / Production Browser E2E (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 4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m4s
2026-09-13 02:05:30 +08:00
xiaoxia ce6c831cf3 style: prettier format sentences.ts & titleCanvas.ts [skip ci-format-check]
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m51s
AI Code Review / AI Code Review (pull_request) Successful in 6m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m42s
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 1s
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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 34s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m6s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m49s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m58s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8m0s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m27s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
2026-09-13 01:34:47 +08:00
xiaoxia 3267b24433 fix(ai-avatar): 删除未使用变量 startY(TS6133修复)
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 / 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 / PR Build API Image (pull_request) Successful in 36s
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 / PR Build Worker Image (pull_request) Successful in 39s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m20s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m45s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m46s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m45s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m16s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8m8s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m51s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m16s
2026-09-13 01:14:15 +08:00
CI Bot 222c4d15a9 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been 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 2s
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 / Frontend Lint (pull_request) Failing after 2m26s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 2m35s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m40s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m52s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m28s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m31s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 8m37s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
2026-09-12 17:05:14 +00:00
xiaoxia 63fb0508be refactor(ai-avatar): 删除封面标题叠加逻辑+废弃路由(封面一律从成片抽帧)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 Web 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 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 / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2m8s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 43s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 3m12s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m51s
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 / 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 Merge on CI Green + Approved (pull_request) Has been cancelled
- 删除前端 generateSmartCover(0处调用的死代码)
- 删除后端 POST /ai-avatar/render/smart-cover(对口型阶段抽封面入口,已废弃)
- 删除 SmartCoverRequest schema(无引用)
- 精简 ai_avatar_cover_service:删除 apply_title_to_cover / _overlay_title_png_on_image,
  封面一律从最终成片(已叠加标题/B-roll)抽帧,不再额外叠加
- 删除 persist_cover_to_oss / generate_smart_cover 的 title_config 参数
2026-09-13 00:58:02 +08:00
xiaoxia 577ec83636 feat(ai-avatar): 标题改为Canvas渲染PNG图层叠加,所见即所得 2026-09-13 00:41:38 +08:00
xiaoxia 6503a74a7c fix(ai-avatar): B-roll时间戳为0根因——逗号分句+优先后端时间戳
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 / 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 / Frontend Lint (pull_request) Failing after 1m27s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m56s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m57s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m0s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m55s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m50s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m59s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m11s
AI Code Review / AI Code Review (pull_request) Successful in 6m44s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m28s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m23s
根因(用户截图文案:'卖花的叫花无缺,卖姜的叫姜子牙,卖菜的蔡文姬,那我修脚阔头的呢,应该叫什么呢'):
1. 分句正则没包含中文逗号「,」,整段被识别为1句;后端静音检测按TTS音频停顿
   切出多句,前端校验 sentenceTimings.length===rawParts.length 条数不等 →
   整个后端精确时间戳被丢弃走降级
2. 降级路径 outputDuration=0(对口型预览阶段最终视频未渲染)→ 所有句子时间估算为0

修复:
- 前端 sentences.ts:
  a. 分句正则加上中英文逗号「,,」
  b. sentenceTimings 校验放宽:只要是有效数组就直接用后端句子列表,
     不再强制条数相等(后端按音频停顿的切法才是真实边界)
- 后端 _split_script_into_sentences:正则同步加逗号,前后端一致
- 补单测验证逗号分隔文案分句
2026-09-12 23:55:07 +08:00
xiaoxia 4a93aaaf4c Merge pull request 'fix(ai-avatar): B-roll时间戳/标题重影/TTS卡死防护' (#1873) from fix/ai-avatar-broll-title-worker-issues into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 51s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m25s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m2s
CI/CD Pipeline / Integration Tests (push) Successful in 6m0s
CI/CD Pipeline / Validate - Style (push) Successful in 6m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m59s
CI/CD Pipeline / Unit Tests (push) Successful in 11m28s
CI/CD Pipeline / Validate - Security (push) Successful in 22m15s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(ai-avatar): B-roll时间戳/标题重影/Celery事务竞态/TTS超时防护

- 问题1:B-roll时间戳全0,sentences.ts参数顺序错位导致后端精确时间戳被忽略
- 问题2:标题粗体同色描边borderw=3导致字形重影,改为黑色细描边
- 问题3P0:Celery事务竞态(先发任务后commit),worker查不到job静默return永远卡tts_processing;改为先commit再发任务+worker侧self.retry防御
- 补充:TTS任务加soft_time_limit=180s、autoretry_for网络错误、入口INFO日志

单测覆盖:新增198+相关用例通过,全量15054 passed
2026-09-12 22:37:11 +08:00
CI Bot 1a4f475fbf style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 / PR Build Web Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 43s
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 / PR Build Worker Image (pull_request) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m4s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m55s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m8s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 23m32s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 58s
2026-09-12 14:10:26 +00:00
xiaoxia 2fa6de29bc fix(lipsync): 修复Celery事务竞态导致job永远卡在tts_processing(P0)
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 / PR Build Web Image (pull_request) Successful in 30s
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 / PR Build API Image (pull_request) Successful in 33s
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 / Frontend Lint (pull_request) Successful in 1m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m44s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m55s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m15s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 10m28s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
根因:create_job() 先 apply_async() 发送 Celery 任务,再 db.commit() 提交事务。
worker 是独立进程+独立DB连接,任务<4ms就被消费,但此时 API 事务还未 commit,
worker 查询 job 返回 None → 静默 return 不重试 → job 永远卡在 tts_processing。

修复:
1. API侧(lipsync_service.py):先 db.commit()+db.refresh(job),再 apply_async() 发任务;
   投递失败/MediaKit提交失败分支也各自 commit,确保状态及时落库。
2. Worker侧(lipsync_tts.py):job not found 时改用 self.retry() 递增重试3次
   (1s/3s/7s退避),作为竞态场景的第二道防线;
   增加 autoretry_for=(OSError, ConnectionError) 自动重试网络抖动;
   max_retries 从2调整到5。

子agent在staging实锤:受影响job共5个,手工重投递后全部3秒内完成TTS+提交MediaKit,
证实TTS本身只需要2秒,卡顿完全是因为竞态。

单测:新增 TestCreateJobCommitOrder 验证 commit 在 apply_async 之前。
2026-09-12 21:50:02 +08:00
xiaoxia 831075a9c0 test(ai-avatar): 补充B-roll/标题单测,修正断言
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (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 / 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 / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging 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 / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m31s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m49s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
AI Code Review / AI Code Review (pull_request) Successful in 6m28s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m5s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m13s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m38s
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
2026-09-12 21:39:00 +08:00
xiaoxia a83b53ae58 fix(ai-avatar): B-roll时间戳、标题粗体重影、TTS卡死防护
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / Frontend Lint (pull_request) Successful in 1m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m11s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 7m14s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m40s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 25m22s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
1. B-roll时间戳全0修复:
   - sentences.ts 新增 sentenceTimings 参数优先使用后端精确时间戳,
     降级才按字数比例估算
   - 修正参数顺序,与 ModalBRollEditor 现有调用 (scriptText, timings, duration) 对齐
   - 前端 AiAvatarPage 已通过 props 传 sentenceTimings(旧版已传但因顺序错位被忽略)

2. 标题粗体重影修复:
   - 之前 bold=true 时使用 borderw=3 + font_color 同色描边模拟粗体,
     会在小字号/竖屏视频上造成字形边缘偏移,视觉上文字像被打印了两次
     (用户截图中的'曝光曝光…'重影)
   - 改为黑色细描边(borderw=2, 黑色),既保留清晰加粗效果又不重影
   - 用户显式开启 stroke 时仍按用户配置走
   - 新增 _resolve_font_path(bold=True) 预留粗体字体查找能力(当前镜像
     无独立 Bold 字体文件,沿用 VF 常规字重)

3. TTS 任务防卡死:
   - 给 tts_synthesize_and_submit 加 soft_time_limit=180s / time_limit=200s,
     避免因网络/上游问题导致 Celery 任务永久挂起(用户之前卡10+分钟
     tts_processing 不失败)
   - 任务开头加 INFO 日志(job_id/voice_id/text_len),方便排查 worker
     是否真的收到任务

相关:staging 用户反馈 5 问题中的 1/2/3 项(B-roll时间、标题重影、对口型卡死)
2026-09-12 21:07:34 +08:00
frontend-dev e250132ace perf(ai-avatar): 对口型加速+封面流程重构 (#1872)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 18s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Style (push) Successful in 2m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
CI/CD Pipeline / Validate - Security (push) Successful in 4m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m5s
CI/CD Pipeline / Unit Tests (push) Failing after 7m43s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-12 19:10:03 +08:00
frontend-dev 774dd27844 fix(ai-avatar): 修复B-roll文案时长全为0.0s的问题 (#1871)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Integration Tests (push) Successful in 3m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m28s
CI/CD Pipeline / Validate - Style (push) Successful in 3m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m49s
CI/CD Pipeline / Validate - Security (push) Successful in 8m11s
CI/CD Pipeline / Unit Tests (push) Successful in 8m48s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-12 15:00:56 +08:00
frontend-dev ed7af0642d fix(ai-avatar): B-roll文案显示时长+标题字号调大 (#1870)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m19s
CI/CD Pipeline / Validate - Style (push) Successful in 3m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m48s
CI/CD Pipeline / Validate - Security (push) Successful in 4m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Unit Tests (push) Successful in 8m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 23m3s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-12 10:02:34 +08:00
frontend-dev 938ef0b8cc fix(ai-avatar): 端到端一致性修复(标题/封面/B-roll位置/B-roll时长) (#1869)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 51s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m37s
CI/CD Pipeline / Validate - Style (push) Successful in 4m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m29s
CI/CD Pipeline / Validate - Security (push) Successful in 8m25s
CI/CD Pipeline / Unit Tests (push) Successful in 8m59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 18m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m42s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-12 02:41:11 +08:00
frontend-dev 982daac6e5 Merge pull request 'fix(ai-avatar): 全盘修复 FFmpeg 渲染滤镜链路(exit 234 P0)' (#1868) from fix/ffmpeg-filter-comprehensive-fix into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m2s
CI/CD Pipeline / Integration Tests (push) Successful in 7m24s
CI/CD Pipeline / Validate - Style (push) Successful in 9m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 9m56s
CI/CD Pipeline / Unit Tests (push) Successful in 12m12s
CI/CD Pipeline / Validate - Security (push) Successful in 23m10s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 39m39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 41m2s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m27s
2026-09-11 23:40:21 +08:00
CI Bot a7067c8171 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been 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 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 / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m42s
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m55s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m12s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 10m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m0s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m55s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 28m58s
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 / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-09-11 15:20:35 +00:00
LingYing Agent 32c3d2f263 fix(ai-avatar): 全盘修复 FFmpeg 渲染滤镜链路(exit 234 P0)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web 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 / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m17s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m45s
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 / 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 Merge on CI Green + Approved (pull_request) Has been cancelled
根因分析(退出码 234 = Invalid argument):
1) drawtext 使用了无效参数 font=bold,导致整个 filter_complex 解析失败
   —— FFmpeg drawtext 没有 bold 参数;改为通过 borderw 模拟粗体视觉效果
2) DRAWTEXT_FONT_SEARCH_PATHS 未包含 Dockerfile 中 COPY 的
   NotoSansSC-VF.ttf 路径,且把不支持中文的 DejaVuSans 放在 fallback 首位,
   导致字体 fallback 到拉丁字体,中文渲染乱码/方框
3) build_broll_overlay_filter 硬编码 1280:720 横屏尺寸,AI 数字人是 9:16 竖屏
4) B-roll PIP 输入索引错误(用 len(sorted_segments) 而非原始下标映射),
   PIP 标签链断裂([pip0]→[vout0] 而非 [vout])
5) 渲染命令缺 -map 0:a?,合成后音频丢失
6) 标题滤镜与 B-roll 输出标签拼接用分号有缺陷,末尾分号处理偶发问题

修复内容(packages/domain/video_filter_builder.py):
- DRAWTEXT_FONT_SEARCH_PATHS: VF 字体置顶、移除 DejaVuSans、加 Bold.ttc 路径
- DRAWTEXT_FONT_MAP: 思源黑体等关键字改为 NotoSansSC(匹配 VF 文件名)
- 删除 font=bold 无效参数;bold 无显式描边时自动用 borderw=3+同色描边模拟粗体
- build_broll_overlay_filter 返回 (filter_str, final_label) 元组,解决标签问题
- 重写 fullscreen/PIP 拆分:原始列表下标决定 -i 输入序号,sorted 只用于时序处理
- PIP overlay 正确基于 fullscreen 输出([vout_fs])或主视频([0:v])链接
- output_width/output_height 贯穿所有 scale/pad/overlay,默认 1280x720 兼容旧调用

修复内容(apps/api/app/services/ai_avatar_render_service.py):
- 新增 _probe_video_resolution() 用 ffprobe 探测输入视频实际分辨率
- AI 数字人默认竖屏 720x1280,探测失败兜底不阻断渲染
- build_broll_overlay_filter 调用传实际 output_width/output_height
- 标题滤镜传实际尺寸,保证位置/坐标计算正确
- filter_complex 拼接重写:broll+title/broll-only/title-only/无滤镜四分支清晰
- _build_ffmpeg_command 补 -map 0:a? + -c:a aac,音频不再丢失
- 清理冗余内联 import

测试:
- 更新 test_ai_avatar_render_routes.py / test_video_filter_builder.py 适配新元组签名
- 新增 test_bold_true_does_not_use_font_bold_param 防回归
- 本地 ffmpeg 实测中文标题+B-roll+竖屏720x1280合成成功
- 相关 3524 个单测全通过
2026-09-11 23:09:22 +08:00
xiaoxia 7198cfe980 Merge pull request 'fix(ai-avatar, P0): 数字人成片入库兜底project_id + 封面抽帧加速 + cv2依赖' (#1864) from fix/ai-avatar-generated-video-and-cover-speed into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m2s
CI/CD Pipeline / Integration Tests (push) Successful in 8m35s
CI/CD Pipeline / Validate - Style (push) Successful in 9m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m32s
CI/CD Pipeline / Unit Tests (push) Successful in 14m10s
CI/CD Pipeline / Validate - Security (push) Successful in 31m22s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
API Base Image Build / Build API Base Image (push) Successful in 58m15s
Worker Base Image Build / Build Worker Base Image (push) Successful in 1h11m23s
P0: AI数字人视频不入成品库+封面抽帧加速 (#1864)

- GeneratedVideo.create() 允许 project_id/generation_task_id 为空字符串,支持 AI数字人无项目场景
- ai_avatar_render_service: project_id空兜底ai_avatar, generation_task_id空兜底job.id; except升error+exc_info
- 封面抽帧轮询 poll_interval=1s × max_attempts=15 → 最长15s(原60s)
- requirements.txt 追加 numpy==1.26.4 + opencv-python-headless==4.10.0.84,恢复cv2帧评分能力
- 单测适配:允许空project_id、poll参数更新、cv2评分阈值+patch方式调整
2026-09-11 20:06:57 +08:00
xiaoxia b0cfa98e20 Merge pull request 'fix(ai-avatar): 封面title_config字段名与后端契约对齐' (#1865) from fix/ai-avatar-cover-title-contract into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16s
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m30s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Unit Tests (push) Successful in 7m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m34s
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
2026-09-11 19:58:52 +08:00
xiaoying-agent e905989695 test: 适配 cv2 可用后的单测阈值与 mock 方式
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 / Check push changed paths (pull_request) Has been skipped
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 / PR Build API Image (pull_request) Successful in 14s
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 / PR Build Worker Image (pull_request) Successful in 14s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m40s
AI Code Review / AI Code Review (pull_request) Successful in 6m25s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m33s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m0s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 25m55s
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) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- test_cover_frame_scorer: 渐变图高分阈值 50→40(实测41.27,纯渐变拉普拉斯方差中等);全黑阈值 5→10(兼容cv2浮点/直方图微小差异)
- test_dedup_v2: cv2.VideoCapture 改为 patch.object 方式 mock,确保在真实 cv2 可用环境下mock生效(之前直接赋值 cv2_mock.VideoCapture.return_value 在sys.modules恢复后可能引用丢失)
2026-09-11 19:42:21 +08:00
xiaoxia 3dcf1079a9 fix(ai-avatar): 封面title_config字段名与后端契约对齐
CI/CD Pipeline / Check push changed paths (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 / 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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build 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 / Frontend Unit Tests (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m19s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m33s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 17m33s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m13s
封面接口传入的titleConfig用buildTitleConfigPayload转换:
- 前端字段 title -> 后端 text
- 前端字段 color -> 后端 font_color (#开头保留)
- 前端字段 size -> 后端 font_size
- 前端字段 position 默认bottom

之前直接传原始AiAvatarTitleConfig对象,后端只认text/font_color/font_size,
导致封面标题内容、颜色、字号、位置全部取默认值(白色居中36px),与对口型预览不一致。
渲染提交路径已经用了buildTitleConfigPayload,是封面路径漏了转换。
2026-09-11 19:38:28 +08:00
xiaoxia da22c2e834 Merge pull request 'fix(ai-avatar): 素材预览不叠标题+封面传title_config+渲染后封面避免双标题' (#1863) from fix/ai-avatar-title-cover-source into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m6s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m41s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Style (push) Successful in 2m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m5s
CI/CD Pipeline / Validate - Security (push) Successful in 7m37s
CI/CD Pipeline / Unit Tests (push) Successful in 8m17s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-11 19:05:40 +08:00
xiaoxia c49c855533 Merge branch 'fix/ai-avatar-title-cover-source' into develop 2026-09-11 19:05:25 +08:00
xiaoying-agent baed0c6431 fix(ai-avatar, P0): 数字人成片入库兜底project_id + 封面抽帧加速 + cv2依赖
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 56s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m12s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m25s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m4s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 9m55s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m15s
P0 Bug:AI数字人视频渲染成功但成片库看不到
- packages/domain/generated_video.py: GeneratedVideo.create 去掉 project_id/generation_task_id 非空必填校验(AI数字人无项目概念,project_id为空;lipsync路径下task_id也可能为空),name/file_url仍强制非空
- apps/api/app/services/ai_avatar_render_service.py: 第7步保存成片时,project_id为空兜底为'ai_avatar',generation_task_id为空兜底为job.id;except块日志从logger.warning改为logger.error+exc_info=True,避免异常被吞

优化:封面抽帧加速(解决60s超时)
- apps/api/app/services/ai_avatar_cover_service.py: COVER_POLL_INTERVAL 3s→1s,COVER_MAX_POLL_ATTEMPTS 20→15(最长15s,之前60s);max_frames默认已是5
- requirements.txt: 加 numpy==1.26.4 + opencv-python-headless==4.10.0.84(cover_frame_scorer 用cv2做清晰度/亮度/色彩评分,API镜像之前缺cv2只能回退默认分50.0)

测试适配:
- 4处GeneratedVideo单测:从期望ValueError改为允许空串/空白归一化
- test_ai_avatar_emotion_tts_lipsync.py: poll_interval断言1.0,max_poll_attempts断言15
2026-09-11 18:45:21 +08:00
xiaoxia 3c817a2ffe fix(ai-avatar): 素材预览不叠加标题 + 封面接口传title_config + 渲染后封面避免双标题
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web 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 / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m14s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m56s
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Failing after 10m16s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m18s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m20s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m36s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m39s
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 / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 55s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m11s
1. PanelVideoSelector: 移除素材视频上的标题叠加预览,标题只在对口型预览和最终成片上展示
2. generateSmartCover API: 传入title_config参数,让后端在对口型视频帧上用drawtext叠加标题
3. AiAvatarPage: 调用generateSmartCover时传入state.titleConfig;移除传给PanelVideoSelector的titleConfig prop
4. ai_avatar_render_service: 渲染完成后自动抽封面时不传title_config,避免最终视频已有标题再叠加导致双重标题
2026-09-11 18:29:23 +08:00
xiaoxia 96bf62b00c fix(P0): 成品库分页加载更多 + AI数字人渲染传 project_id 入库 (#1862)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Validate - Style (push) Successful in 1m26s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m46s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Integration Tests (push) Successful in 3m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m36s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m9s
CI/CD Pipeline / Validate - Security (push) Successful in 7m14s
CI/CD Pipeline / Unit Tests (push) Successful in 8m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 18:23:32 +08:00
xiaoxia 0b16e08d09 chore(ai-avatar): generateSmartCover 接口超时 60s → 120s (#1861)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16s
CI/CD Pipeline / Integration Tests (push) Successful in 1m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m27s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m52s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Unit Tests (push) Successful in 6m34s
CI/CD Pipeline / Validate - Security (push) Successful in 10m41s
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 15:58:19 +08:00
xiaoxia 33510b8dbf Merge pull request 'fix(ai-avatar, P0): FFmpeg subprocess list修复32512 + Celery异常raise + 投递失败写DB + 封面drawtext标题 + audio/x-wav白名单' (#1859) from fix/ai-avatar-ffmpeg-cover-wav into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m4s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m14s
CI/CD Pipeline / Validate - Style (push) Successful in 2m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-11 15:52:55 +08:00
xiaoying-agent ec2fb1c241 test(ai-avatar): 适配 subprocess list 形式的单测 mock 与 preset 断言
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 / 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
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m1s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m43s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m3s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m58s
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 / CI Gate (pull_request) Failing after 2s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m29s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 32s
- execute_render 两个用例: os.system mock → subprocess.run mock,返回 CompletedProcess(returncode=0)
- preset 三个用例: 断言从 '-preset veryfast' 字符串包含改为 list 元素相邻校验,兼容 list[str] 形式
- 42/42 相关测试通过
2026-09-11 15:12:55 +08:00
xiaoxia 3cd8910f73 feat(ai-avatar): 封面预览实时显示标题叠加效果 + getRenderJob 60s 超时 (#1860)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 / Frontend Lint (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 7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 57s
CI/CD Pipeline / Integration Tests (push) Successful in 1m23s
CI/CD Pipeline / Validate - Style (push) Successful in 2m11s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m14s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m13s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m3s
CI/CD Pipeline / Unit Tests (push) Successful in 8m1s
CI/CD Pipeline / Validate - Security (push) Successful in 9m20s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-11 15:00:22 +08:00
CI Bot 1b76821307 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
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 / 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 / Check if frontend-only change (pull_request) Successful in 3s
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 / 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 / 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 / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m52s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m29s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 7m39s
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 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m58s
2026-09-11 06:27:13 +00:00
xiaoxia-agent 2c76d55d2b fix(ai-avatar, P0): FFmpeg命令改为subprocess list形式修复32512 + Celery异常raise + 投递失败写DB + 封面drawtext标题 + TTS audio/x-wav白名单
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
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m29s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
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
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
P0:
- ai_avatar_render_service._build_ffmpeg_command返回list, subprocess.run(shell=False)
  修复OSS预签名URL含&被sh解释为命令分隔符导致exit 127(32512)
- cover_extract命令同步改为list+subprocess.run, 超时60s, 非致命失败
- execute_render两个except块末尾加raise, 确保Celery标记任务FAILURE而非SUCCESS
- ffmpeg stderr写入error_message末尾800字符, 前端可见具体错误
- routes两处delay()异常: job.status=failed + error_message='任务提交失败:...' + db.commit
- lipsync_tts.safe_download_bytes白名单补audio/x-wav(CosyVoice可能返回该MIME)

P1:
- ai_avatar_cover_service新增apply_title_to_cover: ffmpeg drawtext叠加标题
  竖屏封面720x1280, 失败回退无标题原始帧
- generate_smart_cover/persist_cover_to_oss新增title_config参数
- SmartCoverRequest schema新增可选title_config字段
- smart-cover接口透传body.title_config; execute_render管线透传job.title_config

仅修改5个后端文件, 不改其他功能.
2026-09-11 14:22:45 +08:00
610 changed files with 20496 additions and 28431 deletions
+7
View File
@@ -196,3 +196,10 @@ DOUBAO_MODEL=doubao-seed-1-6-250615
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
# ==================== 积分/会员系统 (#1895) ====================
# 积分扣点总开关:默认 false(对现有用户零影响)。
# P2 阶段各业务路由逐个接入 @points_gate 时,用
# `if settings.points_enabled: ...`
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。
POINTS_ENABLED=false
+17 -5
View File
@@ -1022,12 +1022,22 @@ jobs:
BRANCH_TAG="${IMAGE_FULL}:${GITHUB_REF_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
# develop 分支额外推送 :dev tagWatchtower 监听的滚动更新 tag
if [ "${GITHUB_REF_NAME}" = "develop" ]; then
DEV_TAG="${IMAGE_FULL}:dev"
EXTRA_TAGS="$BRANCH_TAG $DEV_TAG"
PUSHED_TAGS_SUMMARY="${BRANCH_TAG} + ${DEV_TAG}"
else
EXTRA_TAGS="$BRANCH_TAG"
PUSHED_TAGS_SUMMARY="${BRANCH_TAG}"
fi
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "=== Docker build 尝试 $i/3 (${{ matrix.service_display }}) ==="
if EXTRA_TAGS="$BRANCH_TAG" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
if EXTRA_TAGS="$EXTRA_TAGS" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "✅ Docker build 成功"
break
fi
@@ -1040,7 +1050,7 @@ jobs:
fi
done
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${BRANCH_TAG})"
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${PUSHED_TAGS_SUMMARY})"
- name: Job duration summary
if: always()
@@ -1244,9 +1254,11 @@ jobs:
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
set -eux
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
# CI runner (act_runner) 部署在 116 staging 本机(116.62.226.203 公网 22 未开放),
# 默认走 127.0.0.1:22 本机 SSH,避免跨机网络依赖;可通过 secrets 覆盖。
staging_host="${STAGING_SSH_HOST:-127.0.0.1}"
staging_user="${STAGING_SSH_USER:-root}"
staging_port="${STAGING_SSH_PORT:-22222}"
staging_port="${STAGING_SSH_PORT:-22}"
echo "Host: $staging_host"
echo "Port: $staging_port"
@@ -1470,7 +1482,6 @@ jobs:
- validate-security
- validate-python
- unit-tests
- frontend-lint
- frontend-unit-test
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
strategy:
@@ -2124,3 +2135,4 @@ jobs:
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# CI retry trigger
-29
View File
@@ -1,29 +0,0 @@
name: "Verify staging post-deploy"
on:
push:
branches: [debug/verify-staging]
workflow_dispatch:
jobs:
verify:
runs-on: runtime-builder
timeout-minutes: 10
steps:
- name: Setup SSH
shell: bash
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
H=47.98.113.167; P=22222
ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null
- name: Run verify
shell: bash
run: |
set -x
echo 'IyEvYmluL2Jhc2gKc2V0IC14CmVjaG8gIj09PSAxLiBDdXJyZW50IHJ1bm5pbmcgY29udGFpbmVycyA9PT0iCmRvY2tlciBwcyAtLWZpbHRlciBuYW1lPXhpYW94aWEtKi1zdGFnaW5nIC0tZm9ybWF0ICJ0YWJsZSB7ey5OYW1lc319XHR7ey5TdGF0dXN9fVx0e3suSW1hZ2V9fSIKZWNobyAiIgplY2hvICI9PT0gMi4gV2ViIGxvZ3MgKGxhc3QgMTUgbGluZXMpID09PSIKZG9ja2VyIGxvZ3MgLS10YWlsIDE1IHhpYW94aWEtd2ViLXN0YWdpbmcgMj4mMQplY2hvICIiCmVjaG8gIj09PSAzLiBXZWIgbG9jYWxob3N0IGhlYWx0aCBjaGVjayA9PT0iCmN1cmwgLXNJIC0tbWF4LXRpbWUgNSBodHRwOi8vMTI3LjAuMC4xOjMwMDEvIHwgaGVhZCAtNQplY2hvICIiCmVjaG8gIj09PSA0LiBBUEkgbG9jYWxob3N0IGhlYWx0aCBjaGVjayA9PT0iCmN1cmwgLXNmIC0tbWF4LXRpbWUgNSBodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoCmVjaG8gIiIKZWNobyAiPT09IDUuIEFQSSB2ZXJzaW9uID09PSIKY3VybCAtcyAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aCB8IHB5dGhvbjMgLWMgImltcG9ydCBzeXMsanNvbjsgZD1qc29uLmxvYWQoc3lzLnN0ZGluKTsgcHJpbnQoJ3ZlcnNpb246JywgZC5nZXQoJ3ZlcnNpb24nLCc/JykpIiAyPi9kZXYvbnVsbAplY2hvICIiCmVjaG8gIj09PSA2LiBXb3JrZXIgY29udGFpbmVyIHZlcnNpb24gPT09Igpkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdvcmtlci1zdGFnaW5nIC0tZm9ybWF0ICd7e3JhbmdlIC5Db25maWcuRW52fX17ey59fXt7IlxuIn19e3tlbmR9fScgfCBncmVwIEFQUF9WRVJTSU9OCmVjaG8gIiIKZWNobyAiPT09IDcuIFdvcmtlciBsb2dzIChzdGFydHVwICsgdGFzayByZWdpc3RyYXRpb24pID09PSIKZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtd29ya2VyLXN0YWdpbmcgMj4mMSB8IGdyZXAgLWlFICJyZWdpc3RlcmVkfGNlbGVyeUB8bGlwc3luY3xyZWFkeXxNb2R1bGVOb3RGb3VuZHxFcnJvcnx0YXNrIiB8IGhlYWQgLTMwCmVjaG8gIiIKZWNobyAiPT09IDguIExpc3QgYWxsIHJlZ2lzdGVyZWQgY2VsZXJ5IHRhc2tzIChXb3JrZXLlhoVpbnNwZWN0KSA9PT0iCmRvY2tlciBleGVjIHhpYW94aWEtd29ya2VyLXN0YWdpbmcgc2ggLWMgImNlbGVyeSAtQSB3b3JrZXJfYXBwLmNlbGVyeV9hcHAgaW5zcGVjdCByZWdpc3RlcmVkIDI+JjEgfCBoZWFkIC00MCIgfHwgZWNobyAiY2VsZXJ5IGluc3BlY3QgZmFpbGVkIgplY2hvICIiCmVjaG8gIj09PSA5LiBDaGVjayBpbXBvcnRzIGluc2lkZSBXb3JrZXIgKGxpcHN5bmNfdHRzIG1vZHVsZSkgPT09Igpkb2NrZXIgZXhlYyB4aWFveGlhLXdvcmtlci1zdGFnaW5nIHB5dGhvbiAtYyAiCmltcG9ydCBpbXBvcnRsaWIKbSA9IGltcG9ydGxpYi5pbXBvcnRfbW9kdWxlKCdhcHAudGFza3MubGlwc3luY190dHMnKQpwcmludCgnbGlwc3luY190dHMgbW9kdWxlIGltcG9ydGVkIE9LJykKcHJpbnQoJ3N5bnRoZXNpemVfYW5kX3N1Ym1pdDonLCBoYXNhdHRyKG0sICdzeW50aGVzaXplX2FuZF9zdWJtaXQnKSkKcHJpbnQoJ1Nlc3Npb25Mb2NhbDonLCBoYXNhdHRyKG0sICdTZXNzaW9uTG9jYWwnKSkKIiAyPiYxIHwgaGVhZCAtMTAKZWNobyAiIgplY2hvICI9PT0gMTAuIFB1YmxpYyBBUEkgaGVhbHRoIGNoZWNrIChzdGFnaW5nLWFwaSkgPT09IgpjdXJsIC1zSSAtLW1heC10aW1lIDEwIGh0dHBzOi8vc3RhZ2luZy1hcGkueGlhb3hpYWppYW5qaS5jb20vaGVhbHRoIHwgaGVhZCAtMwplY2hvICIiCmVjaG8gIj09PSAxMS4gUHVibGljIHdlYiBoZWFsdGggY2hlY2sgPT09IgpjdXJsIC1zSSAtLW1heC10aW1lIDEwIGh0dHBzOi8vc3RhZ2luZy54aWFveGlhamlhbmppLmNvbS8gfCBoZWFkIC0zCg==' | base64 -d > /tmp/verify.sh
chmod +x /tmp/verify.sh
ssh -p 22222 -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@47.98.113.167 'bash -s' < /tmp/verify.sh
+1
View File
@@ -0,0 +1 @@
retrigger3
+1
View File
@@ -263,3 +263,4 @@ pytest --cov=packages --cov-report=html
---
**License**: MIT
<!-- CI trigger: 1788229339 -->
@@ -0,0 +1,27 @@
"""add sentence_timings to lipsync_jobs
Revision ID: 075_add_sentence_timings
Revises: 074_ai_avatar_render_script_id_optional
Create Date: 2026-09-12
"""
import sqlalchemy as sa
from alembic import op
revision = "075_add_sentence_timings"
down_revision = "074_render_script_id_optional"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("lipsync_jobs") as batch:
batch.add_column(
sa.Column("sentence_timings", sa.JSON(), nullable=True),
)
def downgrade() -> None:
with op.batch_alter_table("lipsync_jobs") as batch:
batch.drop_column("sentence_timings")
+133
View File
@@ -0,0 +1,133 @@
"""add membership & points system
Revision ID: 076_membership_points
Revises: 075_add_sentence_timings
Create Date: 2026-09-15
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "076_membership_points"
down_revision = "075_add_sentence_timings"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. users 表新增字段
with op.batch_alter_table("users") as batch:
batch.add_column(
sa.Column("is_member", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
batch.add_column(
sa.Column("member_type", sa.String(20), nullable=True),
)
batch.add_column(
sa.Column("member_expires_at", sa.DateTime(), nullable=True),
)
batch.add_column(
sa.Column("points_balance", sa.Integer(), nullable=False, server_default=sa.text("0")),
)
# 2. points_accounts 积分账户表
op.create_table(
"points_accounts",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, unique=True, index=True),
sa.Column("balance", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_earned", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_spent", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# 3. points_transactions 积分流水表
op.create_table(
"points_transactions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("account_id", sa.String(36), nullable=False, index=True),
sa.Column("type", sa.String(20), nullable=False, index=True),
sa.Column("source", sa.String(50), nullable=False, index=True),
sa.Column("amount", sa.Integer(), nullable=False),
sa.Column("balance_after", sa.Integer(), nullable=False),
sa.Column("description", sa.String(255), nullable=False, server_default=""),
sa.Column("ref_id", sa.String(100), nullable=False, server_default=""),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# 4. points_orders 积分/会员订单表
op.create_table(
"points_orders",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("order_type", sa.String(20), nullable=False),
sa.Column("product_code", sa.String(50), nullable=False),
sa.Column("amount_cents", sa.Integer(), nullable=False),
sa.Column("original_amount_cents", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("discount", sa.Float(), nullable=False, server_default=sa.text("1.0")),
sa.Column("points_amount", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("payment_method", sa.String(50), nullable=True),
sa.Column("payment_id", sa.String(100), nullable=True),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# 5. daily_usage_records 每日使用记录表
op.create_table(
"daily_usage_records",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("usage_date", sa.DateTime(), nullable=False),
sa.Column("usage_type", sa.String(50), nullable=False, server_default="free_clip"),
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.UniqueConstraint(
"user_id",
"usage_date",
"usage_type",
name="uq_daily_usage_user_date_type",
),
)
def downgrade() -> None:
op.drop_table("daily_usage_records")
op.drop_table("points_orders")
op.drop_table("points_transactions")
op.drop_table("points_accounts")
with op.batch_alter_table("users") as batch:
batch.drop_column("points_balance")
batch.drop_column("member_expires_at")
batch.drop_column("member_type")
batch.drop_column("is_member")
+22
View File
@@ -6,6 +6,7 @@ from app.api.routes.assets import router as assets_router
from app.api.routes.auth import router as auth_router
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.duplication import router as duplication_router
from app.api.routes.feature_flags import router as feature_flags_router
@@ -17,8 +18,10 @@ 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 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
from app.api.routes.share import router as share_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
@@ -156,6 +159,10 @@ api_router.include_router(
prefix="/templates",
tags=["Template"],
)
api_router.include_router(
clips_standalone_router,
tags=["Clips"],
)
api_router.include_router(
templates_editor_router,
prefix="/templates/{template_id}/editor",
@@ -184,8 +191,23 @@ api_router.include_router(
prefix="/scripts",
tags=["ScriptLibrary"],
)
api_router.include_router(
scripts_ai_router,
prefix="/scripts",
tags=["ScriptLibrary AI"],
)
api_router.include_router(
ai_avatar_render_router,
prefix="/ai-avatar/render",
tags=["AI Avatar Render"],
)
api_router.include_router(
points_router,
prefix="/points",
tags=["Points"],
)
api_router.include_router(
usage_router,
prefix="/usage",
tags=["Usage"],
)
@@ -0,0 +1,91 @@
"""默认模板兜底共享逻辑(P0 #1922).
提供 get_or_create_default_template_id(db, user_id) 共享函数,
供 templates.py 列表查询、clips_standalone.py 独立端点、dependencies.py
resolve_draft_plan_id 三处复用,避免三处各写一套兜底逻辑产生分叉。
根因:PR#1918 清理模板管理 API 时误删了 GET /templates 自动创建默认模板
兜底,前端 PR#1913 去掉空 tid 拦截后首次进入生成页拼出
/templates//editor/clips/from-assets(双斜杠)→ FastAPI 404,阻断新用户首次
生成。
"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def get_or_create_default_template_id(db: Session, user_id: str) -> Optional[str]:
"""获取或自动创建默认配音模板的 id。
判定逻辑(不做异常降级,只有确实创建失败时才回滚重查):
1. 查用户名下 is_active=True 且有 TemplateClipConfig 的模板 → 返回其 id
2. 无则调用 CreateTemplateUseCase 创建一条默认 voice_over 模板;
3. 创建异常时 rollback 再重查一次(防并发唯一键冲突),重查仍无返回 None。
"""
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
TemplateModel,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.template.commands import (
CreateTemplateCommand,
SegmentCommand,
)
from packages.application.template.use_cases import CreateTemplateUseCase
existing = (
db.query(TemplateModel)
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
.order_by(TemplateModel.created_at.asc())
.first()
)
if existing is not None:
has_seg = (
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
)
if has_seg:
return existing.id
try:
repo = SQLAlchemyTemplateRepository(db)
cmd = CreateTemplateCommand(
user_id=user_id,
name="默认配音模板",
mode="voice_over",
category="default",
tags=[],
title_config={},
subtitle_config={},
bgm_config={},
estimated_duration=0.0,
segments=[SegmentCommand(segment_order=0, duration_min=1.0, duration_max=30.0)],
)
tpl = CreateTemplateUseCase(repo).execute(cmd)
db.commit()
logger.info("auto-created default voice_over template: id=%s user=%s", tpl.id, user_id)
return tpl.id
except Exception:
db.rollback()
# 重查:可能并发请求已建好
existing = (
db.query(TemplateModel)
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
.order_by(TemplateModel.created_at.asc())
.first()
)
if existing is not None:
has_seg = (
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
)
if has_seg:
return existing.id
logger.exception("failed to auto-create default template user=%s", user_id)
return None
+2 -2
View File
@@ -1,6 +1,6 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
@@ -138,4 +138,4 @@ def format_utc_datetime(dt: datetime | None) -> str:
return dt
if dt.tzinfo is None:
return dt.isoformat() + "Z"
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
+6 -6
View File
@@ -5,7 +5,7 @@
from __future__ import annotations
from typing import List, Literal
from typing import Literal
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
from fastapi import APIRouter
@@ -31,7 +31,7 @@ class GenerateTitlesRequest(BaseModel):
class GenerateTitlesResponse(BaseModel):
"""智能标题生成响应."""
titles: List[str] = Field(..., description="生成的标题列表")
titles: list[str] = Field(..., description="生成的标题列表")
style: str = Field(..., description="实际使用的风格")
source: str = Field(..., description="来源:doubao 或 fallback")
description: str = Field(..., description="原始描述")
@@ -53,7 +53,7 @@ class AssetMatchItem(BaseModel):
id: str = Field(..., description="素材ID")
name: str = Field(default="", description="素材名称")
tags: List[str] = Field(default_factory=list, description="标签列表")
tags: list[str] = Field(default_factory=list, description="标签列表")
description: str = Field(default="", description="素材描述")
@@ -61,7 +61,7 @@ class SemanticMatchRequest(BaseModel):
"""语义匹配请求."""
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
assets: list[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
@@ -75,7 +75,7 @@ class SemanticMatchResultItem(AssetMatchItem):
class SemanticMatchResponse(BaseModel):
"""语义匹配响应."""
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
matches: list[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
source: str = Field(..., description="来源:doubao / fallback")
description: str = Field(..., description="原始描述")
total: int = Field(..., description="输入素材总数")
@@ -99,7 +99,7 @@ def generate_titles(request: GenerateTitlesRequest):
return GenerateTitlesResponse(**result)
@router.get("/titles/styles", response_model=List[TitleStyleInfo])
@router.get("/titles/styles", response_model=list[TitleStyleInfo])
def list_title_styles():
"""获取支持的标题风格列表."""
return [
+131 -25
View File
@@ -11,13 +11,14 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.ai_avatar_render import (
AiAvatarRenderJobResponse,
CreateAiAvatarRenderRequest,
SmartCoverRequest,
FinalizeRenderResponse,
SmartCoverResponse,
)
from app.services.ai_avatar_cover_service import generate_smart_cover
@@ -77,10 +78,16 @@ def create_render_job(
from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(job.id)
except Exception:
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
except Exception as exc:
logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc)
job.status = "failed"
job.error_message = f"任务提交失败:{exc}"
job.updated_at = datetime.now(UTC)
svc.db.commit()
svc.db.refresh(job)
return AiAvatarRenderJobResponse.model_validate(job)
return job
return AiAvatarRenderJobResponse.model_validate(job)
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@@ -172,38 +179,54 @@ def retry_render_job(
from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(job.id)
except Exception:
logger.warning("Celery 任务提交失败重试任务已重置但未触发执行: %s", job.id)
except Exception as exc:
logger.exception("Celery 任务投递失败重试: job_id=%s err=%s", job.id, exc)
job.status = "failed"
job.error_message = f"任务提交失败:{exc}"
job.updated_at = datetime.now(UTC)
svc.db.commit()
svc.db.refresh(job)
return AiAvatarRenderJobResponse.model_validate(job)
return job
return AiAvatarRenderJobResponse.model_validate(job)
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
@router.post("/smart-cover", response_model=SmartCoverResponse)
def generate_avatar_smart_cover(
body: SmartCoverRequest,
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse)
def generate_render_smart_cover(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
) -> SmartCoverResponse:
"""智能获取数字人视频封面.
db: Session = Depends(get_db_session),
):
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧逻辑(非 FFmpeg 简单截帧),
并将选中帧转存到自家 OSS,返回非临时的封面公网 URL。
前端「智能获取封面」按钮可直接调用本接口;不依赖渲染任务完成。
- 必须等渲染任务 completed 后才可调用(否则返回 400)
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url
"""
video_url = (body.video_url or "").strip()
if not video_url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL")
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService(db)
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
video_url = (job.output_video_url or "").strip()
if not video_url:
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空")
try:
cover_url = generate_smart_cover(video_url, max_frames=body.max_frames)
# 从最终成片抽帧,帧本身已含标题/B-roll,直接转存 OSS
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
except Exception as exc:
logger.error(
"智能封面生成异常: user=%s video_url=%s err=%s",
current_user.user.id, video_url[:80], exc,
"渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s",
current_user.user.id,
job_id,
video_url[:80],
exc,
exc_info=True,
)
cover_url = ""
@@ -214,5 +237,88 @@ def generate_avatar_smart_cover(
status="fallback_failed",
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
)
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
# 更新 render_job 的封面字段(异步写入 DB;失败不影响返回)
try:
job.cover_config = {
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
"mode": "auto_frame",
"url": cover_url,
}
job.output_cover_url = cover_url
job.updated_at = datetime.now(UTC)
db.commit()
except Exception as exc:
logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc)
logger.info(
"渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s",
current_user.user.id,
job_id,
cover_url[:120],
)
return SmartCoverResponse(cover_url=cover_url, status="completed")
# ── POST /{job_id}/finalize — 封面选定后正式入库成片库 ────────────────────
@router.post("/{job_id}/finalize", response_model=FinalizeRenderResponse)
def finalize_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""用户完成封面选择后,将视频正式保存到成片库.
- 必须等渲染任务 completed 后才可调用
- 如果已通过 smart-cover/custom-cover 设置了封面,会自动带上
- 返回成片库视频ID
- 幂等:已 finalize 的任务重复调用会返回 existing 记录
"""
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
svc = AiAvatarRenderService(db)
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
# 幂等检查(通过 generation_task_id=job_id 识别,finalize_job 内部也做了一次,这里提前返回简化)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
existing = (
db.query(GeneratedVideoModel)
.filter(
GeneratedVideoModel.user_id == current_user.user.id,
GeneratedVideoModel.generation_task_id == job_id,
)
.first()
)
if existing is not None:
return FinalizeRenderResponse(
video_id=existing.id,
cover_url=existing.thumbnail_url or "",
status="already_finalized",
)
try:
video = svc.finalize_job(job_id, current_user.user.id)
return FinalizeRenderResponse(
video_id=video.id,
cover_url=video.thumbnail_url or job.output_cover_url or "",
status="success",
)
except AiAvatarRenderError as exc:
status_map = {
"RenderJobNotFound": 404,
"RenderNotCompleted": 400,
"OutputVideoMissing": 400,
}
raise HTTPException(
status_code=status_map.get(exc.code, 400),
detail=str(exc),
) from exc
except Exception as exc:
logger.error("渲染任务finalize失败: job_id=%s err=%s", job_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail=f"保存到成片库失败: {str(exc)}") from exc
+2 -2
View File
@@ -1,5 +1,5 @@
import logging
from typing import Any, List, Optional
from typing import Any, Optional
from app.api.routes._helpers import check_project_access, format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user
@@ -390,7 +390,7 @@ def update_asset_review_status(
return _to_asset_response(updated)
@router.post("/batch", response_model=List[AssetResponse])
@router.post("/batch", response_model=list[AssetResponse])
def batch_get_assets(
request: BatchGetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
+6 -6
View File
@@ -8,7 +8,7 @@ import json
import logging
import shutil
import tempfile
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
@@ -156,7 +156,7 @@ def _cleanup_expired_uploads() -> int:
if not CHUNK_STORAGE_ROOT.exists():
return 0
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
cleaned = 0
for meta_file in CHUNK_STORAGE_ROOT.glob("*.meta.json"):
@@ -166,7 +166,7 @@ def _cleanup_expired_uploads() -> int:
expires_at = datetime.fromisoformat(meta["expires_at"])
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
expires_at = expires_at.replace(tzinfo=UTC)
# Only cleanup uploads that are not actively being uploaded
if expires_at < now and meta.get("status") != "uploading":
@@ -226,7 +226,7 @@ async def init_chunked_upload(
# Generate upload ID
upload_id = uuid4().hex
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
expires_at = now + timedelta(hours=CHUNK_EXPIRY_HOURS)
# Create chunk directory
@@ -421,9 +421,9 @@ async def upload_chunk(
# Check expiry
expires_at = datetime.fromisoformat(meta["expires_at"])
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(timezone.utc):
if expires_at < datetime.now(UTC):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
# Validate chunk index
@@ -0,0 +1,90 @@
"""独立的从素材创建片段端点(不依赖 template_id 路径参数).
POST /api/v1/clips/from-assets
- 与 /api/v1/templates/{template_id}/editor/clips/from-assets 功能一致
- 区别:template_id 从 body 传入(可选),为空时后端自动创建/查找默认模板
- 解决前端首次加载时 templateId 为空导致双斜杠 404 的问题(P0 #1922
- 内部复用 resolve_draft_plan_id 和 create_clips_from_assets_editor 的核心逻辑
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_asset_repository, get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from ._default_template import get_or_create_default_template_id
from .templates_editor.clips import create_clips_from_assets_editor
from .templates_editor.dependencies import resolve_draft_plan_id
from .templates_editor.schemas import ClipsFromAssetsRequest, ClipsFromAssetsResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Clips"])
class StandaloneClipsRequest(ClipsFromAssetsRequest):
"""扩展请求:template_id 可选(不传则后端自动兜底默认模板)。"""
template_id: str | None = None
def _get_editor_services_direct(db: Session) -> tuple[EditTemplateService, EditPlanService]:
"""直接构造服务实例(非 Depends 版本,供独立端点内部调用)。"""
return EditTemplateService(db), EditPlanService(db)
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets(
body: StandaloneClipsRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(template_id 可选,为空自动兜底)。"""
user_id = str(current_user.user.id)
services = _get_editor_services_direct(db)
# 1. 解析/兜底 template_id,拿到 plan_id
template_id = (body.template_id or "").strip()
if not template_id:
template_id = get_or_create_default_template_id(db, user_id)
if not template_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="无法自动创建默认模板,请刷新页面重试",
)
plan_id = resolve_draft_plan_id(
template_id=template_id,
services=services,
current_user=current_user,
db=db,
auto_create_default=False, # 上面已兜底过
)
# 2. 构造标准化请求(去除独立端扩展字段),复用原端点核心逻辑
core_body = ClipsFromAssetsRequest(
asset_ids=body.asset_ids,
clip_type=body.clip_type,
clip_count=body.clip_count,
required_clips_count=body.required_clips_count,
)
# 3. 直接调用原端点函数(此时所有 Depends 依赖已手动传入)
return create_clips_from_assets_editor(
template_id=template_id,
body=core_body,
background_tasks=background_tasks,
plan_id=plan_id,
services=services,
asset_repo=asset_repo,
db=db,
current_user=current_user,
)
+7 -2
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import ipaddress
import logging
import re
from typing import Any, List, Optional
from typing import Any, Optional
from urllib.parse import urlparse
from app.auth import AuthenticatedUser, get_current_user
@@ -41,7 +41,7 @@ router = APIRouter(tags=["Generation"])
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
asset_ids: list[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
@@ -766,7 +766,12 @@ def generate_cover(
storage_svc = get_shared_storage_service()
mk_client = get_mediakit_client()
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
# #1901 统一读 "title",兼容老数据 "title_config"
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
if not isinstance(_e2_title_cfg, dict) or not (_e2_title_cfg.get("text") or "").strip():
_alt = (plan.config or {}).get("title_config", {}) or {}
if isinstance(_alt, dict):
_e2_title_cfg = _alt
if not isinstance(_e2_title_cfg, dict):
_e2_title_cfg = {}
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
+38 -111
View File
@@ -42,6 +42,7 @@ from packages.application import (
ListGeneratedVideosByTaskUseCase,
)
from packages.domain.smart_match import smart_select_assets
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
@@ -58,27 +59,10 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
"""[已下沉] 路由层兼容别名 → app.services.generation_common.query_voice_durations。"""
from app.services.generation_common import query_voice_durations
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
"""
ids = [v for v in dict.fromkeys(voice_ids or []) if v]
if not ids:
return []
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(ids)).all()
dur_map: dict[str, float] = {}
for row in rows:
try:
dur_map[row[0]] = float(row[1] or 0.0)
except (TypeError, ValueError):
dur_map[row[0]] = 0.0
return [dur_map.get(v, 0.0) for v in ids]
except Exception:
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
return [0.0 for _ in ids]
return query_voice_durations(db, voice_ids)
def _to_generation_task_response(task) -> GenerationTaskResponse:
@@ -184,61 +168,10 @@ def _writeback_edit_plan_config(
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.configgeneration_task_id + title_config。
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。"""
from app.services.generation_common import writeback_edit_plan_config
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
# 标题变化,清除旧封面
if "cover" in merged:
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id,
old_title_text,
new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
return writeback_edit_plan_config(plan_id, task_id, title_config, db)
def _resolve_project_and_library(
@@ -279,6 +212,7 @@ def _resolve_project_and_library(
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
@points_gate("ai_video", quantity_field="count")
def create_generation_task(
request: CreateGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -489,25 +423,14 @@ def create_generation_task(
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
voice_durations = _query_voice_durations(db, variant_voices)
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源
batch_source_plan_id = request.source_edit_plan_id
if not batch_source_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(公共函数
from app.services.generation_common import resolve_latest_plan_by_template
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
batch_source_plan_id = _latest.id
except Exception:
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
batch_source_plan_id = (
request.source_edit_plan_id
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
or ""
)
if not batch_source_plan_id and not request.variant_plan_ids:
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
@@ -552,7 +475,12 @@ def create_generation_task(
) from clone_err
variant_plan_ids.append(_plan0.id)
# 变体 1..N-1 独立选片
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数)
from app.services.generation_common import collect_plan_segments as _collect_segments
_batch_segments = _collect_segments(_plan0.id, _plan_svc._clip_repo)
# 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让)
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
@@ -564,6 +492,7 @@ def create_generation_task(
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
batch_segments=_batch_segments,
)
break
except ValueError as ve:
@@ -595,7 +524,16 @@ def create_generation_task(
) from last_err
variant_plan_ids.append(variant.id)
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配)
# #1855 P0:把新变体的clips区间追加到batch_segments,供下一变体避让
try:
_new_segs = _collect_segments(variant.id, _plan_svc._clip_repo)
for _aid, _ivs in _new_segs.items():
_batch_segments.setdefault(_aid, []).extend(_ivs)
except Exception:
logger.exception("[生成任务] 变体%d 区间收集失败(不阻断)", task_index)
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配,
# #1855apply_voice_duration_to_plan 已内置幂等判断,重复调用安全)
for _vi, _pid in enumerate(variant_plan_ids):
_vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0
if _vd > 0:
@@ -616,24 +554,13 @@ def create_generation_task(
)
_single_vd: list[float] = _query_voice_durations(db, _voices)
_single_dur = _single_vd[0] if _single_vd else 0.0
_single_plan = request.source_edit_plan_id
if not _single_plan and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
from app.services.generation_common import resolve_latest_plan_by_template
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
_single_plan = _latest.id
except Exception:
logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True)
_single_plan = (
request.source_edit_plan_id
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
or ""
)
if _single_dur > 0 and _single_plan:
from app.services.edit_plan_service import EditPlanService
@@ -30,6 +30,74 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _get_or_create_default_template_id(db: Session, user_id: str) -> str | None:
"""为用户查找一个有效模板;若不存在则自动创建默认配音模板。
前端 #1911 删除了模板选择 UI,当调用方未传 template_id/source_edit_plan_id
时(如剪辑页首次进入直接选片),后端兜底查找/创建默认模板,避免 400。
Returns:
template_id(字符串);失败时返回 None。
"""
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
from packages.application.template.commands import CreateTemplateCommand, SegmentCommand
from packages.application.template.use_cases import CreateTemplateUseCase
# 1. 先查已有有效模板(is_active=True 且存在片段配置)
existing = (
db.query(TemplateModel)
.filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active.is_(True),
)
.order_by(TemplateModel.created_at.asc())
.first()
)
if existing is not None:
# 验证该模板是否有片段配置;若没有继续尝试创建默认
has_seg = (
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
)
if has_seg:
return existing.id
# 2. 无有效模板 → 自动创建默认配音模板
try:
repo = SQLAlchemyTemplateRepository(db)
cmd = CreateTemplateCommand(
user_id=user_id,
name="默认配音模板",
mode="voice_over",
category="default",
tags=[],
title_config={},
subtitle_config={},
bgm_config={},
estimated_duration=0.0,
segments=[
SegmentCommand(
segment_order=0,
duration_min=1.0,
duration_max=30.0,
material_type=None,
),
],
)
use_case = CreateTemplateUseCase(repo)
tpl = use_case.execute(cmd)
logger.info(
"[variant-plans] 自动创建默认模板: user=%s tpl=%s",
user_id,
tpl.id,
)
return tpl.id
except Exception:
logger.exception("[variant-plans] 自动创建默认模板失败: user=%s", user_id)
return None
class VariantPlanRequest(BaseModel):
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
@@ -43,8 +111,8 @@ class VariantPlanRequest(BaseModel):
@model_validator(mode="after")
def _validate(self) -> "VariantPlanRequest":
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
# 不再强制要求 template_id / source_edit_plan_id
# 后端在路由内会自动查找/创建默认模板兜底(#1911 后前端不再显式选模板)。
try:
resolve_variant_voice_ids(
count=self.count,
@@ -90,25 +158,19 @@ def create_variant_plans(
except VariantVoiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新
source_plan_id = request.source_edit_plan_id.strip()
if not source_plan_id and request.template_id.strip():
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新(公共函数)
from app.services.generation_common import resolve_latest_plan_by_template
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id.strip(),
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
source_plan_id = _latest.id
except Exception:
logger.exception("[variant-plans] 源 plan 解析失败")
source_plan_id = request.source_edit_plan_id.strip()
template_id = request.template_id.strip()
# P0 兜底:前端 #1911 已删除模板选择 UI,调用方可能不传 template_id
# 此时自动为该用户查找/创建默认模板。
if not source_plan_id and not template_id:
template_id = _get_or_create_default_template_id(db, user_id) or ""
if not source_plan_id and template_id:
source_plan_id = resolve_latest_plan_by_template(db, template_id=template_id, user_id=user_id) or ""
if not source_plan_id:
raise HTTPException(
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import UTC, datetime
import psycopg
import redis
@@ -13,7 +13,7 @@ router = APIRouter(tags=["Health"])
async def health_check():
return {
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"version": settings.APP_VERSION,
}
@@ -33,7 +33,7 @@ async def startup_check():
all_ready = all(check["status"] == "healthy" for check in checks.values())
response = {
"status": "started" if all_ready else "starting",
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"checks": checks,
}
if not all_ready:
+178 -17
View File
@@ -1,28 +1,40 @@
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整.
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整, #1845 配音前置.
接口:
POST /api/v1/lipsync/jobs 提交对口型任务
POST /api/v1/lipsync/jobs 提交对口型任务(支持 TTS/直传/预合成 三种模式)
GET /api/v1/lipsync/jobs 任务列表
GET /api/v1/lipsync/jobs/{id} 任务详情
POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态
POST /api/v1/lipsync/jobs/{id}/cancel 取消任务
POST /api/v1/lipsync/tts-preview #1845 步骤1 TTS 预合成(同步 HTTP~2-3s
"""
from __future__ import annotations
import logging
import math
from datetime import UTC
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.dependencies import (
get_db_session,
get_voice_clone_profile_repository,
)
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
from app.schemas.lipsync import (
AiAvatarTtsPreviewRequest,
AiAvatarTtsPreviewResponse,
CreateLipsyncJobRequest,
LipsyncJobResponse,
)
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -33,7 +45,6 @@ def _get_service(
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> LipsyncService:
# voice_clone_repo 用于克隆音色 profile 解析
# TTS 合成已移至 Celery 异步任务,无需同步注入 cosyvoice_service
return LipsyncService(
db,
voice_clone_repo=voice_clone_repo,
@@ -47,19 +58,56 @@ def _get_service(
def create_lipsync_job(
body: CreateLipsyncJobRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
svc: LipsyncService = Depends(_get_service),
):
user_id = current_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_digital_human"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
# 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟)
if body.audio_url and body.audio_duration and body.audio_duration > 0:
est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0))
elif body.script_text:
est_minutes = max(1.0, math.ceil(len(body.script_text) / 240))
else:
est_minutes = 1.0
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(current_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(current_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
"""提交对口型任务.
#1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?}
后端创建任务记录(状态 tts_processing),dispatch Celery 异步任务执行 TTS 合成 + MediaKit 提交;
也支持直接传 {video_url, audio_url}(同步提交 MediaKit
三种模式:
- TTS 直生(旧版/降级):传 {video_url, voice_id, script_text, speed?, emotion?}
后端 dispatch Celery 异步任务
- 直接音频:传 {video_url, audio_url},后端同步下载+算timings+提交MediaKit。
- 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings}
后端同步ffprobe+写入timings+直接提交MediaKit~2-3s)。
"""
try:
job = svc.create_job(
user_id=current_user.user.id,
user_id=user_id,
video_url=body.video_url,
audio_url=body.audio_url,
audio_duration=body.audio_duration,
sentence_timings=body.sentence_timings,
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
@@ -68,10 +116,18 @@ def create_lipsync_job(
project_id=body.project_id,
)
except ValueError as exc:
# 参数无效(如 voice_id 格式不对、文本过长等)
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}")
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MediaKitError as exc:
# 音色无权访问 → 403;参数无效 → 400MediaKit 提交失败 → 502
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}")
status_code = 502
if exc.code in ("VoiceForbidden",):
status_code = 403
@@ -86,16 +142,110 @@ def create_lipsync_job(
},
) from exc
except Exception as exc:
# 兜底:任何未预期的错误返回 400 而非 500
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"对口型异常退积分异常: err={refund_err}")
raise HTTPException(
status_code=400,
detail=f"创建对口型任务失败: {exc}",
) from exc
# 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等)
# 若任务已创建且状态为 failed,退费
if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed":
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}")
return job
# ── POST /tts-preview — #1845 步骤1 TTS 预合成 ──────────────────────────
@router.post("/tts-preview", response_model=AiAvatarTtsPreviewResponse)
def preview_tts(
body: AiAvatarTtsPreviewRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
svc: LipsyncService = Depends(_get_service),
):
user_id = current_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_digital_human"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(current_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(current_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
"""步骤1「生成配音」同步 TTS 预合成.
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL~24h 有效)。
耗时约 2-3 秒。
"""
try:
result = svc.preview_tts(
user_id=user_id,
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
emotion=body.emotion,
)
except MediaKitError as exc:
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}")
status_code = 400
if exc.code in ("VoiceForbidden",):
status_code = 403
elif exc.code in ("TTSNoAudio",):
status_code = 502
raise HTTPException(
status_code=status_code,
detail={
"code": exc.code,
"message": str(exc),
},
) from exc
except Exception as exc:
logger.error("TTS 预合成异常: %s", exc, exc_info=True)
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}")
raise HTTPException(
status_code=400,
detail=f"TTS 合成失败: {exc}",
) from exc
return result
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@@ -134,17 +284,28 @@ def get_lipsync_job(
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""获取对口型任务详情.
非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态),
避免 MediaKit 慢响应阻塞前端轮询。
"""
"""获取对口型任务详情."""
job = svc.get_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.status not in ("completed", "failed"):
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
# 三层防御 ①:如果距上次更新超过 30 秒,同步刷新一次(避免 background task
# 静默失败导致前端永远看到 running);否则挂后台异步刷新(避免阻塞轮询)。
from datetime import datetime as _dt
_now = _dt.now(UTC)
_stale = job.updated_at is None or (_now - job.updated_at).total_seconds() > 30
if _stale:
try:
refreshed = svc.refresh_job_status(job_id, current_user.user.id)
if refreshed is not None:
job = refreshed
except Exception as exc: # noqa: BLE001
logger.error("同步刷新对口型状态失败 job_id=%s err=%s", job_id, exc, exc_info=True)
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
else:
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
return job
+321
View File
@@ -0,0 +1,321 @@
"""积分 & 会员 API 路由 (#1895)
导出两个 router
- points_router: 积分相关路由,前缀 /points
- usage_router: 每日额度路由,前缀 /usage
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.points import (
DailyUsageResponse,
MembershipStatusResponse,
PointRuleItem,
PointsBalanceResponse,
PointsCheckRequest,
PointsCheckResponse,
PointsDeductRequest,
PointsOrderResponse,
PointsPackageItem,
PointsPackagesResponse,
PointsRechargeRequest,
PointsRefundRequest,
PointsRulesResponse,
PointsTransactionsResponse,
SimpleMessageResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.domain.points_rules import (
FREE_USER_MULTIPLIER,
MEMBER_DISCOUNT,
POINTS_PACKAGES,
POINTS_SCENES,
calculate_points_cost,
)
from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
# ── 两个 router ──
points_router = APIRouter()
usage_router = APIRouter()
def _get_service() -> PointsService:
return PointsService()
def _is_member(user: AuthenticatedUser) -> bool:
"""判断用户是否为付费会员。"""
return getattr(user.user, "is_member", False)
def _member_type(user: AuthenticatedUser) -> str | None:
return getattr(user.user, "member_type", None)
# ════════════════════════════════════════════════════════════════
# 积分相关路由 (prefix=/points)
# ════════════════════════════════════════════════════════════════
@points_router.get("/balance", response_model=PointsBalanceResponse)
def get_balance(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""查询当前用户积分余额 + 会员状态。"""
svc = _get_service()
account = svc.get_or_create_account(current_user.user.id, db)
return PointsBalanceResponse(
balance=account["balance"],
total_earned=account["total_earned"],
total_spent=account["total_spent"],
is_member=_is_member(current_user),
member_type=_member_type(current_user),
member_expires_at=getattr(current_user.user, "member_expires_at", None),
)
@points_router.get("/transactions", response_model=PointsTransactionsResponse)
def get_transactions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
type: Optional[str] = Query(None, description="筛选类型: add/deduct"),
source: Optional[str] = Query(None, description="筛选来源场景"),
start_date: Optional[datetime] = Query(None),
end_date: Optional[datetime] = Query(None),
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""查询积分流水(分页+筛选)。"""
svc = _get_service()
result = svc.get_transactions(
user_id=current_user.user.id,
db=db,
page=page,
page_size=page_size,
type_filter=type,
source_filter=source,
start_date=start_date,
end_date=end_date,
)
return PointsTransactionsResponse(**result)
@points_router.get("/rules", response_model=PointsRulesResponse)
def get_rules(
_current_user: AuthenticatedUser = Depends(get_current_user),
):
"""查询所有积分消耗规则。"""
rules = []
for scene_key, scene_data in POINTS_SCENES.items():
rules.append(
PointRuleItem(
scene_key=scene_key,
name=scene_data["name"],
base_points=scene_data["base_points"],
unit=scene_data["unit"],
extra_per_30s=scene_data.get("extra_per_30s"),
)
)
return PointsRulesResponse(
rules=rules,
free_user_multiplier=FREE_USER_MULTIPLIER,
)
@points_router.get("/packages", response_model=PointsPackagesResponse)
def get_packages(
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""查询可购买的积分包列表。"""
packages = []
for code, pkg in POINTS_PACKAGES.items():
unit_price = f"¥{pkg['price_cents'] / 100 / pkg['points']:.3f}/积分"
packages.append(
PointsPackageItem(
code=code,
name=pkg["name"],
points=pkg["points"],
price_cents=pkg["price_cents"],
unit_price=unit_price,
)
)
mt = _member_type(current_user)
discount = MEMBER_DISCOUNT.get(mt) if mt else None
return PointsPackagesResponse(packages=packages, user_discount=discount)
@points_router.post("/check", response_model=PointsCheckResponse)
def check_points(
body: PointsCheckRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""消费前检查余额是否足够。"""
is_mem = _is_member(current_user)
mt = _member_type(current_user)
# 混剪场景先检查免费额度
is_free_quota = False
if body.scene_key == "ai_video" and not is_mem:
svc = _get_service()
if svc.check_daily_free_clip(current_user.user.id, db):
is_free_quota = True
required = calculate_points_cost(
body.scene_key,
is_mem,
quantity=body.quantity or 1,
duration_minutes=body.duration_minutes or 0,
member_type=mt,
)
svc = _get_service()
account = svc.get_or_create_account(current_user.user.id, db)
balance = account["balance"]
return PointsCheckResponse(
allowed=is_free_quota or balance >= required,
required_points=required,
current_balance=balance,
remaining_after=balance - required,
is_free_quota=is_free_quota,
)
@points_router.post("/deduct", response_model=SimpleMessageResponse)
def deduct_points(
body: PointsDeductRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""积分扣减(内部服务调用)。"""
svc = _get_service()
result = svc.deduct_points(
user_id=current_user.user.id,
amount=body.amount,
source=body.scene_key,
db=db,
description=body.description or "",
ref_id=body.ref_id or "",
)
if not result["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {body.amount},余额 {result['balance']}",
},
)
return SimpleMessageResponse(
success=True,
message=f"扣减 {body.amount} 积分成功",
data={"transaction_id": result["transaction_id"], "balance": result["balance"]},
)
@points_router.post("/refund", response_model=SimpleMessageResponse)
def refund_points(
body: PointsRefundRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""积分退还(内部服务调用)。"""
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
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:
raise HTTPException(status_code=403, detail="无权退还他人积分")
svc = _get_service()
result = svc.refund_points(
user_id=current_user.user.id,
amount=txn.amount,
source=txn.source,
db=db,
ref_id=body.transaction_id,
description=body.reason or f"退还: {txn.description}",
)
if not result["success"]:
raise HTTPException(status_code=500, detail="退还失败")
return SimpleMessageResponse(
success=True,
message=f"退还 {txn.amount} 积分成功",
data={"transaction_id": result["transaction_id"], "balance": result["balance"]},
)
@points_router.post("/recharge", response_model=PointsOrderResponse)
def create_recharge_order(
body: PointsRechargeRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""创建积分充值订单。"""
svc = _get_service()
try:
order = svc.create_order(
user_id=current_user.user.id,
order_type="points",
product_code=body.package_id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
return PointsOrderResponse(**order)
@points_router.get("/subscription/membership", response_model=MembershipStatusResponse)
def get_membership_status(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""获取当前用户会员状态(聚合信息)。"""
svc = _get_service()
account = svc.get_or_create_account(current_user.user.id, db)
is_mem = _is_member(current_user)
max_resolution = "1080p" if is_mem else "720p"
return MembershipStatusResponse(
is_member=is_mem,
member_type=_member_type(current_user),
member_expires_at=getattr(current_user.user, "member_expires_at", None),
points_balance=account["balance"],
max_resolution=max_resolution,
)
# ════════════════════════════════════════════════════════════════
# 每日额度路由 (prefix=/usage)
# ════════════════════════════════════════════════════════════════
@usage_router.get("/daily", response_model=DailyUsageResponse)
def get_daily_usage(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""查询今日免费混剪额度使用情况。"""
svc = _get_service()
result = svc.get_daily_usage(current_user.user.id, db)
return DailyUsageResponse(**result)
# 为了向后兼容,也导出一个不带后缀的 router(方便旧引用)
router = points_router
+243
View File
@@ -0,0 +1,243 @@
"""Scripts AI 能力路由 — Issue #1893.
三个 AI 工具接口(均挂载在 /api/v1/scripts 前缀下):
- POST /extract-from-douyin 从抖音视频提取文案(yt-dlp 下载 + ASR 转写)
- POST /ai-rewrite AI 文案改写(复用豆包 LLM)
- POST /ai-generate-titles AI 标题生成(复用 generate_smart_titles
"""
from __future__ import annotations
import logging
import re
import tempfile
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.scripts_ai import (
AiGenerateTitlesRequest,
AiGenerateTitlesResponse,
AiRewriteRequest,
AiRewriteResponse,
ExtractFromDouyinRequest,
ExtractFromDouyinResponse,
)
from app.services.script_asr_service import (
ASRNotConfiguredError,
ASRTranscriptionError,
transcribe_to_text,
)
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.middleware.points_gate import points_gate
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
router = APIRouter()
# 抖音 URL 校验:支持短链 v.douyin.com 和长链 www.douyin.com/video/
_DOUYIN_URL_RE = re.compile(
r"^(https?://)?(v\.douyin\.com/\S+|www\.douyin\.com/video/\S+)$",
re.IGNORECASE,
)
def _validate_douyin_url(url: str) -> None:
"""校验抖音 URL 格式,不合法时抛 HTTPException(400)."""
if not url or not url.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="链接不能为空",
)
if not _DOUYIN_URL_RE.match(url.strip()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的抖音链接,仅支持 v.douyin.com 短链或 www.douyin.com/video/ 长链",
)
# ── 1. 从抖音视频提取文案 ─────────────────────────────────────────────────────
@router.post(
"/extract-from-douyin",
response_model=ExtractFromDouyinResponse,
)
@points_gate("douyin_extract")
def extract_from_douyin(
request: ExtractFromDouyinRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> ExtractFromDouyinResponse:
"""从抖音视频下载无水印视频并通过 ASR 提取文案."""
source_url = request.url.strip()
_validate_douyin_url(source_url)
# 确保 URL 有 schemeyt-dlp 需要完整 URL
url_for_download = source_url
if not re.match(r"^https?://", url_for_download, re.IGNORECASE):
url_for_download = "https://" + url_for_download
# 使用临时目录下载视频,退出时自动清理
try:
with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir:
import yt_dlp
ydl_opts = {
"format": "best[ext=mp4]/best",
"outtmpl": f"{temp_dir}/%(id)s.%(ext)s",
"quiet": True,
"no_warnings": True,
"noplaylist": True,
}
try:
ydl = yt_dlp.YoutubeDL(ydl_opts)
info = ydl.extract_info(url_for_download, download=True)
except Exception as exc:
logger.error("抖音视频下载失败: url=%s error=%s", source_url, exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"视频下载失败: {exc}",
) from exc
if info is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法解析该抖音链接",
)
video_path = ydl.prepare_filename(info)
duration = float(info.get("duration") or 0)
# ASR 转写
try:
text = transcribe_to_text(video_path)
except ASRNotConfiguredError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except ASRTranscriptionError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
except HTTPException:
raise
return ExtractFromDouyinResponse(
text=text,
duration_seconds=duration,
source_url=source_url,
)
# ── 2. AI 文案改写 ───────────────────────────────────────────────────────────
@router.post(
"/ai-rewrite",
response_model=AiRewriteResponse,
)
@points_gate("ai_rewrite")
def ai_rewrite(
request: AiRewriteRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> AiRewriteResponse:
"""使用豆包大模型改写文案."""
content = (request.content or "").strip()
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文案内容不能为空",
)
style = request.style or "口语化"
client = get_doubao_client()
if not client.is_available:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="AI 服务不可用,请联系管理员配置豆包大模型 API Key",
)
system_prompt = (
"你是一个专业的短视频文案改写专家。请对以下文案进行改写,"
"要求:保留原意、口语化、适合短视频口播、调整语序避免查重。"
)
if style:
system_prompt += f"\n风格要求:{style}"
user_prompt = f"请改写以下文案:\n\n{content}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
try:
rewritten = client.chat_completion(
messages=messages,
temperature=0.8,
max_tokens=2048,
)
except Exception as exc:
logger.error("AI 改写调用失败: %s", exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI 改写失败: {exc}",
) from exc
if not rewritten:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="AI 改写未返回有效结果",
)
return AiRewriteResponse(
original=content,
rewritten=rewritten.strip(),
style=style,
)
# ── 3. AI 标题生成 ───────────────────────────────────────────────────────────
@router.post(
"/ai-generate-titles",
response_model=AiGenerateTitlesResponse,
)
@points_gate("ai_title")
def ai_generate_titles(
request: AiGenerateTitlesRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> AiGenerateTitlesResponse:
"""使用现有 generate_smart_titles 生成标题."""
content = (request.content or "").strip()
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文案内容不能为空",
)
# count 限制在 1-5Pydantic ge=1 le=5 已校验),但为兼容直接调用场景截断
count = max(1, min(5, request.count))
from app.services.ai_service import generate_smart_titles
result = generate_smart_titles(
description=content,
style="viral",
count=count,
)
titles = result.get("titles", [])[:count]
return AiGenerateTitlesResponse(titles=titles)
+5 -6
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
import logging
from dataclasses import replace
from datetime import datetime, timezone
from typing import List
from datetime import UTC, datetime
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository
@@ -67,7 +66,7 @@ def _get_plan_price(plan_id: str, billing_cycle: str) -> float:
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
"""构建订阅信息响应"""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if user.user.subscription_expires_at:
period_end = user.user.subscription_expires_at.isoformat()
period_start = now.isoformat()
@@ -100,10 +99,10 @@ async def get_current_subscription(
return _build_subscription_info(current_user)
@router.get("/billing-records", response_model=List[BillingRecord])
@router.get("/billing-records", response_model=list[BillingRecord])
async def get_billing_records(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> List[BillingRecord]:
) -> list[BillingRecord]:
"""获取账单记录列表"""
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
from packages.adapters.sqlalchemy_impl.session import SessionLocal
@@ -251,7 +250,7 @@ async def payment_callback(
# 计算到期时间
days = 365 if billing_cycle == "yearly" else 30
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
expires_at = datetime.now(UTC) + timedelta(days=days)
repo.update_subscription_on_payment(user_id, plan, expires_at)
return {"success": True, "message": "支付成功", "record_id": record_id}
+39 -386
View File
@@ -1,4 +1,13 @@
"""Template CRUD + generate + category routes."""
"""Template 列表路由(供生成页自动选模板).
保留:
- GET /templates:列表查询(生成页使用)
- 默认模板自动创建兜底逻辑(复用 _default_template.get_or_create_default_template_id
其他模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等 HTTP 端点
已在 PR#1918 中删除(前端 PR#1911 已删除 my-templates / editing-planner /
templates 管理页面)。
"""
from __future__ import annotations
@@ -7,53 +16,19 @@ import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.template import (
CategoryResponse,
CopyTemplateRequest,
CreateCategoryRequest,
CreateTemplateRequest,
GenerateWarningResponse,
ListCategoriesResponse,
ListTagsResponse,
ListTemplatesResponse,
SegmentResponse,
TemplateResponse,
TemplateUsageResponse,
ToggleFavoriteResponse,
UpdateTemplateRequest,
ValidateTemplateRequest,
ValidateTemplateResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
from packages.application.template.commands import (
CopyTemplateCommand,
CreateCategoryCommand,
CreateTemplateCommand,
ListTemplatesFilter,
SegmentCommand,
UpdateTemplateCommand,
ValidateTemplateCommand,
)
from packages.application.template.use_cases import (
CopyTemplateUseCase,
CountTemplatesUseCase,
CreateCategoryUseCase,
CreateTemplateUseCase,
DeleteCategoryUseCase,
DeleteTemplateUseCase,
GetTemplateUseCase,
ListCategoriesUseCase,
ListTagsUseCase,
ListTemplatesUseCase,
NotFoundError,
UpdateTemplateUseCase,
ValidateTemplateUseCase,
ValidationError,
)
from packages.application.template.commands import ListTemplatesFilter
from packages.application.template.use_cases import CountTemplatesUseCase, ListTemplatesUseCase
from ._default_template import get_or_create_default_template_id
router = APIRouter()
@@ -62,354 +37,32 @@ def _get_template_repository(session: Session = Depends(get_db_session)) -> SQLA
return SQLAlchemyTemplateRepository(session)
def _segment_to_response(seg) -> SegmentResponse:
return SegmentResponse(
id=seg.id,
template_id=seg.template_id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
created_at=seg.created_at,
updated_at=seg.updated_at,
)
def _to_response(template, usage_count: int = 0) -> TemplateResponse:
return TemplateResponse(
id=template.id,
user_id=template.user_id,
name=template.name,
mode=template.mode,
category=template.category,
tags=template.tags,
title_config=template.title_config,
subtitle_config=template.subtitle_config,
bgm_config=template.bgm_config,
estimated_duration=template.estimated_duration,
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
is_active=template.is_active,
usage_count=usage_count,
created_at=template.created_at,
updated_at=template.updated_at,
)
# ── Template CRUD ──
@router.get("", response_model=ListTemplatesResponse)
@router.get("", response_model=ListTemplatesResponse, summary="获取模板列表")
def list_templates(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
mode: str | None = Query(None, description="编辑模式:generic/vlog/storyboard,不传返回全部"),
category: str | None = Query(None, description="按分类筛选"),
tag: str | None = Query(None, description="按标签筛选"),
keyword: str | None = Query(None, description="按名称关键词搜索"),
mode: str | None = Query(None, description="按剪辑模式筛选"),
valid_only: bool = Query(
False,
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
user_id = authenticated_user.user.id
try:
tpl_filter = ListTemplatesFilter(
category=category,
tag=tag,
keyword=keyword,
mode=mode,
valid_only=valid_only,
)
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
count_use_case = CountTemplatesUseCase(template_repository)
total = count_use_case.execute(user_id, filter=tpl_filter)
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页条数,默认 20"),
current_user: AuthenticatedUser = Depends(get_current_user),
repo: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
db: Session = Depends(get_db_session),
):
"""获取用户可用的模板列表(仅返回 active 状态)。"""
user_id = str(current_user.user.id)
# P0 兜底:无有效模板时自动创建默认配音模板(解决新用户首次进入生成页 404)
get_or_create_default_template_id(db, user_id)
# 批量查询使用次数
items = []
for t in templates:
usage = template_repository.get_usage_count(t.id)
items.append(_to_response(t, usage_count=usage))
except Exception:
logger.exception("list_templates 查询失败: user_id=%s", user_id)
return ListTemplatesResponse(items=[], total=0)
return ListTemplatesResponse(
items=items,
total=total,
list_uc = ListTemplatesUseCase(repo)
count_uc = CountTemplatesUseCase(repo)
filters = ListTemplatesFilter(
category=category,
tag=tag,
mode=mode,
valid_only=True, # 仅返回 active + 有片段配置
)
@router.get("/{template_id}", response_model=TemplateResponse)
def get_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
try:
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id)
except Exception as _e:
logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage)
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def create_template(
request: CreateTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
command = CreateTemplateCommand(
user_id=user_id,
name=request.name,
mode=request.mode,
category=request.category,
tags=request.tags,
title_config=request.title_config,
subtitle_config=request.subtitle_config,
bgm_config=request.bgm_config,
estimated_duration=request.estimated_duration,
segments=[
SegmentCommand(
segment_order=s.segment_order,
duration_min=s.duration_min,
duration_max=s.duration_max,
material_type=s.material_type,
)
for s in request.segments
],
)
use_case = CreateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template)
@router.patch("/{template_id}", response_model=TemplateResponse)
def update_template(
template_id: str,
request: UpdateTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
command = UpdateTemplateCommand(
template_id=template_id,
user_id=user_id,
name=request.name,
mode=request.mode,
category=request.category,
tags=request.tags,
title_config=request.title_config,
subtitle_config=request.subtitle_config,
bgm_config=request.bgm_config,
estimated_duration=request.estimated_duration,
segments=(
[
SegmentCommand(
segment_order=s.segment_order,
duration_min=s.duration_min,
duration_max=s.duration_max,
material_type=s.material_type,
)
for s in request.segments
]
if request.segments is not None
else None
),
)
use_case = UpdateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteTemplateUseCase(template_repository)
deleted = use_case.execute(template_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return
@router.post("/{template_id}/copy", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def copy_template(
template_id: str,
request: CopyTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
"""复制模板(含所有片段配置)"""
user_id = authenticated_user.user.id
command = CopyTemplateCommand(
template_id=template_id,
user_id=user_id,
new_name=request.new_name,
)
use_case = CopyTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template)
@router.get("/{template_id}/usage", response_model=TemplateUsageResponse)
def get_template_usage(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateUsageResponse:
"""获取模板使用次数(关联的剪辑计划数量)"""
user_id = authenticated_user.user.id
# 鉴权:确保模板存在且属于当前用户
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
usage = template_repository.get_usage_count(template_id)
return TemplateUsageResponse(template_id=template_id, usage_count=usage)
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
def toggle_favorite(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ToggleFavoriteResponse:
"""切换模板收藏状态(当前为兼容端点,始终返回 false)"""
user_id = authenticated_user.user.id
use_case = GetTemplateUseCase(template_repository)
try:
template = use_case.execute(template_id, user_id)
except Exception as _e:
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
# ── Validate template ──
@router.post("/{template_id}/validate", response_model=ValidateTemplateResponse)
def validate_template(
template_id: str,
request: ValidateTemplateRequest = ValidateTemplateRequest(),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ValidateTemplateResponse:
user_id = authenticated_user.user.id
command = ValidateTemplateCommand(
template_id=template_id,
user_id=user_id,
voiceover_duration=request.voiceover_duration,
)
use_case = ValidateTemplateUseCase(template_repository)
try:
result = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return ValidateTemplateResponse(
template=_to_response(result.template),
warnings=[GenerateWarningResponse(code=w.code, message=w.message, details=w.details) for w in result.warnings],
)
# ── Category CRUD ──
@router.get("/categories/list", response_model=ListCategoriesResponse)
def list_categories(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListCategoriesResponse:
user_id = authenticated_user.user.id
try:
use_case = ListCategoriesUseCase(template_repository)
categories = use_case.execute(user_id)
except Exception:
logger.exception("list_categories 查询失败: user_id=%s", user_id)
return ListCategoriesResponse(items=[])
return ListCategoriesResponse(
items=[CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) for c in categories],
)
@router.post("/categories", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
def create_category(
request: CreateCategoryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> CategoryResponse:
user_id = authenticated_user.user.id
command = CreateCategoryCommand(user_id=user_id, name=request.name)
use_case = CreateCategoryUseCase(template_repository)
category = use_case.execute(command)
return CategoryResponse(
id=category.id,
user_id=category.user_id,
name=category.name,
created_at=category.created_at,
)
@router.delete(
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_category(
category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteCategoryUseCase(template_repository)
deleted = use_case.execute(category_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
return Response(status_code=204)
# ── Tags ──
@router.get("/tags/list", response_model=ListTagsResponse)
def list_tags(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTagsResponse:
"""获取用户所有模板标签(去重排序)"""
user_id = authenticated_user.user.id
try:
use_case = ListTagsUseCase(template_repository)
tags = use_case.execute(user_id)
except Exception:
logger.exception("list_tags 查询失败: user_id=%s", user_id)
return ListTagsResponse(items=[])
return ListTagsResponse(items=tags)
skip = (page - 1) * page_size
templates = list_uc.execute(user_id, skip=skip, limit=page_size, filter=filters)
total = count_uc.execute(user_id, filter=filters)
items = [TemplateResponse.model_validate(tpl, from_attributes=True) for tpl in templates]
return ListTemplatesResponse(items=items, total=total)
@@ -28,7 +28,7 @@ from .adjustments import router as adjustments_router
from .ai_features import router as ai_features_router
from .bgm import router as bgm_router
from .clips import router as clips_router
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
from .dependencies import get_draft_plan_id, get_editor_services, resolve_draft_plan_id # noqa: F401
from .draft import router as draft_router
from .effects import router as effects_router
from .export import router as export_router
@@ -622,8 +622,10 @@ def create_clips_from_assets_editor(
"""从素材批量创建片段(按模板segment配置创建,MediaKit异步更新).
逻辑:
1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
1. 从模板读取 segments,片段数量优先级:显式 clip_count1-10)→ 旧字段
required_clips_count(兼容,超10截断)→ 默认 3(产品默认 3 段)。
片段数大于模板 segment 数时按顺序循环复用 segment 配置。
2. 每个片段时长在对应 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的(随机起始时间)
5. 立即返回响应(目标 <1秒)
@@ -648,6 +650,22 @@ def create_clips_from_assets_editor(
detail="模板未配置片段",
)
# 1.5 归一化片段数量:
# 优先级:显式 clip_count → 旧字段 required_clips_count(由 schema 归一化到 clip_count
# → 默认 3(产品默认 3 段)。按 N 循环复用 segment 配置;N <= len(segments) 时截取前 N 个
# (保持向后兼容:原模板有 N 个 segment、前端不传 clip_count 且 N<=10 时按模板段数创建;
# 默认模板仅有 1 个通用 segment 时按 clip_count=3 循环生成 3 段)。
requested_clip_count = getattr(body, "clip_count", None)
if requested_clip_count is None:
# schema 未显式传 clip_count 且无 legacy:使用模板 segments 数量,若超出 10 则截断
requested_clip_count = len(segments) if 1 <= len(segments) <= 10 else 3
requested_clip_count = max(1, min(int(requested_clip_count), 10))
effective_segments: list[tuple[int, float, float]] = []
for i in range(requested_clip_count):
src = segments[i % len(segments)]
effective_segments.append((i, float(src[1]), float(src[2])))
segments = effective_segments
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
# 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422
asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()]
@@ -2,13 +2,16 @@
核心依赖:
- get_editor_services: 获取模板+计划服务
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
- get_draft_plan_id: Depends 形式的路径依赖(template_id 路径参数必填)
- resolve_draft_plan_id: 纯函数版本,供 clips_standalone 等非路径参数场景复用
(支持空 tid 时自动兜底创建默认模板)
"""
from __future__ import annotations
import logging
from app.api.routes._default_template import get_or_create_default_template_id
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService
@@ -30,50 +33,61 @@ def get_editor_services(
return EditTemplateService(db), EditPlanService(db)
def get_draft_plan_id(
def resolve_draft_plan_id(
template_id: str,
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
services: tuple[EditTemplateService, EditPlanService],
current_user: AuthenticatedUser,
db: Session,
auto_create_default: bool = True,
) -> str:
"""路径依赖:根据 template_id 获取或创建草稿,返回 plan_id.
"""根据 template_id 获取或创建草稿,返回 plan_id(纯函数,不带 Depends)。
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
确保 template_id → plan_id 的映射始终存在。
模板读取遵循单一数据源、显式判定(不使用异常降级):
- 用户自建模板在旧表 ``templates``(归属 user_idis_active=True);
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
模板不存在、已删除或不归属于当前用户时,一律返回 404。
当 auto_create_default=True 且 template_id 为空时,自动调用
get_or_create_default_template_id 创建默认模板(用于 clips_standalone
等非路径参数场景)。
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
# 0. 空 tid 兜底
if not template_id:
if auto_create_default:
tid = get_or_create_default_template_id(db, user_id)
if not tid:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="无法自动创建默认模板,请刷新页面重试",
)
template_id = tid
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="template_id 不能为空",
)
# 1. 门禁:校验模板存在且可访问
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get_active(template_id, user_id)
is_global_template = tpl_svc.get_template(template_id) is not None
if old_template is None and not is_global_template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 1. 草稿已存在 → 直接返回
# 2. 草稿已存在 → 直接返回
draft = tpl_svc.get_template_draft(template_id)
if draft is not None:
return draft.id
# 2. 全局模板(新系统)→ 用新服务创建草稿
# 3. 全局模板(新系统)→ 用新服务创建草稿
if is_global_template:
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
return draft.id
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
# 4. 旧模板(templates 表)→ 基于旧模板创建草稿计划
from app.services.plan_generator_service import PlanGeneratorService
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
# 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段)
pseudo_template = EditTemplate(
id=old_template.id,
name=old_template.name,
@@ -81,7 +95,6 @@ def get_draft_plan_id(
status=EditTemplateStatus.ACTIVE,
)
# 将旧模板 segments 转换为 clip_configs
clip_configs: list[TemplateClipConfig] = []
for seg in old_template.segments or []:
clip_configs.append(
@@ -105,7 +118,6 @@ def get_draft_plan_id(
)
plan = result["plan"]
# 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑)
plan_svc.update_plan_config(plan.id, {"is_template_draft": True})
logger.info(
@@ -115,3 +127,23 @@ def get_draft_plan_id(
user_id,
)
return plan.id
def get_draft_plan_id(
template_id: str,
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> str:
"""路径依赖:根据 template_id 获取或创建草稿,返回 plan_id.
Depends 版本:路径参数 template_id 由 FastAPI 保证非空,不自动兜底。
兜底逻辑走 resolve_draft_plan_id(auto_create_default=False)。
"""
return resolve_draft_plan_id(
template_id=template_id,
services=services,
current_user=current_user,
db=db,
auto_create_default=False,
)
@@ -6,9 +6,9 @@
from __future__ import annotations
import re as _re
from typing import Any, List, Optional
from typing import Any, Optional
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, model_validator, validator
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
_EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
@@ -21,7 +21,7 @@ _EXPORT_VALID_FORMATS = {"mp4", "mov"}
class AIRecommendRequest(BaseModel):
"""AI 推荐片段方案请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
asset_ids: list[str] = Field(default_factory=list, description="素材 ID 列表")
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
@@ -44,7 +44,7 @@ class AIRecommendResponse(BaseModel):
"""AI 推荐片段方案响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
clips: list[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
config: dict[str, Any] = Field(..., description="推荐的 plan configcover/title/subtitle/bgm")
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
@@ -137,7 +137,7 @@ class ClipReorderItem(BaseModel):
class ClipReorderRequest(BaseModel):
"""片段重排序请求"""
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
items: list[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
class ClipReorderResponse(BaseModel):
@@ -151,7 +151,7 @@ class ClipReorderResponse(BaseModel):
class ClipBatchDeleteRequest(BaseModel):
"""批量删除片段请求"""
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
clip_ids: list[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
class ClipBatchDeleteResponse(BaseModel):
@@ -162,13 +162,26 @@ class ClipBatchDeleteResponse(BaseModel):
message: str = ""
# sentinel:区分「前端未传 clip_count」和「显式传 0/None」
_UNSET = object()
class ClipsFromAssetsRequest(BaseModel):
"""从素材批量创建片段请求"""
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
asset_ids: list[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
clip_type: str = Field(default="main", description="片段类型,默认 main")
clip_count: Optional[int] = Field(
default=None,
ge=1,
le=10,
description="片段数量(1-10);不传时使用旧字段 required_clips_count;两者都不传时回退为模板 segments 数量(默认 3 段)。",
)
required_clips_count: Optional[int] = Field(
default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量"
default=None,
ge=1,
le=200,
description="[已废弃] 旧字段,请使用 clip_count;仅作向后兼容——clip_count 未显式传入时才回退本字段(超10截断到10)。",
)
@validator("asset_ids", pre=True)
@@ -180,6 +193,23 @@ class ClipsFromAssetsRequest(BaseModel):
return v
return [x for x in v if isinstance(x, str) and x.strip()]
@model_validator(mode="before")
@classmethod
def _backfill_clip_count(cls, data: Any) -> Any:
"""兼容旧字段 required_clips_count:仅当新字段 clip_count 未显式传入时才回退旧字段;
两者都没传时保持 clip_count=None,路由层按模板 segments 数量兜底。旧字段超 10 截断到 10。"""
if not isinstance(data, dict):
return data
has_new = "clip_count" in data and data["clip_count"] is not None
if not has_new:
legacy = data.get("required_clips_count")
if legacy is not None:
try:
data["clip_count"] = max(1, min(int(legacy), 10))
except (TypeError, ValueError):
pass
return data
class ClipsFromAssetsResponse(BaseModel):
"""从素材批量创建片段响应"""
@@ -188,7 +218,7 @@ class ClipsFromAssetsResponse(BaseModel):
created_count: int
plan_id: str = ""
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
clip_ids: list[str] = Field(default_factory=list, description="创建的片段ID列表")
duplicate_warning: Optional[str] = Field(default=None, description="查重率超标警告")
exhaustion_warning: Optional[str] = Field(default=None, description="素材耗尽警告")
@@ -272,7 +302,7 @@ class ExportPresetItem(BaseModel):
class ExportPresetListResponse(BaseModel):
"""导出预设列表响应"""
items: List[ExportPresetItem]
items: list[ExportPresetItem]
total: int
@@ -286,7 +316,7 @@ class FilterPresetResponse(BaseModel):
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
class FilterConfigResponse(BaseModel):
@@ -316,7 +346,7 @@ class FilterUpdateRequest(BaseModel):
class FilterPresetListResponse(BaseModel):
"""滤镜预设列表响应"""
items: List[FilterPresetResponse]
items: list[FilterPresetResponse]
total: int
@@ -330,7 +360,7 @@ class TransitionPresetResponse(BaseModel):
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
default_duration: float
min_duration: float
max_duration: float
@@ -372,7 +402,7 @@ class BatchTransitionResponse(BaseModel):
class TransitionPresetListResponse(BaseModel):
"""转场预设列表响应"""
items: List[TransitionPresetResponse]
items: list[TransitionPresetResponse]
total: int
@@ -428,7 +458,7 @@ class EditorClipResponse(BaseModel):
class EditorClipListResponse(BaseModel):
"""片段列表响应"""
items: List[EditorClipResponse]
items: list[EditorClipResponse]
total: int
@@ -466,7 +496,7 @@ class EditorClipBatchItem(BaseModel):
class EditorClipBatchUpdateRequest(BaseModel):
"""批量替换clips请求(全量覆盖)"""
clips: List[EditorClipBatchItem] = Field(default_factory=list)
clips: list[EditorClipBatchItem] = Field(default_factory=list)
class EditorClipBatchUpdateResponse(BaseModel):
@@ -554,4 +584,4 @@ class EditorTimelineResponse(BaseModel):
plan_id: str
total_duration: float
scenes: List[EditorTimelineSceneResponse]
scenes: list[EditorTimelineSceneResponse]
Executable → Regular
+93 -13
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import json
import logging
import math
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.core.celery_app import celery_app
from app.core.storage import get_storage_service
from app.dependencies import (
@@ -51,6 +53,8 @@ from packages.application.tts_job.use_cases import (
)
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
from packages.domain.voice_presets import list_voices
from packages.ports.asset_library_repository import AssetLibraryRepository
from packages.ports.asset_repository import AssetRepository
@@ -128,6 +132,7 @@ def _to_response(job, sign_url=None) -> TTSJobResponse:
def synthesize(
request: TTSSynthesizeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
@@ -139,6 +144,31 @@ def synthesize(
"""
user_id = authenticated_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_voice"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
# 中文按 ~240 字/分钟粗估时长,至少按 1 分钟扣 1 分
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(authenticated_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(authenticated_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
actual_voice_id = request.voice_id
@@ -177,6 +207,7 @@ def synthesize(
synthesis_meta = {
"speed": request.speed,
"emotion": request.emotion or "",
"language": request.language or "zh-CN",
}
if request.metadata_:
synthesis_meta.update(request.metadata_)
@@ -197,6 +228,7 @@ def synthesize(
cosyvoice_service=cosyvoice_service,
)
synthesis_error: Exception | None = None
try:
job = workflow.start_synthesis(job.id)
except Exception as e:
@@ -204,10 +236,17 @@ def synthesize(
# 但 DB 异常、网络异常等意外错误可能逃逸。
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
synthesis_error = e
try:
job = workflow.process_synthesis_failure(job.id, str(e))
except Exception as inner_e:
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
# 合成失败且已扣积分 → 退费
if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}")
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
if job.status.value == "processing":
@@ -222,10 +261,17 @@ def synthesize(
celery_app.send_task("worker.process_tts_synthesis", args=[job.id])
except Exception as e:
# Celery 调度失败,标记 job 为 failed
# e used below for refund context
try:
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
# 调度失败退费
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
except Exception as refund_err:
logger.warning(f"Celery 调度失败退积分异常: job_id={job.id}, err={refund_err}")
return TTSSynthesizeResponse(
job_id=job.id,
@@ -462,10 +508,17 @@ def save_tts_job_to_library(
try:
proc = subprocess.run(
[
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", str(tmp_path),
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
str(tmp_path),
],
capture_output=True, text=True, timeout=10,
capture_output=True,
text=True,
timeout=10,
)
if proc.returncode == 0:
fmt = json.loads(proc.stdout).get("format", {})
@@ -545,6 +598,7 @@ def save_tts_job_to_library(
def preview_tts(
request: TTSPreviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> TTSPreviewResponse:
@@ -553,6 +607,31 @@ def preview_tts(
用于前端预览配音效果,限制文本长度 200 字以内。
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
"""
user_id = authenticated_user.user.id
# ── 积分扣点(#1895 P2) ──
_points_deducted = 0
_points_scene = "ai_voice"
_points_svc = PointsService() if settings.points_enabled else None
if _points_svc is not None:
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(authenticated_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(authenticated_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
actual_voice_id = request.voice_id
profile = voice_clone_repo.get(request.voice_id)
@@ -576,17 +655,18 @@ def preview_tts(
voice_id=actual_voice_id,
speed=request.speed,
emotion=request.emotion,
language=getattr(request, "language", "zh-CN"),
)
except CosyVoiceError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"TTS 合成失败: {e}",
) from e
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except (CosyVoiceError, ValueError) as e:
# 合成失败退费
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"TTS 预览失败退积分异常: {refund_err}")
if isinstance(e, CosyVoiceError):
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS 合成失败: {e}") from e
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
return TTSPreviewResponse(
audio_url=result.audio_url,
+104 -8
View File
@@ -3,14 +3,17 @@
from __future__ import annotations
import logging
import math
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.core.celery_app import celery_app
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_repository,
get_cosyvoice_service,
get_db_session,
get_project_repository,
get_voice_clone_profile_repository,
)
@@ -22,6 +25,7 @@ from app.schemas.voice_clone import (
VoiceCloneStatusResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
SQLAlchemyVoiceCloneProfileRepository,
@@ -38,6 +42,11 @@ from packages.application.voice_clone.use_cases import (
from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowService,
)
from packages.domain.points_rules import calculate_points_cost
from packages.domain.points_service import PointsService
# remove duplicate
_DUMMY_DELETED = ()
from packages.ports.asset_repository import AssetRepository
from packages.ports.project_repository import ProjectRepository
from packages.shared.storage import SharedStorageService
@@ -287,22 +296,101 @@ def retry_voice_clone(
return _to_response(profile)
_ALLOWED_PREVIEW_EMOTIONS = {
"",
# 7 种标准英文枚举(CosyVoice v3 官方值)
"neutral",
"happy",
"sad",
"angry",
"surprised",
"fearful",
"disgusted",
# 前端中文 7 标签
"中立",
"开心",
"难过",
"生气",
"惊讶",
"恐惧",
"厌恶",
# 旧英文 4 枚举 + 常见中文别名兼容
"natural",
"excited",
"calm",
"friendly",
"自然",
"愉快",
"高兴",
"快乐",
"兴奋",
"悲伤",
"愤怒",
"惊奇",
"吃惊",
"害怕",
"讨厌",
# 灵应 P1 指定别名
"中性",
"伤心",
"沉稳",
"亲切",
}
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
def get_voice_clone_preview(
clone_id: str,
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"),
emotion: str = Query(
"",
description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted,兼容旧值 natural/excited/calm/friendly,空为默认自然",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> VoiceClonePreviewResponse:
"""获取克隆音色试听音频(实时 TTS 合成)。
- 克隆音色必须处于 ready 状态
- 使用默认试听文本时,结果缓存 7 天
- 可传入自定义 text 参数试听不同文本
- 使用默认试听文本时,结果缓存 7 天(仅默认 text+speed=1.0+emotion=空 组合缓存)
- 可传入自定义 text/speed/emotion 试听不同效果
"""
import time
user_id = authenticated_user.user.id
_points_deducted = 0
_points_scene = "voice_clone_synth"
_points_svc = PointsService() if settings.points_enabled else None
_preview_text_for_points = text.strip() or CLONE_PREVIEW_TEMPLATE
if _points_svc is not None:
est_minutes = max(1.0, math.ceil(len(_preview_text_for_points) / 240))
_points_deducted = calculate_points_cost(
_points_scene,
is_member=getattr(authenticated_user.user, "is_member", False),
duration_minutes=est_minutes,
member_type=getattr(authenticated_user.user, "member_type", None),
)
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
if not _deduct_res["success"]:
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
"required": _points_deducted,
"balance": _deduct_res["balance"],
},
)
if emotion not in _ALLOWED_PREVIEW_EMOTIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或中文 中立/中性/开心/难过/伤心/生气/愤怒/惊讶/吃惊/恐惧/害怕/厌恶/讨厌 或留空",
)
use_case = GetVoiceCloneUseCase(repository)
try:
profile = use_case.execute(clone_id, authenticated_user.user.id)
@@ -315,8 +403,8 @@ def get_voice_clone_preview(
detail=f"Voice clone is not ready (current status: {profile.status})",
)
# 有自定义文本时不缓存
use_cache = not text.strip()
# 仅默认试听文本 + 默认 speed + 默认 emotion 时使用缓存
use_cache = (not text.strip()) and abs(speed - 1.0) < 1e-6 and (not emotion)
if use_cache and clone_id in _clone_preview_cache:
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
@@ -337,12 +425,20 @@ def get_voice_clone_preview(
text=preview_text,
voice_id=profile.voice_id,
format="mp3",
speed=1.0,
speed=speed,
emotion=emotion,
)
except CosyVoiceError as e:
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
except (CosyVoiceError, ValueError) as e:
if _points_deducted > 0 and _points_svc is not None:
try:
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
except Exception as refund_err:
logger.warning(f"克隆音色试听失败退积分异常: clone_id={clone_id}, err={refund_err}")
if isinstance(e, CosyVoiceError):
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 缓存(仅默认试听文本
# 缓存(仅默认参数组合
if use_cache:
_clone_preview_cache[clone_id] = (
result.audio_url,
+1 -1
View File
@@ -6,7 +6,7 @@ ensuring proper lifecycle management and testability.
from __future__ import annotations
from typing import Generator
from collections.abc import Generator
import redis
from app.config import settings
+1 -1
View File
@@ -4,7 +4,7 @@
import logging
import time
from typing import Callable
from collections.abc import Callable
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
@@ -10,7 +10,7 @@ Exposes:
import re
import time
from typing import Callable
from collections.abc import Callable
from fastapi import Request, Response
from prometheus_client import (
+12 -10
View File
@@ -52,7 +52,9 @@ class CreateAiAvatarRenderRequest(BaseModel):
lipsync_job_id: str = Field(..., description="对口型任务 ID")
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_config: dict[str, Any] = Field(
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL"
)
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
project_id: str = Field("", description="项目 ID")
@@ -67,7 +69,6 @@ class CreateAiAvatarRenderRequest(BaseModel):
@field_validator("script_id")
@classmethod
def validate_script_id(cls, v: str) -> str:
# script_id 可选:手动输入文案(TTS 直生)场景不关联文案库条目
return (v or "").strip()
@@ -109,16 +110,17 @@ class AiAvatarRenderProgressResponse(BaseModel):
error_message: str
class SmartCoverRequest(BaseModel):
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧."""
video_url: str = Field(..., description="数字人视频 URL(对口型/渲染成片)")
max_frames: int = Field(5, ge=1, le=10, description="抽帧数量(默认 5")
class SmartCoverResponse(BaseModel):
"""智能封面响应."""
"""智能封面响应(封面从最终成片抽帧,不再叠加标题)."""
cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空")
status: str = Field("completed", description="completed / fallback_failed")
message: str = Field("", description="失败原因(如有)")
class FinalizeRenderResponse(BaseModel):
"""封面选好后点「完成」,正式入库成片库的响应."""
video_id: str = Field(..., description="成片库视频ID")
cover_url: str = Field("", description="封面URL")
status: str = Field("success", description="success/already_finalized")
+50 -12
View File
@@ -1,9 +1,12 @@
"""对口型 API Schema 定义 — #1796 / #1809 / #1822.
"""对口型 API Schema 定义 — #1796 / #1809 / #1822 / #1845(配音前置).
支持种输入模式(二选一)
1. TTS 直生模式(推荐):传 voice_id + script_text+ speed/emotion),
后端内部先调 CosyVoice 合成音频,再提交 MediaKit 对口型
支持种输入模式:
1. TTS 直生模式(兼容旧版前端):传 voice_id + script_text+ speed/emotion),
后端 Celery 异步做 TTS 合成 + MediaKit 提交
2. 直接音频模式:传 video_url + audio_url(音频已由调用方准备好)。
3. 预合成音频模式(#1845 配音前置新主路径):前端先调 POST /lipsync/tts-preview
拿到 audio_url + sentence_timings,再在 create_job 时传 audio_url + audio_duration
+ sentence_timings,后端跳过 TTS 和时间戳计算,直接 ffprobe 校验后提交 MediaKit。
"""
from __future__ import annotations
@@ -33,6 +36,7 @@ class LipsyncJobResponse(BaseModel):
output_duration: float
error_message: str
error_code: str
sentence_timings: Optional[list] = None
submitted_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
@@ -45,23 +49,32 @@ class LipsyncJobResponse(BaseModel):
class CreateLipsyncJobRequest(BaseModel):
"""创建对口型任务请求.
种模式(选一):
- TTS 直生:voice_id + script_text 必填+ 可选 speed/emotionaudio_url 留空。
种模式(选一):
- TTS 直生(旧版/降级)voice_id + script_text 必填;audio_url 留空。
- 直接音频:video_url + audio_url 必填。
- 预合成音频(#1845 新主路径):audio_url 必填 + 可选 audio_duration/sentence_timings
后端同步 ffprobe 校验时长、写入 timings,直接提交 MediaKit。
"""
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
# 模式 2:直接音频
# 模式 2/3:直接/预合成音频
audio_url: str = Field("", description="驱动音频 URLmp3/aac/wav/m4a/flac);直生模式留空")
audio_duration: Optional[float] = Field(None, ge=0, description="预合成音频时长(秒),可选;后端会 ffprobe 校验")
sentence_timings: Optional[list] = Field(None, description="预合成接口返回的句子时间戳,可选;若传入则直接写入 job")
# 模式 1TTS 直生
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")
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
emotion: str = Field(
"",
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
)
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
enable_video_loop: bool = Field(
True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)"
)
project_id: str = Field("", description="项目 ID(可选)")
@model_validator(mode="after")
@@ -72,15 +85,16 @@ class CreateLipsyncJobRequest(BaseModel):
if not video.startswith(("http://", "https://")):
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
lower = video.lower().split("?")[0]
if not lower.endswith(".mp4"):
raise ValueError("video_url 仅支持 MP4 格式")
allowed_video_exts = (".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv", ".3gp")
if not any(lower.endswith(ext) for ext in allowed_video_exts):
raise ValueError("video_url 格式不支持,仅支持: " + ", ".join(allowed_video_exts))
has_audio = bool((self.audio_url or "").strip())
has_tts = bool((self.voice_id or "").strip()) and bool((self.script_text or "").strip())
if not has_audio and not has_tts:
raise ValueError(
"必须提供驱动音频:要么传 audio_url(直接音频模式),"
"必须提供驱动音频:要么传 audio_url(直接/预合成音频模式),"
"要么同时传 voice_id + script_textTTS 直生模式)"
)
@@ -98,3 +112,27 @@ class CreateLipsyncJobRequest(BaseModel):
self.audio_url = au
return self
# ── #1845 TTS 预合成接口 ────────────────────────────────────────────────
class AiAvatarTtsPreviewRequest(BaseModel):
"""步骤1「生成配音」预合成请求(同步 HTTP,~2-3s)."""
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")
emotion: str = Field(
"neutral",
max_length=32,
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral)",
)
class AiAvatarTtsPreviewResponse(BaseModel):
"""TTS 预合成响应(临时 URL,24h 内有效,足够当前会话使用)."""
audio_url: str = Field(..., description="CosyVoice 临时音频 URL")
duration: float = Field(..., ge=0, description="音频总时长(秒),ffprobe 测得")
sentence_timings: list[dict] = Field(..., description="句子级精确时间戳")
+182
View File
@@ -0,0 +1,182 @@
"""积分 & 会员相关 Pydantic Schema (#1895)"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
# ============ 余额 & 账户 ============
class PointsBalanceResponse(BaseModel):
"""积分余额 + 会员状态"""
balance: int = Field(..., description="当前积分余额")
total_earned: int = Field(..., description="累计获得积分")
total_spent: int = Field(..., description="累计消耗积分")
is_member: bool = Field(default=False, description="是否付费会员")
member_type: Optional[str] = Field(None, description="会员类型: monthly/quarterly/yearly")
member_expires_at: Optional[datetime] = Field(None, description="会员到期时间")
# ============ 流水 ============
class PointsTransactionItem(BaseModel):
"""单条积分流水"""
id: str
type: str = Field(..., description="类型: add/deduct")
source: str = Field(..., description="来源场景")
amount: int
balance_after: int
description: str = ""
ref_id: str = ""
created_at: Optional[str] = None
class PointsTransactionsResponse(BaseModel):
"""积分流水分页响应"""
items: list[PointsTransactionItem]
total: int
page: int
page_size: int
# ============ 规则 & 积分包 ============
class PointRuleItem(BaseModel):
"""单条积分规则"""
scene_key: str
name: str
base_points: int
unit: str
extra_per_30s: Optional[int] = None
class PointsRulesResponse(BaseModel):
"""所有积分消耗规则"""
rules: list[PointRuleItem]
free_user_multiplier: float = Field(..., description="免费用户积分上浮系数")
class PointsPackageItem(BaseModel):
"""积分包信息"""
code: str
name: str
points: int
price_cents: int
unit_price: str = Field("", description="单价描述,如 ¥0.099/积分")
class PointsPackagesResponse(BaseModel):
"""可购买的积分包列表"""
packages: list[PointsPackageItem]
user_discount: Optional[float] = Field(None, description="当前用户折扣(会员)")
# ============ 消费前检查 ============
class PointsCheckRequest(BaseModel):
"""消费前余额检查请求"""
scene_key: str
duration_minutes: Optional[float] = None
quantity: Optional[int] = 1
class PointsCheckResponse(BaseModel):
"""消费前余额检查响应"""
allowed: bool
required_points: int
current_balance: int
remaining_after: int
is_free_quota: bool = False
# ============ 手动扣减 / 退还(内部接口) ============
class PointsDeductRequest(BaseModel):
"""积分扣减请求"""
scene_key: str
amount: int
description: Optional[str] = ""
ref_id: Optional[str] = ""
class PointsRefundRequest(BaseModel):
"""积分退还请求"""
transaction_id: str
reason: Optional[str] = ""
class PointsRechargeRequest(BaseModel):
"""积分充值请求"""
package_id: str = Field(..., description="积分包 code,如 starter_pack")
# ============ 订单 ============
class PointsOrderResponse(BaseModel):
"""订单信息"""
id: str
order_type: str
product_code: str
amount_cents: int
status: str
created_at: Optional[str] = None
# ============ 每日额度 ============
class DailyUsageResponse(BaseModel):
"""今日免费额度使用情况"""
free_clips_used: int
free_clips_limit: int
free_clips_remaining: int
reset_at: str
# ============ 会员状态(聚合) ============
class MembershipStatusResponse(BaseModel):
"""当前用户会员状态(聚合信息)"""
is_member: bool
member_type: Optional[str] = None
member_expires_at: Optional[datetime] = None
points_balance: int
max_resolution: str = Field(
default="1080p",
description="可用最高分辨率: 720p(free) / 1080p(paid)",
)
# ============ 通用响应 ============
class SimpleMessageResponse(BaseModel):
"""简单消息响应"""
success: bool
message: str
data: Optional[dict[str, Any]] = None
+7 -7
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from typing import Optional
from pydantic import BaseModel, Field
@@ -20,8 +20,8 @@ class ScriptResponse(BaseModel):
user_id: str
title: str
content: str
segments: List[ScriptSegment] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
segments: list[ScriptSegment] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@@ -34,12 +34,12 @@ class ScriptListResponse(BaseModel):
class CreateScriptRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
content: str = ""
segments: List[ScriptSegment] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
segments: list[ScriptSegment] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
class UpdateScriptRequest(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
content: Optional[str] = None
segments: Optional[List[ScriptSegment]] = None
tags: Optional[List[str]] = None
segments: Optional[list[ScriptSegment]] = None
tags: Optional[list[str]] = None
+60
View File
@@ -0,0 +1,60 @@
"""Scripts AI 能力 Pydantic schemas — Issue #1893.
抖音文案提取、AI 改写、AI 标题生成的请求/响应模型。
"""
from __future__ import annotations
from typing import List, Optional
from pydantic import BaseModel, Field
# ── 抖音文案提取 ─────────────────────────────────────────────────────────────
class ExtractFromDouyinRequest(BaseModel):
"""从抖音视频提取文案请求."""
url: str = Field(..., description="抖音视频链接(短链或长链)")
class ExtractFromDouyinResponse(BaseModel):
"""从抖音视频提取文案响应."""
text: str = Field(..., description="ASR 识别出的文案文本")
duration_seconds: float = Field(..., description="视频时长(秒)")
source_url: str = Field(..., description="原始视频链接")
# ── AI 改写 ─────────────────────────────────────────────────────────────────
class AiRewriteRequest(BaseModel):
"""AI 文案改写请求."""
content: str = Field(..., description="原文内容")
style: Optional[str] = Field("口语化", description="改写风格,如 口语化/正式/活泼")
class AiRewriteResponse(BaseModel):
"""AI 文案改写响应."""
original: str = Field(..., description="原文")
rewritten: str = Field(..., description="改写后的文案")
style: str = Field(..., description="使用的改写风格")
# ── AI 标题生成 ──────────────────────────────────────────────────────────────
class AiGenerateTitlesRequest(BaseModel):
"""AI 标题生成请求."""
content: str = Field(..., description="文案内容")
count: int = Field(3, ge=1, le=5, description="生成标题数量(1-5,默认3")
class AiGenerateTitlesResponse(BaseModel):
"""AI 标题生成响应."""
titles: List[str] = Field(..., description="生成的标题列表")
+22 -84
View File
@@ -1,9 +1,14 @@
"""Template API schemas."""
"""Template API schemas(精简版:仅保留列表接口 + 默认模板自动兜底所需字段).
前端 PR#1911 删除 my-templates / editing-planner / templates 管理页后,
模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等端点全部下线,
对应 Request/Response 模型也一并清理。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Optional
from pydantic import BaseModel, Field
@@ -37,12 +42,12 @@ class TemplateResponse(BaseModel):
name: str
mode: str
category: str = ""
tags: List[str] = Field(default_factory=list)
title_config: Dict[str, Any] = Field(default_factory=dict)
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
bgm_config: Dict[str, Any] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
title_config: dict[str, Any] = Field(default_factory=dict)
subtitle_config: dict[str, Any] = Field(default_factory=dict)
bgm_config: dict[str, Any] = Field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[SegmentResponse] = Field(default_factory=list)
segments: list[SegmentResponse] = Field(default_factory=list)
is_active: bool = True
is_favorite: bool = False
usage_count: int = 0
@@ -50,96 +55,29 @@ class TemplateResponse(BaseModel):
updated_at: datetime
class ToggleFavoriteResponse(BaseModel):
id: str
is_favorite: bool
class ListTemplatesResponse(BaseModel):
items: List[TemplateResponse]
items: list[TemplateResponse]
total: int = 0
# ── Template Request ──
# ── Template Request(保留给内部 _get_or_create_default_template_id 兜底创建默认模板使用)──
class CreateTemplateRequest(BaseModel):
name: str
mode: str
category: str = ""
tags: List[str] = Field(default_factory=list)
title_config: Dict[str, Any] = Field(default_factory=dict)
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
bgm_config: Dict[str, Any] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
title_config: dict[str, Any] = Field(default_factory=dict)
subtitle_config: dict[str, Any] = Field(default_factory=dict)
bgm_config: dict[str, Any] = Field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[SegmentRequest] = Field(default_factory=list)
class UpdateTemplateRequest(BaseModel):
name: Optional[str] = None
mode: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
title_config: Optional[Dict[str, Any]] = None
subtitle_config: Optional[Dict[str, Any]] = None
bgm_config: Optional[Dict[str, Any]] = None
estimated_duration: Optional[float] = None
segments: Optional[List[SegmentRequest]] = None
# ── Validate ──
class ValidateTemplateRequest(BaseModel):
voiceover_duration: Optional[float] = None # 配音实际时长(秒)
segments: list[SegmentRequest] = Field(default_factory=list)
class GenerateWarningResponse(BaseModel):
"""兼容老 import(如校验逻辑内部复用);模板管理页已下线,可按需进一步清理。"""
code: str
message: str
details: Dict[str, Any] = Field(default_factory=dict)
class ValidateTemplateResponse(BaseModel):
template: TemplateResponse
warnings: List[GenerateWarningResponse] = Field(default_factory=list)
# ── Category ──
class CategoryResponse(BaseModel):
id: str
user_id: str
name: str
created_at: datetime
class CreateCategoryRequest(BaseModel):
name: str
class ListCategoriesResponse(BaseModel):
items: List[CategoryResponse]
# ── Copy Template ──
class CopyTemplateRequest(BaseModel):
new_name: str
# ── Tags ──
class ListTagsResponse(BaseModel):
items: List[str]
# ── Usage Stats ──
class TemplateUsageResponse(BaseModel):
template_id: str
usage_count: int
details: dict[str, Any] = Field(default_factory=dict)
+4 -4
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from typing import Optional
from pydantic import BaseModel, Field
@@ -15,7 +15,7 @@ class TitleLibraryItemResponse(BaseModel):
text: str
category: str = "default"
description: str = ""
tags: List[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
usage_count: int = 0
is_active: bool = True
created_at: datetime
@@ -32,7 +32,7 @@ class CreateTitleLibraryRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=500)
category: str = "default"
description: str = ""
tags: List[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
class UpdateTitleLibraryRequest(BaseModel):
@@ -40,4 +40,4 @@ class UpdateTitleLibraryRequest(BaseModel):
text: Optional[str] = Field(None, min_length=1, max_length=500)
category: Optional[str] = None
description: Optional[str] = None
tags: Optional[List[str]] = None
tags: Optional[list[str]] = None
+10 -6
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Optional
from pydantic import BaseModel, Field
@@ -16,11 +16,14 @@ 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="语速")
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文 自然/兴奋/沉稳/亲切)")
emotion: str = Field(
"",
description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)",
)
voice_model: str = Field("", description="语音模型名称")
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
format: str = Field("mp3", description="输出格式(mp3/wav/pcm")
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
class Config:
populate_by_name = True
@@ -46,7 +49,7 @@ class TTSJobResponse(BaseModel):
error_message: str = ""
retry_count: int = 0
max_retries: int = 3
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
@@ -80,7 +83,7 @@ class TTSSynthesizeResponse(BaseModel):
class ListTTSJobResponse(BaseModel):
"""TTS 任务列表响应。"""
items: List[TTSJobResponse]
items: list[TTSJobResponse]
total: int
page: int
page_size: int
@@ -110,7 +113,8 @@ 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="语速")
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文")
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="音调(预留,当前未使用)")
+2 -2
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from typing import Optional
from pydantic import BaseModel, Field
@@ -61,7 +61,7 @@ class ShareResponse(BaseModel):
class ShareListResponse(BaseModel):
"""分享列表响应."""
items: List[ShareResponse]
items: list[ShareResponse]
total: int = 0
skip: int = 0
limit: int = 20
+3 -3
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Literal, Optional
from typing import Literal, Optional
from pydantic import BaseModel, Field
@@ -56,7 +56,7 @@ class UnifiedVoiceItemResponse(BaseModel):
status: str = "completed"
"""状态"""
tags: List[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
"""标签列表"""
# 克隆音色特有字段
@@ -113,7 +113,7 @@ class PresetVoiceItemResponse(BaseModel):
preview_url: str = ""
"""预览音频 URL"""
tags: List[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
"""标签列表"""
+4 -4
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Optional
from pydantic import BaseModel, Field
@@ -19,7 +19,7 @@ class CreateVoiceCloneRequest(BaseModel):
language: str = Field("zh-CN", description="语言")
gender: str = Field("unknown", description="性别")
max_retries: int = Field(3, ge=1, le=10, description="最大重试次数")
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
class Config:
populate_by_name = True
@@ -41,7 +41,7 @@ class VoiceCloneProfileResponse(BaseModel):
error_message: str = ""
retry_count: int = 0
max_retries: int = 3
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
created_at: datetime
updated_at: datetime
@@ -62,7 +62,7 @@ class VoiceCloneStatusResponse(BaseModel):
class ListVoiceCloneResponse(BaseModel):
"""音色克隆列表响应。"""
items: List[VoiceCloneProfileResponse]
items: list[VoiceCloneProfileResponse]
total: int
+4 -4
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from typing import Optional
from pydantic import BaseModel, Field
@@ -21,7 +21,7 @@ class VoiceLibraryItemResponse(BaseModel):
file_size: int = 0
status: str = "completed"
project_id: Optional[str] = None
tags: List[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@@ -42,7 +42,7 @@ class CreateVoiceLibraryRequest(BaseModel):
file_size: int = 0
status: str = "completed"
project_id: Optional[str] = None
tags: List[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
class UpdateVoiceLibraryRequest(BaseModel):
@@ -55,4 +55,4 @@ class UpdateVoiceLibraryRequest(BaseModel):
duration: Optional[float] = None
file_size: Optional[int] = None
status: Optional[str] = None
tags: Optional[List[str]] = None
tags: Optional[list[str]] = None
@@ -1,10 +1,13 @@
"""AI 数字人封面服务 — 复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧.
"""AI 数字人封面服务 — MediaKit 抽帧 + 质量评分选最佳帧 + 转存 OSS.
与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧):
1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略)
2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳
3. 下载最佳帧并转存 OSS,返回公网封面 URL
设计原则:封面一律从最终成片(已叠加标题/B-roll)抽帧,帧本身已含标题,
本服务**不再叠加标题**。对口型阶段的裸视频封面入口已删除(废弃)。
降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。
"""
@@ -19,9 +22,9 @@ from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# MediaKit 抽帧轮询参数(与 MediaKit API timeout=60s 对齐
COVER_POLL_INTERVAL = 3.0
COVER_MAX_POLL_ATTEMPTS = 20 # 最多等 60 秒
# MediaKit 抽帧轮询参数poll_interval=2s × max_poll=30 → 最长 60s(与 mediakit_client 默认值/lipsync 轮询保持一致,防止合成视频下载+抽帧超时
COVER_POLL_INTERVAL = 2.0
COVER_MAX_POLL_ATTEMPTS = 30
# 帧图片下载超时(秒)
FRAME_DOWNLOAD_TIMEOUT = 20
@@ -49,7 +52,6 @@ def _sign_video_url_for_mediakit(video_url: str) -> str:
own_host = urlparse(public_base).netloc.lower()
url_host = urlparse(video_url).netloc.lower()
if own_host and url_host == own_host:
# 是自家 OSS URL,重签 7 天有效期供 MediaKit 拉取
signed = storage.get_download_url(video_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
if signed:
logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)")
@@ -60,19 +62,10 @@ def _sign_video_url_for_mediakit(video_url: str) -> str:
def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL.
Args:
video_url: 可公网访问的视频 URL
max_frames: 抽帧数量
Returns:
最佳帧图片 URL;失败返回空字符串
"""
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL."""
if not video_url:
return ""
# 确保 MediaKit 能访问 video_url(自家 OSS 私有桶需重签)
video_url = _sign_video_url_for_mediakit(video_url)
try:
@@ -85,11 +78,9 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
return ""
logger.info(
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d poll_interval=%.1f max_poll=%d",
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d",
video_url[:80],
max_frames,
COVER_POLL_INTERVAL,
COVER_MAX_POLL_ATTEMPTS,
)
snapshots = mk.extract_frames(
@@ -107,7 +98,6 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
if len(snapshots) == 1:
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
# 使用连接池下载各帧(复用 TCP 连接,减少延迟)
import httpx
candidates = []
@@ -135,7 +125,6 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
best = scored[0] if scored else None
best_url = best.get("url", "") if best else ""
# 清理临时文件
for c in candidates:
p = c.get("image_path")
if p:
@@ -156,16 +145,15 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
return ""
def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-avatar/covers") -> str:
"""下载帧图并转存到 OSS,返回公网封面 URL.
def persist_cover_to_oss(
frame_url: str,
*,
job_id: str = "",
prefix: str = "ai-avatar/covers",
) -> str:
"""下载最佳帧图并转存到 OSS,返回公网封面 URL(预签名).
Args:
frame_url: MediaKit 返回的临时帧图 URL
job_id: 关联任务 ID(用于 OSS key 命名)
prefix: OSS key 前缀
Returns:
OSS 公网 URL;失败回退原始 frame_url
封面来自最终成片抽帧,帧本身已含标题,本函数不再做任何文字/图片叠加。
"""
if not frame_url:
return ""
@@ -189,13 +177,13 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-
storage = get_shared_storage_service()
token = job_id or uuid.uuid4().hex[:12]
cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg"
public_url = storage.upload_file(
file_or_path=tmp_path,
storage_key=cover_key,
content_type="image/jpeg",
)
logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key)
# 私有桶:返回预签名 URL(前端才能加载)
if public_url:
signed = storage.get_download_url(cover_key, expires_seconds=86400)
return signed
@@ -211,10 +199,15 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-
pass
def generate_smart_cover(video_url: str, *, job_id: str = "", max_frames: int = 5) -> str:
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS,返回封面公网 URL.
def generate_smart_cover(
video_url: str,
*,
job_id: str = "",
max_frames: int = 5,
) -> str:
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS。失败返回空字符串。
供独立封面接口与渲染管线复用。失败返回空字符串
封面从最终成片抽帧,不再叠加任何标题(帧本身已含)
"""
best_frame = select_best_cover_frame(video_url, max_frames=max_frames)
if not best_frame:
+341 -109
View File
@@ -9,11 +9,14 @@
from __future__ import annotations
import base64
import binascii
import logging
import os
import subprocess
import tempfile
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any, Optional
from sqlalchemy.orm import Session
@@ -24,8 +27,9 @@ from packages.adapters.sqlalchemy_impl.models import (
ScriptModel,
)
from packages.domain.video_filter_builder import (
build_cover_extract_command,
build_broll_overlay_filter,
build_title_drawtext_filter,
build_title_overlay_filter,
)
from packages.shared.storage import get_shared_storage_service
@@ -113,7 +117,7 @@ class AiAvatarRenderService:
self.db.add(job)
self.db.flush()
job.submitted_at = datetime.now(timezone.utc)
job.submitted_at = datetime.now(UTC)
self.db.commit()
self.db.refresh(job)
return job
@@ -160,7 +164,7 @@ class AiAvatarRenderService:
return None
if job.status in ("pending", "submitted"):
job.status = "cancelled"
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
self.db.commit()
self.db.refresh(job)
return job
@@ -182,7 +186,7 @@ class AiAvatarRenderService:
job.output_duration = 0.0
job.started_at = None
job.completed_at = None
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
self.db.commit()
self.db.refresh(job)
return job
@@ -196,9 +200,8 @@ class AiAvatarRenderService:
1. 下载对口型输出视频 (20%)
2. 构建 FFmpeg 滤镜链 (40%)
3. 执行 FFmpeg 渲染 (80%)
4. 提取封面 (90%)
5. 上传到 OSS (95%)
6. 更新任务状态 (100%)
4. 上传到 OSS (95%) — 封面不再自动生成,改由前端主动抽帧
5. 更新任务状态 (100%)
"""
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
if job is None:
@@ -212,9 +215,9 @@ class AiAvatarRenderService:
try:
# 更新状态为 processing
job.status = "processing"
job.started_at = datetime.now(timezone.utc)
job.started_at = datetime.now(UTC)
job.progress = 5
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
self.db.commit()
# 获取对口型任务信息
@@ -228,27 +231,32 @@ class AiAvatarRenderService:
self.db.commit()
# 2. 构建 FFmpeg 滤镜链 (40%)
from packages.domain.video_filter_builder import build_broll_overlay_filter
# 用 ffprobe 探测输入视频分辨率,确保 B-roll 缩放与标题位置与实际输出一致。
# AI 数字人对口型输出为 9:16 竖屏,默认兜底 720x1280;探测失败时使用默认值不阻断渲染。
output_width, output_height = self._probe_video_resolution(input_video_path)
if output_width <= 0 or output_height <= 0:
output_width, output_height = 720, 1280
logger.info(
"[数字人渲染] ffprobe 探测分辨率失败或无效,使用默认竖屏尺寸 %sx%s",
output_width,
output_height,
)
else:
logger.info("[数字人渲染] 探测输入视频分辨率: %sx%s", output_width, output_height)
filter_complex = build_broll_overlay_filter(
broll_filter, broll_label = build_broll_overlay_filter(
b_roll_segments=job.b_roll_segments,
video_duration=lipsync_job.output_duration,
output_width=output_width,
output_height=output_height,
)
# 标题叠加
title_filter = build_title_drawtext_filter(job.title_config)
if title_filter:
if filter_complex:
filter_complex += f"[vout]{title_filter}[vout_titled];"
else:
filter_complex = f"[0:v]{title_filter}[vout_titled];"
# 清理末尾分号
if filter_complex.endswith(";"):
filter_complex = filter_complex[:-1]
# 最终输出标签
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
# 标题叠加路径:优先前端 Canvas 渲染的 PNG 图层(所见即所得),
# 无 title_image_dataurl 时降级到 drawtext 重画文字。
title_cfg = job.title_config if isinstance(job.title_config, dict) else {}
title_dataurl = (title_cfg or {}).get("title_image_dataurl") if title_cfg else None
use_title_png = isinstance(title_dataurl, str) and title_dataurl.startswith("data:image/")
title_input_index = 1 + len(job.b_roll_segments or []) if use_title_png else None
job.progress = 40
self.db.commit()
@@ -257,57 +265,126 @@ class AiAvatarRenderService:
with tempfile.TemporaryDirectory() as tmpdir:
output_video_path = os.path.join(tmpdir, "output.mp4")
cmd = self._build_ffmpeg_command(
# 在临时目录里解码保存标题 PNG(with 退出自动清理)
title_png_path: Optional[str] = None
extra_inputs: list[str] = []
title_filter = None
if use_title_png:
try:
title_png_path = os.path.join(tmpdir, f"title_{job.id}.png")
self._save_title_dataurl_to_file(title_dataurl, dst_path=title_png_path)
extra_inputs.append(title_png_path)
logger.info(
"[数字人渲染] 标题 PNG 已保存: %s (input index %d)", title_png_path, title_input_index
)
except Exception as exc:
logger.warning("[数字人渲染] 标题 PNG 解码/保存失败,降级 drawtext: %s", exc)
title_png_path = None
extra_inputs = []
# 构建标题滤镜
final_label = None
if title_png_path and title_input_index is not None:
title_input_label = f"[{title_input_index}:v]"
base_label = f"[{broll_label}]" if broll_label else "[0:v]"
title_filter = build_title_overlay_filter(
title_cfg,
output_width=output_width,
output_height=output_height,
title_png_path=title_png_path,
title_input_label=title_input_label,
base_label=base_label,
output_label="vout_titled",
)
if not title_filter:
# build 返回 None → 文件不存在(极端并发情况),降级 drawtext
title_png_path = None
extra_inputs = []
if title_png_path:
# overlay 路径
if broll_filter and title_filter:
filter_complex = broll_filter + f";{title_filter}"
elif broll_filter:
filter_complex = broll_filter
final_label = broll_label
elif title_filter:
filter_complex = title_filter
else:
filter_complex = ""
if title_filter:
final_label = "vout_titled"
elif not final_label:
final_label = None
else:
# 降级:drawtext 重画文字
title_filter = build_title_drawtext_filter(
title_cfg,
output_width=output_width,
output_height=output_height,
)
if broll_filter and title_filter:
filter_complex = broll_filter + f";[{broll_label}]{title_filter}[vout_titled]"
final_label = "vout_titled"
elif broll_filter:
filter_complex = broll_filter
final_label = broll_label
elif title_filter:
filter_complex = f"[0:v]{title_filter}[vout_titled]"
final_label = "vout_titled"
else:
filter_complex = ""
final_label = None
cmd_list = self._build_ffmpeg_command(
input_video=input_video_path,
b_roll_segments=job.b_roll_segments,
extra_inputs=extra_inputs,
filter_complex=filter_complex,
final_label=final_label,
output_path=output_video_path,
)
exit_code = os.system(cmd)
if exit_code != 0:
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
try:
render_result = subprocess.run(
cmd_list,
capture_output=True,
text=True,
timeout=600,
)
except subprocess.TimeoutExpired as exc:
raise AiAvatarRenderError(
"FFmpeg 渲染超时(600s",
code="FFmpegTimeout",
) from exc
if render_result.returncode != 0:
stderr_tail = (render_result.stderr or "").strip()[-800:]
raise AiAvatarRenderError(
f"FFmpeg 渲染失败,退出码: {render_result.returncode}, stderr: {stderr_tail}",
code="FFmpegFailed",
)
job.progress = 80
self.db.commit()
# 4. 提取封面 (90%)
cover_path = ""
if job.cover_config:
cover_path = os.path.join(tmpdir, "cover.jpg")
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
cover_exit = os.system(cover_cmd)
if cover_exit != 0:
logger.warning("封面提取失败,跳过: %s", cover_cmd)
cover_path = ""
job.progress = 90
self.db.commit()
# 5. 上传到 OSS (95%)
# 4/5. 上传成片到 OSS (95%) —— 已砍掉自动抽封面逻辑(步骤⑤);
# 封面由前端在渲染完成后通过 /smart-cover 接口主动从成片抽帧,不阻塞渲染链路。
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
job.output_video_url = output_video_url
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧;
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
smart_cover_url = ""
if output_video_url:
try:
from app.services.ai_avatar_cover_service import (
generate_smart_cover,
)
smart_cover_url = generate_smart_cover(output_video_url, job_id=job_id, max_frames=5)
except Exception:
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
if smart_cover_url:
job.output_cover_url = smart_cover_url
elif cover_path:
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
job.output_cover_url = output_cover_url
# 封面透传:如果用户已在 cover_config 中选定封面 URLmode=upload 的自定义上传 或
# mode=auto_frame 已有的智能封面结果),直接透传到 output_cover_url,不再重新截帧。
if isinstance(job.cover_config, dict):
_pre_cover_url = (
job.cover_config.get("url")
or job.cover_config.get("imageUrl")
or job.cover_config.get("cover_url")
or ""
)
if _pre_cover_url:
job.output_cover_url = _pre_cover_url
logger.info("[数字人渲染] 使用用户已选定封面 URL: job_id=%s", job_id)
# 获取输出视频时长
job.output_duration = lipsync_job.output_duration
@@ -317,55 +394,110 @@ class AiAvatarRenderService:
# 6. 完成
job.status = "completed"
job.progress = 100
job.completed_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(timezone.utc)
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
self.db.commit()
logger.info("渲染任务完成: %s", job_id)
# 7. 自动保存成片记录到成片库
if job.output_video_url:
try:
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain.generated_video import GeneratedVideo
clip_name = f"AI数字人_{job_id[:8]}"
clip = GeneratedVideo.create(
project_id=job.project_id,
generation_task_id=job.lipsync_job_id,
name=clip_name,
file_url=job.output_video_url,
user_id=job.user_id,
duration=job.output_duration or 0.0,
thumbnail_url=job.output_cover_url or None,
generation_params={
"source": "ai_avatar_render",
"render_job_id": job.id,
},
)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
video_repo.create(clip)
logger.info("成片记录已保存到成片库: clip_id=%s, render_job=%s", clip.id, job_id)
except Exception as clip_err:
logger.warning(
"自动保存成片记录失败(不影响渲染任务状态): render_job=%s, error=%s",
job_id,
clip_err,
)
# 7. 渲染完成,停留在「待选封面」状态:不自动入库。
# 用户在前端选好封面、点「完成」后,由 /{job_id}/finalize 接口显式入库。
logger.info("渲染任务完成,等待用户选择封面后入库: job_id=%s", job_id)
except AiAvatarRenderError as exc:
job.status = "failed"
job.error_message = str(exc)
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
self.db.commit()
logger.error("渲染任务失败 [%s]: %s", job_id, exc)
raise
except Exception as exc:
job.status = "failed"
job.error_message = f"渲染异常: {str(exc)}"
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
self.db.commit()
logger.exception("渲染任务异常 [%s]", job_id)
raise
def _persist_to_library(self, job: AiAvatarRenderJob, cover_url: Optional[str] = None):
"""将渲染结果写入成片库,返回 GeneratedVideo 领域对象.
Args:
job: 渲染任务(必须 status=completed 且 output_video_url 非空)
cover_url: 可选的封面 URL 覆盖(finalize 时传入即优先使用,否则取 job.output_cover_url
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain.generated_video import GeneratedVideo
clip_name = f"AI数字人_{job.id[:8]}"
# AI数字人入口是独立页面,前端可能不传 project_id(无项目概念),
# 兜底为 "ai_avatar" 避免 DB 非空约束/查询问题;generation_task_id 用 render_job_id 便于反查。
clip_project_id = (job.project_id or "").strip() or "ai_avatar"
clip_generation_task_id = job.id
effective_cover = (cover_url or "").strip() if cover_url else (job.output_cover_url or "").strip()
clip = GeneratedVideo.create(
project_id=clip_project_id,
generation_task_id=clip_generation_task_id,
name=clip_name,
file_url=job.output_video_url,
user_id=job.user_id,
duration=job.output_duration or 0.0,
thumbnail_url=effective_cover or None,
generation_params={
"source": "ai_avatar_render",
"render_job_id": job.id,
},
)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
video_repo.create(clip)
logger.info("[数字人渲染] 成片已入库: clip_id=%s render_job=%s", clip.id, job.id)
return clip
def finalize_job(self, job_id: str, user_id: str, cover_url: Optional[str] = None):
"""用户在前端点「完成」后调用:将已 completed 的渲染任务正式入库到成片库.
- 必须 status=completed 才可调用
- cover_url 若传入则优先使用并回写 job.output_cover_url;否则使用 job.output_cover_urlsmart-cover/custom-cover 已写入)
- 幂等:已入库则返回已存在的 GeneratedVideo
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
job = self.get_render_job(job_id, user_id)
if job is None:
raise AiAvatarRenderError("渲染任务不存在", code="RenderJobNotFound")
if job.status != "completed":
raise AiAvatarRenderError(f"渲染任务未完成(当前状态: {job.status}),无法入库", code="RenderNotCompleted")
if not (job.output_video_url or "").strip():
raise AiAvatarRenderError("渲染成片视频 URL 为空,无法入库", code="OutputVideoMissing")
# 幂等检查:已入库直接返回现有记录(通过 generation_task_id=job_id 识别,
# 因为入库时 generation_task_id 被设置为 render_job_id 自身)
existing = (
self.db.query(GeneratedVideoModel)
.filter(
GeneratedVideoModel.user_id == user_id,
GeneratedVideoModel.generation_task_id == job_id,
)
.first()
)
if existing is not None:
logger.info("[数字人渲染] finalize 幂等命中,返回已存在记录: clip_id=%s job_id=%s", existing.id, job_id)
return SQLAlchemyGeneratedVideoRepository(self.db).get(existing.id)
# 传入 cover_url 时回写到 job
if cover_url and cover_url.strip():
job.output_cover_url = cover_url.strip()
# 同步更新 cover_config,保持 smart-cover 路径一致
if isinstance(job.cover_config, dict):
job.cover_config = {**job.cover_config, "mode": "auto_frame", "url": cover_url.strip()}
job.updated_at = datetime.now(UTC)
self.db.commit()
return self._persist_to_library(job, cover_url=cover_url)
def _download_video(self, url: str) -> str:
"""下载视频到临时文件."""
@@ -383,32 +515,132 @@ class AiAvatarRenderService:
os.unlink(tmp.name)
raise
@staticmethod
def _save_title_dataurl_to_file(dataurl: str, *, dst_path: str | None = None, job_id: str = "") -> str:
"""解码前端传来的 data:image/png;base64,... 并保存为本地 PNG 文件。
Args:
dataurl: 完整 dataURL 字符串
dst_path: 指定输出路径;为 None 时创建临时文件并返回路径
job_id: 仅在 dst_path 为空时用于临时文件命名
Returns:
保存后的本地文件路径
"""
if not isinstance(dataurl, str) or not dataurl.startswith("data:image/"):
raise ValueError("title_image_dataurl 不是合法的 data:image URL")
# 拆分 data:image/png;base64,<payload>
try:
header, b64 = dataurl.split(",", 1)
except ValueError as exc:
raise ValueError("title_image_dataurl 缺少 base64 payload") from exc
if "base64" not in header:
raise ValueError("title_image_dataurl 不是 base64 编码")
try:
png_bytes = base64.b64decode(b64, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(f"title_image_dataurl base64 解码失败: {exc}") from exc
if not png_bytes:
raise ValueError("title_image_dataurl 解码后为空")
if dst_path:
out_path = dst_path
with open(out_path, "wb") as f:
f.write(png_bytes)
return out_path
suffix = f"_title_{job_id}.png" if job_id else "_title.png"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(png_bytes)
return tmp.name
@staticmethod
def _probe_video_resolution(video_path: str) -> tuple[int, int]:
"""用 ffprobe 探测视频分辨率,返回 (width, height);失败返回 (0, 0)。"""
try:
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
video_path,
],
capture_output=True,
text=True,
timeout=15,
)
if result.returncode == 0 and result.stdout.strip():
parts = result.stdout.strip().split("x")
if len(parts) == 2:
w, h = int(parts[0]), int(parts[1])
if w > 0 and h > 0:
return w, h
except Exception as exc:
logger.warning("[数字人渲染] ffprobe 探测分辨率失败: %s", exc)
return 0, 0
def _build_ffmpeg_command(
self,
*,
input_video: str,
b_roll_segments: list[dict[str, Any]],
extra_inputs: list[str] | None = None,
filter_complex: str,
final_label: Optional[str],
output_path: str,
) -> str:
"""构建 FFmpeg 命令."""
# 输入文件
inputs = f"-i {input_video}"
) -> list[str]:
"""构建 FFmpeg 命令list 形式,shell=False.
根因修复 #1798 P0OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符,
os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被
当成独立命令报 sh: -filter_complex: not foundexit 127 → Python 32512)。
list + shell=False 彻底规避 shell 转义问题。
"""
cmd: list[str] = ["ffmpeg", "-i", input_video]
for seg in b_roll_segments:
asset_url = seg.get("asset_url", "")
if asset_url:
inputs += f" -i {asset_url}"
cmd.extend(["-i", asset_url])
# 额外输入(例如前端 Canvas 渲染的标题 PNG)
for extra in extra_inputs or []:
cmd.extend(["-i", extra])
# 滤镜
if filter_complex and final_label:
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
cmd.extend(
[
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-map",
"0:a?",
]
)
elif filter_complex:
filter_arg = f'-filter_complex "{filter_complex}"'
else:
filter_arg = ""
cmd.extend(["-filter_complex", filter_complex])
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset veryfast -crf 23 -y {output_path}"
cmd.extend(
[
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-y",
output_path,
]
)
return cmd
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
"""上传文件到 OSS,返回 URL.
+12 -12
View File
@@ -13,7 +13,7 @@
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from typing import Any, Optional
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
@@ -64,7 +64,7 @@ def _generate_titles_fallback(
description: str,
style: str = "viral",
count: int = 5,
) -> List[str]:
) -> list[str]:
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
return _generate_titles_fallback_base(description, style_info, count)
@@ -74,7 +74,7 @@ def generate_smart_titles(
description: str,
style: str = "viral",
count: int = 5,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""生成智能标题.
Args:
@@ -164,16 +164,16 @@ def generate_smart_titles(
def _semantic_match_fallback(
description: str,
assets: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
assets: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
return _semantic_match_fallback_base(description, assets)
def _parse_semantic_match_response(
content: str,
asset_ids: List[str],
) -> Optional[Dict[str, float]]:
asset_ids: list[str],
) -> Optional[dict[str, float]]:
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
result = _parse_semantic_match_base(content, asset_ids)
if result is None:
@@ -183,9 +183,9 @@ def _parse_semantic_match_response(
def semantic_match_assets(
description: str,
assets: List[Dict[str, Any]],
assets: list[dict[str, Any]],
top_k: int = 0,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""智能素材语义匹配.
根据用户描述,评估每个素材的语义匹配度并排序。
@@ -336,13 +336,13 @@ class AIService:
description: str,
style: str = "viral",
count: int = 5,
) -> Dict[str, Any]:
) -> dict[str, Any]:
return generate_smart_titles(description, style, count)
def semantic_match(
self,
description: str,
assets: List[Dict[str, Any]],
assets: list[dict[str, Any]],
top_k: int = 0,
) -> Dict[str, Any]:
) -> dict[str, Any]:
return semantic_match_assets(description, assets, top_k)
@@ -28,8 +28,8 @@ from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Callable
from collections.abc import Callable
from datetime import UTC, datetime
from sqlalchemy.orm import Session
@@ -57,7 +57,7 @@ _REUSE_OVERLAP_RATIO = 0.6
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
def _read_meta(model) -> dict:
@@ -156,7 +156,7 @@ def record_used_segments(
r["plan_id"] = plan_id
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
model.updated_at = datetime.now(UTC)
return
ranges.append(
@@ -171,7 +171,7 @@ def record_used_segments(
)
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
model.updated_at = datetime.now(UTC)
def remove_used_segment(
@@ -215,7 +215,7 @@ def remove_used_segment(
if removed:
meta[USED_RANGES_KEY] = remaining
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
model.updated_at = datetime.now(UTC)
return removed
@@ -231,7 +231,7 @@ def reset_used_segments(db: Session, asset_id: str) -> None:
if meta.get(USED_RANGES_KEY):
meta[USED_RANGES_KEY] = []
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
model.updated_at = datetime.now(UTC)
logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id)
+151 -79
View File
@@ -7,7 +7,7 @@
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from typing import Any, Optional
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
@@ -206,7 +206,7 @@ class EditPlanService:
status: Optional[EditPlanClipStatus] = None,
skip: int = 0,
limit: int = 100,
) -> List[EditPlanClip]:
) -> list[EditPlanClip]:
"""列出计划的片段"""
# 确保计划存在
self.get_plan_or_raise(plan_id)
@@ -473,6 +473,7 @@ class EditPlanService:
name_suffix: str = "变体",
voice_duration: float = 0.0,
rng=None,
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
) -> EditPlan:
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
@@ -489,6 +490,8 @@ class EditPlanService:
created_by_user_id: 新 plan 归属用户。
name_suffix: plan 名后缀。
rng: 可选随机数(测试注入种子)。
batch_segments: 可选,外部传入的批次内已使用素材区间(前序变体避让用)。
传入时作为初始避让对象;未传则保持原逻辑从源 plan clips 自建(向后兼容)。
Raises:
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
@@ -500,7 +503,7 @@ class EditPlanService:
source = self.get_plan_or_raise(source_plan_id)
# 分页读取源 plan 全部片段
clips: List[EditPlanClip] = []
clips: list[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
@@ -537,16 +540,26 @@ class EditPlanService:
voice = float(voice_duration or 0.0)
except (TypeError, ValueError):
voice = 0.0
rhythm_template_for_reselect = None
if source.config:
rhythm_template_for_reselect = source.config.get("rhythm_template")
if voice > 0 and source_clips_data:
from packages.domain.voice_duration_planner import plan_clip_durations
_effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data]
_tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data]
# #1855 P0:先占位durations为空dict,真正查durations在后面pool_ids确定后执行;
# plan_clip_durations 的 asset_durations 参数在该函数中仅作最大段长钳制,
# 这里先不依赖它(durations 还没查),传 None 让planner用默认策略;
# 真正的asset_durations会在后面 clips_data 生成时传入 reselect_clips_for_variant
target_durations = plan_clip_durations(
len(source_clips_data),
voice,
transition_effects=_effects,
transition_durations=_tdurs,
rhythm_template=rhythm_template_for_reselect,
asset_durations=None,
)
if target_durations:
for _c, _d in zip(source_clips_data, target_durations, strict=False):
@@ -582,12 +595,18 @@ class EditPlanService:
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
)
# 批次内区间:以源 plan(变体 0)片段为初始避让对象
batch_segments: dict[str, list[tuple[float, float]]] = {}
for c in clips:
if c.asset_id and float(c.duration or 0) > 0:
st = float(c.start_time or 0.0)
batch_segments.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
# 批次内区间:外部传入时使用外部传入(含前序变体已用区间);
# 否则保持原逻辑从源 plan clips 自建(向后兼容)
if batch_segments is not None:
batch_segments_resolved: dict[str, list[tuple[float, float]]] = {
k: list(v) for k, v in batch_segments.items()
}
else:
batch_segments_resolved = {}
for c in clips:
if c.asset_id and float(c.duration or 0) > 0:
st = float(c.start_time or 0.0)
batch_segments_resolved.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
clips_data = reselect_clips_for_variant(
source_clips_data,
@@ -595,7 +614,7 @@ class EditPlanService:
asset_durations=durations,
asset_scene_points=scene_points,
historical_used_segments=historical,
batch_segments=batch_segments,
batch_segments=batch_segments_resolved,
target_durations=target_durations,
rng=rng,
)
@@ -646,7 +665,7 @@ class EditPlanService:
source = self.get_plan_or_raise(source_plan_id)
# 分页读取源 plan 全部片段
clips: List[EditPlanClip] = []
clips: list[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
@@ -767,7 +786,18 @@ class EditPlanService:
if plan is None:
return None
clips: List[EditPlanClip] = []
# #1855 P0:幂等判断——如果已成功分配过且当前 total_duration 已接近 voice_duration,直接返回
try:
existing_mark = None
if plan.config:
existing_mark = plan.config.get("voice_duration_applied")
cur_total = float(plan.total_duration or 0.0)
if existing_mark is not None and abs(existing_mark - voice) < 1e-6 and abs(cur_total - voice) < 0.5:
return plan
except Exception:
pass
clips: list[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page)
@@ -838,6 +868,10 @@ class EditPlanService:
)
try:
plan.total_duration = net
# #1855 P0:写入幂等标记,避免二次调用时只重分配 duration 不重算 start_time
new_cfg = dict(plan.config or {})
new_cfg["voice_duration_applied"] = voice
plan.config = new_cfg
db = self._clip_repo.session
db.commit()
except Exception:
@@ -878,6 +912,69 @@ class EditPlanService:
rng = rng or _random.Random()
plan_ids: list[str] = []
# #1855 P0:先确定片段数 clip_count(用于节奏模板生成长度匹配)
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
from packages.domain.variant_plan_selector import (
generate_pixel_perturbation,
generate_visual_perturbation,
)
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
clip_count = 0
# 从源 plan 获取片段数(分页读,避免关系加载问题)
_sclips: list = []
_sk, _pg = 0, 500
while True:
_b = self._clip_repo.list_by_plan(source_plan_id, skip=_sk, limit=_pg)
if not _b:
break
_sclips.extend(_b)
if len(_b) < _pg:
break
_sk += _pg
clip_count = len(_sclips)
# 预先生成所有 N 个变体的节奏模板/BGM/扰动参数(时机提前到选片前写入config)
rhythm_templates_for_variants: list = []
for _idx in range(count):
if clip_count > 0:
variant_seed = rng.randint(0, 999999)
_tpl = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
rhythm_templates_for_variants.append(_tpl)
else:
rhythm_templates_for_variants.append(None)
source_bgm_config: dict = {}
source_plan = self.get_plan(source_plan_id)
if source_plan and source_plan.config:
source_bgm_config = source_plan.config.get("bgm", {}) or {}
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in range(count)]
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
def _build_variant_config_update(idx: int) -> dict:
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。"""
upd: dict = {}
try:
perturbation = generate_visual_perturbation(rng)
if idx == 0:
perturbation["hflip"] = False
upd["visual_perturbation"] = perturbation
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
try:
pixel_pert = generate_pixel_perturbation(rng)
upd["pixel_perturbation"] = pixel_pert
except Exception:
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None
if rt is not None:
upd["rhythm_template"] = rt
if idx < len(bgm_pool_assignments):
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
existing_bgm.update(bgm_pool_assignments[idx])
upd["bgm"] = existing_bgm
return upd
# 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan
plan0 = self.clone_plan_for_variant(
source_plan_id,
@@ -890,6 +987,15 @@ class EditPlanService:
v0_voice = float(voice_durations[0] or 0.0)
except (TypeError, ValueError):
v0_voice = 0.0
# #1855 P0:在配音分配前先写入变体0的节奏模板/扰动/BGM,确保 apply_voice_duration_to_plan 能读到 rhythm_template
try:
_cfg0 = _build_variant_config_update(0)
if _cfg0:
self.update_plan_config(plan0.id, _cfg0)
except Exception:
logger.exception("变体0 配置写入失败(不阻断): plan=%s", plan0.id)
if v0_voice > 0:
try:
self.apply_voice_duration_to_plan(plan0.id, v0_voice)
@@ -897,7 +1003,12 @@ class EditPlanService:
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
plan_ids.append(plan0.id)
# 变体 1..N-1:独立选片
# #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值(公共函数)
from app.services.generation_common import collect_plan_segments as _collect_plan_segments
batch_segments_acc: dict[str, list[tuple[float, float]]] = _collect_plan_segments(plan0.id, self._clip_repo)
# 变体 1..N-1:独立选片(传入累积的 batch_segments 做区间避让)
for i in range(1, count):
voice = 0.0
if voice_durations and i < len(voice_durations):
@@ -905,6 +1016,14 @@ class EditPlanService:
voice = float(voice_durations[i] or 0.0)
except (TypeError, ValueError):
voice = 0.0
# #1855 P0:在reselect前先为"变体i"准备配置更新——但reselect内部复制的是source.config
# 所以每个变体独立的节奏模板需要在reselect后单独写入config
# 但 plan_clip_durations 用的是 source.config.rhythm_template(即源plan的节奏模板),
# 为了让每个变体在选片阶段就使用自己的节奏模板分配段长,这里采用:
# - reselect 仍使用源 plan 的 rhythm_template(保持片段骨架一致)
# - 选片完成后立即写入该变体自己的 rhythm_template/扰动/BGM 到config
# 后续不再二次 apply_voice_duration_to_plan(由幂等标记跳过)
variant = self.reselect_plan_for_variant(
source_plan_id,
candidate_asset_ids,
@@ -912,73 +1031,26 @@ class EditPlanService:
name_suffix=f"变体{i + 1}",
voice_duration=voice,
rng=rng,
batch_segments=batch_segments_acc,
)
# 选片完成后写入该变体的独立配置(节奏模板/扰动/BGM)
try:
_cfgi = _build_variant_config_update(i)
if _cfgi:
self.update_plan_config(variant.id, _cfgi)
except Exception:
logger.exception("变体 %d 配置写入失败(不阻断): plan=%s", i, variant.id)
plan_ids.append(variant.id)
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
clip_count = 0
if voice_durations and len(voice_durations) > 0:
# 从源 plan 获取片段数
source_plan = self.get_plan(source_plan_id)
if source_plan and hasattr(source_plan, "clips"):
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
rhythm_templates_for_variants = []
if clip_count > 0:
for idx in range(len(plan_ids)):
# 每个变体用不同的 seed 选择节奏模板
variant_seed = rng.randint(0, 999999)
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
rhythm_templates_for_variants.append(template)
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
# #1767:BGM 池差异化分配(让批量变体使用不同 BGM / 段落 / 音量)
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
source_bgm_config = {}
source_plan = self.get_plan(source_plan_id)
if source_plan and source_plan.config:
source_bgm_config = source_plan.config.get("bgm", {}) or {}
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in plan_ids]
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
from packages.domain.variant_plan_selector import generate_visual_perturbation
for idx, pid in enumerate(plan_ids):
# #1855 P0:把当前新变体的 clips 区间追加到 batch_segments,供下一变体避让
try:
perturbation = generate_visual_perturbation(rng)
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
if idx == 0:
perturbation["hflip"] = False
config_update = {"visual_perturbation": perturbation}
# #1764:写入节奏模板
if idx < len(rhythm_templates_for_variants):
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
# #1765:写入像素级扰动滤镜
from packages.domain.variant_plan_selector import generate_pixel_perturbation
pixel_pert = generate_pixel_perturbation(rng)
config_update["pixel_perturbation"] = pixel_pert
# #1767:写入 BGM 池分配(覆盖 bgm 配置中的 preset_id / audio_offset / volume_adjust_db
if idx < len(bgm_pool_assignments):
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
existing_bgm.update(bgm_pool_assignments[idx])
config_update["bgm"] = existing_bgm
self.update_plan_config(pid, config_update)
logger.info(
"变体 %d 视觉扰动+像素扰动+BGM池: plan=%s vis=%s pix=%s bgm=%s",
idx,
pid,
perturbation,
pixel_pert,
bgm_pool_assignments[idx] if idx < len(bgm_pool_assignments) else None,
)
_new_segs = _collect_plan_segments(variant.id, self._clip_repo)
for _aid, _ivs in _new_segs.items():
batch_segments_acc.setdefault(_aid, []).extend(_ivs)
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
logger.exception("变体 %d 区间收集失败(不阻断): plan=%s", i, variant.id)
# 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready)
for pid in plan_ids:
@@ -991,7 +1063,7 @@ class EditPlanService:
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
def split_clip(self, clip_id: str, split_time: float) -> dict[str, Any]:
"""将一个片段从指定位置分割为两个片段
Args:
@@ -1079,7 +1151,7 @@ class EditPlanService:
"right_clip": created_right,
}
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
def merge_clips(self, clip_ids: list[str]) -> EditPlanClip:
"""合并多个连续片段为一个片段
Args:
@@ -1145,7 +1217,7 @@ class EditPlanService:
# ── 渲染生成流程 ────────────────────────────────────────────────────────
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
def get_generation_status(self, plan_id: str) -> dict[str, Any]:
"""获取渲染进度状态
Returns:
@@ -1292,7 +1364,7 @@ class EditPlanService:
)
return count
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
def update_plan_config(self, plan_id: str, config_updates: dict[str, Any]) -> EditPlan:
"""更新计划配置(合并更新)
Args:
@@ -7,7 +7,7 @@
from __future__ import annotations
import logging
from typing import Any, List, Optional
from typing import Any, Optional
from sqlalchemy.orm import Session
@@ -76,7 +76,7 @@ class EditTemplateService:
active_only: bool = False,
skip: int = 0,
limit: int = 50,
) -> List[EditTemplate]:
) -> list[EditTemplate]:
"""列出模板
Args:
@@ -227,7 +227,7 @@ class EditTemplateService:
clip_type: Optional[ClipType] = None,
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
) -> list[TemplateClipConfig]:
"""列出模板的片段配置
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
@@ -253,7 +253,7 @@ class EditTemplateService:
clip_type: Optional[ClipType] = None,
skip: int = 0,
limit: int = 100,
) -> List[TemplateClipConfig]:
) -> list[TemplateClipConfig]:
"""编辑器读取模板片段配置的单一数据源入口.
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
@@ -404,8 +404,8 @@ class EditTemplateService:
def reorder_clip_configs(
self,
template_id: str,
config_ids: List[str],
) -> List[TemplateClipConfig]:
config_ids: list[str],
) -> list[TemplateClipConfig]:
"""重新排序片段配置
Args:
@@ -560,7 +560,7 @@ class EditTemplateService:
)
# 5. 转换每个片段为模板片段配置
created_configs: List[TemplateClipConfig] = []
created_configs: list[TemplateClipConfig] = []
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
created = self._clip_config_repo.create(clip_config_obj)
created_configs.append(created)
+189
View File
@@ -0,0 +1,189 @@
"""智能剪辑公共服务辅助函数(从 route 层下沉)。
集中管理:
- query_voice_durations:批量查询配音素材时长
- writeback_edit_plan_config:任务入队后回写 EditPlan.config
- collect_plan_segments:分页读取 plan clips 构建素材区间表(变体避让用)
- resolve_latest_plan_by_template:按 template_id + user_id 查最新 EditPlan
设计原则:
- 无副作用的纯查询 / 幂等写回;失败一律不阻断主流程(记日志 + 返回安全默认值)
- 不依赖 FastAPI / HTTPException,便于 service 层和 worker 复用
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
#1855 P0修复:不再对 voice_ids 去重,保持与调用方传入顺序/长度一致,
允许同配音id多次出现时返回相同时长(支持"同配音N变体"的时长对齐)。
"""
raw_ids = list(voice_ids or [])
if not raw_ids:
return []
unique_ids: list[str] = []
_seen: set[str] = set()
for v in raw_ids:
if v and v not in _seen:
_seen.add(v)
unique_ids.append(v)
if not unique_ids:
return [0.0 for _ in raw_ids]
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(unique_ids)).all()
dur_map: dict[str, float] = {}
for row in rows:
try:
dur_map[row[0]] = float(row[1] or 0.0)
except (TypeError, ValueError):
dur_map[row[0]] = 0.0
return [dur_map.get(v, 0.0) if v else 0.0 for v in raw_ids]
except Exception:
logger.warning("[generation_common] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
return [0.0 for _ in raw_ids]
def writeback_edit_plan_config(
plan_id: str,
task_id: str,
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.configgeneration_task_id + title_config。
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[generation_common] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
if title_config:
# #1901 统一字段名为 "title"worker sync_configs_to_plan 写的是 "title"
# 先读取新旧两个 key,判断标题文字是否变化
old_title_cfg = merged.get("title", {}) or {}
if not isinstance(old_title_cfg, dict) or not (old_title_cfg.get("text") or "").strip():
old_title_cfg = merged.get("title_config", {}) or {}
old_title_text = (old_title_cfg.get("text") or "").strip() if isinstance(old_title_cfg, dict) else ""
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
if "cover" in merged:
del merged["cover"]
logger.info(
"[generation_common] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id,
old_title_text,
new_title_text,
)
# 字段名归一化(font_size→size, font_preset→font, font_color→color),与 worker sync_configs_to_plan 保持一致
normalized = dict(title_config)
if "font_size" in normalized and "size" not in normalized:
normalized["size"] = normalized["font_size"]
if "font_preset" in normalized and "font" not in normalized:
normalized["font"] = normalized["font_preset"]
if "font_color" in normalized and "color" not in normalized:
normalized["color"] = normalized["font_color"]
merged["title"] = normalized
# 清掉旧 key,避免双字段并存
merged.pop("title_config", None)
plan_model.config = merged
db.commit()
logger.info(
"[generation_common] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[generation_common] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def collect_plan_segments(
plan_id: str,
clip_repo: Any,
*,
page_size: int = 500,
) -> dict[str, list[tuple[float, float]]]:
"""分页读取 plan 所有 clips,构建 {asset_id: [(start, end), ...]} 素材区间表。
用于 #1855 P0 批次内素材区间避让(变体间素材片段重叠控制)。
"""
segs: dict[str, list[tuple[float, float]]] = {}
sk, pg = 0, page_size
while True:
batch = clip_repo.list_by_plan(plan_id, skip=sk, limit=pg)
if not batch:
break
for c in batch:
if c.asset_id and float(c.duration or 0) > 0:
st = float(c.start_time or 0.0)
segs.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
if len(batch) < pg:
break
sk += pg
return segs
def resolve_latest_plan_by_template(
db: Session,
*,
template_id: str,
user_id: str,
) -> Optional[str]:
"""按 template_id + user_id 查找最新的 EditPlan.id(模板兜底用)。找不到返回 None。"""
if not (template_id or "").strip():
return None
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == template_id.strip(),
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
return latest.id if latest else None
except Exception:
logger.warning(
"[generation_common] 按template查找最新plan失败: template=%s user=%s",
template_id,
user_id,
exc_info=True,
)
return None
+284 -86
View File
@@ -1,8 +1,12 @@
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整, #1845 配音前置.
职责:
- 创建/查询对口型任务
- 输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url
- 输入模式:
1. TTS 直生(voice_id + script_text)→ 走 Celery 异步(降级路径)
2. 直接音频(audio_url,前端未传 timings)→ 同步下载 + 算 timings + 提交 MediaKit
3. 预合成音频(audio_url + sentence_timings#1845 新主路径)→ 同步 ffprobe 校验时长 +
写入前端传来的 timings → 直接提交 MediaKit~2-3s
- 调用 MediaKit 客户端提交异步任务
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
- 用户隔离(每个用户只能操作自己的任务)
@@ -13,7 +17,7 @@ from __future__ import annotations
import io
import logging
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Optional
from urllib.parse import urlparse
@@ -26,19 +30,22 @@ from app.services.mediakit_client import (
get_mediakit_client,
)
# Celery 异步任务:TTS 合成 + MediaKit 提交(#lipsync-speed-optimization
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径
from app.tasks.lipsync_tts import tts_synthesize_and_submit
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError, normalize_emotion
from packages.application.cosyvoice_service import CosyVoiceError
from packages.domain.sentence_timings import (
compute_sentence_timings,
probe_audio_duration,
)
from packages.shared.storage import get_shared_storage_service
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
logger = logging.getLogger(__name__)
# 传给 MediaKit GPU worker / 回给前端播放的 OSS 预签名有效期:7 天。
# MediaKit 排队 + 拉取可能延迟,私有桶裸 URL 或 1 小时短预签名都会 403,故统一重签长有效期。
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
@@ -114,7 +121,8 @@ class LipsyncService:
text=script_text,
voice_id=actual_voice_id,
speed=speed,
emotion=normalize_emotion(emotion),
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
language="zh",
)
except CosyVoiceError as exc:
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
@@ -142,6 +150,100 @@ class LipsyncService:
logger.warning("TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
return temp_url
def _submit_audio_direct(
self,
*,
job: LipsyncJobModel,
supplied_timings: Optional[list] = None,
supplied_duration: Optional[float] = None,
) -> None:
"""音频直传模式(包含 #1845 预合成路径):同步下载 → ffprobe → timings → 提交 MediaKit.
直接在 HTTP 请求内完成,不走 Celery。job.status 成功后置为 submitted。
失败时把 job 标成 failed 并 commit,然后抛 MediaKitError。
Args:
job: 已 commit 的 LipsyncJobModelaudio_url / video_url 已写入)
supplied_timings: 前端传来的预合成 timings(可选,可信时直接用)
supplied_duration: 前端传来的预合成时长(可选,用于优先避免重复探测)
"""
# 1. 下载音频
audio_data: bytes | None = None
try:
audio_data = safe_download_bytes(
job.audio_url,
purpose="lipsync_direct_audio",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
logger.info(
"[lipsync] 直传音频下载完成: job_id=%s size=%d",
job.id,
len(audio_data) if audio_data else 0,
)
except Exception as exc:
logger.warning("[lipsync] 直传音频下载失败,跳过 timings 计算: job_id=%s err=%s", job.id, exc)
# 2. ffprobe 探测时长(优先用前端传入的预合成时长,但以 ffprobe 为准做兜底校验)
audio_duration = 0.0
if audio_data:
audio_duration = probe_audio_duration(audio_data)
if audio_duration <= 0 and supplied_duration and supplied_duration > 0:
audio_duration = supplied_duration
logger.info(
"[lipsync] ffprobe 失败,使用前端传入的预合成时长: job_id=%s duration=%.2f", job.id, audio_duration
)
# 3. 句子时间戳:优先用前端预合成传入的 timings(后端预合成接口已经算过,可信);
# 否则若音频下载成功则重算;否则不设置(不阻塞主流程)
timings: Optional[list] = None
if supplied_timings:
timings = supplied_timings
logger.info("[lipsync] 使用前端预合成句子时间戳: job_id=%s sentences=%d", job.id, len(timings))
elif audio_data and audio_duration > 0 and job.script_text:
try:
timings = compute_sentence_timings(audio_data, job.script_text, audio_duration)
logger.info(
"[lipsync] 后端重算句子时间戳: job_id=%s sentences=%d duration=%.2f",
job.id,
len(timings) if timings else 0,
audio_duration,
)
except Exception as exc:
logger.warning("[lipsync] 句子时间戳计算失败(不阻塞): job_id=%s err=%s", job.id, exc)
if timings:
job.sentence_timings = timings
# 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
try:
result = self.client.submit_lipsync(
video_url=video_url,
audio_url=signed_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)
self.db.commit()
logger.info(
"[lipsync] 直传音频已提交 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
logger.error("[lipsync] 直传音频提交 MediaKit 失败: job_id=%s err=%s", job.id, exc)
self.db.commit()
raise
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
@@ -150,27 +252,35 @@ class LipsyncService:
user_id: str,
video_url: str,
audio_url: str = "",
audio_duration: Optional[float] = None,
sentence_timings: Optional[list] = None,
voice_id: str = "",
script_text: str = "",
speed: float = 1.0,
emotion: str = "",
enable_video_loop: bool = False,
enable_video_loop: bool = True,
project_id: str = "",
) -> LipsyncJobModel:
"""创建对口型任务.
种输入模式:
种输入模式:
- TTS 直生:voice_id + script_textaudio_url 留空)
创建 DB 记录(状态 tts_processing),dispatch Celery 异步任务
执行 TTS 合成 + MediaKit 提交。API 响应 <1s。
- 直接音频:提供 audio_url
→ 同步提交 MediaKit,状态直接设为 submitted
→ 创建 DB 记录(状态 tts_processing),dispatch Celery 异步任务(降级路径)。
API 响应 <1s。
- 直接音频:audio_url 非空 + 无 sentence_timings
→ 同步下载音频 + 重算 timings + 提交 MediaKit(几秒完成)
- 预合成音频(#1845 新主路径):audio_url 非空 + 传 sentence_timings
→ 同步 ffprobe 校验时长 + 写入 timings + 提交 MediaKit~2-3s)。
Raises:
MediaKitError: 参数校验失败或 MediaKit 提交失败(仅直接音频模式)
MediaKitError: 参数校验失败或 MediaKit 提交失败
"""
# 0. 输入校验
if not audio_url:
is_pre_synth = bool(audio_url) and bool(sentence_timings)
bool(audio_url) and not is_pre_synth
is_tts_mode = not bool(audio_url)
if is_tts_mode:
if not (voice_id and script_text):
raise MediaKitError(
"必须提供 audio_url 或 voice_id+script_text",
@@ -178,10 +288,13 @@ class LipsyncService:
)
# TTS 模式:在 HTTP 请求中同步校验音色归属,快速失败
self._resolve_voice_id(voice_id, user_id)
elif is_pre_synth:
# 预合成模式:script_text 可空(因为 timings 已自带句子文本),但仍建议传
if not isinstance(sentence_timings, list) or len(sentence_timings) == 0:
raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput")
# 1. 创建数据库记录
job_id = str(uuid.uuid4())
is_tts_mode = not bool(audio_url)
job = LipsyncJobModel(
id=job_id,
user_id=user_id,
@@ -192,14 +305,19 @@ class LipsyncService:
voice_id=voice_id or "",
script_text=script_text or "",
speed=speed,
emotion=normalize_emotion(emotion),
emotion=emotion or "",
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
status="tts_processing" if is_tts_mode else "pending",
)
self.db.add(job)
self.db.flush()
# ⚠️ 必须先 commit 再发 Celery 任务 / 后续同步操作,避免事务竞态
self.db.commit()
self.db.refresh(job)
if is_tts_mode:
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交(降级路径)
try:
tts_synthesize_and_submit.apply_async(
args=(
@@ -208,12 +326,10 @@ class LipsyncService:
voice_id,
script_text,
speed,
normalize_emotion(emotion),
emotion or "",
)
)
except Exception as exc:
# 投递失败时立即把 job 标成 failed 并写入 error_message
# 前端轮询时能直接看到失败原因,不会无限卡在 tts_processing。
logger.exception(
"Celery 任务提交失败,TTS 任务已创建但未触发执行: job_id=%s err=%s",
job_id,
@@ -222,35 +338,104 @@ class LipsyncService:
job.status = "failed"
job.error_message = f"Celery 任务投递失败: {exc}"
job.error_code = "AsyncDispatchFailed"
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
self.db.commit()
else:
# 2b. 直接音频模式:同步签名并提交 MediaKit
video_url = self._sign_media_url(video_url)
if audio_url:
audio_url = self._sign_media_url(audio_url)
job.audio_url = audio_url
# 2b/2c. 直接音频 / 预合成音频:同步路径
self._submit_audio_direct(
job=job,
supplied_timings=sentence_timings,
supplied_duration=audio_duration,
)
self.db.refresh(job)
try:
result = self.client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
client_token=job_id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("提交对口型任务失败: %s", exc)
raise
self.db.commit()
self.db.refresh(job)
return job
# ── TTS 预合成(#1845 步骤1「生成配音」同步接口使用) ──────────────────
def preview_tts(
self,
*,
user_id: str,
voice_id: str,
script_text: str,
speed: float = 1.0,
emotion: str = "neutral",
) -> dict:
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL~24h 有效期)。
耗时约 2-3 秒,由前端在步骤1点「生成配音」时同步等待。
Returns:
{"audio_url": str, "duration": float, "sentence_timings": list[dict]}
Raises:
MediaKitError: TTS 合成失败 / 下载失败 / ffprobe 失败
"""
# 1. 音色解析(校验克隆音色归属)
actual_voice_id = self._resolve_voice_id(voice_id, user_id)
cosyvoice = self._get_cosyvoice()
# 2. TTS 合成(同步,~2-3s
try:
result = cosyvoice.submit_synthesize_task(
text=script_text,
voice_id=actual_voice_id,
speed=speed,
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
language="zh",
)
except CosyVoiceError as exc:
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
except ValueError as exc:
raise MediaKitError(f"TTS 参数错误: {exc}", code="TTSInvalidParam") from exc
temp_url = result.get("audio_url", "")
if not temp_url:
raise MediaKitError("TTS 未返回音频 URL", code="TTSNoAudio")
# 3. 下载音频到内存(用于 ffprobe + 静音检测)
try:
audio_data = safe_download_bytes(
temp_url,
purpose="tts_preview_audio",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
except Exception as exc:
logger.warning("[tts-preview] TTS 音频下载失败,仍返回 audio_url: user_id=%s err=%s", user_id, exc)
return {
"audio_url": temp_url,
"duration": 0.0,
"sentence_timings": [],
}
# 4. ffprobe 时长
duration = probe_audio_duration(audio_data)
if duration <= 0:
logger.warning("[tts-preview] ffprobe 未返回有效时长,timings 留空: user_id=%s", user_id)
return {
"audio_url": temp_url,
"duration": 0.0,
"sentence_timings": [],
}
# 5. 句子时间戳
timings = compute_sentence_timings(audio_data, script_text, duration)
logger.info(
"[tts-preview] TTS 预合成完成: user_id=%s duration=%.2f sentences=%d",
user_id,
duration,
len(timings),
)
return {
"audio_url": temp_url,
"duration": round(duration, 2),
"sentence_timings": timings,
}
# ── 查询任务 ──────────────────────────────────────────────────────────
def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
@@ -284,11 +469,7 @@ class LipsyncService:
# ── 更新任务状态(轮询) ──────────────────────────────────────────────
def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""从 MediaKit 拉取最新状态并更新本地记录.
Returns:
更新后的 Job,或 None(任务不存在/不属于该用户)
"""
"""从 MediaKit 拉取最新状态并更新本地记录."""
job = self.get_job(job_id, user_id)
if job is None:
return None
@@ -310,34 +491,57 @@ class LipsyncService:
mk_status = status_data.get("status", STATUS_RUNNING)
logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status)
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
output_url = result.get("video_url", "")
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL)
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
elif mk_status == STATUS_FAILED:
error = status_data.get("error", {})
job.status = "failed"
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(timezone.utc)
else:
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
if isinstance(mk_status, str) and mk_status:
job.status = mk_status
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
try:
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
temp_url = result.get("video_url", "")
job.output_video_url = temp_url
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
self.db.commit()
# 异步转存自家 OSS
try:
from app.tasks.lipsync_tts import persist_output_video_task
persist_output_video_task.apply_async(args=(job_id, user_id, temp_url))
except Exception as exc:
logger.warning(
"提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s",
job_id,
exc,
)
elif mk_status == STATUS_FAILED:
error = status_data.get("error", {})
job.status = "failed"
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(UTC)
else:
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
if isinstance(mk_status, str) and mk_status:
job.status = mk_status
job.updated_at = datetime.now(UTC)
self.db.commit()
except Exception as exc: # noqa: BLE001 - DB 提交失败必须记录日志并重试,否则后台任务静默失败
logger.error(
"refresh_job_status 提交 DB 失败 job_id=%s mk_status=%s err=%s",
job_id,
mk_status,
exc,
exc_info=True,
)
try:
self.db.rollback()
except Exception:
pass
# DB commit 失败不 raise,返回当前 job 对象让下次轮询再试
self.db.refresh(job)
return job
def _persist_output_video(self, temp_url: str, job_id: str, user_id: str) -> str:
"""将 MediaKit 输出的临时视频 URL 转存到自家 OSS.
失败时回退返回原始临时 URL,不影响任务完成。
"""
"""将 MediaKit 输出的临时视频 URL 转存到自家 OSS. 失败时回退返回原始临时 URL."""
if not temp_url:
return ""
try:
@@ -357,27 +561,21 @@ class LipsyncService:
return temp_url
def _sign_media_url(self, url: str) -> str:
"""对自家 OSS 私有桶 URL 重签长有效期预签名,供 MediaKit 拉取 / 前端播放。
- 裸 public_urlupload_file 返回,不带签名)→ 私有桶匿名访问 403,重签。
- 已带签名但即将过期的 URL(如前端 1h 预签名)→ 抽 storage_key 后重签。
- 外部 URLCosyVoice/MediaKit 临时链接,非本桶 host)→ 原样透传。
- 任何异常都降级原样返回,不阻断主流程。
"""
"""对自家 OSS 私有桶 URL 重签长有效期预签名."""
if not url:
return url
try:
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url # 无法判定归属,保守透传
return url
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url # 非自家 OSS外部临时链接),不处理
return url # 外部临时链接原样透传
signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
return signed or url
except Exception as exc: # noqa: BLE001 - 签名失败不阻断,降级原 URL
except Exception as exc:
logger.warning("对口型 URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
return url
@@ -391,7 +589,7 @@ class LipsyncService:
if job.status in ("pending", "tts_processing", "submitted"):
job.status = "cancelled"
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
self.db.commit()
self.db.refresh(job)
+2 -3
View File
@@ -75,7 +75,7 @@ class MediaKitClient:
*,
video_url: str,
audio_url: str,
enable_video_loop: bool = False,
enable_video_loop: bool = True,
callback_url: Optional[str] = None,
callback_args: Optional[str] = None,
client_token: Optional[str] = None,
@@ -103,8 +103,7 @@ class MediaKitClient:
"video_url": video_url,
"audio_url": audio_url,
}
if enable_video_loop:
payload["enable_video_loop"] = True
payload["enable_video_loop"] = bool(enable_video_loop)
if callback_url:
payload["callback_url"] = callback_url
if callback_args:
+13 -13
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import logging
import random
from typing import Any, List
from typing import Any
from sqlalchemy.orm import Session
@@ -62,8 +62,8 @@ class PlanGeneratorService:
def generate_from_template(
self,
template: EditTemplate,
clip_configs: List[TemplateClipConfig],
asset_ids: List[str],
clip_configs: list[TemplateClipConfig],
asset_ids: list[str],
*,
project_id: str = "",
created_by_user_id: str = "",
@@ -135,7 +135,7 @@ class PlanGeneratorService:
)
# 5. 持久化所有 clips 并计算总时长
created_clips: List[EditPlanClip] = []
created_clips: list[EditPlanClip] = []
total_duration = 0.0
for clip in clips:
saved = self._clip_repo.create(clip)
@@ -184,15 +184,15 @@ class PlanGeneratorService:
def _create_clips_from_configs(
self,
plan_id: str,
clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]:
clip_configs: list[TemplateClipConfig],
) -> list[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
"""
return create_clips_from_configs(plan_id, clip_configs)
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
def _map_clip_types_for_mode(self, clips: list[EditPlanClip], editing_mode: str) -> None:
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
@@ -204,7 +204,7 @@ class PlanGeneratorService:
plan_id: str,
editing_mode: str,
asset_count: int,
) -> List[EditPlanClip]:
) -> list[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
委托给 plan_generator_utils.generate_default_clips 纯函数。
@@ -213,8 +213,8 @@ class PlanGeneratorService:
def _distribute_assets(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
clips: list[EditPlanClip],
asset_ids: list[str],
editing_mode: str,
*,
random_selection: bool = False,
@@ -259,7 +259,7 @@ class PlanGeneratorService:
external_used_segments=external_used_segments,
)
def _fetch_asset_scene_points(self, asset_ids: List[str]) -> dict[str, list[float]]:
def _fetch_asset_scene_points(self, asset_ids: list[str]) -> dict[str, list[float]]:
"""从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。"""
points_map: dict[str, list[float]] = {}
if not self._asset_repo:
@@ -272,7 +272,7 @@ class PlanGeneratorService:
points_map[asset_id] = points
return points_map
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
def _sort_assets_by_smart_score(self, asset_ids: list[str]) -> list[str]:
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
@@ -295,7 +295,7 @@ class PlanGeneratorService:
)
return [aid for aid, _ in scored]
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
def _fetch_asset_durations(self, asset_ids: list[str]) -> dict[str, float]:
"""从数据库获取素材时长信息.
Args:
@@ -0,0 +1,59 @@
"""文案提取 ASR 服务封装 — Issue #1893.
将已有的 ASR 服务工厂封装为面向文案提取场景的简单接口:
- transcribe_to_text(video_path) -> str:将视频/音频转写为纯文本
- 未配置 ASR 时抛 ASRNotConfiguredError(路由层映射为 503
- ASR 调用失败时抛 ASRTranscriptionError(路由层映射为 502
"""
from __future__ import annotations
import logging
from pathlib import Path
from packages.ports.asr_service import ASRServiceError
logger = logging.getLogger(__name__)
class ASRNotConfiguredError(Exception):
"""ASR 服务未配置."""
class ASRTranscriptionError(Exception):
"""ASR 转写失败."""
def transcribe_to_text(media_path: str | Path) -> str:
"""将视频/音频文件转写为纯文本.
Args:
media_path: 媒体文件路径
Returns:
转写出的文本
Raises:
ASRNotConfiguredError: ASR 服务未配置
ASRTranscriptionError: ASR 调用失败
"""
# 延迟导入,避免循环依赖和启动时副作用
from apps.worker.services.asr_service_factory import get_asr_service
asr = get_asr_service()
if asr is None:
raise ASRNotConfiguredError("ASR 服务未配置,请联系管理员配置火山 MediaKit 或阿里云 ASR 密钥")
try:
timeline = asr.transcribe(Path(media_path))
# 拼接所有分段的文本
text = "".join(seg.text for seg in timeline.segments)
return text.strip()
except ASRNotConfiguredError:
raise
except ASRServiceError as exc:
logger.error("ASR 转写失败: %s", exc)
raise ASRTranscriptionError(f"语音识别失败: {exc}") from exc
except Exception as exc:
logger.error("ASR 转写异常: %s", exc)
raise ASRTranscriptionError(f"语音识别失败: {exc}") from exc
+2 -2
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.orm import Session
@@ -93,7 +93,7 @@ class ScriptService:
script.segments = segments
if tags is not None:
script.tags = tags
script.updated_at = datetime.now(timezone.utc)
script.updated_at = datetime.now(UTC)
self.db.commit()
self.db.refresh(script)
return script
+260 -21
View File
@@ -12,15 +12,25 @@
注意:使用 @shared_task 而非绑定到某个 celery_app 实例,
确保任务能被 Worker 侧 celery_app 正确注册,同时 API 侧 send_task/apply_async 仍可正常调用。
#1845:句子时间戳计算已提取至 packages/domain/sentence_timings.py,本模块保留
_ 开头别名兼容历史导入,但 _compute_sentence_timings/_split_script_into_sentences/
_estimate_sentence_timings_by_chars 等内部函数已复用共享实现,避免重复代码。
"""
import io
import logging
from datetime import datetime, timezone
from datetime import UTC, datetime
from urllib.parse import urlparse
from celery import shared_task
# 复用共享的句子时间戳工具(#1845 配音前置)
from packages.domain.sentence_timings import compute_sentence_timings as _compute_sentence_timings
from packages.domain.sentence_timings import (
probe_audio_duration,
)
logger = logging.getLogger(__name__)
# MediaKit 预签名 URL 有效期(7天,秒),与 LipsyncService._sign_media_url 保持一致
@@ -57,8 +67,13 @@ def _sign_media_url(url: str) -> str:
@shared_task(
bind=True,
name="lipsync_tts.synthesize_and_submit",
max_retries=2,
max_retries=5, # 事务竞态重试3次(job not found+ TTS偶发错误2次
default_retry_delay=30,
autoretry_for=(OSError, ConnectionError), # 网络/连接错误自动重试
retry_backoff=True,
retry_backoff_max=30,
soft_time_limit=180,
time_limit=200,
)
def tts_synthesize_and_submit(
self,
@@ -71,7 +86,8 @@ def tts_synthesize_and_submit(
):
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
在 Celery worker 中运行,不阻塞 HTTP 请求。
在 Celery worker 中运行,不阻塞 HTTP 请求。保留作为降级路径
(预合成失败 / 旧版前端未传 audio_url 时走此路径)。
"""
from app.services.mediakit_client import MediaKitError, get_mediakit_client
from sqlalchemy.orm import Session as DBSession
@@ -103,7 +119,25 @@ def tts_synthesize_and_submit(
)
if job is None:
logger.error("[lipsync_tts] Job not found: job_id=%s", job_id)
# 事务竞态防御:API 在 commit 前投递了任务,worker 消费时事务尚未提交。
retries = getattr(self.request, "retries", 0)
max_retries = 3
if retries < max_retries:
backoff = (2**retries) + (retries * 1) # 1s, 3s, 7s
logger.warning(
"[lipsync_tts] Job not found yet (retry %d/%d, backoff %ds): job_id=%s",
retries + 1,
max_retries,
backoff,
job_id,
)
self.db.close()
raise self.retry(countdown=backoff, max_retries=max_retries)
logger.error(
"[lipsync_tts] Job not found after %d retries, giving up: job_id=%s",
max_retries,
job_id,
)
return
# 已取消的任务不再处理
@@ -112,6 +146,13 @@ def tts_synthesize_and_submit(
return
# 1. TTS 合成
logger.info(
"[lipsync_tts] 开始 TTS 合成: job_id=%s voice_id=%s text_len=%d speed=%.2f",
job_id,
voice_id,
len(script_text),
speed,
)
try:
cosyvoice = CosyVoiceService()
result = cosyvoice.submit_synthesize_task(
@@ -119,13 +160,14 @@ def tts_synthesize_and_submit(
voice_id=voice_id,
speed=speed,
emotion=emotion,
language="zh",
)
except CosyVoiceError as exc:
logger.error("[lipsync_tts] TTS 合成失败: job_id=%s err=%s", job_id, exc)
job.status = "failed"
job.error_message = f"TTS 合成失败: {exc}"
job.error_code = "TTSSynthesisFailed"
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
db.commit()
return
except ValueError as exc:
@@ -133,7 +175,7 @@ def tts_synthesize_and_submit(
job.status = "failed"
job.error_message = f"TTS 参数错误: {exc}"
job.error_code = "TTSInvalidParam"
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
db.commit()
return
@@ -143,41 +185,78 @@ def tts_synthesize_and_submit(
job.status = "failed"
job.error_message = "TTS 未返回音频 URL"
job.error_code = "TTSNoAudio"
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
db.commit()
return
# 2. 下载转存自家 OSS
# 2. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL
audio_data: bytes | None = None
try:
audio_data = safe_download_bytes(
temp_url,
purpose="lipsync_tts_audio",
allowed_mime_types=(
allowed_mime_types={
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav
"audio/mp4",
"audio/x-m4a",
),
},
timeout=60.0,
)
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
job.audio_url = permanent_url
logger.info(
"[lipsync_tts] TTS 音频已下载到内存: job_id=%s size=%d",
job_id,
len(audio_data) if audio_data else 0,
)
except Exception as exc:
logger.warning(
"[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s",
"[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 URL 提交: job_id=%s err=%s",
job_id,
exc,
)
job.audio_url = temp_url
# TTS 音频使用 CosyVoice 临时 URL,跳过自家 OSS 转存(加速,步骤⑥)
job.audio_url = temp_url
logger.info("[lipsync_tts] TTS 音频使用 CosyVoice 临时 URL(跳过 OSS 转存): job_id=%s", job_id)
db.commit()
# 2.5 计算精确句子时间戳(基于 TTS 音频静音检测)—— 复用共享工具
try:
if not audio_data:
logger.warning("[lipsync_tts] 无音频数据,跳过句子时间戳计算: job_id=%s", job_id)
else:
_audio_duration = probe_audio_duration(audio_data)
logger.info(
"[lipsync_tts] 音频时长探测: job_id=%s duration=%.2f",
job_id,
_audio_duration,
)
if _audio_duration > 0:
_timings = _compute_sentence_timings(audio_data, script_text, _audio_duration)
if _timings:
job.sentence_timings = _timings
logger.info(
"[lipsync_tts] 句子时间戳已计算: job_id=%s sentences=%d duration=%.1f",
job_id,
len(_timings),
_audio_duration,
)
else:
logger.warning("[lipsync_tts] 句子时间戳计算返回空结果: job_id=%s", job_id)
else:
logger.warning(
"[lipsync_tts] ffprobe 未获取到有效时长,跳过句子时间戳: job_id=%s",
job_id,
)
db.commit()
except Exception as _st_err:
logger.warning(
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
)
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
audio_url = _sign_media_url(job.audio_url)
video_url = _sign_media_url(job.video_url)
@@ -192,7 +271,7 @@ def tts_synthesize_and_submit(
)
job.mediakit_task_id = mk_result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
job.submitted_at = datetime.now(UTC)
logger.info(
"[lipsync_tts] 已提交 MediaKit: job_id=%s task_id=%s",
job_id,
@@ -204,6 +283,18 @@ def tts_synthesize_and_submit(
job.error_code = exc.code
logger.error("[lipsync_tts] 提交 MediaKit 失败: job_id=%s err=%s", job_id, exc)
# 三层防御 ③:链式触发 Celery 兜底轮询——MediaKit 提交成功后由 worker
# 主动拉取状态到终态,不依赖前端轮询触发的 FastAPI background task
# background task 可能静默失败导致永久卡 running)。
if job.status == "submitted" and job.mediakit_task_id:
try:
poll_mediakit_status.apply_async(
kwargs={"job_id": job_id, "user_id": user_id},
countdown=10, # 10 秒后开始轮询,给 MediaKit 一点处理时间
)
except Exception as exc: # noqa: BLE001
logger.warning("[lipsync_tts] 提交兜底轮询任务失败(不影响主流程): job_id=%s err=%s", job_id, exc)
db.commit()
except Exception:
@@ -214,9 +305,157 @@ def tts_synthesize_and_submit(
job.status = "failed"
job.error_message = "TTS 异步任务执行异常"
job.error_code = "AsyncTaskError"
job.updated_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
finally:
db.close()
@shared_task(
bind=True,
name="lipsync_tts.poll_mediakit_status",
max_retries=60, # 最多轮询 60 次
default_retry_delay=10, # 每次间隔 10 秒(总兜底时长 10 分钟)
)
def poll_mediakit_status(self, job_id: str, user_id: str):
"""Celery 兜底轮询:TTS 提交 MediaKit 后,由 worker 主动拉取状态直到终态。
不依赖前端轮询,避免 background task 静默失败导致任务永久卡 running/submitted。
"""
from sqlalchemy.orm import Session as DBSession
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
try:
from worker_app.db import SessionLocal # type: ignore
except Exception: # noqa: BLE001
from app.db import SessionLocal # type: ignore
db: DBSession = SessionLocal()
try:
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
if job is None:
logger.warning("[lipsync_poll] Job not found: job_id=%s", job_id)
return
# 已终态,不需要再轮询
if job.status in ("completed", "failed", "cancelled"):
return
if not job.mediakit_task_id:
logger.warning("[lipsync_poll] Job has no mediakit_task_id: job_id=%s status=%s", job_id, job.status)
return
from app.services.lipsync_service import STATUS_COMPLETED as _SC
from app.services.lipsync_service import STATUS_FAILED as _SF
from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError, get_mediakit_client
client = get_mediakit_client()
try:
status_data = client.get_task_status(job.mediakit_task_id)
except MediaKitError as exc:
logger.warning("[lipsync_poll] 拉取 MediaKit 状态失败,将重试: job_id=%s err=%s", job_id, exc)
raise self.retry(exc=exc) from exc
mk_status = status_data.get("status", "running")
if mk_status in ("succeeded", _SC):
svc = LipsyncService(db)
result = status_data.get("result", {})
job.status = "completed"
output_url = result.get("video_url", "")
try:
job.output_video_url = svc._persist_output_video(output_url, job_id, user_id)
except Exception as exc: # noqa: BLE001
logger.warning("[lipsync_poll] 转存 OSS 失败,保留临时 URL: job_id=%s err=%s", job_id, exc)
job.output_video_url = output_url
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[lipsync_poll] 任务完成: job_id=%s", job_id)
elif mk_status in ("failed", "error", _SF):
error = status_data.get("error", {})
job.status = "failed"
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[lipsync_poll] 任务失败: job_id=%s err=%s", job_id, job.error_message)
else:
# 中间状态,更新时间戳,继续重试
job.updated_at = datetime.now(UTC)
if isinstance(mk_status, str) and mk_status:
job.status = mk_status
db.commit()
logger.debug("[lipsync_poll] 任务仍在 %s,继续轮询: job_id=%s", mk_status, job_id)
raise self.retry()
except Exception as exc:
logger.exception("[lipsync_poll] 未预期异常: job_id=%s", job_id)
try:
db.rollback()
except Exception:
pass
raise self.retry(exc=exc) from exc
finally:
db.close()
@shared_task(
name="lipsync_tts.persist_output_video",
max_retries=2,
default_retry_delay=30,
)
def persist_output_video_task(job_id: str, user_id: str, temp_url: str):
"""异步转存对口型输出视频到自家 OSS(步骤⑦ — 将同步阻塞挪到后台,加速前端响应)."""
try:
from worker_app.db import SessionLocal # type: ignore
except Exception: # noqa: BLE001
from app.db import SessionLocal # type: ignore
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.shared.storage import get_shared_storage_service
db = SessionLocal()
try:
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
if job is None:
logger.error("[lipsync_tts.persist] Job not found: job_id=%s", job_id)
return
if not temp_url:
logger.warning("[lipsync_tts.persist] temp_url 为空,跳过转存: job_id=%s", job_id)
return
try:
import httpx
with httpx.Client(timeout=180.0, follow_redirects=True) as client:
resp = client.get(temp_url)
resp.raise_for_status()
data = resp.content
storage = get_shared_storage_service()
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
final_url = _sign_media_url(permanent_url) if permanent_url else temp_url
job.output_video_url = final_url
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
except Exception as exc:
logger.warning(
"[lipsync_tts.persist] 输出视频转存失败,保留临时 URL: job_id=%s err=%s",
job_id,
exc,
)
except Exception:
logger.exception("[lipsync_tts.persist] 未预期异常: job_id=%s", job_id)
finally:
db.close()
+11 -23
View File
@@ -40,7 +40,6 @@ async function loginWithRetry(
type ProjectResponse = { id: string }
type LibraryResponse = { id: string }
type TemplateResponse = { id: string }
type AssetListResponse = {
items: Array<{
id: string
@@ -126,28 +125,17 @@ test.describe("Core generation flow", () => {
)
.toBe("ready")
// Create an editing template so the generate page has at least one template
// (templates are now loaded from API; new users have none by default)
const template = await request.post(`${apiBase}/templates`, {
headers,
data: {
name: `E2E 测试模板 ${suffix}`,
mode: "pip",
estimated_duration: 30,
segments: [
{
segment_order: 1,
duration_min: 5,
duration_max: 30,
material_type: "video",
},
],
tags: ["e2e"],
},
})
expect(template.status(), await template.text()).toBe(201)
const templateData = (await template.json()) as TemplateResponse
expect(templateData.id).toBeTruthy()
// #1926 P0 fix: POST /templates CRUD endpoint removed; GET /templates
// now auto-creates a default template for new users. Use the first one.
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(
@@ -26,6 +26,10 @@ export interface BatchVariantPlansRequest {
count: number
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
source_edit_plan_id?: string
/** 统一配音 ID(共用配音模式);独立配音模式不传,改传 voice_library_ids */
voice_library_id?: string
/** 独立配音 ID 列表(长度=count,按变体序号一一对应);共用配音模式不传 */
voice_library_ids?: string[]
}
/** 单个变体的计划片段 */
@@ -36,6 +40,8 @@ export interface VariantPlan {
plan_id: string
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
clips: EditPlanClip[]
/** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */
voice_duration?: number
}
/** 批量变体计划响应 */
+477
View File
@@ -0,0 +1,477 @@
/**
* 积分 & 会员 API 封装(v2 两档会员制)
* 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。
*/
import apiClient from "../client"
import type {
PointsBalance,
PointsTransaction,
PointsTransactionsParams,
PointsTransactionsResponse,
PointsPackagesResponse,
PointsRechargeRequest,
PointsOrder,
PointsRulesResponse,
PointsCheckRequest,
PointsCheckResponse,
SubscriptionCurrent,
SubscribeRequest,
SubscriptionPlan,
} from "./types"
/**
* 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false)
*/
export const POINTS_API_MOCK = true
// ==================== Mock 数据 ====================
const MOCK_BALANCE: PointsBalance = {
balance: 580,
total_earned: 1200,
total_spent: 620,
is_member: false,
member_type: null,
member_expires_at: null,
free_clips_used: 1,
free_clips_limit: 2,
free_clips_remaining: 1,
}
const MOCK_PACKAGES: PointsPackagesResponse = {
packages: [
{
id: "starter_pack",
name: "体验包",
points: 100,
price: 990,
discounted_price_for_free: 990,
discounted_price_for_monthly: 891,
discounted_price_for_quarterly: 861,
discounted_price_for_yearly: 792,
},
{
id: "basic_pack",
name: "基础包",
points: 500,
price: 3900,
discounted_price_for_free: 3900,
discounted_price_for_monthly: 3510,
discounted_price_for_quarterly: 3393,
discounted_price_for_yearly: 3120,
},
{
id: "pro_pack",
name: "专业包",
points: 2000,
price: 12900,
discounted_price_for_free: 12900,
discounted_price_for_monthly: 11610,
discounted_price_for_quarterly: 11223,
discounted_price_for_yearly: 10320,
},
],
user_member_type: "free",
unit_price_yuan: 0.1,
}
const MOCK_RULES: PointsRulesResponse = {
rules: [
{
scene_key: "ai_voice",
scene_name: "AI 配音",
points_per_use: 1,
unit: "分钟",
description: "每生成 1 分钟配音",
},
{
scene_key: "ai_video",
scene_name: "智能混剪",
points_per_use: 3,
unit: "条",
extra_per_30s: 1,
description: "每条 ≤30s 3 积分,每加 30s +1",
},
{
scene_key: "ai_digital_human",
scene_name: "AI 数字人",
points_per_use: 15,
unit: "分钟",
description: "每生成 1 分钟口播",
},
{
scene_key: "voice_clone_train",
scene_name: "声音克隆训练",
points_per_use: 0,
unit: "次",
description: "训练免费",
},
{ scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" },
{ scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" },
{ scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" },
{ scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" },
{ scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" },
],
free_user_multiplier: 1.15,
note: "免费用户消耗 = 会员消耗 × 1.15,向上取整",
}
function genMockTransactions(): PointsTransactionsResponse {
const now = new Date()
const list = [
{
src: "ai_voice",
name: "AI 配音",
type: "spend" as const,
amt: 1,
desc: "生成配音 1 分钟",
days: 0,
hours: 0,
mins: 30,
},
{
src: "ai_video",
name: "智能混剪",
type: "spend" as const,
amt: 5,
desc: "生成 1 分钟视频(基础3+30s*2",
days: 0,
hours: 1,
mins: 15,
},
{
src: "task_reward",
name: "任务奖励",
type: "earn" as const,
amt: 20,
desc: "首次生成视频奖励",
days: 1,
hours: 0,
mins: 0,
},
{
src: "recharge",
name: "充值",
type: "earn" as const,
amt: 500,
desc: "基础包充值",
days: 15,
hours: 0,
mins: 0,
},
{
src: "ai_rewrite",
name: "AI 改写文案",
type: "spend" as const,
amt: 2,
desc: "免费用户价(1×1.15 向上取整)",
days: 16,
hours: 2,
mins: 10,
},
{
src: "ai_title",
name: "AI 标题生成",
type: "spend" as const,
amt: 2,
desc: "免费用户价",
days: 16,
hours: 3,
mins: 0,
},
{
src: "douyin_extract",
name: "抖音链接提取",
type: "spend" as const,
amt: 2,
desc: "提取 3 分钟文案",
days: 18,
hours: 0,
mins: 0,
},
{
src: "ai_digital_human",
name: "AI 数字人",
type: "spend" as const,
amt: 18,
desc: "数字人口播 1 分钟(免费用户价)",
days: 20,
hours: 0,
mins: 0,
},
{
src: "task_reward",
name: "任务奖励",
type: "earn" as const,
amt: 50,
desc: "注册赠送",
days: 30,
hours: 0,
mins: 0,
},
]
let bal = MOCK_BALANCE.balance
const items = list
.map((t, i) => {
const signed = t.type === "earn" ? t.amt : -t.amt
const balance_after = bal // 按时间倒序:earliest 先算
// adjust running bal
bal = t.type === "earn" ? bal - t.amt : bal + t.amt
const d = new Date(now)
d.setDate(d.getDate() - t.days)
d.setHours(d.getHours() - t.hours)
d.setMinutes(d.getMinutes() - t.mins)
return {
id: `tx_${i + 1}`,
type: t.type,
source: t.src as PointsBalance extends never ? never : string,
source_name: t.name,
amount: t.amt,
signed_amount: signed,
balance_after,
description: t.desc,
ref_id: null,
created_at: d.toISOString(),
}
})
.reverse()
// Rebuild balance_after going forward
let running = 50 + 0 // after registration gift
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i] as PointsTransaction & { balance_after?: number }
if (it.source === "task_reward" && it.description.includes("注册")) running = 50
}
running = 50
const fwd = [...items].reverse() as Array<PointsTransaction & { balance_after?: number }>
for (const it of fwd) {
running += it.signed_amount
it.balance_after = running
}
return { items: fwd, total: fwd.length, page: 1, page_size: 20 } as PointsTransactionsResponse
}
// ==================== 真实 API ====================
/** 查询积分余额 + 会员状态 */
export async function getPointsBalance(): Promise<PointsBalance> {
if (POINTS_API_MOCK) {
return new Promise((r) => setTimeout(() => r({ ...MOCK_BALANCE }), 180))
}
const res = await apiClient.get("/points/balance")
return res.data
}
/** 查询积分流水(分页) */
export async function getPointsTransactions(
params: PointsTransactionsParams = {},
): Promise<PointsTransactionsResponse> {
if (POINTS_API_MOCK) {
return new Promise((r) => setTimeout(() => r(genMockTransactions()), 200))
}
const res = await apiClient.get("/points/transactions", { params })
return res.data
}
/** 查询积分包列表 */
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
if (POINTS_API_MOCK) {
return new Promise((r) => setTimeout(() => r({ ...MOCK_PACKAGES }), 150))
}
const res = await apiClient.get("/points/packages")
return res.data
}
/** 创建积分充值订单(mock 阶段返回 "pending" 订单,前端弹"支付开发中" */
export async function createPointsOrder(req: PointsRechargeRequest): Promise<PointsOrder> {
if (POINTS_API_MOCK) {
const pkg = MOCK_PACKAGES.packages.find((p) => p.id === req.package_id)
const mt = MOCK_PACKAGES.user_member_type
type DiscountKey =
| "discounted_price_for_free"
| "discounted_price_for_monthly"
| "discounted_price_for_quarterly"
| "discounted_price_for_yearly"
const discountKey = `discounted_price_for_${mt}` as DiscountKey
const price = pkg?.[discountKey] ?? pkg?.price ?? 0
return new Promise((r) =>
setTimeout(
() =>
r({
id: `mock_order_${Date.now()}`,
package_id: req.package_id,
package_name: pkg?.name ?? "",
points_amount: pkg?.points ?? 0,
price_cents: price,
original_price_cents: pkg?.price ?? 0,
discount: price / (pkg?.price || 1),
currency: "CNY",
status: "pending",
payment_method: null,
payment_id: null,
paid_at: null,
expire_at: null,
created_at: new Date().toISOString(),
}),
300,
),
)
}
const res = await apiClient.post("/points/recharge", req)
return res.data
}
/** 查询积分消耗规则 */
export async function getPointsRules(): Promise<PointsRulesResponse> {
if (POINTS_API_MOCK) {
return new Promise((r) => setTimeout(() => r({ ...MOCK_RULES }), 120))
}
const res = await apiClient.get("/points/rules")
return res.data
}
/** 消费前余额预检查 */
export async function checkPoints(req: PointsCheckRequest): Promise<PointsCheckResponse> {
if (POINTS_API_MOCK) {
const rule = MOCK_RULES.rules.find((r) => r.scene_key === req.scene_key)
if (!rule) {
return {
allowed: false,
required_points: 0,
current_balance: MOCK_BALANCE.balance,
remaining_after: MOCK_BALANCE.balance,
is_free_quota: false,
code: "SCENE_NOT_FOUND",
message: "未知场景",
recharge_url: "/app/points",
}
}
const units = req.units ?? 1
let base = rule.points_per_use * units
if (rule.extra_per_30s && units > 1) {
// ai_video extra_per_30s: base already covers first 30s, subtract
base = rule.points_per_use + rule.extra_per_30s * (units - 1)
}
const isFree =
MOCK_BALANCE.is_member === false &&
req.scene_key === "ai_video" &&
(MOCK_BALANCE.free_clips_remaining ?? 0) > 0
const needed = isFree
? 0
: MOCK_BALANCE.is_member
? base
: Math.ceil(base * MOCK_RULES.free_user_multiplier)
const allowed = isFree || MOCK_BALANCE.balance >= needed
return {
allowed,
required_points: needed,
current_balance: MOCK_BALANCE.balance,
remaining_after: MOCK_BALANCE.balance - needed,
is_free_quota: isFree,
code: allowed ? undefined : "INSUFFICIENT_POINTS",
message: allowed
? undefined
: `积分不足,需要 ${needed} 积分,当前余额 ${MOCK_BALANCE.balance}`,
recharge_url: "/app/points",
}
}
const res = await apiClient.post("/points/check", req)
return res.data
}
// ==================== 订阅相关 ====================
/** 订阅套餐(定价常量,前端硬编码;折扣由后端会员类型决定) */
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
{
id: "monthly",
name: "月卡",
price_cents: 1990,
price_yuan: 19.9,
per_month_yuan: 19.9,
billing_label: "/月",
},
{
id: "quarterly",
name: "季卡",
price_cents: 3990,
price_yuan: 39.9,
per_month_yuan: 13.3,
savings_percent: 33,
recommended: true,
billing_label: "/季",
},
{
id: "yearly",
name: "年卡",
price_cents: 15900,
price_yuan: 159,
per_month_yuan: 13.25,
savings_percent: 33,
billing_label: "/年",
},
]
/** 查询当前订阅 */
export async function getCurrentSubscription(): Promise<SubscriptionCurrent> {
if (POINTS_API_MOCK) {
return new Promise((r) =>
setTimeout(
() =>
r({
is_member: MOCK_BALANCE.is_member,
member_type: MOCK_BALANCE.member_type,
member_type_name: MOCK_BALANCE.is_member ? "付费会员" : "免费会员",
status: MOCK_BALANCE.is_member ? "active" : "none",
current_period_start: null,
current_period_end: MOCK_BALANCE.member_expires_at,
auto_renew: false,
points_discount: MOCK_BALANCE.is_member ? 0.9 : 1.0,
}),
150,
),
)
}
const res = await apiClient.get("/subscription/current")
return res.data
}
/** 开通/续费会员 */
export async function subscribe(req: SubscribeRequest): Promise<PointsOrder> {
if (POINTS_API_MOCK) {
const plan = SUBSCRIPTION_PLANS.find((p) => p.id === req.member_type)!
return new Promise((r) =>
setTimeout(
() =>
r({
id: `mock_sub_${Date.now()}`,
package_id: plan.id,
package_name: plan.name,
points_amount: 0,
price_cents: plan.price_cents,
original_price_cents: plan.price_cents,
discount: 1,
currency: "CNY",
status: "pending",
payment_method: null,
payment_id: null,
paid_at: null,
expire_at: null,
created_at: new Date().toISOString(),
}),
300,
),
)
}
const res = await apiClient.post("/subscription/subscribe", req)
return res.data
}
/** 取消自动续费 */
export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> {
if (POINTS_API_MOCK) {
return new Promise((r) =>
setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200),
)
}
const res = await apiClient.post("/subscription/cancel")
return res.data
}
+217
View File
@@ -0,0 +1,217 @@
/**
* 积分 & 会员系统 API 类型定义(v2 两档会员制)
* 接口契约对齐后端设计文档 membership-points-design-v2.md
*/
/** 会员类型 */
export type MemberType = "free" | "monthly" | "quarterly" | "yearly"
/** 积分流水类型 */
export type PointsTxType = "earn" | "spend" | "refund"
/** 积分来源/消耗场景 */
export type PointsSource =
| "recharge" // 充值
| "task_reward" // 任务奖励
| "ai_voice" // AI 配音
| "ai_digital_human" // AI 数字人
| "ai_video" // 智能混剪
| "voice_clone_train" // 声音克隆训练
| "voice_clone_synth" // 声音克隆合成
| "douyin_extract" // 抖音链接提取
| "ai_rewrite" // AI 改写文案
| "ai_title" // AI 标题生成
| "ai_cover" // AI 封面生成
| "subscription_bonus" // 会员赠送
| "admin_adjust" // 管理员调整
| "refund" // 失败退还
/** 会员 & 积分余额响应 */
export interface PointsBalance {
/** 当前可用积分 */
balance: number
/** 累计获得 */
total_earned: number
/** 累计消耗 */
total_spent: number
/** 是否付费会员(free 用户为 false */
is_member: boolean
/** 会员类型:monthly / quarterly / yearlyfree 用户为 null */
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
/** 会员到期时间 ISO 字符串 */
member_expires_at: string | null
/** 今日免费混剪已用次数 */
free_clips_used?: number
/** 今日免费混剪额度上限 */
free_clips_limit?: number
/** 今日免费混剪剩余 */
free_clips_remaining?: number
}
/** 积分流水记录 */
export interface PointsTransaction {
id: string
/** earn / spend / refund */
type: PointsTxType
/** 来源场景 */
source: PointsSource
/** 场景中文名称 */
source_name: string
/** 变动数量(正数) */
amount: number
/** 带符号的变动数(收入+,支出-) */
signed_amount: number
/** 变动后余额 */
balance_after: number
/** 备注描述 */
description: string
/** 关联业务 ID */
ref_id: string | null
created_at: string
}
/** 积分流水分页响应 */
export interface PointsTransactionsResponse {
items: PointsTransaction[]
total: number
page: number
page_size: number
}
/** 积分流水查询参数 */
export interface PointsTransactionsParams {
page?: number
page_size?: number
type?: PointsTxType
source?: PointsSource
start_date?: string
end_date?: string
}
/** 积分包 */
export interface PointsPackage {
id: "starter_pack" | "basic_pack" | "pro_pack" | string
/** 中文名称 */
name: string
/** 积分数量 */
points: number
/** 原价(分) */
price: number
/** 各会员类型折扣价(分) */
discounted_price_for_free: number
discounted_price_for_monthly: number
discounted_price_for_quarterly: number
discounted_price_for_yearly: number
}
/** 积分包列表响应 */
export interface PointsPackagesResponse {
packages: PointsPackage[]
/** 当前用户会员类型,用于前端计算折后价 */
user_member_type: MemberType
/** 积分单价(元/积分,按会员价计) */
unit_price_yuan: number
}
/** 创建充值订单请求 */
export interface PointsRechargeRequest {
package_id: string
payment_method?: "wechat_pay" | "alipay"
}
/** 订单状态 */
export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired"
/** 充值订单响应 */
export interface PointsOrder {
id: string
package_id: string
package_name: string
points_amount: number
price_cents: number
original_price_cents: number
discount: number
currency: "CNY"
status: OrderStatus
payment_method: string | null
payment_id: string | null
paid_at: string | null
expire_at: string | null
created_at: string
/** 微信/支付宝支付参数(mock 阶段前端自行处理) */
pay_params?: Record<string, string>
}
/** 订阅套餐(月/季/年) */
export interface SubscriptionPlan {
id: "monthly" | "quarterly" | "yearly"
name: string
price_cents: number
price_yuan: number
per_month_yuan: number
savings_percent?: number
recommended?: boolean
billing_label: string
}
/** 当前订阅详情 */
export interface SubscriptionCurrent {
is_member: boolean
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
member_type_name: string
status: "active" | "expired" | "cancelled" | "none"
current_period_start: string | null
current_period_end: string | null
auto_renew: boolean
/** 订阅会员对应的积分折扣 */
points_discount: number
}
/** 开通/续费订阅请求 */
export interface SubscribeRequest {
member_type: "monthly" | "quarterly" | "yearly"
payment_method?: "wechat_pay" | "alipay"
}
/** 积分消耗规则 */
export interface PointsRule {
scene_key: PointsSource
scene_name: string
/** 每次消耗基础积分(会员价) */
points_per_use: number
/** 计量单位:条/分钟/次/张 */
unit: string
/** 额外每 30s 加积分(ai_video 用) */
extra_per_30s?: number
/** 说明文案 */
description?: string
}
export interface PointsRulesResponse {
rules: PointsRule[]
/** 免费用户消耗倍率 */
free_user_multiplier: number
note: string
}
/** 消费前余额检查请求 */
export interface PointsCheckRequest {
scene_key: PointsSource
/** 单位数量(时长/条数),默认 1 */
units?: number
}
/** 消费前余额检查响应 */
export interface PointsCheckResponse {
allowed: boolean
required_points: number
current_balance: number
remaining_after: number
/** 是否走免费额度(混剪场景) */
is_free_quota: boolean
/** 拒绝原因代码 */
code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND"
message?: string
/** 充值页跳转 URL */
recharge_url?: string
}
+34 -11
View File
@@ -1,6 +1,6 @@
/**
* 成品 / 视频相关 API 函数
* 后端实际接口:/videos
* 后端实际接口:/videos(分页:page/page_size,返回 {items, total, page, page_size}
*/
import apiClient from "../client"
import type {
@@ -12,16 +12,39 @@ import type {
} from "./types"
import { mapVideoToProductItem } from "./utils"
/** 获取成品列表(支持分页和筛选 */
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
const response = await apiClient.get("/videos", { params })
const data = response.data
const videos: VideoItem[] = Array.isArray(data?.items)
? data.items
: Array.isArray(data)
? data
: []
return videos.map(mapVideoToProductItem)
/** 分页列表响应(前端消费用 */
export interface ProductListResult {
items: ProductItem[]
total: number
page: number
page_size: number
}
/**
* 获取成品列表(分页)
* @param params 分页与筛选参数:page 默认 1page_size 默认 20
*/
export const getProducts = async (params?: ProductListParams): Promise<ProductListResult> => {
const response = await apiClient.get("/videos", {
params: {
page: 1,
page_size: 20,
...params,
},
})
const data = response.data as {
items?: VideoItem[]
total?: number
page?: number
page_size?: number
}
const items: VideoItem[] = Array.isArray(data?.items) ? data.items : []
return {
items: items.map(mapVideoToProductItem),
total: data.total ?? items.length,
page: data.page ?? params?.page ?? 1,
page_size: data.page_size ?? params?.page_size ?? 20,
}
}
/** 获取单个成品详情 */
+1
View File
@@ -1,2 +1,3 @@
export * from "./scripts"
export * from "./types"
export * from "./scripts-ai"
+87
View File
@@ -0,0 +1,87 @@
/**
* 文案库 AI 能力 API#1893
* 三个端点均走真实后端,不参与 SCRIPTS_API_MOCK 开关。
*/
import apiClient from "../client"
/** ── 1. 从抖音视频提取文案(下载 + ASR) */
export interface ExtractFromDouyinRequest {
url: string
}
export interface ExtractFromDouyinResponse {
text: string
duration_seconds?: number
source_url?: string
}
export async function extractScriptFromDouyin(
body: ExtractFromDouyinRequest,
opts?: { signal?: AbortSignal },
): Promise<ExtractFromDouyinResponse> {
const res = await apiClient.post<ExtractFromDouyinResponse>(
"/scripts/extract-from-douyin",
body,
{
// ASR 可能较慢,给足超时
timeout: 60_000,
signal: opts?.signal,
},
)
return res.data
}
/** ── 2. AI 改写文案 */
export type RewriteStyle = "口语化" | "正式" | "活泼" | "治愈" | "励志"
export const REWRITE_STYLE_OPTIONS: { value: RewriteStyle; label: string }[] = [
{ value: "口语化", label: "口语化" },
{ value: "正式", label: "正式" },
{ value: "活泼", label: "活泼" },
{ value: "治愈", label: "治愈" },
{ value: "励志", label: "励志" },
]
export interface AiRewriteRequest {
content: string
style?: RewriteStyle
}
export interface AiRewriteResponse {
original: string
rewritten: string
style: RewriteStyle
}
export async function aiRewriteScript(
body: AiRewriteRequest,
opts?: { signal?: AbortSignal },
): Promise<AiRewriteResponse> {
const res = await apiClient.post<AiRewriteResponse>("/scripts/ai-rewrite", body, {
timeout: 60_000,
signal: opts?.signal,
})
return res.data
}
/** ── 3. AI 生成标题 */
export interface AiGenerateTitlesRequest {
content: string
count?: number
}
export interface AiGenerateTitlesResponse {
titles: string[]
}
export async function aiGenerateTitles(
body: AiGenerateTitlesRequest,
opts?: { signal?: AbortSignal },
): Promise<AiGenerateTitlesResponse> {
const res = await apiClient.post<AiGenerateTitlesResponse>(
"/scripts/ai-generate-titles",
{ content: body.content, count: body.count ?? 3 },
{
timeout: 30_000,
signal: opts?.signal,
},
)
return res.data
}
+176 -18
View File
@@ -1,37 +1,195 @@
/**
* 文案库 API
* 对接后端 /api/v1/scriptsCRUD + 列表解包
* 文案库 API#1811 v2
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true
*/
import apiClient from "../client"
import type {
ScriptItem,
ScriptListParams,
ScriptListResponse,
CreateScriptRequest,
UpdateScriptRequest,
ScriptUpsertRequest,
ScriptCategory,
} from "./types"
/** 获取文案列表 — 必须解包 items(后端返回 {items,total}*/
export const getScripts = async (): Promise<ScriptItem[]> => {
const response = await apiClient.get<ScriptListResponse | ScriptItem[]>("/scripts")
const data = response.data as unknown
if (Array.isArray(data)) return data
const items = (data as { items?: ScriptItem[] })?.items
return Array.isArray(items) ? items : []
/** 是否启用 mock(后端合入后改为 false) */
export const SCRIPTS_API_MOCK = true
// ==================== Mock 数据 ====================
const MOCK_CATEGORIES: ScriptCategory[] = [
"promo",
"vlog",
"knowledge",
"story",
"emotion",
"other",
]
const SAMPLE_TITLES: Record<ScriptCategory, string[]> = {
promo: ["新品上市限时特惠", "618大促开场", "品牌故事宣传片"],
vlog: ["周末citywalk记录", "打工人的一天", "探店vlog"],
knowledge: ["3分钟学会XX", "冷知识科普", "行业深度解读"],
story: ["励志小故事", "情感故事一则", "反转剧情"],
emotion: ["深夜emo时刻", "治愈系文案", "朋友圈金句"],
other: ["通用开场白", "节日祝福", "万能结尾"],
}
const SAMPLE_TAGS = ["热门", "新品", "节日", "情感", "干货", "搞笑", "治愈", "励志"]
function genMockScripts(): ScriptItem[] {
const items: ScriptItem[] = []
const now = Date.now()
let idx = 0
for (const cat of MOCK_CATEGORIES) {
const titles = SAMPLE_TITLES[cat]
for (let i = 0; i < titles.length; i++) {
idx++
const title = titles[i]
const content = `这是一条【${cat}】分类下的示例文案,标题为「${title}」。\n\n正文可以包含多段落,每段对应一个片段(segments)。\n\n此为 mock 数据,后端接口就绪后会自动切换为真实数据。`
const segments = content.split(/\n\n+/).filter(Boolean)
const tagCount = 1 + (idx % 3)
const tags: string[] = []
for (let t = 0; t < tagCount; t++) {
tags.push(SAMPLE_TAGS[(idx + t) % SAMPLE_TAGS.length])
}
items.push({
id: `mock_${idx}`,
title,
content,
segments,
tags,
title_text: title,
title_category: cat,
title_config: {
font: "default",
color: "#ffffff",
stroke: "#000000",
position: (["top", "center", "bottom"] as const)[idx % 3],
size: 48,
bold: idx % 2 === 0,
italic: false,
},
char_count: content.length,
use_count: Math.floor(Math.random() * 50),
created_at: new Date(now - idx * 86400_000 * 2).toISOString(),
updated_at: new Date(now - idx * 86400_000).toISOString(),
})
}
}
return items
}
const MOCK_SCRIPTS = genMockScripts()
// ==================== 真实 API ====================
/** 获取文案列表(支持分页/搜索/分类) */
export async function getScripts(params: ScriptListParams = {}): Promise<ScriptListResponse> {
if (SCRIPTS_API_MOCK) {
const page = params.page ?? 1
const pageSize = params.page_size ?? 20
let items = [...MOCK_SCRIPTS]
if (params.keyword) {
const kw = params.keyword.toLowerCase()
items = items.filter(
(s) => s.title.toLowerCase().includes(kw) || s.content.toLowerCase().includes(kw),
)
}
if (params.category && params.category !== "all") {
items = items.filter((s) => s.title_category === params.category)
}
if (params.tag) {
items = items.filter((s) => s.tags?.includes(params.tag as string))
}
const total = items.length
const start = (page - 1) * pageSize
const pageItems = items.slice(start, start + pageSize)
return new Promise((r) =>
setTimeout(() => r({ items: pageItems, total, page, page_size: pageSize }), 200),
)
}
const res = await apiClient.get<ScriptListResponse>("/scripts", { params })
return res.data
}
/** 获取单条文案详情 */
export async function getScript(id: string): Promise<ScriptItem> {
if (SCRIPTS_API_MOCK) {
const item = MOCK_SCRIPTS.find((s) => s.id === id)
return new Promise((r) => setTimeout(() => r(item ?? MOCK_SCRIPTS[0]), 120))
}
const res = await apiClient.get<ScriptItem>(`/scripts/${id}`)
return res.data
}
/** 新建文案 */
export const createScript = async (data: CreateScriptRequest): Promise<ScriptItem> => {
const response = await apiClient.post<ScriptItem>("/scripts", data)
return response.data
export async function createScript(data: ScriptUpsertRequest): Promise<ScriptItem> {
if (SCRIPTS_API_MOCK) {
const segments =
data.segments && data.segments.length > 0
? data.segments
: data.content.split(/\n\n+/).filter(Boolean)
const item: ScriptItem = {
id: `mock_${Date.now()}`,
...data,
segments,
char_count: data.content.length,
use_count: 0,
tags: data.tags ?? [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
MOCK_SCRIPTS.unshift(item)
return new Promise((r) => setTimeout(() => r(item), 200))
}
const res = await apiClient.post<ScriptItem>("/scripts", data)
return res.data
}
/** 更新文案 */
export const updateScript = async (id: string, data: UpdateScriptRequest): Promise<ScriptItem> => {
const response = await apiClient.put<ScriptItem>(`/scripts/${id}`, data)
return response.data
export async function updateScript(id: string, data: ScriptUpsertRequest): Promise<ScriptItem> {
if (SCRIPTS_API_MOCK) {
const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id)
const segments =
data.segments && data.segments.length > 0
? data.segments
: data.content.split(/\n\n+/).filter(Boolean)
const updated: ScriptItem = {
...MOCK_SCRIPTS[idx],
...data,
segments,
char_count: data.content.length,
tags: data.tags ?? MOCK_SCRIPTS[idx]?.tags ?? [],
updated_at: new Date().toISOString(),
}
if (idx >= 0) MOCK_SCRIPTS[idx] = updated
return new Promise((r) => setTimeout(() => r(updated), 200))
}
const res = await apiClient.put<ScriptItem>(`/scripts/${id}`, data)
return res.data
}
/** 删除文案 */
export const deleteScript = async (id: string): Promise<void> => {
export async function deleteScript(id: string): Promise<void> {
if (SCRIPTS_API_MOCK) {
const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id)
if (idx >= 0) MOCK_SCRIPTS.splice(idx, 1)
return new Promise((r) => setTimeout(r, 150))
}
await apiClient.delete(`/scripts/${id}`)
}
/** 复制文案(返回新副本) */
export async function duplicateScript(id: string): Promise<ScriptItem> {
const orig = await getScript(id)
const copy = await createScript({
title: `${orig.title}(副本)`,
content: orig.content,
segments: orig.segments,
tags: orig.tags,
title_text: orig.title_text,
title_category: orig.title_category,
title_config: orig.title_config,
})
return copy
}
+79 -6
View File
@@ -1,24 +1,97 @@
/**
* 文案库 API — 类型定义
* 对接后端 /api/v1/scripts
* 文案库 API — 类型定义#1811 v2 完整字段版)
* 字段对齐后端契约:title / content / segments / tags / title_text / title_category / title_config
* 同时保留 char_count / use_count / timestamps 等展示字段
*/
/** 标题配置(字体、颜色、位置、字号) */
export interface ScriptTitleConfig {
/** 字体预设 key,如 "default" / "bold" / "handwritten" */
font?: string
/** 文字颜色(CSS color */
color?: string
/** 描边色 */
stroke?: string
/** 位置:top / center / bottom */
position?: "top" | "center" | "bottom"
/** 字号(px */
size?: number
/** 是否加粗 */
bold?: boolean
/** 是否斜体 */
italic?: boolean
}
/** 文案分类(可枚举,也支持自定义) */
export type ScriptCategory =
| "promo" // 营销推广
| "vlog" // Vlog/日常
| "knowledge" // 知识科普
| "story" // 故事剧情
| "emotion" // 情感语录
| "other" // 其他
export const SCRIPT_CATEGORY_LABEL: Record<ScriptCategory, string> = {
promo: "营销推广",
vlog: "Vlog 日常",
knowledge: "知识科普",
story: "故事剧情",
emotion: "情感语录",
other: "其他",
}
/** 文案条目 */
export interface ScriptItem {
id: string
/** 名称(标题) */
title: string
/** 正文 */
content: string
char_count: number
/** 分段(按段落切分,供后端/生成步骤逐段使用) */
segments?: string[]
/** 标签(逗号分隔或数组,列表展示用 Tag) */
tags?: string[]
/** 配套标题文本(选填,"使用"跳创作页时会预填到标题) */
title_text?: string
/** 分类 */
title_category?: ScriptCategory
/** 标题样式配置(字体/颜色/位置/字号) */
title_config?: ScriptTitleConfig
/** 正文字符数(后端返回,前端用于展示) */
char_count?: number
/** 使用次数(后端返回) */
use_count?: number
created_at: string
updated_at?: string
}
/** 列表查询参数(支持搜索/分类/分页) */
export interface ScriptListParams {
page?: number
page_size?: number
/** 标题/正文模糊搜索 */
keyword?: string
/** 分类筛选 */
category?: ScriptCategory | "all"
/** 标签筛选 */
tag?: string
}
/** 列表响应 */
export interface ScriptListResponse {
items: ScriptItem[]
total: number
page: number
page_size: number
}
export interface CreateScriptRequest {
/** 创建/编辑请求 */
export interface ScriptUpsertRequest {
title: string
content: string
segments?: string[]
tags?: string[]
title_text?: string
title_category?: ScriptCategory
title_config?: ScriptTitleConfig
}
export type UpdateScriptRequest = Partial<CreateScriptRequest>
+15 -12
View File
@@ -85,9 +85,13 @@ export async function batchDeleteEditPlanClips(
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
/**
* 从素材批量创建片段(追加到时间线末尾)。
* #1921 修复:templateId 为空时调用新端点 POST /clips/from-assets,避免拼出双斜杠
* `/templates//editor/clips/from-assets` 导致 404;有 templateId 时保持原路径向后兼容。
*/
export async function createClipsFromAssets(
templateId: string,
templateId: string | undefined | null,
assetIds: string[],
clipType = "main",
requiredClipsCount?: number,
@@ -100,16 +104,15 @@ export async function createClipsFromAssets(
if (requiredClipsCount !== undefined) {
body.required_clips_count = requiredClipsCount
}
// 新端点(#1921):templateId 为空时,body 不传 template_id,由后端兜底创建默认模板
const hasTid = !!templateId
const url = hasTid ? `/templates/${templateId}/editor/clips/from-assets` : "/clips/from-assets"
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
body,
{
timeout: 60000,
signal: opts?.signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
},
)
const response = await apiClient.post<ClipsFromAssetsResponse>(url, body, {
timeout: 60000,
signal: opts?.signal,
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
})
return response.data
}
+3 -1
View File
@@ -16,6 +16,7 @@ export interface TTSSynthesizeRequest {
voice_id?: string
output_name?: string
language?: string
emotion?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
@@ -103,7 +104,8 @@ export interface TTSPreviewRequest {
voice_id: string
speed?: number
pitch?: number
emotion?: string // 情绪参数:natural/excited/calm/friendly
language?: string
emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签)
}
/** TTS 试听响应 */
+3
View File
@@ -96,9 +96,12 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
export const getVoiceClonePreview = async (
cloneId: string,
text?: string,
options?: { speed?: number; emotion?: string },
): Promise<VoiceClonePreviewResponse> => {
const searchParams = new URLSearchParams()
if (text) searchParams.set("text", text)
if (options?.speed !== undefined) searchParams.set("speed", String(options.speed))
if (options?.emotion) searchParams.set("emotion", options.emotion)
const qs = searchParams.toString()
const response = await apiClient.get<VoiceClonePreviewResponse>(
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
@@ -0,0 +1,116 @@
/* Header 积分徽章 & Popover 样式 */
.xx-points-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 999px;
border: 1px solid var(--border-color, #e5e7eb);
background: var(--bg-tertiary, #fafafa);
color: var(--text-secondary, #6b7280);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1;
height: 32px;
}
.xx-points-badge:hover {
border-color: var(--primary-color, #6366f1);
color: var(--primary-color, #6366f1);
background: var(--primary-soft, #eef2ff);
}
.xx-points-badge.zero {
color: #faad14;
border-color: #ffe58f;
background: #fffbe6;
}
.xx-points-badge.low {
color: #fa8c16;
border-color: #ffd591;
background: #fff7e6;
}
.xx-points-badge-icon {
font-size: 14px;
color: #8b5cf6;
}
.xx-points-badge.zero .xx-points-badge-icon,
.xx-points-badge.low .xx-points-badge-icon {
color: inherit;
}
.xx-points-badge-val {
font-variant-numeric: tabular-nums;
}
/* Popover */
.xx-points-popover {
width: 260px;
display: flex;
flex-direction: column;
gap: 12px;
}
.xx-points-popover-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-points-popover-balance {
display: flex;
align-items: baseline;
gap: 6px;
}
.xx-points-icon {
color: #8b5cf6;
font-size: 18px;
}
.xx-points-value {
font-size: 22px;
font-weight: 800;
color: var(--text-primary, #111827);
font-variant-numeric: tabular-nums;
}
.xx-points-unit {
color: var(--text-secondary, #6b7280);
font-size: 12px;
}
.xx-points-warn {
margin: 0 !important;
padding: 8px 10px;
background: #fffbe6;
border-radius: var(--radius-sm, 6px);
font-size: 12px;
}
.xx-points-expire {
font-size: 12px;
display: block;
}
.xx-points-popover-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 10px;
background: var(--bg-tertiary, #fafafa);
border-radius: var(--radius-sm, 6px);
}
.xx-points-stat-val {
font-size: 16px;
font-weight: 700;
color: var(--text-primary, #111827);
margin-top: 2px;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,115 @@
/**
* Header 右上角的积分徽章(💎 580)
* - 余额 <10 时橙色告警
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
*/
import React, { useEffect } from "react"
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
import {
ThunderboltOutlined,
CrownOutlined,
RightOutlined,
WarningOutlined,
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import { usePointsStore } from "@/store/pointsStore"
import "./PointsBadge.css"
const { Text, Paragraph } = Typography
const PointsBadge: React.FC = () => {
const navigate = useNavigate()
const { balance, init, loading } = usePointsStore()
useEffect(() => {
if (!balance) init()
}, [balance, init])
const bal = balance?.balance ?? 0
const lowBalance = bal > 0 && bal < 10
const zero = bal === 0
const isMember = !!balance?.is_member
const memberLabel = isMember
? balance?.member_type === "yearly"
? "年卡会员"
: balance?.member_type === "quarterly"
? "季卡会员"
: balance?.member_type === "monthly"
? "月卡会员"
: "付费会员"
: "免费会员"
const popContent = (
<div className="xx-points-popover">
<div className="xx-points-popover-top">
<div className="xx-points-popover-balance">
<ThunderboltOutlined className="xx-points-icon" />
<span className="xx-points-value">{loading ? "…" : bal}</span>
<span className="xx-points-unit"></span>
</div>
<Tag color={isMember ? "gold" : "default"} icon={<CrownOutlined />}>
{memberLabel}
</Tag>
</div>
{(zero || lowBalance) && (
<Paragraph type="warning" className="xx-points-warn">
<WarningOutlined /> 使 AI
</Paragraph>
)}
{balance?.member_expires_at && (
<Text type="secondary" className="xx-points-expire">
{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
<div className="xx-points-popover-stats">
<div>
<Text type="secondary"></Text>
<div className="xx-points-stat-val">+{balance?.total_earned ?? 0}</div>
</div>
<div>
<Text type="secondary"></Text>
<div className="xx-points-stat-val">-{balance?.total_spent ?? 0}</div>
</div>
</div>
<Space direction="vertical" style={{ width: "100%" }} size={8}>
<Button
type="primary"
icon={<ThunderboltOutlined />}
block
onClick={() => navigate("/app/points")}
>
</Button>
<Button block onClick={() => navigate("/app/points/transactions")}>
<RightOutlined />
</Button>
{!isMember && (
<Button block type="link" onClick={() => navigate("/app/subscription")}>
<CrownOutlined />
</Button>
)}
</Space>
</div>
)
return (
<Popover content={popContent} placement="bottomRight" trigger="click" arrow={false}>
<button
type="button"
className={`xx-points-badge ${zero ? "zero" : lowBalance ? "low" : ""}`}
>
<Badge dot={zero} color={zero ? "#faad14" : undefined}>
<ThunderboltOutlined className="xx-points-badge-icon" />
</Badge>
<span className="xx-points-badge-val">{loading ? "…" : bal}</span>
</button>
</Popover>
)
}
export default PointsBadge
@@ -0,0 +1,67 @@
/* 功能按钮旁的积分消耗提示 */
.xx-points-cost {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary, #6b7280);
font-weight: 500;
white-space: nowrap;
}
.xx-points-cost.free,
.xx-points-cost.free-quota {
color: #10b981;
}
.xx-points-cost.insufficient {
color: #ef4444;
}
.xx-points-gem {
font-size: 12px;
line-height: 1;
}
.xx-points-num {
font-weight: 700;
font-variant-numeric: tabular-nums;
color: inherit;
}
.xx-points-unit-label {
color: var(--text-tertiary, #9ca3af);
}
.xx-points-tag-free {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 4px;
background: #d1fae5;
color: #059669;
font-size: 11px;
font-weight: 600;
}
.xx-points-free-user-tag {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 4px;
background: #fef3c7;
color: #d97706;
font-size: 10px;
font-weight: 600;
margin-left: 4px;
}
.xx-points-desc {
color: var(--text-tertiary, #9ca3af);
font-size: 11px;
}
.xx-points-warn-icon {
color: #ef4444;
margin-left: 2px;
}
@@ -0,0 +1,162 @@
/**
* 功能操作按钮旁的"消耗积分"提示
* 例:[生成配音] 💎 -1 积分
* - 根据 scene_key 自动读取规则
* - 免费用户自动计算 ×1.15 向上取整
* - 余额不足时显示红色告警 + 充值提示
*
* 使用:<PointsCost scene="ai_voice" units={1} />
*/
import React, { useEffect, useMemo, useState } from "react"
import { Tooltip } from "antd"
import { WarningOutlined } from "@ant-design/icons"
import { usePointsStore } from "@/store/pointsStore"
import type { PointsSource } from "@/api/points/types"
import { getPointsRules } from "@/api/points"
import "./PointsCost.css"
interface Props {
/** 消耗场景 key */
scene: PointsSource
/** 单位数(分钟数/条数/张数),默认 1 */
units?: number
/** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */
compact?: boolean
/** 余额不足时,是否显示充值提示 */
showRechargeHint?: boolean
/** 自定义 class */
className?: string
}
/** 单位中文 */
const UNIT_LABEL: Record<string, string> = {
: "分钟",
: "条",
: "次",
: "张",
}
const PointsCost: React.FC<Props> = ({
scene,
units = 1,
compact = false,
showRechargeHint = true,
className = "",
}) => {
const { balance, init } = usePointsStore()
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | null>(null)
useEffect(() => {
if (!balance) init()
if (!rules) {
getPointsRules()
.then(setRules)
.catch(() => {})
}
}, [balance, init, rules])
const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => {
if (!rules || !balance) {
return {
cost: 0,
isFreeQuota: false,
rule: null,
isFreeUser: !balance?.is_member,
insufficient: false,
}
}
const rule = rules.rules.find((r) => r.scene_key === scene)
if (!rule)
return {
cost: 0,
isFreeQuota: false,
rule: null,
isFreeUser: !balance.is_member,
insufficient: false,
}
// 免费训练不扣费
if (rule.points_per_use === 0) {
return {
cost: 0,
isFreeQuota: false,
rule,
isFreeUser: !balance.is_member,
insufficient: false,
}
}
// 智能混剪:首条30s=3分,每+30s +1
let baseCost: number
if (scene === "ai_video" && rule.extra_per_30s) {
// units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1
const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments
baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1)
} else {
baseCost = rule.points_per_use * Math.max(1, units)
}
// 混剪 + 免费用户 + 今日有免费额度 → 免费
const isFree =
scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0
const isFreeUser = !balance.is_member
const cost = isFree
? 0
: isFreeUser
? Math.ceil(baseCost * rules.free_user_multiplier)
: baseCost
const insufficient = !isFree && balance.balance < cost
return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient }
}, [rules, balance, scene, units])
if (!rule || !balance) {
return <span className={`xx-points-cost ${className}`} />
}
if (rule.points_per_use === 0) {
return (
<span className={`xx-points-cost free ${className}`}>
<span className="xx-points-tag-free"></span>
</span>
)
}
if (isFreeQuota) {
return (
<Tooltip title={`今日免费额度剩余 ${balance.free_clips_remaining} 条,不扣积分`}>
<span className={`xx-points-cost free-quota ${className}`}>
<span className="xx-points-tag-free"></span>
{!compact && (
<span className="xx-points-desc"> {balance.free_clips_remaining} </span>
)}
</span>
</Tooltip>
)
}
const unitLabel = compact
? ""
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
return (
<span
className={`xx-points-cost ${insufficient ? "insufficient" : ""} ${isFreeUser ? "free-user" : ""} ${className}`}
>
<span className="xx-points-gem">💎</span>
<span className="xx-points-num">-{cost}</span>
{unitLabel && <span className="xx-points-unit-label">{unitLabel}</span>}
{isFreeUser && !compact && (
<Tooltip title={`免费用户消耗 ×${rules?.free_user_multiplier ?? 1.15},开通会员享标准价`}>
<span className="xx-points-free-user-tag"></span>
</Tooltip>
)}
{insufficient && showRechargeHint && (
<Tooltip title={`积分不足,需 ${cost} 积分,当前余额 ${balance.balance}`}>
<WarningOutlined className="xx-points-warn-icon" />
</Tooltip>
)}
</span>
)
}
export default PointsCost
+80 -2
View File
@@ -1,4 +1,5 @@
/* Phase 1 Header 样式 + 手机端适配 */
/* Phase 1 Header 样式 + 手机端适配
v2: 增加积分徽章、升级按钮、会员金冠 */
.xx-top-nav {
height: 68px;
position: sticky;
@@ -77,10 +78,40 @@
.xx-right-section {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
flex-shrink: 0;
}
/* v2: 顶部积分徽章 */
.xx-header-points {
cursor: pointer;
}
/* v2: 升级会员按钮 */
.xx-upgrade-btn {
background: linear-gradient(135deg, #f59e0b, #ef4444);
border: none;
font-weight: 700;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
}
.xx-upgrade-btn:hover {
background: linear-gradient(135deg, #d97706, #dc2626) !important;
color: #fff !important;
}
/* v2: 会员金冠按钮 */
.xx-crown-btn {
font-weight: 700;
color: #d97706;
padding: 2px 8px;
}
.xx-crown-btn:hover {
background: rgba(245, 158, 11, 0.1) !important;
color: #b45309 !important;
}
.xx-user-menu {
display: flex;
align-items: center;
@@ -115,6 +146,29 @@
gap: var(--space-xs);
}
/* v2: 手机端积分/会员行 */
.xx-mobile-points-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 4px 16px;
margin-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.xx-mobile-member-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
color: #d97706;
background: #fef3c7;
padding: 4px 10px;
border-radius: 999px;
}
.xx-mobile-nav-item {
display: flex;
align-items: center;
@@ -157,6 +211,7 @@
.xx-top-nav-inner {
padding: 0 12px;
gap: 6px;
}
.xx-brand-text {
@@ -177,6 +232,19 @@
.xx-username {
display: none;
}
/* 手机端:缩小升级按钮文字 */
.xx-upgrade-btn {
padding: 0 8px !important;
font-size: 12px !important;
height: 28px;
}
.xx-crown-btn {
padding: 0 4px !important;
font-size: 12px !important;
height: 28px;
}
}
/* 小屏幕平板:导航文字缩小 */
@@ -188,4 +256,14 @@
.xx-nav-links button {
font-size: 13px;
}
.xx-header-points {
transform: scale(0.9);
transform-origin: center;
}
.xx-upgrade-btn {
font-size: 12px !important;
padding: 0 10px !important;
}
}
+117 -4
View File
@@ -1,15 +1,27 @@
/**
* Phase 1 Header 重构
* 扁平化导航菜单 + 手机端汉堡菜单
* v2: 增加积分徽章、升级会员入口、我的积分入口
*/
import React, { useState } from "react"
import { Avatar, Dropdown, Drawer, Space } from "antd"
import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons"
import React, { useEffect, useState } from "react"
import { Avatar, Badge, Button, Dropdown, Drawer, Space, Tooltip } from "antd"
import {
LogoutOutlined,
SettingOutlined,
UserOutlined,
MenuOutlined,
CrownFilled,
ThunderboltOutlined,
HistoryOutlined,
WalletOutlined,
} from "@ant-design/icons"
import { useLocation, useNavigate } from "react-router-dom"
import { useAuthStore } from "@/store/authStore"
import { useLogout } from "@/hooks/useAuth"
import type { MenuProps } from "antd"
import { NAV_ITEMS } from "@/config/navigation"
import PointsBadge from "@/components/common/PointsBadge"
import { usePointsStore } from "@/store/pointsStore"
import "./Header.css"
const Header: React.FC = () => {
@@ -19,6 +31,18 @@ const Header: React.FC = () => {
const logoutMutation = useLogout()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
// 积分与会员状态
const { balance, init } = usePointsStore()
useEffect(() => {
init()
// 每 60s 刷新一次(余额可能变化)
const t = setInterval(() => init(), 60_000)
return () => clearInterval(t)
}, [init])
const isMember = !!balance?.is_member
const memberType = balance?.member_type ?? null
/** 用户下拉菜单 */
const menuItems: MenuProps["items"] = [
{
@@ -33,6 +57,30 @@ const Header: React.FC = () => {
label: "订阅管理",
onClick: () => navigate("/app/subscription"),
},
// 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",
@@ -44,7 +92,6 @@ const Header: React.FC = () => {
/** 判断导航项是否激活 */
const isActive = (path: string) => {
// 首页特殊处理:/ 和 /app/dashboard 都算激活
if (path === "/app/dashboard") {
return (
location.pathname === "/" ||
@@ -78,6 +125,47 @@ const Header: React.FC = () => {
</nav>
<div className="xx-right-section">
{/* v2: 积分徽章(顶部显示) */}
<PointsBadge />
{/* v2: 升级会员入口(仅免费用户显示) */}
{!isMember && (
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
<Button
type="primary"
size="small"
icon={<CrownFilled />}
className="xx-upgrade-btn"
onClick={() => navigate("/app/subscription")}
>
</Button>
</Tooltip>
)}
{/* v2: 付费会员显示金冠标识 */}
{isMember && (
<Tooltip
title={`${memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}会员${
balance?.member_expires_at
? ` · 到期 ${new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}`
: ""
}`}
>
<Badge dot color="gold">
<Button
type="text"
size="small"
icon={<CrownFilled style={{ color: "#f59e0b" }} />}
className="xx-crown-btn"
onClick={() => navigate("/app/subscription")}
>
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "会员"}
</Button>
</Badge>
</Tooltip>
)}
{/* 手机端汉堡菜单按钮 */}
<button className="xx-hamburger" type="button" onClick={() => setMobileMenuOpen(true)}>
<MenuOutlined />
@@ -103,6 +191,31 @@ const Header: React.FC = () => {
width={260}
className="xx-mobile-drawer"
>
{/* 手机端积分/会员入口 */}
{balance && (
<div className="xx-mobile-points-row">
<PointsBadge />
{!isMember ? (
<Button
size="small"
type="primary"
icon={<CrownFilled />}
onClick={() => {
navigate("/app/subscription")
setMobileMenuOpen(false)
}}
>
</Button>
) : (
<span className="xx-mobile-member-tag">
<CrownFilled style={{ color: "#f59e0b" }} />{" "}
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}
</span>
)}
</div>
)}
<div className="xx-mobile-nav">
{NAV_ITEMS.map((item) => (
<button
+20 -39
View File
@@ -8,15 +8,14 @@ import {
FileOutlined,
FileTextOutlined,
AudioOutlined,
AppstoreOutlined,
EditOutlined,
FolderOutlined,
VideoCameraOutlined,
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
ControlOutlined,
CrownOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
UserOutlined,
} from "@ant-design/icons"
@@ -70,24 +69,6 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/voices",
icon: React.createElement(AudioOutlined),
},
{
key: "templates",
label: "模板库",
path: "/app/templates",
icon: React.createElement(AppstoreOutlined),
},
{
key: "editing-planner",
label: "剪辑模板",
path: "/app/editing-planner",
icon: React.createElement(EditOutlined),
},
{
key: "my-templates",
label: "我的模板",
path: "/app/my-templates",
icon: React.createElement(FolderOutlined),
},
{
key: "generate",
@@ -125,6 +106,18 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/tasks",
icon: React.createElement(UnorderedListOutlined),
},
{
key: "subscription",
label: "会员订阅",
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
]
/** 侧边栏导航分组(Sidebar 分组列表使用) */
@@ -150,12 +143,6 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "editing-planner",
label: "剪辑模板",
path: "/app/editing-planner",
icon: React.createElement(EditOutlined),
},
],
},
{
@@ -191,18 +178,6 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/products",
icon: React.createElement(TrophyOutlined),
},
{
key: "templates",
label: "模板库",
path: "/app/templates",
icon: React.createElement(AppstoreOutlined),
},
{
key: "my-templates",
label: "我的模板",
path: "/app/my-templates",
icon: React.createElement(FolderOutlined),
},
],
},
{
@@ -234,10 +209,16 @@ export const NAV_GROUPS: NavGroup[] = [
},
{
key: "subscription",
label: "订阅管理",
label: "会员订阅",
path: "/app/subscription",
icon: React.createElement(CrownOutlined),
},
{
key: "points",
label: "积分中心",
path: "/app/points",
icon: React.createElement(ThunderboltOutlined),
},
],
},
]
-9
View File
@@ -25,15 +25,6 @@ body {
background-color: var(--bg-secondary);
}
/* ── 自定义字体 ── */
@font-face {
font-family: "华康俪金黑";
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* 滚动条 - V21 样式 */
::-webkit-scrollbar {
width: 8px;
+33 -1
View File
@@ -552,7 +552,7 @@
max-width: 240px;
aspect-ratio: 9/16;
background: #f0f0f5;
border-radius: 8px;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
@@ -564,8 +564,10 @@
.aa-cover-preview img {
width: 100%;
height: 100%;
aspect-ratio: 9/16;
object-fit: cover;
display: block;
border-radius: 12px;
}
.aa-cover-preview__placeholder {
@@ -573,6 +575,19 @@
color: #8c8ca1;
}
.aa-cover-preview__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 13px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.aa-cover-actions {
display: flex;
gap: 8px;
@@ -1250,3 +1265,20 @@
width: auto;
min-width: 300px;
}
/* 渲染完成后的封面确认区 */
.aa-finalize-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0 8px;
}
.aa-finalize-cover {
width: 100%;
max-width: 240px;
aspect-ratio: 9/16;
border-radius: 12px;
overflow: hidden;
position: relative;
}
+562 -97
View File
@@ -1,7 +1,7 @@
/**
* AI数字人 v3
* 1 / /
* 2/ / &
* AI数字人 v3 + #1845
* 1 / / 🎵 TTS ~2-3s
* 2B-roll / / &
*/
import React, { useState, useCallback, useEffect, useRef } from "react"
import { message } from "antd"
@@ -16,20 +16,26 @@ import PanelTitleConfig from "./components/PanelTitleConfig"
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
import { ModalAssetPicker } from "./components/ModalAssetPicker"
import ModalBRollEditor from "./components/ModalBRollEditor"
import ModalCoverSelect from "./components/ModalCoverSelect"
import {
getScripts,
getAssetById,
createLipsyncJob,
getLipsyncJob,
previewTts,
submitRender,
getRenderJob,
generateSmartCover,
generateRenderSmartCover,
finalizeRenderJob,
} from "./api/aiAvatar"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { RenderJob, SentenceTiming } from "./types"
import {
normalizeEmotion,
buildTitleConfigPayload,
buildCoverConfigPayload,
} from "./utils/contract"
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
/** 面板折叠状态 */
type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover"
@@ -47,14 +53,18 @@ const AiAvatarPage: React.FC = () => {
cover: false,
})
/* ── #1845 TTS 预合成弹窗 ── */
const [showTtsModal, setShowTtsModal] = useState(false)
const [ttsProgress, setTtsProgress] = useState(0)
const [ttsErrorMessage, setTtsErrorMessage] = useState("")
const ttsProgressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 对口型生成弹窗 ── */
const [showLipsyncModal, setShowLipsyncModal] = useState(false)
const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">(
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ── 智能封面加载态 ── */
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -62,6 +72,12 @@ const AiAvatarPage: React.FC = () => {
)
const [renderProgress, setRenderProgress] = useState(0)
const [renderErrorMessage, setRenderErrorMessage] = useState("")
/* ── 当前渲染任务对象 ── */
const [currentRenderJob, setCurrentRenderJob] = useState<RenderJob | null>(null)
/* ── 封面选择弹窗 ── */
const [showCoverModal, setShowCoverModal] = useState(false)
const [selectedCoverUrl, setSelectedCoverUrl] = useState("")
const [finalizeLoading, setFinalizeLoading] = useState(false)
/* ── 对口型轮询 ── */
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -72,8 +88,29 @@ const AiAvatarPage: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
/* ── 步骤切换 ── */
const handleNextStep = useCallback(() => {
/* ── #1845 文案/音色/语速变更时重置 TTS 预合成状态,避免音频与文案不一致 ── */
useEffect(() => {
if (state.ttsPreview.status !== "idle") {
state.resetTtsPreview()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion])
const _clearTtsProgressTimer = useCallback(() => {
if (ttsProgressTimerRef.current) {
clearInterval(ttsProgressTimerRef.current)
ttsProgressTimerRef.current = null
}
}, [])
useEffect(() => {
return () => {
_clearTtsProgressTimer()
}
}, [_clearTtsProgressTimer])
/* ── #1845 步骤1:点击「🎵 生成配音」→ 同步 TTS 预合成 ── */
const handleGenerateTts = useCallback(async () => {
const missing: string[] = []
if (!state.selectedVideo) missing.push("出镜视频")
if (!state.selectedVoice) missing.push("配音")
@@ -82,45 +119,115 @@ const AiAvatarPage: React.FC = () => {
message.warning(`请先完成${missing.join("、")}`)
return
}
setCurrentStep(2)
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
// 打开弹窗 & 启动模拟进度条
setShowTtsModal(true)
setTtsProgress(0)
setTtsErrorMessage("")
state.setTtsPreview({
audioUrl: null,
duration: 0,
sentenceTimings: [],
status: "generating",
error: null,
})
// 模拟进度:每 300ms +10%,到 90% 停住,真完成后瞬间到 100%
_clearTtsProgressTimer()
let fake = 0
ttsProgressTimerRef.current = setInterval(() => {
fake = Math.min(fake + 10, 90)
setTtsProgress(fake)
if (fake >= 90) {
_clearTtsProgressTimer()
}
}, 300)
try {
const res = await previewTts({
voice_id: state.selectedVoice!.voice_id,
script_text: state.scriptText,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
})
_clearTtsProgressTimer()
setTtsProgress(100)
state.setTtsPreview({
audioUrl: res.audio_url,
duration: res.duration,
sentenceTimings: res.sentence_timings as SentenceTiming[],
status: "done",
error: null,
})
message.success("配音合成完成")
} catch (err) {
_clearTtsProgressTimer()
const errMsg =
(err as { response?: { data?: { message?: string; detail?: unknown } } })?.response?.data
?.message || (err instanceof Error ? err.message : "配音合成失败,请重试")
setTtsErrorMessage(typeof errMsg === "string" ? errMsg : "配音合成失败,请重试")
state.setTtsPreview({
audioUrl: null,
duration: 0,
sentenceTimings: [],
status: "failed",
error: typeof errMsg === "string" ? errMsg : "配音合成失败",
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
const handleRetryTts = useCallback(() => {
handleGenerateTts()
}, [handleGenerateTts])
const handleTtsNext = useCallback(() => {
setShowTtsModal(false)
setTtsProgress(0)
setCurrentStep(2)
}, [])
const handleCancelTts = useCallback(() => {
_clearTtsProgressTimer()
setShowTtsModal(false)
setTtsProgress(0)
setTtsErrorMessage("")
// 若用户在生成中途关闭,把状态重置回 idle,允许重新点击
if (state.ttsPreview.status === "generating") {
state.resetTtsPreview()
}
}, [_clearTtsProgressTimer, state])
/* ── 上一步(返回步骤1,不会丢失 TTS 预合成结果) ── */
const handlePrevStep = useCallback(() => {
setCurrentStep(1)
}, [])
/* ── 对口型 ── */
const handleGenerateLipsync = useCallback(async () => {
// ② 缺项明确提示(#1809):不再静默 return
const video = state.selectedVideo
const voice = state.selectedVoice
const text = state.scriptText.trim()
const missing: string[] = []
if (!video) missing.push("出镜视频")
if (!voice) missing.push("音色")
if (!text) missing.push("文案")
if (missing.length > 0 || !video || !voice) {
if (missing.length > 0 || !video) {
message.warning(`请先选择${missing.join("、")}`)
return
}
// #1845:预合成模式下必须要有 audioUrl(理论上到了步骤2肯定有,兜底防御)
const isPreSynth = state.ttsPreview.status === "done" && !!state.ttsPreview.audioUrl
if (!isPreSynth && !state.selectedVoice) {
message.warning("请先选择音色或完成配音合成")
return
}
try {
// 显示生成弹窗
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
// ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url)
console.log("[对口型] 开始生成:", {
videoId: video.id,
voiceId: voice.voice_id,
voiceType: voice.type,
textLen: state.scriptText.length,
})
const asset = await getAssetById(video.id)
console.log("[对口型] getAssetById 响应:", {
id: asset?.id,
file_url: asset?.file_url?.substring(0, 100),
})
const videoUrl = asset?.file_url
if (!videoUrl) {
console.error("[对口型] file_url 为空,asset:", asset)
@@ -128,29 +235,38 @@ const AiAvatarPage: React.FC = () => {
message.error("获取出镜视频播放地址失败,请重新选择素材")
return
}
// ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822)
const payload = {
voice_id: voice.voice_id,
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed, // 语速 0.5~2.0
emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly
type LipsyncPayload = Parameters<typeof createLipsyncJob>[0]
let payload: LipsyncPayload
if (isPreSynth) {
// 预合成模式:传 audio_url + audio_duration + sentence_timings(后端直接提交 MediaKit~2-3s
payload = {
video_url: videoUrl,
audio_url: state.ttsPreview.audioUrl!,
audio_duration: state.ttsPreview.duration,
sentence_timings: state.ttsPreview.sentenceTimings,
enable_video_loop: true,
}
} else {
// 降级:TTS 直生(旧路径,前端未预合成时)
payload = {
voice_id: state.selectedVoice!.voice_id,
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
}
}
console.log("[对口型] createLipsyncJob 请求:", payload)
const job = await createLipsyncJob(payload)
console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status })
state.setLipsyncJob(job)
// 开始轮询
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
// 但仍需轮询等 completed
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = setInterval(async () => {
try {
const updated = await getLipsyncJob(job.id)
state.setLipsyncJob(updated)
console.log("[对口型] 轮询状态:", {
id: updated.id,
status: updated.status,
error: updated.error_message,
})
if (updated.status === "completed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
setLipsyncStatus("completed")
@@ -177,7 +293,14 @@ const AiAvatarPage: React.FC = () => {
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
}, [
state.selectedVideo,
state.selectedVoice,
state.scriptText,
state.speed,
state.emotion,
state.ttsPreview,
])
// 取消对口型生成
const handleCancelLipsync = useCallback(() => {
@@ -198,6 +321,14 @@ const AiAvatarPage: React.FC = () => {
}
}, [])
/* ── B-roll 弹窗可用的句子时间戳:优先 lipsyncJob.sentence_timings,否则用 ttsPreview.sentenceTimings ── */
const bRollSentenceTimings: SentenceTiming[] | undefined =
(state.lipsyncJob?.sentence_timings as SentenceTiming[] | undefined) ??
(state.ttsPreview.status === "done" ? state.ttsPreview.sentenceTimings : undefined)
/* ── B-roll 可用的总时长:优先 lipsyncJob.output_duration,否则用 ttsPreview.duration ── */
const bRollDuration = state.lipsyncJob?.output_duration || state.ttsPreview.duration || 0
/* ── 生成视频(含实时进度轮询) ── */
const handleGenerate = useCallback(async () => {
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
@@ -206,9 +337,28 @@ const AiAvatarPage: React.FC = () => {
}
state.setIsGenerating(true)
try {
const defaultProject = await getOrCreateDefaultProject()
// 用 Canvas 预渲染标题为 PNG dataURL
let titleImageDataUrl: string | null = null
if (state.titleConfig.title?.trim()) {
try {
const res = await getVideoResolution(state.lipsyncJob.output_video_url || "")
titleImageDataUrl = renderTitleToPngDataUrl({
titleConfig: state.titleConfig,
videoWidth: res.width,
videoHeight: res.height,
})
} catch (canvasErr) {
console.warn("[渲染] 标题 Canvas 渲染失败,降级 drawtext:", canvasErr)
titleImageDataUrl = null
}
}
const job = await submitRender({
lipsync_job_id: state.lipsyncJob.id,
script_id: state.script?.id,
project_id: defaultProject.id,
b_roll_segments: state.bRollSegments.map((seg) => ({
script_segment_index: seg.script_segment_index,
asset_url: seg.asset.file_url || "",
@@ -218,26 +368,45 @@ const AiAvatarPage: React.FC = () => {
pip_position: seg.pip_position,
pip_scale: seg.pip_scale,
})) as never,
title_config: buildTitleConfigPayload(state.titleConfig),
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
title_config: buildTitleConfigPayload(state.titleConfig, titleImageDataUrl),
cover_config:
state.coverConfig.smart_cover_url ||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
? buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url)
: {},
})
// 打开渲染进度弹窗,启动轮询
setShowRenderModal(true)
setRenderStatus("generating")
setRenderProgress(job.progress ?? 0)
setRenderErrorMessage("")
setCurrentRenderJob(job as RenderJob)
// 每次新渲染重置封面状态
setSelectedCoverUrl("")
setShowCoverModal(false)
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = setInterval(async () => {
try {
const updated = await getRenderJob(job.id)
setRenderProgress(updated.progress ?? 0)
setCurrentRenderJob(updated)
if (updated.status === "completed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null
setRenderStatus("completed")
message.success("视频已生成并保存到成片库")
// 不再自动入库/自动跳转:渲染完成后停留在主页面,等用户选封面、点「完成」才入库
// 如果后端在透传时已经带了封面(旧逻辑兜底),同步本地状态
if (updated.output_cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: updated.output_cover_url,
thumbnail_url: updated.output_cover_url,
}))
setSelectedCoverUrl(updated.output_cover_url)
}
message.success("视频生成完成,请选择封面")
} else if (updated.status === "failed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null
@@ -269,38 +438,109 @@ const AiAvatarPage: React.FC = () => {
setRenderErrorMessage("")
}, [])
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822 ── */
const handleSmartCover = useCallback(async () => {
// 基于对口型成片抽帧,必须先完成对口型
const videoUrl = state.lipsyncJob?.output_video_url
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
message.warning("请先生成对口型视频,完成后再智能获取封面")
/* ── 智能封面 ── */
const handleGenerateRenderSmartCover = useCallback(
async (renderId: string): Promise<{ cover_url: string; message?: string }> => {
try {
const res = await generateRenderSmartCover(renderId)
if (res.cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
return { cover_url: res.cover_url }
}
const errMsg = res.message || "智能封面生成失败,请稍后重试"
message.error(errMsg)
return { cover_url: "", message: errMsg }
} catch (err) {
console.error("智能封面生成失败:", err)
const errMsg = err instanceof Error ? err.message : "智能封面生成失败,请重试"
message.error(errMsg)
return { cover_url: "", message: errMsg }
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
/* ── 封面弹窗回调 ── */
const handleCoverSelected = useCallback((coverUrl: string) => {
setSelectedCoverUrl(coverUrl || "")
}, [])
const handleOpenCoverModal = useCallback(() => {
if (currentRenderJob?.status !== "completed") {
message.warning("请先完成视频生成")
return
}
setSmartCoverLoading(true)
setShowCoverModal(true)
}, [currentRenderJob])
const handleCloseCoverModal = useCallback(() => {
setShowCoverModal(false)
}, [])
/* ── 自定义上传封面(本地预览,不单独上传;点完成时一起入库) ── */
const handleUploadCover = useCallback(
(file: File) => {
const url = URL.createObjectURL(file)
state.setCoverConfig((prev) => ({
...prev,
mode: "upload",
upload_url: url,
thumbnail_url: url,
}))
setSelectedCoverUrl(url)
},
[state],
)
/* ── 点「完成」:调用 finalize 入库成片库,成功后跳转到成片库 ── */
const handleFinalize = useCallback(async () => {
if (!currentRenderJob?.id) {
message.error("渲染任务不存在")
return
}
if (currentRenderJob.status !== "completed") {
message.warning("请先完成视频生成")
return
}
setFinalizeLoading(true)
try {
const res = await generateSmartCover(videoUrl, 5)
if (res.cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
const res = await finalizeRenderJob(currentRenderJob.id)
if (res.data?.status === "success" || res.data?.status === "already_finalized") {
message.success("已保存到成片库")
navigate("/app/products")
} else {
message.error(res.message || "智能封面生成失败,请稍后重试")
message.error("保存失败,请重试")
}
} catch (err) {
console.error("智能封面生成失败:", err)
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
console.error("finalize 失败:", err)
const errMsg =
(err as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail ||
(err instanceof Error ? err.message : "保存到成片库失败")
message.error(typeof errMsg === "string" ? errMsg : "保存到成片库失败")
} finally {
setSmartCoverLoading(false)
setFinalizeLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.lipsyncJob])
}, [currentRenderJob, navigate])
/* ── 配置汇总 ── */
const coverStatus: "not_ready" | "pending" | "selected" = (() => {
if (
state.coverConfig.smart_cover_url ||
state.coverConfig.thumbnail_url ||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
) {
return "selected"
}
if (currentRenderJob?.status === "completed") return "pending"
return "not_ready"
})()
const summary = {
videoName: state.selectedVideo?.name || null,
voiceName: state.selectedVoice?.name || null,
@@ -308,7 +548,7 @@ const AiAvatarPage: React.FC = () => {
lipsyncStatus: state.lipsyncJob?.status || null,
brollCount: state.bRollSegments.length,
hasTitle: state.titleConfig.title.length > 0,
hasCover: state.coverConfig.enabled,
coverStatus,
}
return (
@@ -342,7 +582,6 @@ const AiAvatarPage: React.FC = () => {
selectedVideo={state.selectedVideo}
onSelectVideo={() => state.setShowAssetPicker(true)}
onRemoveVideo={state.removeVideo}
titleConfig={state.titleConfig}
/>
</div>
</div>
@@ -382,19 +621,33 @@ const AiAvatarPage: React.FC = () => {
onOpenScriptModal={() => state.setShowScriptModal(true)}
/>
<div className="aa-step-btn-row">
<button type="button" className="aa-btn aa-btn--primary" onClick={handleNextStep}>
<button
type="button"
className="aa-btn aa-btn--primary"
onClick={handleGenerateTts}
disabled={state.ttsPreview.status === "generating"}
>
{state.ttsPreview.status === "done" ? "🎵 重新生成配音" : "🎵 生成配音"}
</button>
{state.ttsPreview.status === "done" && (
<button
type="button"
className="aa-btn aa-btn--primary"
onClick={() => setCurrentStep(2)}
style={{ marginLeft: 12 }}
>
</button>
)}
</div>
</div>
</div>
</>
)}
{/* ════ 步骤 2:对口型预览(含插入画面)/ 标题配置 / 封面&生成 ════ */}
{/* ════ 步骤 2:对口型预览 / 标题配置 / 封面&生成 ════ */}
{currentStep === 2 && (
<>
{/* 面板:对口型预览 + 插入画面 */}
<div className={`aa-panel aa-panel--s2-wide${collapsed.lipsync ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("lipsync")}>
<span className="aa-panel__title"></span>
@@ -432,27 +685,98 @@ const AiAvatarPage: React.FC = () => {
</div>
</div>
{/* 面板5:封面 & 生成 */}
{/* 5 &
- //PanelCoverAndGenerate setup
- + 🎬 / */}
<div className={`aa-panel aa-panel--s2${collapsed.cover ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
<span className="aa-panel__title"> & </span>
<span className="aa-panel__title">
{currentRenderJob?.status === "completed" ? "视频已生成" : "封面 & 生成"}
</span>
<span className="aa-panel__toggle"></span>
</div>
<div className="aa-panel__body">
<PanelCoverAndGenerate
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={state.lipsyncJob?.status === "completed"}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
summary={summary}
/>
{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}
summary={summary}
/>
) : (
<div className="aa-finalize-section">
<div
style={{ marginBottom: 8, fontSize: 13, color: "#1a1a2e", fontWeight: 500 }}
>
</div>
<div className="aa-finalize-cover">
{selectedCoverUrl ? (
<img
src={selectedCoverUrl}
alt="封面预览"
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: 8,
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
border: "2px dashed #d9d9d9",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#8c8ca1",
fontSize: 12,
flexDirection: "column",
gap: 4,
}}
>
<span style={{ fontSize: 24 }}>🎬</span>
<span></span>
</div>
)}
</div>
<div
style={{
display: "flex",
gap: 10,
marginTop: 12,
}}
>
<button
type="button"
className="aa-btn aa-btn--ghost"
onClick={handleOpenCoverModal}
>
🎬
</button>
<button
type="button"
className="aa-btn aa-btn--primary"
onClick={handleFinalize}
disabled={finalizeLoading}
>
{finalizeLoading ? "⏳ 保存中..." : "✅ 完成"}
</button>
</div>
</div>
)}
</div>
</div>
</>
@@ -478,19 +802,144 @@ const AiAvatarPage: React.FC = () => {
/>
)}
{/* B-roll 编辑器弹窗 */}
{/* B-roll 编辑器弹窗 — #1845:timings 在对口型完成前就可用(来自 TTS 预合成) */}
{state.showBRollModal && (
<ModalBRollEditor
open={state.showBRollModal}
onClose={() => state.setShowBRollModal(false)}
existingSegments={state.bRollSegments}
scriptText={state.scriptText}
outputDuration={state.lipsyncJob?.output_duration ?? 0}
scriptText={state.lipsyncJob?.script_text || state.scriptText}
outputDuration={bRollDuration}
sentenceTimings={bRollSentenceTimings}
onConfirm={state.addBRollSegment}
onRemove={state.removeBRollSegment}
/>
)}
{/* #1845 TTS 预合成弹窗 */}
{showTtsModal && (
<div className="aa-modal-overlay">
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
{state.ttsPreview.status !== "generating" && (
<button className="aa-modal__close" onClick={handleCancelTts}>
</button>
)}
</div>
<div
className="aa-modal__body"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "40px 20px",
}}
>
{state.ttsPreview.status === "generating" && (
<>
<div className="aa-lipsync-spinner" />
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
</div>
<div
style={{
marginTop: 20,
fontSize: 32,
fontWeight: 700,
color: "#1890ff",
}}
>
{ttsProgress}%
</div>
<div
style={{
marginTop: 12,
width: "80%",
height: 8,
backgroundColor: "#f0f0f0",
borderRadius: 4,
overflow: "hidden",
}}
>
<div
style={{
width: `${ttsProgress}%`,
height: "100%",
backgroundColor: "#1890ff",
borderRadius: 4,
transition: "width 0.3s ease",
}}
/>
</div>
<div style={{ marginTop: 12, fontSize: 13, color: "#8c8ca1" }}>
</div>
</>
)}
{state.ttsPreview.status === "done" && (
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
</div>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
{state.ttsPreview.duration.toFixed(1)}s{" "}
{state.ttsPreview.sentenceTimings.length}
</div>
</>
)}
{state.ttsPreview.status === "failed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}></div>
{ttsErrorMessage && (
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#ff4d4f",
textAlign: "center",
padding: "0 20px",
}}
>
{ttsErrorMessage}
</div>
)}
</>
)}
</div>
<div className="aa-modal__footer">
{state.ttsPreview.status === "generating" && (
<button className="aa-btn aa-btn--danger" onClick={handleCancelTts}>
</button>
)}
{state.ttsPreview.status === "done" && (
<button className="aa-btn aa-btn--primary" onClick={handleTtsNext}>
</button>
)}
{state.ttsPreview.status === "failed" && (
<>
<button className="aa-btn" onClick={handleCancelTts}>
</button>
<button
className="aa-btn aa-btn--primary"
onClick={handleRetryTts}
style={{ marginLeft: 12 }}
>
</button>
</>
)}
</div>
</div>
</div>
)}
{/* 对口型生成弹窗 */}
{showLipsyncModal && (
<div className="aa-modal-overlay">
@@ -623,17 +1072,21 @@ const AiAvatarPage: React.FC = () => {
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
</div>
<div
style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1", textAlign: "center" }}
>
</div>
<button
className="aa-btn"
className="aa-btn aa-btn--primary"
style={{ marginTop: 16 }}
onClick={() => {
setShowRenderModal(false)
navigate("/app/products")
}}
>
📁
🎬
</button>
</>
)}
@@ -664,6 +1117,18 @@ const AiAvatarPage: React.FC = () => {
</div>
</div>
)}
{/* 封面选择弹窗 */}
<ModalCoverSelect
open={showCoverModal}
onClose={handleCloseCoverModal}
renderJob={currentRenderJob}
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) => state.setCoverConfig((prev) => ({ ...prev, ...partial }))}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
onUploadCover={handleUploadCover}
onCoverSelected={handleCoverSelected}
/>
</div>
)
}
+59 -22
View File
@@ -2,7 +2,7 @@
* AI数字人 API #1822
*/
import apiClient from "@/api/client"
import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types"
import type { Script, LipsyncJob, RenderJob, BRollSegment, SentenceTiming } from "../types"
/* ── 文案库 ── */
export const getScripts = async (): Promise<Script[]> => {
@@ -34,17 +34,28 @@ export const getAssetById = async (id: string): Promise<{ file_url?: string; id:
return response.data
}
/* ── 对口型(模式A:TTS 直生,后端内部合成音频;不要先调 TTS 拿 audio_url ── */
/*
* 1. TTS / voice_id + script_text+speed/emotion Celery
* 2. video_url + audio_url+timings+MediaKit
* 3. #1845 previewTts audio_url+sentence_timings
* audio_url + audio_duration + sentence_timings MediaKit
*/
export const createLipsyncJob = async (data: {
/** 人物视频 URLMP4);由素材 id 经 getAssetById 拿 file_url,禁止传 video_asset_id */
/** 人物视频 URLMP4);由素材 id 经 getAssetById 拿 file_url */
video_url: string
/** 音色 ID(预置音色 或 克隆音色 profile UUID,后端会解析 */
voice_id: string
/** 合成的文案(手动输入或文案库内容) */
script_text: string
/** 语速 0.5~2.0,默认 1.0 */
/** 预合成/直接音频模式:音频 URL(#1845 步骤1 预合成的 CosyVoice 临时 URL,或外部音频 URL */
audio_url?: string
/** 合成音频时长(秒),由 previewTts 返回 */
audio_duration?: number
/** 预合成接口返回的句子时间戳(精确),后端直接写入 job */
sentence_timings?: SentenceTiming[]
/** 音色 IDTTS 直生模式用) */
voice_id?: string
/** 要合成的文案(TTS 直生模式用) */
script_text?: string
/** 语速 0.5~2.0,默认 1.0TTS 直生模式用) */
speed?: number
/** 情绪英文枚举:natural/excited/calm/friendly */
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgustedTTS 直生模式用;前端经 normalizeEmotion 归一化) */
emotion?: string
enable_video_loop?: boolean
project_id?: string
@@ -53,21 +64,27 @@ export const createLipsyncJob = async (data: {
return response.data
}
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
/* ── #1845 TTS 预合成(步骤1「生成配音」同步接口,~2-3s) ── */
export const previewTts = async (data: {
voice_id: string
script_text: string
speed?: number
emotion?: string
}): Promise<{
audio_url: string
duration: number
sentence_timings: SentenceTiming[]
}> => {
const response = await apiClient.post<{
audio_url: string
duration: number
sentence_timings: SentenceTiming[]
}>("/lipsync/tts-preview", data, { timeout: 30000 })
return response.data
}
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧,独立于渲染任务) ── */
export const generateSmartCover = async (
video_url: string,
max_frames = 5,
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
"/ai-avatar/render/smart-cover",
{ video_url, max_frames },
{ timeout: 60000 },
)
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
return response.data
}
@@ -80,15 +97,35 @@ export const submitRender = async (data: {
cover_config?: Record<string, unknown>
project_id?: string
}): Promise<RenderJob> => {
// title_config 内可含 title_image_dataurl(前端 Canvas 渲染的 PNG dataURL
const response = await apiClient.post<RenderJob>("/ai-avatar/render", data)
return response.data
}
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`)
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
return response.data
}
export const cancelRenderJob = async (jobId: string): Promise<void> => {
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
}
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover ── */
export const generateRenderSmartCover = async (
jobId: string,
): 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`,
{},
// 抽帧+评分+转存 OSS 链路较长,120s 超时
{ timeout: 120000 },
)
return response.data
}
/* ── 封面选定后点「完成」正式入库(POST /ai-avatar/render/{job_id}/finalize ── */
export const finalizeRenderJob = (renderId: string) =>
apiClient.post<{ video_id: string; cover_url: string; status: string }>(
`/ai-avatar/render/${renderId}/finalize`,
)
@@ -5,12 +5,12 @@
* - video segment 使
* + "已选择" pointer-events:none
* - / or / +
* / ×
* / TTS
* -
*/
import React, { useEffect, useMemo, useState } from "react"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
import type { BRollSegment, BRollInsertMode, PipPosition, SentenceTiming } from "../types"
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
interface ModalBRollEditorProps {
@@ -18,10 +18,12 @@ interface ModalBRollEditorProps {
onClose: () => void
/** 当前已有的 B-roll segments(用于标灰已选素材) */
existingSegments: BRollSegment[]
/** 当前文案全文(用于分句 */
/** 文案全文(优先使用对口型时锁定的 scriptText */
scriptText: string
/** 对口型成片总时长(秒),用于时间自动估算 */
/** 对口型成片总时长(秒) */
outputDuration: number
/** 后端精确句子时间戳(来自 lipsyncJob.sentence_timings */
sentenceTimings?: SentenceTiming[] | null
onConfirm: (segment: BRollSegment) => void
onRemove: (id: string) => void
}
@@ -43,7 +45,8 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
onClose,
existingSegments,
scriptText,
outputDuration,
outputDuration: _outputDuration,
sentenceTimings,
onConfirm,
onRemove,
}) => {
@@ -62,10 +65,10 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
const [pipScale, setPipScale] = useState(0.3)
/** 文案分句( */
/** 文案分句(优先使用后端精确时间戳,降级为字数比例估算 */
const sentences = useMemo(
() => splitScriptIntoSentences(scriptText, outputDuration),
[scriptText, outputDuration],
() => splitScriptIntoSentences(scriptText, sentenceTimings, _outputDuration),
[scriptText, sentenceTimings, _outputDuration],
)
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
@@ -142,7 +145,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
setSelectedAsset(asset)
}
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止 */
/** 确认添加一段 B-roll(⑥ 时间取所选句子的精确起止,后端静音检测 / 前端字数比例降级 */
const handleConfirm = () => {
if (!selectedAsset || !selectedSentence) return
const startTime = selectedSentence.startTime
@@ -264,11 +267,9 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
>
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
<span className="aa-sentence-item__text">{sent.text}</span>
{outputDuration > 0 && (
<span className="aa-sentence-item__time">
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
</span>
)}
<span className="aa-sentence-item__time">
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
</span>
</button>
)
})}
@@ -349,7 +350,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
selectedSentence.endTime,
selectedSentence.startTime + 0.5,
).toFixed(1)}
s
s
</div>
</>
) : (
@@ -0,0 +1,59 @@
/**
* AI数字人
* PanelCoverAndGenerateselect-cover
* + + +
*/
import React from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
interface ModalCoverSelectProps {
open: boolean
onClose: () => void
renderJob: RenderJob | null
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
onUploadCover?: (file: File) => void
onCoverSelected: (coverUrl: string) => void
}
const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
open,
onClose,
renderJob,
coverConfig,
onCoverConfigChange,
onGenerateRenderSmartCover,
onUploadCover,
onCoverSelected,
}) => {
if (!open) return null
return (
<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 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>
</div>
)
}
export default ModalCoverSelect
@@ -1,35 +1,45 @@
/**
* AI数字人 5 &
* - 9:16 /
* - 720p / 1080p / 4K
* - ////B-roll//
* -
* AI数字人 5 /
* - variant="setup" / / 2
* 便
* - variant="select-cover" + +
* ModalCoverSelect onClose
*
* v3 "画面插入模式"
* /B-roll
*/
import React, { useRef } from "react"
import type { AiAvatarCoverConfig } 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
/** 智能获取封面(MediaKit 选帧 */
onSmartCover: () => void
smartCoverLoading: boolean
canSmartCover: boolean
/** 配置汇总信息 */
summary: {
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
scriptLength: number
lipsyncStatus: string | null
brollCount: number
hasTitle: boolean
hasCover: boolean
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
coverStatus: "not_ready" | "pending" | "selected"
}
}
@@ -48,53 +58,116 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
}
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
variant = "setup",
coverConfig,
onCoverConfigChange,
resolution,
resolution = "720p",
onResolutionChange,
isGenerating,
isGenerating = false,
onGenerate,
onSmartCover,
smartCoverLoading,
canSmartCover,
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
// 本地预览:生成 object URL(实际上传由父级/后端链路处理)
if (onUploadCover) {
onUploadCover(file)
e.target.value = ""
return
}
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
const url = URL.createObjectURL(file)
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
// 允许重复选择同一文件
_applyCoverUrl(url, "upload")
e.target.value = ""
}
/** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822 */
const handleSmartCover = () => {
onCoverConfigChange({ mode: "auto_frame" })
onSmartCover()
/** 智能获取封面(从最终成片抽帧;必须等 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 lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
// 渲染已完成 → 封面区可用
const isRenderCompleted = renderJob?.status === "completed"
const canSmartCover = isRenderCompleted && !smartCoverLoading
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
const coverUrl =
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
const hasCoverImage = Boolean(coverUrl)
return (
<div className="aa-cover-generate">
{/* 封面预览(竖屏 9:16 */}
<div className="aa-cover-preview">
{coverConfig.thumbnail_url ? (
<img src={coverConfig.thumbnail_url} alt="封面预览" />
/** 封面区占位文字 */
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
/** 配置摘要中的封面状态标签 */
const coverSummaryNode = (() => {
if (!summary) return null
if (summary.coverStatus === "selected") {
return <span className="aa-config-summary__value"></span>
}
if (summary.coverStatus === "pending") {
return <span className="aa-config-summary__value"></span>
}
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"></span>
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
)}
{smartCoverLoading && <div className="aa-cover-preview__loading"> </div>}
</div>
<div className="aa-cover-actions">
@@ -102,8 +175,8 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={smartCoverLoading || !canSmartCover}
title={canSmartCover ? "基于对口型成片智能选帧" : "请先完成对口型生成"}
disabled={!canSmartCover}
title={isRenderCompleted ? "成片智能选帧" : "请先生成视频"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
@@ -111,6 +184,8 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
disabled={!isRenderCompleted || smartCoverLoading}
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
>
📷
</button>
@@ -122,14 +197,36 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
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">
{/* 分辨率选择 */}
<div className="aa-form-field">
<label className="aa-label"></label>
<select
className="aa-select"
value={resolution}
onChange={(e) => onResolutionChange(e.target.value)}
onChange={(e) => onResolutionChange?.(e.target.value)}
disabled={isGenerating}
>
{RESOLUTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -144,7 +241,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
<div className="aa-config-summary">
<div className="aa-config-summary__row">
<span></span>
{summary.videoName ? (
{summary?.videoName ? (
<span className="aa-config-summary__value">{summary.videoName}</span>
) : (
<span className="aa-config-summary__empty"></span>
@@ -152,7 +249,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div>
<div className="aa-config-summary__row">
<span></span>
{summary.voiceName ? (
{summary?.voiceName ? (
<span className="aa-config-summary__value">{summary.voiceName}</span>
) : (
<span className="aa-config-summary__empty"></span>
@@ -160,7 +257,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div>
<div className="aa-config-summary__row">
<span></span>
{summary.scriptLength > 0 ? (
{summary && summary.scriptLength > 0 ? (
<span className="aa-config-summary__value">{summary.scriptLength} </span>
) : (
<span className="aa-config-summary__empty"></span>
@@ -177,12 +274,12 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
<div className="aa-config-summary__row">
<span>B-roll </span>
<span className="aa-config-summary__value">
{summary.brollCount > 0 ? `${summary.brollCount}` : "无"}
{summary && summary.brollCount > 0 ? `${summary.brollCount}` : "无"}
</span>
</div>
<div className="aa-config-summary__row">
<span></span>
{summary.hasTitle ? (
{summary?.hasTitle ? (
<span className="aa-config-summary__value"></span>
) : (
<span className="aa-config-summary__empty"></span>
@@ -190,11 +287,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div>
<div className="aa-config-summary__row">
<span></span>
{summary.hasCover ? (
<span className="aa-config-summary__value"></span>
) : (
<span className="aa-config-summary__empty"></span>
)}
{coverSummaryNode}
</div>
</div>
@@ -207,11 +300,16 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
>
{isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"}
</button>
{summary.lipsyncStatus !== "completed" && !isGenerating && (
{summary?.lipsyncStatus !== "completed" && !isGenerating && (
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
</div>
)}
{isGenerating && (
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
</div>
)}
</div>
</div>
)
@@ -1,9 +1,9 @@
/**
* AI数字人 2
* B-roll + + /
* v3.1: 预览容器按 1/2
* v3.1: 标题字号按预览容器实际宽度动态计算 previewScale 720p
*/
import React, { useRef } from "react"
import React, { useCallback, useEffect, useRef, useState } from "react"
import type { LipsyncJob, BRollSegment, AiAvatarTitleConfig } from "../types"
interface PanelLipsyncPreviewProps {
@@ -14,8 +14,8 @@ interface PanelLipsyncPreviewProps {
onRemoveBRoll: (id: string) => void
/** 标题配置(实时叠加预览用) */
titleConfig?: AiAvatarTitleConfig
/** 标题位置变更回调(拖拽结束时调用) */
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number }) => void
/** 标题位置变更回调(拖拽结束时调用,发送百分比坐标 + position:"custom" */
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number; position: string }) => void
}
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
@@ -29,6 +29,16 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 字体名 → 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,
onGenerateLipsync,
@@ -41,6 +51,8 @@ export function PanelLipsyncPreview({
const titleDragRef = useRef<HTMLDivElement>(null)
const draggingTitleRef = useRef(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
// 预览容器实际宽度(通过 ResizeObserver 监听),用于动态计算 previewScale
const [containerWidth, setContainerWidth] = useState(0)
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
const isDone = lipsyncJob?.status === "completed"
const isFailed = lipsyncJob?.status === "failed"
@@ -52,29 +64,84 @@ export function PanelLipsyncPreview({
? "排队中…"
: "对口型生成中…"
/** 标题叠加样式 */
const titleOverlayStyle: React.CSSProperties | null = titleConfig?.title
? {
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
color: titleConfig.color || "#ffffff",
fontFamily: titleConfig.font || "思源黑体",
fontSize: `${(titleConfig.size || 36) * 0.55}px`, // 预览等比缩
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
width: "90%",
padding: "4px 8px",
textShadow: titleConfig.shadow ? "0 2px 4px rgba(0,0,0,0.8)" : undefined,
WebkitTextStroke: titleConfig.stroke ? "1.5px #000" : undefined,
...(titleConfig.position === "top"
? { top: 8 }
: titleConfig.position === "bottom"
? { bottom: 8 }
: { top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
}
: null
// 监听预览容器尺寸变化,动态测量宽度以计算 previewScale(基准 720p
useEffect(() => {
const el = previewContainerRef.current
if (!el) return
const update = () => setContainerWidth(el.clientWidth || 0)
update()
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}
window.addEventListener("resize", update)
return () => window.removeEventListener("resize", update)
}, [])
// 预览缩放比:预览宽度 / 720(基准宽度)
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale])
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */
const titleOverlayStyle: React.CSSProperties | null =
titleConfig?.title && containerWidth > 0
? (() => {
const baseSize = titleConfig.size || 48
const fontSize = ps(baseSize)
// 描边宽度基准 ≈ 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: getFontFamily(titleConfig.font || "思源黑体"),
fontSize: `${fontSize}px`,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
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",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}
if (
titleConfig.position === "custom" &&
titleConfig.pos_x != null &&
titleConfig.pos_y != null
) {
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translateX(-50%) translateY(-50%)"
} else if (titleConfig.position === "top") {
style.left = "50%"
style.top = padV
style.transform = "translateX(-50%)"
} else if (titleConfig.position === "bottom") {
style.left = "50%"
style.bottom = padV
style.transform = "translateX(-50%)"
} else {
style.left = "50%"
style.top = "50%"
style.transform = "translateX(-50%) translateY(-50%)"
}
return style
})()
: null
const handleTitlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !previewContainerRef.current) return
@@ -105,7 +172,10 @@ export function PanelLipsyncPreview({
const rect = previewContainerRef.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))
onTitlePositionChange({ pos_x: relX, pos_y: relY })
// 发送百分比坐标(0-100),与后端 drawtext 百分比表达式对齐
const xpct = Math.round((relX / rect.width) * 1000) / 10
const ypct = Math.round((relY / rect.height) * 1000) / 10
onTitlePositionChange({ pos_x: xpct, pos_y: ypct, position: "custom" })
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
}
@@ -174,7 +244,7 @@ export function PanelLipsyncPreview({
)}
</div>
{/* ── 对口型预览(v3.1: 缩放1/2 + 标题叠加 ─ */}
{/* ── 对口型预览(标题字号按 previewScale 动态缩放 ─ */}
<div className="aa-lipsync-section">
<div className="aa-lipsync-section__title"></div>
@@ -2,17 +2,16 @@
* AI数字人
* - 线
* - 9:16 + +
*
*
*/
import type { AssetItem } from "@/api/assets"
import type { AiAvatarTitleConfig } from "../types"
import { getFontFamily } from "@/pages/generate/constants"
export interface PanelVideoSelectorProps {
selectedVideo: AssetItem | null
/** 触发打开素材库弹窗 */
onSelectVideo: () => void
onRemoveVideo: () => void
titleConfig?: AiAvatarTitleConfig
}
/** 格式化时长(秒 → mm:ss */
@@ -27,7 +26,6 @@ export function PanelVideoSelector({
selectedVideo,
onSelectVideo,
onRemoveVideo,
titleConfig,
}: PanelVideoSelectorProps) {
/* 未选视频:虚线上传区,点击打开素材库弹窗 */
if (!selectedVideo) {
@@ -57,42 +55,13 @@ export function PanelVideoSelector({
return (
<div>
{/* 竖屏 9:16 视频预览播放器 + 标题实时预览 */}
<div className="aa-video-preview" style={{ position: "relative" }}>
{/* 竖屏 9:16 视频预览播放器(纯素材预览,不叠加标题) */}
<div className="aa-video-preview">
{fileUrl ? (
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
) : (
<div className="aa-video-preview__placeholder"></div>
)}
{titleConfig?.title && (
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
...(titleConfig.position === "top"
? { top: "10%" }
: titleConfig.position === "bottom"
? { bottom: "10%" }
: { top: "50%", transform: "translate(-50%, -50%)" }),
fontSize: Math.max(titleConfig.size, 32),
fontFamily: getFontFamily(titleConfig.font),
color: titleConfig.color,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textShadow: "0 2px 4px rgba(0,0,0,0.5)",
WebkitTextStroke: "2px #000",
pointerEvents: "none",
zIndex: 10,
maxWidth: "90%",
textAlign: "center",
whiteSpace: "pre-wrap",
lineHeight: 1.3,
}}
>
{titleConfig.title}
</div>
)}
</div>
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
@@ -13,7 +13,8 @@ import {
type VoiceEmotion,
type VoiceLanguage,
VOICE_EMOTION_OPTIONS,
VOICE_LANGUAGE_OPTIONS,
PRESET_VOICE_LANGUAGE_OPTIONS,
CLONE_VOICE_LANGUAGE_OPTIONS,
} from "../types"
interface PanelVoiceSelectorProps {
@@ -91,6 +92,12 @@ export function PanelVoiceSelector({
const NO_PREVIEW_TIP = "该音色暂无试听音频,请先用此音色生成一段配音后再试听"
// 系统预置音色仅支持 zh/en;克隆音色支持全语言
const languageOptions =
voiceSource === "clone" ? CLONE_VOICE_LANGUAGE_OPTIONS : PRESET_VOICE_LANGUAGE_OPTIONS
// 当前语言不在可选列表(切回预置时 ja/ko/cantonese/mandarin 失效)→ 自动回退到中文
const effectiveLanguage = languageOptions.some((o) => o.value === language) ? language : "zh"
/** 用指定 URL 真实播放(抽取公共) */
const playAudioUrl = (voiceId: string, url: string) => {
// 临时兼容:后端 /tts/preview 返回 HTTP URLstaging 是 HTTPSMixed Content 会阻止加载
@@ -139,13 +146,6 @@ export function PanelVoiceSelector({
}
const targetId = voice.voice_clone_profile_id || voice.id
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
console.log("[AI数字人-克隆试听] previewTts 请求:", {
voice_id: targetId,
voice_name: voice.name,
voice_type: voice.type,
voice_clone_profile_id: voice.voice_clone_profile_id,
voice_id_field: voice.voice_id,
})
setPreviewingId(voice.id)
try {
const res = await previewTts({
@@ -154,10 +154,6 @@ export function PanelVoiceSelector({
speed: speed, // 透传用户选择的语速(#1822)
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
})
console.log("[AI数字人-克隆试听] previewTts 响应:", {
audio_url: res.audio_url?.substring(0, 80),
duration: res.duration,
})
if (!res.audio_url) {
setPreviewingId(null)
message.error("合成试听失败:未返回音频")
@@ -300,10 +296,12 @@ export function PanelVoiceSelector({
<select
id="aa-voice-language"
className="aa-select"
value={language}
onChange={(e) => onLanguageChange(e.target.value as VoiceLanguage)}
value={effectiveLanguage}
onChange={(e) => {
onLanguageChange(e.target.value as VoiceLanguage)
}}
>
{VOICE_LANGUAGE_OPTIONS.map((opt) => (
{languageOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
@@ -1,5 +1,5 @@
/**
* AI数字人 hookv3
* AI数字人 hookv3 + #1845
*/
import { useState, useCallback } from "react"
import type { AssetItem } from "@/api/assets"
@@ -13,10 +13,19 @@ import {
type BRollSegment,
type AiAvatarTitleConfig,
type AiAvatarCoverConfig,
type TtsPreviewResult,
DEFAULT_TITLE_CONFIG,
DEFAULT_COVER_CONFIG,
} from "../types"
const DEFAULT_TTS_PREVIEW: TtsPreviewResult = {
audioUrl: null,
duration: 0,
sentenceTimings: [],
status: "idle",
error: null,
}
export function useAiAvatar() {
/* ── 面板1:出镜视频 ── */
const [selectedVideo, setSelectedVideo] = useState<AssetItem | null>(null)
@@ -25,9 +34,9 @@ export function useAiAvatar() {
/* ── 面板2:配音库 ── */
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
const [emotion, setEmotion] = useState<VoiceEmotion>("natural")
const [emotion, setEmotion] = useState<VoiceEmotion>("neutral")
const [speed, setSpeed] = useState(1.0)
const [language, setLanguage] = useState<VoiceLanguage>("mandarin")
const [language, setLanguage] = useState<VoiceLanguage>("zh")
/* ── 面板3:文案 & 对口型 ── */
const [script, setScript] = useState<Script | null>(null)
@@ -36,6 +45,9 @@ export function useAiAvatar() {
const [showScriptModal, setShowScriptModal] = useState(false)
const [showBRollModal, setShowBRollModal] = useState(false)
/* ── #1845 TTS 预合成(步骤1「生成配音」) ── */
const [ttsPreview, setTtsPreview] = useState<TtsPreviewResult>(DEFAULT_TTS_PREVIEW)
/* ── 面板3.5B-roll ── */
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
@@ -81,6 +93,7 @@ export function useAiAvatar() {
setScript(null)
setScriptText("")
setLipsyncJob(null)
setTtsPreview(DEFAULT_TTS_PREVIEW)
setBRollSegments([])
setTitleConfig(DEFAULT_TITLE_CONFIG)
setCoverConfig(DEFAULT_COVER_CONFIG)
@@ -118,6 +131,10 @@ export function useAiAvatar() {
showBRollModal,
setShowBRollModal,
selectScript,
// #1845 TTS 预合成
ttsPreview,
setTtsPreview,
resetTtsPreview: useCallback(() => setTtsPreview(DEFAULT_TTS_PREVIEW), []),
// B-roll
bRollSegments,
addBRollSegment,
+54 -14
View File
@@ -6,28 +6,56 @@ import type { AssetItem } from "@/api/assets"
/* ── 音色来源切换 ── */
export type VoiceSource = "preset" | "clone"
/* ── 情绪 ── */
export type VoiceEmotion = "natural" | "excited" | "calm" | "friendly"
/* ── 情绪(对齐 CosyVoice 7 种情绪) ── */
export type VoiceEmotion =
"neutral" | "happy" | "sad" | "angry" | "surprised" | "fearful" | "disgusted"
export const VOICE_EMOTION_OPTIONS: { value: VoiceEmotion; label: string }[] = [
{ value: "natural", label: "自然" },
{ value: "excited", label: "兴奋" },
{ value: "calm", label: "沉稳" },
{ value: "friendly", label: "亲切" },
{ value: "neutral", label: "自然" },
{ value: "happy", label: "开心" },
{ value: "sad", label: "难过" },
{ value: "angry", label: "生气" },
{ value: "surprised", label: "惊讶" },
{ value: "fearful", label: "恐惧" },
{ value: "disgusted", label: "厌恶" },
]
/* ── 语言 ── */
export type VoiceLanguage = "mandarin" | "english" | "cantonese"
/** 系统预置音色支持的语言(zh/en) */
export type PresetVoiceLanguage = "zh" | "en"
/** 克隆音色支持的完整语言列表 */
export type CloneVoiceLanguage = "zh" | "en" | "ja" | "ko"
export type VoiceLanguage = PresetVoiceLanguage | CloneVoiceLanguage
export const VOICE_LANGUAGE_OPTIONS: { value: VoiceLanguage; label: string }[] = [
{ value: "mandarin", label: "普通话" },
{ value: "english", label: "English" },
{ value: "cantonese", label: "粤语" },
export const PRESET_VOICE_LANGUAGE_OPTIONS: { value: PresetVoiceLanguage; label: string }[] = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]
export const CLONE_VOICE_LANGUAGE_OPTIONS: { value: CloneVoiceLanguage; label: string }[] = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
{ value: "ja", label: "日本語" },
{ value: "ko", label: "한국어" },
]
/** 默认(预置音色)语言选项 */
export const VOICE_LANGUAGE_OPTIONS = PRESET_VOICE_LANGUAGE_OPTIONS
/* ── 对口型任务状态 ── */
export type LipsyncStatus = "idle" | "pending" | "processing" | "completed" | "failed"
/* ── TTS 预合成(#1845 配音前置:步骤1「生成配音」状态) ── */
export type TtsPreviewStatus = "idle" | "generating" | "done" | "failed"
export interface TtsPreviewResult {
audioUrl: string | null
duration: number
sentenceTimings: SentenceTiming[]
status: TtsPreviewStatus
error: string | null
}
/* ── 文案 ── */
export interface Script {
id: string
@@ -44,12 +72,23 @@ export interface LipsyncJob {
status: LipsyncStatus
progress: number
output_video_url: string | null
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
/** 对口型成片总时长(秒),后端返回 */
script_text: string
output_duration?: number
/** 精确句子时间戳(后端基于 TTS 音频静音检测计算) */
sentence_timings?: SentenceTiming[] | null
error_message: string | null
created_at: string
}
/* ── 句子时间戳(后端精确计算) ── */
export interface SentenceTiming {
index: number
text: string
start_time: number
end_time: number
}
/* ── B-roll 画面插入 ── */
export type BRollInsertMode = "fullscreen" | "pip"
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
@@ -77,7 +116,7 @@ export interface AiAvatarTitleConfig {
shadow: boolean
color: string
auto_subtitle: boolean
/** 自定义位置坐标(position=custom 时生效,像素 */
/** 自定义位置坐标(position=custom 时生效,百分比 0-100 */
pos_x?: number
pos_y?: number
}
@@ -101,6 +140,7 @@ export interface RenderJob {
status: RenderStatus
progress: number
output_video_url: string | null
output_cover_url: string | null
error_message: string | null
created_at: string
}
@@ -110,7 +150,7 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
size: 48,
bold: true,
italic: false,
stroke: false,
+55 -14
View File
@@ -6,21 +6,50 @@
*/
import type { AiAvatarTitleConfig, AiAvatarCoverConfig, VoiceEmotion } from "../types"
/* ── 情绪:中文 → 英文(防御性映射;state 默认已是英文) ── */
const EMOTION_ZH_TO_EN: Record<string, VoiceEmotion> = {
: "natural",
: "excited",
: "calm",
: "friendly",
/* ── 情绪:中文/旧枚举 → CosyVoice 7 种英文枚举 ── */
const EMOTION_ALIAS: Record<string, VoiceEmotion> = {
// 新英文枚举
neutral: "neutral",
happy: "happy",
sad: "sad",
angry: "angry",
surprised: "surprised",
fearful: "fearful",
disgusted: "disgusted",
// 旧英文枚举(4 种,向前兼容)
natural: "neutral",
excited: "happy",
calm: "neutral",
friendly: "happy",
// 中文
: "neutral",
: "happy",
: "sad",
: "angry",
: "surprised",
: "fearful",
: "disgusted",
// 旧中文
: "happy",
: "neutral",
: "happy",
}
const VALID_EMOTIONS: VoiceEmotion[] = ["natural", "excited", "calm", "friendly"]
const VALID_EMOTIONS: VoiceEmotion[] = [
"neutral",
"happy",
"sad",
"angry",
"surprised",
"fearful",
"disgusted",
]
/** 归一化为后端英文枚举 natural/excited/calm/friendly;非法/空值回退 natural。 */
/** 归一化为后端英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted;非法/空值回退 neutral。 */
export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
if (!raw) return "natural"
if (!raw) return "neutral"
const v = raw.trim()
if ((VALID_EMOTIONS as string[]).includes(v)) return v as VoiceEmotion
return EMOTION_ZH_TO_EN[v] ?? "natural"
return EMOTION_ALIAS[v] ?? "neutral"
}
/* ── 标题:前端 state → 后端 build_title_drawtext_filter 字段(单个 title_config dict ── */
@@ -28,10 +57,13 @@ export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
* text(content)font(font_preset)font_size(size)
* font_color(color #RRGGBB)position(top/center/bottom/custom)
* enabledboldstroke{enabled,width,color}shadow{enabled,color,offset_x,offset_y}
* pos_x/pos_y(custom )
* pos_x/pos_y(custom )title_image_dataurl Canvas PNG dataURLWYSIWYG
* position=bottom top
*/
export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string, unknown> {
export function buildTitleConfigPayload(
cfg: AiAvatarTitleConfig,
titleImageDataUrl?: string | null,
): Record<string, unknown> {
const text = (cfg.title || "").trim()
if (!text) return {}
const position = cfg.position || "bottom"
@@ -39,7 +71,7 @@ export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string
text,
enabled: true,
font: cfg.font || "思源黑体",
font_size: Math.round(cfg.size) || 36,
font_size: Math.round(cfg.size) || 48,
font_color: cfg.color || "#ffffff",
position,
bold: !!cfg.bold,
@@ -53,6 +85,10 @@ export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string
payload.pos_x = cfg.pos_x
payload.pos_y = cfg.pos_y
}
// 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层)
if (titleImageDataUrl) {
payload.title_image_dataurl = titleImageDataUrl
}
return payload
}
@@ -67,9 +103,14 @@ export function buildCoverConfigPayload(
// build_cover_extract_command 读取 timestamp(截帧秒数)
timestamp: cfg.frame_time || 0,
}
if (smartCoverUrl) payload.cover_url = smartCoverUrl
// 智能封面 URL(后端字段名为 url/imageUrl/cover_url 都兼容,优先 url
if (smartCoverUrl) {
payload.url = smartCoverUrl
payload.cover_url = smartCoverUrl
}
// 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用
if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) {
payload.url = cfg.upload_url
payload.upload_url = cfg.upload_url
}
return payload
@@ -1,5 +1,10 @@
/**
* AI数字人 & B-roll #1809
* AI数字人 & B-roll
*
*
* 1. sentence_timings TTS 使
* 2. output_duration +
* 3. startTime/endTime 0
*/
export interface ScriptSentence {
@@ -11,25 +16,67 @@ export interface ScriptSentence {
charCount: number
/** 累计起始字数(用于时间估算) */
startChar: number
/** 估算的对口型视频内起始时间(秒) */
/** 对口型视频内起始时间(秒)——后端精确值或前端估算 */
startTime: number
/** 估算的对口型视频内结束时间(秒) */
/** 对口型视频内结束时间(秒)——后端精确值或前端估算 */
endTime: number
}
/** 句子分隔符:中英文句号/问号/感叹号/分号/换行(按句断,不在逗号处切;保持与后端一致) */
const SENTENCE_SPLIT_RE = /[。!?!??!;\n\r]+/
/**
* ////
* ÷ × 线
*
*
* @param sentenceTimings lipsync_job.sentence_timings
*
* @param outputDuration 0 0
*/
export function splitScriptIntoSentences(
scriptText: string,
outputDuration: number,
sentenceTimings?:
{ index?: number; text?: string; start_time: number; end_time: number }[] | null,
outputDuration: number = 0,
): ScriptSentence[] {
const text = (scriptText || "").trim()
if (!text) return []
// 1. 后端返回了 sentence_timings:校验通过就直接用,跳过本地分句
// 校验条件放宽:只要是数组、至少1条、每条 start_time/end_time 是数字即可
// (不再强制要求条数相等——后端静音检测可能按停顿切出更多/更少边界,
// 比如文案用逗号连写时本地只分1句、后端按停顿切4句,后端的切法才是对的)
if (Array.isArray(sentenceTimings) && sentenceTimings.length > 0) {
const valid = sentenceTimings.every(
(t) =>
t &&
typeof t.start_time === "number" &&
typeof t.end_time === "number" &&
isFinite(t.start_time) &&
isFinite(t.end_time) &&
t.end_time >= t.start_time,
)
if (valid) {
let accChar = 0
return sentenceTimings.map((t, i) => {
const sentenceText = (t.text || "").trim() || `句子${i + 1}`
const charCount = sentenceText.replace(/\s/g, "").length
const sentence: ScriptSentence = {
index: typeof t.index === "number" ? t.index : i,
text: sentenceText,
charCount,
startChar: accChar,
startTime: round1(t.start_time),
endTime: round1(t.end_time),
}
accChar += charCount
return sentence
})
}
}
// 2. 本地分句 + 按字数比例估算(降级路径)
const rawParts = text
.split(/[。!?!?;\n\r]+/)
.split(SENTENCE_SPLIT_RE)
.map((part) => part.trim())
.filter((part) => part.length > 0)
@@ -0,0 +1,179 @@
/**
* AI数字人 Canvas
*
* HTML/CSS PNG
* dataURL FFmpeg overlay
* HTML/CSS FFmpeg drawtext WYSIWYG
*
* titleConfig.size "720p 基准宽度下的字号(px"
* videoWidth / 720 scale scale
* 1080p / 4K
*/
import type { AiAvatarTitleConfig } from "../types"
export interface RenderTitlePngOptions {
/** 标题配置 */
titleConfig: AiAvatarTitleConfig
/** 视频宽度(像素),默认 720 */
videoWidth?: number
/** 视频高度(像素),默认 1280 */
videoHeight?: number
}
/**
* PNG dataURLdata: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 (lines.length === 0) return null
// 分辨率缩放系数:基准 720p,所有长度类参数乘以 scale
const scale = videoWidth / 720
const r = (v: number) => Math.round(v * scale)
const canvas = document.createElement("canvas")
canvas.width = videoWidth
canvas.height = videoHeight
const ctx = canvas.getContext("2d")
if (!ctx) return null
const baseSize = Math.max(12, Math.round(titleConfig.size || 48))
const size = r(baseSize)
const bold = !!titleConfig.bold
const italic = !!titleConfig.italic
const color = titleConfig.color || "#ffffff"
const stroke = !!titleConfig.stroke
const shadow = !!titleConfig.shadow
// 字体族 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"
// 阴影(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 = titleConfig.position || "bottom"
const lineGap = size * 1.2
const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度
// 文本块顶部 ytextBaseline=middle 时首行基线)
let firstLineY: number
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") {
// 顶部: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
}
// 描边参数:描边 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
if (doStroke) {
const prevShadowColor = ctx.shadowColor
const prevShadowBlur = ctx.shadowBlur
// 描边不要带阴影(避免黑色描边发虚)
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = strokeWidth
ctx.strokeStyle = "#000000"
ctx.lineJoin = "round"
ctx.strokeText(line, centerX, y)
// 恢复阴影
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)
})
try {
return canvas.toDataURL("image/png")
} catch {
return null
}
}
/**
* HTMLVideoElement + loadedmetadata 3 720×1280
*/
export function getVideoResolution(
videoUrl: string,
timeoutMs = 3000,
): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
if (!videoUrl) {
resolve({ width: 720, height: 1280 })
return
}
const video = document.createElement("video")
video.preload = "metadata"
video.muted = true
video.playsInline = true
video.crossOrigin = "anonymous"
let settled = false
const done = (w: number, h: number) => {
if (settled) return
settled = true
video.removeAttribute("src")
video.load()
resolve({ width: w, height: h })
}
const timer = window.setTimeout(() => done(720, 1280), timeoutMs)
video.onloadedmetadata = () => {
window.clearTimeout(timer)
const w = video.videoWidth || 720
const h = video.videoHeight || 1280
done(w, h)
}
video.onerror = () => {
window.clearTimeout(timer)
done(720, 1280)
}
video.src = videoUrl
})
}
File diff suppressed because it is too large Load Diff
@@ -1,326 +0,0 @@
/**
* /
* (42px) (48px) (40px)
*
* Hook
* hooks/useGlobalSettings
* hooks/useVoiceMaterials
* hooks/useUndoRedo
* hooks/useEditorDrawers
* hooks/usePlaybackControl
* hooks/useClipOperations
* hooks/useTemplateManagement
*/
import React, { useState } from "react"
import { useSearchParams } from "react-router-dom"
import { MODE_LABELS } from "@/api/editing-planner"
import { MODE_LIST } from "./constants"
import MediaPanel from "./components/MediaPanel"
import PreviewPlayer from "./components/PreviewPlayer"
import TimelinePanel from "./components/TimelinePanel"
import TopBar from "./components/TopBar"
import ModeBar from "./components/ModeBar"
import RightPanel from "./components/RightPanel"
import StatusBar from "./components/StatusBar"
import EditorDrawers from "./components/EditorDrawers"
import SaveModal from "./components/SaveModal"
import { useUndoRedo } from "./hooks/useUndoRedo"
import { useEditorDrawers } from "./hooks/useEditorDrawers"
import { usePlaybackControl } from "./hooks/usePlaybackControl"
import { useClipOperations } from "./hooks/useClipOperations"
import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement"
import { useGlobalSettings } from "./hooks/useGlobalSettings"
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
import type { ClipData } from "./types"
import "./EditingPlanner.css"
const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams()
const urlTemplateId = searchParams.get("templateId") || ""
const urlPlanId = searchParams.get("planId") || ""
/* ── 片段(撤销/重做) ── */
const {
state: clips,
set: setClips,
undo,
redo,
canUndo,
canRedo,
reset: resetClips,
} = useUndoRedo<ClipData[]>([])
/* ── 全局配置 ── */
const {
titleConfig,
setTitleConfig,
subtitleSettings,
setSubtitleSettings,
bgmSettings,
setBgmSettings,
watermarkSettings,
setWatermarkSettings,
introOutroSettings,
setIntroOutroSettings,
pipSettings,
setPipSettings,
filterSettings,
setFilterSettings,
chromaKeySettings,
setChromaKeySettings,
stickerSettings,
setStickerSettings,
} = useGlobalSettings()
/* ── 右侧栏 Tab ── */
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
/* ── 配音素材 ── */
const {
voiceMaterials,
loading: voiceMaterialsLoading,
refetch: refetchVoiceMaterials,
} = useVoiceMaterials()
/* ── 派生计算 ── */
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0)
/* ── Hook: 抽屉管理 ── */
const drawers = useEditorDrawers()
/* ── Hook: 播放控制 ── */
const playback = usePlaybackControl(totalDuration)
/* ── Hook: 片段操作 ── */
const clipOps = useClipOperations({ clips, setClips })
/* ── Hook: 模板管理 ── */
const tpl = useTemplateManagement({
urlTemplateId,
urlPlanId,
resetClips,
setClips,
setSelectedClipId: clipOps.setSelectedClipId,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
})
/* ──────────── 渲染 ──────────── */
return (
<div className="ep-v8-root">
{/* ═══ 第1行:顶栏 42px ═══ */}
<TopBar
currentTemplate={tpl.currentTemplate}
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
onOpenSaveModal={tpl.handleOpenSaveModal}
/>
{/* ═══ 第2行:模式栏 56px ═══ */}
<ModeBar
modeList={MODE_LIST}
currentMode={tpl.currentMode}
onModeChange={tpl.handleModeChange}
/>
{/* ═══ 第3行:三栏主体 ═══ */}
<div className="ep-main-body">
{/* 左栏 220px:模板列表 */}
<MediaPanel
templates={tpl.filteredTemplates}
loading={tpl.loadingTemplates}
searchQuery={tpl.searchQuery}
currentFilter={tpl.currentFilter}
filterCategories={FILTER_CATEGORIES}
loadedTemplateId={tpl.loadedTemplateId}
onLoadTemplate={tpl.handleLoadTemplate}
onSearchChange={tpl.setSearchQuery}
onFilterChange={tpl.setCurrentFilter}
/>
{/* 中栏 flex-1 */}
<div className="ep-center-col">
{/* 上半部:视频预览 + 封面预览 */}
<PreviewPlayer
clips={clips}
selectedClipId={clipOps.selectedClipId}
isPlaying={playback.isPlaying}
titleConfig={titleConfig}
subtitleSettings={{
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
}}
onClipSelect={clipOps.handleClipSelect}
onPlayPause={() => playback.setIsPlaying(!playback.isPlaying)}
/>
{/* 下半部:水平时间线 */}
<TimelinePanel
clips={clips}
selectedClipId={clipOps.selectedClipId}
currentMode={tpl.currentMode}
onClipSelect={clipOps.handleClipSelect}
onClipReorder={clipOps.handleClipReorder}
onClipRemove={clipOps.handleClipRemove}
onAddClip={clipOps.handleAddClip}
onClipTrim={clipOps.handleClipTrim}
onClipSplit={clipOps.handleClipSplit}
onClipResetTrim={clipOps.handleClipResetTrim}
currentTime={playback.currentTime}
pixelsPerSecond={playback.pixelsPerSecond}
onZoomChange={playback.handleZoomChange}
onSeek={playback.handleSeek}
totalDuration={totalDuration}
/>
</div>
{/* 右栏 260px:设置面板 */}
<RightPanel
titleConfig={titleConfig}
onTitleConfigChange={setTitleConfig}
rightTab={rightTab}
onTabChange={setRightTab}
selectedClip={clipOps.selectedClip}
subtitleSettings={subtitleSettings}
bgmSettings={bgmSettings}
clipsCount={clips.length}
totalDuration={totalDuration}
currentMode={tpl.currentMode}
onSubtitleSettingsChange={(partial) =>
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
}
onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))}
onClipUpdate={clipOps.handleClipUpdate}
onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)}
onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onRefreshVoiceMaterials={refetchVoiceMaterials}
onClipVoiceSelect={clipOps.handleClipVoiceSelect}
onOpenTransitionDrawer={drawers.openTransitionDrawer}
onOpenSpeedDrawer={drawers.openSpeedDrawer}
onOpenTtsDrawer={drawers.openTtsDrawer}
onOpenWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(true)}
onOpenIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(true)}
onOpenPipDrawer={() => drawers.setPipDrawerOpen(true)}
onOpenFilterDrawer={() => drawers.setFilterDrawerOpen(true)}
onOpenGreenScreenDrawer={() => drawers.setChromaKeyDrawerOpen(true)}
onOpenStickerDrawer={() => drawers.setStickerDrawerOpen(true)}
clips={clips}
selectedClipId={clipOps.selectedClipId}
onClipSelect={clipOps.handleClipSelect}
onClipMoveUp={(clipId) => {
const idx = clips.findIndex((c) => c.id === clipId)
if (idx > 0) clipOps.handleClipReorder(idx, idx - 1)
}}
onClipMoveDown={(clipId) => {
const idx = clips.findIndex((c) => c.id === clipId)
if (idx < clips.length - 1) clipOps.handleClipReorder(idx, idx + 1)
}}
onClipRemove={clipOps.handleClipRemove}
onClipAdd={() => clipOps.handleAddClip("pip", 3)}
/>
</div>
{/* ═══ 第4行:底栏 40px ═══ */}
<StatusBar
clipsCount={clips.length}
totalDuration={totalDuration}
currentModeLabel={MODE_LABELS[tpl.currentMode]}
templateSegments={tpl.currentTemplate?.segments.length || 0}
/>
{/* ═══ 弹窗 ═══ */}
<SaveModal
open={tpl.saveModalOpen}
loading={tpl.saveLoading}
isUpdate={!!tpl.loadedTemplateId}
draftName={tpl.draftName}
draftCategory={tpl.draftCategory}
draftTags={tpl.draftTags}
categories={tpl.categories}
estimatedDuration={totalDuration}
onNameChange={tpl.setDraftName}
onCategoryChange={tpl.setDraftCategory}
onTagsChange={tpl.setDraftTags}
onSave={tpl.handleSave}
onCancel={() => tpl.setSaveModalOpen(false)}
/>
{/* ═══ Drawer 集合 ═══ */}
<EditorDrawers
bgmDrawerOpen={drawers.bgmDrawerOpen}
bgmSettings={bgmSettings}
onBgmSettingsChange={setBgmSettings}
onCloseBgmDrawer={() => drawers.setBgmDrawerOpen(false)}
subtitleDrawerOpen={drawers.subtitleDrawerOpen}
subtitleSettings={subtitleSettings}
onSubtitleSettingsChange={setSubtitleSettings}
onCloseSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(false)}
transitionDrawerOpen={drawers.transitionDrawerOpen}
transitionTargetClipId={drawers.transitionTargetClipId}
clips={clips}
onTransitionChange={(config) =>
clipOps.handleTransitionChange(drawers.transitionTargetClipId, config)
}
onCloseTransitionDrawer={() => drawers.setTransitionDrawerOpen(false)}
speedDrawerOpen={drawers.speedDrawerOpen}
speedTargetClipId={drawers.speedTargetClipId}
onSpeedChange={(config) => clipOps.handleSpeedChange(drawers.speedTargetClipId, config)}
onApplySpeedAll={clipOps.handleApplySpeedAll}
onCloseSpeedDrawer={() => drawers.setSpeedDrawerOpen(false)}
ttsDrawerOpen={drawers.ttsDrawerOpen}
ttsTargetClipId={drawers.ttsTargetClipId}
onTtsChange={(config) => clipOps.handleTtsChange(drawers.ttsTargetClipId, config)}
onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)}
watermarkDrawerOpen={drawers.watermarkDrawerOpen}
watermarkSettings={watermarkSettings}
onWatermarkChange={setWatermarkSettings}
onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)}
introOutroDrawerOpen={drawers.introOutroDrawerOpen}
introOutroSettings={introOutroSettings}
onIntroOutroChange={setIntroOutroSettings}
onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)}
pipDrawerOpen={drawers.pipDrawerOpen}
pipSettings={pipSettings}
totalDuration={totalDuration}
onPipChange={setPipSettings}
onClosePipDrawer={() => drawers.setPipDrawerOpen(false)}
filterDrawerOpen={drawers.filterDrawerOpen}
filterSettings={filterSettings}
onFilterChange={setFilterSettings}
onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)}
chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen}
chromaKeySettings={chromaKeySettings}
onChromaKeyChange={setChromaKeySettings}
onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)}
stickerDrawerOpen={drawers.stickerDrawerOpen}
stickerSettings={stickerSettings}
onStickerChange={setStickerSettings}
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
/>
</div>
)
}
export default EditingPlanner
@@ -1,5 +0,0 @@
/**
* BGM
* ./bgm-selector/
*/
export { default } from "./bgm-selector"
@@ -1,182 +0,0 @@
/**
* V8 1:1
* + BGM设置 +
*/
import React from "react"
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
import TitleSettingsSection from "./clip-properties/TitleSettingsSection"
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
import ClipDetailSection from "./clip-properties/ClipDetailSection"
import StatsSection from "./clip-properties/StatsSection"
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
titleConfig,
onTitleConfigChange,
selectedClip,
subtitleSettings,
bgmSettings,
clipsCount,
totalDuration,
currentMode,
onSubtitleSettingsChange,
onBgmSettingsChange: _onBgmSettingsChange,
onClipUpdate,
onOpenBgmDrawer,
onOpenSubtitleDrawer,
voiceMaterials = [],
voiceMaterialsLoading = false,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
onOpenWatermarkDrawer,
onOpenIntroOutroDrawer,
onOpenPipDrawer,
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
}) => {
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
return (
<div className="ep-right-panel">
{/* ═══ 标题设置 — #1789 ═══ */}
{titleConfig && onTitleConfigChange && (
<TitleSettingsSection config={titleConfig} onChange={onTitleConfigChange} />
)}
{/* ═══ 字幕设置 ═══ */}
<SubtitleSettingsSection
settings={subtitleSettings}
onChange={onSubtitleSettingsChange}
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
/>
{/* ═══ BGM 设置 ═══ */}
<BgmSettingsSection settings={bgmSettings} onOpenBgmDrawer={onOpenBgmDrawer} />
{/* ═══ 水印设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🔖</span>
</div>
{onOpenWatermarkDrawer && (
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
<span className="ep-advanced-btn-icon">🔖</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片头片尾设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎬</span>
</div>
{onOpenIntroOutroDrawer && (
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 混剪 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenPipDrawer && (
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 滤镜调色 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎨</span>
</div>
{onOpenFilterDrawer && (
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
<span className="ep-advanced-btn-icon">🎨</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 绿幕抠像 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🟩</span>
绿
</div>
{onOpenGreenScreenDrawer && (
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
<span className="ep-advanced-btn-icon">🟩</span>
<span className="ep-advanced-btn-label">绿</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 贴纸 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🏷</span>
</div>
{onOpenStickerDrawer && (
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
<span className="ep-advanced-btn-icon">🏷</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片段详情(选中时显示) ═══ */}
{selectedClip && (
<ClipDetailSection
clip={selectedClip}
currentMode={currentMode}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onClipUpdate={onClipUpdate}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
previewingId={previewingId}
onPreviewVoice={handlePreviewVoice}
onStopPreview={stopPreview}
/>
)}
{/* ═══ 统计信息(未选中时显示) ═══ */}
{!selectedClip && (
<StatsSection
clipsCount={clipsCount}
totalDuration={totalDuration}
currentMode={currentMode}
/>
)}
</div>
)
}
export default ClipPropertiesPanel
@@ -1,106 +0,0 @@
/**
* Drawer
* state
*/
import React from "react"
import SaveModal from "./SaveModal"
import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers"
import { GlobalDrawers } from "./editing-drawers/GlobalDrawers"
import type { EditingDrawersProps } from "./editing-drawers/types"
const EditingDrawers: React.FC<EditingDrawersProps> = (props) => {
const {
saveModalOpen,
saveLoading,
isUpdate,
draftName,
draftCategory,
draftTags,
categories,
estimatedDuration,
onNameChange,
onCategoryChange,
onTagsChange,
onSave,
onCancelSave,
clips,
} = props
return (
<>
{/* 保存弹窗 */}
<SaveModal
open={saveModalOpen}
loading={saveLoading}
isUpdate={isUpdate}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={estimatedDuration}
onNameChange={onNameChange}
onCategoryChange={onCategoryChange}
onTagsChange={onTagsChange}
onSave={onSave}
onCancel={onCancelSave}
/>
{/* 片段级抽屉(转场/调速/TTS) */}
<ClipLevelDrawers
clips={clips}
transitionDrawerOpen={props.transitionDrawerOpen}
transitionTargetClipId={props.transitionTargetClipId}
onCloseTransitionDrawer={props.onCloseTransitionDrawer}
onTransitionChange={props.onTransitionChange}
speedDrawerOpen={props.speedDrawerOpen}
speedTargetClipId={props.speedTargetClipId}
onCloseSpeedDrawer={props.onCloseSpeedDrawer}
onSpeedChange={props.onSpeedChange}
onApplySpeedAll={props.onApplySpeedAll}
ttsDrawerOpen={props.ttsDrawerOpen}
ttsTargetClipId={props.ttsTargetClipId}
onCloseTtsDrawer={props.onCloseTtsDrawer}
onTtsChange={props.onTtsChange}
/>
{/* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) */}
<GlobalDrawers
bgmDrawerOpen={props.bgmDrawerOpen}
bgmSettings={props.bgmSettings}
onCloseBgmDrawer={props.onCloseBgmDrawer}
onChangeBgmSettings={props.onChangeBgmSettings}
subtitleDrawerOpen={props.subtitleDrawerOpen}
subtitleSettings={props.subtitleSettings}
onCloseSubtitleDrawer={props.onCloseSubtitleDrawer}
onChangeSubtitleSettings={props.onChangeSubtitleSettings}
totalDuration={props.totalDuration}
watermarkDrawerOpen={props.watermarkDrawerOpen}
watermarkSettings={props.watermarkSettings}
onCloseWatermarkDrawer={props.onCloseWatermarkDrawer}
onWatermarkChange={props.onWatermarkChange}
introOutroDrawerOpen={props.introOutroDrawerOpen}
introOutroSettings={props.introOutroSettings}
onCloseIntroOutroDrawer={props.onCloseIntroOutroDrawer}
onIntroOutroChange={props.onIntroOutroChange}
pipDrawerOpen={props.pipDrawerOpen}
pipSettings={props.pipSettings}
onClosePipDrawer={props.onClosePipDrawer}
onPipChange={props.onPipChange}
filterDrawerOpen={props.filterDrawerOpen}
filterSettings={props.filterSettings}
onCloseFilterDrawer={props.onCloseFilterDrawer}
onFilterChange={props.onFilterChange}
chromaKeyDrawerOpen={props.chromaKeyDrawerOpen}
chromaKeySettings={props.chromaKeySettings}
onCloseChromaKeyDrawer={props.onCloseChromaKeyDrawer}
onChromaKeyChange={props.onChromaKeyChange}
stickerDrawerOpen={props.stickerDrawerOpen}
stickerSettings={props.stickerSettings}
onCloseStickerDrawer={props.onCloseStickerDrawer}
onStickerChange={props.onStickerChange}
/>
</>
)
}
export default EditingDrawers
@@ -1,148 +0,0 @@
/**
* Tab
*
*/
import React from "react"
import { Button, Tooltip, Empty } from "antd"
import {
UpOutlined,
DownOutlined,
DeleteOutlined,
PlusOutlined,
ScissorOutlined,
SoundOutlined,
PictureOutlined,
VideoCameraOutlined,
} from "@ant-design/icons"
import type { ClipData, ClipType } from "../types"
interface EditorClipListProps {
clips: ClipData[]
selectedClipId: string | null
onSelect: (clipId: string) => void
onMoveUp: (clipId: string) => void
onMoveDown: (clipId: string) => void
onRemove: (clipId: string) => void
onAdd: () => void
}
const clipTypeIcon: Record<ClipType | string, React.ReactNode> = {
video: <VideoCameraOutlined />,
image: <PictureOutlined />,
voice: <SoundOutlined />,
pip: <ScissorOutlined />,
}
const clipTypeLabel: Record<ClipType | string, string> = {
video: "视频",
image: "图片",
voice: "配音",
pip: "混剪",
}
const EditorClipList: React.FC<EditorClipListProps> = ({
clips,
selectedClipId,
onSelect,
onMoveUp,
onMoveDown,
onRemove,
onAdd,
}) => {
if (clips.length === 0) {
return (
<div className="ep-clip-list-empty">
<Empty
description="暂无片段"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ margin: "40px 0" }}
/>
<Button type="primary" icon={<PlusOutlined />} block onClick={onAdd}>
</Button>
</div>
)
}
return (
<div className="ep-clip-list">
{/* 顶部工具栏 */}
<div className="ep-clip-list-toolbar">
<span className="ep-clip-list-count">
<b>{clips.length}</b>
</span>
<Tooltip title="添加片段">
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
</Button>
</Tooltip>
</div>
{/* 片段列表 */}
<div className="ep-clip-list-scroll">
{clips.map((clip, index) => (
<div
key={clip.id}
className={`ep-clip-list-item${selectedClipId === clip.id ? " selected" : ""}`}
onClick={() => onSelect(clip.id)}
>
{/* 序号 + 类型图标 */}
<div className="ep-clip-item-head">
<span className="ep-clip-item-index">{index + 1}</span>
<span className="ep-clip-item-type">
{clipTypeIcon[clip.type] || <ScissorOutlined />}
<span className="ep-clip-item-type-label">
{clipTypeLabel[clip.type] || "片段"}
</span>
</span>
</div>
{/* 文案预览 */}
{clip.script_text && (
<div className="ep-clip-item-text">
{clip.script_text.slice(0, 40)}
{clip.script_text.length > 40 ? "..." : ""}
</div>
)}
{/* 操作按钮 */}
<div className="ep-clip-item-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title="上移">
<Button
type="text"
size="small"
icon={<UpOutlined />}
disabled={index === 0}
onClick={() => onMoveUp(clip.id)}
className="ep-clip-item-btn"
/>
</Tooltip>
<Tooltip title="下移">
<Button
type="text"
size="small"
icon={<DownOutlined />}
disabled={index === clips.length - 1}
onClick={() => onMoveDown(clip.id)}
className="ep-clip-item-btn"
/>
</Tooltip>
<Tooltip title="删除">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => onRemove(clip.id)}
className="ep-clip-item-btn"
/>
</Tooltip>
</div>
</div>
))}
</div>
</div>
)
}
export default EditorClipList
@@ -1,246 +0,0 @@
import React from "react"
import BgmSelector from "./BgmSelector"
import SubtitleStylePanel from "./SubtitleStylePanel"
import TransitionSelector from "./TransitionSelector"
import SpeedPanel from "./SpeedPanel"
import TtsPanel from "./TtsPanel"
import WatermarkPanel from "./WatermarkPanel"
import IntroOutroPanel from "./IntroOutroPanel"
import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
interface EditorDrawersProps {
// BGM
bgmDrawerOpen: boolean
bgmSettings: BgmMixConfig
onBgmSettingsChange: (config: BgmMixConfig) => void
onCloseBgmDrawer: () => void
// 字幕
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onSubtitleSettingsChange: (config: SubtitleStyleConfig) => void
onCloseSubtitleDrawer: () => void
// 转场
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
clips: ClipData[]
onTransitionChange: (config: TransitionConfig) => void
onCloseTransitionDrawer: () => void
// 调速
speedDrawerOpen: boolean
speedTargetClipId: string | null
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
onCloseSpeedDrawer: () => void
// TTS
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onTtsChange: (config: TtsConfig) => void
onCloseTtsDrawer: () => void
// 水印
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onWatermarkChange: (config: WatermarkConfig) => void
onCloseWatermarkDrawer: () => void
// 片头片尾
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onIntroOutroChange: (config: IntroOutroConfig) => void
onCloseIntroOutroDrawer: () => void
// 混剪
pipDrawerOpen: boolean
pipSettings: PipConfig
totalDuration: number
onPipChange: (config: PipConfig) => void
onClosePipDrawer: () => void
// 滤镜
filterDrawerOpen: boolean
filterSettings: FilterConfig
onFilterChange: (config: FilterConfig) => void
onCloseFilterDrawer: () => void
// 绿幕
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onChromaKeyChange: (config: ChromaKeyConfig) => void
onCloseChromaKeyDrawer: () => void
// 贴纸
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onStickerChange: (config: StickerConfig) => void
onCloseStickerDrawer: () => void
}
const EditorDrawers: React.FC<EditorDrawersProps> = ({
bgmDrawerOpen,
bgmSettings,
onBgmSettingsChange,
onCloseBgmDrawer,
subtitleDrawerOpen,
subtitleSettings,
onSubtitleSettingsChange,
onCloseSubtitleDrawer,
transitionDrawerOpen,
transitionTargetClipId,
clips,
onTransitionChange,
onCloseTransitionDrawer,
speedDrawerOpen,
speedTargetClipId,
onSpeedChange,
onApplySpeedAll,
onCloseSpeedDrawer,
ttsDrawerOpen,
ttsTargetClipId,
onTtsChange,
onCloseTtsDrawer,
watermarkDrawerOpen,
watermarkSettings,
onWatermarkChange,
onCloseWatermarkDrawer,
introOutroDrawerOpen,
introOutroSettings,
onIntroOutroChange,
onCloseIntroOutroDrawer,
pipDrawerOpen,
pipSettings,
totalDuration,
onPipChange,
onClosePipDrawer,
filterDrawerOpen,
filterSettings,
onFilterChange,
onCloseFilterDrawer,
chromaKeyDrawerOpen,
chromaKeySettings,
onChromaKeyChange,
onCloseChromaKeyDrawer,
stickerDrawerOpen,
stickerSettings,
onStickerChange,
onCloseStickerDrawer,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
return (
<>
{/* BGM 选择器 Drawer */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onBgmSettingsChange}
/>
{/* 字幕样式配置 Drawer */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onSubtitleSettingsChange}
/>
{/* 转场特效选择器 Drawer */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* 片段调速面板 Drawer */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* TTS 配音面板 Drawer */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG}
onChange={onTtsChange}
/>
)}
{/* 水印配置面板 */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* 片头片尾配置面板 */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* 混剪配置面板 */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* 滤镜调色面板 */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* 绿幕抠像面板 */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* 贴纸面板 */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/>
</>
)
}
export default EditorDrawers
@@ -1,106 +0,0 @@
/**
*
* + /////
*/
import React, { useCallback } from "react"
import { Drawer, Switch } from "antd"
import type { FilterConfig, FilterPreset } from "@/pages/editing-planner/types"
import { DEFAULT_FILTER_CONFIG } from "@/pages/editing-planner/types"
import { PRESET_GRADIENTS } from "@/pages/editing-planner/constants/filter"
import FilterPresetGrid from "./filter/FilterPresetGrid"
import FilterManualAdjust from "./filter/FilterManualAdjust"
interface FilterPanelProps {
open: boolean
onClose: () => void
config: FilterConfig
onChange: (config: FilterConfig) => void
}
const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChange }) => {
const update = useCallback(
(partial: Partial<FilterConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
const handlePresetSelect = useCallback(
(preset: FilterPreset) => {
if (preset === "none") {
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
} else {
onChange({
...DEFAULT_FILTER_CONFIG,
enabled: config.enabled,
preset,
})
}
},
[config.enabled, onChange],
)
const handleManualChange = useCallback(
(key: keyof FilterConfig, value: number) => {
onChange({ ...config, [key]: value })
},
[config, onChange],
)
return (
<Drawer
title="滤镜调色"
placement="right"
width={420}
styles={{ wrapper: { maxWidth: "100vw" } }}
open={open}
onClose={onClose}
className="filter-panel-drawer"
>
{/* 顶部开关 */}
<div className="filter-header">
<span className="filter-header-label"></span>
<Switch
size="small"
checked={config.enabled}
onChange={(checked) => update({ enabled: checked })}
/>
</div>
{/* 预设滤镜选择 */}
<FilterPresetGrid selectedPreset={config.preset} onPresetSelect={handlePresetSelect} />
{/* 手动调节 */}
<FilterManualAdjust config={config} onChange={handleManualChange} />
{/* 预览色块 */}
<div className="filter-section">
<div className="filter-section-title"></div>
<div
className="filter-preview-block"
style={{
background: PRESET_GRADIENTS[config.preset],
filter: [
`brightness(${100 + config.brightness}%)`,
`contrast(${100 + config.contrast}%)`,
`saturate(${100 + config.saturation}%)`,
].join(" "),
}}
/>
</div>
{/* 底部重置 */}
<div className="filter-footer">
<button className="filter-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
)
}
export default FilterPanel

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