Compare commits

..

6 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
28 changed files with 1204 additions and 2006 deletions
+1 -14
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
@@ -14,7 +14,6 @@ from app.schemas.asset import (
AssetResponse,
BatchClassifyRequest,
BatchDeleteRequest,
BatchGetRequest,
BatchMarkRequest,
BatchOperationResponse,
BatchTagRequest,
@@ -371,18 +370,6 @@ def update_asset_review_status(
return _to_asset_response(updated)
@router.post("/batch", response_model=List[AssetResponse])
def batch_get_assets(
request: BatchGetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
) -> list[AssetResponse]:
"""批量获取素材详情(根据 ID 列表)。"""
items = asset_repository.find_by_ids(request.ids)
storage_service = get_storage_service()
return [_to_asset_response(item, storage_service) for item in items]
@router.post("/batch-delete", response_model=BatchOperationResponse)
def batch_delete_assets(
request: BatchDeleteRequest,
@@ -49,10 +49,6 @@ class GenerateCoverRequest(BaseModel):
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
cover_url: Optional[str] = Field(
default=None,
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
)
class GenerateCoverResponse(BaseModel):
@@ -84,29 +80,6 @@ def generate_cover(
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
# ── upload 类型:直接保存前端上传的封面图片,不需要预览视频 ──────
if body.cover_type == "upload":
if not body.cover_url:
raise HTTPException(
status_code=400,
detail="cover_type=upload 时必须提供 cover_url",
)
cover_data = {
"type": "upload",
"image_url": body.cover_url,
}
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面上传完成: plan_id=%s cover_url=%s by user=%s",
plan_id,
body.cover_url[:80] if body.cover_url else "",
current_user.user.id,
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
# 第一步:从 plan.config 读取
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
@@ -8,9 +8,8 @@
from __future__ import annotations
import json
import logging
from typing import Any, Optional
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
@@ -46,7 +45,6 @@ from ._fallback import (
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
from .schemas import (
ClipStatusItem,
EditPlanGenerateRequest,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
@@ -59,7 +57,6 @@ router = APIRouter(tags=["Template Editor"])
@router.post("/generate", response_model=EditPlanGenerateResponse)
def generate_editor_draft(
template_id: str,
request: Optional[EditPlanGenerateRequest] = None,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
@@ -68,7 +65,6 @@ def generate_editor_draft(
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发模板草稿渲染生成"""
req = request or EditPlanGenerateRequest()
_, plan_svc = services
plan_check = plan_svc.get_plan_or_raise(plan_id)
@@ -89,36 +85,6 @@ def generate_editor_draft(
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
if reusable_task:
# 复用预览产物:标记为正式产出,跳过渲染
# 如果前端传了 title_config,需要创建新任务(因为预览任务的 custom_title 可能不同)
title_config_reuse = req.title_config or {}
title_text_reuse = (title_config_reuse.get("text") or "").strip()
existing_custom_title = getattr(reusable_task, "custom_title", "") or ""
if title_text_reuse and existing_custom_title:
# 如果新标题和已有标题不同,不能复用,走新建任务流程
new_title_json = json.dumps(title_config_reuse, ensure_ascii=False)
if new_title_json != existing_custom_title:
logger.info(
"[模板生成] 标题已变更,跳过复用: task_id=%s",
reusable_task.id,
)
reusable_task = None
elif title_text_reuse and not existing_custom_title:
# 原来没标题,现在有标题,不能复用
logger.info(
"[模板生成] 新增标题,跳过复用: task_id=%s",
reusable_task.id,
)
reusable_task = None
elif not title_text_reuse and existing_custom_title:
# 原来有标题,现在移除了,不能复用
logger.info(
"[模板生成] 移除标题,跳过复用: task_id=%s",
reusable_task.id,
)
reusable_task = None
if reusable_task:
# 复用预览产物:标记为正式产出,跳过渲染
reusable_task.mark_confirmed()
@@ -167,21 +133,6 @@ def generate_editor_draft(
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = plan_svc.get_plan_or_raise(plan_id)
config_asset_ids = (plan.config or {}).get("asset_ids", [])
# 从 plan config 读取封面 URL(由 generate-cover 保存)
cover_url_from_config = (plan.config or {}).get("cover", {}).get("image_url", "")
# 处理标题配置:序列化 title_config 为 JSON 存入 custom_title
title_config = req.title_config or {}
title_text = (title_config.get("text") or "").strip()
custom_title_value = ""
if title_text:
custom_title_value = json.dumps(title_config, ensure_ascii=False)
logger.info(
"[模板生成] 标题配置: text=%s, config_keys=%s",
title_text[:30],
list(title_config.keys()),
)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id=plan.project_id or "",
@@ -189,8 +140,6 @@ def generate_editor_draft(
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
cover_url=cover_url_from_config,
custom_title=custom_title_value,
),
)
@@ -6,7 +6,7 @@
from __future__ import annotations
import re as _re
from typing import Any, Dict, List, Optional
from typing import Any, List, Optional
from app.schemas.generation_task import GenerationTaskResponse
from pydantic import BaseModel, Field, validator
@@ -44,14 +44,6 @@ class EditPlanGenerationStatusResponse(BaseModel):
clips: List[ClipStatusItem]
class EditPlanGenerateRequest(BaseModel):
"""模板编辑器触发生成请求体"""
title_config: Optional[Dict[str, Any]] = Field(
default_factory=dict,
description="标题配置(可选),渲染时烧录到视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
)
class EditPlanGenerateResponse(BaseModel):
"""剪辑计划触发生成响应体"""
-6
View File
@@ -58,12 +58,6 @@ class AssetResponse(BaseModel):
MAX_BATCH_SIZE = 200
class BatchGetRequest(BaseModel):
"""批量获取素材详情请求。"""
ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
class BatchDeleteRequest(BaseModel):
"""批量删除请求(软删除)。"""
+60 -52
View File
@@ -1,15 +1,15 @@
/**
* 智能剪辑页面 — V24 前端预览播放器架构改造
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
* 智能剪辑页面 — V22 多预览 + 配音前置
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 选择标题 → 生成预览 → 选择封面 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
*
* 架构改造:
* - Step5 预览改为前端素材切片播放(FrontendPreviewPlayer
* - 完全去除后端 FFmpeg 预览依赖
* - 标题样式通过 CSS 层实时叠加,所见即所得
* - 最终成片仍走后端 FFmpeg 渲染(Step7 确认生成)
* 主组件仅保留整体布局与事件编排
* 状态管理 → hooks/useGenerateFormState
* 步骤导航 → hooks/useStepNavigation
* 步骤内容 → components/GenerateStepContent
* 底部按钮 → components/GenerateStepActions
* 生成核心逻辑 → hooks/useGenerateVideo
*/
import React, { useMemo } from "react"
import React, { useState, useMemo } from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
@@ -24,8 +24,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { useStep5Preview } from "./hooks/useStep5Preview"
import "./generate.css"
const GeneratePage: React.FC = () => {
@@ -72,12 +71,6 @@ const GeneratePage: React.FC = () => {
setPreviewModalOpen,
} = formState
/* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */
const styleUpdaters = useTitleStyleUpdaters({
titleSettings,
onTitleSettingsChange: setTitleSettings,
})
/* ── 克隆声音 ── */
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
@@ -87,23 +80,36 @@ const GeneratePage: React.FC = () => {
message.success("音色克隆成功!")
}
/* ── 前端预览:加载选中素材的视频文件信息 ── */
const previewAssetIds = useMemo(
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
[materialMode, smartSelectedIds, selectedMaterials],
)
const previewAssetsEnabled = currentStep >= 4 && previewAssetIds.length > 0
const {
assets: previewAssets,
loading: previewAssetsLoading,
ready: previewAssetsReady,
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
/* ── 预览数量(多预览) ── */
const [previewCount, setPreviewCount] = useState(1)
/* ── 当前模板对象(传给前端预览播放器) ── */
const currentTemplate = useMemo(
() => userTemplates.find((t) => t.id === selectedTemplate) || null,
[userTemplates, selectedTemplate],
)
/* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */
/* selectedVoice / selectedClonedVoice 均为 string 类型(voice ID),
见 useGenerateFormState 返回值类型定义 */
const previewVoiceIds = useMemo((): string[] => {
if (voiceMode === "clone") {
const id: string = selectedClonedVoice
return id ? [id] : []
}
// preset / custom 模式
const id: string = selectedVoice
return id ? [id] : []
}, [voiceMode, selectedVoice, selectedClonedVoice])
/* ── Step5 预览生成(多预览 + voice_ids ── */
const step5Preview = useStep5Preview({
templates: userTemplates,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
duration,
videoRatio,
voiceIds: previewVoiceIds,
voiceLibraryId: selectedVoice || undefined,
previewCount,
titleSettings,
})
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
@@ -114,7 +120,7 @@ const GeneratePage: React.FC = () => {
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady: previewAssetsReady,
previewReady: step5Preview.canProceed,
})
/* ── 视频生成核心逻辑 ── */
@@ -145,7 +151,7 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
generateCount,
previewTaskId: "",
previewTaskId: step5Preview.selectedTaskId,
})
/* ================================================================
@@ -177,17 +183,6 @@ const GeneratePage: React.FC = () => {
onSmartSelectedIdsChange={setSmartSelectedIds}
titleSettings={titleSettings}
onTitleSettingsChange={setTitleSettings}
/* 标题样式回调 */
onUpdatePosition={styleUpdaters.updatePosition}
onUpdateFont={styleUpdaters.updateFont}
onUpdateSize={styleUpdaters.updateSize}
onToggleBold={styleUpdaters.toggleBold}
onToggleItalic={styleUpdaters.toggleItalic}
onToggleStroke={styleUpdaters.toggleStroke}
onToggleShadow={styleUpdaters.toggleShadow}
onApplyPreset={styleUpdaters.applyPreset}
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
coverSettings={coverSettings}
onCoverSettingsChange={setCoverSettings}
duration={duration}
@@ -212,6 +207,19 @@ const GeneratePage: React.FC = () => {
onRetry={handleRetryGenerate}
onDismissError={handleDismissError}
presetVoices={presetVoices}
videoRatio={videoRatio}
/* Step5 多预览 */
previewCount={previewCount}
onPreviewCountChange={setPreviewCount}
previewItems={step5Preview.items}
previewSelectedIndex={step5Preview.selectedIndex}
onSelectPreview={step5Preview.setSelectedIndex}
previewOverallStatus={step5Preview.previewStatus}
previewOverallError={step5Preview.previewError}
previewOverallProgress={step5Preview.progress}
previewAnyGenerating={step5Preview.anyGenerating}
onGeneratePreview={step5Preview.generatePreview}
onRegeneratePreview={step5Preview.regeneratePreview}
/>
<GenerateStepActions
@@ -227,15 +235,15 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step4+ 显示,含 CSS 标题实时预览层 */}
{/* 预览视频面板(Step4+ 显示) */}
{currentStep >= 4 && (
<PreviewVideoPanel
assets={previewAssets}
template={currentTemplate}
previewStatus={step5Preview.previewStatus}
previewResult={step5Preview.previewResult}
previewError={step5Preview.previewError}
progress={step5Preview.progress}
videoRatio={videoRatio}
assetsReady={previewAssetsReady}
assetsLoading={previewAssetsLoading}
titleSettings={titleSettings}
onRegenerate={step5Preview.regeneratePreview}
/>
)}
{currentStep >= 6 && (
@@ -1,300 +0,0 @@
/**
* 前端预览播放器
* 用原生 <video> 标签按时间线播放素材片段
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
*/
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
interface FrontendPreviewPlayerProps {
/** 选中的素材列表 */
assets: AssetItem[]
/** 当前模板(用于获取片段时长配置) */
template: EditingTemplate | null
/** 视频比例 */
videoRatio: string
/** 是否准备好播放(素材已加载) */
ready: boolean
}
/** 格式化时间 mm:ss */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
/**
* 将素材映射为播放片段
* 每个素材对应一个模板片段,按顺序分配
*/
function buildPlaybackSegments(
assets: AssetItem[],
template: EditingTemplate | null,
): PlaybackSegment[] {
if (!assets.length) return []
const templateSegments = template?.segments || []
const segments: PlaybackSegment[] = []
assets.forEach((asset, i) => {
// 获取素材时长(从 metadata 或顶层字段)
const assetDuration = asset.duration || asset.metadata?.duration || 30
// 模板片段的时长约束
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
const segDuration = tplSeg
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
: Math.min(assetDuration, 10)
const startTime = 0
const endTime = Math.min(startTime + segDuration, assetDuration)
segments.push({
assetId: asset.id,
videoUrl: asset.file_url || asset.storage_key,
startTime,
endTime,
order: i,
})
})
return segments
}
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
assets,
template,
videoRatio,
ready,
}) => {
// 构建播放片段
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
const {
isPlaying,
currentTime,
totalDuration,
currentSegmentIndex,
isEnded,
canPlay,
togglePlayPause,
seekTo,
videoRef,
} = useSegmentScheduler(segments)
// 进度条拖拽
const [isDragging, setIsDragging] = useState(false)
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
seekTo(ratio * totalDuration)
},
[totalDuration, seekTo],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
handleProgressClick(e)
},
[handleProgressClick],
)
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
seekTo(ratio * totalDuration)
}
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, totalDuration, seekTo])
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// 未就绪状态
if (!ready || !assets.length) {
return (
<div className="xx-preview-empty">
<SoundOutlined style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }} />
<p className="xx-preview-empty-title">...</p>
<p className="xx-preview-empty-desc"></p>
</div>
)
}
// 无播放片段
if (!canPlay) {
return (
<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>
)
}
return (
<div className="xx-frontend-preview-player">
{/* 视频区域 */}
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
<video
ref={videoRef}
preload="auto"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
}}
playsInline
/>
{/* 播放/暂停按钮覆盖 */}
{!isPlaying && (
<button
className="xx-preview-play-btn"
onClick={togglePlayPause}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
width: 56,
height: 56,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 28,
zIndex: 10,
transition: "opacity 0.2s",
}}
>
{isEnded ? <PlayCircleOutlined /> : <PlayCircleOutlined />}
</button>
)}
{/* 当前片段指示器 */}
<div
style={{
position: "absolute",
top: 8,
left: 8,
background: "rgba(0,0,0,0.6)",
color: "#fff",
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
zIndex: 10,
}}
>
{currentSegmentIndex + 1}/{segments.length}
</div>
</div>
{/* 控制条 */}
<div
className="xx-preview-controls"
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 0",
}}
>
{/* 播放/暂停 */}
<button
onClick={togglePlayPause}
style={{
background: "none",
border: "none",
color: "var(--text-primary, #fff)",
fontSize: 18,
cursor: "pointer",
padding: 4,
display: "flex",
alignItems: "center",
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 时间 */}
<span
style={{
fontSize: 12,
color: "var(--text-secondary, #999)",
minWidth: 80,
fontVariantNumeric: "tabular-nums",
}}
>
{formatTime(currentTime)} / {formatTime(totalDuration)}
</span>
{/* 进度条 */}
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 4,
background: "rgba(255,255,255,0.15)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#3b82f6",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
{/* 进度指示点 */}
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#3b82f6",
border: "2px solid #fff",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</div>
</div>
)
}
export default FrontendPreviewPlayer
@@ -2,8 +2,6 @@
* GeneratePage 步骤内容渲染
* 根据当前步骤渲染对应的 Step 组件
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*
* V24: 移除 Step5 预览生成相关 props,改为纯标题样式编辑
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
@@ -11,6 +9,7 @@ import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
import Step1TemplateSelect from "../components/Step1TemplateSelect"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3VoiceSelect from "../components/Step5VoiceSelect"
@@ -36,17 +35,6 @@ export interface GenerateStepContentProps {
/* 标题 */
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
/* 标题样式回调 — Step5 样式面板使用 */
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
onUpdateSize: (size: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* 封面 */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
@@ -75,6 +63,19 @@ export interface GenerateStepContentProps {
onDismissError: () => void
/* 其他 */
presetVoices: PresetVoiceItem[]
videoRatio: string
/* Step4 预览(多预览) */
previewCount: number
onPreviewCountChange: (count: number) => void
previewItems: PreviewItem[]
previewSelectedIndex: number
onSelectPreview: (index: number) => void
previewOverallStatus: PreviewStatus
previewOverallError: string
previewOverallProgress: number
previewAnyGenerating: boolean
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -91,16 +92,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onSmartSelectedIdsChange,
titleSettings,
onTitleSettingsChange,
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onApplyPreset,
activePreset,
titlePresets,
coverSettings,
onCoverSettingsChange,
duration,
@@ -119,6 +110,18 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onRetry,
onDismissError,
presetVoices,
videoRatio,
previewCount,
onPreviewCountChange,
previewItems,
previewSelectedIndex,
onSelectPreview,
previewOverallStatus,
previewOverallError,
previewOverallProgress,
previewAnyGenerating,
onGeneratePreview,
onRegeneratePreview,
} = props
switch (currentStep) {
@@ -158,17 +161,18 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
case 5:
return (
<Step5GeneratePreview
titleSettings={titleSettings}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
videoRatio={videoRatio}
previewCount={previewCount}
onPreviewCountChange={onPreviewCountChange}
items={previewItems}
selectedIndex={previewSelectedIndex}
onSelectPreview={onSelectPreview}
overallStatus={previewOverallStatus}
overallError={previewOverallError}
overallProgress={previewOverallProgress}
anyGenerating={previewAnyGenerating}
onGeneratePreview={onGeneratePreview}
onRegeneratePreview={onRegeneratePreview}
/>
)
case 6:
@@ -1,213 +1,117 @@
/**
* 右侧预览视频面板
* Step4+: 显示预览视频面板
* Step5: 前端实时预览 — 用原生 video 播放素材片段 + CSS 标题叠加
* Step5+: 显示后端生成的预览视频(标题已由 FFmpeg 烧录)
*
* 架构改造:完全去除后端 FFmpeg 预览依赖
* - 使用 FrontendPreviewPlayer 直接播放素材片段
* - TitleOverlay CSS 层实时响应标题样式变化
* 设计说明:
* - Step4(标题设置页):右侧显示空状态提示,引导用户输入标题
* - Step5(预览生成页):显示后端返回的预览视频
* - Canvas 预览已删除(统一由后端 FFmpeg 渲染标题)
*/
import React, { useMemo } from "react"
import { LoadingOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import type { TitleSettings } from "../types"
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
import React from "react"
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
interface PreviewVideoPanelProps {
/** 已加载的素材列表 */
assets: AssetItem[]
/** 当前模板 */
template: EditingTemplate | null
/** 视频比例 */
previewStatus: PreviewStatus
previewResult: PreviewResult | null
previewError: string
progress: number
videoRatio: string
/** 素材是否已加载就绪 */
assetsReady: boolean
/** 素材是否正在加载 */
assetsLoading: boolean
/** 标题设置 — 用于 CSS 实时预览层 */
titleSettings?: TitleSettings
onRegenerate: () => void
}
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
const ASS_VIDEO_HEIGHT = 720
const ASS_TITLE_MARGIN_TOP = 60
const ASS_TITLE_MARGIN_BOTTOM = 60
const ASS_TITLE_MARGIN_SIDE = 40
/**
* 根据 position 计算 CSS 垂直定位
* 与后端 position_to_ass_alignment() 对齐:top→8, center→5, bottom→2
*/
function getPositionStyle(position: string): React.CSSProperties {
const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100
switch (position) {
case "bottom":
return {
bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
case "center":
return {
top: "50%",
transform: "translateY(-50%)",
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
case "top":
default:
return {
top: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
}
}
}
/**
* 构建 CSS 标题层的样式
* 所有渲染参数与后端 FFmpeg ASS 字幕一致
*/
function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
const fontSizePercent = (Math.min(settings.size, 36) / ASS_VIDEO_HEIGHT) * 100
const base: React.CSSProperties = {
fontFamily: settings.font || "思源黑体",
fontSize: `${fontSizePercent}%`,
color: settings.color || "#ffffff",
fontWeight: settings.bold ? 700 : 400,
fontStyle: settings.italic ? "italic" : "normal",
lineHeight: 1.3,
wordBreak: "break-word",
pointerEvents: "none",
userSelect: "none",
paddingLeft: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
paddingRight: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
}
if (settings.stroke) {
base.WebkitTextStroke = "1px #000000"
}
if (settings.shadow) {
base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
}
return base
}
/**
* CSS 标题预览覆盖层
*/
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
const positionStyle = useMemo(
() => getPositionStyle(titleSettings.position),
[titleSettings.position],
)
// eslint-disable-next-line react-hooks/exhaustive-deps
const titleStyle = useMemo(
() => buildTitleStyle(titleSettings),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
titleSettings.font,
titleSettings.size,
titleSettings.color,
titleSettings.bold,
titleSettings.italic,
titleSettings.stroke,
titleSettings.shadow,
],
)
if (!titleSettings.title?.trim()) return null
return (
<div
className="xx-preview-title-overlay"
style={{
position: "absolute",
inset: 0,
zIndex: 20,
pointerEvents: "none",
}}
>
<div
style={{
...positionStyle,
...titleStyle,
position: "absolute",
}}
>
{titleSettings.title}
</div>
</div>
)
}
/* ── 主组件 ── */
/* ── 组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
assets,
template,
previewStatus,
previewResult,
previewError,
progress,
videoRatio,
assetsReady,
assetsLoading,
titleSettings,
onRegenerate,
}) => {
const hasPreview = previewStatus === "ready" && previewResult
const isLoading = previewStatus === "pending" || previewStatus === "generating"
const isError = previewStatus === "error"
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
<h3></h3>
{assetsReady && assets.length > 0 && <span className="xx-preview-badge"></span>}
{hasPreview && <span className="xx-preview-badge">480p </span>}
</div>
{/* 加载中 */}
{assetsLoading && (
{/* 空状态:还没生成预览 */}
{previewStatus === "idle" && (
<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>
)}
{/* 生成中 */}
{isLoading && (
<div className="xx-preview-loading-panel">
<div className="xx-preview-video" style={videoAspectStyle}>
<div className="xx-preview-loading-center">
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
...
{previewStatus === "pending" ? "排队中..." : `生成中 ${progress}%`}
</p>
</div>
</div>
<div className="xx-preview-progress-bar-wrap">
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{/* 前端预览播放器 + CSS 标题叠加 */}
{!assetsLoading && (
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assetsReady}
/>
{/* CSS 标题实时预览层 — 与 FFmpeg ASS 渲染坐标对齐 */}
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
{/* 生成失败 */}
{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>
</div>
<p className="xx-preview-error-msg">
{typeof previewError === "string" && previewError ? previewError : "请重试"}
</p>
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onRegenerate}>
</button>
</div>
)}
{/* 素材信息 */}
{assetsReady && assets.length > 0 && (
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span></span>
<span>{assets.length} </span>
{/* 预览成功 */}
{hasPreview && (
<>
<div className="xx-preview-video" style={videoAspectStyle}>
<video src={previewResult.videoUrl} controls preload="metadata" />
</div>
<div className="xx-preview-info-row">
<span></span>
<span>{videoRatio}</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>
</>
)}
</div>
)
@@ -1,13 +1,13 @@
/**
* Step 4 标题设置组件
* 仅包含标题文字输入 + AI 标题生成
* 标题样式面板已迁移到 Step5(生成预览页面)
*/
import React from "react"
import { AutoComplete } from "antd"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import type { TitleSettings } from "../types"
import { useStep4Title } from "../hooks/useStep4Title"
import AiTitleGenerator from "./title/AiTitleGenerator"
import TitleStylePanel from "./title/TitleStylePanel"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
@@ -110,6 +110,23 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
</div>
</>
)}
{/* 样式面板始终可见,两种模式下都可调整标题展示样式 */}
<TitleStylePanel
settings={t.titleSettings}
onUpdatePosition={t.updatePosition}
onUpdateFont={t.updateFont}
onUpdateSize={t.updateSize}
onToggleBold={t.toggleBold}
onToggleItalic={t.toggleItalic}
onToggleStroke={t.toggleStroke}
onToggleShadow={t.toggleShadow}
onApplyPreset={t.applyPreset}
activePreset={t.activePreset}
titlePresets={t.titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
</div>
)
}
@@ -1,82 +1,274 @@
/**
* Step 5 生成预览组件
* 架构改造:移除后端预览生成,改为前端实时预览
* 左侧仅保留标题样式面板,视频在右侧 PreviewVideoPanel 实时播放
* Step 5 生成预览组件(支持多预览)
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
*/
import React from "react"
import { PlayCircleOutlined } from "@ant-design/icons"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import type { TitleSettings } from "../types"
import TitleStylePanel from "./title/TitleStylePanel"
import {
CheckCircleFilled,
LoadingOutlined,
ReloadOutlined,
PlayCircleOutlined,
ExclamationCircleFilled,
ClockCircleOutlined,
} from "@ant-design/icons"
import { InputNumber } from "antd"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
interface Step5GeneratePreviewProps {
/* 标题样式 */
titleSettings: TitleSettings
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
onUpdateSize: (size: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
videoRatio: string
previewCount: number
onPreviewCountChange: (count: number) => void
items: PreviewItem[]
selectedIndex: number
onSelectPreview: (index: number) => void
overallStatus: PreviewStatus
overallError: string
overallProgress: number
anyGenerating: boolean
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
/** 预览数量选项 */
const PREVIEW_COUNT_OPTIONS = [
{ value: 1, label: "1个" },
{ value: 2, label: "2个" },
{ value: 3, label: "3个" },
]
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
titleSettings,
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onApplyPreset,
activePreset,
titlePresets,
videoRatio,
previewCount,
onPreviewCountChange,
items,
selectedIndex,
onSelectPreview,
overallStatus,
overallError,
overallProgress,
anyGenerating,
onGeneratePreview,
onRegeneratePreview,
}) => {
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
const isIdle = overallStatus === "idle"
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
return (
<div className="xx-form-section">
<h3>🎬 </h3>
<h3>🎬 </h3>
{/* 前端实时预览提示 */}
<div
className="xx-preview-tip"
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 16px",
background: "rgba(59, 130, 246, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(59, 130, 246, 0.15)",
}}
>
<PlayCircleOutlined style={{ fontSize: 18, color: "#3b82f6" }} />
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
</span>
</div>
{/* 预览数量选择器(仅在 idle 状态显示) */}
{isIdle && (
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: 14, color: "#666" }}></span>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{PREVIEW_COUNT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onPreviewCountChange(opt.value)}
style={{
padding: "4px 12px",
borderRadius: 6,
border: previewCount === opt.value ? "1px solid #1677ff" : "1px solid #d9d9d9",
background: previewCount === opt.value ? "#e6f4ff" : "#fff",
color: previewCount === opt.value ? "#1677ff" : "#666",
cursor: "pointer",
fontSize: 13,
fontWeight: previewCount === opt.value ? 600 : 400,
}}
>
{opt.label}
</button>
))}
<InputNumber
min={1}
max={10}
value={previewCount}
onChange={(val) => val && onPreviewCountChange(val)}
style={{ width: 70 }}
placeholder="自定义"
/>
<span style={{ fontSize: 12, color: "#999", marginLeft: 4 }}>1~10</span>
</div>
{previewCount > 1 && (
<span style={{ fontSize: 12, color: "#999" }}></span>
)}
</div>
)}
{/* 标题样式面板 */}
<TitleStylePanel
settings={titleSettings}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
{/* 预览生成按钮(idle 状态) */}
{isIdle && (
<div className="xx-preview-generate-section">
<div className="xx-preview-generate-hint">
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
<p className="xx-preview-generate-title"></p>
<p className="xx-preview-generate-desc">
AI
{previewCount > 1 ? `${previewCount}个不同版本的` : ""}480p
</p>
</div>
<button
className="xx-btn xx-btn-primary xx-preview-generate-btn"
onClick={onGeneratePreview}
>
{previewCount > 1 ? `${previewCount}个)` : ""}
</button>
</div>
)}
{/* 整体排队中(所有都在 pending) */}
{anyGenerating && items.every((it) => it.status === "pending") && (
<div className="xx-preview-loading">
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
<p className="xx-preview-loading-text">...</p>
<p className="xx-preview-loading-desc"></p>
</div>
)}
{/* 多预览网格(生成中/完成/部分完成) */}
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
<div
className="xx-preview-grid"
style={{
display: "grid",
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
gap: 12,
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
margin: "0 auto 16px",
}}
>
{items.map((item) => {
const isSelected = item.index === selectedIndex
return (
<div
key={item.index}
onClick={() => {
if (item.status === "ready") onSelectPreview(item.index)
}}
style={{
borderRadius: 8,
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
overflow: "hidden",
cursor: item.status === "ready" ? "pointer" : "default",
opacity: item.status === "error" ? 0.6 : 1,
transition: "all 0.2s",
}}
>
{/* 轻量卡片:深色背景 + 状态指示 */}
<div
style={{
aspectRatio,
background: "#1a1a2e",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
position: "relative",
gap: 8,
}}
>
{/* 中心:预览编号 */}
<span
style={{
fontSize: 24,
fontWeight: 700,
color: "#fff",
opacity: 0.9,
}}
>
#{item.index + 1}
</span>
{/* 状态指示 */}
{item.status === "generating" && (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<LoadingOutlined style={{ fontSize: 14, color: "#fff" }} spin />
<span style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
{item.progress}%
</span>
</div>
)}
{item.status === "pending" && (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<ClockCircleOutlined
style={{ fontSize: 14, color: "rgba(255,255,255,0.6)" }}
/>
<span style={{ color: "rgba(255,255,255,0.6)", fontSize: 12 }}>
...
</span>
</div>
)}
{item.status === "ready" && (
<CheckCircleFilled style={{ fontSize: 18, color: "#52c41a" }} />
)}
{item.status === "error" && (
<ExclamationCircleFilled style={{ fontSize: 18, color: "#ef4444" }} />
)}
{/* 选中角标 */}
{isSelected && item.status === "ready" && (
<div
style={{
position: "absolute",
top: 4,
right: 4,
background: "#1677ff",
color: "#fff",
fontSize: 10,
padding: "2px 6px",
borderRadius: 4,
}}
>
#{item.index + 1}
</div>
)}
</div>
</div>
)
})}
</div>
)}
{/* 整体进度条(多预览生成中) */}
{anyGenerating && (
<div className="xx-preview-progress-bar" style={{ marginBottom: 12 }}>
<div className="xx-preview-progress-fill" style={{ width: `${overallProgress}%` }} />
</div>
)}
{/* 全部完成提示 */}
{overallStatus === "ready" && (
<div className="xx-preview-tip">
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
<span>
{items.filter((it) => it.status === "ready").length}
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
</span>
<button
className="xx-preview-regenerate-btn"
onClick={onRegeneratePreview}
title="重新生成"
>
<ReloadOutlined />
</button>
</div>
)}
{/* 全部失败 */}
{isError && (
<div className="xx-preview-error">
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
<p className="xx-preview-error-text"></p>
<p className="xx-preview-error-desc">
{typeof overallError === "string" && overallError ? overallError : "请稍后重试"}
</p>
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
<ReloadOutlined />
</button>
</div>
)}
</div>
)
}
@@ -5,7 +5,7 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { GeneratedVideo } from "@/api/template-editor"
import { confirmGeneration, createPreview } from "@/api/generation"
import { confirmGeneration } from "@/api/generation"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
@@ -55,17 +55,21 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
clearTimer()
try {
// 使用确认生成 API(基于预览任务)
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
const ratio = props.videoRatio || "9:16"
let outputWidth: number
let outputHeight: number
if (ratio.includes(":")) {
// 宽高比格式,如 "9:16" → 基于基准高度 1920 计算
const [rw, rh] = ratio.split(":").map(Number)
if (rw > 0 && rh > 0) {
// 基准:长边 1920,短边按比例计算
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
const baseLong = 1920
const baseShort = Math.round((baseLong * shortSide) / longSide)
// 确保偶数(FFmpeg 要求)
const evenShort = baseShort - (baseShort % 2)
if (rw < rh) {
outputWidth = evenShort
@@ -79,6 +83,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
outputHeight = 1920
}
} else if (ratio.includes("x")) {
// 分辨率格式,如 "1080x1920"
const [wStr, hStr] = ratio.split("x")
outputWidth = parseInt(wStr, 10) || 1080
outputHeight = parseInt(hStr, 10) || 1920
@@ -87,35 +92,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
outputHeight = 1920
}
// 获取或创建后端任务 ID
// 预览改为前端播放后,不再有预览任务,需要在此处创建
let taskId = props.previewTaskId
if (!taskId) {
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
const previewResp = await createPreview({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: props.duration || undefined,
video_ratio: props.videoRatio,
voice_ids: undefined,
title_config: props.titleSettings?.title
? {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
}
: undefined,
})
taskId = previewResp.task_id
}
await confirmGeneration(taskId, {
await confirmGeneration(props.previewTaskId, {
output_width: outputWidth,
output_height: outputHeight,
cover_url: props.coverSettings.upload_url || "",
@@ -132,7 +109,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(finalMsg)
message.error(finalMsg)
}
}, [props, clearTimer, startPolling, selectedTemplate])
}, [props, clearTimer, startPolling])
/* 重新生成(失败后重试) */
const retry = useCallback(() => {
@@ -1,125 +0,0 @@
/**
* 预览素材加载 Hook
* 根据选中的素材 ID 列表,批量获取素材详情(含 file_url、duration 等)
* 供前端预览播放器使用
*/
import { useState, useEffect, useCallback, useRef } from "react"
import type { AssetItem } from "@/api/assets"
import type { AxiosResponse } from "axios"
/** 批量获取素材详情的 API 路径 */
const ASSETS_BATCH_URL = "/assets/batch"
/**
* 通过 ID 列表批量获取素材
* 优先使用批量接口,失败则回退为逐个获取
*/
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
if (!ids.length) return []
try {
// 尝试批量接口
const { default: apiClient } = await import("@/api/client")
const response = await apiClient.post(ASSETS_BATCH_URL, { ids })
const items: AssetItem[] = response.data?.items || response.data || []
if (items.length > 0) return items
} catch {
// 批量接口不存在,回退为逐个获取
}
// 回退:逐个获取
try {
const { default: apiClient } = await import("@/api/client")
const results = await Promise.allSettled(
ids.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
)
return results
.filter(
(r): r is PromiseFulfilledResult<AxiosResponse<AssetItem>> =>
r.status === "fulfilled" && !!r.value?.data,
)
.map((r) => r.value.data)
} catch {
return []
}
}
interface UsePreviewAssetsReturn {
/** 加载后的素材列表 */
assets: AssetItem[]
/** 是否正在加载 */
loading: boolean
/** 是否已就绪(加载完成) */
ready: boolean
/** 手动触发重新加载 */
reload: () => void
}
/**
* usePreviewAssets — 加载选中素材的视频文件信息
*/
export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn {
const [assets, setAssets] = useState<AssetItem[]>([])
const [loading, setLoading] = useState(false)
const [ready, setReady] = useState(false)
const requestIdRef = useRef(0)
// 稳定化 assetIds:只有内容真正变化时才更新引用
const stableAssetIds = useStableArray(assetIds)
const load = useCallback(async () => {
if (!stableAssetIds.length || !enabled) {
setAssets([])
setReady(false)
return
}
const thisRequestId = ++requestIdRef.current
setLoading(true)
setReady(false)
try {
const result = await fetchAssetsByIds(stableAssetIds)
// 防止竞态:只保留最新请求的结果
if (requestIdRef.current === thisRequestId) {
setAssets(result)
setReady(result.length > 0)
}
} catch {
if (requestIdRef.current === thisRequestId) {
setAssets([])
setReady(false)
}
} finally {
if (requestIdRef.current === thisRequestId) {
setLoading(false)
}
}
}, [stableAssetIds, enabled])
useEffect(() => {
load()
}, [load])
return { assets, loading, ready, reload: load }
}
/**
* useStableArray — 数组内容稳定化 Hook
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
*/
function useStableArray<T>(array: T[]): T[] {
const ref = useRef<T[]>(array)
// 比较数组内容是否真正变化
const hasChanged =
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
if (hasChanged) {
ref.current = array
}
return ref.current
}
export default usePreviewAssets
@@ -1,311 +0,0 @@
/**
* 素材片段调度器 Hook
* 控制原生 <video> 元素按时间线依次播放素材片段(入点→出点)
* 实现前端预览播放,替代后端 FFmpeg 渲染
*/
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"
/** 单个播放片段 */
export interface PlaybackSegment {
/** 素材 ID */
assetId: string
/** 素材视频 URL */
videoUrl: string
/** 片段在素材中的入点(秒) */
startTime: number
/** 片段在素材中的出点(秒) */
endTime: number
/** 片段在时间线中的顺序 */
order: number
}
/** 调度器返回 */
export interface SegmentSchedulerState {
/** 是否正在播放 */
isPlaying: boolean
/** 当前播放的全局时间(秒) */
currentTime: number
/** 总时长(秒) */
totalDuration: number
/** 当前片段索引(在 segments 数组中的位置) */
currentSegmentIndex: number
/** 当前片段的本地播放时间 */
segmentLocalTime: number
/** 是否已播完 */
isEnded: boolean
/** 是否可以播放(至少有 1 个片段) */
canPlay: boolean
/** 播放 */
play: () => void
/** 暂停 */
pause: () => void
/** 切换播放/暂停 */
togglePlayPause: () => void
/** 跳转到全局时间 */
seekTo: (time: number) => void
/** 绑定到 <video> 元素 */
videoRef: React.RefObject<HTMLVideoElement>
}
/**
* 根据全局时间定位对应的片段和本地时间
*/
function findSegmentAtTime(
segments: PlaybackSegment[],
globalTime: number,
): { index: number; localTime: number } {
let accumulated = 0
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
const segDuration = seg.endTime - seg.startTime
if (globalTime < accumulated + segDuration || i === segments.length - 1) {
return { index: i, localTime: seg.startTime + (globalTime - accumulated) }
}
accumulated += segDuration
}
return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime }
}
/**
* 计算每个片段的全局起始时间
*/
function buildTimeline(segments: PlaybackSegment[]): number[] {
const starts: number[] = []
let acc = 0
for (const seg of segments) {
starts.push(acc)
acc += seg.endTime - seg.startTime
}
return starts
}
/**
* useSegmentScheduler — 素材片段调度器
*/
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
const videoRef = useRef<HTMLVideoElement | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
const [isEnded, setIsEnded] = useState(false)
const rafRef = useRef<number>(0)
const isSeekingRef = useRef(false)
// 预加载用的隐藏 video 元素
const preloadVideoRef = useRef<HTMLVideoElement | null>(null)
// 计算时间线
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
const totalDuration = useMemo(
() => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0),
[segments],
)
const canPlay = segments.length > 0
// 当前片段信息
const currentSegment = segments[currentSegmentIndex] || null
const segmentLocalTime = currentSegment
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
: 0
/** 切换到指定片段 */
const switchToSegment = useCallback(
(index: number, seekToLocalTime?: number) => {
const video = videoRef.current
if (!video || index >= segments.length) return
const seg = segments[index]
video.src = seg.videoUrl
const localTime = seekToLocalTime ?? seg.startTime
// 等待 src 设置后再设置 currentTime
const onLoaded = () => {
video.currentTime = localTime
video.removeEventListener("loadedmetadata", onLoaded)
}
video.addEventListener("loadedmetadata", onLoaded)
setCurrentSegmentIndex(index)
// 预加载下一段
if (index + 1 < segments.length) {
const nextSeg = segments[index + 1]
if (!preloadVideoRef.current) {
preloadVideoRef.current = document.createElement("video")
preloadVideoRef.current.preload = "auto"
}
preloadVideoRef.current.src = nextSeg.videoUrl
}
},
[segments],
)
/** 播放循环 — 检测片段边界并切换 */
const tick = useCallback(() => {
const video = videoRef.current
if (!video || isSeekingRef.current) {
rafRef.current = requestAnimationFrame(tick)
return
}
const seg = segments[currentSegmentIndex]
if (!seg) return
// 检查是否到达出点(容差 0.15s)
if (video.currentTime >= seg.endTime - 0.15) {
const nextIndex = currentSegmentIndex + 1
if (nextIndex < segments.length) {
// 切到下一段
switchToSegment(nextIndex)
const accumulatedTime =
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
setCurrentTime(accumulatedTime)
} else {
// 播放结束
video.pause()
setIsPlaying(false)
setIsEnded(true)
setCurrentTime(totalDuration)
return
}
} else {
// 更新全局时间
const globalTime =
(timelineStarts[currentSegmentIndex] || 0) + (video.currentTime - seg.startTime)
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
}
rafRef.current = requestAnimationFrame(tick)
}, [segments, currentSegmentIndex, timelineStarts, totalDuration, switchToSegment])
/** 播放 */
const play = useCallback(async () => {
const video = videoRef.current
if (!video || !canPlay) return
setIsEnded(false)
// 如果还没设置 src(首次播放),先加载第一段
if (!video.src || video.src === "") {
switchToSegment(0)
}
try {
await video.play()
setIsPlaying(true)
rafRef.current = requestAnimationFrame(tick)
} catch {
// 自动播放可能被浏览器阻止,忽略
console.warn("[useSegmentScheduler] auto-play blocked by browser")
}
}, [canPlay, switchToSegment, tick])
/** 暂停 */
const pause = useCallback(() => {
const video = videoRef.current
if (video) video.pause()
setIsPlaying(false)
cancelAnimationFrame(rafRef.current)
}, [])
/** 切换播放/暂停 */
const togglePlayPause = useCallback(() => {
if (isPlaying) {
pause()
} else {
if (isEnded) {
// 播放结束后再次播放,从头开始
setIsEnded(false)
switchToSegment(0, segments[0]?.startTime)
const video = videoRef.current
if (video) {
const onSeeked = () => {
video.play().catch(() => {})
setIsPlaying(true)
setCurrentTime(0)
rafRef.current = requestAnimationFrame(tick)
video.removeEventListener("seeked", onSeeked)
}
video.addEventListener("seeked", onSeeked)
}
} else {
play()
}
}
}, [isPlaying, isEnded, pause, play, switchToSegment, segments, tick])
/** 跳转到指定全局时间 */
const seekTo = useCallback(
(time: number) => {
const video = videoRef.current
if (!video || !canPlay) return
const clampedTime = Math.max(0, Math.min(time, totalDuration))
const { index, localTime } = findSegmentAtTime(segments, clampedTime)
isSeekingRef.current = true
// 如果片段变了,需要切换 src
if (index !== currentSegmentIndex) {
switchToSegment(index, localTime)
// switchToSegment 会设置 src 并在 loadedmetadata 后设置 currentTime
// 所以这里不需要再设置
} else {
video.currentTime = localTime
}
setCurrentTime(clampedTime)
setCurrentSegmentIndex(index)
setIsEnded(false)
// 延迟恢复 tick 检测
setTimeout(() => {
isSeekingRef.current = false
}, 200)
},
[canPlay, totalDuration, segments, currentSegmentIndex, switchToSegment],
)
// 组件卸载时清理
useEffect(() => {
return () => {
cancelAnimationFrame(rafRef.current)
if (preloadVideoRef.current) {
preloadVideoRef.current.src = ""
preloadVideoRef.current = null
}
}
}, [])
// 片段列表变化时重置
useEffect(() => {
cancelAnimationFrame(rafRef.current)
setIsPlaying(false)
setCurrentTime(0)
setCurrentSegmentIndex(0)
setIsEnded(false)
const video = videoRef.current
if (video) {
video.pause()
video.src = ""
}
}, [segments])
return {
isPlaying,
currentTime,
totalDuration,
currentSegmentIndex,
segmentLocalTime,
isEnded,
canPlay,
play,
pause,
togglePlayPause,
seekTo,
videoRef,
}
}
export default useSegmentScheduler
@@ -0,0 +1,476 @@
/**
* Step 5 生成预览 Hook(支持多预览 + voice_ids
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
*/
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation"
import { updateEditPlan } from "@/api/template-editor/editPlans"
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
import type { EditingTemplate } from "@/api/editing-planner"
import { safeExtractError } from "./generate-video/errorUtils"
import type { TitleSettings } from "../types"
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
const safeString = (val: unknown, fallback: string): string => {
if (val == null) return fallback
const s = safeExtractError(val)
return s || fallback
}
/** 安全地将值转为数字,防止非数字值进入渲染 */
const safeNumber = (val: unknown, fallback = 0): number => {
if (typeof val === "number" && !Number.isNaN(val)) return val
if (typeof val === "string") {
const n = Number(val)
return Number.isNaN(n) ? fallback : n
}
return fallback
}
interface UseStep5PreviewProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
duration: number
videoRatio: string
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
voiceIds?: string[]
/** 配音素材库ID(用户选择的上传音频或AI配音素材) */
voiceLibraryId?: string
/** 要生成的预览数量 */
previewCount?: number
/** 标题设置(传递给后端,让预览视频包含标题) */
titleSettings?: TitleSettings
}
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
/** 单个预览生成结果 */
export interface PreviewResult {
taskId: string
videoUrl: string
clipCount: number
transitionCount: number
materialUsage: number
duration: number
fileSize: number
generateDuration: number
progress: number
}
/** 单个预览项的完整状态(用于多预览) */
export interface PreviewItem {
index: number
status: PreviewStatus
result: PreviewResult | null
error: string
progress: number
}
// 轮询超时时间(10 分钟)
const POLL_TIMEOUT_MS = 10 * 60 * 1000
/** 初始单项状态 */
const createInitialItem = (index: number): PreviewItem => ({
index,
status: "idle",
result: null,
error: "",
progress: 0,
})
export function useStep5Preview({
templates,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
duration,
videoRatio,
voiceIds,
voiceLibraryId,
previewCount = 1,
titleSettings,
}: UseStep5PreviewProps) {
const templateName = useMemo(
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
[templates, selectedTemplate],
)
const materialCount = useMemo(() => {
if (materialMode === "auto") {
return `${smartSelectedIds.length} 个素材(智能匹配)`
}
return `${selectedMaterials.length} 个素材`
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
/* ── 多预览状态 ── */
const [items, setItems] = useState<PreviewItem[]>(() =>
Array.from({ length: previewCount }, (_, i) => createInitialItem(i)),
)
const [selectedIndex, setSelectedIndex] = useState(0)
// 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储)
const taskIdsRef = useRef<Map<number, string>>(new Map())
const pollTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
const startTimeRef = useRef<number>(0)
const clearPollTimer = useCallback((index?: number) => {
if (index !== undefined) {
const timer = pollTimersRef.current.get(index)
if (timer) {
clearTimeout(timer)
pollTimersRef.current.delete(index)
}
} else {
pollTimersRef.current.forEach((timer) => clearTimeout(timer))
pollTimersRef.current.clear()
}
}, [])
// 同步 previewCount 变化(增减项)
useEffect(() => {
setItems((prev) => {
if (prev.length === previewCount) return prev
if (prev.length > previewCount) return prev.slice(0, previewCount)
return [
...prev,
...Array.from({ length: previewCount - prev.length }, (_, i) =>
createInitialItem(prev.length + i),
),
]
})
// 如果 selectedIndex 超出范围,重置
setSelectedIndex((prev) => Math.min(prev, previewCount - 1))
}, [previewCount])
/* ── 参数变化时重置所有预览状态 ── */
const prevDepsRef = useRef({
selectedTemplate,
materialMode,
selectedMaterials: [...selectedMaterials].sort().join(","),
smartSelectedIds: [...smartSelectedIds].sort().join(","),
duration,
videoRatio,
voiceIds: [...(voiceIds || [])].sort().join(","),
titleSettings: titleSettings?.title || "",
})
useEffect(() => {
const currentKey = [
selectedTemplate,
materialMode,
[...selectedMaterials].sort().join(","),
[...smartSelectedIds].sort().join(","),
duration,
videoRatio,
[...(voiceIds || [])].sort().join(","),
titleSettings?.title || "",
].join("|")
const prevKey = [
prevDepsRef.current.selectedTemplate,
prevDepsRef.current.materialMode,
prevDepsRef.current.selectedMaterials,
prevDepsRef.current.smartSelectedIds,
prevDepsRef.current.duration,
prevDepsRef.current.videoRatio,
prevDepsRef.current.voiceIds,
prevDepsRef.current.titleSettings,
].join("|")
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
taskIdsRef.current.clear()
clearPollTimer()
setItems(Array.from({ length: previewCount }, (_, i) => createInitialItem(i)))
setSelectedIndex(0)
}
prevDepsRef.current = {
selectedTemplate,
materialMode,
selectedMaterials: [...selectedMaterials].sort().join(","),
smartSelectedIds: [...smartSelectedIds].sort().join(","),
duration,
videoRatio,
voiceIds: [...(voiceIds || [])].sort().join(","),
titleSettings: titleSettings?.title || "",
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
duration,
videoRatio,
voiceIds,
previewCount,
titleSettings,
])
// 组件卸载时清理所有轮询
useEffect(() => {
return () => {
clearPollTimer()
}
}, [clearPollTimer])
/** 轮询单个预览任务状态 */
const pollPreviewStatus = useCallback(
(index: number, taskId: string) => {
const poll = async () => {
// 竞态检查
if (taskIdsRef.current.get(index) !== taskId) return
// 超时检查
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
),
)
return
}
try {
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
if (taskIdsRef.current.get(index) !== taskId) return
const status = data.status as ApiPreviewStatus
if (status === "completed") {
const result: PreviewResult = {
taskId: safeString(data.task_id, ""),
videoUrl: safeString(data.video_url, ""),
clipCount: safeNumber(data.clip_count),
transitionCount: safeNumber(data.transition_count),
materialUsage: safeNumber(data.material_usage),
duration: safeNumber(data.duration),
fileSize: safeNumber(data.file_size),
generateDuration: safeNumber(data.generate_duration),
progress: 100,
}
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
),
)
// 保存预览视频 URL 到 plan config,供封面生成使用
if (result.videoUrl && selectedTemplate) {
updateEditPlan(selectedTemplate, {
config: { rendered_storage_key: result.videoUrl },
}).catch((err) => {
console.warn("[Step4] 保存预览视频URL到plan config失败:", err)
})
}
return
}
if (status === "failed") {
setItems((prev) =>
prev.map((it) =>
it.index === index
? {
...it,
status: "error",
error: safeString(data.error_message, "预览生成失败,请重试"),
}
: it,
),
)
return
}
if (status === "cancelled") {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
),
)
return
}
// pending / generating 状态继续轮询
const prog = safeNumber(data.progress)
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
),
)
const delay = status === "pending" ? 5000 : 2000
pollTimersRef.current.set(index, setTimeout(poll, delay))
} catch {
if (taskIdsRef.current.get(index) !== taskId) return
pollTimersRef.current.set(index, setTimeout(poll, 3000))
}
}
pollTimersRef.current.set(index, setTimeout(poll, 1000))
},
[selectedTemplate],
)
/** 生成所有预览 */
const generatePreview = useCallback(async () => {
if (!selectedTemplate) {
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择模板" })))
return
}
if (materialTotal === 0) {
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择素材" })))
return
}
// 取消之前的所有轮询
clearPollTimer()
taskIdsRef.current.clear()
// 初始化所有项为 pending
setItems(
Array.from({ length: previewCount }, (_, i) => ({
index: i,
status: "pending" as PreviewStatus,
result: null,
error: "",
progress: 0,
})),
)
setSelectedIndex(0)
startTimeRef.current = Date.now()
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
// 并发创建所有预览任务(Promise.all 并行请求,减少串行等待)
const createTasks = Array.from({ length: previewCount }, async (_, i) => {
try {
const response = await createPreview({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: duration || undefined,
video_ratio: videoRatio,
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
voice_library_id: voiceLibraryId || undefined,
// 标题烧录配置
title_config: titleSettings?.title
? {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
}
: undefined,
})
if (startTimeRef.current === 0) return
taskIdsRef.current.set(i, response.task_id)
pollPreviewStatus(i, response.task_id)
} catch (e) {
const errMsg = safeString(e instanceof Error ? e.message : e, "预览生成失败")
setItems((prev) =>
prev.map((it) => (it.index === i ? { ...it, status: "error", error: errMsg } : it)),
)
}
})
await Promise.all(createTasks)
}, [
selectedTemplate,
materialTotal,
materialMode,
smartSelectedIds,
selectedMaterials,
duration,
videoRatio,
voiceIds,
voiceLibraryId,
previewCount,
titleSettings,
clearPollTimer,
pollPreviewStatus,
])
/** 重新生成所有预览 */
const regeneratePreview = useCallback(() => {
generatePreview()
}, [generatePreview])
/** 是否所有预览都已完成 */
const allReady = items.length > 0 && items.every((it) => it.status === "ready")
/** 是否至少有一个预览已完成 */
const anyReady = items.some((it) => it.status === "ready")
/** 是否有任一正在生成中 */
const anyGenerating = items.some((it) => it.status === "pending" || it.status === "generating")
/** 当前选中的预览结果 */
const selectedResult = items[selectedIndex]?.result ?? null
/** 综合状态(兼容旧逻辑) */
const previewStatus: PreviewStatus = useMemo(() => {
if (items.every((it) => it.status === "idle")) return "idle"
if (items.some((it) => it.status === "pending" || it.status === "generating"))
return "generating"
if (allReady) return "ready"
if (items.every((it) => it.status === "error")) return "error"
// 部分完成部分出错
if (anyReady) return "ready"
return "error"
}, [items, allReady, anyReady])
/** 综合进度(取平均) */
const progress = useMemo(() => {
if (items.length === 0) return 0
return Math.round(items.reduce((sum, it) => sum + it.progress, 0) / items.length)
}, [items])
/** 综合错误信息 */
const previewError = useMemo(() => {
const errorItems = items.filter((it) => it.status === "error" && it.error)
if (errorItems.length === 0) return ""
if (errorItems.length === 1) return errorItems[0].error
return `${errorItems.length} 个预览生成失败`
}, [items])
const canProceed = anyReady
/** 当前选中预览的 taskId(用于确认生成时复用预览产物) */
const selectedTaskId = selectedResult?.taskId ?? ""
return {
templateName,
materialCount,
duration,
videoRatio,
// 多预览状态
items,
selectedIndex,
setSelectedIndex,
previewCount,
// 综合状态
previewStatus,
previewResult: selectedResult,
previewError,
progress,
canProceed,
allReady,
anyReady,
anyGenerating,
generatePreview,
regeneratePreview,
// 确认生成复用预览产物
selectedTaskId,
}
}
export default useStep5Preview
@@ -109,30 +109,26 @@ export function useStep6Cover({
console.error("[Step6] 智能封面生成失败:", err)
// 提取详细错误信息
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyErr = err as any
// 如果 API 拦截器已经弹出了后端返回的具体错误信息,这里跳过重复 toast
if (anyErr?.__msgShown) {
// 拦截器已处理,不再重复弹出
} else {
let errorMsg = "封面生成失败"
const e = anyErr as {
response?: { data?: { detail?: string; message?: string }; status?: number }
request?: unknown
message?: string
}
if (e.response) {
const detail = e.response.data?.detail || e.response.data?.message || ""
errorMsg = detail || `后端错误 (${e.response.status})`
console.error("[Step6] 后端返回:", e.response.data)
} else if (e.request) {
errorMsg = "服务器无响应,请检查网络连接"
console.error("[Step6] 请求无响应:", e.request)
} else if (e.message) {
errorMsg = e.message
}
message.error(errorMsg)
let errorMsg = "封面生成失败"
const e = err as {
response?: { data?: { detail?: string; message?: string }; status?: number }
request?: unknown
message?: string
}
if (e.response) {
// 后端返回错误
const detail = e.response.data?.detail || e.response.data?.message || ""
errorMsg = detail || `后端错误 (${e.response.status})`
console.error("[Step6] 后端返回:", e.response.data)
} else if (e.request) {
// 请求已发送但无响应
errorMsg = "服务器无响应,请检查网络连接"
console.error("[Step6] 请求无响应:", e.request)
} else if (e.message) {
errorMsg = e.message
}
message.error(errorMsg)
} finally {
clearTimeout(timeoutId)
setGenerating(false)
@@ -2,8 +2,6 @@
* GeneratePage 步骤导航
* 管理步骤切换与各步骤的前置校验
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*
* V24: previewReady 改为前端素材加载状态
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -16,7 +14,7 @@ export interface UseStepNavigationOptions {
selectedMaterials: string[]
smartSelectedIds: string[]
titleSettings: TitleSettings
/** 预览是否就绪(前端素材已加载) */
/** Step4 是否已生成预览 */
previewReady: boolean
}
@@ -56,7 +54,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
return
}
if (currentStep === 5 && !previewReady) {
message.warning("请先选择素材以预览效果")
message.warning("请先生成剪辑预览")
return
}
if (currentStep < 7) {
+8 -21
View File
@@ -31,27 +31,14 @@ vi.mock("react-router-dom", async () => {
})
vi.mock("@/store/authStore", () => ({
useAuthStore: Object.assign(
(selector: any) =>
selector({
user: { id: "1", username: "testuser" },
token: "mock-token",
refreshToken: "mock-refresh-token",
isAuthenticated: true,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
}),
{
getState: () => ({
user: { id: "1", username: "testuser" },
token: "mock-token",
refreshToken: "mock-refresh-token",
isAuthenticated: true,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
}),
},
),
useAuthStore: (selector: any) =>
selector({
user: { id: "1", username: "testuser" },
token: "mock-token",
isAuthenticated: true,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
}),
}))
vi.mock("@tanstack/react-query", () => ({
@@ -47,8 +47,7 @@ describe("GeneratePage module smoke test", () => {
})
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/usePreviewAssets"
import "@/pages/generate/hooks/useSegmentScheduler"
import "@/pages/generate/hooks/useStep5Preview"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
import "@/pages/generate/hooks/useGenerateFormState"
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
@@ -5,9 +5,7 @@
import { describe, it, expect } from "vitest"
import "@/pages/generate/components/Step5GeneratePreview"
import "@/pages/generate/hooks/usePreviewAssets"
import "@/pages/generate/hooks/useSegmentScheduler"
import "@/pages/generate/components/FrontendPreviewPlayer"
import "@/pages/generate/hooks/useStep5Preview"
import "@/pages/generate/hooks/useStepNavigation"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/GeneratePage"
@@ -98,7 +98,6 @@ def _finalize_render_success(
output_path: Path,
engine: str,
thumbnail_url: str = "",
cover_candidates: list[dict] | None = None,
) -> dict:
"""渲染成功后的统一收尾:查重 + 更新状态 + 返回结果。"""
# 创建 GeneratedVideo 记录 + 查重
@@ -164,19 +163,6 @@ def _finalize_render_success(
clip_count=len(rendered_clip_ids),
)
gen_task.completed_at = datetime.now(timezone.utc)
# 回写封面 URL 到 GenerationTask,供封面生成接口读取
if cover_candidates:
first_cover = cover_candidates[0].get("image_url") or cover_candidates[0].get("url") or ""
if first_cover:
gen_task.cover_url = first_cover
logger.info(
"预览渲染完成,回写 cover_url: plan_id=%s task_id=%s url=%s",
plan_id,
generation_task_id,
first_cover[:80],
)
gen_task_repo.update(gen_task)
logger.info(
@@ -302,7 +288,6 @@ def _render_with_unified(
output_path=output_path,
engine="unified",
thumbnail_url=thumbnail_url,
cover_candidates=result.cover_candidates,
)
+158
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import json
import logging
import os
import tempfile
import time
from pathlib import Path
@@ -36,6 +37,8 @@ from packages.domain.bgm_utils import merge_bgm_config
OUTPUT_WIDTH = 1280
OUTPUT_HEIGHT = 720
OUTPUT_FPS = 25.0
OUTPUT_DURATION_SECONDS = 5.0
GENERATED_FILES_DIR = Path(os.getenv("GENERATED_FILES_DIR", "/app/generated"))
logger = logging.getLogger(__name__)
@@ -152,6 +155,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
from video_processing.oss_helpers import (
download_asset,
get_signed_download_url,
@@ -326,6 +330,61 @@ def _build_plan_and_clips_from_task(
return plan, clips, asset_path_map
def _create_fallback_clip(output_path: Path, title: str) -> None:
"""创建 fallback 视频(无素材时)"""
safe_title = title.replace(":", "\\:").replace("'", "\\'")[:80]
run_ffmpeg(
[
FFMPEG_BIN,
"-y",
"-f",
"lavfi",
"-i",
f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}",
"-vf",
f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(output_path),
]
)
def _mux_audio_track(video_path: Path, audio_path: str, output_path: Path) -> None:
"""将音频轨混入已渲染的视频(后处理步骤)。
使用 FFmpeg 将视频和音频合并,视频时长为准,音频不足则循环,
音频过长则截断。
"""
command = [
FFMPEG_BIN,
"-y",
"-i",
str(video_path),
"-i",
audio_path,
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-movflags",
"+faststart",
str(output_path),
]
run_ffmpeg(command)
def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
"""下载配音文件。
@@ -369,6 +428,105 @@ def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
return download_asset(storage_key, local_path)
def _prepare_bgm_track(
*,
bgm_config: dict,
temp_path: Path,
task_id: str = "",
) -> str | None:
"""准备 BGM 音频文件(下载到本地).
支持 3 种来源(按优先级):
1. audio_url — 外部直链 URL(最高优先级)
2. asset_id — 素材库中的音频素材
3. preset_id — 预设 BGM 库
Returns:
BGM 本地文件路径,准备失败返回 None
"""
from urllib.parse import urlparse
audio_url = bgm_config.get("audio_url", "") or ""
asset_id = bgm_config.get("asset_id", "") or ""
preset_id = bgm_config.get("preset_id", "") or ""
bgm_file = temp_path / f"bgm_{task_id or 'track'}.mp3"
# 优先级1:外部直链 URL
if audio_url:
try:
parsed = urlparse(audio_url)
if parsed.scheme in ("http", "https"):
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
safe_download_file(
audio_url,
str(bgm_file),
purpose="bgm_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[task_id=%s] [BGM] URL下载失败: %s", task_id, e)
# 优先级2:素材库素材
if asset_id:
try:
from app.core.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = SessionLocal()
try:
model = session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model and (model.storage_key or model.file_url):
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
storage_key = model.storage_key or model.file_url
logger.info("[task_id=%s] [BGM] 从素材库下载: asset_id=%s", task_id, asset_id)
ok = download_asset(storage_key, bgm_file)
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
finally:
session.close()
except Exception as e:
logger.warning("[task_id=%s] [BGM] 素材库下载失败: %s", task_id, e)
# 优先级3:预设 BGM 库
if preset_id:
try:
from packages.domain.preset_bgm import get_preset_bgm
preset = get_preset_bgm(preset_id)
if preset and preset.audio_url:
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
safe_download_file(
preset.audio_url,
str(bgm_file),
purpose="bgm_preset_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[task_id=%s] [BGM] 预设库下载失败: %s", task_id, e)
# 所有来源都失败
logger.warning("[task_id=%s] [BGM] 所有来源都无法获取BGM,跳过", task_id)
return None
def _verify_url_accessible(
url: str,
timeout: float = 10.0,
+62 -17
View File
@@ -14,7 +14,11 @@ import pytest
from video_processing.unified_render_service import (
UnifiedRenderService,
)
from worker_app.tasks.generation import _build_plan_and_clips_from_task
from worker_app.tasks.generation import (
_build_plan_and_clips_from_task,
_create_fallback_clip,
_mux_audio_track,
)
pytestmark = pytest.mark.skipif(
not shutil.which("ffmpeg"),
@@ -58,6 +62,61 @@ def _generate_test_audio(path: Path, duration: float = 5.0) -> None:
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
# ── 测试 _create_fallback_clip ────────────────────────────────────────────────
class TestFallbackClip:
"""测试 fallback 视频生成。"""
def test_fallback_clip_creates_video(self):
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "fallback.mp4"
_create_fallback_clip(output, "Test Fallback")
assert output.exists()
assert output.stat().st_size > 0
# ── 测试 _mux_audio_track ────────────────────────────────────────────────────
class TestMuxAudioTrack:
"""测试视频+音频混合。"""
def test_mux_audio_into_video(self):
with tempfile.TemporaryDirectory() as tmpdir:
video_path = Path(tmpdir) / "video.mp4"
audio_path = Path(tmpdir) / "audio.aac"
output_path = Path(tmpdir) / "output.mp4"
_generate_test_video(video_path, duration=3.0)
_generate_test_audio(audio_path, duration=5.0)
_mux_audio_track(video_path, str(audio_path), output_path)
assert output_path.exists()
assert output_path.stat().st_size > 0
# 验证输出文件包含音频轨
probe_cmd = [
"ffprobe",
"-v",
"quiet",
"-show_streams",
"-select_streams",
"a",
"-of",
"csv=p=0",
str(output_path),
]
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
# 如果有音频流,输出非空
assert result.stdout.strip() != "" or result.returncode == 0
# ── 测试 PlanGenerator → UnifiedRenderService 全链路 ─────────────────────────
class TestFullPipeline:
"""验证从虚拟 plan 构建到渲染输出的完整流程。"""
@@ -116,26 +175,12 @@ class TestFullPipeline:
)
render_result = service.render()
# 混音 - 直接用 ffmpeg_mux_audio_track 已被清理)
# 混音
audio_path = work_dir / "voice.aac"
_generate_test_audio(audio_path, duration=5.0)
final_path = work_dir / "final.mp4"
mux_cmd = [
"ffmpeg",
"-y",
"-i",
str(render_result.output_path),
"-i",
str(audio_path),
"-c:v",
"copy",
"-c:a",
"aac",
"-shortest",
str(final_path),
]
subprocess.run(mux_cmd, check=True, capture_output=True, timeout=30)
_mux_audio_track(render_result.output_path, str(audio_path), final_path)
assert final_path.exists()
assert final_path.stat().st_size > 0
+14 -79
View File
@@ -108,30 +108,21 @@ def _run_with_fakes(
session = MagicMock()
session_maker = MagicMock(return_value=session)
captured: dict = {"upload_calls": []}
captured = {"upload_calls": [], "session": session_maker()}
def _tracking_upload(local_path, storage_key):
captured["upload_calls"].append((local_path, storage_key))
return upload_fn(local_path, storage_key)
# Wrap session_maker to capture the session INSIDE the patch context.
# This avoids stale PromiseProxy cache issues in full-suite runs.
_created_sessions: list = []
_orig_sm = session_maker
def _tracking_sm(*a, **kw):
s = _orig_sm(*a, **kw)
_created_sessions.append(s)
return s
bound_task = _make_bound_task()
import worker_app.db as _db_mod
with patch.object(_db_mod, "SessionLocal", _tracking_sm):
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=repo,
):
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=repo,
"worker_app.db.SessionLocal",
session_maker,
):
with patch(
"video_processing.oss_helpers.upload_to_oss",
@@ -143,7 +134,6 @@ def _run_with_fakes(
):
result = _call_task(batch_download_videos, bound_task, [v.id for v in videos], user_id)
captured["session"] = _created_sessions[0] if _created_sessions else None
captured["result"] = result
return captured
@@ -254,66 +244,19 @@ def test_batch_download_single_video():
def test_batch_download_session_closed():
"""DB session is always closed (via finally block).
Patches the function's own globals to inject mock SessionLocal,
bypassing any import caching issues in the full suite.
"""
import sys
import worker_app.db as _db_mod
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
"""DB session is always closed (via finally block)."""
videos = [_FakeVideo("v1", "v.mp4")]
repo = _FakeGeneratedVideoRepository(videos)
session = MagicMock()
mock_session_factory = MagicMock(return_value=session)
session_maker = MagicMock(return_value=session)
def _noop_download(url, dest):
Path(dest).parent.mkdir(parents=True, exist_ok=True)
Path(dest).write_bytes(b"fake video data")
_run_with_fakes(videos, session_maker=session_maker)
bound_task = _make_bound_task()
# Get the raw function to patch its globals
raw_fn = _get_raw_task_fn(batch_download_videos)
# Patch SessionLocal in ALL possible module locations
_db_mod.SessionLocal = mock_session_factory
if "worker_app.db" in sys.modules:
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
# Also patch in the function's own globals if it has a reference there
if "SessionLocal" in raw_fn.__globals__:
raw_fn.__globals__["SessionLocal"] = mock_session_factory
try:
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=repo,
):
with patch("video_processing.oss_helpers.upload_to_oss", return_value="https://oss.example.com/zip"):
with patch(
"apps.worker.worker_app.tasks.batch_download._download_video_to_file",
_noop_download,
):
raw_fn(bound_task, ["v1"], "user_1")
finally:
pass # Don't restore - other tests handle their own patches
# Diagnostic: check if our mock factory was actually called
assert mock_session_factory.called, "SessionLocal mock was never called! " f"raw_fn={raw_fn}, type={type(raw_fn)}"
session.close.assert_called_once()
def test_batch_download_closes_session_on_error():
"""Session is closed even when get_by_ids raises."""
import sys
import worker_app.db as _db_mod
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
class _ExplodingRepo:
@@ -321,25 +264,17 @@ def test_batch_download_closes_session_on_error():
raise RuntimeError("db down")
session = MagicMock()
mock_session_factory = MagicMock(return_value=session)
session_maker = MagicMock(return_value=session)
bound_task = _make_bound_task()
raw_fn = _get_raw_task_fn(batch_download_videos)
_db_mod.SessionLocal = mock_session_factory
if "worker_app.db" in sys.modules:
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
if "SessionLocal" in raw_fn.__globals__:
raw_fn.__globals__["SessionLocal"] = mock_session_factory
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=_ExplodingRepo(),
):
with pytest.raises(RuntimeError, match="db down"):
raw_fn(bound_task, ["v1"], "u")
with patch("worker_app.db.SessionLocal", session_maker):
with pytest.raises(RuntimeError, match="db down"):
_call_task(batch_download_videos, bound_task, ["v1"], "u")
assert mock_session_factory.called, "SessionLocal mock was never called!"
session.close.assert_called_once()
-132
View File
@@ -1,132 +0,0 @@
"""Tests for cover_url backfill to GenerationTask.
Verifies _finalize_render_success correctly writes cover_url
from cover_candidates to gen_task.cover_url.
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add worker app to sys.path
_WORKER_ROOT = Path(__file__).resolve().parents[2] / "apps" / "worker"
if str(_WORKER_ROOT) not in sys.path:
sys.path.insert(0, str(_WORKER_ROOT))
class FakeGenTask:
"""Simple stand-in for GenerationTask that tracks attribute assignment."""
def __init__(self):
object.__setattr__(self, "_assigned", {})
self.id = "task-1"
self.status = MagicMock()
self.status.value = "running"
def __setattr__(self, name, value):
if not name.startswith("_"):
self._assigned[name] = value
object.__setattr__(self, name, value)
def append_log(self, **kwargs):
pass
def _make_plan():
plan = MagicMock()
plan.project_id = "proj-1"
plan.created_by_user_id = "user-1"
plan.config = {"batch_id": "batch-1", "mode": "edit_plan", "title": {"text": "test"}}
plan.mark_completed = MagicMock()
return plan
def _call_finalize(cover_candidates=None, gen_task=None, plan=None):
from worker_app.tasks.edit_plan_generation import _finalize_render_success
plan = plan or _make_plan()
gen_task = gen_task or FakeGenTask()
plan_repo = MagicMock()
clip_repo = MagicMock()
gen_task_repo = MagicMock()
gen_task_repo.get.return_value = gen_task
db = MagicMock()
with patch("worker_app.tasks.edit_plan_generation.create_video_record_and_dedup"):
result = _finalize_render_success(
plan=plan,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
plan_id="plan-1",
output_url="https://oss.example.com/output.mp4",
storage_key="rendered/plan-1/task-1.mp4",
duration=10.0,
file_size=1024,
width=1280,
height=720,
rendered_clip_ids=["clip-1"],
failed_clip_ids=[],
generation_task_id="task-1",
output_path=Path("/tmp/output.mp4"),
engine="unified",
thumbnail_url="",
cover_candidates=cover_candidates,
)
return result, gen_task, gen_task_repo
class TestFinalizeCoverUrl:
def test_cover_url_set_from_image_url(self):
"""cover_candidates with image_url should set gen_task.cover_url"""
candidates = [
{"image_url": "https://oss.example.com/cover1.jpg", "frame_time": 1.5},
{"image_url": "https://oss.example.com/cover2.jpg", "frame_time": 3.0},
]
_, gen_task, gen_task_repo = _call_finalize(cover_candidates=candidates)
assert gen_task.cover_url == "https://oss.example.com/cover1.jpg"
gen_task_repo.update.assert_called()
def test_cover_url_fallback_to_url_key(self):
"""Should fallback to 'url' key when 'image_url' is absent"""
candidates = [{"url": "https://oss.example.com/cover_url_key.jpg"}]
_, gen_task, _ = _call_finalize(cover_candidates=candidates)
assert gen_task.cover_url == "https://oss.example.com/cover_url_key.jpg"
def test_cover_url_not_set_when_empty_list(self):
"""Empty cover_candidates should not set cover_url"""
_, gen_task, _ = _call_finalize(cover_candidates=[])
assert "cover_url" not in gen_task._assigned
def test_cover_url_not_set_when_none(self):
"""None cover_candidates should not set cover_url"""
_, gen_task, _ = _call_finalize(cover_candidates=None)
assert "cover_url" not in gen_task._assigned
def test_cover_url_not_set_when_url_empty(self):
"""Empty URL strings in candidates should not set cover_url"""
candidates = [{"image_url": "", "url": ""}]
_, gen_task, _ = _call_finalize(cover_candidates=candidates)
assert "cover_url" not in gen_task._assigned
def test_no_generation_task_no_crash(self):
"""Should not crash when gen_task is None"""
candidates = [{"image_url": "https://oss.example.com/cover.jpg"}]
gen_task_repo = MagicMock()
gen_task_repo.get.return_value = None
result, _, _ = _call_finalize(cover_candidates=candidates)
assert result["status"] == "completed"
def test_image_url_priority_over_url(self):
"""image_url should take priority over url key"""
candidates = [{"image_url": "https://a.jpg", "url": "https://b.jpg"}]
_, gen_task, _ = _call_finalize(cover_candidates=candidates)
assert gen_task.cover_url == "https://a.jpg"
-369
View File
@@ -1,369 +0,0 @@
"""Tests for /generate endpoint — custom_title and cover_url passing."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
class TestGenerateEndpointTitleAndCover:
"""测试 /generate 端点传递 custom_title 和 cover_url。"""
def test_generate_passes_cover_url_from_plan_config(self):
"""从 plan.config.cover.image_url 读取封面 URL 传递给生成任务。"""
from app.api.routes.templates_editor.generation import generate_editor_draft
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
mock_plan = MagicMock()
mock_plan.id = "plan-123"
mock_plan.project_id = "project-1"
mock_plan.template_id = "template-1"
mock_plan.status = MagicMock(value="editing")
mock_plan.config = {
"clips": [{"id": "c1"}],
"asset_ids": ["a1"],
"cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/cover.jpg"},
}
mock_plan.updated_at = None
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_plan_svc.can_generate.return_value = (True, "")
mock_plan_svc.mark_clips_ready.return_value = 1
mock_template_svc = MagicMock()
mock_gen_task = MagicMock()
mock_gen_task.id = "task-new"
mock_gen_task.project_id = "project-1"
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = EditPlanGenerateRequest() # No title_config
with (
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
patch("app.api.routes.templates_editor.generation.celery_app"),
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-123"),
):
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_usecase = MagicMock()
mock_usecase.execute.return_value = mock_gen_task
mock_usecase_cls.return_value = mock_usecase
mock_plan_svc.transition_status = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
result = generate_editor_draft(
template_id="template-1",
request=body,
plan_id="plan-123",
services=(mock_template_svc, mock_plan_svc),
db=MagicMock(),
current_user=mock_current_user,
asset_library_repo=MagicMock(),
asset_repo=MagicMock(),
)
# Verify cover_url was passed to CreateGenerationTaskCommand
call_args = mock_usecase.execute.call_args
command = call_args[0][0]
assert command.cover_url == "https://oss.example.com/uploaded/cover.jpg"
assert command.custom_title == ""
def test_generate_passes_custom_title_from_title_config(self):
"""前端传 title_config 时,序列化为 JSON 存入 custom_title。"""
from app.api.routes.templates_editor.generation import generate_editor_draft
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
mock_plan = MagicMock()
mock_plan.id = "plan-456"
mock_plan.project_id = "project-1"
mock_plan.template_id = "template-1"
mock_plan.status = MagicMock(value="editing")
mock_plan.config = {
"clips": [{"id": "c1"}],
"asset_ids": ["a1"],
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/cover.jpg"},
}
mock_plan.updated_at = None
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_plan_svc.can_generate.return_value = (True, "")
mock_plan_svc.mark_clips_ready.return_value = 1
mock_template_svc = MagicMock()
mock_gen_task = MagicMock()
mock_gen_task.id = "task-title"
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
title_config = {
"text": "测试标题",
"font_size": 36,
"font_color": "#ffffff",
"position": "center",
}
body = EditPlanGenerateRequest(title_config=title_config)
with (
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
patch("app.api.routes.templates_editor.generation.celery_app"),
):
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_usecase = MagicMock()
mock_usecase.execute.return_value = mock_gen_task
mock_usecase_cls.return_value = mock_usecase
mock_plan_svc.transition_status = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
result = generate_editor_draft(
template_id="template-1",
request=body,
plan_id="plan-456",
services=(mock_template_svc, mock_plan_svc),
db=MagicMock(),
current_user=mock_current_user,
asset_library_repo=MagicMock(),
asset_repo=MagicMock(),
)
# Verify custom_title was serialized to JSON
call_args = mock_usecase.execute.call_args
command = call_args[0][0]
parsed_title = json.loads(command.custom_title)
assert parsed_title["text"] == "测试标题"
assert parsed_title["font_size"] == 36
assert command.cover_url == "https://oss.example.com/cover.jpg"
def test_generate_empty_title_config_passes_empty_custom_title(self):
"""title_config 为空时 custom_title 为空字符串。"""
from app.api.routes.templates_editor.generation import generate_editor_draft
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
mock_plan = MagicMock()
mock_plan.id = "plan-789"
mock_plan.project_id = "project-1"
mock_plan.template_id = "template-1"
mock_plan.status = MagicMock(value="editing")
mock_plan.config = {"clips": [{"id": "c1"}], "asset_ids": ["a1"]}
mock_plan.updated_at = None
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_plan_svc.can_generate.return_value = (True, "")
mock_plan_svc.mark_clips_ready.return_value = 1
mock_template_svc = MagicMock()
mock_gen_task = MagicMock()
mock_gen_task.id = "task-no-title"
body = EditPlanGenerateRequest() # No title_config
with (
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
patch("app.api.routes.templates_editor.generation.celery_app"),
):
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_usecase = MagicMock()
mock_usecase.execute.return_value = mock_gen_task
mock_usecase_cls.return_value = mock_usecase
mock_plan_svc.transition_status = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
result = generate_editor_draft(
template_id="template-1",
request=body,
plan_id="plan-789",
services=(mock_template_svc, mock_plan_svc),
db=MagicMock(),
current_user=MagicMock(),
asset_library_repo=MagicMock(),
asset_repo=MagicMock(),
)
call_args = mock_usecase.execute.call_args
command = call_args[0][0]
assert command.custom_title == ""
class TestGenerateEndpointRequestSchema:
"""测试 EditPlanGenerateRequest schema。"""
def test_schema_default_empty_title_config(self):
"""默认 title_config 为空 dict。"""
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
req = EditPlanGenerateRequest()
assert req.title_config == {}
def test_schema_accepts_title_config(self):
"""可以传入标题配置。"""
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
req = EditPlanGenerateRequest(title_config={"text": "我的标题", "font_size": 48})
assert req.title_config["text"] == "我的标题"
assert req.title_config["font_size"] == 48
class TestGenerateTitleChangeSkipsReuse:
"""测试标题变更时跳过预览产物复用。"""
def _make_mocks(self, custom_title=""):
mock_plan = MagicMock()
mock_plan.id = "plan-reuse"
mock_plan.project_id = "project-1"
mock_plan.template_id = "template-1"
mock_plan.status = MagicMock(value="editing")
mock_plan.config = {"clips": [{"id": "c1"}], "asset_ids": ["a1"]}
mock_plan.updated_at = None
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_plan_svc.can_generate.return_value = (True, "")
mock_plan_svc.mark_clips_ready.return_value = 1
mock_template_svc = MagicMock()
reusable_task = MagicMock()
reusable_task.id = "task-reusable"
reusable_task.is_completed = True
reusable_task.is_preview = True
reusable_task.custom_title = custom_title
reusable_task.project_id = "project-1"
reusable_task.source_edit_plan_id = "plan-reuse"
mock_new_task = MagicMock()
mock_new_task.id = "task-new"
return mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task
def test_title_removed_skips_reuse(self):
"""原来有标题,现在移除了 → 跳过复用,创建新任务。"""
from app.api.routes.templates_editor.generation import generate_editor_draft
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task = self._make_mocks(
custom_title='{"text": "旧标题"}'
)
body = EditPlanGenerateRequest() # No title_config → title removed
with (
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=reusable_task),
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
patch("app.api.routes.templates_editor.generation.celery_app"),
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-reuse"),
):
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_usecase = MagicMock()
mock_usecase.execute.return_value = mock_new_task
mock_usecase_cls.return_value = mock_usecase
mock_plan_svc.transition_status = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
result = generate_editor_draft(
template_id="template-1",
request=body,
plan_id="plan-reuse",
services=(mock_template_svc, mock_plan_svc),
db=MagicMock(),
current_user=MagicMock(),
asset_library_repo=MagicMock(),
asset_repo=MagicMock(),
)
# 应该创建新任务而不是复用
mock_usecase.execute.assert_called_once()
# 不应该 mark_confirmed 在 reusable_task 上
reusable_task.mark_confirmed.assert_not_called()
def test_title_changed_skips_reuse(self):
"""标题变更 → 跳过复用。"""
import json
from app.api.routes.templates_editor.generation import generate_editor_draft
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task = self._make_mocks(
custom_title=json.dumps({"text": "旧标题", "font_size": 36}, ensure_ascii=False)
)
body = EditPlanGenerateRequest(title_config={"text": "新标题", "font_size": 48})
with (
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=reusable_task),
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
patch("app.api.routes.templates_editor.generation.celery_app"),
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-reuse"),
):
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_usecase = MagicMock()
mock_usecase.execute.return_value = mock_new_task
mock_usecase_cls.return_value = mock_usecase
mock_plan_svc.transition_status = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
result = generate_editor_draft(
template_id="template-1",
request=body,
plan_id="plan-reuse",
services=(mock_template_svc, mock_plan_svc),
db=MagicMock(),
current_user=MagicMock(),
asset_library_repo=MagicMock(),
asset_repo=MagicMock(),
)
mock_usecase.execute.assert_called_once()
reusable_task.mark_confirmed.assert_not_called()
-135
View File
@@ -552,138 +552,3 @@ class TestStrayLoggerRemoved:
assert (
"logger.info(\n plan_id," not in source
), "Stray logger.info(plan_id, generation_task_id) should be removed"
class TestUploadCoverType:
"""测试 cover_type=upload 封面上传功能。"""
def test_upload_cover_saves_url_directly(self):
"""cover_type=upload 时直接保存 cover_url,不需要预览视频。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-upload"
body = GenerateCoverRequest(
cover_type="upload",
cover_url="https://oss.example.com/uploaded/cover.jpg",
)
with patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize:
mock_normalize.return_value = {
"cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/cover.jpg"}
}
from app.api.routes.generation_cover import generate_cover
result = generate_cover(
body=body,
template_id="template-upload",
plan_id="plan-upload",
services=(mock_template_svc, mock_plan_svc),
db=MagicMock(),
current_user=mock_current_user,
)
assert result.plan_id == "plan-upload"
assert result.cover["type"] == "upload"
assert result.cover["image_url"] == "https://oss.example.com/uploaded/cover.jpg"
# 验证 plan config 被更新
mock_plan_svc.update_plan_config.assert_called_once()
call_args = mock_plan_svc.update_plan_config.call_args
assert call_args[0][0] == "plan-upload"
assert call_args[0][1]["cover"]["type"] == "upload"
def test_upload_cover_without_url_returns_400(self):
"""cover_type=upload 但未提供 cover_url 时返回 400。"""
from unittest.mock import MagicMock
from app.api.routes.generation_cover import GenerateCoverRequest
from fastapi import HTTPException
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
body = GenerateCoverRequest(cover_type="upload")
# cover_url is None by default
import pytest
from app.api.routes.generation_cover import generate_cover
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="template-upload",
plan_id="plan-upload",
services=(mock_template_svc, mock_plan_svc),
db=MagicMock(),
current_user=MagicMock(),
)
assert exc_info.value.status_code == 400
assert "cover_url" in exc_info.value.detail
def test_upload_cover_schema_has_cover_url_field(self):
"""GenerateCoverRequest schema 包含 cover_url 字段。"""
from app.api.routes.generation_cover import GenerateCoverRequest
req = GenerateCoverRequest(cover_type="upload", cover_url="https://example.com/img.jpg")
assert req.cover_url == "https://example.com/img.jpg"
assert req.cover_type == "upload"
# 默认值为 None
req2 = GenerateCoverRequest()
assert req2.cover_url is None
def test_upload_cover_does_not_require_preview_video(self):
"""cover_type=upload 时不查找预览视频,即使 plan.config 为空也不报错。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {} # 没有 rendered_storage_key
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
body = GenerateCoverRequest(
cover_type="upload",
cover_url="https://oss.example.com/uploaded/my-cover.png",
)
with patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize:
mock_normalize.return_value = {
"cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/my-cover.png"}
}
from app.api.routes.generation_cover import generate_cover
# 不应该抛出 "请先生成预览视频" 的异常
result = generate_cover(
body=body,
template_id="template-1",
plan_id="plan-no-preview",
services=(mock_template_svc, mock_plan_svc),
db=MagicMock(),
current_user=MagicMock(),
)
assert result.cover["image_url"] == "https://oss.example.com/uploaded/my-cover.png"
# 验证没有调用任何预览视频查找逻辑
# (normalize_plan_config 是唯一被调用的外部函数)
+1 -5
View File
@@ -61,9 +61,6 @@ def _make_mock_clip(clip_id="clip-001", order=0, duration=10.0, clip_type="video
clip.config = {}
clip.asset_id = "asset-001"
clip.status = "ready"
clip.template_clip_config_id = ""
clip.created_at = None
clip.updated_at = None
return clip
@@ -330,8 +327,7 @@ class TestClipSplitMerge:
resp = c.post(BASE + "/clips/merge", json={"clip_ids": ["clip-001", "clip-002"]})
assert resp.status_code == 200
data = resp.json()
assert "merged_clip" in data
assert "id" in data["merged_clip"]
assert "id" in data
mock_plan_svc.merge_clips.assert_called_once_with(["clip-001", "clip-002"])
def test_merge_clips_single_422(self, client):