Compare commits

..

12 Commits

Author SHA1 Message Date
CI Bot 1db2ee0808 fix: preserve insertion order in asset_id dedup to fix test ordering
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m28s
AI Code Review / AI Code Review (pull_request) Failing after 4m43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m44s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 46s
Changed set-based dedup to order-preserving dedup in _build_asset_url_map.
Set dedup caused non-deterministic iteration order which broke
side_effect-based mock assertions in tests.
2026-08-17 18:08:26 +08:00
CI Bot ac416493e0 fix: update test mocks for batch query + add asset_id dedup
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 35s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m59s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m0s
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
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m34s
CI/CD Pipeline / CI Gate (pull_request) Failing after 8s
- Replace find_by_id mocks with find_by_ids (returns list) in all 28 tests
- Add .id attributes to mock assets for asset_map key matching
- Dedup asset_ids in _build_asset_url_map to avoid redundant signing
- Fixes AI Code Review blocking issues #1 and #3
2026-08-17 16:37:39 +08:00
CI Bot 9e97473eec fix: batch query assets to resolve N+1 in _build_asset_url_map
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 58s
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) Failing after 0s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m18s
AI Code Review / AI Code Review (pull_request) Failing after 2m19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m26s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m45s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m50s
CI/CD Pipeline / Validate - Code Quality (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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- Add find_by_ids() to SQLAlchemyAssetRepository (single SQL IN query)
- Replace per-id find_by_id loop with single batch call
- Fixes AI Code Review blocking performance issue in PR #1404
2026-08-17 16:28:49 +08:00
CI Bot 34bd9372ce style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m0s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m5s
AI Code Review / AI Code Review (pull_request) Failing after 2m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m24s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m59s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m18s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m34s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m23s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
2026-08-17 07:40:34 +00:00
xiaoxia 2e53c59cd7 fix: remove unused typing.Any import + add clips API response structure tests
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 47s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m58s
AI Code Review / AI Code Review (pull_request) Successful in 3m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m7s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m41s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- Remove unused 'from typing import Any' (Ruff F401)
- Add 28 unit/integration tests covering:
  - _clip_to_response: enum conversion, datetime formatting, asset_url param
  - _build_asset_url_map: empty list, storage failure, asset not found,
    no storage_key, success path, exception handling, mixed scenarios
  - Route integration: list/detail/split/merge with asset URL resolution
- Incremental coverage: 100% (95/95 added lines covered)
2026-08-17 15:35:46 +08:00
CI Bot 9aa0c69b26 fix(clips): 全面修复 Clips API 响应结构
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 49s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m22s
AI Code Review / AI Code Review (pull_request) Failing after 2m26s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m53s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m33s
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 / Integration Tests (pull_request) Successful in 2m35s
CI/CD Pipeline / CI Gate (pull_request) Failing after 30s
P0: EditorClipResponse 补全所有数据库字段
- 新增 start_time, transition_duration, asset_id, status,
  template_clip_config_id, created_at, updated_at
- 新增 asset_url (OSS签名URL, 1小时有效)

P1: split/merge 端点统一使用 EditorClipResponse
- split 返回 {left_clip, right_clip} 均为 EditorClipResponse
- merge 返回 {merged_clip, deleted_clip_ids}
- 移除 dict[str, Any] 响应类型

P1: asset_url 签名URL解析
- 批量查询 Asset 表获取 storage_key
- 使用 StorageService.get_download_url() 签名
- 列表/详情/split/merge 端点均支持

P2: ClipsFromAssetsResponse 补上 plan_id 字段
2026-08-17 14:42:58 +08:00
灵应 6c04bb53ad fix: 主动token刷新-在过期前自动续期避免401错误 (#1385)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m7s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m2s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m43s
CI/CD Pipeline / Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Unit Tests (push) Successful in 11m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 24m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-08-17 14:28:47 +08:00
xiaoxia e186cb2253 cleanup: 删除Canvas预览代码,简化PreviewVideoPanel (#1402)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m10s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m38s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-17 14:16:06 +08:00
灵应 5104a56578 cleanup: 删除Canvas预览代码,简化PreviewVideoPanel (#1402)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-08-17 14:15:12 +08:00
灵应 c7ba43c309 fix: Canvas预览与后端ASS渲染坐标系对齐,消除标题预览不一致 (#1401)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m18s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m51s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m9s
CI/CD Pipeline / Unit Tests (push) Successful in 11m29s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m46s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 32m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m57s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 13:18:22 +08:00
xiaoxia ff3f6ddf97 fix: Worker容器安装CJK中文字体 — 修复ASS字幕标题不渲染 (#1400)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m15s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m30s
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Successful in 11m26s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14m29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 30m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 12:10:40 +08:00
xiaoxia 13b8fb7f66 fix: 预览视频标题渲染全链路修复 — custom_title注入+ASR路径标题支持 (#1399)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m40s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m57s
CI/CD Pipeline / Unit Tests (push) Successful in 10m5s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-17 00:50:27 +08:00
13 changed files with 769 additions and 597 deletions
+110 -35
View File
@@ -16,13 +16,16 @@
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
ClipBatchDeleteRequest,
@@ -43,30 +46,100 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
def _clip_to_response(clip) -> EditorClipResponse:
"""统一构造片段响应"""
def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse:
"""统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐"""
def _enum_str(val) -> str:
return val.value if hasattr(val, "value") else str(val)
def _fmt_dt(val) -> str:
if val is None:
return ""
if hasattr(val, "isoformat"):
return val.isoformat()
return str(val)
return EditorClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type.value
if hasattr(clip.clip_type, "value")
else str(clip.clip_type),
clip_type=_enum_str(getattr(clip, "clip_type", "")),
order=clip.order,
duration=clip.duration,
start_time=getattr(clip, "start_time", 0.0) or 0.0,
text_content=clip.text_content or "",
transition_effect=clip.transition_effect.value
if hasattr(clip.transition_effect, "value")
else str(clip.transition_effect),
transition_effect=_enum_str(getattr(clip, "transition_effect", "cut")),
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=clip.playback_speed or 1.0,
asset_id=getattr(clip, "asset_id", "") or "",
asset_url=asset_url,
status=getattr(clip, "status", "pending") or "pending",
template_clip_config_id=getattr(clip, "template_clip_config_id", "") or "",
config=clip.config or {},
created_at=_fmt_dt(getattr(clip, "created_at", None)),
updated_at=_fmt_dt(getattr(clip, "updated_at", None)),
)
def _build_asset_url_map(
asset_ids: list[str],
asset_repo: SQLAlchemyAssetRepository,
) -> dict[str, str | None]:
"""批量查询素材并生成签名URL映射.
Returns:
{asset_id: signed_url_or_None}
"""
if not asset_ids:
return {}
# 去重:多个 clip 可能引用同一个素材
# 去重并保持顺序
seen: set[str] = set()
unique_ids = []
for aid in asset_ids:
if aid and aid not in seen:
seen.add(aid)
unique_ids.append(aid)
result: dict[str, str | None] = {}
try:
storage = get_storage_service()
except Exception:
logger.warning("获取存储服务失败,跳过asset_url生成")
return {aid: None for aid in asset_ids}
# 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1)
try:
assets = asset_repo.find_by_ids(unique_ids)
asset_map = {a.id: a for a in assets}
except Exception:
logger.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True)
return {aid: None for aid in asset_ids if aid}
for aid in unique_ids:
try:
asset = asset_map.get(aid)
if asset is None:
result[aid] = None
continue
storage_key = getattr(asset, "storage_key", None) or ""
if not storage_key:
result[aid] = None
continue
result[aid] = storage.get_download_url(storage_key, expires_seconds=3600)
except Exception:
logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True)
result[aid] = None
return result
@router.get("/clips", response_model=EditorClipListResponse)
def list_draft_clips(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
_: AuthenticatedUser = Depends(get_current_user),
@@ -75,8 +148,17 @@ def list_draft_clips(
_, plan_svc = services
clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit)
total = plan_svc.count_clips(plan_id)
# 批量解析素材签名URL
asset_ids = [getattr(c, "asset_id", "") or "" for c in clips]
asset_ids = [aid for aid in asset_ids if aid]
url_map = _build_asset_url_map(asset_ids, asset_repo)
return EditorClipListResponse(
items=[_clip_to_response(c) for c in clips],
items=[
_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or ""))
for c in clips
],
total=total,
)
@@ -156,6 +238,7 @@ def get_draft_clip_detail(
clip_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取草稿中的片段详情"""
@@ -165,16 +248,20 @@ def get_draft_clip_detail(
raise HTTPException(status_code=404, detail="片段不存在")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail="片段不存在")
return _clip_to_response(clip)
asset_id = getattr(clip, "asset_id", "") or ""
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
return _clip_to_response(clip, asset_url=url_map.get(asset_id))
@router.post("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
@router.post("/clips/{clip_id}/split", status_code=status.HTTP_200_OK)
def split_draft_clip(
template_id: str,
clip_id: str,
body: SplitClipRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user),
):
"""将一个片段从指定时间点分割为两个片段"""
@@ -190,32 +277,22 @@ def split_draft_clip(
) from exc
left = result["left_clip"]
right = result["right_clip"]
asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""]
asset_ids = [a for a in asset_ids if a]
url_map = _build_asset_url_map(asset_ids, asset_repo)
return {
"left_clip": {
"id": left.id,
"plan_id": left.plan_id,
"clip_type": left.clip_type,
"order": left.order,
"duration": left.duration,
"start_time": left.start_time,
},
"right_clip": {
"id": right.id,
"plan_id": right.plan_id,
"clip_type": right.clip_type,
"order": right.order,
"duration": right.duration,
"start_time": right.start_time,
},
"left_clip": _clip_to_response(left, asset_url=url_map.get(getattr(left, "asset_id", "") or "")),
"right_clip": _clip_to_response(right, asset_url=url_map.get(getattr(right, "asset_id", "") or "")),
}
@router.post("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
@router.post("/clips/merge", status_code=status.HTTP_200_OK)
def merge_draft_clips(
template_id: str,
body: MergeClipsRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user),
):
"""将多个连续的同类型片段合并为一个片段"""
@@ -230,13 +307,11 @@ def merge_draft_clips(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
asset_id = getattr(merged, "asset_id", "") or ""
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
return {
"id": merged.id,
"plan_id": merged.plan_id,
"clip_type": merged.clip_type,
"order": merged.order,
"duration": merged.duration,
"text_content": merged.text_content,
"merged_clip": _clip_to_response(merged, asset_url=url_map.get(asset_id)),
"deleted_clip_ids": body.clip_ids,
}
@@ -227,6 +227,7 @@ class ClipsFromAssetsResponse(BaseModel):
success: bool = True
created_count: int
plan_id: str = ""
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
@@ -439,17 +440,28 @@ class EditorUpdateRequest(BaseModel):
class EditorClipResponse(BaseModel):
"""片段响应"""
"""片段响应 — 与数据库 edit_plan_clips 表字段对齐"""
id: str
plan_id: str
clip_type: str
order: int
duration: float
start_time: float = 0.0
text_content: str = ""
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
asset_id: str = ""
asset_url: str | None = Field(
default=None,
description="素材视频签名URL(1小时有效),用于前端预览播放",
)
status: str = "pending"
template_clip_config_id: str = ""
config: dict[str, Any] = Field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
class EditorClipListResponse(BaseModel):
+82
View File
@@ -0,0 +1,82 @@
/**
* 主动 Token 刷新模块
*
* 在 access_token 过期前主动刷新,避免 API 请求触发 401。
* JWT payload 是 base64 编码的 JSON,无需第三方库即可解码。
*/
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./login"
let refreshTimer: ReturnType<typeof setTimeout> | null = null
/** 提前刷新的缓冲时间(秒) */
const REFRESH_BUFFER_SECONDS = 60
/**
* 解码 JWT payload(不验签,仅读取 exp 字段)
*/
function decodeJwtPayload(token: string): { exp?: number } | null {
try {
const parts = token.split(".")
if (parts.length !== 3) return null
// JWT 使用 base64url 编码,需要转换为标准 base64
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
const decoded = atob(padded)
return JSON.parse(decoded)
} catch {
return null
}
}
/**
* 取消已调度的主动刷新
*/
export function cancelProactiveRefresh(): void {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
/**
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
*/
export function scheduleProactiveRefresh(): void {
cancelProactiveRefresh()
const accessToken = localStorage.getItem("access_token")
const refreshTokenValue = useAuthStore.getState().refreshToken
if (!accessToken || !refreshTokenValue) return
const payload = decodeJwtPayload(accessToken)
if (!payload?.exp) return
const now = Math.floor(Date.now() / 1000)
const secondsUntilExpiry = payload.exp - now
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
refreshTimer = setTimeout(async () => {
try {
const data = await refreshAccessToken(refreshTokenValue)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshTokenValue
// 更新 Zustand store + localStorage
useAuthStore
.getState()
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
// 递归调度下一次刷新
scheduleProactiveRefresh()
} catch {
// 刷新失败 → 清除认证状态,跳转登录页
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/login"
}
}, delaySeconds * 1000)
}
+5
View File
@@ -6,6 +6,7 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { message } from "antd"
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./auth"
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
// 创建 Axios 实例
const apiClient = axios.create({
@@ -109,6 +110,9 @@ apiClient.interceptors.response.use(
// 处理排队的请求
processQueue(null, newAccessToken)
// 重新调度主动刷新(基于新 token 的过期时间)
scheduleProactiveRefresh()
// 重试原始请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
@@ -116,6 +120,7 @@ apiClient.interceptors.response.use(
return apiClient(originalRequest)
} catch (refreshError) {
// 刷新失败 → 登出
cancelProactiveRefresh()
processQueue(refreshError, null)
useAuthStore.getState().clearAuth()
window.location.href = "/"
+8
View File
@@ -4,6 +4,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import * as authApi from "@/api/auth"
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "@/api/auth/tokenRefresh"
import { useAuthStore } from "@/store/authStore"
// 登录 Hook
@@ -31,6 +32,9 @@ export const useLogin = () => {
const user = await authApi.getCurrentUser()
setAuth(user, data.access_token, refreshToken)
// 启动主动 token 刷新,避免后续请求触发 401
scheduleProactiveRefresh()
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
localStorage.removeItem("login_redirect")
@@ -73,6 +77,9 @@ export const useWechatCallback = () => {
const user = await authApi.getCurrentUser()
setAuth(user, result.access_token, result.refresh_token)
// 启动主动 token 刷新
scheduleProactiveRefresh()
return { ...result, user }
}
@@ -121,6 +128,7 @@ export const useLogout = () => {
} catch (error) {
// 即使登出失败也清除本地状态
} finally {
cancelProactiveRefresh()
clearAuth()
queryClient.clear()
navigate("/")
+7
View File
@@ -9,6 +9,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ConfigProvider, App as AntApp } from "antd"
import zhCN from "antd/locale/zh_CN"
import router from "./router"
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
// 这样可以在 token 过期前自动刷新,避免 API 请求触发 401
if (localStorage.getItem("access_token")) {
scheduleProactiveRefresh()
}
import "./index.css"
import "./styles/global.css"
+1 -24
View File
@@ -12,10 +12,7 @@
import React, { useState, useMemo } from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import type { VoiceClone } from "@/api/voice-clone"
import { getAssetsByKind } from "@/api/assets/assets"
import type { AssetItem } from "@/api/assets/types"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
@@ -74,20 +71,6 @@ const GeneratePage: React.FC = () => {
setPreviewModalOpen,
} = formState
/* ── 查询视频素材,用于 Step4 标题预览背景 ── */
const { data: videoAssets = [] } = useQuery({
queryKey: ["generate-video-assets"],
queryFn: () => getAssetsByKind("video", { limit: 50 }),
})
// 获取第一个选中素材的 URL
const sourceVideoUrl = useMemo(() => {
const firstId = selectedMaterials[0]
if (!firstId) return undefined
const asset = videoAssets.find((a: AssetItem) => a.id === firstId)
return asset?.file_url
}, [selectedMaterials, videoAssets])
/* ── 克隆声音 ── */
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
@@ -252,7 +235,7 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step4+ 显示,Step4 显示标题预览叠加,Step5+ 仅视频 */}
{/* 预览视频面板(Step4+ 显示) */}
{currentStep >= 4 && (
<PreviewVideoPanel
previewStatus={step5Preview.previewStatus}
@@ -261,14 +244,8 @@ const GeneratePage: React.FC = () => {
progress={step5Preview.progress}
videoRatio={videoRatio}
onRegenerate={step5Preview.regeneratePreview}
titleText={titleSettings.title}
titleSettings={titleSettings}
showTitlePreview={currentStep === 4}
sourceVideoUrl={sourceVideoUrl}
/>
)}
{/* 正式生成结果(Step6+ 才显示) */}
{currentStep >= 6 && (
<GenerateResultPanel
generated={generated}
@@ -1,22 +1,16 @@
/**
* 右侧预览视频面板
* Step4 生成预览后常驻显示预览视频
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致
* Step4+: 显示预览视频面板
* Step5+: 显示后端生成的预览视频(标题已由 FFmpeg 烧录
*
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
*
* Canvas 居中修复说明:
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect),
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移。
* 设计说明:
* - Step4(标题设置页):右侧显示空状态提示,引导用户输入标题
* - Step5(预览生成页):显示后端返回的预览视频
* - Canvas 预览已删除(统一由后端 FFmpeg 渲染标题)
*/
import React, { useRef, useEffect, useCallback } from "react"
import React from "react"
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
import type { TitleSettings } from "../types"
import { drawTitleOnCanvas } from "../utils/drawTitleOnCanvas"
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
interface PreviewVideoPanelProps {
previewStatus: PreviewStatus
@@ -25,14 +19,6 @@ interface PreviewVideoPanelProps {
progress: number
videoRatio: string
onRegenerate: () => void
/** 标题文字 */
titleText?: string
/** 标题样式设置 */
titleSettings?: TitleSettings
/** Step4 标题预览模式 */
showTitlePreview?: boolean
/** 素材视频 URL(用于 Step4 标题预览背景) */
sourceVideoUrl?: string
}
/* ── 组件 ── */
@@ -44,167 +30,21 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
progress,
videoRatio,
onRegenerate,
titleText,
titleSettings,
showTitlePreview,
sourceVideoUrl,
}) => {
const hasPreview = previewStatus === "ready" && previewResult
const isLoading = previewStatus === "pending" || previewStatus === "generating"
const isError = previewStatus === "error"
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
// video 模式 refs
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
const fontLoadedRef = useRef(false)
/** 在 video canvas 上绘制标题 */
const drawVideoTitle = useCallback(() => {
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
if (!fontLoadedRef.current) return
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container || !titleSettings) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const containerRect = container.getBoundingClientRect()
if (containerRect.width <= 0 || containerRect.height <= 0) return
// 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
const videoEl = videoRef.current
let drawW = containerRect.width
let drawH = containerRect.height
let offsetX = 0
let offsetY = 0
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
const videoRect = videoEl.getBoundingClientRect()
drawW = videoRect.width
drawH = videoRect.height
offsetX = videoRect.left - containerRect.left
offsetY = videoRect.top - containerRect.top
}
// 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
canvas.style.left = `${offsetX}px`
canvas.style.top = `${offsetY}px`
canvas.style.width = `${drawW}px`
canvas.style.height = `${drawH}px`
// 绘制时,坐标系基于 Canvas 自身尺寸,无需额外偏移
drawTitleOnCanvas(
ctx,
drawW,
drawH,
titleText || "",
titleSettings,
40,
titleSettings.position,
60,
)
}, [titleText, titleSettings])
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
useEffect(() => {
if (!showTitlePreview || !hasPreview) return
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => {
drawVideoTitle()
})
observer.observe(container)
requestAnimationFrame(drawVideoTitle)
return () => observer.disconnect()
}, [showTitlePreview, hasPreview, drawVideoTitle])
// 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体
useEffect(() => {
if (!showTitlePreview || !titleSettings) {
fontLoadedRef.current = false
return
}
let cancelled = false
fontLoadedRef.current = false
const fontWeight = titleSettings.bold ? "bold" : ""
const fontStyle = titleSettings.italic ? "italic" : ""
const fontSpec =
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
const onFontReady = () => {
if (cancelled) return
fontLoadedRef.current = true
// ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态)
requestAnimationFrame(() => {
if (!cancelled) {
drawVideoTitle()
}
})
}
if (document.fonts.check(fontSpec)) {
onFontReady()
return
}
document.fonts
.load(fontSpec)
.then(() => onFontReady())
.catch(() => {
document.fonts.ready.then(() => onFontReady())
})
return () => {
cancelled = true
}
}, [showTitlePreview, titleSettings, drawVideoTitle])
// video 加载完成后重绘
const handleVideoLoaded = useCallback(() => {
if (showTitlePreview) {
requestAnimationFrame(drawVideoTitle)
}
}, [showTitlePreview, drawVideoTitle])
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
<h3>{showTitlePreview && !hasPreview ? "标题预览" : "预览视频"}</h3>
{hasPreview && !showTitlePreview && <span className="xx-preview-badge">480p </span>}
<h3></h3>
{hasPreview && <span className="xx-preview-badge">480p </span>}
</div>
{/* Step4 标题预览模式 */}
{showTitlePreview && titleSettings && titleText && (
<div style={{ padding: "0 16px 16px" }}>
<TitlePreviewCanvas
titleText={titleText}
titleSettings={titleSettings}
videoRatio="9:16"
sourceVideoUrl={sourceVideoUrl}
/>
</div>
)}
{/* Step4 但无标题时的空状态 */}
{showTitlePreview && (!titleText || !titleSettings) && (
<div className="xx-preview-empty">
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<p className="xx-preview-empty-title"></p>
<p className="xx-preview-empty-desc"></p>
</div>
)}
{/* 空状态:还没生成预览(非 Step4 模式) */}
{!showTitlePreview && previewStatus === "idle" && (
{/* 空状态:还没生成预览 */}
{previewStatus === "idle" && (
<div className="xx-preview-empty">
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
@@ -214,8 +54,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 生成中(非 Step4 模式) */}
{!showTitlePreview && isLoading && (
{/* 生成中 */}
{isLoading && (
<div className="xx-preview-loading-panel">
<div className="xx-preview-video" style={videoAspectStyle}>
<div className="xx-preview-loading-center">
@@ -231,8 +71,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 生成失败(非 Step4 模式) */}
{!showTitlePreview && isError && (
{/* 生成失败 */}
{isError && (
<div className="xx-preview-error-panel">
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}></p>
@@ -246,54 +86,32 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
</div>
)}
{/* 预览成功 + Canvas 标题叠加 */}
{/* 预览成功 */}
{hasPreview && (
<div ref={containerRef} style={{ position: "relative" }}>
<>
<div className="xx-preview-video" style={videoAspectStyle}>
<video
ref={videoRef}
src={previewResult.videoUrl}
controls
preload="metadata"
onLoadedMetadata={handleVideoLoaded}
/>
<video src={previewResult.videoUrl} controls preload="metadata" />
</div>
{showTitlePreview && (
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
pointerEvents: "none",
}}
/>
)}
</div>
)}
{/* 预览信息(非 Step4 模式) */}
{!showTitlePreview && hasPreview && previewResult && (
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span></span>
<span>
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(1)}{" "}
</span>
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span></span>
<span>
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(
1,
)}{" "}
</span>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{previewResult.clipCount} </span>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{videoRatio}</span>
</div>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{previewResult.clipCount} </span>
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{videoRatio}</span>
</div>
</div>
</>
)}
</div>
)
@@ -1,192 +0,0 @@
/**
* 标题实时预览 Canvas 组件
*
* 在 Step4 标题设置面板中嵌入,让用户实时看到标题文字、字体、大小、颜色、
* 位置、描边、阴影等样式的实际渲染效果(所见即所得)。
*
* 使用共享的 drawTitleOnCanvas 工具函数,与 PreviewVideoPanel 行为一致。
*/
import React, { useRef, useEffect } from "react"
import type { TitleSettings } from "../../types"
import { drawTitleOnCanvas } from "../../utils/drawTitleOnCanvas"
interface TitlePreviewCanvasProps {
/** 标题文字 */
titleText: string
/** 标题样式设置 */
titleSettings: TitleSettings
/** 视频比例,默认 "9:16"(竖屏) */
videoRatio?: string
/** 素材视频 URL(作为背景显示) */
sourceVideoUrl?: string
}
/**
* 解析 videoRatio 字符串为 aspect-ratio CSS 值
*/
function parseAspect(ratio: string): string {
return (ratio || "9:16").replace(":", "/")
}
const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
titleText,
titleSettings,
videoRatio = "9:16",
sourceVideoUrl,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
// 字体加载状态
const fontLoadedRef = useRef(false)
/** 在 Canvas 上绘制标题 */
const draw = () => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const w = rect.width
const h = rect.height
// 更新 Canvas CSS 尺寸匹配容器
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
drawTitleOnCanvas(ctx, w, h, titleText, titleSettings, 24, titleSettings.position, 40)
}
// 字体加载:确保 measureText 使用正确字体
useEffect(() => {
let cancelled = false
fontLoadedRef.current = false
const fontWeight = titleSettings.bold ? "bold" : ""
const fontStyle = titleSettings.italic ? "italic" : ""
const fontSpec =
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
const onFontReady = () => {
if (cancelled) return
fontLoadedRef.current = true
requestAnimationFrame(() => {
if (!cancelled) draw()
})
}
// 用 FontFace API 加载字体,失败则降级
try {
const fontFace = new FontFace(titleSettings.font, `local("${titleSettings.font}")`)
fontFace
.load()
.then(() => {
if (!cancelled) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- FontFaceSet.add() exists at runtime
;(document.fonts as any).add(fontFace)
onFontReady()
}
})
.catch(() => {
// 字体加载失败,用默认字体继续
onFontReady()
})
} catch {
// FontFace 不可用,直接绘制
onFontReady()
}
// 同时检查 document.fonts 是否已有该字体
if (document.fonts.check(fontSpec)) {
onFontReady()
return
}
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
}, [titleSettings.font, titleSettings.size, titleSettings.bold, titleSettings.italic])
// props 变化时重绘
useEffect(() => {
requestAnimationFrame(draw)
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
}, [titleText, titleSettings])
// ResizeObserver 监听容器尺寸变化
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(() => {
requestAnimationFrame(draw)
})
observer.observe(container)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
}, [])
return (
<div>
<div
style={{
fontSize: 12,
color: "var(--text-tertiary, #999)",
marginBottom: 6,
fontWeight: 500,
}}
>
</div>
<div
ref={containerRef}
style={{
width: "100%",
aspectRatio: parseAspect(videoRatio),
background: sourceVideoUrl
? "#000"
: "linear-gradient(135deg, #1a1a2e, #16213e, #0f3460)",
borderRadius: 8,
overflow: "hidden",
position: "relative",
}}
>
{sourceVideoUrl && (
<video
src={sourceVideoUrl}
muted
loop
autoPlay
playsInline
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
/>
</div>
</div>
)
}
export default TitlePreviewCanvas
@@ -1,126 +0,0 @@
/**
* Canvas 标题绘制工具函数(共享模块)
*
* 供 PreviewVideoPanel(预览视频标题叠加)和 TitlePreviewCanvas(标题设置实时预览)共用。
* 绘制行为与 ASS 字幕引擎一致:逐字换行、居中、描边/阴影。
*/
import type { TitleSettings } from "../types"
/**
* 将文本按 maxWidth 逐字换行,返回行数组。
* 与 ASS 字幕引擎的逐字换行行为一致。
*/
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = []
let currentLine = ""
for (const char of text) {
const testLine = currentLine + char
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
lines.push(currentLine)
currentLine = char
} else {
currentLine = testLine
}
}
if (currentLine) lines.push(currentLine)
return lines
}
/**
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
*
* @param ctx canvas 上下文
* @param w canvas CSS 宽度
* @param h canvas CSS 高度
* @param text 标题文字
* @param settings 标题样式
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
* @param position "top" | "center" | "bottom"
* @param topOffset 顶部/底部偏移量
*/
export function drawTitleOnCanvas(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
text: string,
settings: TitleSettings,
paddingX: number,
position: string,
topOffset: number,
) {
const dpr = window.devicePixelRatio || 1
// 设置 canvas 物理像素尺寸(高清屏适配)
ctx.canvas.width = Math.round(w * dpr)
ctx.canvas.height = Math.round(h * dpr)
ctx.scale(dpr, dpr)
// 清除
ctx.clearRect(0, 0, w, h)
// 可用宽度 = 总宽 - 左右边距
const availableWidth = w - paddingX * 2
if (availableWidth <= 0) return
// 字体设置
const fontSize = Math.round(Math.min(settings.size, 36))
const fontWeight = settings.bold ? "bold" : "normal"
const fontStyle = settings.italic ? "italic" : "normal"
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
// 文字属性
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
const lineHeight = fontSize * 1.4
// 描边 & 阴影
if (settings.stroke) {
ctx.strokeStyle = "rgba(0,0,0,0.6)"
ctx.lineWidth = 2
ctx.lineJoin = "round"
}
if (settings.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.7)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
}
// 换行
const displayText = text && text.trim() ? text : "请选择或输入标题"
const lines = wrapText(ctx, displayText, availableWidth)
// 起始 Y:根据 position 计算
const totalTextHeight = lines.length * lineHeight
let startY: number
switch (position) {
case "top":
startY = topOffset
break
case "center":
startY = (h - totalTextHeight) / 2 + lineHeight / 2
break
case "bottom":
default:
startY = h - topOffset - totalTextHeight + lineHeight / 2
break
}
// 居中 x = w/2
const x = w / 2
lines.forEach((line, i) => {
const y = startY + i * lineHeight
if (settings.stroke) ctx.strokeText(line, x, y)
ctx.fillText(line, x, y)
})
// 重置 shadow(避免影响后续绘制)
if (settings.shadow) {
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
}
+3
View File
@@ -67,6 +67,9 @@ RUN chmod +x /usr/local/bin/entrypoint-worker.sh
# 业务代码(变化最频繁,放最后)
COPY apps/worker/ /app/apps/worker/
# ---- Install CJK fonts for ASS subtitle rendering ----
RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-cjk && fc-cache -fv && rm -rf /var/lib/apt/lists/*
USER celery
# Worker 入口点
@@ -101,6 +101,13 @@ class SQLAlchemyAssetRepository:
return None
return self._to_domain(model)
def find_by_ids(self, asset_ids: list[str]) -> list[Asset]:
"""批量查询素材(单次 SQL IN 查询,避免 N+1)。"""
if not asset_ids:
return []
models = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
return [self._to_domain(m) for m in models]
def get(self, asset_id: str) -> Asset | None:
return self.find_by_id(asset_id)
@@ -0,0 +1,496 @@
"""片段管理路由 clips.py 增量覆盖率测试.
覆盖 PR fix/clips-api-response-structure 新增代码:
- _clip_to_response: 枚举转换、日期格式化、asset_url 参数
- _build_asset_url_map: 批量素材 URL 解析(空列表/异常/正常路径)
- 路由层 asset_repo 注入与 URL 拼接逻辑
"""
from __future__ import annotations
import os
import sys
from enum import Enum
from pathlib import Path
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# 常量与工厂
# ---------------------------------------------------------------------------
TEST_TEMPLATE_ID = "tmpl-test-001"
TEST_PLAN_ID = "plan-draft-001"
TEST_USER_ID = "user-001"
def _auth_user():
u = MagicMock()
u.user.id = TEST_USER_ID
u.user_id = TEST_USER_ID
return u
def _clip(**overrides):
"""构造 mock clip,支持 Enum 类型字段"""
c = MagicMock()
c.id = overrides.get("id", "clip-001")
c.plan_id = overrides.get("plan_id", TEST_PLAN_ID)
c.clip_type = overrides.get("clip_type", "video")
c.order = overrides.get("order", 0)
c.duration = overrides.get("duration", 10.0)
c.start_time = overrides.get("start_time", 0.0)
c.text_content = overrides.get("text_content", "")
c.transition_effect = overrides.get("transition_effect", "cut")
c.transition_duration = overrides.get("transition_duration", 0.0)
c.playback_speed = overrides.get("playback_speed", 1.0)
c.asset_id = overrides.get("asset_id", "")
c.status = overrides.get("status", "ready")
c.template_clip_config_id = overrides.get("template_clip_config_id", "")
c.config = overrides.get("config", {})
c.created_at = overrides.get("created_at", None)
c.updated_at = overrides.get("updated_at", None)
return c
def _services(plan_svc_overrides=None):
tpl = MagicMock()
plan = MagicMock()
if plan_svc_overrides:
for k, v in plan_svc_overrides.items():
setattr(plan, k, v)
return tpl, plan
# ---------------------------------------------------------------------------
# 单元测试: _clip_to_response
# ---------------------------------------------------------------------------
class TestClipToResponse:
"""_clip_to_response 纯函数测试 — 覆盖行 53-80"""
def test_basic_fields(self):
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(id="c1", order=3, duration=5.5, text_content="hello")
resp = _clip_to_response(c)
assert resp.id == "c1"
assert resp.order == 3
assert resp.duration == 5.5
assert resp.text_content == "hello"
assert resp.asset_url is None
def test_enum_clip_type(self):
"""Enum 值应被 .value 解包"""
from app.api.routes.templates_editor.clips import _clip_to_response
class ClipType(str, Enum):
VIDEO = "video"
AUDIO = "audio"
c = _clip(clip_type=ClipType.VIDEO)
resp = _clip_to_response(c)
assert resp.clip_type == "video"
def test_plain_string_clip_type(self):
"""非 Enum 字符串直接用 str()"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(clip_type="main")
resp = _clip_to_response(c)
assert resp.clip_type == "main"
def test_enum_transition_effect(self):
from app.api.routes.templates_editor.clips import _clip_to_response
class Transition(str, Enum):
FADE = "fade"
c = _clip(transition_effect=Transition.FADE)
resp = _clip_to_response(c)
assert resp.transition_effect == "fade"
def test_default_transition_when_none(self):
"""transition_effect 缺失时默认 cut"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip()
del c.transition_effect # 触发 getattr default
resp = _clip_to_response(c)
assert resp.transition_effect == "cut"
def test_asset_url_passed(self):
"""asset_url 参数应透传到响应"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(asset_id="a1")
resp = _clip_to_response(c, asset_url="https://signed-url.example.com/video.mp4")
assert resp.asset_url == "https://signed-url.example.com/video.mp4"
def test_asset_url_none_by_default(self):
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip()
resp = _clip_to_response(c)
assert resp.asset_url is None
def test_datetime_isoformat(self):
"""datetime 对象应被 isoformat()"""
from datetime import datetime
from app.api.routes.templates_editor.clips import _clip_to_response
dt = datetime(2026, 8, 17, 12, 0, 0)
c = _clip(created_at=dt, updated_at=dt)
resp = _clip_to_response(c)
assert "2026-08-17" in resp.created_at
assert "2026-08-17" in resp.updated_at
def test_none_datetime_empty_string(self):
"""None 日期应格式化为空字符串"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(created_at=None, updated_at=None)
resp = _clip_to_response(c)
assert resp.created_at == ""
assert resp.updated_at == ""
def test_string_datetime_passthrough(self):
"""已经是字符串的日期直接 str()"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(created_at="2026-08-17T00:00:00")
resp = _clip_to_response(c)
assert resp.created_at == "2026-08-17T00:00:00"
def test_none_defaults_for_optional_fields(self):
"""None/缺失字段的默认值"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(asset_id=None, status=None, template_clip_config_id=None)
resp = _clip_to_response(c)
assert resp.asset_id == ""
assert resp.status == "pending"
assert resp.template_clip_config_id == ""
def test_zero_duration_fallback(self):
"""duration=0 → playback_speed 默认 1.0"""
from app.api.routes.templates_editor.clips import _clip_to_response
c = _clip(playback_speed=None)
resp = _clip_to_response(c)
assert resp.playback_speed == 1.0
# ---------------------------------------------------------------------------
# 单元测试: _build_asset_url_map
# ---------------------------------------------------------------------------
class TestBuildAssetUrlMap:
"""_build_asset_url_map 测试 — 覆盖行 93-118"""
def test_empty_list(self):
"""空 asset_ids 直接返回空 dict"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
repo = MagicMock()
result = _build_asset_url_map([], repo)
assert result == {}
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_storage_service_failure(self, mock_get_storage):
"""存储服务获取失败时返回全 None"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
mock_get_storage.side_effect = RuntimeError("storage unavailable")
repo = MagicMock()
result = _build_asset_url_map(["a1", "a2"], repo)
assert result == {"a1": None, "a2": None}
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_asset_not_found(self, mock_get_storage):
"""asset_id 找不到对应素材 → None"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
mock_get_storage.return_value = storage
repo = MagicMock()
repo.find_by_ids.return_value = []
result = _build_asset_url_map(["missing-id"], repo)
assert result == {"missing-id": None}
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_no_storage_key(self, mock_get_storage):
"""素材没有 storage_key → None"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
mock_get_storage.return_value = storage
repo = MagicMock()
asset = MagicMock()
asset.id = "a1"
asset.storage_key = ""
repo.find_by_ids.return_value = [asset]
result = _build_asset_url_map(["a1"], repo)
assert result == {"a1": None}
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_successful_url_generation(self, mock_get_storage):
"""正常路径:返回签名 URL"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.example.com/signed.mp4"
mock_get_storage.return_value = storage
repo = MagicMock()
asset = MagicMock()
asset.id = "a1"
asset.storage_key = "videos/test.mp4"
repo.find_by_ids.return_value = [asset]
result = _build_asset_url_map(["a1"], repo)
assert result == {"a1": "https://cdn.example.com/signed.mp4"}
storage.get_download_url.assert_called_once_with("videos/test.mp4", expires_seconds=3600)
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_exception_during_url_generation(self, mock_get_storage):
"""单个 asset 生成 URL 异常 → None,不影响其他"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
storage.get_download_url.side_effect = [Exception("boom"), "https://ok.com/v2"]
mock_get_storage.return_value = storage
repo = MagicMock()
asset1 = MagicMock()
asset1.id = "a1"
asset1.storage_key = "v1.mp4"
asset2 = MagicMock()
asset2.id = "a2"
asset2.storage_key = "v2.mp4"
repo.find_by_ids.return_value = [asset1, asset2]
result = _build_asset_url_map(["a1", "a2"], repo)
assert result["a1"] is None
assert result["a2"] == "https://ok.com/v2"
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_skip_empty_asset_id(self, mock_get_storage):
"""空字符串 asset_id 被跳过"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
mock_get_storage.return_value = storage
repo = MagicMock()
result = _build_asset_url_map(["", "a1"], repo)
# "" not in result because it's skipped by `if not aid: continue`
assert "" not in result
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_multiple_assets_mixed(self, mock_get_storage):
"""混合场景:正常+异常+缺失"""
from app.api.routes.templates_editor.clips import _build_asset_url_map
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.com/ok.mp4"
mock_get_storage.return_value = storage
repo = MagicMock()
good_asset = MagicMock()
good_asset.id = "a1"
good_asset.storage_key = "good.mp4"
# a1=good, a2=not found, a3=good
good_asset2 = MagicMock()
good_asset2.id = "a3"
good_asset2.storage_key = "good.mp4"
repo.find_by_ids.return_value = [good_asset, good_asset2]
result = _build_asset_url_map(["a1", "a2", "a3"], repo)
assert result["a1"] == "https://cdn.com/ok.mp4"
assert result["a2"] is None
assert result["a3"] == "https://cdn.com/ok.mp4"
# ---------------------------------------------------------------------------
# 集成测试: 路由层 asset_repo 注入
# ---------------------------------------------------------------------------
class TestClipRoutesAssetIntegration:
"""路由层测试 — 覆盖 asset_url 在 list/detail/split/merge 中的拼接逻辑"""
def _create_app(self, plan_svc_config=None):
from app.api.routes import templates_editor as editor_module
from app.dependencies import get_asset_repository
mock_clip_1 = _clip(id="c1", asset_id="asset-001")
mock_clip_2 = _clip(id="c2", asset_id="")
mock_tpl_svc = MagicMock()
mock_plan_svc = MagicMock()
mock_plan_svc.list_clips.return_value = [mock_clip_1, mock_clip_2]
mock_plan_svc.count_clips.return_value = 2
mock_plan_svc.get_clip.return_value = mock_clip_1
mock_plan_svc.create_clip.return_value = _clip(id="c-new", asset_id="")
mock_plan_svc.update_clip.return_value = _clip(id="c1", duration=15.0)
mock_plan_svc.delete_clip.return_value = True
mock_plan_svc.split_clip.return_value = {
"left_clip": _clip(id="c-left", asset_id="asset-L"),
"right_clip": _clip(id="c-right", asset_id="asset-R"),
}
mock_plan_svc.merge_clips.return_value = _clip(id="c-merged", asset_id="asset-M")
if plan_svc_config:
for k, v in plan_svc_config.items():
setattr(mock_plan_svc, k, v)
def _deps():
return mock_tpl_svc, mock_plan_svc
mock_asset_repo = MagicMock()
app = FastAPI()
app.include_router(
editor_module.router,
prefix="/api/v1/templates/{template_id}/editor",
)
app.dependency_overrides[editor_module.get_current_user] = _auth_user
app.dependency_overrides[editor_module.get_draft_plan_id] = lambda: TEST_PLAN_ID
app.dependency_overrides[editor_module.get_editor_services] = _deps
app.dependency_overrides[get_asset_repository] = lambda: mock_asset_repo
return TestClient(app), mock_plan_svc, mock_asset_repo
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_list_clips_includes_asset_urls(self, mock_get_storage):
"""GET /clips 应为有 asset_id 的片段返回签名 URL"""
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.com/c1.mp4"
mock_get_storage.return_value = storage
client, _, asset_repo = self._create_app()
asset = MagicMock()
asset.id = "asset-001"
asset.storage_key = "videos/c1.mp4"
asset_repo.find_by_ids.return_value = [asset]
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips")
assert resp.status_code == 200
data = resp.json()
items = data["items"]
assert len(items) == 2
# c1 has asset_id → should have url
assert items[0]["asset_url"] == "https://cdn.com/c1.mp4"
# c2 has empty asset_id → None
assert items[1]["asset_url"] is None
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_get_clip_detail_with_asset_url(self, mock_get_storage):
"""GET /clips/{clip_id} 应返回素材签名 URL"""
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.com/detail.mp4"
mock_get_storage.return_value = storage
client, _, asset_repo = self._create_app()
asset = MagicMock()
asset.id = "asset-001"
asset.storage_key = "videos/detail.mp4"
asset_repo.find_by_ids.return_value = [asset]
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001")
assert resp.status_code == 200
assert resp.json()["asset_url"] == "https://cdn.com/detail.mp4"
def test_get_clip_detail_no_asset(self):
"""片段没有 asset_id 时不应调用 URL 解析"""
client, plan_svc, asset_repo = self._create_app()
# 返回没有 asset_id 的片段
plan_svc.get_clip.return_value = _clip(id="c-no-asset", asset_id="")
resp = client.get(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/c-no-asset")
assert resp.status_code == 200
assert resp.json()["asset_url"] is None
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_split_clip_returns_asset_urls(self, mock_get_storage):
"""POST /clips/{clip_id}/split 返回的左右片段应带签名 URL"""
storage = MagicMock()
storage.get_download_url.side_effect = ["https://cdn.com/L.mp4", "https://cdn.com/R.mp4"]
mock_get_storage.return_value = storage
client, _, asset_repo = self._create_app()
asset_l = MagicMock()
asset_l.storage_key = "videos/L.mp4"
asset_r = MagicMock()
asset_r.storage_key = "videos/R.mp4"
asset_l.id = "asset-L"
asset_r.id = "asset-R"
asset_repo.find_by_ids.return_value = [asset_l, asset_r]
resp = client.post(
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001/split",
json={"split_time": 5.0},
)
assert resp.status_code == 200
data = resp.json()
assert data["left_clip"]["asset_url"] == "https://cdn.com/L.mp4"
assert data["right_clip"]["asset_url"] == "https://cdn.com/R.mp4"
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_merge_clips_returns_asset_url(self, mock_get_storage):
"""POST /clips/merge 返回的合并片段应带签名 URL"""
storage = MagicMock()
storage.get_download_url.return_value = "https://cdn.com/M.mp4"
mock_get_storage.return_value = storage
client, _, asset_repo = self._create_app()
asset = MagicMock()
asset.id = "asset-M"
asset.storage_key = "videos/M.mp4"
asset_repo.find_by_ids.return_value = [asset]
resp = client.post(
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge",
json={"clip_ids": ["c1", "c2"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["merged_clip"]["asset_url"] == "https://cdn.com/M.mp4"
assert data["deleted_clip_ids"] == ["c1", "c2"]
def test_merge_clips_not_found(self):
"""merge 时某片段不存在应返回 404"""
client, plan_svc, _ = self._create_app()
plan_svc.get_clip.return_value = None
resp = client.post(
f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/merge",
json={"clip_ids": ["nonexistent-1", "nonexistent-2"]},
)
assert resp.status_code == 404
def test_delete_clip_success(self):
"""DELETE /clips/{clip_id} 成功返回 204"""
client, _, _ = self._create_app()
resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/clip-001")
assert resp.status_code == 204
def test_delete_clip_not_found(self):
"""DELETE 片段不存在返回 404"""
client, plan_svc, _ = self._create_app()
plan_svc.delete_clip.return_value = False
resp = client.delete(f"/api/v1/templates/{TEST_TEMPLATE_ID}/editor/clips/bad-id")
assert resp.status_code == 404