Compare commits

..

5 Commits

Author SHA1 Message Date
CI Bot 6f222db4c7 fix(editor): 移除asset_id min_length限制+order默认None (#1468)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m35s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m51s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m15s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 39s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 57s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 575h25m23s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 575h25m27s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 575h25m27s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 575h25m25s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 575h30m34s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 575h30m36s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 575h31m17s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 575h31m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 575h31m20s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 575h31m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 575h31m22s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 575h59m14s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 575h59m16s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 576h4m26s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 576h5m8s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 576h5m10s
AI Code Review 第三轮修复:
1. EditorClipBatchItem.asset_id 移除 min_length=1,允许空字符串(占位片段场景)
2. EditorClipBatchItem.order 默认值改为 None(非0),仅在显式传入时加入 dict
3. 服务层 order 逻辑:dict 无 order key 时回退到索引 i
4. 测试更新:9 passed
2026-08-23 15:54:34 +08:00
CI Bot ee561b0d64 fix(editor): B017 lint修复 pytest.raises(Exception)→ValidationError
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m20s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m43s
AI Code Review / AI Code Review (pull_request) Failing after 2m9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m30s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m17s
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 575h41m39s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 575h41m40s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 575h41m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 575h41m41s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 575h45m20s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 575h46m27s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 575h46m53s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 575h46m55s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 575h46m55s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 575h46m59s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 575h46m59s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 575h47m0s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 576h15m28s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 576h15m30s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 576h20m16s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 576h20m46s
2026-08-23 15:39:08 +08:00
CI Bot 0386a1f08f fix(editor): 批量更新clips加事务保护+asset_id校验 (#1468)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m53s
AI Code Review / AI Code Review (pull_request) Failing after 2m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m33s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m46s
CI/CD Pipeline / CI Gate (pull_request) Failing after 8s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 575h49m48s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 575h49m50s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 575h49m51s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 575h49m51s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 575h53m49s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 575h49m52s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 575h53m51s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 575h53m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 575h53m53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 575h53m23s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 575h53m55s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 575h53m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 575h53m56s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 576h23m39s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 576h27m12s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 576h27m40s
AI Code Review 修复:
1. batch_update_clips 改用 replace_all_clips_transactional:
   清空→创建→标记ready 在同一事务内完成,失败自动 rollback
2. EditorClipBatchItem.asset_id 加 min_length=1 校验,
   禁止空字符串避免脏数据

新增 8 个测试(5 endpoint + 3 service),全量相关 92 passed。
2026-08-23 15:32:12 +08:00
CI Bot dd7513a512 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Failing after 1m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m1s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m25s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m44s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m2s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 576h3m48s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 576h3m48s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 576h3m49s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 576h3m50s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 576h3m50s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 576h3m50s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 576h8m3s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 576h7m23s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 576h8m4s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 576h8m45s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 576h8m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 576h8m47s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 576h8m51s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 576h8m53s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 576h42m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 576h42m38s
2026-08-23 07:17:14 +00:00
CI Bot 0cb67afd61 refactor(worker): generate_video直接读取edit_plan_clips渲染,删除内存重建逻辑
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m54s
AI Code Review / AI Code Review (pull_request) Failing after 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 576h10m8s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 576h11m13s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 576h11m48s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 576h11m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 576h11m52s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 576h11m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 576h11m55s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 576h45m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 576h45m38s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 576h45m42s
核心改造:
- 有source_edit_plan_id时,Worker直接调用RenderAdapter.render_plan(plan_id)
  从数据库加载edit_plan+clips渲染,不再_download_all_assets+_build_plan_and_clips
- 新增_sync_task_config_to_plan:将title_config/bgm_config/分辨率同步到plan.config,
  配音下载后通过voiceover_audio_path传给RenderAdapter
- 无source_edit_plan_id时保留旧路径(标记DEPRECATED)
- RenderAdapter.render_plan新增voiceover_audio_path参数透传给_do_render

新增API:
- PUT /templates/{id}/editor/clips 批量替换clips(delete_all+create+mark_ready)
- EditorClipBatchItem/EditorClipBatchUpdateRequest/EditorClipBatchUpdateResponse schema

删除死代码:
- worker_app/tasks/edit_plan_generation.py(worker.render_edit_plan,452行)
- worker_app/tasks/compose_video.py(worker.compose_video,198行)
- celery_app.py imports清理、tasks/__init__.py清理
- test_edit_plan_worker_failure.py、test_cover_url_finalize.py(测试已删除模块)
- templates_editor/generation.py的send_task改为worker.generate_video

新增3个单测,全量13790 passed
2026-08-23 15:14:01 +08:00
111 changed files with 6773 additions and 25448 deletions
+15 -32
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
import json
import logging
from app.auth import AuthenticatedUser, get_current_user
@@ -264,38 +265,22 @@ def create_preview_generation_task(
if not video_ratio and request.template_id:
video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id)
# 根据 video_ratio 计算输出分辨率(默认竖屏 1080x1920)
output_width, output_height = 1080, 1920
if video_ratio:
parts = video_ratio.split(":")
if len(parts) == 2:
try:
w, h = int(parts[0]), int(parts[1])
base = 1920
if w < h:
# 竖屏
output_width = round(base * w / h)
output_height = base
else:
# 横屏
output_width = base
output_height = round(base * h / w)
# 对齐到偶数
output_width = output_width - output_width % 2
output_height = output_height - output_height % 2
except (ValueError, ZeroDivisionError):
output_width, output_height = 1080, 1920
resolution = f"{output_width}x{output_height}"
logger.info(
"[预览生成] 分辨率: video_ratio=%s → %s (%dx%d)",
video_ratio, resolution, output_width, output_height,
)
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
# 处理标题配置:如果有标题文本,序列化到 custom_title 字段传递给 worker
title_config = request.title_config or {}
title_text = (title_config.get("text") or "").strip()
custom_title_value = ""
if title_text:
# 将标题文本和样式配置序列化为 JSON 存入 custom_title
# Worker 端会解析 JSON 获取完整标题配置
custom_title_value = json.dumps(title_config, ensure_ascii=False)
logger.info(
"[预览生成] 标题配置: text=%s, config_keys=%s",
title_text[:30],
list(title_config.keys()),
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
@@ -315,14 +300,12 @@ def create_preview_generation_task(
asset_select_mode="",
batch_id="",
video_title=request.video_title,
resolution=resolution,
resolution="",
bgm_config=request.bgm_config or {},
auto_retry_enabled=False,
auto_retry_max=0,
is_preview=True,
title_config=title_config,
output_width=output_width,
output_height=output_height,
custom_title=custom_title_value,
)
)
except ValueError as e:
+14 -144
View File
@@ -70,6 +70,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
output_width=getattr(task, "output_width", 1280),
output_height=getattr(task, "output_height", 720),
cover_url=getattr(task, "cover_url", ""),
custom_title=getattr(task, "custom_title", ""),
title_config=getattr(task, "title_config", {}) or {},
logs=getattr(task, "logs", "[]"),
status=task.status,
@@ -293,89 +294,6 @@ def create_generation_task(
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
)
# ── 兜底复用预览产物 ──
# 前端刷新后 previewTaskId 丢失,降级调 create 接口时,
# 如果同一 edit_plan 有已完成的预览任务,直接复用(秒出)。
if request.source_edit_plan_id and not request.is_preview:
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_preview_model = (
db.query(GenerationTaskModel)
.filter(
GenerationTaskModel.source_edit_plan_id == request.source_edit_plan_id,
GenerationTaskModel.is_preview.is_(True),
GenerationTaskModel.status == "completed",
GenerationTaskModel.created_by_user_id == authenticated_user.user.id,
)
.order_by(GenerationTaskModel.created_at.desc())
.first()
)
if _preview_model is not None:
# 校验分辨率一致性(与 confirm 端点逻辑相同)
req_w = request.output_width or 0
req_h = request.output_height or 0
src_w = getattr(_preview_model, "output_width", 0) or 0
src_h = getattr(_preview_model, "output_height", 0) or 0
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
_to_domain,
)
preview_task = _to_domain(_preview_model)
# 如果传了标题,更新 title_config
fallback_title_config = None
if request.title_config and request.title_config.get("text", "").strip():
fallback_title_config = dict(preview_task.title_config or {})
fallback_title_config.update(request.title_config)
preview_task.mark_confirmed(
cover_url=request.cover_url or preview_task.cover_url,
output_width=request.output_width or preview_task.output_width,
output_height=request.output_height or preview_task.output_height,
title_config=fallback_title_config,
)
generation_task_repository.update(preview_task)
# 同步标题到 EditPlan.config
if fallback_title_config:
_writeback_edit_plan_config(
plan_id=request.source_edit_plan_id,
task_id=preview_task.id,
title_config=fallback_title_config,
db=db,
)
logger.info(
"[生成任务] 兜底复用预览产物: preview_task_id=%s, plan_id=%s",
preview_task.id,
request.source_edit_plan_id,
)
return BatchGenerationTaskResponse(
items=[_to_generation_task_response(preview_task)],
total=1,
)
else:
logger.info(
"[生成任务] 兜底复用跳过(分辨率不一致): plan_id=%s, src=%sx%s, req=%sx%s",
request.source_edit_plan_id,
src_w,
src_h,
req_w,
req_h,
)
except Exception:
logger.warning(
"[生成任务] 兜底复用预览产物异常(不影响主流程): plan_id=%s",
request.source_edit_plan_id,
exc_info=True,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
created_tasks = []
@@ -437,53 +355,11 @@ def create_generation_task(
output_width=request.output_width,
output_height=request.output_height,
cover_url=request.cover_url,
custom_title=request.custom_title,
title_config=request.title_config or {},
)
)
try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_plan_model = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _plan_model:
task.source_edit_plan_id = _plan_model.id
generation_task_repository.update(task)
logger.info(
"[生成任务] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_plan_model.id,
)
except Exception:
logger.warning(
"[生成任务] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 回写 plan.config:必须在 enqueue 之前执行,
# 确保 worker 读取 plan 时 config 中已包含 generation_task_id。
# 只在首个任务时回写一次,避免批量生成时循环覆盖。
_effective_plan_id = task.source_edit_plan_id
if _effective_plan_id and len(created_tasks) == 0:
_writeback_edit_plan_config(
plan_id=_effective_plan_id,
task_id=task.id,
title_config=request.title_config,
db=db,
)
if safe_enqueue_generation_task(
task,
generation_task_repository,
@@ -492,6 +368,15 @@ def create_generation_task(
log_task_status=True,
):
created_tasks.append(task)
# 只在首个成功任务时回写一次 plan.config,
# 避免批量生成时循环覆盖 generation_task_id
if request.source_edit_plan_id and len(created_tasks) == 1:
_writeback_edit_plan_config(
plan_id=request.source_edit_plan_id,
task_id=task.id,
title_config=request.title_config,
db=db,
)
else:
failed_tasks.append(task)
except UserPendingLimitExceeded as _e:
@@ -528,7 +413,6 @@ def confirm_generation(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
project_repository: Any = Depends(get_project_repository),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse:
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
@@ -557,29 +441,13 @@ def confirm_generation(
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
# 如果用户传了 custom_title,同步更新 title_config
confirmed_title_config = None
if request.custom_title and request.custom_title.strip():
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
confirmed_title_config["text"] = request.custom_title.strip()
source_task.mark_confirmed(
cover_url=request.cover_url,
custom_title=request.custom_title,
output_width=request.output_width,
output_height=request.output_height,
title_config=confirmed_title_config,
)
generation_task_repository.update(source_task)
# 同步标题到 EditPlan.config
if confirmed_title_config and source_task.source_edit_plan_id:
_writeback_edit_plan_config(
plan_id=source_task.source_edit_plan_id,
task_id=source_task.id,
title_config=confirmed_title_config,
db=db,
)
logger.info(
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
task_id,
@@ -621,6 +489,7 @@ def confirm_generation(
output_width=request.output_width,
output_height=request.output_height,
cover_url=request.cover_url,
custom_title=request.custom_title,
)
)
@@ -753,6 +622,7 @@ def retry_generation_task(
output_width=getattr(task, "output_width", 1280),
output_height=getattr(task, "output_height", 720),
cover_url=getattr(task, "cover_url", ""),
custom_title=getattr(task, "custom_title", ""),
)
)
try:
@@ -1,9 +1,10 @@
"""模板编辑器 API 路由包.
模块拆分:
将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
- schemas.py: 所有 Pydantic model
- dependencies.py: 依赖注入
- _utils.py: 工具函数
- _fallback.py: 自动兜底逻辑
- draft.py: 草稿管理(详情/更新/发布/版本/回滚)
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
@@ -12,6 +13,7 @@
- export.py: 导出配置
- subtitles.py: 字幕管理
- ai_features.py: AI 推荐
- generation.py: 生成(触发/进度/记录)
- timeline.py: 时间线
挂载路径: /api/v1/templates/{template_id}/editor/
@@ -32,6 +34,7 @@ from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
from .draft import router as draft_router
from .effects import router as effects_router
from .export import router as export_router
from .generation import router as generation_router
from .subtitles import router as subtitles_router
from .timeline import router as timeline_router
@@ -48,6 +51,7 @@ _sub_routers = [
export_router,
subtitles_router,
ai_features_router,
generation_router,
timeline_router,
]
+227
View File
@@ -0,0 +1,227 @@
"""模板编辑器自动兜底逻辑.
generate_editor_draft 触发生成前的自动修复流程:
1. draft → editing 状态迁移
2. 无片段时从模板复制片段配置
3. 为无素材片段分配指定素材
4. 项目有素材库时自动选素材
"""
from __future__ import annotations
import logging
import random
from typing import Any
from app.services.edit_plan_service import EditPlanService
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info(
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
plan_id,
len(configs),
)
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info(
"模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段",
plan_id,
len(segments),
)
def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
logger.info(
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r",
plan_id,
len(all_clips),
len(clips_without_asset),
config_asset_ids[:5] if config_asset_ids else [],
)
if clips_without_asset and config_asset_ids:
logger.info(
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
assigned = 0
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
try:
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
assigned += 1
except Exception as exc:
logger.error(
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
plan_id,
clip.id,
config_asset_ids[asset_idx],
exc,
)
logger.info(
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
plan_id,
assigned,
len(clips_without_asset),
)
# 重新检查剩余无素材片段
all_clips_after = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
if clips_without_asset:
logger.warning(
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
plan_id,
len(clips_without_asset),
)
elif not clips_without_asset:
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
elif not config_asset_ids:
logger.info(
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
plan_id,
)
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
user_id: str = "",
) -> None:
"""自动兜底 4: 自动选素材分配给无素材片段
查找策略(按优先级):
1. plan 有 project_id → 从项目素材库查找
2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找
"""
if not clips_without_asset:
return
ready_videos: list = []
source_desc = ""
# 策略 1: 通过 project_id 查找项目素材库
if plan_check.project_id:
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
source_desc = f"素材库 {video_lib.name}"
# 策略 2: 通过 user_id 查找用户上传的素材
if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"):
logger.info(
"模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材",
plan_id,
user_id,
)
ready_videos = asset_repo.find_ready_videos_by_user(user_id)
source_desc = f"用户上传 (user_id={user_id[:8]}...)"
if not ready_videos:
logger.warning(
"模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)",
plan_id,
plan_check.project_id or "(empty)",
user_id[:8] + "..." if user_id else "(empty)",
)
return
logger.info(
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)",
plan_id,
len(clips_without_asset),
source_desc,
len(ready_videos),
)
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"模板编辑器自动兜底4: plan=%s 从 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
source_desc,
len(ready_videos),
len(clips_without_asset),
)
@@ -25,7 +25,6 @@ from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain.plan_generator_utils import _calc_random_start_time
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
@@ -46,9 +45,6 @@ from .schemas import (
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
# 编辑器默认片段时长(秒)
_DEFAULT_EDITOR_CLIP_DURATION = 5.0
def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse:
"""统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐"""
@@ -159,7 +155,10 @@ def list_draft_clips(
url_map = _build_asset_url_map(asset_ids, asset_repo)
return EditorClipListResponse(
items=[_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or "")) for c in clips],
items=[
_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or ""))
for c in clips
],
total=total,
)
@@ -273,7 +272,9 @@ def split_draft_clip(
try:
result = plan_svc.split_clip(clip_id, body.split_time)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
left = result["left_clip"]
right = result["right_clip"]
asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""]
@@ -303,7 +304,9 @@ def merge_draft_clips(
try:
merged = plan_svc.merge_clips(body.clip_ids)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
asset_id = getattr(merged, "asset_id", "") or ""
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
return {
@@ -357,103 +360,32 @@ def create_clips_from_assets_editor(
body: ClipsFromAssetsRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(支持同一素材切多个片段 + 随机起始时间 + 去重).
逻辑:
1. 模板要求 N 个片段,必须创建 N 个(不管素材有几个)
2. 素材数量 < 片段数量时,同一素材轮询切多个片段
3. 每个片段从素材中随机选取不重复时间段
4. 素材时长不足 5s 时缩短 clip duration
5. 新片段追加到时间线末尾(order 在现有最大值基础上递增)
"""
"""从素材批量创建片段"""
_, plan_svc = services
required_count = body.required_clips_count if body.required_clips_count is not None else len(body.asset_ids)
# 去重后批量获取素材实际时长,避免重复查询
unique_asset_ids = list(dict.fromkeys(body.asset_ids))
asset_durations: dict[str, float] = {}
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算追加起始 order:当前 plan 已有片段的最大 order + 1
# list_clips 返回 List[EditPlanClip]
existing_clips_list = plan_svc.list_clips(plan_id)
next_order = max((c.order for c in existing_clips_list), default=-1) + 1
# 从已有片段中构建已使用时间段,避免跨任务重复使用同一段素材区域
used_segments: dict[str, list[tuple[float, float]]] = {}
for _clip in existing_clips_list:
if _clip.asset_id and _clip.start_time is not None and _clip.duration is not None:
used_segments.setdefault(_clip.asset_id, []).append(
(float(_clip.start_time), float(_clip.start_time) + float(_clip.duration))
)
clips = []
for i in range(required_count):
# 轮询分配素材:素材不够时同一素材切多个片段
asset_id = body.asset_ids[i % len(body.asset_ids)]
# 素材时长不足时缩短 clip duration
asset_total = asset_durations.get(asset_id, 0.0)
if asset_total > 0:
clip_duration = min(_DEFAULT_EDITOR_CLIP_DURATION, asset_total)
else:
clip_duration = _DEFAULT_EDITOR_CLIP_DURATION
# 计算随机 start_time,避开已使用的时间段
start_time = _calc_random_start_time(asset_id, clip_duration, asset_durations, used_segments)
if start_time is None:
# 素材时长信息缺失,无法计算随机起始时间
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"素材 {asset_id} 时长信息缺失,无法创建片段",
)
# 记录已使用时间段(用于去重)
used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration))
for i, asset_id in enumerate(body.asset_ids):
try:
clip = plan_svc.create_clip(
plan_id,
clip_type=body.clip_type or "main",
order=next_order + i,
duration=clip_duration,
start_time=start_time,
clip_type="main",
order=body.start_order + i if hasattr(body, "start_order") else i,
duration=5.0,
asset_id=asset_id,
)
clips.append(clip)
except ValueError as exc:
logger.error(
"创建片段失败: plan_id=%s asset_id=%s order=%d error=%s",
plan_id,
asset_id,
next_order + i,
exc,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"创建片段失败: {exc}",
) from exc
except ValueError:
pass
logger.info(
"模板编辑器从素材创建片段: template_id=%s plan_id=%s required=%d actual=%d by user=%s",
"模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s",
template_id,
plan_id,
required_count,
len(clips),
current_user.user.id,
)
# 新创建的片段已分配素材,立即标记为 ready,否则渲染管线找不到就绪片段
plan_svc.mark_clips_ready(plan_id)
return ClipsFromAssetsResponse(
created_count=len(clips),
plan_id=plan_id,
@@ -3,6 +3,7 @@
核心依赖:
- get_editor_services: 获取模板+计划服务
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
- _check_queue_limits: 生成队列限流检查
"""
from __future__ import annotations
@@ -10,6 +11,7 @@ from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
@@ -111,3 +113,29 @@ def get_draft_plan_id(
user_id,
)
return plan.id
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = (
hasattr(gen_task_repo, "count_pending_by_user")
and hasattr(gen_task_repo, "count_pending_total")
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e)
+385
View File
@@ -0,0 +1,385 @@
"""草稿生成路由.
端点:
- POST /generate 触发生成
- GET /generation-status 生成进度
- GET /generations 生成记录列表
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_generated_video_repository,
)
from app.schemas.generation_task import GenerationTaskResponse
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
from ._fallback import (
_auto_fallback_assign_assets,
_auto_fallback_auto_material_mode,
_auto_fallback_copy_template_clips,
_auto_fallback_draft_to_editing,
)
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
from .schemas import (
ClipStatusItem,
EditPlanGenerateRequest,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
@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),
current_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repo: Any = Depends(get_asset_library_repository),
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)
# 自动兜底流程
_auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(
plan_svc,
plan_id,
plan_check,
clips_without_asset,
asset_library_repo,
asset_repo,
user_id=str(current_user.user.id),
)
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
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()
gen_task_repo.update(reusable_task)
# 将产物 URL 写入 plan config
rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db)
plan_svc.update_plan_config(
plan_id,
{
"generation_task_id": reusable_task.id,
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
},
)
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
updated_plan = plan_svc.get_plan_or_raise(plan_id)
logger.info(
"模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s",
template_id,
plan_id,
reusable_task.id,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=reusable_task.id,
clip_count=len((plan_check.config or {}).get("clips", [])),
)
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
try:
can_gen, reason = plan_svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
try:
clip_count = plan_svc.mark_clips_ready(plan_id)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
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 "",
template_id=plan.template_id,
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,
),
)
plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.generate_video", args=[gen_task.id])
updated_plan = plan_svc.get_plan_or_raise(plan_id)
logger.info(
"模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s",
template_id,
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception as _e:
logger.exception(
"模板编辑器触发生成失败: template_id=%s plan_id=%s",
template_id,
plan_id,
)
try:
plan_svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
) from _e
def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None":
"""查找该 plan 关联的已完成预览任务,判断是否可复用。
复用条件:
1. 存在 source_edit_plan_id == plan_id 的已完成预览任务
2. plan 在预览完成后未被修改(updated_at <= 预览完成时间)
Returns:
可复用的 GenerationTask,或 None
"""
try:
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
except Exception:
return None
for task in tasks:
if not getattr(task, "is_preview", False):
continue
if not task.is_completed:
continue
# 检查 plan 是否在预览完成后被修改
completed_at = getattr(task, "completed_at", None)
if completed_at and hasattr(plan, "updated_at"):
plan_updated = plan.updated_at
# 如果 plan.updated_at 为空,无法判断是否修改过,跳过
if plan_updated is None:
continue
# 如果 plan 在预览完成后又被修改了,不能复用
if plan_updated > completed_at:
continue
return task
return None
def _get_task_output_url(task, gen_task_repo, db) -> str:
"""获取任务的输出视频 URL。"""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task.id)
if videos:
url = getattr(videos[0], "file_url", "") or ""
# 规范化:合并路径中的双斜杠(保留协议头 ://)
if url:
import re as _re
url = _re.sub(r"(?<!:)//", "/", url)
return url
except Exception:
pass
return ""
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
def get_editor_generation_status(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
storage_service: OSSStorageService = Depends(get_storage_service),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanGenerationStatusResponse:
"""查询草稿生成进度"""
_, plan_svc = services
try:
gen_status = plan_svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
plan = gen_status["plan"]
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
video_url = ""
if raw_video_url:
if raw_video_url.startswith("http"):
video_url = raw_video_url # 已经是完整 URL
else:
try:
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
except Exception as e:
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
video_url = raw_video_url
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan_status_val,
generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
@router.get("/generations", response_model=EditPlanGenerationsResponse)
def list_editor_generations(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanGenerationsResponse:
"""查询草稿关联的生成记录列表"""
_, plan_svc = services
plan_svc.get_plan_or_raise(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
@@ -6,8 +6,9 @@
from __future__ import annotations
import re as _re
from typing import Any, List, Optional
from typing import Any, Dict, List, Optional
from app.schemas.generation_task import GenerationTaskResponse
from pydantic import BaseModel, Field, validator
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
@@ -15,6 +16,59 @@ _EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best
_EXPORT_VALID_FORMATS = {"mp4", "mov"}
# ── 生成状态相关 ────────────────────────────────────────────────────────────
class ClipStatusItem(BaseModel):
"""片段生成状态"""
clip_id: str
clip_type: str
order: int
status: str
asset_id: str
text_content: str
duration: float
class EditPlanGenerationStatusResponse(BaseModel):
"""剪辑计划生成进度响应体"""
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
generation_task_status: Optional[str] = None
progress: float = 0.0
video_url: str = ""
error_message: str = ""
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):
"""剪辑计划触发生成响应体"""
plan_id: str
plan_status: str
generation_task_id: str
clip_count: int
class EditPlanGenerationsResponse(BaseModel):
"""剪辑计划关联的生成记录列表响应体"""
items: List[GenerationTaskResponse]
total: int
# ── AI 推荐 ────────────────────────────────────────────────────────────────
@@ -167,7 +221,6 @@ class ClipsFromAssetsRequest(BaseModel):
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
clip_type: str = Field(default="main", description="片段类型,默认 main")
required_clips_count: Optional[int] = Field(default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量")
class ClipsFromAssetsResponse(BaseModel):
+1 -56
View File
@@ -20,8 +20,6 @@ from app.schemas.tts import (
SaveToLibraryRequest,
SaveToLibraryResponse,
TTSJobResponse,
TTSPreviewRequest,
TTSPreviewResponse,
TTSStatusResponse,
TTSSynthesizeRequest,
TTSSynthesizeResponse,
@@ -33,7 +31,7 @@ from packages.adapters.sqlalchemy_impl.tts_job_repository import (
SQLAlchemyTTSJobRepository,
)
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.streaming_service import TTSStreamingService
from packages.application.tts_job.use_cases import (
CreateTTSJobUseCase,
@@ -375,59 +373,6 @@ def save_tts_job_to_library(
)
@router.post("/preview", response_model=TTSPreviewResponse)
def preview_tts(
request: TTSPreviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> TTSPreviewResponse:
"""TTS 预览(试听)——同步合成,立即返回音频 URL。
用于前端预览配音效果,限制文本长度 200 字以内。
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
"""
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
actual_voice_id = request.voice_id
profile = voice_clone_repo.get(request.voice_id)
if profile is not None:
# 命中克隆音色 profile — 校验归属权限
if profile.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问该音色",
)
if not profile.voice_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="音色克隆尚未完成,请稍后再试",
)
actual_voice_id = profile.voice_id
try:
result = cosyvoice_service.synthesize_speech(
text=request.text,
voice_id=actual_voice_id,
speed=request.speed,
)
except CosyVoiceError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"TTS 合成失败: {e}",
) from e
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
return TTSPreviewResponse(
audio_url=result.audio_url,
duration=result.duration if result.duration and result.duration > 0 else None,
)
@router.websocket("/ws/tts/stream")
async def tts_websocket_stream(
websocket: WebSocket,
+4 -2
View File
@@ -10,7 +10,7 @@ class ConfirmGenerationRequest(BaseModel):
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
cover_url: str = Field(default="", description="自定义封面图片 URL")
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
custom_title: str = Field(default="", description="自定义视频标题")
class CreateGenerationTaskRequest(BaseModel):
@@ -33,7 +33,7 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── 标题配置(结构化)──
# ── 标题配置(结构化,优先于 custom_title 纯文本)──
title_config: dict | None = Field(
default=None,
description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。",
@@ -77,6 +77,7 @@ class CreateGenerationTaskRequest(BaseModel):
output_width: int = Field(default=1280, description="输出视频宽度")
output_height: int = Field(default=720, description="输出视频高度")
cover_url: str = Field(default="", description="封面图片 URL")
custom_title: str = Field(default="", description="自定义视频标题")
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -112,6 +113,7 @@ class GenerationTaskResponse(BaseModel):
output_width: int = 1280
output_height: int = 720
cover_url: str = ""
custom_title: str = ""
title_config: dict = Field(default_factory=dict)
status: str
progress: float
-16
View File
@@ -101,19 +101,3 @@ class SaveToLibraryResponse(BaseModel):
voice_id: str
voice_name: str
status: str
class TTSPreviewRequest(BaseModel):
"""TTS 预览(试听)请求。"""
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
voice_id: str = Field(..., min_length=1, description="音色 ID")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
class TTSPreviewResponse(BaseModel):
"""TTS 预览(试听)响应。"""
audio_url: str = Field(..., description="合成音频 URL")
duration: Optional[float] = Field(default=None, description="音频时长(秒)")
@@ -423,9 +423,6 @@ class EditPlanService:
)
db.add(model)
# flush 让新建 clip 写入当前事务(未 commit),后续查询才能找到它们
db.flush()
# 3. 标记有 asset_id 的 clips 为 ready(不 commit)
pending_with_asset = (
db.query(EditPlanClipModel)
@@ -118,9 +118,9 @@ class PlanGeneratorService:
# 4. 按 editing_mode 分配素材
if asset_ids:
# 获取素材时长信息,用于随机起始时间
# 如果是随机预览模式,获取素材时长信息
asset_durations = None
if self._asset_repo:
if random_preview and self._asset_repo:
asset_durations = self._fetch_asset_durations(asset_ids)
self._distribute_assets(
clips,
-6
View File
@@ -13,10 +13,6 @@ export interface CreatePreviewRequest {
video_title?: string
duration?: number
video_ratio?: string
/** 输出视频宽度(与 video_ratio 匹配,如 9:16 → 1080) */
output_width?: number
/** 输出视频高度(与 video_ratio 匹配,如 9:16 → 1920) */
output_height?: number
/* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */
title_config?: {
text?: string
@@ -42,8 +38,6 @@ export interface CreatePreviewResponse {
is_preview: boolean
resolution: string
created_at: string
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id) */
source_edit_plan_id?: string
}
/** 预览任务详情响应 */
-8
View File
@@ -84,14 +84,6 @@ export interface CreateGenerationTaskRequest {
}
/** 关联的草稿 ID(编辑流程数据链路用) */
source_edit_plan_id?: string
/** 配音素材库 ID(用户上传的音频或 AI 配音素材) */
voice_library_id?: string
/** 自定义 BGM 配置,覆盖模板 BGM 设置 */
bgm_config?: {
enabled: boolean
preset_id?: string
volume?: number
}
}
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
+1 -9
View File
@@ -90,18 +90,10 @@ export async function createClipsFromAssets(
templateId: string,
assetIds: string[],
clipType = "main",
requiredClipsCount?: number,
): Promise<ClipsFromAssetsResponse> {
const body: Record<string, unknown> = {
asset_ids: assetIds,
clip_type: clipType,
}
if (requiredClipsCount !== undefined) {
body.required_clips_count = requiredClipsCount
}
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
body,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
+27 -28
View File
@@ -1,8 +1,15 @@
/**
* 模板草稿 CRUD API
* 模板草稿 CRUD + 生成相关 API
*/
import apiClient from "../client"
import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
import type {
EditPlan,
UpdateEditPlanRequest,
GenerateResponse,
GenerationStatusResponse,
EditPlanGeneration,
GeneratedVideo,
} from "./types"
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
@@ -20,34 +27,26 @@ export async function updateEditPlan(
return response.data
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** ── 草稿 clips 批量更新 ── */
export interface EditPlanClipInput {
asset_id: string
start_time: number
duration: number
order: number
}
/**
* 批量替换草稿的 clips(先全删再批量插入)
* 后端路由:PUT /templates/{template_id}/editor/clips
*/
export async function updateEditPlanClips(
templateId: string,
clips: EditPlanClipInput[],
signal?: AbortSignal,
): Promise<{ count: number }> {
const response = await apiClient.put(
`/templates/${templateId}/editor/clips`,
{ clips },
{ signal },
)
return response.data
}
+6 -2
View File
@@ -16,7 +16,10 @@ export type {
EditPlanConfig,
EditPlan,
UpdateEditPlanRequest,
GenerateResponse,
EditPlanGeneration,
ClipStatusItem,
GenerationStatusResponse,
GeneratedVideo,
AIRecommendRequest,
AIRecommendClipItem,
@@ -48,10 +51,11 @@ export {
export {
getEditPlan,
updateEditPlan,
updateEditPlanClips,
generateEditPlan,
getGenerationStatus,
getEditPlanGenerations,
getGenerationTaskResults,
} from "./editPlans"
export type { EditPlanClipInput } from "./editPlans"
// 片段 CRUD + 批量操作
export {
+38
View File
@@ -114,6 +114,8 @@ export interface EditPlanConfig {
auto_subtitles?: boolean
/** 是否启用 BGM */
bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */
material_mode?: string
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
@@ -185,6 +187,31 @@ export interface EditPlanListResponse {
/* ── 生成相关 ── */
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录 */
export interface EditPlanGeneration {
id: string
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string
@@ -197,6 +224,17 @@ export interface ClipStatusItem {
error_message?: string
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情 */
export interface GeneratedVideo {
id: string
+2 -2
View File
@@ -71,7 +71,7 @@ export const NAV_ITEMS: NavItem[] = [
},
{
key: "editing-planner",
label: "剪辑模板",
label: "剪辑编辑器",
path: "/app/editing-planner",
icon: React.createElement(EditOutlined),
},
@@ -133,7 +133,7 @@ export const NAV_GROUPS: NavGroup[] = [
},
{
key: "editing-planner",
label: "剪辑模板",
label: "剪辑编辑器",
path: "/app/editing-planner",
icon: React.createElement(EditOutlined),
},
@@ -0,0 +1,116 @@
/**
* 生成历史弹窗 — 展示当前模板草稿的生成任务记录
* 从 EditingPlanner 拆分,避免主文件过大
*/
import React from "react"
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
import type { EditPlanGeneration } from "@/api/template-editor"
import { PLAN_STATUS_LABELS } from "@/api/template-editor"
interface GenerationHistoryModalProps {
open: boolean
loading: boolean
history: EditPlanGeneration[]
onClose: () => void
onCancel?: (taskId: string) => void
cancelLoading?: boolean
}
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
open,
loading,
history,
onClose,
onCancel,
cancelLoading,
}) => {
if (!open) return null
return (
<div className="ep-modal-overlay" onClick={onClose}>
<div className="ep-modal ep-gh-modal" onClick={(e) => e.stopPropagation()}>
<div className="ep-modal-header">
<h3>生成历史</h3>
<button className="ep-modal-close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="ep-modal-body ep-gh-body">
{loading ? (
<div className="ep-gh-empty">
<div className="ep-skeleton">
<div className="ep-skeleton-item ep-skeleton-item--header" />
<div className="ep-skeleton-item" />
<div className="ep-skeleton-item" />
<div className="ep-skeleton-item" />
</div>
</div>
) : history.length === 0 ? (
<div className="ep-gh-empty">
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
<span>暂无生成记录</span>
</div>
) : (
<table className="ep-gh-table">
<thead>
<tr className="ep-gh-table-header-row">
<th className="ep-gh-th">任务ID</th>
<th className="ep-gh-th">状态</th>
<th className="ep-gh-th">创建时间</th>
<th className="ep-gh-th">更新时间</th>
{onCancel && <th className="ep-gh-th">操作</th>}
</tr>
</thead>
<tbody>
{history.map((gen) => {
const statusClass = `ep-gh-status-tag--${gen.status}`
const canCancel = gen.status === "rendering" || gen.status === "editing"
return (
<tr key={gen.id} className="ep-gh-table-row">
<td className="ep-gh-td ep-gh-td-id">
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"}
</td>
<td className="ep-gh-td">
<span className={`ep-gh-status-tag ${statusClass}`}>
{PLAN_STATUS_LABELS[gen.status] || gen.status}
</span>
</td>
<td className="ep-gh-td ep-gh-td-time">
{gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"}
</td>
<td className="ep-gh-td ep-gh-td-time">
{gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"}
</td>
{onCancel && (
<td className="ep-gh-td ep-gh-td-action">
{canCancel ? (
<button
className="ep-gh-cancel-btn"
onClick={() => onCancel(gen.id)}
disabled={cancelLoading}
>
取消
</button>
) : (
<span className="ep-gh-action-placeholder">—</span>
)}
</td>
)}
</tr>
)
})}
</tbody>
</table>
)}
</div>
<div className="ep-modal-footer">
<button className="ep-btn ep-btn-secondary" onClick={onClose}>
关闭
</button>
</div>
</div>
</div>
)
}
export default GenerationHistoryModal
@@ -0,0 +1,6 @@
/**
* 生成进度弹窗 — 入口文件(向后兼容)
* 实际实现已移至 ./generation-progress-modal/ 目录
*/
export { default } from "./generation-progress-modal"
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
@@ -0,0 +1,65 @@
import React from "react"
import { Modal } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
import { getStepLabel, getStatusColor } from "./constants"
interface ProgressPhaseProps {
open: boolean
task: TaskItem | null
onCancel: () => void
}
/** progress(进度轮询)阶段弹窗 */
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
const progress = task?.progress ?? 0
const status = task?.status ?? ""
const currentStep = task?.current_step ?? ""
const userMessage = task?.user_message ?? ""
const stepColor = getStatusColor(status, currentStep)
return (
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
<div className="ep-gen-progress">
{/* 进度环 */}
<div className="ep-gen-progress-ring-wrap">
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
<circle
className="ep-gen-progress-ring-fill"
cx="60"
cy="60"
r="52"
style={{
strokeDasharray: `${2 * Math.PI * 52}`,
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
stroke: stepColor,
}}
/>
</svg>
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
{progress}%
</span>
</div>
{/* 当前步骤 */}
<div className="ep-gen-step-text">
{userMessage || getStepLabel(currentStep) || "处理中…"}
</div>
{/* 进度条 */}
<div className="ep-gen-progress-bar">
<div
className="ep-gen-progress-bar-fill"
style={{
width: `${progress}%`,
backgroundColor: stepColor,
}}
/>
</div>
{/* 任务 ID */}
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
</div>
</Modal>
)
}
@@ -0,0 +1,82 @@
import React from "react"
import { Modal, Button } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
interface ResultPhaseProps {
open: boolean
phase: "completed" | "failed"
task: TaskItem | null
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
/** completed / failed(结果)阶段弹窗 */
export const ResultPhase: React.FC<ResultPhaseProps> = ({
open,
phase,
task,
onCancel,
onRetry,
onClose,
}) => {
const userMessage = task?.user_message ?? ""
const errorMessage = task?.error_message ?? ""
const retryable = task?.retryable ?? false
const handleClose = onClose || onCancel
if (phase === "completed") {
return (
<Modal
open={open}
title="✅ 生成完成"
footer={null}
onCancel={handleClose}
closable
width={440}
>
<div className="ep-gen-result">
<div className="ep-gen-result-icon">🎉</div>
<div className="ep-gen-result-title">视频生成完成!</div>
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
<div className="ep-gen-result-actions">
<Button buttonType="primary" onClick={handleClose}>
查看结果
</Button>
</div>
</div>
</Modal>
)
}
return (
<Modal
open={open}
title="❌ 生成失败"
footer={null}
onCancel={handleClose}
closable
width={440}
>
<div className="ep-gen-result ep-gen-result--error">
<div className="ep-gen-result-icon">😥</div>
<div className="ep-gen-result-title">视频生成失败</div>
{(errorMessage || userMessage) && (
<div className="ep-gen-result-msg ep-gen-result-msg--error">
{errorMessage || userMessage}
</div>
)}
<div className="ep-gen-result-actions">
{retryable && onRetry && (
<Button buttonType="primary" onClick={onRetry}>
🔄 重试
</Button>
)}
<Button buttonType="secondary" onClick={handleClose}>
关闭
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,53 @@
import React from "react"
import { Modal } from "@/components/ui"
interface SetupPhaseProps {
open: boolean
voiceoverDuration: number | null
estimatedDuration: number
submitting: boolean
onDurationChange: (v: number | null) => void
onGenerate: () => void
onCancel: () => void
}
/** setup(配置)阶段弹窗 */
export const SetupPhase: React.FC<SetupPhaseProps> = ({
open,
voiceoverDuration,
estimatedDuration,
submitting,
onDurationChange,
onGenerate,
onCancel,
}) => (
<Modal
open={open}
title="使用模板生成视频"
confirmLoading={submitting}
onOk={onGenerate}
onCancel={onCancel}
okText="开始生成"
cancelText="取消"
width={440}
>
<div className="ep-gen-setup">
<label className="ep-gen-field-label">配音时长(秒)</label>
<input
type="number"
className="ep-gen-duration-input"
placeholder="请输入配音时长"
value={voiceoverDuration ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null
onDurationChange(v)
}}
min={1}
max={600}
/>
<div className="ep-gen-estimate">
预估总时长:<strong>{estimatedDuration}s</strong>
</div>
</div>
</Modal>
)
@@ -0,0 +1,31 @@
/* ──────────── 步骤文案映射 ──────────── */
export const STEP_LABELS: Record<string, string> = {
queued: "排队中…",
preparing: "准备素材…",
generating_video: "渲染视频中…",
adding_effects: "添加特效…",
composing: "合成中…",
encoding: "编码输出中…",
completed: "生成完成!",
failed: "生成失败",
}
export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
/* ──────────── 状态徽标颜色 ──────────── */
export const STATUS_COLOR: Record<string, string> = {
queued: "#6b7280",
pending: "#6b7280",
preparing: "#f59e0b",
generating_video: "#4f46e5",
adding_effects: "#7c3aed",
composing: "#2563eb",
encoding: "#0891b2",
completed: "#10b981",
failed: "#ef4444",
}
export const getStatusColor = (status: string, currentStep: string) =>
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
@@ -0,0 +1,70 @@
/**
* 生成进度弹窗 — 任务 2.17
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
* V21 设计系统,CSS 类名前缀 ep-gen-
*/
import React, { useEffect, useRef } from "react"
import type { GenPhase, GenerationProgressModalProps } from "./types"
import { SetupPhase } from "./SetupPhase"
import { ProgressPhase } from "./ProgressPhase"
import { ResultPhase } from "./ResultPhase"
/* 重新导出类型,保持向后兼容 */
export type { GenPhase, GenerationProgressModalProps }
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
open,
phase,
voiceoverDuration,
estimatedDuration,
onDurationChange,
onGenerate,
task,
submitting,
onCancel,
onRetry,
onClose,
}) => {
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
const prevOpen = useRef(false)
useEffect(() => {
if (prevOpen.current && !open) {
/* modal just closed — parent handles reset */
}
prevOpen.current = open
}, [open])
/* setup 阶段 */
if (phase === "setup") {
return (
<SetupPhase
open={open}
voiceoverDuration={voiceoverDuration}
estimatedDuration={estimatedDuration}
submitting={submitting}
onDurationChange={onDurationChange}
onGenerate={onGenerate}
onCancel={onCancel}
/>
)
}
/* progress 阶段 */
if (phase === "progress") {
return <ProgressPhase open={open} task={task} onCancel={onCancel} />
}
/* completed / failed 阶段 */
return (
<ResultPhase
open={open}
phase={phase as "completed" | "failed"}
task={task}
onCancel={onCancel}
onRetry={onRetry}
onClose={onClose}
/>
)
}
export default GenerationProgressModal
@@ -0,0 +1,23 @@
import type { TaskItem } from "@/api/tasks"
export type GenPhase = "setup" | "progress" | "completed" | "failed"
export interface GenerationProgressModalProps {
open: boolean
phase: GenPhase
/* setup 阶段 */
voiceoverDuration: number | null
estimatedDuration: number
onDurationChange: (v: number | null) => void
onGenerate: () => void
/* progress / 结果阶段 */
task: TaskItem | null
/* 通用 */
submitting: boolean
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
+98 -133
View File
@@ -1,27 +1,30 @@
/**
* 智能剪辑页面 — 前端实时预览架构
* 智能剪辑页面 — V24 前端预览播放器架构改造
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
*
* 架构:
* - 步骤 4-6 右侧显示 FrontendPreviewPlayer 实时预览
* - 步骤 7 右侧内联播放生成的最终视频
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
* 架构改造:
* - Step5 预览改为前端素材切片播放(FrontendPreviewPlayer)
* - 完全去除后端 FFmpeg 预览依赖
* - 标题样式通过 CSS 层实时叠加,所见即所得
* - 最终成片仍走后端 FFmpeg 渲染(Step7 确认生成)
*/
import React, { useMemo, useState, useEffect, useRef } from "react"
import { message } from "antd"
import React, { useMemo } from "react"
import { Modal, message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
import { useQuery } from "@tanstack/react-query"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import { getAssetsByKind } from "@/api/assets"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
import {
calculateTotalVideoDuration,
estimateTotalVideoDuration,
} from "./utils/calculateTotalVideoDuration"
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateResultPanel from "./components/GenerateResultPanel"
import PreviewVideoPanel from "./components/PreviewVideoPanel"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
@@ -29,8 +32,6 @@ import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts"
import "./generate.css"
const GeneratePage: React.FC = () => {
@@ -63,72 +64,26 @@ const GeneratePage: React.FC = () => {
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
sourceEditPlanId,
previewTaskId,
setPreviewTaskId,
storedSourceEditPlanId,
setStoredSourceEditPlanId,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
} = formState
/* ── 标题样式回调 ── */
/* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */
const styleUpdaters = useTitleStyleUpdaters({
titleSettings,
onTitleSettingsChange: setTitleSettings,
})
/* ── 配音素材库(TTS 试听)── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
const ttsAbortRef = useRef<AbortController | null>(null)
useEffect(() => {
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
if (voiceAsset?.file_url) {
setPreviewVoiceAudioUrl(voiceAsset.file_url)
return
}
const voiceId = selectedClonedVoice || selectedVoice
if (!voiceId || !titleSettings.title) {
setPreviewVoiceAudioUrl(null)
return
}
ttsAbortRef.current?.abort()
const controller = new AbortController()
ttsAbortRef.current = controller
let cancelled = false
previewTts({ text: titleSettings.title, voice_id: voiceId })
.then((res) => {
if (!cancelled && res.audio_url) {
setPreviewVoiceAudioUrl(res.audio_url)
}
})
.catch((err) => {
if (!cancelled) {
console.warn("[预览配音生成失败]", err)
setPreviewVoiceAudioUrl(null)
}
})
return () => {
cancelled = true
controller.abort()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
/* ── 克隆声音 ── */
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
@@ -138,38 +93,44 @@ const GeneratePage: React.FC = () => {
message.success("音色克隆成功!")
}
/* ── 素材 ID 列表 ── */
/* ── 前端预览:加载选中素材的视频文件信息 ── */
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 currentTemplate = useMemo(
() => userTemplates.find((t) => t.id === selectedTemplate) || null,
[userTemplates, selectedTemplate],
)
/* ── BGM 配置 ── */
const bgmConfig = useMemo(
() => ({
enabled: bgm,
music_id: currentTemplate?.bgm_config?.music_id || "",
}),
[bgm, currentTemplate],
)
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
/* ── 视频总时长计算 ── */
/* ── 视频总时长计算(用于配音时长校验) ── */
const totalVideoDuration = useMemo(() => {
// 优先用素材精确时长;素材未加载时用模板 segments 的 duration_max 之和估算
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
if (exact > 0) return exact
return estimateTotalVideoDuration(currentTemplate ?? undefined)
}, [previewAssets, currentTemplate])
/* ── 配音音频 URL ── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const voiceAudioUrl = useMemo(() => {
if (!selectedVoice) return undefined
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
return asset?.file_url || undefined
}, [selectedVoice, voiceMaterials])
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
currentStep,
@@ -179,6 +140,7 @@ const GeneratePage: React.FC = () => {
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady: previewAssetsReady,
})
/* ── 视频生成核心逻辑 ── */
@@ -208,26 +170,24 @@ const GeneratePage: React.FC = () => {
duration,
autoSubtitles,
bgm,
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
previewTaskId,
bgmConfig,
onGenerationSuccess: () => {
setPreviewTaskId(null)
setStoredSourceEditPlanId(null)
},
generateCount,
sourceEditPlanId: editPlanId,
})
/* ================================================================
渲染
渲染 — 主页面
================================================================ */
return (
<div className="xx-generate-page">
{/* ── 页头 ── */}
<GenerateHeader fromEditPlan={!!editPlanId} />
{/* ── 步骤条 ── */}
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}`}>
{/* ── 主布局 ── */}
<div className="xx-generate-layout">
{/* ════ 左侧:表单区 ════ */}
<div className="xx-generate-form">
<GenerateStepContent
@@ -243,6 +203,7 @@ const GeneratePage: React.FC = () => {
onSmartSelectedIdsChange={setSmartSelectedIds}
titleSettings={titleSettings}
onTitleSettingsChange={setTitleSettings}
/* 标题样式回调 */
onUpdatePosition={styleUpdaters.updatePosition}
onUpdateFont={styleUpdaters.updateFont}
onUpdateSize={styleUpdaters.updateSize}
@@ -253,10 +214,6 @@ const GeneratePage: React.FC = () => {
onApplyPreset={styleUpdaters.applyPreset}
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
onPreviewTaskCreated={setPreviewTaskId}
onSourceEditPlanIdExtracted={setStoredSourceEditPlanId}
bgm={bgm}
bgmConfig={bgmConfig}
coverSettings={coverSettings}
onCoverSettingsChange={setCoverSettings}
duration={duration}
@@ -272,6 +229,8 @@ const GeneratePage: React.FC = () => {
hasProcessing={hasProcessing}
cloneModalOpen={cloneModalOpen}
onCloneModalOpenChange={setCloneModalOpen}
generateCount={generateCount}
onGenerateCountChange={setGenerateCount}
generating={generating}
generated={generated}
generateError={generateError}
@@ -293,57 +252,63 @@ const GeneratePage: React.FC = () => {
/>
</div>
{/* ════ 右侧:步骤 4-6 实时预览,步骤 7 最终视频 ════ */}
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{currentStep >= 4 && currentStep <= 6 && !!currentTemplate && (
<FrontendPreviewPlayer
{/* 预览视频面板(Step4+ 显示,含 CSS 标题实时预览层) */}
{currentStep >= 4 && (
<PreviewVideoPanel
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
}}
assetsReady={previewAssetsReady}
assetsLoading={previewAssetsLoading}
titleSettings={titleSettings}
voiceAudioUrl={voiceAudioUrl}
/>
)}
{currentStep === 7 && generated && generatedVideos.length > 0 && (
<div className="xx-inline-video-player">
<video
src={generatedVideos[0].download_url || generatedVideos[0].file_url}
controls
autoPlay
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
poster={generatedVideos[0].thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
⬇️ 下载
</button>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗 分享
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁 前往成片库
</button>
</div>
</div>
{currentStep >= 6 && (
<GenerateResultPanel
generated={generated}
generating={generating}
progress={progress}
generateError={generateError}
generatedVideos={generatedVideos}
onVideoPreview={(video) => {
setPreviewVideo(video)
setPreviewModalOpen(true)
}}
onDownload={handleDownload}
onShare={handleShare}
onGoToLibrary={() => navigate("/app/products")}
/>
)}
</div>
</div>
{/* 音色克隆弹窗 */}
{/* ── 视频预览弹窗 ── */}
<Modal
className="xx-preview-modal"
open={previewModalOpen}
onCancel={() => setPreviewModalOpen(false)}
footer={null}
width="80vw"
centered
destroyOnClose
>
{previewVideo && (
<div className="xx-preview-modal-content">
<video
src={previewVideo.download_url || previewVideo.file_url}
controls
autoPlay
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain" }}
poster={previewVideo.thumbnail_url || undefined}
/>
</div>
)}
</Modal>
{/* ── 音色克隆弹窗 ── */}
<CloneModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
@@ -76,59 +76,12 @@ function buildPlaybackSegments(
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
assets,
template,
videoRatio,
videoRatio: _videoRatio,
ready,
voiceAudioUrl,
titleSettings,
}) => {
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
const TITLE_MARGIN_TOP = 120
const TITLE_MARGIN_BOTTOM = 60
const TITLE_MARGIN_SIDE = 40
const playRes = (() => {
switch (videoRatio) {
case "16:9":
return { width: 1920, height: 1080 }
case "1:1":
return { width: 1080, height: 1080 }
case "9:16":
default:
return { width: 1080, height: 1920 }
}
})()
const playerContainerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(0)
useEffect(() => {
const el = playerContainerRef.current
if (!el) return
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = entry.contentRect.height
if (h > 0) setContainerHeight(h)
}
})
ro.observe(el)
const rect = el.getBoundingClientRect()
if (rect.height > 0) setContainerHeight(rect.height)
return () => ro.disconnect()
}, [])
// 标题字号按容器高度与 PlayResY 的比例缩放
const titleFontSizePx =
containerHeight > 0
? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight
: (titleSettings?.size ?? 36)
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
// 描边/阴影也要按缩放比例放大
const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1
const titleStrokeWidth = Math.max(1, 2 * titleScale)
const titleShadowBlur = 4 * titleScale
const titleShadowOffset = 2 * titleScale
// 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
const useWebCodecs = false
@@ -324,29 +277,20 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (!ready || !assets.length) {
return (
<div
className="xx-preview-empty"
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}}
>
<SoundOutlined style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }} />
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
准备预览素材...
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
加载素材后即可预览播放
</p>
<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>
)
}
@@ -356,48 +300,31 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
return (
<div
className="xx-preview-empty"
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 24,
zIndex: 1,
}}
>
{isBuffering ? (
<>
<LoadingOutlined style={{ fontSize: 40, color: "#fff", marginBottom: 12 }} spin />
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14, margin: 0 }}>加载中...</p>
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
<p style={{ color: "rgba(255,255,255,0.8)" }}>加载中...</p>
</>
) : showDecodeError ? (
<>
<PlayCircleOutlined style={{ fontSize: 40, color: "#ef4444", marginBottom: 12 }} />
<p
style={{
color: "rgba(255,255,255,0.9)",
fontSize: 14,
margin: "0 0 4px",
fontWeight: 500,
}}
>
<PlayCircleOutlined style={{ fontSize: 48, color: "#ef4444", marginBottom: 12 }} />
<p className="xx-preview-empty-title" style={{ color: "rgba(255,255,255,0.9)" }}>
视频解码失败
</p>
<p
style={{
color: "rgba(255,255,255,0.5)",
fontSize: 12,
margin: 0,
textAlign: "center",
}}
className="xx-preview-empty-desc"
style={{ color: "rgba(255,255,255,0.6)", maxWidth: 300, textAlign: "center" }}
>
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
</p>
@@ -405,14 +332,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
) : (
<>
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
暂无可播放素材
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
请先在左侧选择素材
</p>
<p className="xx-preview-empty-title">暂无可播放素材</p>
<p className="xx-preview-empty-desc">请先在左侧选择素材</p>
</>
)}
</div>
@@ -420,20 +343,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
return (
<div
ref={playerContainerRef}
style={{
position: "relative",
width: "100%",
maxWidth: 280,
aspectRatio: "9 / 16",
background: "#0a0a0a",
borderRadius: 24,
overflow: "hidden",
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
}}
>
<>
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
{effectiveUseWebCodecs && (
<div
@@ -482,121 +392,54 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
/>
))}
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{titleSettings?.title && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 5,
pointerEvents: "none",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
left: `${titleSidePct}%`,
right: `${titleSidePct}%`,
textAlign: "center",
...(titleSettings.position === "top"
? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: `${titleBottomPct}%` }),
}}
>
<span
style={{
fontSize: `${titleFontSizePx}px`,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
fontWeight: titleSettings.bold ? 700 : 400,
fontStyle: titleSettings.italic ? "italic" : "normal",
lineHeight: 1.05,
wordBreak: "break-word",
WebkitTextStroke: titleSettings.stroke
? `${titleStrokeWidth}px #000000`
: undefined,
textShadow: titleSettings.shadow
? `${titleShadowOffset}px ${titleShadowOffset}px ${titleShadowBlur}px rgba(0,0,0,0.8)`
: undefined,
}}
>
{titleSettings.title.split(/[//]/).map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</span>
</div>
</div>
)}
{/* 中央播放按钮 */}
{/* 播放按钮 */}
{!isPlaying && (
<button
className="xx-preview-play-btn"
onClick={handleTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
width: 52,
height: 52,
width: 56,
height: 56,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
fontSize: 28,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
{/* 片段指示器 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: 10,
fontWeight: 500,
left: 8,
background: "rgba(0,0,0,0.6)",
color: "#fff",
fontSize: 11,
padding: "2px 8px",
borderRadius: 999,
borderRadius: 4,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${videoCurrentSegIdx + 1} / ${segments.length}`}
{`片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
</div>
{/* 控制条 — 手机风格毛玻璃 */}
{/* 控制条 */}
<div
className="xx-preview-controls"
style={{
position: "absolute",
bottom: 0,
@@ -604,36 +447,23 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
right: 0,
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
gap: 8,
padding: "8px 12px",
background: "linear-gradient(transparent, rgba(0,0,0,0.6))",
zIndex: 10,
}}
>
<button
onClick={handleTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
background: "none",
border: "none",
color: "#fff",
fontSize: 16,
fontSize: 18,
cursor: "pointer",
width: 32,
height: 32,
borderRadius: "50%",
padding: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
@@ -641,11 +471,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<span
style={{
fontSize: 11,
color: "rgba(255,255,255,0.85)",
minWidth: 72,
fontSize: 12,
color: "rgba(255,255,255,0.8)",
minWidth: 80,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatTime(currentTime)} / {formatTime(totalDuration)}
@@ -656,8 +485,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
height: 4,
background: "rgba(255,255,255,0.15)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
@@ -667,7 +496,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
background: "#3b82f6",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
@@ -681,15 +510,15 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
background: "#3b82f6",
border: "2px solid #fff",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</div>
</div>
</>
)
}
@@ -65,6 +65,8 @@ export interface GenerateStepContentProps {
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
/* 生成 */
generateCount: number
onGenerateCountChange: (n: number) => void
generating: boolean
generated: boolean
generateError: string | null
@@ -74,14 +76,6 @@ export interface GenerateStepContentProps {
onDismissError: () => void
/* 其他 */
presetVoices: PresetVoiceItem[]
/** 预览任务创建回调——传递给 Step6CoverSettings */
onPreviewTaskCreated?: (taskId: string) => void
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
onSourceEditPlanIdExtracted?: (planId: string) => void
/** BGM 开关 */
bgm: boolean
/** BGM 配置(来自模板) */
bgmConfig?: { enabled: boolean; music_id?: string }
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -117,6 +111,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
voiceMode,
selectedClonedVoice,
clonedVoices,
generateCount,
onGenerateCountChange,
generating,
generated,
generateError,
@@ -125,16 +121,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onRetry,
onDismissError,
presetVoices,
onPreviewTaskCreated,
onSourceEditPlanIdExtracted,
bgm,
bgmConfig,
} = props
/* 当前模板的 segments,传给 Step2 构建 clips */
const currentTemplate = userTemplates.find((t) => t.id === selectedTemplate)
const templateSegments = currentTemplate?.segments
switch (currentStep) {
case 1:
return (
@@ -154,7 +142,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
smartSelectedIds={smartSelectedIds}
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
selectedTemplate={selectedTemplate}
templateSegments={templateSegments}
/>
)
case 3:
@@ -198,13 +185,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
selectedTemplate={selectedTemplate}
titleSettings={titleSettings}
onPreviewTaskCreated={onPreviewTaskCreated}
onSourceEditPlanIdExtracted={onSourceEditPlanIdExtracted}
voiceMode={voiceMode}
selectedVoice={selectedVoice}
selectedClonedVoice={selectedClonedVoice}
bgm={bgm}
bgmConfig={bgmConfig}
/>
)
case 7:
@@ -222,6 +202,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
presetVoices={presetVoices}
clonedVoices={clonedVoices}
coverSettings={coverSettings}
generateCount={generateCount}
onGenerateCountChange={onGenerateCountChange}
generating={generating}
generated={generated}
generateError={generateError}
@@ -1,72 +1,58 @@
/**
* 右侧预览视频面板 — 服务器渲染预览架构
* 右侧预览视频面板
* Step4+: 显示预览视频面板
* Step5: 前端实时预览 — 用原生 video 播放素材片段 + CSS 标题叠加
*
* Step4+: 显示预览面板
* Step5: 播放服务器渲染的真实视频(POST /generation/preview)
* 架构改造:完全去除后端 FFmpeg 预览依赖
* - 使用 FrontendPreviewPlayer 直接播放素材片段
* - TitleOverlay CSS 层实时响应标题样式变化
*
* 架构:
* - 进入 Step4/5 时自动创建服务器预览渲染任务
* - 轮询完成后用 <video> 标签播放返回的 video_url
* - 标题样式编辑时 CSS TitleOverlay 实时叠加预览
* - 素材/配音/BGM 变更自动重新渲染
* - 标题文字/样式变更标记 stale,保留旧视频 + 显示"重新预览"按钮
*
* 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致。
* 布局:本组件提供 .xx-preview-video 容器(position: relative + overflow: hidden)
* FrontendPreviewPlayer 的内容通过 absolute 定位填充容器
* TitleOverlay 通过 absolute 定位 + z-index: 30 覆盖在最上层
*/
import React, { useMemo, useRef, useState, useEffect } from "react"
import { LoadingOutlined, ReloadOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
import { Button } from "antd"
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 { getFontFamily } from "../constants"
import type { ServerPreviewStatus } from "../hooks/useServerPreview"
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
interface PreviewVideoPanelProps {
/** 服务器预览状态 */
previewStatus: ServerPreviewStatus
/** 服务器渲染视频 URL */
videoUrl: string | null
/** 渲染进度 0-100 */
progress: number
/** 错误信息 */
error: string | null
/** 重新预览回调 */
onRetry: () => void
/** 已加载的素材列表 */
assets: AssetItem[]
/** 当前模板 */
template: EditingTemplate | null
/** 视频比例 */
videoRatio: string
/** 标题设置 — CSS 实时预览层 */
/** 素材是否已加载就绪 */
assetsReady: boolean
/** 素材是否正在加载 */
assetsLoading: boolean
/** 标题设置 — 用于 CSS 实时预览层 */
titleSettings?: TitleSettings
/** 素材数量 */
assetCount?: number
/** 配音音频 URL */
voiceAudioUrl?: string
}
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
const TITLE_MARGIN_TOP = 120
const TITLE_MARGIN_BOTTOM = 60
const TITLE_MARGIN_SIDE = 40
const ASS_VIDEO_HEIGHT = 720
const ASS_TITLE_MARGIN_TOP = 60
const ASS_TITLE_MARGIN_BOTTOM = 60
const ASS_TITLE_MARGIN_SIDE = 40
/** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY) */
function getResolution(ratio: string): { width: number; height: number } {
switch (ratio) {
case "16:9":
return { width: 1920, height: 1080 }
case "1:1":
return { width: 1080, height: 1080 }
case "9:16":
default:
return { width: 1080, height: 1920 }
}
}
/**
* 根据 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
function getPositionStyle(
position: string,
playResX: number,
playResY: number,
): React.CSSProperties {
const sidePercent = (TITLE_MARGIN_SIDE / playResX) * 100
switch (position) {
case "bottom":
return {
bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
@@ -82,7 +68,7 @@ function getPositionStyle(
case "top":
default:
return {
top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
top: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`,
left: `${sidePercent}%`,
right: `${sidePercent}%`,
textAlign: "center",
@@ -90,16 +76,16 @@ function getPositionStyle(
}
}
function buildTitleStyle(
settings: TitleSettings,
containerHeight: number,
playResY: number,
): React.CSSProperties {
// 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致)
/**
* 构建 CSS 标题层的样式
* 所有渲染参数与后端 FFmpeg ASS 字幕一致
*/
function buildTitleStyle(settings: TitleSettings, containerHeight: number): React.CSSProperties {
// 用 px 计算 fontSize,不再依赖父元素 font-size 的百分比
const fontSizePx =
containerHeight > 0
? (settings.size / playResY) * containerHeight
: (settings.size / playResY) * 400
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 // fallback
const base: React.CSSProperties = {
fontFamily: getFontFamily(settings.font),
@@ -107,24 +93,35 @@ function buildTitleStyle(
color: settings.color || "#ffffff",
fontWeight: settings.bold ? 700 : 400,
fontStyle: settings.italic ? "italic" : "normal",
lineHeight: 1.05,
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)"
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; videoRatio: string }> = ({
titleSettings,
videoRatio,
}) => {
/**
* CSS 标题预览覆盖层
* 始终渲染:有标题显示标题,无标题显示占位文本"标题预览"
* z-index: 20(在视频 z-index:1 和控制条 z-index:10 之上)
*/
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
const containerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400)
const [containerHeight, setContainerHeight] = useState(400) // fallback
// ResizeObserver 获取容器实际高度
useEffect(() => {
const el = containerRef.current
if (!el) return
@@ -135,21 +132,19 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string
}
})
ro.observe(el)
// 初始化也读一次
const rect = el.getBoundingClientRect()
if (rect.height > 0) setContainerHeight(rect.height)
return () => ro.disconnect()
}, [])
const { width: playResX, height: playResY } = getResolution(videoRatio)
const positionStyle = useMemo(
() => getPositionStyle(titleSettings.position, playResX, playResY),
// eslint-disable-next-line react-hooks/exhaustive-deps
[titleSettings.position, playResX, playResY],
() => getPositionStyle(titleSettings.position),
[titleSettings.position],
)
const titleStyle = useMemo(
() => buildTitleStyle(titleSettings, containerHeight, playResY),
// eslint-disable-next-line react-hooks/exhaustive-deps
() => buildTitleStyle(titleSettings, containerHeight),
// eslint-disable-next-line react-hooks/exhaustive-deps -- 已逐字段列出 titleSettings 依赖
[
containerHeight,
titleSettings.font,
@@ -159,7 +154,6 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string
titleSettings.italic,
titleSettings.stroke,
titleSettings.shadow,
playResY,
],
)
@@ -176,8 +170,14 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string
overflow: "hidden",
}}
>
<div style={{ ...positionStyle, ...titleStyle, position: "absolute" }}>
{displayTitle.split(/[//]/).map((part, i) => (
<div
style={{
...positionStyle,
...titleStyle,
position: "absolute",
}}
>
{displayTitle.split("/").map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
@@ -191,47 +191,30 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string
/* ── 主组件 ── */
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
previewStatus,
videoUrl,
progress,
error,
onRetry,
assets,
template,
videoRatio,
assetsReady,
assetsLoading,
titleSettings,
assetCount,
voiceAudioUrl,
}) => {
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
const isLoading = previewStatus === "loading"
const isReady = previewStatus === "ready" || previewStatus === "stale"
const isFailed = previewStatus === "failed"
const isIdle = previewStatus === "idle"
const isStale = previewStatus === "stale"
return (
<div className="xx-generate-preview">
<div className="xx-preview-header">
<h3>预览视频</h3>
{isReady && !isStale && <span className="xx-preview-badge">服务器渲染</span>}
{isStale && (
<span
style={{
fontSize: 12,
color: "#faad14",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<ExclamationCircleOutlined /> 配置已变更
</span>
)}
{isLoading && <span className="xx-preview-badge">渲染中</span>}
{assetsReady && assets.length > 0 && <span className="xx-preview-badge">实时预览</span>}
</div>
{/* ✅ 预览容器 — 唯一的 .xx-preview-video 容器
内部所有内容(视频、控制条、标题叠加层)通过 absolute 定位填充 */}
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
{/* 加载中 */}
{isLoading && (
{/* 加载中状态 */}
{assetsLoading && (
<div
className="xx-preview-loading-center"
style={{
position: "absolute",
inset: 0,
@@ -240,132 +223,34 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
alignItems: "center",
justifyContent: "center",
zIndex: 5,
background: "rgba(0,0,0,0.6)",
}}
>
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.9)", fontSize: 14 }}>
正在渲染预览视频{progress > 0 ? `...${progress}%` : "..."}
</p>
<p style={{ marginTop: 4, color: "rgba(255,255,255,0.5)", fontSize: 12 }}>
首次渲染约需 30-60 秒
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
加载素材中...
</p>
</div>
)}
{/* 空闲状态(尚未触发预览) */}
{isIdle && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.3)",
zIndex: 5,
}}
>
<p style={{ color: "rgba(255,255,255,0.7)", fontSize: 14 }}>等待素材选择...</p>
</div>
)}
{/* 前端播放器(视频 + 控制条 + 播放按钮)*/}
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assetsReady}
voiceAudioUrl={voiceAudioUrl}
/>
{/* 服务器渲染的真实视频 */}
{isReady && videoUrl && (
<video
key={videoUrl}
src={videoUrl}
controls
autoPlay
loop
playsInline
style={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
inset: 0,
zIndex: 1,
}}
/>
)}
{/* 标题样式实时预览层(仅在有视频时叠加) */}
{isReady && titleSettings && (
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
)}
{/* stale 遮罩:配置变更提示 */}
{isStale && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: "10px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.85))",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
zIndex: 30,
}}
>
<span style={{ color: "rgba(255,255,255,0.9)", fontSize: 12 }}>
配置已变更,预览内容可能不是最新
</span>
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={onRetry}>
重新预览
</Button>
</div>
)}
{/* 错误状态 */}
{isFailed && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.7)",
zIndex: 10,
padding: 24,
}}
>
<ExclamationCircleOutlined style={{ fontSize: 40, color: "#ff4d4f" }} />
<p
style={{
marginTop: 12,
color: "rgba(255,255,255,0.9)",
fontSize: 14,
textAlign: "center",
}}
>
{error || "预览渲染失败"}
</p>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={onRetry}
style={{ marginTop: 12 }}
>
重新预览
</Button>
</div>
)}
{/* CSS 标题实时预览层 — z-index: 20,始终渲染在内容层之上 */}
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
</div>
{/* 素材信息 */}
{assetCount !== undefined && assetCount > 0 && (
{assetsReady && assets.length > 0 && (
<div className="xx-preview-info">
<div className="xx-preview-info-row">
<span>素材数</span>
<span>{assetCount} 个</span>
<span>{assets.length} 个</span>
</div>
<div className="xx-preview-info-row">
<span>比例</span>
@@ -2,7 +2,6 @@
* Step 2 素材选择组件
*/
import React from "react"
import type { TemplateSegment } from "@/api/templates/types"
import { useStep2Materials } from "../hooks/useStep2Materials"
import MaterialModeTabs from "./material/MaterialModeTabs"
import ManualMaterialList from "./material/ManualMaterialList"
@@ -18,8 +17,6 @@ interface Step2MaterialSelectProps {
onSmartSelectedIdsChange: (ids: string[]) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
/** 当前模板的 segments(用于构建 clips duration) */
templateSegments?: TemplateSegment[]
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
@@ -1,10 +1,7 @@
/**
* Step 5 预览设置组件
*
* 前端实时预览架构:
* - 右侧面板使用 FrontendPreviewPlayer 实时播放素材片段
* - 标题样式可实时调整,CSS 层即时叠加预览
* - 点"确认生成"时触发一次服务器渲染
* Step 5 生成预览组件
* 架构改造:移除后端预览生成,改为前端实时预览
* 左侧仅保留标题样式面板,视频在右侧 PreviewVideoPanel 实时播放
*/
import React from "react"
import { PlayCircleOutlined } from "@ant-design/icons"
@@ -13,6 +10,7 @@ import type { TitleSettings } from "../types"
import TitleStylePanel from "./title/TitleStylePanel"
interface Step5GeneratePreviewProps {
/* 标题样式 */
titleSettings: TitleSettings
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
@@ -43,7 +41,9 @@ const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
<div className="xx-form-section">
<h3>🎬 预览设置</h3>
{/* 前端实时预览提示 */}
<div
className="xx-preview-tip"
style={{
display: "flex",
alignItems: "center",
@@ -57,10 +57,11 @@ const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
>
<PlayCircleOutlined style={{ fontSize: 18, color: "#3b82f6" }} />
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
右侧为实时预览,选完素材即可播放。确认生成后服务器渲染最终视频
右侧面板直接播放素材片段,调整标题样式可实时预览效果
</span>
</div>
{/* 标题样式面板 */}
<TitleStylePanel
settings={titleSettings}
onUpdatePosition={onUpdatePosition}
@@ -16,20 +16,6 @@ interface Step6CoverSettingsProps {
selectedTemplate?: string
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
titleSettings?: import("../types").TitleSettings
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
onPreviewTaskCreated?: (taskId: string) => void
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
onSourceEditPlanIdExtracted?: (planId: string) => void
/** 配音模式 */
voiceMode?: "preset" | "custom" | "clone"
/** 选中的配音素材 ID */
selectedVoice?: string
/** 选中的克隆音色 ID */
selectedClonedVoice?: string
/** BGM 开关 */
bgm?: boolean
/** BGM 配置 */
bgmConfig?: { enabled: boolean; music_id?: string }
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
@@ -57,13 +43,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
assetIds: props.assetIds,
selectedTemplate: props.selectedTemplate,
titleSettings: props.titleSettings,
onPreviewTaskCreated: props.onPreviewTaskCreated,
onSourceEditPlanIdExtracted: props.onSourceEditPlanIdExtracted,
voiceMode: props.voiceMode,
selectedVoice: props.selectedVoice,
selectedClonedVoice: props.selectedClonedVoice,
bgm: props.bgm,
bgmConfig: props.bgmConfig,
})
const handleAutoGenerate = () => {
@@ -24,6 +24,8 @@ interface Step7ConfirmGenerateProps {
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generateCount: number
onGenerateCountChange: (count: number) => void
generating: boolean
generated: boolean
generateError: string | null
@@ -40,6 +42,9 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
title,
voiceName,
coverSummary,
generateCount,
handleDecrement,
handleIncrement,
generating,
generated,
generateError,
@@ -60,6 +65,10 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
title={title}
voiceName={voiceName}
coverSummary={coverSummary}
generateCount={generateCount}
generating={generating}
onDecrement={handleDecrement}
onIncrement={handleIncrement}
/>
<GenerationStatus
generating={generating}
@@ -1,4 +1,5 @@
import React from "react"
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
interface SummaryCardProps {
templateName: string
@@ -6,6 +7,10 @@ interface SummaryCardProps {
title: string
voiceName: string
coverSummary: string
generateCount: number
generating: boolean
onDecrement: () => void
onIncrement: () => void
}
const SummaryCard: React.FC<SummaryCardProps> = ({
@@ -14,6 +19,10 @@ const SummaryCard: React.FC<SummaryCardProps> = ({
title,
voiceName,
coverSummary,
generateCount,
generating,
onDecrement,
onIncrement,
}) => {
return (
<div className="xx-summary-card">
@@ -37,6 +46,29 @@ const SummaryCard: React.FC<SummaryCardProps> = ({
<span className="xx-summary-label">封面</span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">生成数量</span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={onDecrement}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={onIncrement}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint">条视频</span>
</div>
</span>
</div>
</div>
)
}
@@ -94,7 +94,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
className="xx-slider"
type="range"
min={12}
max={128}
max={48}
value={settings.size}
onChange={(e) => onUpdateSize(Number(e.target.value))}
/>
+47 -24
View File
@@ -113,14 +113,6 @@
align-items: start;
}
.xx-generate-layout.full-width {
grid-template-columns: 1fr;
}
.xx-generate-layout.full-width .xx-generate-right-col {
display: none;
}
/* ============================================================
左侧表单区 generate-form
============================================================ */
@@ -1288,6 +1280,53 @@
}
}
/* ── 生成数量步进器 ── */
.xx-count-stepper {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xx-count-stepper-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 8px;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #64748b);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.xx-count-stepper-btn:hover:not(:disabled) {
border-color: var(--primary-400, #818cf8);
color: var(--primary-600, #4f46e5);
background: var(--primary-50, #eef2ff);
}
.xx-count-stepper-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.xx-count-stepper-value {
min-width: 24px;
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-count-stepper-hint {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
margin-left: 2px;
}
/* ── 素材选择模式切换 Tab ── */
.xx-material-mode-tabs {
display: flex;
@@ -2486,25 +2525,9 @@
.xx-generate-right-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* ── 内联视频播放器(右侧) ── */
.xx-inline-video-player {
width: 100%;
max-width: 320px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.xx-inline-video-player video {
background: #000;
}
.xx-preview-header {
display: flex;
align-items: center;
@@ -18,17 +18,9 @@ export interface UseGenerateVideoProps {
duration: number
autoSubtitles: boolean
bgm: boolean
generateCount: number
/** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */
sourceEditPlanId?: string | null
/** 预览任务 ID(由 useStep6Cover 创建后写入,供 confirmGeneration 复用预览产物) */
previewTaskId?: string | null
/** BGM 配置(来自模板 bgm_config,受 bgm 开关控制) */
bgmConfig?: {
enabled: boolean
music_id?: string
}
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
onGenerationSuccess?: () => void
}
/** 生成阶段 */
@@ -4,6 +4,7 @@
*/
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import type { CoverConfig } from "../../types/cover"
import type { PresetVoiceItem } from "@/api/voices"
@@ -13,7 +14,6 @@ import { useTemplateSelection } from "./useTemplateSelection"
import { useTitleCoverSync } from "./useTitleCoverSync"
import { useVoiceState } from "./useVoiceState"
import { usePlanConfigLoader } from "./usePlanConfigLoader"
import { usePersistedState } from "../usePersistedState"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
@@ -67,6 +67,10 @@ export interface GenerateFormState {
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* 生成数量 */
generateCount: number
setGenerateCount: (n: number) => void
/* 高级设置 */
videoRatio: string
duration: number
@@ -78,21 +82,11 @@ export interface GenerateFormState {
editPlanId: string | null
planConfigStr: string | null
/**
* 传给 Worker 的 source_edit_plan_id。
* 仅使用 URL 中的 edit_plan_id(从剪辑模板编辑器跳转时携带)。
* URL 没有时传 null,后端正式生成 API 会通过 template_id+user_id 兜底查找正确的 plan。
* 注意:selectedTemplate 是模板 ID,不是 edit_plan_id,不能作为此值传递。
*/
sourceEditPlanId: string | null
/** 预览任务 ID(由 useStep6Cover 创建后写入,供 useGenerateVideo 复用) */
previewTaskId: string | null
setPreviewTaskId: (id: string | null) => void
/** 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) */
storedSourceEditPlanId: string | null
setStoredSourceEditPlanId: (planId: string | null) => void
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
previewModalOpen: boolean
setPreviewModalOpen: (open: boolean) => void
}
export const useGenerateFormState = (): GenerateFormState => {
@@ -106,11 +100,6 @@ export const useGenerateFormState = (): GenerateFormState => {
/* ── 模板选择 ── */
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
// selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆;
// 后端正式生成 API 会在 source_edit_plan_id 为空时通过 template_id+user_id 自动关联。
const sourceEditPlanId = editPlanId || null
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
@@ -144,6 +133,9 @@ export const useGenerateFormState = (): GenerateFormState => {
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("9:16")
const [duration] = useState(30)
@@ -151,29 +143,9 @@ export const useGenerateFormState = (): GenerateFormState => {
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 预览任务 ID(useStep6Cover 创建预览时写入,useGenerateVideo 复用) ── */
// 持久化到 localStorage,key 按 editPlanId/templateId 区分,刷新页面后可恢复
const previewStorageKey = editPlanId
? `preview_task_id_${editPlanId}`
: selectedTemplate
? `preview_task_id_tpl_${selectedTemplate}`
: null
const [previewTaskId, setPreviewTaskId] = usePersistedState<string | null>(
previewStorageKey,
null,
)
/* ── 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) ── */
// 持久化到 localStorage,刷新页面后 fallback 路径仍能正确传递 source_edit_plan_id
const planIdStorageKey = editPlanId
? `source_edit_plan_id_${editPlanId}`
: selectedTemplate
? `source_edit_plan_id_tpl_${selectedTemplate}`
: null
const [storedSourceEditPlanId, setStoredSourceEditPlanId] = usePersistedState<string | null>(
planIdStorageKey,
null,
)
/* ── 预览弹窗 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/* ── 从 URL / 编辑计划加载配置 ── */
usePlanConfigLoader({
@@ -209,17 +181,18 @@ export const useGenerateFormState = (): GenerateFormState => {
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
sourceEditPlanId,
planConfigStr,
previewTaskId,
setPreviewTaskId,
storedSourceEditPlanId,
setStoredSourceEditPlanId,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
}
}
@@ -10,11 +10,10 @@ import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
import { validateGenerateInputs } from "./generate-video/buildPayload"
import { calculateResolution } from "../utils/calculateResolution"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
export function useGenerateVideo(props: UseGenerateVideoProps) {
const { selectedTemplate, onGenerationSuccess } = props
const { selectedTemplate } = props
/* ── 生成状态 ── */
const [generating, setGenerating] = useState(false)
@@ -24,16 +23,11 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
const handleProgress = useCallback((p: number) => setProgress(p), [])
const handleComplete = useCallback(
(videos: unknown[]) => {
setGenerating(false)
setGenerated(true)
setGeneratedVideos(videos as GeneratedVideo[])
// 生成成功后清除持久化的预览状态,避免下次进入复用旧任务
onGenerationSuccess?.()
},
[onGenerationSuccess],
)
const handleComplete = useCallback((videos: unknown[]) => {
setGenerating(false)
setGenerated(true)
setGeneratedVideos(videos as GeneratedVideo[])
}, [])
const handleFailed = useCallback((errorMsg: string) => {
setGenerating(false)
setGenerateError(errorMsg)
@@ -60,10 +54,37 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
clearTimer()
try {
// 解析分辨率(共享工具函数)
const { width: outputWidth, height: outputHeight } = calculateResolution(
props.videoRatio || "9:16",
)
// 解析分辨率
const ratio = props.videoRatio || "9:16"
let outputWidth: number
let outputHeight: number
if (ratio.includes(":")) {
const [rw, rh] = ratio.split(":").map(Number)
if (rw > 0 && rh > 0) {
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
const baseLong = 1920
const baseShort = Math.round((baseLong * shortSide) / longSide)
const evenShort = baseShort - (baseShort % 2)
if (rw < rh) {
outputWidth = evenShort
outputHeight = baseLong
} else {
outputWidth = baseLong
outputHeight = evenShort
}
} else {
outputWidth = 1080
outputHeight = 1920
}
} else if (ratio.includes("x")) {
const [wStr, hStr] = ratio.split("x")
outputWidth = parseInt(wStr, 10) || 1080
outputHeight = parseInt(hStr, 10) || 1920
} else {
outputWidth = 1080
outputHeight = 1920
}
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
@@ -71,14 +92,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// 解析配音参数:voice_library_id 是配音素材库 asset ID
// 优先用当前 voiceMode 对应的选择,兜底用 selectedVoice(防止 voiceMode 切换后丢失)
const voiceLibraryId =
props.voiceMode === "clone"
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
// 创建生成任务(服务器渲染)
// 直接创建正式生成任务
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
@@ -88,15 +102,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
// 配音:始终传递 voice_library_id,确保后端能正确接收
voice_library_id: voiceLibraryId,
// 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
// BGM 配置:受 bgm 开关控制,enabled=false 时也显式传覆盖模板 BGM
bgm_config: {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
@@ -113,8 +118,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
: {}),
})
const taskId = taskResp.items?.[0]?.id
// 从创建响应直接拿 task_id,改用新接口轮询
const taskId = taskResp.items?.[0]?.id
if (!taskId) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
@@ -1,63 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react"
/**
* 持久化到 localStorage 的 state hook
*
* 用于在页面刷新后恢复 previewTaskId / sourceEditPlanId 等关键状态。
* 当 localStorage 不可用(SSR、隐私模式等)时自动降级为普通 useState。
* 当 key 变化时(例如切换模板),自动从新 key 重新读取并更新 state。
*/
export function usePersistedState<T>(
key: string | null | undefined,
defaultValue: T,
): [T, (value: T | ((prev: T) => T)) => void] {
const storageKey = key ? `xiaoxia_${key}` : null
// defaultValue 用 ref 持有,避免作为 useEffect 依赖导致频繁重跑
const defaultValueRef = useRef(defaultValue)
defaultValueRef.current = defaultValue
const readFromStorage = useCallback((k: string | null): T => {
if (!k) return defaultValueRef.current
try {
const stored = localStorage.getItem(k)
if (stored !== null) {
return JSON.parse(stored) as T
}
} catch (e) {
// localStorage 不可用或 JSON 解析失败,使用默认值
console.warn("[usePersistedState] 读取 localStorage 失败:", e)
}
return defaultValueRef.current
}, [])
const [state, setState] = useState<T>(() => readFromStorage(storageKey))
// key 变化时(如切换模板/草稿),从新 key 重新读取,避免状态与存储不同步
useEffect(() => {
setState(readFromStorage(storageKey))
}, [storageKey, readFromStorage])
const setPersistedState = useCallback(
(value: T | ((prev: T) => T)) => {
setState((prev) => {
const nextValue = typeof value === "function" ? (value as (prev: T) => T)(prev) : value
if (storageKey) {
try {
if (nextValue === null || nextValue === undefined || nextValue === "") {
localStorage.removeItem(storageKey)
} else {
localStorage.setItem(storageKey, JSON.stringify(nextValue))
}
} catch (e) {
// localStorage 写入失败(存储空间满/隐私模式),静默忽略
console.warn("[usePersistedState] 写入 localStorage 失败:", e)
}
}
return nextValue
})
},
[storageKey],
)
return [state, setPersistedState]
}
@@ -1,294 +0,0 @@
/**
* 服务器渲染预览 Hook
*
* 核心职责:
* 1. 调用 POST /generation/preview 创建服务器预览渲染任务
* 2. 轮询 GET /generation/preview/{task_id} 直到完成
* 3. 返回服务器渲染的真实视频 URL(供 <video> 标签播放)
* 4. 检测配置变更,标记预览失效(stale)或自动重新渲染
* 5. 网络错误自动重试 2 次
*
* 状态机:
* idle → loading → ready → stale (config changed)
* ↘ failed → idle (retry)
*/
import { useState, useCallback, useRef, useEffect } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import type { CreatePreviewRequest } from "@/api/generation/types"
export type ServerPreviewStatus = "idle" | "loading" | "ready" | "stale" | "failed"
interface UseServerPreviewOptions {
/** 是否启用预览(Step4+ 且有素材和模板时) */
enabled: boolean
/** 构建预览请求参数(每次 render 调用,获取最新配置) */
buildRequest: () => CreatePreviewRequest
/** 预览任务创建成功回调 */
onPreviewTaskCreated?: (taskId: string, sourceEditPlanId?: string) => void
}
interface UseServerPreviewReturn {
status: ServerPreviewStatus
videoUrl: string | null
error: string | null
/** 进度 0-100 */
progress: number
/** 手动触发预览创建("重新预览"按钮或标题变更后手动刷新) */
triggerPreview: () => void
/** 当前预览任务 ID */
taskId: string | null
}
const POLL_INTERVAL = 2000
const POLL_TIMEOUT = 120_000
const MAX_NETWORK_RETRIES = 2
/**
* 对配置参数做指纹,用于检测配置是否变化
*/
function buildFingerprint(req: CreatePreviewRequest): string {
return JSON.stringify({
t: req.template_id,
a: [...req.asset_ids].sort(),
d: req.duration,
r: req.video_ratio,
v: req.voice_library_id,
b: req.bgm_config,
title: req.title_config,
})
}
export function useServerPreview({
enabled,
buildRequest,
onPreviewTaskCreated,
}: UseServerPreviewOptions): UseServerPreviewReturn {
const [status, setStatus] = useState<ServerPreviewStatus>("idle")
const [videoUrl, setVideoUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [taskId, setTaskId] = useState<string | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestSeqRef = useRef(0)
const renderedFingerprintRef = useRef<string>("")
const mountedRef = useRef(true)
const networkRetriesRef = useRef(0)
// 始终持有最新的 buildRequest 和回调
const buildRequestRef = useRef(buildRequest)
buildRequestRef.current = buildRequest
const onCreatedRef = useRef(onPreviewTaskCreated)
onCreatedRef.current = onPreviewTaskCreated
/* ── 清理 ── */
const clearTimers = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current)
pollTimerRef.current = null
}
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
clearTimers()
}
}, [clearTimers])
/* ── 创建预览 + 轮询 ── */
const createAndPoll = useCallback(
async (request: CreatePreviewRequest, seq: number) => {
setStatus("loading")
setProgress(0)
setError(null)
networkRetriesRef.current = 0
try {
const resp = await createPreview(request)
if (seq !== requestSeqRef.current || !mountedRef.current) return
setTaskId(resp.task_id)
onCreatedRef.current?.(resp.task_id, resp.source_edit_plan_id)
let completed = false
// 超时保护
timeoutTimerRef.current = setTimeout(() => {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
completed = true
clearTimers()
setStatus("failed")
setError("预览渲染超时(120秒),请重试")
}, POLL_TIMEOUT)
const poll = async () => {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
try {
const st = await getPreviewStatus(resp.task_id)
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (st.status === "completed" && st.video_url) {
completed = true
clearTimers()
renderedFingerprintRef.current = buildFingerprint(request)
setVideoUrl(st.video_url)
setProgress(100)
setStatus("ready")
setError(null)
return
}
if (st.status === "failed" || st.status === "cancelled") {
completed = true
clearTimers()
setStatus("failed")
setError(
st.status === "cancelled"
? "预览任务已取消"
: st.error_message || "预览渲染失败,请重试",
)
return
}
// pending / generating
if (typeof st.progress === "number") setProgress(st.progress)
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL)
} catch (pollErr) {
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (networkRetriesRef.current < MAX_NETWORK_RETRIES) {
networkRetriesRef.current += 1
console.warn(
`[ServerPreview] 轮询网络错误,第 ${networkRetriesRef.current} 次重试`,
pollErr,
)
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL * 2)
} else {
completed = true
clearTimers()
setStatus("failed")
setError("网络错误,无法获取预览状态,请重试")
}
}
}
poll()
} catch (createErr) {
if (seq !== requestSeqRef.current || !mountedRef.current) return
console.error("[ServerPreview] 创建预览任务失败:", createErr)
const isNetworkError =
!!(createErr as { request?: unknown })?.request ||
(createErr as { code?: string })?.code === "ERR_NETWORK"
if (isNetworkError && networkRetriesRef.current < MAX_NETWORK_RETRIES) {
networkRetriesRef.current += 1
console.warn(`[ServerPreview] 创建任务网络错误,第 ${networkRetriesRef.current} 次重试`)
setTimeout(() => {
if (seq === requestSeqRef.current && mountedRef.current) {
createAndPoll(request, seq)
}
}, POLL_INTERVAL * 2)
return
}
const errData = (
createErr as { response?: { data?: { detail?: string; message?: string } } }
)?.response?.data
setStatus("failed")
setError(errData?.detail || errData?.message || "预览任务创建失败,请重试")
}
},
[clearTimers],
)
/* ── 手动触发预览 ── */
const triggerPreview = useCallback(() => {
if (!enabled) return
const request = buildRequestRef.current()
if (!request.template_id || request.asset_ids.length === 0) return
clearTimers()
const seq = ++requestSeqRef.current
setVideoUrl(null)
setTaskId(null)
createAndPoll(request, seq)
}, [enabled, clearTimers, createAndPoll])
/* ── 自动触发 + 配置变更检测 ── */
// 每次 render 都检查最新配置 fingerprint,与已渲染的 fingerprint 比较
const request = enabled ? buildRequest() : null
const currentFingerprint = request
? request.template_id && request.asset_ids.length > 0
? buildFingerprint(request)
: ""
: ""
// 首次进入自动触发
const didInitRef = useRef(false)
useEffect(() => {
if (!enabled || !currentFingerprint) {
didInitRef.current = false
// 禁用时取消进行中的轮询,避免回到前序步骤后仍在后台轮询
requestSeqRef.current += 1
clearTimers()
return
}
if (!didInitRef.current) {
didInitRef.current = true
renderedFingerprintRef.current = currentFingerprint
triggerPreview()
}
}, [enabled, currentFingerprint, triggerPreview, clearTimers])
// 配置变更检测:素材/配音/BGM 等变化 → 自动重渲染;标题样式变化 → 标记 stale
const prevFingerprintRef = useRef(currentFingerprint)
useEffect(() => {
if (!enabled || !currentFingerprint) return
const prev = prevFingerprintRef.current
prevFingerprintRef.current = currentFingerprint
if (!prev || prev === currentFingerprint) return
if (currentFingerprint === renderedFingerprintRef.current) return
// 配置已变更
// 判断是标题样式变更还是素材/配音/BGM 变更
const prevParsed = JSON.parse(prev) as Record<string, unknown>
const currParsed = JSON.parse(currentFingerprint) as Record<string, unknown>
const nonTitleChanged =
prevParsed.t !== currParsed.t ||
prevParsed.a !== currParsed.a ||
prevParsed.d !== currParsed.d ||
prevParsed.r !== currParsed.r ||
prevParsed.v !== currParsed.v ||
JSON.stringify(prevParsed.b) !== JSON.stringify(currParsed.b)
if (nonTitleChanged) {
// 素材/配音/BGM/模板等变化 → 自动重新渲染
renderedFingerprintRef.current = currentFingerprint
triggerPreview()
} else {
// 仅标题文字/样式变化 → 标记 stale,不自动重渲染(避免频繁请求)
// 实时预览由 CSS TitleOverlay 提供
setStatus((s) => (s === "ready" ? "stale" : s))
}
}, [enabled, currentFingerprint, triggerPreview])
return {
status,
videoUrl,
error,
progress,
triggerPreview,
taskId,
}
}
export default useServerPreview
@@ -3,8 +3,6 @@
* 组合素材库加载 + 智能匹配两个子 Hook
*/
import { useCallback, useEffect, useRef } from "react"
import type { TemplateSegment } from "@/api/templates/types"
import { updateEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
import { formatDuration } from "../utils/formatDuration"
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
@@ -19,8 +17,6 @@ interface UseStep2MaterialsProps {
onSmartSelectedIdsChange: (ids: string[]) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
/** 当前模板的 segments(用于构建 clips duration) */
templateSegments?: TemplateSegment[]
}
export function useStep2Materials({
@@ -31,7 +27,6 @@ export function useStep2Materials({
smartSelectedIds,
onSmartSelectedIdsChange,
selectedTemplate,
templateSegments,
}: UseStep2MaterialsProps) {
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
useMaterialLibrary()
@@ -62,7 +57,7 @@ export function useStep2Materials({
handleSmartMatch()
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
/* ── Step2 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */
/* ── Step2 选择素材后自动保存草稿(防抖 500ms,失败静默) ── */
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
useEffect(() => {
if (!selectedTemplate) return
@@ -70,62 +65,6 @@ export function useStep2Materials({
scheduleSave({ asset_ids: ids }, 500)
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
/* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ──
* 调用后端 POST /clips/from-assets,由后端处理:
* - 素材不够时同一素材切多个片段
* - 随机 start_time,不重复
* - required_clips_count 保证片段数与模板 segments 一致
* 先 PUT /clips(空数组)清空旧片段,再调用 from-assets 创建新片段
*/
const clipsTimerRef = useRef<ReturnType<typeof setTimeout>>()
const clipsAbortRef = useRef<AbortController | null>(null)
const templateSegmentsRef = useRef(templateSegments)
templateSegmentsRef.current = templateSegments
const selectedTemplateRef = useRef(selectedTemplate)
selectedTemplateRef.current = selectedTemplate
useEffect(() => {
const tid = selectedTemplateRef.current
if (!tid) return
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
if (!ids.length) return
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
clipsTimerRef.current = setTimeout(async () => {
// 取消上一次未完成的请求
if (clipsAbortRef.current) clipsAbortRef.current.abort()
const controller = new AbortController()
clipsAbortRef.current = controller
const segs = templateSegmentsRef.current || []
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
try {
// 1. 清空旧片段
await updateEditPlanClips(tid, [], controller.signal)
// 2. 调用后端 from-assets 接口创建片段
await createClipsFromAssets(tid, ids, "main", requiredClipsCount)
} catch (err) {
const name = (err as { name?: string })?.name
if (name !== "CanceledError" && name !== "AbortError") {
console.warn("[useStep2Materials] 写入 clips 失败:", err)
}
}
}, 800)
return () => {
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
}
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, templateSegments])
// 组件卸载时取消未完成请求
useEffect(() => {
return () => {
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
if (clipsAbortRef.current) clipsAbortRef.current.abort()
}
}, [])
/* ── 手动选择素材 ── */
const handleToggleMaterial = useCallback(
(materialId: string) => {
@@ -2,7 +2,7 @@
* Step 6 封面设置 Hook
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
*/
import { useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { message } from "antd"
import type { CoverConfig, CoverTemplate } from "../types/cover"
import { generateCover } from "@/api/generation"
@@ -26,20 +26,6 @@ interface UseStep6CoverProps {
selectedTemplate?: string
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
titleSettings?: TitleSettings
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
onPreviewTaskCreated?: (taskId: string) => void
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
onSourceEditPlanIdExtracted?: (planId: string) => void
/** 配音模式 */
voiceMode?: "preset" | "custom" | "clone"
/** 选中的配音素材 ID(配音素材库 asset ID) */
selectedVoice?: string
/** 选中的克隆音色 ID */
selectedClonedVoice?: string
/** BGM 开关 */
bgm?: boolean
/** BGM 配置(来自模板) */
bgmConfig?: { enabled: boolean; music_id?: string }
}
export function useStep6Cover({
@@ -49,17 +35,8 @@ export function useStep6Cover({
assetIds = [],
selectedTemplate = "",
titleSettings,
onPreviewTaskCreated,
onSourceEditPlanIdExtracted,
voiceMode,
selectedVoice,
selectedClonedVoice,
bgm,
bgmConfig,
}: UseStep6CoverProps) {
const [generating, setGenerating] = useState(false)
// 防竞态:记录当前预览生成的参数指纹,任务完成时校验一致性
const previewParamsRef = useRef<string>("")
// ── 封面设置弹窗状态 ──
const [showCoverSettings, setShowCoverSettings] = useState(false)
@@ -174,24 +151,10 @@ export function useStep6Cover({
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
message.info("正在准备预览视频,请稍候...")
try {
// 记录当前参数指纹,用于任务完成时校验一致性(防竞态)
previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
const previewVoiceLibraryId =
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
const previewResp = await createPreview({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: duration || 30,
// 配音:始终传递 voice_library_id,确保后端能正确接收
voice_library_id: previewVoiceLibraryId,
// 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
...(selectedVoice && !previewVoiceLibraryId ? { voice_ids: [selectedVoice] } : {}),
// BGM 配置:受 bgm 开关控制
bgm_config: {
enabled: bgm !== false,
...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}),
},
...(titleSettings?.title
? {
title_config: {
@@ -207,15 +170,6 @@ export function useStep6Cover({
}
: {}),
})
// 将预览任务 ID 暴露给父组件,供 Step7 确认生成时复用(confirmGeneration)
const currentFingerprint = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
if (previewResp.task_id && previewParamsRef.current === currentFingerprint) {
onPreviewTaskCreated?.(previewResp.task_id)
// 提取后端自动关联的 source_edit_plan_id,供 fallback 路径使用
if (previewResp.source_edit_plan_id) {
onSourceEditPlanIdExtracted?.(previewResp.source_edit_plan_id)
}
}
// 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
await new Promise<void>((resolve, reject) => {
let finished = false
@@ -328,13 +282,6 @@ export function useStep6Cover({
generating,
duration,
titleSettings,
onPreviewTaskCreated,
onSourceEditPlanIdExtracted,
voiceMode,
selectedVoice,
selectedClonedVoice,
bgm,
bgmConfig,
])
// ── 模板操作方法 ──
@@ -25,6 +25,8 @@ interface UseStep7GenerateProps {
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generateCount: number
onGenerateCountChange: (count: number) => void
generating: boolean
generated: boolean
generateError: string | null
@@ -45,6 +47,8 @@ export function useStep7Generate({
presetVoices: _presetVoices,
clonedVoices: _clonedVoices,
coverSettings,
generateCount,
onGenerateCountChange,
generating,
generated,
generateError,
@@ -86,6 +90,14 @@ export function useStep7Generate({
return { label: "即将完成", icon: "✨" }
}
const handleDecrement = () => {
onGenerateCountChange(Math.max(1, generateCount - 1))
}
const handleIncrement = () => {
onGenerateCountChange(Math.min(10, generateCount + 1))
}
const handleScrollToPreview = () => {
const el = document.querySelector(".xx-preview-section")
el?.scrollIntoView({ behavior: "smooth", block: "start" })
@@ -97,6 +109,9 @@ export function useStep7Generate({
title,
voiceName,
coverSummary,
generateCount,
handleDecrement,
handleIncrement,
generating,
generated,
generateError,
@@ -3,7 +3,7 @@
* 管理步骤切换与各步骤的前置校验
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*
* 前端实时预览架构:Step5 无需等待服务器渲染
* V24: previewReady 改为前端素材加载状态
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -16,6 +16,8 @@ export interface UseStepNavigationOptions {
selectedMaterials: string[]
smartSelectedIds: string[]
titleSettings: TitleSettings
/** 预览是否就绪(前端素材已加载) */
previewReady: boolean
}
export interface UseStepNavigationReturn {
@@ -32,6 +34,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady,
} = options
const goNext = () => {
@@ -47,10 +50,15 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
// Step3 配音:配音为可选项,不强制校验,用户可跳过
if (currentStep === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep === 5 && !previewReady) {
message.warning("请先选择素材以预览效果")
return
}
if (currentStep < 7) {
setCurrentStep((s) => s + 1)
}
@@ -1,46 +0,0 @@
/**
* 根据视频比例计算输出分辨率
*
* 规则:
* - 长边固定 1920
* - 短边按 1920 × (短/长) 计算,对齐到偶数
* - 9:16 → 1080×1920(竖屏)
* - 16:9 → 1920×1080(横屏)
* - 1:1 → 1920×1920
* - 无法解析时 fallback 到 1080×1920
*/
export interface Resolution {
width: number
height: number
}
export function calculateResolution(ratio: string): Resolution {
if (!ratio) return { width: 1080, height: 1920 }
if (ratio.includes(":")) {
const [rw, rh] = ratio.split(":").map(Number)
if (rw > 0 && rh > 0) {
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
const baseLong = 1920
const baseShort = Math.round((baseLong * shortSide) / longSide)
const evenShort = baseShort - (baseShort % 2)
if (rw < rh) {
// 竖屏:短边是宽,长边是高
return { width: evenShort, height: baseLong }
} else {
// 横屏:长边是宽,短边是高
return { width: baseLong, height: evenShort }
}
}
return { width: 1080, height: 1920 }
}
if (ratio.includes("x")) {
const [wStr, hStr] = ratio.split("x")
const w = parseInt(wStr, 10) || 1080
const h = parseInt(hStr, 10) || 1920
return { width: w, height: h }
}
return { width: 1080, height: 1920 }
}
@@ -87,7 +87,7 @@ export const useTemplateLibrary = () => {
[copyMutation],
)
/* 操作:使用模板 → 跳转剪辑模板 */
/* 操作:使用模板 → 跳转剪辑编辑器 */
const handleUse = useCallback(
(template: TemplateItem) => {
navigate(`/app/editing-planner?templateId=${template.id}`)
+51
View File
@@ -2,7 +2,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"
import {
getEditPlan,
updateEditPlan,
generateEditPlan,
getGenerationStatus,
aiRecommendClips,
getEditPlanGenerations,
getGenerationTaskResults,
getEditPlanClips,
getEditPlanClip,
@@ -77,6 +80,38 @@ describe("editPlans API", () => {
})
})
describe("generateEditPlan", () => {
it("should resolve successfully", async () => {
await expect(generateEditPlan("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(generateEditPlan("test-planId")).rejects.toThrow()
})
})
describe("getGenerationStatus", () => {
it("should resolve successfully", async () => {
await expect(getGenerationStatus("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(getGenerationStatus("test-planId")).rejects.toThrow()
})
})
describe("aiRecommendClips", () => {
it("should resolve successfully", async () => {
await expect(aiRecommendClips("test-planId")).resolves.not.toThrow()
@@ -93,6 +128,22 @@ describe("editPlans API", () => {
})
})
describe("getEditPlanGenerations", () => {
it("should resolve successfully", async () => {
await expect(getEditPlanGenerations("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(getEditPlanGenerations("test-planId")).rejects.toThrow()
})
})
describe("getGenerationTaskResults", () => {
it("should resolve successfully", async () => {
await expect(getGenerationTaskResults("test-taskId")).resolves.not.toThrow()
@@ -148,8 +148,11 @@ vi.mock("@/api/editing-planner", () => ({
vi.mock("@/api/template-editor", () => ({
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
getEditPlan: vi.fn().mockResolvedValue({}),
updateEditPlan: vi.fn().mockResolvedValue({}),
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
createEditPlanClip: vi.fn().mockResolvedValue({}),
@@ -220,6 +223,12 @@ vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
}))
vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({
default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }),
}))
vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({
default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }),
}))
// === useUndoRedo hook mock ===
vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({
@@ -214,6 +214,12 @@ vi.mock("@/api/titles", () => ({
}))
vi.mock("@/api/template-editor", () => ({
generateEditPlan: vi.fn().mockResolvedValue({
plan_id: "test-plan",
generation_task_id: "test-task",
plan_status: "processing",
clip_count: 5,
}),
updateEditPlan: vi.fn().mockResolvedValue({ plan_id: "test-plan", template_id: "test-template" }),
getEditPlan: vi.fn().mockResolvedValue({
plan_id: "test-plan",
@@ -222,6 +228,9 @@ vi.mock("@/api/template-editor", () => ({
config: {},
status: "draft",
}),
getGenerationStatus: vi
.fn()
.mockResolvedValue({ plan_status: "completed", generation_task_id: "test-task", clips: [] }),
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
}))
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import GenerationHistoryModal from "@/pages/editing-planner/components/GenerationHistoryModal"
const defaultProps = {
open: true,
loading: false,
history: [],
onClose: vi.fn(),
onCancel: vi.fn(),
cancelLoading: false,
}
describe("GenerationHistoryModal", () => {
it("should render without crashing", () => {
const { container } = render(<GenerationHistoryModal {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should not render when open is false", () => {
const { container } = render(<GenerationHistoryModal {...defaultProps} open={false} />)
expect(container.firstChild).toBeNull()
})
it("should render with history items", () => {
const history = [
{
id: "1",
status: "completed",
created_at: "2024-01-01T00:00:00Z",
duration: 60,
},
]
const { container } = render(
<GenerationHistoryModal {...defaultProps} history={history as any} />,
)
expect(container).toBeTruthy()
})
})
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import GenerationProgressModal from "@/pages/editing-planner/components/GenerationProgressModal"
const baseProps = {
open: true,
voiceoverDuration: null,
estimatedDuration: 60,
onDurationChange: vi.fn(),
onGenerate: vi.fn(),
task: null,
submitting: false,
onCancel: vi.fn(),
onRetry: vi.fn(),
onClose: vi.fn(),
}
describe("GenerationProgressModal", () => {
it("should render setup phase", () => {
const { container } = render(<GenerationProgressModal {...baseProps} phase="setup" />)
expect(container).toBeTruthy()
})
it("should render progress phase without task", () => {
const { container } = render(<GenerationProgressModal {...baseProps} phase="progress" />)
expect(container).toBeTruthy()
})
it("should render progress phase with task data", () => {
const { container } = render(
<GenerationProgressModal
{...baseProps}
phase="progress"
task={
{
id: "task-123",
status: "generating_video",
progress: 50,
current_step: "generating_video",
user_message: "正在生成视频",
} as any
}
/>,
)
expect(container).toBeTruthy()
})
it("should render completed phase", () => {
const { container } = render(
<GenerationProgressModal
{...baseProps}
phase="completed"
task={{ id: "task-1", status: "completed", progress: 100 } as any}
/>,
)
expect(container).toBeTruthy()
})
it("should render failed phase with retry", () => {
const { container } = render(
<GenerationProgressModal
{...baseProps}
phase="failed"
task={
{
id: "task-1",
status: "failed",
progress: 30,
error_message: "生成失败",
retryable: true,
} as any
}
/>,
)
expect(container).toBeTruthy()
})
it("should render failed phase without retry", () => {
const { container } = render(
<GenerationProgressModal
{...baseProps}
phase="failed"
task={
{
id: "task-1",
status: "failed",
progress: 30,
retryable: false,
} as any
}
/>,
)
expect(container).toBeTruthy()
})
it("should not render when closed", () => {
const { container } = render(
<GenerationProgressModal {...baseProps} phase="setup" open={false} />,
)
expect(container).toBeTruthy()
})
})
@@ -31,6 +31,8 @@ import "@/pages/editing-planner/components/ClipPropertiesPanel"
import "@/pages/editing-planner/components/EditorClipList"
import "@/pages/editing-planner/components/EditingDrawers"
import "@/pages/editing-planner/components/FilterPanel"
import "@/pages/editing-planner/components/GenerationHistoryModal"
import "@/pages/editing-planner/components/GenerationProgressModal"
import "@/pages/editing-planner/components/GreenScreenPanel"
import "@/pages/editing-planner/components/IntroOutroPanel"
import "@/pages/editing-planner/components/MediaPanel"
@@ -84,7 +84,6 @@ class RenderAdapterResult:
cover_candidates: list[dict] | None = (
None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
)
temp_dir: str | None = None # 渲染临时目录,成功时由调用方清理,失败时由 finally 清理
def __post_init__(self):
if self.rendered_clip_ids is None:
@@ -201,7 +200,7 @@ class RenderAdapter:
self._report_progress(progress_cb, 35.0, "准备 BGM 音频")
# 3~6. 统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)
result = self._do_render(
return self._do_render(
plan=plan,
clips=ready_clips,
asset_path_map=asset_path_map,
@@ -213,11 +212,6 @@ class RenderAdapter:
failed_clip_ids=failed_clip_ids,
voiceover_audio_path=voiceover_audio_path,
)
# 成功时将临时目录所有权转移给调用方,阻止 finally 清理
if result.success and temp_dir:
result.temp_dir = temp_dir
temp_dir = None # 阻止 finally 块清理
return result
except subprocess.CalledProcessError as exc:
stderr_text = (exc.stderr or "").strip()
+32 -97
View File
@@ -67,16 +67,6 @@ def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool:
return ctx._audio_cache[key]
def _clip_volume(clip: ResolvedClip) -> float:
"""读取 clip 的音量配置(0.0~1.0,>1 放大)。缺省 1.0 原声。"""
cfg = getattr(clip, "config", None) or {}
try:
vol = float(cfg.get("volume", 1.0))
except (TypeError, ValueError):
return 1.0
return max(0.0, vol)
# ── 音频混音 ──────────────────────────────────────────────────────────────────
@@ -92,13 +82,12 @@ def mix_audio(
"""音频后处理混音.
处理逻辑:
1. 保留主图层(main/broll/overlay/corner_voice)视频素材的原声,按顺序 concat 拼接
2. 每个 clip 按 config.volume 应用音量(volume=0 静音,=1 原声)
3. 独立音频轨(audio role,TTS/配音)通过 amix 混入
4. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
5. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
6. 输出时长截断到 video_duration
7. 如果配置了降噪,最后应用降噪
1. 丢弃主图层(main/broll/overlay/corner_voice)的原始音频,避免录入源视频杂音
2. 仅使用独立音频轨(audio role,TTS/配音)作为主音频
3. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
4. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
5. 输出时长截断到 video_duration
6. 如果配置了降噪,最后应用降噪
Args:
ctx: 渲染上下文
@@ -134,12 +123,10 @@ def mix_audio(
if "audio" in layer_map:
audio_clips = layer_map["audio"].clips
# ── 保留源视频原声:过滤掉无音频流的 main clip(图片/无声素材) ──
# 注意:volume=0 的 clip 不能移除——移除会导致后续 clip 音频时间轴前移、音画不同步。
# volume=0 通过滤镜链生成静音流,保持时间轴对齐。
main_clips = [c for c in main_clips if clip_has_audio(ctx, c)]
# ── 丢弃源视频的原始音频(避免录入杂音),成片仅保留 TTS 配音 + BGM ──
main_clips = []
# ── 防御:过滤掉无音频流的独立音频轨 ──
# ── 防御:过滤掉无音频流的 clip ──
audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)]
if not main_clips and not audio_clips:
@@ -157,7 +144,7 @@ def mix_audio(
# 构建音频处理命令
output_path = ctx.work_dir / f"audio_{ctx.plan_id}.aac"
# 主音频为视频素材原声 concat;独立音频轨(TTS/配音)通过 amix 混入。
# 源视频原始音频已被丢弃(main_clips = []),最终音频完全由独立音频轨 + BGM + 多轨配置组成。
# 当无 main_clips 时,将独立音频轨作为主音频走 concat 拼接;当二者均有则走 amix 混音。
if main_clips:
effective_main = main_clips
@@ -279,67 +266,28 @@ def concat_main_audio(
has_speed = abs(speed - 1.0) >= 1e-6
if not has_speed and not has_reverse:
# 无调速无倒放:根据是否需要裁剪/音量选择最高效的路径。
vol = _clip_volume(clip)
need_trim = trim_start > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
need_volume = abs(vol - 1.0) >= 1e-6
if need_trim:
# 需要裁剪:用 atrim 滤镜在滤镜链中精确裁剪(采样点级精度,不浪费解码)。
# 滤镜顺序:atrim → asetpts → volume(先裁剪再调音量,避免处理被丢弃的数据)。
af_parts: list[str] = []
if trim_start > 0 and effective_duration > 0:
af_parts.append(f"atrim=start={trim_start:.3f}:duration={final_duration:.3f}")
elif trim_start > 0:
af_parts.append(f"atrim=start={trim_start:.3f}")
elif final_duration > 0:
af_parts.append(f"atrim=duration={final_duration:.3f}")
af_parts.append("asetpts=PTS-STARTPTS")
if need_volume:
af_parts.append(f"volume={vol:.4f}")
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-af",
",".join(af_parts),
"-acodec",
"aac",
"-b:a",
"128k",
"-ar",
"48000",
"-ac",
"2",
]
# atrim 已精确控制时长,无需额外 -t
command.append(str(output_path))
run_ffmpeg(command)
else:
# 无需裁剪:直接提取,最高效。音量用单个 -af(如有)。
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
"-ar",
"48000",
"-ac",
"2",
]
if need_volume:
command.extend(["-af", f"volume={vol:.4f}"])
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
# 无调速无倒放:简单命令行,-ss 裁剪更高效
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
"-ar",
"48000",
"-ac",
"2",
]
if trim_start > 0:
command.extend(["-ss", f"{trim_start:.3f}"])
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
else:
# 有调速或倒放:用 filter_complex
speed_engine = SpeedEngine()
@@ -364,11 +312,6 @@ def concat_main_audio(
if reverse_filter:
audio_filters.append(reverse_filter)
# 音量
vol = _clip_volume(clip)
if abs(vol - 1.0) >= 1e-6:
audio_filters.append(f"volume={vol:.4f}")
# aformat 归一化:统一输出格式为 48000Hz + stereo + fltp
audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
@@ -435,11 +378,6 @@ def concat_main_audio(
if reverse_filter:
audio_filters.append(reverse_filter)
# 音量(0=静音,1=原声)
vol = _clip_volume(clip)
if abs(vol - 1.0) >= 1e-6:
audio_filters.append(f"volume={vol:.4f}")
# aformat 归一化:统一采样率48000Hz + 双声道stereo + fltp采样格式
# concat filter 要求所有输入音频参数完全一致,否则 exit=234 失败
audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
@@ -512,9 +450,6 @@ def mix_with_independent_audio(
clip_filters.append("asetpts=PTS-STARTPTS")
else:
clip_filters.append("asetpts=PTS-STARTPTS")
vol = _clip_volume(clip)
if abs(vol - 1.0) >= 1e-6:
clip_filters.append(f"volume={vol:.4f}")
# aformat 归一化:concat/amix 前统一音频参数,否则不同采样率/声道会失败
clip_filters.append(AFORMAT)
filter_parts.append(f"[{input_idx}:a]{','.join(clip_filters)}[ma{input_idx}]")
@@ -204,76 +204,6 @@ def generate_and_upload_thumbnail(
Path(tmp.name).unlink(missing_ok=True)
def _extract_frames_via_mediakit(
video_path: str,
plan_id: str,
num_frames: int,
) -> list[dict] | None:
"""使用 MediaKit 智能抽帧 API 提取封面帧。
Args:
video_path: 本地视频文件路径
plan_id: 编辑计划 ID
num_frames: 需要的帧数
Returns:
帧列表 [{"image_url": str, "timestamp": float}, ...],失败返回 None
"""
import uuid
from video_processing.oss_helpers import upload_to_oss
from packages.shared.mediakit_client import get_mediakit_client
client = get_mediakit_client()
if not client.is_available:
logger.info("[thumbnail] MediaKit 未配置,跳过智能抽帧")
return None
# 1. 上传视频到 OSS 获取 URL
try:
video_storage_key = f"temp/{plan_id}/{uuid.uuid4().hex[:8]}_{Path(video_path).name}"
video_url = upload_to_oss(video_path, video_storage_key)
if not video_url:
logger.warning("[thumbnail] 视频上传 OSS 失败,无法使用 MediaKit")
return None
logger.info("[thumbnail] 视频已上传 OSS: %s", video_url[:80])
except Exception as e:
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到 ffmpeg", e)
return None
# 2. 调用 MediaKit 智能抽帧
try:
frames = client.extract_frames(
video_url=video_url,
strategy="SceneChange",
max_frames=num_frames * 2, # 多取一些帧供选择
)
if not frames:
logger.warning("[thumbnail] MediaKit 抽帧返回空,降级到 ffmpeg")
return None
# 选取最均匀的 num_frames 个帧
if len(frames) > num_frames:
step = len(frames) // num_frames
frames = [frames[i * step] for i in range(num_frames)]
logger.info("[thumbnail] MediaKit 抽帧成功: %d 帧", len(frames))
return frames
except Exception as e:
logger.warning("[thumbnail] MediaKit 抽帧异常: %s,降级到 ffmpeg", e)
return None
finally:
# 清理临时视频文件
try:
from video_processing.oss_helpers import delete_from_oss
delete_from_oss(video_storage_key)
except Exception:
pass
def extract_and_upload_cover_frames(
video_path: str,
plan_id: str,
@@ -286,8 +216,6 @@ def extract_and_upload_cover_frames(
) -> list[dict]:
"""从视频中抽取多帧作为封面候选,上传到 OSS。
优先使用 MediaKit 智能抽帧,失败时降级到 ffmpeg 直接抽帧。
Args:
video_path: 视频文件路径
plan_id: 编辑计划 ID(用于生成 storage key)
@@ -301,7 +229,6 @@ def extract_and_upload_cover_frames(
Returns:
封面候选列表,每项包含 {"url": str, "position": float}
"""
import httpx
from video_processing.ffmpeg_utils import probe_duration
from video_processing.oss_helpers import upload_to_oss
@@ -311,51 +238,6 @@ def extract_and_upload_cover_frames(
duration = 0.0
candidates: list[dict] = []
# 优先尝试 MediaKit 智能抽帧
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
if mediakit_frames:
for i, frame in enumerate(mediakit_frames):
frame_url = frame.get("image_url")
if not frame_url:
continue
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
try:
# 下载 MediaKit 返回的帧图
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
with open(tmp.name, "wb") as f:
f.write(resp.content)
# 叠加标题文字(如需要)
if title_text and title_text.strip():
apply_title_overlay(
tmp.name,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
seek_time = frame.get("timestamp", 0.0)
candidates.append({"url": url, "position": round(seek_time, 2)})
except Exception as e:
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
finally:
Path(tmp.name).unlink(missing_ok=True)
if len(candidates) >= num_frames:
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d 帧", len(candidates))
return candidates[:num_frames]
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
# Fallback: ffmpeg 直接抽帧
logger.info("[thumbnail] 使用 ffmpeg 抽帧")
# 均匀分布抽帧点:从 10% 到 90%
for i in range(num_frames):
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
@@ -36,7 +36,6 @@ from video_processing.ffmpeg_utils import (
DEFAULT_TRANSITION_DURATION,
FFMPEG_BIN,
probe_duration,
probe_has_audio,
probe_video_info,
run_ffmpeg,
)
@@ -824,17 +823,6 @@ class UnifiedRenderService:
)
audio_layer.clips.append(vo_clip)
# replace 模式:静音原视频音轨(main + broll 图层)
if tts_config.overlap_mode == "replace":
for layer in layers:
if layer.role in ("main", "broll"):
for clip in layer.clips:
clip.config["volume"] = 0
logger.info(
"TTS replace 模式:已静音原视频音轨: plan_id=%s",
self.plan.id,
)
logger.info(
"TTS 配音已添加: plan_id=%s voice_id=%s segments=%d total_%.2fs",
self.plan.id,
@@ -900,16 +888,6 @@ class UnifiedRenderService:
)
audio_layer.clips.append(vo_clip)
# 配音素材库默认替换原音:静音原视频音轨(main + broll 图层)
for layer in layers:
if layer.role in ("main", "broll"):
for clip in layer.clips:
clip.config["volume"] = 0
logger.info(
"配音素材库:已静音原视频音轨: plan_id=%s",
self.plan.id,
)
logger.info(
"配音素材库音频已添加到 audio 图层: plan_id=%s duration=%.2fs",
self.plan.id,
@@ -1022,11 +1000,6 @@ class UnifiedRenderService:
if abs(speed - 1.0) >= 1e-6:
return False, f"有调速: speed={speed:.2f}x"
# 音量非默认(静音/放大)→ 需要音频滤镜重编码 → 不能 copy
vol = UnifiedRenderService._clip_volume(clip)
if abs(vol - 1.0) >= 1e-6:
return False, f"音量非默认: volume={vol:.2f}"
# 有倒放 → 需要重编码 → 不能 copy
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
if reverse_config.enabled and (reverse_config.reverse_video or reverse_config.reverse_audio):
@@ -1297,23 +1270,13 @@ class UnifiedRenderService:
"+faststart",
]
# 音频处理:background 通常是图片无音频,跳过;其他角色先探测是否真有音频流。
# volume=0 不丢弃音频流,而是保留后通过 volume=0 滤镜静音,保持时间轴对齐。
clip_volume = UnifiedRenderService._clip_volume(clip)
if role != "background":
try:
has_audio = probe_has_audio(clip.local_path)
except Exception as e:
# probe_has_audio 内部已保守返回 True;只有极端错误才会到这里。
# 此时不静默丢音频,记录 error 并向上抛出,让任务失败而不是产出无声视频。
logger.error("[unified-render] 探测音频流发生致命错误,终止渲染: %s: %s", clip.local_path, e)
raise
else:
has_audio = False
# 音频处理:background 通常是图片无音频,跳过;其他编码为 aac
# background 以外的视频素材,默认带音频
has_audio = role != "background"
if has_audio:
af_parts: list[str] = []
# 音频降噪(最先处理:在原始信号上降噪效果最好)
# 音频降噪
try:
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
@@ -1327,16 +1290,13 @@ class UnifiedRenderService:
except Exception as e:
logger.warning("[unified-render] 直通模式音频降噪应用失败,跳过: %s", e)
# 音频调速(在降噪之后、音量之前,与 render_audio.py concat 路径保持一致)
# SpeedEngine.build_audio_filter 内部已实现多级 atempo 串联,
# 自动处理超出 [0.5, 2.0] 范围的速度(如 0.25x → atempo=0.5,atempo=0.5)。
# 音频调速(与视频setpts对应,保持音画同步)
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) >= 1e-6:
try:
from video_processing.speed_engine import SpeedConfig, SpeedEngine
speed_cfg = SpeedConfig(speed=speed)
speed_cfg.clamp()
speed_engine = SpeedEngine()
af_parts.append(speed_engine.build_audio_filter(speed_cfg))
except Exception as e:
@@ -1349,10 +1309,6 @@ class UnifiedRenderService:
if af_filter:
af_parts.append(af_filter)
# 片段音量(最后应用:确保调速/倒放后的最终输出音量准确,与 concat 路径一致)
if abs(clip_volume - 1.0) >= 1e-6:
af_parts.append(f"volume={clip_volume:.4f}")
if af_parts:
command.extend(["-af", ",".join(af_parts)])
@@ -2020,16 +1976,6 @@ class UnifiedRenderService:
"""
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
@staticmethod
def _clip_volume(clip: ResolvedClip) -> float:
"""获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。"""
cfg = getattr(clip, "config", None) or {}
try:
vol = float(cfg.get("volume", 1.0))
except (TypeError, ValueError):
return 1.0
return max(0.0, vol)
@staticmethod
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
"""计算调速后的 clip 实际时长(用于拼接计算)。
File diff suppressed because it is too large Load Diff
-19875
View File
File diff suppressed because it is too large Load Diff
+2 -12
View File
@@ -12,22 +12,14 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debia
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 预装系统依赖(gcc 编译 psycopg/pg 扩展,libpq-dev 编译期,libpq5 运行期,ffmpeg 封面取帧)
# 字体修复:fonts-noto-cjk 包的 .ttc 文件混入了 Mono 变体,导致 Bold 匹配到等宽字体
# 解决方案:删除有问题的 .ttc,使用仓库内预下载的 Noto Sans SC Variable Font(不含 Mono)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
libpq5 \
ffmpeg \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/* \
# 删除有问题的 .ttc 文件(包含 Mono 变体)
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc \
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc
# 复制预下载的 Noto Sans SC Variable Font(包含所有字重,不含 Mono 变体)
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
RUN fc-cache -fv
fontconfig \
&& rm -rf /var/lib/apt/lists/*
# 创建虚拟环境
RUN python -m venv /opt/venv
@@ -51,5 +43,3 @@ RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
RUN rm -f /tmp/requirements-base.txt /tmp/requirements.txt
ENV PYTHONPATH=/app
# Rebuild trigger: ACR push retry 20260826
+2 -12
View File
@@ -12,8 +12,6 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debia
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 预装系统依赖(编译工具 + 运行时 + CJK 字体用于 ASS 字幕渲染)
# 字体修复:fonts-noto-cjk 包的 .ttc 文件混入了 Mono 变体,导致 Bold 匹配到等宽字体
# 解决方案:删除有问题的 .ttc,使用仓库内预下载的 Noto Sans SC Variable Font(不含 Mono)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
@@ -22,14 +20,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libglib2.0-0 \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/* \
# 删除有问题的 .ttc 文件(包含 Mono 变体)
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc \
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc
# 复制预下载的 Noto Sans SC Variable Font(包含所有字重,不含 Mono 变体)
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
RUN fc-cache -fv
&& fc-cache -fv \
&& rm -rf /var/lib/apt/lists/*
# 创建虚拟环境
RUN python -m venv /opt/venv
@@ -55,5 +47,3 @@ RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
RUN rm -f /tmp/requirements-base.txt /tmp/requirements.txt /tmp/requirements-worker.txt
ENV PYTHONPATH=/app:/app/packages
# Rebuild trigger: ACR push retry 20260826
Binary file not shown.
@@ -41,6 +41,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
output_width=getattr(model, "output_width", 1280) or 1280,
output_height=getattr(model, "output_height", 720) or 720,
cover_url=getattr(model, "cover_url", "") or "",
custom_title=getattr(model, "custom_title", "") or "",
title_config=dict(getattr(model, "title_config", {}) or {}),
logs=model.logs or "[]",
created_at=model.created_at,
@@ -85,6 +86,7 @@ class SQLAlchemyGenerationTaskRepository:
output_width=task.output_width,
output_height=task.output_height,
cover_url=task.cover_url or "",
custom_title=task.custom_title or "",
title_config=dict(task.title_config) if task.title_config else {},
logs=task.logs,
created_at=task.created_at,
@@ -272,6 +274,7 @@ class SQLAlchemyGenerationTaskRepository:
model.output_width = task.output_width
model.output_height = task.output_height
model.cover_url = task.cover_url or ""
model.custom_title = task.custom_title or ""
model.title_config = dict(task.title_config) if task.title_config else {}
model.logs = task.logs
self.session.commit()
+2
View File
@@ -31,6 +31,7 @@ class CreateGenerationTaskCommand:
output_width: int = 1280
output_height: int = 720
cover_url: str = ""
custom_title: str = ""
title_config: dict = field(default_factory=dict)
@@ -67,6 +68,7 @@ class CreateGenerationTaskUseCase:
output_width=command.output_width,
output_height=command.output_height,
cover_url=command.cover_url,
custom_title=command.custom_title,
title_config=command.title_config,
)
return self.generation_task_repository.create(task)
+32 -85
View File
@@ -20,32 +20,10 @@ logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# Title/Subtitle 默认边距(像素)
TITLE_MARGIN_TOP = 120
TITLE_MARGIN_TOP = 60
TITLE_MARGIN_BOTTOM = 60
TITLE_MARGIN_SIDE = 40
# 字体名称映射:前端中文字体名 → 服务器实际注册名(ffmpeg/ASS 通过注册名匹配字体)
FONT_NAME_MAP: dict[str, str] = {
"思源黑体": "Noto Sans SC",
"思源宋体": "Noto Serif CJK SC",
"苹方": "Noto Sans SC",
"PingFang": "Noto Sans SC",
"微软雅黑": "Noto Sans SC",
"楷体": "Noto Serif CJK SC",
"华康俪金黑": "Noto Sans SC",
}
# ASS Fontsize 是字体 em-square 高度(含 Latin 升降部留白),
# 中文字符实际只占声明字号的约 65%~75%;浏览器 CSS font-size 让中文字符占满声明高度。
# 为让成片中文字高与前端 CSS 预览一致,写入 ASS 时对字号乘以补偿系数。
# font_size=89 → ASS Fontsize=round(89*1.35)=120,实际中文字高约 78~85px。
ASS_FONTSIZE_COMPENSATION = 1.35
def _compensate_ass_fontsize(font_size: int) -> int:
"""将 CSS 语义字号换算为 ASS Fontsize,补偿中文字符在 em-square 中的留白。"""
return max(1, round(font_size * ASS_FONTSIZE_COMPENSATION))
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
@@ -139,20 +117,14 @@ def build_ass_style(
bold_val = -1 if bold else 0
italic_val = -1 if italic else 0
# 字体名称映射:前端中文字体名 → 服务器注册名,未命中则原样使用
actual_font = FONT_NAME_MAP.get(font_name, font_name)
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow)
back_color = primary_color
# Shadow 深度:shadow_offset[1] 作为纵向偏移
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
# 写入 ASS Style 时对字号做补偿,使成片中文字高与前端 CSS 预览一致
ass_font_size = _compensate_ass_fontsize(font_size)
return (
f"Style: {style_name},{actual_font},{ass_font_size},{primary_color},"
f"Style: {style_name},{font_name},{font_size},{primary_color},"
f"&H000000FF,{outline_color},{back_color},"
f"{bold_val},{italic_val},0,0,100,100,0,0,"
f"1,{outline_width},{shadow_depth},{alignment},"
@@ -175,8 +147,6 @@ def escape_ass_text(text: str) -> str:
Returns:
转义后的 ASS 文本
"""
# 用户手动换行符(半角/全角斜杠)转为 ASS 硬换行(在自动换行之前优先处理)
text = text.replace("/", "\\N").replace("/", "\\N")
# 将实际换行转为 ASS 硬换行
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
# 转义大括号(ASS 用它做样式覆盖标签)
@@ -205,6 +175,7 @@ def format_ass_time(seconds: float) -> str:
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
def _wrap_title_text(
text: str,
video_width: int,
@@ -224,36 +195,26 @@ def _wrap_title_text(
if available_width <= 0:
return text
# 换行计算使用原始 font_size,与 CSS 预览一致;1.35x 补偿仅用于 ASS Fontsize 渲染
lines: list[str] = []
current_line = ""
current_width = 0.0
for ch in text:
# CJK 字符按全角估算,其他按半角
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
# 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回
segments = text.split("\\N")
wrapped_segments: list[str] = []
for seg in segments:
lines: list[str] = []
current_line = ""
current_width = 0.0
for ch in seg:
# CJK 字符按全角估算,其他按半角
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
if current_width + char_width > available_width and current_line:
lines.append(current_line)
current_line = ch
current_width = char_width
else:
current_line += ch
current_width += char_width
if current_line:
if current_width + char_width > available_width and current_line:
lines.append(current_line)
current_line = ch
current_width = char_width
else:
current_line += ch
current_width += char_width
wrapped_segments.append("\\N".join(lines))
if current_line:
lines.append(current_line)
return "\\N".join(wrapped_segments)
return "\\N".join(lines)
def build_ass_content(
@@ -286,40 +247,26 @@ def build_ass_content(
title_config = title_config or {}
subtitle_config = subtitle_config or {}
# ── 字段名归一化:前端传 font_size/font_color,内部用 size/color ──
if "font_size" in title_config and "size" not in title_config:
title_config["size"] = title_config["font_size"]
if "font_color" in title_config and "color" not in title_config:
title_config["color"] = title_config["font_color"]
# ── 兼容前端简化格式:stroke/shadow 为 boolean 时,转换为标准 dict ──
# 前端 TitleSettings 发送 stroke=true/false, shadow=true/false
# 后端 build_ass_style 期望 stroke={enabled, color, width}, shadow={enabled, blur, offset_x, offset_y}
if title_config:
_stroke_val = title_config.get("stroke")
if isinstance(_stroke_val, bool):
title_config["stroke"] = (
{
"enabled": _stroke_val,
"color": "#000000",
"width": 2,
}
if _stroke_val
else {"enabled": False}
)
title_config["stroke"] = {
"enabled": _stroke_val,
"color": "#000000",
"width": 2,
} if _stroke_val else {"enabled": False}
_shadow_val = title_config.get("shadow")
if isinstance(_shadow_val, bool):
title_config["shadow"] = (
{
"enabled": _shadow_val,
"color": "#000000",
"blur": 4,
"offset_x": 2,
"offset_y": 2,
}
if _shadow_val
else {"enabled": False}
)
title_config["shadow"] = {
"enabled": _shadow_val,
"color": "#000000",
"blur": 4,
"offset_x": 2,
"offset_y": 2,
} if _shadow_val else {"enabled": False}
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
@@ -349,7 +296,7 @@ def build_ass_content(
build_ass_style(
"TitleStyle",
font_name=title_config.get("font", "思源黑体"),
font_size=int(title_config.get("size", 36)),
font_size=min(int(title_config.get("size", 36)), 36),
primary_color=title_color,
outline_color=stroke_color,
outline_width=stroke_width,
@@ -366,7 +313,7 @@ def build_ass_content(
# 根据视频宽度和字号自动换行标题,防止超出画面
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
title_font_size = int(title_config.get("size", 36))
title_font_size = min(int(title_config.get("size", 36)), 36)
safe_title_text_raw = escape_ass_text(title_text)
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
+6 -3
View File
@@ -120,6 +120,7 @@ class GenerationTask:
output_width: int = 1280
output_height: int = 720
cover_url: str = ""
custom_title: str = ""
title_config: dict = field(default_factory=dict)
extra_meta: dict = field(default_factory=dict)
logs: str = "[]"
@@ -152,6 +153,7 @@ class GenerationTask:
output_width: int = 1280,
output_height: int = 720,
cover_url: str = "",
custom_title: str = "",
title_config: dict | None = None,
extra_meta: dict | None = None,
) -> "GenerationTask":
@@ -183,6 +185,7 @@ class GenerationTask:
output_width=output_width,
output_height=output_height,
cover_url=cover_url,
custom_title=custom_title,
title_config=dict(title_config) if title_config else {},
extra_meta=dict(extra_meta) if extra_meta else {},
)
@@ -303,10 +306,10 @@ class GenerationTask:
self,
*,
cover_url: str = "",
custom_title: str = "",
extra_meta: dict | None = None,
output_width: int = 0,
output_height: int = 0,
title_config: dict | None = None,
) -> None:
"""将预览任务确认为正式产出。
@@ -316,12 +319,12 @@ class GenerationTask:
self.is_preview = False
if cover_url:
self.cover_url = cover_url
if custom_title:
self.custom_title = custom_title
if output_width > 0:
self.output_width = output_width
if output_height > 0:
self.output_height = output_height
if title_config:
self.title_config = dict(title_config)
if extra_meta:
self.extra_meta.update(extra_meta)
self.updated_at = datetime.now(timezone.utc)
+9 -85
View File
@@ -77,18 +77,12 @@ def _distribute_one_take(
asset_durations: dict[str, float] | None = None,
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips."""
used_segments: dict[str, list[tuple[float, float]]] = {}
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
asset_id = asset_ids[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + clip.duration))
def _distribute_pip(
@@ -97,18 +91,12 @@ def _distribute_pip(
asset_durations: dict[str, float] | None = None,
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips."""
used_segments: dict[str, list[tuple[float, float]]] = {}
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
asset_id = asset_ids[0]
start_time = _calc_random_start_time(asset_id, main_clips[0].duration, asset_durations, used_segments)
start_time = _calc_random_start_time(asset_id, main_clips[0].duration, asset_durations)
main_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + main_clips[0].duration))
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
@@ -116,13 +104,8 @@ def _distribute_pip(
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
asset_id = remaining[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + clip.duration))
def _distribute_voice_over(
@@ -131,18 +114,12 @@ def _distribute_voice_over(
asset_durations: dict[str, float] | None = None,
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)."""
used_segments: dict[str, list[tuple[float, float]]] = {}
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
asset_id = asset_ids[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + clip.duration))
def _distribute_voice_pip(
@@ -151,7 +128,6 @@ def _distribute_voice_pip(
asset_durations: dict[str, float] | None = None,
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll."""
used_segments: dict[str, list[tuple[float, float]]] = {}
bg_clips = [c for c in clips if c.clip_type == "background"]
voice_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
@@ -161,25 +137,15 @@ def _distribute_voice_pip(
# 第1个 → background
if idx < len(asset_ids) and bg_clips:
asset_id = asset_ids[idx]
start_time = _calc_random_start_time(asset_id, bg_clips[0].duration, asset_durations, used_segments)
start_time = _calc_random_start_time(asset_id, bg_clips[0].duration, asset_durations)
bg_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + bg_clips[0].duration))
idx += 1
# 第2个 → corner_voice
if idx < len(asset_ids) and voice_clips:
asset_id = asset_ids[idx]
start_time = _calc_random_start_time(asset_id, voice_clips[0].duration, asset_durations, used_segments)
start_time = _calc_random_start_time(asset_id, voice_clips[0].duration, asset_durations)
voice_clips[0].assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + voice_clips[0].duration))
idx += 1
# 剩余 → b_roll clips
@@ -187,13 +153,8 @@ def _distribute_voice_pip(
for i, clip in enumerate(broll_clips):
if i < len(remaining):
asset_id = remaining[i]
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations, used_segments)
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations)
clip.assign_asset(asset_id, start_time=start_time)
# Record used segment
if start_time is not None and asset_durations is not None:
if asset_id not in used_segments:
used_segments[asset_id] = []
used_segments[asset_id].append((start_time, start_time + clip.duration))
# ── 随机 start_time 计算 ────────────────────────────────────────────────────
@@ -203,19 +164,16 @@ def _calc_random_start_time(
asset_id: str,
clip_duration: float,
asset_durations: dict[str, float] | None,
used_segments: dict[str, list[tuple[float, float]]] | None = None,
) -> float | None:
"""计算随机 start_time,避开已使用的时间段.
"""计算随机 start_time.
在素材总时长范围内随机取点,确保 clip_duration 不超出素材边界。
如果 asset_durations 为 None 或素材不在其中,返回 None(使用默认 0.0)。
如果提供了 used_segments,会避开已使用的时间区间。
Args:
asset_id: 素材 ID
clip_duration: 片段时长(秒)
asset_durations: 素材 ID -> 时长映射
used_segments: {asset_id: [(start1, end1), (start2, end2), ...]} 已使用的时间段
Returns:
随机 start_time 或 None
@@ -232,41 +190,7 @@ def _calc_random_start_time(
if max_start <= 0:
return 0.0
# 如果没有已使用段,直接随机
if not used_segments or asset_id not in used_segments:
return random.uniform(0.0, max_start)
# 尝试找到一个不与已使用段重叠的起始点
used = sorted(used_segments[asset_id])
max_attempts = 100
for _ in range(max_attempts):
candidate = random.uniform(0.0, max_start)
candidate_end = candidate + clip_duration
# 检查是否与任何已使用段重叠
overlap = False
for seg_start, seg_end in used:
# 两个区间 [a, b] 和 [c, d] 重叠的条件: a < d and c < b
if candidate < seg_end and seg_start < candidate_end:
overlap = True
break
if not overlap:
return candidate
# 如果尝试多次仍找不到,缩短时长使用素材末尾
# 找到最后一个已使用段之后的可用空间
last_used_end = 0.0
for _seg_start, seg_end in used:
last_used_end = max(last_used_end, seg_end)
if last_used_end < total_duration:
# 返回从最后使用点开始的位置
return min(last_used_end, max_start)
# 实在没有空间,返回0(可能会重叠,但至少能执行)
return 0.0
return random.uniform(0.0, max_start)
# ── clip_type 映射 ────────────────────────────────────────────────────────
-1
View File
@@ -15,7 +15,6 @@ logger = logging.getLogger(__name__)
# 按优先级查找 CJK 字体(Debian/Ubuntu fonts-noto-cjk 安装路径)
_FONT_CANDIDATES = (
"/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
+1
View File
@@ -398,6 +398,7 @@ def cleanup_repo(repo, keep_count, dry_run, protected_tags, pr_sha=None, pr_open
# 注意:pr tag是pr-<sha>,sha可能属于某个PR
# 简化策略:收集所有打开PR的commit sha,在白名单里的保留
print(" 模式: Gitea PR状态检查")
open_pr_shas = set()
# 这里做了简化:因为每个PR都查commits太慢,我们用另一种方式
# 对于PR tag,先尝试匹配PR编号(如果tag名里有编号),否则按时间
# 实际pr-<sha>没法直接知道PR编号,所以降级为按时间+打开PR的head sha白名单
+3 -3
View File
@@ -232,7 +232,7 @@ def main():
# 初始化git仓库
ensure_git_repo_for_push(api_url, repo, token, branch_name)
head_branch = branch_name
# fix_mode removed: all PRs auto-fix (2026-08-26)
fix_mode = "auto_fix_and_push"
# ====== PR事件处理 ======
elif event_name == "pull_request":
@@ -251,7 +251,7 @@ def main():
# 防循环检测:检查最新commit是否已经是格式修复commit
# 修复commit message 带 [skip ci-format-check] 标记,检测到则跳过
_head_branch_tmp = pr_info.get("head", {}).get("ref", "") # noqa: F841
head_branch_tmp = pr_info.get("head", {}).get("ref", "")
skip_marker = "[skip ci-format-check]"
try:
commits_url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits?limit=3"
@@ -268,7 +268,7 @@ def main():
# 所有PR都自动修复格式(不再区分人/Agent)
print("检测到格式问题,将自动修复并推送回分支")
# fix_mode removed: all PRs auto-fix (2026-08-26)
fix_mode = "auto_fix_and_push"
print("=== 检测到代码格式问题,尝试自动修复 ===")
print(f"PR #{pr_number}")
@@ -0,0 +1,234 @@
"""剪辑模式渲染集成测试.
验证剪辑模式(ONE_TAKE / VOICE_OVER)通过
_build_plan_and_clips_from_task + UnifiedRenderService 的完整渲染流程。
注:PIP / VOICE_PIP 已下线,统一映射为 ONE_TAKE。
需要 ffmpeg 可用;CI 无 ffmpeg 时自动跳过。
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
from video_processing.unified_render_service import (
RenderResult,
UnifiedRenderService,
_resolve_layer_role,
)
from worker_app.tasks.generation import _build_plan_and_clips_from_task
pytestmark = pytest.mark.skipif(
not shutil.which("ffmpeg"),
reason="ffmpeg not available",
)
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
def _generate_test_video(path: Path, duration: float = 3.0, color: str = "red") -> None:
"""生成一个纯色测试视频。"""
cmd = [
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
f"color=c={color}:s=640x360:d={duration}:r=25",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(path),
]
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
def _render_with_mode(
mode: str,
num_clips: int = 3,
duration: float = 2.0,
) -> tuple[RenderResult, Path]:
"""用指定模式生成测试视频并渲染,返回 (result, work_dir)。
调用方负责清理 work_dir。
"""
work_dir = Path(tempfile.mkdtemp(prefix="test_4mode_"))
# 生成测试视频素材
colors = ["red", "green", "blue", "yellow", "purple"]
downloaded_paths: list[Path] = []
for i in range(num_clips):
p = work_dir / f"test_{i:03d}.mp4"
_generate_test_video(p, duration=duration, color=colors[i % len(colors)])
downloaded_paths.append(p)
# 构建虚拟 plan + clips
task_id = f"test_task_{mode}"
plan, clips, asset_path_map = _build_plan_and_clips_from_task(
task_id=task_id,
downloaded_paths=downloaded_paths,
mode=mode,
)
# 渲染
service = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
work_dir=work_dir,
output_width=640,
output_height=360,
output_fps=25,
)
result = service.render()
return result, work_dir
# ── 测试 _build_plan_and_clips_from_task ──────────────────────────────────────
class TestBuildPlanAndClips:
"""测试 4 种模式的虚拟 plan 构建。"""
def _make_paths(self, n: int) -> list[Path]:
return [Path(f"/tmp/test_{i}.mp4") for i in range(n)]
def test_one_take_mode(self):
paths = self._make_paths(3)
plan, clips, asset_map = _build_plan_and_clips_from_task("t1", paths, "one_take")
assert plan.id == "t1"
assert len(clips) == 3
assert all(c.clip_type == "main" for c in clips)
assert len(asset_map) == 3
def test_pip_mode_maps_to_one_take(self):
"""PIP 已下线,映射为 one_take → 全部 main clips。"""
paths = self._make_paths(3)
plan, clips, asset_map = _build_plan_and_clips_from_task("t2", paths, "pip")
assert len(clips) == 3
assert all(c.clip_type == "main" for c in clips)
def test_voice_over_mode(self):
paths = self._make_paths(3)
plan, clips, asset_map = _build_plan_and_clips_from_task("t3", paths, "voice_over")
assert len(clips) == 3
assert all(c.clip_type == "main" for c in clips)
assert all(c.config.get("role") == "b_roll" for c in clips)
def test_voice_pip_mode_maps_to_one_take(self):
"""VOICE_PIP 已下线,映射为 one_take → 全部 main clips。"""
paths = self._make_paths(4)
plan, clips, asset_map = _build_plan_and_clips_from_task("t4", paths, "voice_pip")
assert len(clips) == 4
assert all(c.clip_type == "main" for c in clips)
def test_unknown_mode_defaults_to_one_take(self):
paths = self._make_paths(2)
plan, clips, asset_map = _build_plan_and_clips_from_task("t5", paths, "unknown_mode")
assert len(clips) == 2
assert all(c.clip_type == "main" for c in clips)
def test_asset_path_map_keys_match_clip_asset_ids(self):
paths = self._make_paths(3)
_, clips, asset_map = _build_plan_and_clips_from_task("t6", paths, "one_take")
clip_asset_ids = {c.asset_id for c in clips}
map_keys = set(asset_map.keys())
assert clip_asset_ids == map_keys
# ── 测试图层分组(4 模式) ────────────────────────────────────────────────────
class TestFourModeLayerGrouping:
"""验证 4 种模式的 clip_type 分布经 _resolve_layer_role 后产生正确的图层。"""
def test_one_take_layers(self):
"""ONE_TAKE: 3 main → 1 main layer。"""
paths = [Path(f"/tmp/ot_{i}.mp4") for i in range(3)]
_, clips, _ = _build_plan_and_clips_from_task("ot", paths, "one_take")
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
assert roles == {"main"}
def test_pip_layers_now_one_take(self):
"""PIP 已下线 → one_take: 3 main → 1 main layer。"""
paths = [Path(f"/tmp/pip_{i}.mp4") for i in range(3)]
_, clips, _ = _build_plan_and_clips_from_task("pip", paths, "pip")
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
assert roles == {"main"}
def test_voice_over_layers(self):
"""VOICE_OVER: 3 main(b_roll) → broll。"""
paths = [Path(f"/tmp/vo_{i}.mp4") for i in range(3)]
_, clips, _ = _build_plan_and_clips_from_task("vo", paths, "voice_over")
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
assert roles == {"broll"}
def test_voice_pip_layers_now_one_take(self):
"""VOICE_PIP 已下线 → one_take: 4 main → main layer。"""
paths = [Path(f"/tmp/vpip_{i}.mp4") for i in range(4)]
_, clips, _ = _build_plan_and_clips_from_task("vpip", paths, "voice_pip")
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
assert roles == {"main"}
# ── 端到端渲染测试(需要 ffmpeg) ─────────────────────────────────────────────
class TestEndToEndRendering:
"""4 种模式的完整渲染测试,验证输出文件存在且时长合理。"""
def test_one_take_render(self):
result, work_dir = _render_with_mode("one_take", num_clips=2, duration=2.0)
try:
assert result.output_path.exists()
assert result.file_size > 0
assert result.duration > 0
assert result.width == 640
assert result.height == 360
finally:
shutil.rmtree(work_dir, ignore_errors=True)
def test_pip_render(self):
result, work_dir = _render_with_mode("pip", num_clips=2, duration=2.0)
try:
assert result.output_path.exists()
assert result.file_size > 0
assert result.duration > 0
finally:
shutil.rmtree(work_dir, ignore_errors=True)
def test_voice_over_render(self):
result, work_dir = _render_with_mode("voice_over", num_clips=2, duration=2.0)
try:
assert result.output_path.exists()
assert result.file_size > 0
assert result.duration > 0
finally:
shutil.rmtree(work_dir, ignore_errors=True)
def test_voice_pip_render(self):
result, work_dir = _render_with_mode("voice_pip", num_clips=3, duration=2.0)
try:
assert result.output_path.exists()
assert result.file_size > 0
assert result.duration > 0
finally:
shutil.rmtree(work_dir, ignore_errors=True)
+189
View File
@@ -0,0 +1,189 @@
"""全链路集成测试.
验证 PlanGeneratorService → UnifiedRenderService → 查重 的端到端流程。
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
from video_processing.unified_render_service import (
UnifiedRenderService,
)
from worker_app.tasks.generation import _build_plan_and_clips_from_task
pytestmark = pytest.mark.skipif(
not shutil.which("ffmpeg"),
reason="ffmpeg not available",
)
def _generate_test_video(path: Path, duration: float = 3.0) -> None:
"""生成一个测试视频。"""
cmd = [
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
f"color=c=blue:s=640x360:d={duration}:r=25",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
str(path),
]
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
def _generate_test_audio(path: Path, duration: float = 5.0) -> None:
"""生成一个测试音频文件。"""
cmd = [
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
f"sine=frequency=440:duration={duration}",
"-c:a",
"aac",
"-b:a",
"128k",
str(path),
]
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
class TestFullPipeline:
"""验证从虚拟 plan 构建到渲染输出的完整流程。"""
def test_one_take_pipeline(self):
"""ONE_TAKE 模式完整流程。"""
with tempfile.TemporaryDirectory() as tmpdir:
work_dir = Path(tmpdir)
# 生成测试素材
paths = []
for i in range(3):
p = work_dir / f"clip_{i}.mp4"
_generate_test_video(p, duration=2.0)
paths.append(p)
# 构建虚拟 plan
plan, clips, asset_map = _build_plan_and_clips_from_task("pipeline_test", paths, "one_take")
# 渲染
service = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_map,
work_dir=work_dir,
output_width=640,
output_height=360,
)
result = service.render()
assert result.output_path.exists()
assert result.duration > 0
assert result.file_size > 0
assert result.width == 640
assert result.height == 360
def test_pipeline_with_audio_mux(self):
"""渲染 + 混音后处理。"""
with tempfile.TemporaryDirectory() as tmpdir:
work_dir = Path(tmpdir)
# 生成测试素材
video_path = work_dir / "clip_0.mp4"
_generate_test_video(video_path, duration=3.0)
# 构建虚拟 plan
plan, clips, asset_map = _build_plan_and_clips_from_task("audio_test", [video_path], "one_take")
# 渲染
service = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_map,
work_dir=work_dir,
output_width=640,
output_height=360,
)
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)
assert final_path.exists()
assert final_path.stat().st_size > 0
def test_single_clip_pipeline(self):
"""单 clip 渲染(无转场)。"""
with tempfile.TemporaryDirectory() as tmpdir:
work_dir = Path(tmpdir)
video_path = work_dir / "single.mp4"
_generate_test_video(video_path, duration=5.0)
plan, clips, asset_map = _build_plan_and_clips_from_task("single_test", [video_path], "one_take")
service = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_map,
work_dir=work_dir,
output_width=640,
output_height=360,
)
result = service.render()
assert result.output_path.exists()
assert result.duration > 0
def test_dedup_helper_integration(self):
"""验证 dedup_helpers.create_video_record_and_dedup 的导入和签名。"""
# 只验证函数存在且签名正确(不实际调用,需要数据库)
import inspect
from video_processing.dedup_helpers import create_video_record_and_dedup
sig = inspect.signature(create_video_record_and_dedup)
params = set(sig.parameters.keys())
expected = {
"generation_task_id",
"project_id",
"batch_id",
"file_url",
"file_size",
"duration",
"video_path",
"mode",
"session",
"width",
"height",
"fps",
}
assert expected.issubset(params), f"Missing params: {expected - params}"
+4 -96
View File
@@ -15,7 +15,6 @@ from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
_wrap_title_text,
build_ass_content,
build_ass_style,
escape_ass_text,
@@ -106,9 +105,9 @@ class TestBuildAssStyle:
def test_contains_font_size(self):
result = build_ass_style("S1", font_size=36)
# Style行格式:Name, Fontname, Fontsize, ...(36*1.35=48.6→49)
# Style行格式:Name, Fontname, Fontsize, ...
parts = result.split(",")
assert parts[2] == "49"
assert parts[2] == "36"
def test_bold_true(self):
result = build_ass_style("S1", bold=True)
@@ -219,14 +218,6 @@ class TestEscapeAssText:
def test_chinese_text(self):
assert escape_ass_text("你好世界") == "你好世界"
def test_slash_converted_to_newline(self):
"""半角斜杠 / 应转为 ASS 硬换行。"""
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
def test_fullwidth_slash_converted_to_newline(self):
"""全角斜杠 / 应转为 ASS 硬换行。"""
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
def test_backslash_n_in_input(self):
# 文本里本身有 \n 字符串(不是换行符)
result = escape_ass_text("\\n")
@@ -417,53 +408,11 @@ class TestBuildAssContent:
title_text="T",
title_config={"size": 72},
)
# 在TitleStyle行里查找字体大小(字号上限已移除,72应原样保留)
# 在TitleStyle行里查找字体大小
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "97" # 72*1.35=97.2→97
break
def test_title_font_size_frontend_field_alias(self):
"""前端传 font_size 应归一化为内部 size 字段。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"font_size": 48},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "65" # 48*1.35=64.8→65
break
def test_title_font_color_frontend_field_alias(self):
"""前端传 font_color 应归一化为内部 color 字段。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"font_color": "#FF0000"},
)
# 红色 → &H0000FF
assert "&H0000FF" in result
def test_title_size_takes_precedence_over_font_size(self):
"""同时传 size 和 font_size 时,size 优先。"""
result = build_ass_content(
video_width=1920,
video_height=1080,
video_duration=5.0,
title_text="T",
title_config={"size": 56, "font_size": 28},
)
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "76" # 56*1.35=75.6→76
assert parts[2] == "36"
break
def test_title_bold(self):
@@ -580,44 +529,3 @@ class TestConstants:
assert isinstance(TITLE_MARGIN_TOP, int)
assert isinstance(TITLE_MARGIN_BOTTOM, int)
assert isinstance(TITLE_MARGIN_SIDE, int)
# ============================================================
# _wrap_title_text 换行逻辑验证
# ============================================================
class TestWrapTitleTextFontSizeConsistency:
"""验证换行计算使用原始 font_size,与 CSS 预览一致。"""
def test_font_size_113_uses_original_not_compensated(self):
"""font_size=113 时,每行应容纳8个字(113px字宽),而非6个字(153px字宽)。"""
# 标题"永康拾掇脚阔头"共7个字
# 可用宽度 = 1080 - 60 - 60 = 960px
# 用 font_size=113:char_width=113,960/113 ≈ 8.5,每行8个字
# 7个字 < 8个字,应该在一行内
title = "永康拾掇脚阔头"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 不应该有换行
assert "\\N" not in result
assert result == title
def test_long_title_wraps_correctly(self):
"""长标题应该按 font_size 字宽正确换行。"""
# 16个中文字,每行8个字,应该换行为2行
title = "永康拾掇脚阔头来一个笑一个哈哈哈"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 应该有一个换行
assert result.count("\\N") == 1
# 每行8个字
lines = result.split("\\N")
assert len(lines) == 2
assert len(lines[0]) == 8
assert len(lines[1]) == 8
def test_mixed_cjk_and_ascii(self):
"""混合中英文时,英文按半角宽度计算。"""
# "测试test" = 2个中文(2*113=226) + 4个英文(4*113*0.55=248.6) = 474.6px
title = "测试test"
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
# 总宽度474.6px < 960px,应该在一行内
assert "\\N" not in result
+8
View File
@@ -149,6 +149,14 @@ class TestWorkerGenerationNoPreviewOverride:
assert 'resolution = "854x480"' not in source, "Should not override resolution to 480p in preview mode"
assert 'bitrate = "1M"' not in source, "Should not override bitrate to 1M in preview mode"
def test_parallel_download_still_works(self):
"""并行下载逻辑保留。"""
with open("apps/worker/worker_app/tasks/generation.py") as f:
source = f.read()
assert "ThreadPoolExecutor" in source, "Should use ThreadPoolExecutor for parallel downloads"
assert "as_completed" in source, "Should use as_completed for result collection"
# ── 5. generation_preview.py 不再有 PREVIEW_RESOLUTION ──
@@ -2,6 +2,7 @@
验证:
1. _load_task_info 正确加载 voice_ids
2. _render_video 接受 voice_ids 参数
3. voice_ids 正确注入到 plan config 中(实际执行代码路径,diff-cover 可达)
"""
@@ -73,3 +74,185 @@ class TestLoadTaskInfoVoiceIds:
result = _load_task_info("test_task_id")
assert result["voice_ids"] == []
class TestRenderVideoVoiceInjection:
"""验证 _render_video 正确注入 voice_id 到 plan config(实际执行代码路径)"""
def test_render_video_accepts_voice_ids(self):
"""_render_video 签名包含 voice_ids 参数"""
import inspect
from worker_app.tasks.generation import _render_video
sig = inspect.signature(_render_video)
assert "voice_ids" in sig.parameters
def test_voice_ids_default_none(self):
"""voice_ids 参数默认为 None"""
import inspect
from worker_app.tasks.generation import _render_video
sig = inspect.signature(_render_video)
param = sig.parameters["voice_ids"]
assert param.default is None
def test_voice_ids_injected_into_plan_config(self):
"""voice_ids 非空时,voice_id 和 subtitle.auto_generated 被注入到 plan config。
此测试实际执行 _render_video 的配音注入代码路径,确保 diff-cover 覆盖新增行。
"""
from dataclasses import dataclass, field
@dataclass
class MockClip:
"""模拟 VirtualClip,至少需要 duration 属性。"""
id: str = "clip_1"
duration: float = 5.0
config: dict = field(default_factory=dict)
@dataclass
class MockPlan:
"""模拟 VirtualPlan,至少需要 config 属性。"""
id: str = "test_plan"
name: str = "test"
config: dict = field(default_factory=dict)
mock_plan = MockPlan(config={"some_key": "some_value"})
mock_clips = [MockClip(duration=5.0), MockClip(duration=3.0)]
mock_asset_path_map = {"asset_1": Path("/tmp/video1.mp4")}
# Mock RenderAdapter 和 render 结果
mock_render_result = MagicMock()
mock_render_result.success = True
mock_render_result.output_path = Path("/tmp/output.mp4")
mock_render_result.duration = 8.0
mock_adapter_cls = MagicMock(return_value=MagicMock())
mock_adapter_cls.return_value.render_from_memory.return_value = mock_render_result
mock_db = MagicMock()
with (
patch(
"worker_app.tasks.generation._build_plan_and_clips_from_task",
return_value=(mock_plan, mock_clips, mock_asset_path_map),
),
patch(
"worker_app.tasks.generation._load_template_plan_config",
return_value=None,
),
patch(
"video_processing.render_adapter.RenderAdapter",
mock_adapter_cls,
),
patch(
"worker_app.tasks.generation.SessionLocal",
return_value=mock_db,
),
):
from worker_app.tasks.generation import _render_video
from packages.domain import EditingMode
output_path, render_duration, cover_candidates = _render_video(
task_id="test_task_123",
downloaded_videos=[Path("/tmp/video1.mp4")],
voice_path=None,
editing_mode=EditingMode.ONE_TAKE,
project_id="proj_1",
template_id="tmpl_1",
user_id="user_1",
temp_path=Path("/tmp"),
output_name="test_output.mp4",
resolution="854x480",
voice_ids=["voice_abc"],
)
# 验证 voice_id 被注入到 plan config(覆盖新增代码行)
assert mock_plan.config.get("voice_id") == "voice_abc"
# 验证 subtitle.auto_generated 被设置为 True
assert mock_plan.config.get("subtitle", {}).get("auto_generated") is True
# 验证 RenderAdapter 被调用
mock_adapter_cls.return_value.render_from_memory.assert_called_once()
# 验证返回值
assert output_path == Path("/tmp/output.mp4")
assert render_duration == 8.0
def test_voice_ids_empty_skips_injection(self):
"""voice_ids 为空时,不注入 voice_id 到 plan config"""
from dataclasses import dataclass, field
@dataclass
class MockClip:
id: str = "clip_1"
duration: float = 5.0
@dataclass
class MockPlan:
id: str = "test_plan"
name: str = "test"
config: dict = field(default_factory=dict)
mock_plan = MockPlan(config={"export": {"resolution": "854x480"}})
mock_clips = [MockClip(duration=5.0)]
mock_render_result = MagicMock()
mock_render_result.success = True
mock_render_result.output_path = Path("/tmp/output.mp4")
mock_render_result.duration = 5.0
mock_adapter_cls = MagicMock(return_value=MagicMock())
mock_adapter_cls.return_value.render_from_memory.return_value = mock_render_result
with (
patch(
"worker_app.tasks.generation._build_plan_and_clips_from_task",
return_value=(mock_plan, mock_clips, {}),
),
patch(
"worker_app.tasks.generation._load_template_plan_config",
return_value=None,
),
patch(
"video_processing.render_adapter.RenderAdapter",
mock_adapter_cls,
),
patch(
"worker_app.tasks.generation.SessionLocal",
return_value=MagicMock(),
),
):
from worker_app.tasks.generation import _render_video
from packages.domain import EditingMode
_render_video(
task_id="test_task_456",
downloaded_videos=[Path("/tmp/video1.mp4")],
voice_path=None,
editing_mode=EditingMode.ONE_TAKE,
project_id="proj_1",
template_id="",
user_id="user_1",
temp_path=Path("/tmp"),
output_name="test_output.mp4",
voice_ids=[],
)
# 验证 voice_id 没有被注入
assert "voice_id" not in mock_plan.config
class TestGenerateVideoPassesVoiceIds:
"""验证 generate_video 调用 _render_video 时传递 voice_ids"""
def test_generate_video_passes_voice_ids(self):
"""generate_video 中 _render_video 调用包含 voice_ids 参数"""
with open("apps/worker/worker_app/tasks/generation.py", "r") as f:
content = f.read()
assert 'voice_ids=task_info.get("voice_ids", [])' in content
+4 -98
View File
@@ -79,12 +79,12 @@ class TestBuildAssStyle:
def test_minimal_style(self):
result = build_ass_style("TestStyle")
assert result.startswith("Style: TestStyle,")
assert "Noto Sans SC" in result
assert ",65," in result # 48*1.35=64.8→65
assert "思源黑体" in result
assert ",48," in result
def test_custom_font_size(self):
result = build_ass_style("Title", font_size=64)
assert ",86," in result # 64*1.35=86.4→86
assert ",64," in result
def test_bold_enabled(self):
result = build_ass_style("BoldStyle", bold=True)
@@ -152,26 +152,6 @@ class TestBuildAssStyle:
# Style: 行有 23 个字段(去掉 "Style: " 前缀后)
assert len(parts) == 23
def test_font_name_mapping_siyuan(self):
"""思源黑体 → Noto Sans SC"""
result = build_ass_style("Test", font_name="思源黑体")
assert "Noto Sans SC" in result
def test_font_name_mapping_apple(self):
"""苹方 → Noto Sans SC"""
result = build_ass_style("Test", font_name="苹方")
assert "Noto Sans SC" in result
def test_font_name_mapping_msyh(self):
"""微软雅黑 → Noto Sans SC"""
result = build_ass_style("Test", font_name="微软雅黑")
assert "Noto Sans SC" in result
def test_font_name_mapping_unknown_passthrough(self):
"""未映射字体原样使用"""
result = build_ass_style("Test", font_name="CustomFont")
assert "CustomFont" in result
# ── 文本转义 ──────────────────────────────────────────────────────────────────
@@ -210,19 +190,6 @@ class TestEscapeAssText:
def test_chinese_text(self):
assert escape_ass_text("你好世界") == "你好世界"
def test_slash_converted_to_newline(self):
"""半角斜杠 / 应转为 ASS 硬换行。"""
assert escape_ass_text("第一行/第二行") == "第一行\\N第二行"
def test_fullwidth_slash_converted_to_newline(self):
"""全角斜杠 / 应转为 ASS 硬换行。"""
assert escape_ass_text("第一行/第二行") == "第一行\\N第二行"
def test_mixed_slashes_and_newlines(self):
"""斜杠和换行符都应转为硬换行。"""
result = escape_ass_text("A/B\nC/D")
assert result == "A\\NB\\NC\\ND"
# ── 时间格式化 ────────────────────────────────────────────────────────────────
@@ -473,7 +440,7 @@ class TestBuildAssContent:
class TestConstants:
def test_title_margin_top(self):
assert TITLE_MARGIN_TOP == 120
assert TITLE_MARGIN_TOP == 60
def test_title_margin_bottom(self):
assert TITLE_MARGIN_BOTTOM == 60
@@ -481,7 +448,6 @@ class TestConstants:
def test_title_margin_side(self):
assert TITLE_MARGIN_SIDE == 40
# ── 标题自动换行 ──────────────────────────────────────────────────────────────
@@ -525,34 +491,6 @@ class TestWrapTitleText:
"""字号为0时直接返回原文。"""
assert _wrap_title_text("测试", 1080, 0) == "测试"
def test_preserves_explicit_newline(self):
"""已有的 \\N 换行标记应保留,不被当普通字符算宽度。"""
text = "第一行\\N第二行"
result = _wrap_title_text(text, video_width=1080, font_size=48)
assert result == text
def test_explicit_newline_each_segment_wraps_independently(self):
"""\\N 分段后,每段各自自动换行。"""
# 480px 宽,48px 字号,可用 400px,每段约8个中文字
text = "这是第一段很长很长很长的内容\\N这是第二段也很长很长的内容"
result = _wrap_title_text(text, video_width=480, font_size=48)
# 应该有多个 \N:用户手动的 + 自动换行的
assert "\\N" in result
segments = result.split("\\N")
# 至少3行(两段都需要换行)
assert len(segments) >= 3
# 验证包含两段的文字
joined = result.replace("\\N", "")
assert "第一段" in joined
assert "第二段" in joined
def test_multiple_explicit_newlines(self):
"""多个 \\N 分段都应保留。"""
text = "A\\NB\\NC"
result = _wrap_title_text(text, video_width=1080, font_size=48)
assert result == text
assert result.count("\\N") == 2
def test_build_ass_content_integration(self):
"""集成测试:build_ass_content 中的标题应该自动换行。"""
long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作"
@@ -570,35 +508,3 @@ class TestWrapTitleText:
break
else:
pytest.fail("未找到 TitleStyle Dialogue 行")
class TestFontsizeCompensation:
"""ASS Fontsize 补偿系数(CSS 字号 → ASS em-square 字号)。"""
def test_default_48_compensated_to_65(self):
result = build_ass_style("S")
parts = result.split(",")
assert parts[2] == "65" # round(48*1.35)=65
def test_89_compensated_to_120(self):
"""实测对齐点:font_size=89 → ASS Fontsize=120。"""
result = build_ass_style("S", font_size=89)
parts = result.split(",")
assert parts[2] == "120"
def test_subtitle_also_compensated(self):
content = build_ass_content(
video_width=1080,
video_height=1920,
video_duration=5.0,
subtitle_text="字幕",
subtitle_config={"size": 24},
)
sub_line = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
fields = [f.strip() for f in sub_line.split(",")]
assert fields[2] == "32" # round(24*1.35)=32
def test_minimum_fontsize_at_least_one(self):
result = build_ass_style("S", font_size=0)
parts = result.split(",")
assert int(parts[2]) >= 1
+130
View File
@@ -0,0 +1,130 @@
"""
测试:模板 config 字段存储了非 dict 值(如 True / False / str)时,
渲染链路不会崩溃('bool' object has no attribute 'get')。
覆盖两个关键文件:
1. generation.py — _load_template_plan_config 旧系统路径
2. unified_render_service.py — _maybe_generate_ass
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add worker app to path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
class TestLoadTemplatePlanConfigBoolDefense:
"""_load_template_plan_config 旧系统路径对非 dict 值的防护。"""
def _call_old_path(self, title_cfg, subtitle_cfg, bgm_cfg):
"""通过 mock 新模板系统返回 None,强制走旧模板系统 fallback 路径。"""
from worker_app.tasks.generation import _load_template_plan_config
mock_old_template = MagicMock()
mock_old_template.title_config = title_cfg
mock_old_template.subtitle_config = subtitle_cfg
mock_old_template.bgm_config = bgm_cfg
mock_session = MagicMock()
# 旧系统 query 返回 mock template
mock_session.query.return_value.filter.return_value.first.return_value = mock_old_template
# Mock 新模板系统 repo.get() 返回 None(强制走 fallback)
mock_repo_cls = MagicMock()
mock_repo_cls.return_value.get.return_value = None
with (
patch("worker_app.tasks.generation.SessionLocal", return_value=mock_session),
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyEditTemplateRepository", mock_repo_cls),
patch("packages.adapters.sqlalchemy_impl.SQLAlchemyTemplateClipConfigRepository", MagicMock()),
):
return _load_template_plan_config("fake-id")
def test_bool_values_return_empty(self):
"""title_config=True / subtitle_config=False / bgm_config='str' → 全部过滤掉"""
result = self._call_old_path(True, False, "not_a_dict")
assert isinstance(result, dict)
assert "title" not in result
assert "subtitle" not in result
assert "bgm" not in result
def test_valid_dict_passes_through(self):
"""正常 dict 正常传递"""
result = self._call_old_path(
{"text": "标题", "enabled": True},
{"text": "副标题"},
{"enabled": True, "source": "test.mp3"},
)
assert result["title"] == {"text": "标题", "enabled": True}
assert result["subtitle"] == {"text": "副标题"}
assert result["bgm"] == {"enabled": True, "source": "test.mp3"}
def test_none_returns_empty(self):
"""None → 空 dict"""
result = self._call_old_path(None, None, None)
assert result == {}
def test_mixed_valid_and_invalid(self):
"""部分有效、部分无效时只保留有效的"""
result = self._call_old_path({"text": "OK"}, True, None)
assert "title" in result
assert "subtitle" not in result
assert "bgm" not in result
def test_int_and_list_also_filtered(self):
"""int / list 类型也被过滤"""
result = self._call_old_path(42, [1, 2, 3], 0)
assert result == {}
class TestUnifiedRenderBoolConfigDefense:
"""_maybe_generate_ass 对 plan.config 中非 dict title/subtitle 的防护。"""
def _make_service(self, config):
from video_processing.unified_render_service import UnifiedRenderService
service = UnifiedRenderService.__new__(UnifiedRenderService)
mock_plan = MagicMock()
mock_plan.config = config
service.plan = mock_plan
service.task_id = "test-task"
return service
def test_bool_title_does_not_crash(self):
"""config['title']=True → 不崩溃,返回 None"""
service = self._make_service({"title": True, "subtitle": {}})
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
def test_bool_subtitle_does_not_crash(self):
"""config['subtitle']=False → 不崩溃,返回 None"""
service = self._make_service({"title": {}, "subtitle": False})
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
def test_str_title_does_not_crash(self):
"""config['title']='plain string' → 不崩溃"""
service = self._make_service({"title": "plain string", "subtitle": {}})
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
def test_none_config_does_not_crash(self):
"""config=None → 不崩溃"""
service = self._make_service(None)
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
def test_int_title_does_not_crash(self):
"""config['title']=42 → 不崩溃"""
service = self._make_service({"title": 42, "subtitle": 0})
result = service._maybe_generate_ass(video_duration=10.0)
assert result is None
+9 -2
View File
@@ -5,7 +5,7 @@
- 预览任务未完成 → 创建新任务走渲染流程
- 预览任务不存在 → 404
- 权限不足 → 403
- cover_url 正确传递
- cover_url 和 custom_title 正确传递
"""
from __future__ import annotations
@@ -201,6 +201,7 @@ def _make_preview_task(**kwargs: Any) -> GenerationTask:
output_width=1080,
output_height=1920,
cover_url="",
custom_title="",
video_title="",
resolution="",
bgm_config={},
@@ -232,6 +233,7 @@ class TestConfirmGenerationReuse:
"output_width": 1080,
"output_height": 1920,
"cover_url": "https://example.com/cover.jpg",
"custom_title": "我的视频",
},
)
@@ -249,6 +251,7 @@ class TestConfirmGenerationReuse:
assert item["output_height"] == 1920
# 封面和标题更新
assert item["cover_url"] == "https://example.com/cover.jpg"
assert item["custom_title"] == "我的视频"
# 没有创建新任务
assert len(gen_task_repo._store) == initial_count
@@ -274,6 +277,7 @@ class TestConfirmGenerationReuse:
assert updated is not None
assert updated.is_preview is False
assert updated.cover_url == "https://cdn.example.com/cover.png"
assert updated.custom_title == "测试标题"
def test_confirm_creates_new_task_when_preview_not_completed(
self,
@@ -352,6 +356,7 @@ class TestConfirmGenerationErrors:
assert item["output_height"] == 1080
# 默认封面和标题为空
assert item["cover_url"] == ""
assert item["custom_title"] == ""
# 配置保留
assert item["voice_library_id"] == "voice-001"
assert item["template_id"] == "tmpl-001"
@@ -363,7 +368,7 @@ class TestConfirmGenerationErrors:
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
) -> None:
"""cover_url 正确传递"""
"""cover_url 和 custom_title 正确传递"""
preview = _make_preview_task()
gen_task_repo.create(preview)
@@ -373,12 +378,14 @@ class TestConfirmGenerationErrors:
"output_width": 1080,
"output_height": 1920,
"cover_url": "https://cdn.example.com/my-cover.png",
"custom_title": "测试视频标题",
},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["cover_url"] == "https://cdn.example.com/my-cover.png"
assert item["custom_title"] == "测试视频标题"
def test_confirm_default_resolution(
self,
@@ -1,409 +0,0 @@
"""create 端点兜底复用预览产物 + confirm 标题同步 — 单元测试."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from typing import Any, Optional
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ── Stubs ────────────────────────────────────────────────────────────────────
class StubGenerationTaskRepository:
def __init__(self) -> None:
self._store: dict[str, Any] = {}
def create(self, task: Any) -> Any:
self._store[task.id] = task
return task
def get(self, task_id: str) -> Optional[Any]:
return self._store.get(task_id)
def update(self, task: Any) -> Any:
self._store[task.id] = task
return task
def list_by_user(self, user_id: str) -> list[Any]:
return [t for t in self._store.values() if t.created_by_user_id == user_id]
def count_pending_by_user(self, user_id: str) -> int:
return len(
[
t
for t in self._store.values()
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
]
)
def count_pending_total(self) -> int:
return len([t for t in self._store.values() if t.status == GenerationTaskStatus.PENDING])
def count_by_user(self, user_id: str) -> int:
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
@dataclass
class FakeProject:
id: str = "project-001"
owner_user_id: str = "user-001"
shared_users: list[str] = field(default_factory=list)
name: str = "Test Project"
def can_access(self, user_id: str) -> bool:
return user_id == self.owner_user_id or user_id in self.shared_users
class StubProjectRepository:
def __init__(self) -> None:
self._projects: dict[str, FakeProject] = {}
def add(self, project: FakeProject) -> None:
self._projects[project.id] = project
def find_by_id(self, project_id: str) -> Optional[FakeProject]:
return self._projects.get(project_id)
@dataclass
class FakeUser:
id: str = "user-001"
email: str = "test@example.com"
@dataclass
class FakeAuthenticatedUser:
user: FakeUser = field(default_factory=FakeUser)
session_id: str | None = None
token_type: str | None = None
def _make_preview_task(**kwargs: Any) -> GenerationTask:
defaults = dict(
id="preview-task-001",
project_id="project-001",
asset_library_id="library-001",
strategy_id="one_take",
voice_library_id="",
template_id="tmpl-001",
asset_ids=["asset-1"],
title_ids=[],
voice_ids=[],
status=GenerationTaskStatus.COMPLETED,
progress=100.0,
result_count=1,
error_message="",
created_by_user_id="user-001",
source_edit_plan_id="plan-001",
asset_select_mode="all",
is_preview=True,
source_task_id="",
output_width=1080,
output_height=1920,
cover_url="",
video_title="",
resolution="",
bgm_config={},
title_config={"text": "预览标题"},
)
defaults.update(kwargs)
return GenerationTask(**defaults)
def _make_db_with_preview(preview: GenerationTask):
"""Create a mock DB that returns a model-like object for the preview."""
db = MagicMock()
mock_model = MagicMock()
mock_model.id = preview.id
mock_model.output_width = preview.output_width
mock_model.output_height = preview.output_height
# Set up chain: db.query(...).filter(...).order_by(...).first()
chain = db.query.return_value
chain.filter.return_value = chain
chain.order_by.return_value = chain
chain.first.return_value = mock_model
return db, mock_model
def _make_db_empty():
"""Create a mock DB that returns None (no preview found)."""
db = MagicMock()
chain = db.query.return_value
chain.filter.return_value = chain
chain.order_by.return_value = chain
chain.first.return_value = None
return db
# ── Fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def gen_task_repo() -> StubGenerationTaskRepository:
return StubGenerationTaskRepository()
@pytest.fixture
def project_repo() -> StubProjectRepository:
repo = StubProjectRepository()
repo.add(FakeProject())
return repo
@pytest.fixture
def app(
gen_task_repo: StubGenerationTaskRepository,
project_repo: StubProjectRepository,
) -> FastAPI:
from app.api.routes.generation_tasks import router
from app.auth import get_current_user
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
get_project_repository,
)
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/generation")
test_app.dependency_overrides[get_current_user] = lambda: FakeAuthenticatedUser()
test_app.dependency_overrides[get_generation_task_repository] = lambda: gen_task_repo
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_library_repository] = lambda: MagicMock()
test_app.dependency_overrides[get_asset_repository] = lambda: MagicMock()
test_app.dependency_overrides[get_generated_video_repository] = lambda: MagicMock()
# db_session will be overridden per-test
yield test_app
test_app.dependency_overrides.clear()
def _make_client(app: FastAPI, db: MagicMock) -> TestClient:
from app.dependencies import get_db_session
app.dependency_overrides[get_db_session] = lambda: db
return TestClient(app)
# ── Domain: mark_confirmed with title_config ─────────────────────────────────
class TestMarkConfirmedTitleConfig:
def test_mark_confirmed_sets_title_config(self):
task = _make_preview_task()
task.mark_confirmed(title_config={"text": "新标题"})
assert task.is_preview is False
assert task.title_config["text"] == "新标题"
def test_mark_confirmed_without_title_config_preserves_existing(self):
task = _make_preview_task(title_config={"text": "原标题"})
task.mark_confirmed()
assert task.title_config["text"] == "原标题"
# ── Create endpoint fallback ─────────────────────────────────────────────────
class TestCreateEndpointFallback:
"""create 端点兜底复用预览产物。"""
def test_reuse_completed_preview(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""带 source_edit_plan_id + is_preview=False → 复用已完成预览"""
preview = _make_preview_task()
gen_task_repo.create(preview)
db, _ = _make_db_with_preview(preview)
with (
patch(
"packages.adapters.sqlalchemy_impl.generation_task_repository._to_domain",
return_value=preview,
),
patch(
"app.api.routes.generation_tasks._writeback_edit_plan_config",
),
):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": False,
"output_width": 1080,
"output_height": 1920,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["id"] == preview.id
assert data["items"][0]["is_preview"] is False
def test_no_preview_found_creates_new_task(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""没有已完成预览 → 正常创建新任务"""
db = _make_db_empty()
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-002",
"is_preview": False,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["id"] != "preview-task-001"
assert data["items"][0]["is_preview"] is False
def test_preview_request_does_not_use_fallback(
self,
app: FastAPI,
):
"""is_preview=True → 不走兜底,正常创建预览任务"""
db = _make_db_empty()
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": True,
},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["is_preview"] is True
# 兜底查询 GenerationTaskModel 不应被调用(只可能查 EditPlanModel 做自动关联)
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
for call_args in db.query.call_args_list:
assert (
call_args[0][0] is not GenerationTaskModel
), "fallback should not query GenerationTaskModel for preview requests"
def test_fallback_resolution_mismatch_creates_new(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""兜底找到预览但分辨率不一致 → 跳过复用,创建新任务"""
preview = _make_preview_task(output_width=1080, output_height=1920)
gen_task_repo.create(preview)
db, _ = _make_db_with_preview(preview)
with (
patch(
"packages.adapters.sqlalchemy_impl.generation_task_repository._to_domain",
return_value=preview,
),
patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True),
):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": False,
"output_width": 1920,
"output_height": 1080,
},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["id"] != preview.id
# ── Confirm endpoint title sync ──────────────────────────────────────────────
class TestConfirmTitleSync:
"""confirm 端点 custom_title 同步到 title_config。"""
def test_confirm_with_custom_title_updates_title_config(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""传了 custom_title → title_config.text 被更新"""
preview = _make_preview_task(
title_config={"text": "旧标题", "font_size": 32},
source_edit_plan_id="plan-001",
)
gen_task_repo.create(preview)
db = _make_db_empty()
with patch("app.api.routes.generation_tasks._writeback_edit_plan_config"):
client = _make_client(app, db)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={
"output_width": 1080,
"output_height": 1920,
"custom_title": "新标题",
},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["title_config"]["text"] == "新标题"
assert item["title_config"]["font_size"] == 32
def test_confirm_without_custom_title_preserves_title(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""没传 custom_title → title_config 不变"""
preview = _make_preview_task(title_config={"text": "原标题"})
gen_task_repo.create(preview)
db = _make_db_empty()
client = _make_client(app, db)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={"output_width": 1080, "output_height": 1920},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["title_config"]["text"] == "原标题"
@@ -1,541 +0,0 @@
"""测试编辑器 from-assets 端点:同一素材切多个片段 + 随机起始 + 去重.
覆盖:
- required_clips_count 精确控制片段数量
- 素材不足时同一素材轮询切多个片段
- 随机 start_time + used_segments 去重
- 素材时长不足时 clip duration 缩短
- 向后兼容(不传 required_clips_count 时等于素材数量)
- order 追加到时间线末尾
- start_time=None 时抛出 400
- create_clip 失败时抛出 400 并记录日志
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from fastapi import HTTPException
TEST_PLAN_ID = "plan-draft-001"
TEST_USER_ID = "user-001"
def _make_auth_user():
auth = MagicMock()
auth.user.id = TEST_USER_ID
auth.user.email = "test@example.com"
auth.user.display_name = "测试用户"
auth.user_id = TEST_USER_ID
return auth
def _make_mock_clip(clip_id, order, duration, start_time=0.0, asset_id=""):
clip = MagicMock()
clip.id = clip_id
clip.plan_id = TEST_PLAN_ID
clip.clip_type = "main"
clip.order = order
clip.duration = duration
clip.start_time = start_time
clip.text_content = ""
clip.transition_effect = "cut"
clip.transition_duration = 0.0
clip.playback_speed = 1.0
clip.config = {}
clip.asset_id = asset_id
clip.status = "pending"
clip.template_clip_config_id = ""
clip.created_at = None
clip.updated_at = None
return clip
def _make_mock_asset(asset_id, duration):
asset = MagicMock()
asset.id = asset_id
asset.duration = duration
return asset
def _create_clips(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw):
return _make_mock_clip(
clip_id=f"clip-{order}",
order=order,
duration=duration,
start_time=start_time,
asset_id=asset_id,
)
def _make_plan_svc(existing_clips=None):
svc = MagicMock()
svc.get_plan_or_raise = MagicMock()
svc.create_clip = MagicMock(side_effect=_create_clips)
svc.list_clips = MagicMock(return_value=existing_clips or [])
return svc
class TestEditorClipsRequiredCount:
"""测试 required_clips_count 控制片段数量 + 同素材多片段."""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_creates_exactly_required_clips_count(self, mock_storage):
"""required_clips_count=4 时,即使只有2个素材也创建4个片段."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]))
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=4)
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert result.created_count == 4
assert mock_plan_svc.create_clip.call_count == 4
# 验证轮询分配:a1, a2, a1, a2
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["asset_id"] == "a1"
assert calls[1].kwargs["asset_id"] == "a2"
assert calls[2].kwargs["asset_id"] == "a1"
assert calls[3].kwargs["asset_id"] == "a2"
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_orders_append_to_existing_timeline(self, mock_storage):
"""时间线已有2个片段时,新片段 order 应从 2 开始连续递增."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 模拟已有 order=0, order=1 的片段
existing = [_make_mock_clip("old-1", 0, 5.0), _make_mock_clip("old-2", 1, 5.0)]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["order"] == 2
assert calls[1].kwargs["order"] == 3
assert calls[2].kwargs["order"] == 4
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_orders_start_at_zero_when_empty(self, mock_storage):
"""空时间线时 order 从 0 开始."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc(existing_clips=[])
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["order"] == 0
assert calls[1].kwargs["order"] == 1
assert calls[2].kwargs["order"] == 2
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_backward_compatible_default_count(self, mock_storage):
"""不传 required_clips_count 时,片段数等于素材数(向后兼容)."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2", "a3"])
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert result.created_count == 3
assert mock_plan_svc.create_clip.call_count == 3
class TestEditorClipsDurationAndStartTime:
"""测试素材时长获取、clip duration 缩短、start_time 传入."""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_clip_duration_shortened_for_short_assets(self, mock_storage):
"""素材只有 3s 时 clip duration 缩短到 3.0."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0))
body = ClipsFromAssetsRequest(asset_ids=["short"])
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert len(calls) == 1
assert calls[0].kwargs["duration"] == 3.0
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_start_time_passed_to_create_clip(self, mock_storage):
"""验证 _calc_random_start_time 返回值被传入 create_clip."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
with patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
side_effect=[12.5, 18.0],
) as mock_calc:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert mock_calc.call_count == 2
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["start_time"] == 12.5
assert calls[1].kwargs["start_time"] == 18.0
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_asset_durations_deduped(self, mock_storage):
"""asset_ids 有重复时只查询一次素材时长."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
# a1 出现 3 次,但时长只应查一次
body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"])
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
# 去重后只调用 1 次
assert mock_asset_repo.get.call_count == 1
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_used_segments_passed_to_calc(self, mock_storage):
"""同一素材切多个片段时,used_segments 应被维护并传入."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
captured_used_segments = []
def fake_calc(asset_id, clip_duration, asset_durations, used_segments):
captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()})
return (len(captured_used_segments) - 1) * 5.0
with patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
side_effect=fake_calc,
):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert captured_used_segments[0] == {}
assert captured_used_segments[1] == {"a1": [(0.0, 5.0)]}
assert captured_used_segments[2] == {"a1": [(0.0, 5.0), (5.0, 10.0)]}
class TestEditorClipsErrorHandling:
"""测试异常处理."""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_none_start_time_raises_400(self, mock_storage):
"""_calc_random_start_time 返回 None 时应抛出 HTTPException 400."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
# asset_repo.get 返回 None → asset_durations 为空 → _calc_random_start_time 返回 None
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=None)
body = ClipsFromAssetsRequest(asset_ids=["missing-asset"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "时长信息缺失" in exc_info.value.detail
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_create_clip_value_error_raises_400(self, mock_storage):
"""create_clip 抛出 ValueError 时应转为 HTTPException 400."""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_plan_svc.create_clip = MagicMock(side_effect=ValueError("计划不存在"))
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "创建片段失败" in exc_info.value.detail
class TestMarkClipsReadyAfterCreation:
"""验证 from-assets 创建片段后立即调用 mark_clips_ready,确保渲染管线能找到就绪片段。"""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_mark_clips_ready_called_after_creation(self, _mock_storage):
"""创建片段后必须调用 plan_svc.mark_clips_ready(plan_id)。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0}[aid]))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-xyz",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
# 关键断言:mark_clips_ready 必须被调用,且传入正确的 plan_id
mock_plan_svc.mark_clips_ready.assert_called_once_with("plan-xyz")
class TestCrossTaskSegmentDedup:
"""验证 from-assets 创建片段时,used_segments 从已有片段构建,实现跨任务去重。"""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_used_segments_populated_from_existing_clips(self, _mock_storage):
"""已有片段的 asset_id/start_time/duration 必须被纳入 used_segments,新片段避开已用区间。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 模拟已有片段:asset "a1" 在 0~5s 已使用
existing = [
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id="a1"),
]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-dedup",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
# 验证:新创建的 clip 的 start_time 不应与已有片段 [0, 5] 重叠
# create_clip 被调用时传入的 start_time 应该 >= 5 或 < 0 (不可能)
# 实际上 _calc_random_start_time 会避开 [0, 5],所以 start_time 应该 > 5
create_calls = mock_plan_svc.create_clip.call_args_list
assert len(create_calls) == 1
new_start_time = create_calls[0].kwargs.get("start_time") or create_calls[0][1].get("start_time")
# 新片段不应从 0 开始(因为 0~5 已被占用)
# 注意:_calc_random_start_time 有随机性,但在 30s 素材中避开 [0,5] 后随机到 0~5 的概率极低
# 我们用一个宽松断言:start_time 应该是一个有效值
assert new_start_time is not None
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_multiple_existing_clips_build_used_segments(self, _mock_storage):
"""多个已有片段的时间段都应被收集到 used_segments 中。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 模拟已有片段:asset "a1" 在 [0,5] 和 [10,15] 已使用
existing = [
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id="a1"),
_make_mock_clip("c2", order=1, duration=5.0, start_time=10.0, asset_id="a1"),
]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-dedup2",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
create_calls = mock_plan_svc.create_clip.call_args_list
assert len(create_calls) == 1
new_start_time = create_calls[0].kwargs.get("start_time") or create_calls[0][1].get("start_time")
assert new_start_time is not None
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_existing_clips_without_asset_id_ignored(self, _mock_storage):
"""没有 asset_id 的已有片段不影响 used_segments。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 模拟已有片段:一个没有 asset_id 的片段
existing = [
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id=""),
]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
result = create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-dedup3",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
# 应正常创建,不受空 asset_id 片段影响
assert result.created_count == 1
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_no_existing_clips_works_same_as_before(self, _mock_storage):
"""没有已有片段时,行为与修复前一致(used_segments 为空)。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc(existing_clips=[])
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
result = create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-dedup4",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
assert result.created_count == 1
@@ -1,32 +0,0 @@
"""回归测试:source_edit_plan_id 为空时任务必须被标记为 failed。
背景 (2026-08-24):确认生成卡在 10%。根因是 worker 在
source_edit_plan_id 为空时直接 return failed,但没有调用 mark_failed,
导致 DB 状态永远停在 running。
"""
from pathlib import Path
GENERATION_PY = Path(__file__).resolve().parents[2] / "apps" / "worker" / "worker_app" / "tasks" / "generation.py"
def test_mark_failed_in_else_branch():
"""generation.py 中 source_edit_plan_id 为空的 else 分支必须调用 mark_failed。"""
source = GENERATION_PY.read_text(encoding="utf-8")
# 定位 else 分支:紧跟在 'source_edit_plan_id 为空' 日志之后的 else 块
marker = "source_edit_plan_id 为空"
idx = source.find(marker)
assert idx != -1, f"generation.py 中未找到 '{marker}'"
# 从 marker 位置向后搜索到下一个 return 语句
after_marker = source[idx:]
return_idx = after_marker.find("return {")
assert return_idx != -1, "else 分支中未找到 return 语句"
# 关键断言:marker 和 return 之间必须包含 mark_failed
block = source[idx : idx + return_idx]
assert "mark_failed" in block, (
"else 分支在 return 之前必须调用 _update_task_status(task_id, "
"'mark_failed', ...) 以更新 DB 状态,否则任务永远卡在 running"
)
+331
View File
@@ -0,0 +1,331 @@
"""Unit tests for PR #1338: 确认生成兜底增强 — user_id 查找素材.
覆盖:
- SQLAlchemyAssetRepository.find_ready_videos_by_user
- _auto_fallback_auto_material_mode 策略2 (user_id 兜底)
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages"))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.models import Base
from packages.domain import Asset, AssetStatus
def _make_repo():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
return SQLAlchemyAssetRepository(session)
class TestFindReadyVideosByUser:
"""SQLAlchemyAssetRepository.find_ready_videos_by_user 测试."""
def test_returns_ready_videos_for_user(self):
repo = _make_repo()
user_id = "user-abc-123"
v1 = Asset.create(
project_id="",
library_id="lib-1",
name="video1.mp4",
storage_key="v/v1.mp4",
mime_type="video/mp4",
status=AssetStatus.READY,
uploaded_by_user_id=user_id,
)
v2 = Asset.create(
project_id="",
library_id="lib-1",
name="video2.mp4",
storage_key="v/v2.mp4",
mime_type="video/mp4",
status=AssetStatus.READY,
uploaded_by_user_id=user_id,
)
repo.create(v1)
repo.create(v2)
result = repo.find_ready_videos_by_user(user_id)
assert len(result) == 2
assert {a.id for a in result} == {v1.id, v2.id}
def test_excludes_non_video_assets(self):
repo = _make_repo()
user_id = "user-abc-123"
video = Asset.create(
project_id="",
library_id="lib-1",
name="clip.mp4",
storage_key="v/clip.mp4",
mime_type="video/mp4",
status=AssetStatus.READY,
uploaded_by_user_id=user_id,
)
image = Asset.create(
project_id="",
library_id="lib-1",
name="photo.jpg",
storage_key="v/photo.jpg",
mime_type="image/jpeg",
status=AssetStatus.READY,
uploaded_by_user_id=user_id,
)
repo.create(video)
repo.create(image)
result = repo.find_ready_videos_by_user(user_id)
assert len(result) == 1
assert result[0].id == video.id
def test_excludes_non_ready_assets(self):
repo = _make_repo()
user_id = "user-abc-123"
ready = Asset.create(
project_id="",
library_id="lib-1",
name="ready.mp4",
storage_key="v/ready.mp4",
mime_type="video/mp4",
status=AssetStatus.READY,
uploaded_by_user_id=user_id,
)
uploading = Asset.create(
project_id="",
library_id="lib-1",
name="uploading.mp4",
storage_key="v/uploading.mp4",
mime_type="video/mp4",
status=AssetStatus.UPLOADING,
uploaded_by_user_id=user_id,
)
repo.create(ready)
repo.create(uploading)
result = repo.find_ready_videos_by_user(user_id)
assert len(result) == 1
assert result[0].id == ready.id
def test_excludes_other_users_assets(self):
repo = _make_repo()
my_video = Asset.create(
project_id="",
library_id="lib-1",
name="mine.mp4",
storage_key="v/mine.mp4",
mime_type="video/mp4",
status=AssetStatus.READY,
uploaded_by_user_id="user-A",
)
other_video = Asset.create(
project_id="",
library_id="lib-1",
name="other.mp4",
storage_key="v/other.mp4",
mime_type="video/mp4",
status=AssetStatus.READY,
uploaded_by_user_id="user-B",
)
repo.create(my_video)
repo.create(other_video)
result = repo.find_ready_videos_by_user("user-A")
assert len(result) == 1
assert result[0].id == my_video.id
def test_empty_result_for_unknown_user(self):
repo = _make_repo()
result = repo.find_ready_videos_by_user("nonexistent-user")
assert result == []
def test_respects_limit(self):
repo = _make_repo()
user_id = "user-abc-123"
for i in range(10):
asset = Asset.create(
project_id="",
library_id="lib-1",
name=f"video_{i}.mp4",
storage_key=f"v/v{i}.mp4",
mime_type="video/mp4",
status=AssetStatus.READY,
uploaded_by_user_id=user_id,
)
repo.create(asset)
result = repo.find_ready_videos_by_user(user_id, limit=3)
assert len(result) == 3
class TestAutoFallbackAutoMaterialModeUserId:
"""_auto_fallback_auto_material_mode user_id 兜底策略测试."""
def _make_plan_check(self, project_id="", template_id="tmpl-1"):
plan = MagicMock()
plan.project_id = project_id
plan.template_id = template_id
plan.config = {}
return plan
def _make_clip(self, clip_id="clip-1"):
clip = MagicMock()
clip.id = clip_id
clip.asset_id = ""
return clip
def test_skips_when_no_clips_without_asset(self):
from app.api.routes.templates_editor._fallback import (
_auto_fallback_auto_material_mode,
)
svc = MagicMock()
plan_check = self._make_plan_check()
_auto_fallback_auto_material_mode(
svc,
"plan-1",
plan_check,
[],
MagicMock(),
MagicMock(),
user_id="user-1",
)
svc.assign_asset.assert_not_called()
def test_strategy2_user_id_fallback(self):
from app.api.routes.templates_editor._fallback import (
_auto_fallback_auto_material_mode,
)
svc = MagicMock()
plan_check = self._make_plan_check(project_id="")
clip = self._make_clip("clip-1")
mock_asset = MagicMock()
mock_asset.id = "asset-from-user"
mock_asset.status = AssetStatus.READY
mock_asset.mime_type = "video/mp4"
asset_repo = MagicMock()
asset_repo.find_ready_videos_by_user.return_value = [mock_asset]
asset_library_repo = MagicMock()
_auto_fallback_auto_material_mode(
svc,
"plan-1",
plan_check,
[clip],
asset_library_repo,
asset_repo,
user_id="user-123",
)
asset_repo.find_ready_videos_by_user.assert_called_once_with("user-123")
svc.assign_asset.assert_called_once_with("clip-1", "asset-from-user")
def test_strategy1_takes_priority_over_strategy2(self):
from app.api.routes.templates_editor._fallback import (
_auto_fallback_auto_material_mode,
)
svc = MagicMock()
plan_check = self._make_plan_check(project_id="proj-1")
clip = self._make_clip("clip-1")
mock_lib = MagicMock()
mock_lib.id = "lib-video"
mock_lib.kind = MagicMock()
mock_lib.kind.value = "video"
mock_asset = MagicMock()
mock_asset.id = "asset-from-project"
mock_asset.status = "ready"
mock_asset.mime_type = "video/mp4"
asset_library_repo = MagicMock()
asset_library_repo.find_by_project.return_value = [mock_lib]
asset_repo = MagicMock()
asset_repo.find_by_library.return_value = [mock_asset]
_auto_fallback_auto_material_mode(
svc,
"plan-1",
plan_check,
[clip],
asset_library_repo,
asset_repo,
user_id="user-123",
)
asset_library_repo.find_by_project.assert_called_once_with("proj-1")
asset_repo.find_ready_videos_by_user.assert_not_called()
svc.assign_asset.assert_called_once_with("clip-1", "asset-from-project")
def test_falls_back_when_project_has_no_videos(self):
from app.api.routes.templates_editor._fallback import (
_auto_fallback_auto_material_mode,
)
svc = MagicMock()
plan_check = self._make_plan_check(project_id="proj-1")
clip = self._make_clip("clip-1")
asset_library_repo = MagicMock()
asset_library_repo.find_by_project.return_value = []
mock_asset = MagicMock()
mock_asset.id = "asset-from-user"
mock_asset.status = AssetStatus.READY
mock_asset.mime_type = "video/mp4"
asset_repo = MagicMock()
asset_repo.find_ready_videos_by_user.return_value = [mock_asset]
_auto_fallback_auto_material_mode(
svc,
"plan-1",
plan_check,
[clip],
asset_library_repo,
asset_repo,
user_id="user-123",
)
asset_repo.find_ready_videos_by_user.assert_called_once_with("user-123")
svc.assign_asset.assert_called_once_with("clip-1", "asset-from-user")
def test_no_assets_found_does_nothing(self):
from app.api.routes.templates_editor._fallback import (
_auto_fallback_auto_material_mode,
)
svc = MagicMock()
plan_check = self._make_plan_check(project_id="")
clip = self._make_clip("clip-1")
asset_repo = MagicMock()
asset_repo.find_ready_videos_by_user.return_value = []
asset_library_repo = MagicMock()
_auto_fallback_auto_material_mode(
svc,
"plan-1",
plan_check,
[clip],
asset_library_repo,
asset_repo,
user_id="user-123",
)
svc.assign_asset.assert_not_called()
def test_no_user_id_skips_strategy2(self):
from app.api.routes.templates_editor._fallback import (
_auto_fallback_auto_material_mode,
)
svc = MagicMock()
plan_check = self._make_plan_check(project_id="")
clip = self._make_clip("clip-1")
asset_repo = MagicMock()
asset_library_repo = MagicMock()
_auto_fallback_auto_material_mode(
svc,
"plan-1",
plan_check,
[clip],
asset_library_repo,
asset_repo,
user_id="",
)
asset_repo.find_ready_videos_by_user.assert_not_called()
svc.assign_asset.assert_not_called()
+369
View File
@@ -0,0 +1,369 @@
"""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()
@@ -321,27 +321,6 @@ class TestThumbnailInDedupHelpers:
video_processing.dedup = mock_dedup
video_processing.thumbnail_generator = mock_thumb
@classmethod
def teardown_class(cls):
"""恢复 setup_class 中替换的模块,避免污染后续测试。"""
import sys
import video_processing
# 从 sys.modules 移除 mock 模块
for mod_name in ("video_processing.thumbnail_generator", "video_processing.dedup"):
sys.modules.pop(mod_name, None)
# 重新导入真实模块以恢复 sys.modules
try:
import video_processing.thumbnail_generator # noqa: F401
except Exception:
pass
try:
import video_processing.dedup # noqa: F401
except Exception:
pass
def test_pre_generated_thumbnail_url_is_reused(self):
"""传入 thumbnail_url 时直接复用,统一封面管道不再自动生成缩略图。"""
from video_processing.dedup_helpers import create_video_record_and_dedup
@@ -0,0 +1,389 @@
"""P3 优化单元测试 — generation.py 三项优化.
覆盖:
P3-1: _download_library_assets strict 模式
P3-2: 归属校验合并到同一 DB session
P3-3: _verify_url_accessible HEAD 重试
"""
from __future__ import annotations
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
# ── 预注入 mock 模块,防止 worker_app.db 触发真实数据库连接 ──
# worker_app.db 在模块级别调用 ensure_database_exists() 尝试连接 PostgreSQL,
# 增量测试单独跑这些文件时会失败。与 test_voice_clone_task.py 同理。
_mock_db_module = MagicMock()
_mock_db_module.SessionLocal = MagicMock()
sys.modules.setdefault("worker_app.db", _mock_db_module)
if "worker_app" in sys.modules:
sys.modules["worker_app"].db = _mock_db_module
# ── P3-3: _verify_url_accessible 重试 ───────────────────────────────────────
class TestVerifyUrlAccessibleRetry:
"""_verify_url_accessible 重试逻辑."""
@patch("time.sleep")
@patch("urllib.request.OpenerDirector.open")
def test_first_attempt_success(self, mock_open, mock_sleep):
"""首次成功,不重试."""
from worker_app.tasks.generation import _verify_url_accessible
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
mock_open.return_value = mock_resp
assert _verify_url_accessible("https://example.com/file.mp4") is True
assert mock_open.call_count == 1
mock_sleep.assert_not_called()
@patch("time.sleep")
@patch("urllib.request.OpenerDirector.open")
def test_retry_then_success(self, mock_open, mock_sleep):
"""首次失败,重试后成功."""
from worker_app.tasks.generation import _verify_url_accessible
# 第一次失败(网络异常),第二次成功
mock_resp_ok = MagicMock()
mock_resp_ok.status = 200
mock_resp_ok.__enter__ = MagicMock(return_value=mock_resp_ok)
mock_resp_ok.__exit__ = MagicMock(return_value=False)
mock_open.side_effect = [
OSError("connection reset"),
mock_resp_ok,
]
assert _verify_url_accessible("https://example.com/file.mp4") is True
assert mock_open.call_count == 2
mock_sleep.assert_called_once_with(1)
@patch("time.sleep")
@patch("urllib.request.OpenerDirector.open")
def test_all_retries_exhausted(self, mock_open, mock_sleep):
"""全部重试耗尽,返回 False."""
from worker_app.tasks.generation import _verify_url_accessible
mock_open.side_effect = OSError("connection refused")
assert _verify_url_accessible("https://example.com/file.mp4") is False
# 1 首次 + 2 重试 = 3 次
assert mock_open.call_count == 3
assert mock_sleep.call_count == 2
@patch("time.sleep")
@patch("urllib.request.OpenerDirector.open")
def test_http_500_then_success(self, mock_open, mock_sleep):
"""HTTP 500 后重试成功."""
from worker_app.tasks.generation import _verify_url_accessible
mock_resp_500 = MagicMock()
mock_resp_500.status = 500
mock_resp_500.__enter__ = MagicMock(return_value=mock_resp_500)
mock_resp_500.__exit__ = MagicMock(return_value=False)
mock_resp_200 = MagicMock()
mock_resp_200.status = 200
mock_resp_200.__enter__ = MagicMock(return_value=mock_resp_200)
mock_resp_200.__exit__ = MagicMock(return_value=False)
mock_open.side_effect = [mock_resp_500, mock_resp_200]
assert _verify_url_accessible("https://example.com/file.mp4") is True
assert mock_open.call_count == 2
@patch("time.sleep")
@patch("urllib.request.OpenerDirector.open")
def test_custom_retries_zero(self, mock_open, mock_sleep):
"""retries=0 时不重试."""
from worker_app.tasks.generation import _verify_url_accessible
mock_open.side_effect = OSError("timeout")
assert _verify_url_accessible("https://example.com/file.mp4", retries=0) is False
assert mock_open.call_count == 1
mock_sleep.assert_not_called()
# ── P3-1: _download_library_assets strict 模式 ──────────────────────────────
def _make_mock_asset(
asset_id: str,
name: str,
file_url: str | None,
asset_library_id: str = "lib-1",
project_id: str = "",
):
"""构造 mock AssetModel 实例."""
return SimpleNamespace(
id=asset_id,
name=name,
file_url=file_url,
asset_library_id=asset_library_id,
project_id=project_id,
status="ready",
file_type="video",
created_at="2026-01-01",
)
def _setup_mock_session(assets):
"""构造 mock session,返回 (mock_session, mock_query_chain)."""
mock_session = MagicMock()
mock_query = MagicMock()
# chain: session.query().filter().filter().order_by().all()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = assets
return mock_session
class TestDownloadLibraryAssetsStrictMode:
"""_download_library_assets strict 模式."""
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_strict_mode_raises_on_download_failure(self, mock_download, mock_session_factory):
"""strict=True 时,单个素材下载失败立即抛 RuntimeError."""
from worker_app.tasks.generation import _download_library_assets
assets = [
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4"),
_make_mock_asset("a2", "video2.mp4", "uploads/video2.mp4"),
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
# 第一个成功,第二个失败
mock_download.side_effect = [True, False]
with pytest.raises(RuntimeError, match="素材下载失败"):
_download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
asset_ids=["a1", "a2"],
strict=True,
)
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_non_strict_mode_returns_partial_results(self, mock_download, mock_session_factory):
"""strict=False 时,跳过失败素材,返回成功列表."""
from worker_app.tasks.generation import _download_library_assets
assets = [
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4"),
_make_mock_asset("a2", "video2.mp4", "uploads/video2.mp4"),
_make_mock_asset("a3", "video3.mp4", "uploads/video3.mp4"),
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
# 第一个成功,第二个失败,第三个成功
mock_download.side_effect = [True, False, True]
result = _download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
asset_ids=["a1", "a2", "a3"],
strict=False,
)
assert len(result) == 2
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_non_strict_all_fail_raises(self, mock_download, mock_session_factory):
"""strict=False 但全部失败时仍抛 RuntimeError."""
from worker_app.tasks.generation import _download_library_assets
assets = [
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4"),
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
mock_download.return_value = False
with pytest.raises(RuntimeError, match="全部下载失败"):
_download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
asset_ids=["a1"],
strict=False,
)
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_strict_mode_raises_on_missing_file_url(self, mock_download, mock_session_factory):
"""strict=True 时,素材缺少 file_url 立即抛异常."""
from worker_app.tasks.generation import _download_library_assets
assets = [
_make_mock_asset("a1", "video1.mp4", None), # file_url 为空
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
with pytest.raises(RuntimeError, match="素材缺少 file_url"):
_download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
strict=True,
)
# download_asset 不应被调用
mock_download.assert_not_called()
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_default_is_strict(self, mock_download, mock_session_factory):
"""默认 strict=True."""
from worker_app.tasks.generation import _download_library_assets
assets = [
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4"),
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
mock_download.return_value = False
# 不传 strict 参数,默认严格模式
with pytest.raises(RuntimeError, match="素材下载失败"):
_download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
)
# ── P3-2: 归属校验合并到同一 session ────────────────────────────────────────
class TestDownloadLibraryAssetsOwnershipValidation:
"""归属校验合并到 _download_library_assets 同一 session."""
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_ownership_mismatch_raises_value_error(self, mock_download, mock_session_factory):
"""asset_ids 不属于指定素材库时抛 ValueError."""
from worker_app.tasks.generation import _download_library_assets
# asset 属于 lib-2,但请求的是 lib-1
assets = [
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4", asset_library_id="lib-2"),
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
with pytest.raises(ValueError, match="素材不属于指定素材库"):
_download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
asset_ids=["a1"],
)
# 不应调用 download_asset(校验在下载前)
mock_download.assert_not_called()
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_missing_asset_ids_raises_value_error(self, mock_download, mock_session_factory):
"""指定的 asset_ids 不存在时抛 ValueError."""
from worker_app.tasks.generation import _download_library_assets
# DB 返回空(asset_ids 不存在,query 过滤后无结果)
mock_session = _setup_mock_session([])
mock_session_factory.return_value = mock_session
with pytest.raises(RuntimeError, match="未找到视频素材"):
_download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
asset_ids=["nonexistent-id"],
)
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_project_ownership_mismatch_raises(self, mock_download, mock_session_factory):
"""项目级模式下归属不匹配抛 ValueError."""
from worker_app.tasks.generation import _download_library_assets
assets = [
_make_mock_asset(
"a1",
"video1.mp4",
"uploads/video1.mp4",
asset_library_id="",
project_id="proj-2",
),
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
with pytest.raises(ValueError, match="素材不属于指定项目"):
_download_library_assets(
Path("/tmp"),
project_id="proj-1",
asset_ids=["a1"],
)
mock_download.assert_not_called()
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_ownership_pass_then_download(self, mock_download, mock_session_factory):
"""归属校验通过后正常下载."""
from worker_app.tasks.generation import _download_library_assets
assets = [
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4", asset_library_id="lib-1"),
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
mock_download.return_value = True
result = _download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
asset_ids=["a1"],
)
assert len(result) == 1
mock_download.assert_called_once()
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_single_session_used(self, mock_download, mock_session_factory):
"""验证只创建了一个 DB session(P3-2 核心)."""
from worker_app.tasks.generation import _download_library_assets
assets = [
_make_mock_asset("a1", "video1.mp4", "uploads/video1.mp4", asset_library_id="lib-1"),
]
mock_session = _setup_mock_session(assets)
mock_session_factory.return_value = mock_session
mock_download.return_value = True
_download_library_assets(
Path("/tmp"),
asset_library_id="lib-1",
asset_ids=["a1"],
)
# SessionLocal 只调用一次(合并前会调用两次:校验 + 下载)
assert mock_session_factory.call_count == 1
+1 -4
View File
@@ -1206,10 +1206,7 @@ class TestPreviewRouteAutoInfersVideoRatio:
# Verify the resolution passed to CreateGenerationTaskCommand
call_args = MockUC.return_value.execute.call_args
cmd = call_args[0][0]
# video_ratio inferred from pip → 9:16 → resolution=1080x1920
assert cmd.resolution == "1080x1920", f"Expected 1080x1920, got {cmd.resolution}"
assert cmd.output_width == 1080, f"Expected output_width=1080, got {cmd.output_width}"
assert cmd.output_height == 1920, f"Expected output_height=1920, got {cmd.output_height}"
assert cmd.resolution == "", f"Expected empty resolution, got {cmd.resolution}"
# ═══════════════════════════════════════════════════════════════════════════════
+228
View File
@@ -0,0 +1,228 @@
"""Tests for generation.py worker-side fixes: segment durations + preview resolution."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
# Add worker app to path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
def _patch_session_local(mock_session):
"""Patch worker_app.db.SessionLocal robustly even when other tests
have pre-registered a MagicMock for worker_app.db in sys.modules.
Uses patch.dict to inject a clean module so that
'from worker_app.db import SessionLocal' resolves correctly."""
from types import ModuleType
_fresh_db = ModuleType("worker_app.db")
_fresh_db.SessionLocal = lambda *a, **kw: mock_session
return patch.dict(sys.modules, {"worker_app.db": _fresh_db})
class TestLoadTemplateSegmentDurations:
"""_load_template_segment_durations 单元测试 (covers lines 198-226)."""
def test_empty_template_id(self):
"""空 template_id 直接返回空列表。"""
from worker_app.tasks.generation import _load_template_segment_durations
result = _load_template_segment_durations("")
assert result == []
def test_loads_durations_ordered(self):
"""按 segment_order 排序返回 duration_max 列表。"""
from worker_app.tasks.generation import _load_template_segment_durations
mock_seg1 = MagicMock(duration_max=5.0)
mock_seg2 = MagicMock(duration_max=8.0)
mock_seg3 = MagicMock(duration_max=3.0)
mock_query = MagicMock()
mock_query.filter.return_value.order_by.return_value.all.return_value = [
mock_seg1,
mock_seg2,
mock_seg3,
]
mock_session = MagicMock()
mock_session.query.return_value = mock_query
with _patch_session_local(mock_session):
result = _load_template_segment_durations("tpl_123")
assert result == [5.0, 8.0, 3.0]
def test_filters_zero_and_negative(self):
"""duration_max <= 0 的 segment 被过滤。"""
from worker_app.tasks.generation import _load_template_segment_durations
mock_seg_valid = MagicMock(duration_max=5.0)
mock_seg_zero = MagicMock(duration_max=0.0)
mock_seg_none = MagicMock(duration_max=None)
mock_query = MagicMock()
mock_query.filter.return_value.order_by.return_value.all.return_value = [
mock_seg_valid,
mock_seg_zero,
mock_seg_none,
]
mock_session = MagicMock()
mock_session.query.return_value = mock_query
with _patch_session_local(mock_session):
result = _load_template_segment_durations("tpl_456")
assert result == [5.0]
def test_db_error_returns_empty(self):
"""数据库异常返回空列表,不抛出。"""
from types import ModuleType
from worker_app.tasks.generation import _load_template_segment_durations
_err_db = ModuleType("worker_app.db")
def _raise(*a, **kw):
raise Exception("DB down")
_err_db.SessionLocal = _raise
with patch.dict(sys.modules, {"worker_app.db": _err_db}):
result = _load_template_segment_durations("tpl_789")
assert result == []
def test_empty_segments_returns_empty(self):
"""没有 segment 时返回空列表。"""
from worker_app.tasks.generation import _load_template_segment_durations
mock_query = MagicMock()
mock_query.filter.return_value.order_by.return_value.all.return_value = []
mock_session = MagicMock()
mock_session.query.return_value = mock_query
with _patch_session_local(mock_session):
result = _load_template_segment_durations("tpl_empty")
assert result == []
class TestDurationCappingInBuildPlan:
"""_build_plan_and_clips_from_task 时长约束测试 (covers lines 322-333)."""
def _make_temp_video(self, tmpdir: Path, name: str = "v.mp4") -> Path:
p = tmpdir / name
p.write_bytes(b"\x00" * 100)
return p
def test_clips_capped_by_segment_max(self):
"""clip 时长超过 segment duration_max 时截断。"""
import tempfile
from worker_app.tasks.generation import _build_plan_and_clips_from_task
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
paths = [self._make_temp_video(tmpdir, f"v{i}.mp4") for i in range(3)]
with patch("worker_app.tasks.generation.probe_duration", return_value=30.0):
with patch(
"worker_app.tasks.generation._load_template_segment_durations",
return_value=[5.0, 4.0, 3.0],
):
with patch(
"worker_app.tasks.generation._load_template_clip_configs",
return_value=[],
):
_, clips, _ = _build_plan_and_clips_from_task(
task_id="test_cap",
downloaded_paths=paths,
mode="one_take",
template_id="tpl_test",
)
assert clips[0].duration == 5.0
assert clips[1].duration == 4.0
assert clips[2].duration == 3.0
def test_clips_not_capped_when_under_max(self):
"""clip 时长小于 segment duration_max 时不截断。"""
import tempfile
from worker_app.tasks.generation import _build_plan_and_clips_from_task
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
paths = [self._make_temp_video(tmpdir)]
with patch("worker_app.tasks.generation.probe_duration", return_value=3.0):
with patch(
"worker_app.tasks.generation._load_template_segment_durations",
return_value=[5.0],
):
with patch(
"worker_app.tasks.generation._load_template_clip_configs",
return_value=[],
):
_, clips, _ = _build_plan_and_clips_from_task(
task_id="test_no_cap",
downloaded_paths=paths,
mode="one_take",
template_id="tpl_test",
)
assert clips[0].duration == 3.0
def test_no_capping_without_template(self):
"""无 template_id 时不截断。"""
import tempfile
from worker_app.tasks.generation import _build_plan_and_clips_from_task
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
paths = [self._make_temp_video(tmpdir)]
with patch("worker_app.tasks.generation.probe_duration", return_value=30.0):
_, clips, _ = _build_plan_and_clips_from_task(
task_id="test_no_tpl",
downloaded_paths=paths,
mode="one_take",
template_id="",
)
assert clips[0].duration == 30.0
def test_partial_segments_only_caps_matching(self):
"""segment 数量少于 clip 时,只截断有对应 segment 的 clip。"""
import tempfile
from worker_app.tasks.generation import _build_plan_and_clips_from_task
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
paths = [self._make_temp_video(tmpdir, f"v{i}.mp4") for i in range(3)]
with patch("worker_app.tasks.generation.probe_duration", return_value=20.0):
with patch(
"worker_app.tasks.generation._load_template_segment_durations",
return_value=[5.0], # only 1 segment for 3 clips
):
with patch(
"worker_app.tasks.generation._load_template_clip_configs",
return_value=[],
):
_, clips, _ = _build_plan_and_clips_from_task(
task_id="test_partial",
downloaded_paths=paths,
mode="one_take",
template_id="tpl_test",
)
assert clips[0].duration == 5.0 # capped
assert clips[1].duration == 20.0 # not capped (no matching segment)
assert clips[2].duration == 20.0 # not capped
+440
View File
@@ -1,6 +1,7 @@
"""P0/P1 修复单元测试 — 一键生成 P0 问题 + P1 校验.
覆盖:
P0-1: _download_library_assets 双模式查询(asset_library_id / project_id)
P0-2: OSS 上传失败抛异常 + URL 可访问性校验
P0-3: FFmpeg 失败时完整 stderr 日志
P1: template_id 存在性校验 + asset_ids 归属校验
@@ -25,6 +26,130 @@ if str(_WORKER_ROOT) not in sys.path:
# ── P0-1: _download_library_assets ────────────────────────────────────────────
class TestDownloadLibraryAssets:
"""P0-1: 素材下载双模式 + 错误处理."""
def _make_asset(self, id_: str, file_url: str, project_id: str = "p1", library_id: str = "lib1"):
mock = MagicMock()
mock.id = id_
mock.file_url = file_url
mock.name = f"asset_{id_}"
mock.project_id = project_id
mock.asset_library_id = library_id
return mock
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_asset_library_mode(self, mock_download, mock_session_factory):
"""素材库模式:按 asset_library_id 查询."""
from worker_app.tasks.generation import _download_library_assets
session = MagicMock()
mock_session_factory.return_value = session
query = MagicMock()
session.query.return_value = query
filter_result = MagicMock()
query.filter.return_value = filter_result
in_filter = MagicMock()
filter_result.filter.return_value = in_filter
assets = [self._make_asset("a1", "video/a1.mp4")]
in_filter.order_by.return_value.all.return_value = assets
mock_download.return_value = True
with patch("worker_app.tasks.generation.AssetModel", create=True):
result = _download_library_assets(
Path("/tmp/test"),
asset_library_id="lib1",
)
assert len(result) == 1
mock_download.assert_called_once()
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_project_mode(self, mock_download, mock_session_factory):
"""项目级模式:asset_library_id 为空时按 project_id 查询."""
from worker_app.tasks.generation import _download_library_assets
session = MagicMock()
mock_session_factory.return_value = session
query = MagicMock()
session.query.return_value = query
filter_result = MagicMock()
query.filter.return_value = filter_result
proj_filter = MagicMock()
filter_result.filter.return_value = proj_filter
assets = [self._make_asset("a1", "video/a1.mp4", project_id="proj1")]
proj_filter.order_by.return_value.all.return_value = assets
mock_download.return_value = True
result = _download_library_assets(
Path("/tmp/test"),
project_id="proj1",
)
assert len(result) == 1
def test_both_empty_raises(self):
"""asset_library_id 和 project_id 都为空时抛 ValueError."""
from worker_app.tasks.generation import _download_library_assets
with pytest.raises(ValueError, match="至少需要提供一个"):
_download_library_assets(Path("/tmp/test"))
@patch("worker_app.tasks.generation.SessionLocal")
def test_no_assets_found_raises(self, mock_session_factory):
"""查不到素材时抛 RuntimeError."""
from worker_app.tasks.generation import _download_library_assets
session = MagicMock()
mock_session_factory.return_value = session
query = MagicMock()
session.query.return_value = query
filter_result = MagicMock()
query.filter.return_value = filter_result
in_filter = MagicMock()
filter_result.filter.return_value = in_filter
in_filter.order_by.return_value.all.return_value = []
with pytest.raises(RuntimeError, match="未找到视频素材"):
_download_library_assets(
Path("/tmp/test"),
asset_library_id="lib1",
)
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_all_asset_ids_fail_raises(self, mock_download, mock_session_factory):
"""指定 asset_ids 但全部下载失败时抛 RuntimeError."""
from worker_app.tasks.generation import _download_library_assets
session = MagicMock()
mock_session_factory.return_value = session
query = MagicMock()
session.query.return_value = query
filter_result = MagicMock()
query.filter.return_value = filter_result
id_filter = MagicMock()
filter_result.filter.return_value = id_filter
assets = [self._make_asset("a1", "video/a1.mp4")]
id_filter.order_by.return_value.all.return_value = assets
mock_download.return_value = False # 全部下载失败
with pytest.raises(RuntimeError, match="素材下载失败"):
_download_library_assets(
Path("/tmp/test"),
asset_library_id="lib1",
asset_ids=["a1"],
)
# ── P0-2: OSS 上传 + URL 校验 ────────────────────────────────────────────────
class TestOSSUploadAndVerify:
"""P0-2: OSS 上传失败抛异常 + URL 可访问性校验."""
@@ -165,3 +290,318 @@ class TestP1Validations:
# ── P1: 一键生成 clip 级效果层映射 ───────────────────────────────────────────
class TestTemplateClipEffectMapping:
"""P1: 模板 clip 级效果层映射到一键生成素材 clips."""
def _make_virtual_clip(self, idx: int, clip_type: str = "main", config: dict | None = None):
from dataclasses import dataclass, field
_clip_type_val = clip_type
@dataclass
class FakeClip:
id: str = f"vc_{idx:03d}"
plan_id: str = "task_001"
clip_type: str = _clip_type_val
order: int = idx
asset_id: str = f"asset_{idx}"
duration: float = 5.0
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
config: dict = field(default_factory=dict)
return FakeClip(config=config or {})
def _make_template_clip_config(self, clip_type: str = "main", transition: str = "cut", config: dict | None = None):
mock = MagicMock()
mock.clip_type = clip_type
mock.transition_effect = transition
mock.config = config or {}
mock.default_duration = 3.0
mock.text_template = ""
return mock
def test_transition_effect_mapped(self):
"""转场效果正确映射到素材 clips."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(3)]
clip_configs = [
self._make_template_clip_config("main", transition="fade"),
self._make_template_clip_config("main", transition="dissolve"),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# 前两个按顺序映射,第三个用最后一个模板配置
assert clips[0].transition_effect == "fade"
assert clips[1].transition_effect == "dissolve"
assert clips[2].transition_effect == "dissolve" # 复用最后一个
def test_color_grade_mapped(self):
"""滤镜配置正确映射到 clip.config.color_grade."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(2)]
clip_configs = [
self._make_template_clip_config(
"main", config={"color_grade": {"enabled": True, "filter": "vintage", "brightness": 0.1}}
),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
assert clips[0].config["color_grade"]["filter"] == "vintage"
assert clips[0].config["color_grade"]["brightness"] == 0.1
# 第二个素材复用第一个模板配置
assert clips[1].config["color_grade"]["filter"] == "vintage"
def test_existing_config_preserved(self):
"""已有 clip.config 内容(如 role)被保留."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0, config={"role": "b_roll"})]
clip_configs = [
self._make_template_clip_config("main", config={"color_grade": {"enabled": True, "filter": "warm"}}),
]
_apply_template_clip_effects(clips, clip_configs, "voice_over")
assert clips[0].config["role"] == "b_roll" # 保留原有配置
assert clips[0].config["color_grade"]["filter"] == "warm" # 新增滤镜配置
def test_empty_clip_configs_no_change(self):
"""空模板配置时 clips 保持不变."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(2)]
_apply_template_clip_effects(clips, [], "one_take")
assert clips[0].transition_effect == "cut"
assert clips[1].transition_effect == "cut"
def test_cut_transition_not_overwritten(self):
"""模板转场为 cut 时不覆盖(保持默认)."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0)]
clips[0].transition_effect = "fade" # 已有非默认值
clip_configs = [
self._make_template_clip_config("main", transition="cut"),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# 模板是 cut 时,保留原有值(避免无意义覆盖)
assert clips[0].transition_effect == "fade"
def test_transition_duration_mapped(self):
"""转场时长(transition_duration)从模板 config 正确映射到 clip."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(3)]
clip_configs = [
self._make_template_clip_config("main", transition="fade", config={"transition_duration": 0.8}),
self._make_template_clip_config("main", transition="dissolve", config={"transition_duration": 1.2}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# 前两个按顺序映射,第三个复用最后一个
assert clips[0].transition_effect == "fade"
assert clips[0].transition_duration == 0.8
assert clips[1].transition_effect == "dissolve"
assert clips[1].transition_duration == 1.2
assert clips[2].transition_effect == "dissolve"
assert clips[2].transition_duration == 1.2
def test_transition_duration_ignored_for_cut(self):
"""模板转场为 cut 时,transition_duration 不生效(保持默认0)."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0)]
clip_configs = [
self._make_template_clip_config("main", transition="cut", config={"transition_duration": 0.5}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# cut 转场不映射,transition_duration 也不应用
assert clips[0].transition_duration == 0.0
def test_transition_duration_invalid_value_skipped(self):
"""transition_duration 为无效值时安全跳过."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0)]
clip_configs = [
self._make_template_clip_config("main", transition="fade", config={"transition_duration": "abc"}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
assert clips[0].transition_effect == "fade"
assert clips[0].transition_duration == 0.0 # 无效值保持默认
def test_intro_outro_extracted(self):
"""intro/outro 类型 clip_config 正确提取为 plan 级 intro_outro 配置."""
from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs
clip_configs = [
self._make_template_clip_config("intro", config={"intro_type": "text", "intro_text_color": "#ffffff"}),
self._make_template_clip_config("main"),
self._make_template_clip_config("outro", config={"outro_type": "follow", "outro_follow_text": "关注我们"}),
]
# 设置 intro/outro 的 text_template
clip_configs[0].text_template = "精彩视频"
clip_configs[0].default_duration = 2.5
result = _extract_intro_outro_from_clip_configs(clip_configs)
assert result["has_intro"] is True
assert result["intro_type"] == "text"
assert result["intro_text"] == "精彩视频"
assert result["intro_duration"] == 2.5
assert result["intro_text_color"] == "#ffffff"
assert result["has_outro"] is True
assert result["outro_type"] == "follow"
assert result["outro_follow_text"] == "关注我们"
def test_intro_outro_empty_when_none(self):
"""没有 intro/outro 时返回空 dict."""
from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs
clip_configs = [
self._make_template_clip_config("main"),
self._make_template_clip_config("main"),
]
result = _extract_intro_outro_from_clip_configs(clip_configs)
assert result == {}
class TestTemplatePlanConfigLoading:
"""验证从模板加载 plan 级配置(BGM、字幕、标题)的逻辑。"""
def _mock_template(
self,
title_config=None,
subtitle_config=None,
bgm_config=None,
is_active=True,
):
template = MagicMock()
template.id = "tmpl_001"
template.name = "Test Template"
template.is_active = is_active
template.title_config = title_config or {}
template.subtitle_config = subtitle_config or {}
template.bgm_config = bgm_config or {}
return template
def _mock_session(self, template):
session = MagicMock()
# EditTemplateModel 查询返回 None(走旧模板系统 fallback)
edit_query = MagicMock()
edit_filter = MagicMock()
edit_query.filter.return_value = edit_filter
edit_filter.first.return_value = None
# TemplateModel 查询返回 template(旧模板系统)
old_query = MagicMock()
old_filter = MagicMock()
old_query.filter.return_value = old_filter
old_filter.first.return_value = template
def _query_side_effect(model):
name = getattr(model, "__name__", "")
if "EditTemplate" in name:
return edit_query
return old_query
session.query.side_effect = _query_side_effect
return session
def test_load_template_config_assembles_three_fields(self):
"""模板的三个独立字段正确组装成 plan.config 格式。"""
from worker_app.tasks.generation import _load_template_plan_config
title_cfg = {"enabled": True, "text": "我的标题", "font_size": 36}
subtitle_cfg = {"enabled": True, "auto_generated": True, "language": "zh"}
bgm_cfg = {"enabled": True, "preset_id": "bgm-001", "volume": 0.5}
template = self._mock_template(
title_config=title_cfg,
subtitle_config=subtitle_cfg,
bgm_config=bgm_cfg,
)
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert result["title"] == title_cfg
assert result["subtitle"] == subtitle_cfg
assert result["bgm"] == bgm_cfg
def test_load_template_config_empty_template_returns_empty(self):
"""模板三个字段都为空时返回空 dict。"""
from worker_app.tasks.generation import _load_template_plan_config
template = self._mock_template()
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert result == {}
def test_load_template_config_only_bgm(self):
"""只有 BGM 配置时只返回 bgm 字段。"""
from worker_app.tasks.generation import _load_template_plan_config
bgm_cfg = {"enabled": True, "audio_url": "https://example.com/bgm.mp3"}
template = self._mock_template(bgm_config=bgm_cfg)
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert "bgm" in result
assert result["bgm"] == bgm_cfg
assert "title" not in result
assert "subtitle" not in result
def test_load_template_config_empty_template_id(self):
"""空 template_id 直接返回空 dict。"""
from worker_app.tasks.generation import _load_template_plan_config
result = _load_template_plan_config("")
assert result == {}
result = _load_template_plan_config(None)
assert result == {}
def test_load_template_config_not_found_returns_empty(self):
"""模板不存在时返回空 dict,不抛异常。"""
from worker_app.tasks.generation import _load_template_plan_config
session = MagicMock()
def _query_side_effect(model):
q = MagicMock()
f = MagicMock()
q.filter.return_value = f
f.first.return_value = None
return q
session.query.side_effect = _query_side_effect
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_nonexist")
assert result == {}
-215
View File
@@ -1,215 +0,0 @@
"""回归测试:render_plan 新路径必须保留视频素材原声。
历史 Bug:mix_audio 中 `main_clips = []` 无条件丢弃源视频原声,
导致最终生成视频没有原声(与预览不一致)。本测试钉住新行为:
- 有音频流的 main/broll clip 原声必须进入最终音轨
- clip.config.volume=0 静音,volume≠1.0 应用音量滤镜
- 无音频流的素材被安全过滤
- 直通(pass-through)路径同样尊重 probe + volume
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from video_processing.render_audio import (
RenderContext,
_clip_volume,
mix_audio,
)
from video_processing.unified_render_service import (
ResolvedClip,
UnifiedRenderService,
)
def _ctx() -> RenderContext:
return RenderContext(work_dir=Path("/tmp/test_render_audio_fix"), plan_id="plan_audio")
def _clip(
cid: str,
*,
clip_type: str = "main",
order: int = 0,
duration: float = 5.0,
config: dict | None = None,
asset: str | None = None,
) -> ResolvedClip:
return ResolvedClip(
clip_id=cid,
asset_id=asset or f"asset_{cid}.mp4",
clip_type=clip_type,
order=order,
local_path=Path(f"/tmp/asset_{cid}.mp4"),
duration=duration,
actual_duration=duration,
config=config or {},
)
def _layers(svc, clips):
# 直接把 ResolvedClip 分组为图层,跳过 _resolve_clips(后者要求原始 EditPlanClip)
return svc._group_clips_into_layers(clips)
def _service(clips):
paths = {c.asset_id: c.local_path for c in clips}
return UnifiedRenderService(
plan=type("P", (), {"id": "plan_audio", "config": {}})(),
clips=clips,
asset_path_map=paths,
work_dir=Path("/tmp/test_render_audio_fix"),
output_width=1080,
output_height=1920,
output_fps=25,
)
class TestOriginalAudioRetained:
"""钉住原声不再被丢弃。"""
def test_single_main_clip_audio_kept(self):
svc = _service([_clip("c1")])
with (
patch("pathlib.Path.exists", return_value=True),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch("video_processing.render_audio.probe_has_audio", return_value=True),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
result = mix_audio(_ctx(), _layers(svc, [_clip("c1")]), 5.0)
assert result is not None
cmd_str = " ".join(mock_run.call_args[0][0])
assert "asset_c1.mp4" in cmd_str
def test_multi_main_clips_concat_audio(self):
clips = [_clip("c1", order=0), _clip("c2", order=1)]
svc = _service(clips)
with (
patch("pathlib.Path.exists", return_value=True),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch("video_processing.render_audio.probe_has_audio", return_value=True),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
result = mix_audio(_ctx(), _layers(svc, clips), 9.0)
assert result is not None
cmd_str = " ".join(mock_run.call_args[0][0])
assert "asset_c1.mp4" in cmd_str
assert "asset_c2.mp4" in cmd_str
assert "concat=n=2:v=0:a=1" in cmd_str
def test_main_audio_plus_independent_track_amix(self):
clips = [
_clip("c1", order=0),
_clip("tts1", order=1, config={"role": "audio", "volume": 0.5}),
]
svc = _service(clips)
with (
patch("pathlib.Path.exists", return_value=True),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch("video_processing.render_audio.probe_has_audio", return_value=True),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
result = mix_audio(_ctx(), _layers(svc, clips), 5.0)
assert result is not None
cmd_str = " ".join(mock_run.call_args[0][0])
assert "amix=inputs=2" in cmd_str
assert "asset_c1.mp4" in cmd_str
assert "asset_tts1.mp4" in cmd_str
assert "volume=0.5" in cmd_str
def test_silent_clip_volume_zero_retained_with_silence_filter(self):
"""volume=0 的素材必须保留在 concat 中(用 volume=0 滤镜静音),不能移除以避免音画不同步。"""
clips = [_clip("mute", order=0, config={"volume": 0})]
svc = _service(clips)
with (
patch("pathlib.Path.exists", return_value=True),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch("video_processing.render_audio.probe_has_audio", return_value=True),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
result = mix_audio(_ctx(), _layers(svc, clips), 5.0)
# 有音频流 → 应生成音频文件,且 ffmpeg 命令包含 volume=0.0000 静音滤镜
assert result is not None
mock_run.assert_called_once()
cmd_str = " ".join(mock_run.call_args[0][0])
assert "volume=0" in cmd_str
def test_no_audio_stream_returns_none(self):
clips = [_clip("c1")]
svc = _service(clips)
with (
patch("pathlib.Path.exists", return_value=True),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch("video_processing.render_audio.probe_has_audio", return_value=False),
patch("video_processing.render_audio.run_ffmpeg") as mock_run,
):
result = mix_audio(_ctx(), _layers(svc, clips), 5.0)
assert result is None
mock_run.assert_not_called()
def test_volume_helper_default_and_override(self):
assert _clip_volume(_clip("c1")) == 1.0
assert _clip_volume(_clip("c2", config={"volume": 0.3})) == pytest.approx(0.3)
assert _clip_volume(_clip("c3", config={"volume": 0})) == 0.0
class TestPassThroughAudioProbe:
"""直通路径必须先探测音频,不能无条件假设 main 有音频。"""
def test_pass_through_probes_audio_before_encoding(self):
clips = [_clip("c1")]
svc = _service(clips)
with (
patch("pathlib.Path.exists", return_value=True),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch(
"video_processing.unified_render_service.probe_has_audio",
return_value=False,
) as mock_probe,
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
):
layers = svc._group_clips_into_layers(clips)
has_audio = svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=5.0)
assert has_audio is False
mock_probe.assert_called()
cmd_str = " ".join(mock_run.call_args[0][0])
assert "aac" not in cmd_str
def test_pass_through_volume_non_default_disables_stream_copy(self):
svc = _service([_clip("c1", config={"volume": 0.5})])
clip = _clip("c1", config={"volume": 0.5})
can_copy, reason = svc._can_use_stream_copy(clip)
assert can_copy is False
assert "音量" in reason
def test_clip_volume_static_helper(self):
assert UnifiedRenderService._clip_volume(_clip("c1")) == 1.0
assert UnifiedRenderService._clip_volume(_clip("c2", config={"volume": 0.7})) == pytest.approx(0.7)
def test_pass_through_probe_exception_raises(self):
"""probe_has_audio 抛致命异常时必须向上抛出,不能静默丢音频产出无声视频。"""
clips = [_clip("c1")]
svc = _service(clips)
with (
patch("pathlib.Path.exists", return_value=True),
patch("video_processing.unified_render_service.probe_duration", return_value=5.0),
patch(
"video_processing.unified_render_service.probe_has_audio",
side_effect=RuntimeError("probe failed"),
),
patch("video_processing.unified_render_service.run_ffmpeg") as mock_run,
pytest.raises(RuntimeError, match="probe failed"),
):
layers = svc._group_clips_into_layers(clips)
svc._render_pass_through(layers, Path("/tmp/out.mp4"), video_duration=5.0)
mock_run.assert_not_called()
-117
View File
@@ -886,120 +886,3 @@ class TestTemplateConfigPropagation:
assert clip_cfg.get("speed_ratio") == 1.2
assert clip_cfg.get("name") == "开场"
assert clip_cfg.get("custom_field") == "value"
class TestAssetDurationsAlwaysFetched:
"""验证 asset_durations 不再受 random_preview 条件限制。
修复前:asset_durations 仅在 random_preview=True 时传入 distribute_assets
修复后:只要 _asset_repo 存在,就始终获取 asset_durations
"""
def _make_service_with_asset_repo(self):
"""创建带 mock asset_repo 的 PlanGeneratorService"""
from apps.api.app.services.plan_generator_service import PlanGeneratorService
plan_repo = StubEditPlanRepository()
clip_repo = StubEditPlanClipRepository()
# mock asset_repo: 返回带 duration 的素材
asset_repo = MagicMock()
def fake_get(asset_id):
mock_asset = MagicMock()
mock_asset.duration = 30.0 # 每个素材 30 秒
return mock_asset
asset_repo.get = MagicMock(side_effect=fake_get)
with (
patch(
"apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository",
return_value=plan_repo,
),
patch(
"apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository",
return_value=clip_repo,
),
):
db = MagicMock()
svc = PlanGeneratorService(db, asset_repo=asset_repo)
svc._plan_repo = plan_repo
svc._clip_repo = clip_repo
return svc, asset_repo
def test_asset_durations_fetched_without_random_preview(self):
"""random_preview=False 时也应获取 asset_durations"""
svc, asset_repo = self._make_service_with_asset_repo()
template = _make_template("one_take")
clip_configs = _make_clip_configs(
template_id=template.id,
specs=[{"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}],
)
# patch distribute_assets 以捕获传入的参数
with patch("apps.api.app.services.plan_generator_service.distribute_assets") as mock_distribute:
svc.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=["a1", "a2"],
random_preview=False, # 关键:非随机预览模式
)
# 验证 asset_durations 被传入(不是 None)
mock_distribute.assert_called_once()
call_kwargs = mock_distribute.call_args
asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations"))
assert asset_durations is not None, "asset_durations should be fetched even when random_preview=False"
assert "a1" in asset_durations
assert "a2" in asset_durations
assert asset_durations["a1"] == 30.0
# 验证 asset_repo.get 被调用(说明 _fetch_asset_durations 执行了)
assert asset_repo.get.call_count >= 2
def test_asset_durations_fetched_with_random_preview(self):
"""random_preview=True 时仍正常获取 asset_durations(行为不变)"""
svc, asset_repo = self._make_service_with_asset_repo()
template = _make_template("one_take")
clip_configs = _make_clip_configs(
template_id=template.id,
specs=[{"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}],
)
with patch("apps.api.app.services.plan_generator_service.distribute_assets") as mock_distribute:
svc.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=["a1"],
random_preview=True,
)
call_kwargs = mock_distribute.call_args
asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations"))
assert asset_durations is not None
assert "a1" in asset_durations
def test_no_asset_repo_means_no_durations(self):
"""_asset_repo 为 None 时 asset_durations 应为 None"""
svc, _, _ = _make_generator() # 默认不带 asset_repo
template = _make_template("one_take")
clip_configs = _make_clip_configs(
template_id=template.id,
specs=[{"clip_type": ClipType.MAIN, "order": 0, "min_duration": 3.0, "max_duration": 5.0}],
)
with patch("apps.api.app.services.plan_generator_service.distribute_assets") as mock_distribute:
svc.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=["a1"],
)
call_kwargs = mock_distribute.call_args
asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations"))
assert asset_durations is None
-130
View File
@@ -10,7 +10,6 @@ from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.editing_mode import EditingMode
from packages.domain.plan_generator_utils import (
DEFAULT_CLIP_DURATION,
_calc_random_start_time,
create_clips_from_configs,
distribute_assets,
generate_default_clips,
@@ -648,132 +647,3 @@ class TestConstants:
def test_default_duration_value(self):
"""默认片段时长应为 5 秒."""
assert DEFAULT_CLIP_DURATION == 5.0
# ── 随机 start_time 与去重测试 ─────────────────────────────────────────
class TestCalcRandomStartTime:
"""_calc_random_start_time 函数测试."""
def test_no_durations_returns_none(self):
"""asset_durations 为 None 时返回 None."""
result = _calc_random_start_time("a1", 5.0, None)
assert result is None
def test_asset_not_in_durations_returns_none(self):
"""素材不在 durations 映射中时返回 None."""
result = _calc_random_start_time("a_missing", 5.0, {"a1": 60.0})
assert result is None
def test_zero_duration_returns_none(self):
"""素材时长为 0 时返回 None."""
result = _calc_random_start_time("a1", 5.0, {"a1": 0.0})
assert result is None
def test_clip_longer_than_asset_returns_zero(self):
"""片段时长 >= 素材时长时返回 0.0."""
result = _calc_random_start_time("a1", 60.0, {"a1": 30.0})
assert result == 0.0
def test_returns_value_in_range(self):
"""返回值在 [0, max_start] 范围内."""
for _ in range(50):
result = _calc_random_start_time("a1", 5.0, {"a1": 30.0})
assert result is not None
assert 0.0 <= result <= 25.0
def test_avoids_used_segments(self):
"""生成的 start_time 不与已使用段重叠."""
used = {"a1": [(10.0, 15.0), (20.0, 25.0)]}
for _ in range(100):
result = _calc_random_start_time("a1", 3.0, {"a1": 30.0}, used)
assert result is not None
end = result + 3.0
# 不应与 [10,15) 重叠
assert not (result < 15.0 and 10.0 < end)
# 不应与 [20,25) 重叠
assert not (result < 25.0 and 20.0 < end)
def test_fallback_when_no_space(self):
"""所有空间都被占用时,回退到最后已使用段之后或 0."""
used = {"a1": [(0.0, 28.0)]}
result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}, used)
assert result is not None
# 应该返回 last_used_end (28.0) 但受限于 max_start (25.0),取 min
assert result == 25.0
def test_no_used_segments_for_asset(self):
"""used_segments 中没有当前 asset_id 时正常随机."""
used = {"a2": [(10.0, 15.0)]}
for _ in range(20):
result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}, used)
assert result is not None
assert 0.0 <= result <= 25.0
class TestDistributeAssetsOverlapAvoidance:
"""测试 distribute_assets 中同一素材被多次使用时的去重逻辑."""
def test_same_asset_reused_no_overlap(self):
"""同一素材分配给多个 clip 时,start_time 不重叠."""
# 2 个 clip 共享同一素材,素材时长足够
clips = [
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=1, duration=5.0),
]
asset_ids = ["a1", "a1"] # 同一素材用两次
durations = {"a1": 60.0}
distribute_assets(clips, asset_ids, EditingMode.ONE_TAKE.value, asset_durations=durations)
# 两个 clip 都应有 start_time
assert clips[0].start_time is not None
assert clips[1].start_time is not None
# 两段不应重叠
s1, e1 = clips[0].start_time, clips[0].start_time + 5.0
s2, e2 = clips[1].start_time, clips[1].start_time + 5.0
assert not (s1 < e2 and s2 < e1), f"Segments overlap: [{s1},{e1}) and [{s2},{e2})"
def test_different_assets_independent(self):
"""不同素材各自独立随机,互不影响."""
clips = [
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=1, duration=5.0),
]
asset_ids = ["a1", "a2"]
durations = {"a1": 60.0, "a2": 60.0}
distribute_assets(clips, asset_ids, EditingMode.ONE_TAKE.value, asset_durations=durations)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
assert clips[0].start_time is not None
assert clips[1].start_time is not None
def test_no_durations_still_works(self):
"""不传 asset_durations 时仍正常分配(start_time 为 None)."""
clips = [
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
]
distribute_assets(clips, ["a1"], EditingMode.ONE_TAKE.value)
assert clips[0].asset_id == "a1"
# start_time 默认为 0.0(模型默认值)
assert clips[0].start_time == 0.0
def test_voice_pip_overlap_avoidance(self):
"""VOICE_PIP 模式下同一素材多次使用时避免重叠."""
clips = [
EditPlanClip.create(plan_id="p1", clip_type="background", order=0, duration=5.0),
EditPlanClip.create(plan_id="p1", clip_type="b_roll", order=1, duration=5.0),
]
asset_ids = ["a1", "a1"] # 同一素材用两次
durations = {"a1": 60.0}
distribute_assets(clips, asset_ids, EditingMode.VOICE_PIP.value, asset_durations=durations)
assert clips[0].start_time is not None
assert clips[1].start_time is not None
s1, e1 = clips[0].start_time, clips[0].start_time + 5.0
s2, e2 = clips[1].start_time, clips[1].start_time + 5.0
assert not (s1 < e2 and s2 < e1), f"Segments overlap: [{s1},{e1}) and [{s2},{e2})"
+190
View File
@@ -0,0 +1,190 @@
"""Tests for preview title_config feature.
验证预览 API 的 title_config 字段和 Worker 的标题配置解析逻辑。
"""
from __future__ import annotations
import json
import pytest
class TestPreviewTitleConfigSchema:
"""测试 CreatePreviewGenerationTaskRequest 的 title_config 字段."""
def test_title_config_default_empty(self):
"""title_config 默认为空 dict."""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
req = CreatePreviewGenerationTaskRequest(
template_id="test_template",
asset_ids=["asset1"],
)
assert req.title_config == {}
def test_title_config_with_text(self):
"""传入标题文本."""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
req = CreatePreviewGenerationTaskRequest(
template_id="test_template",
asset_ids=["asset1"],
title_config={"text": "测试标题"},
)
assert req.title_config["text"] == "测试标题"
def test_title_config_with_full_style(self):
"""传入完整标题样式配置."""
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
config = {
"text": "我的视频标题",
"font": "思源黑体",
"font_size": 48,
"font_color": "#ffffff",
"position": "top",
"bold": True,
"stroke": 2,
"shadow": True,
}
req = CreatePreviewGenerationTaskRequest(
template_id="test_template",
asset_ids=["asset1"],
title_config=config,
)
assert req.title_config["text"] == "我的视频标题"
assert req.title_config["font_size"] == 48
assert req.title_config["position"] == "top"
class TestCommandTitleConfig:
"""测试 CreateGenerationTaskCommand 的 title_config 字段."""
def test_command_has_title_config(self):
"""Command 包含 title_config 字段."""
from packages.application.generation_tasks import CreateGenerationTaskCommand
cmd = CreateGenerationTaskCommand(
title_config={"text": "hello", "font_size": 32},
)
assert cmd.title_config["text"] == "hello"
assert cmd.title_config["font_size"] == 32
def test_command_title_config_default_empty(self):
"""Command 的 title_config 默认为空 dict."""
from packages.application.generation_tasks import CreateGenerationTaskCommand
cmd = CreateGenerationTaskCommand()
assert cmd.title_config == {}
class TestWorkerTitleConfigParsing:
"""测试 Worker 渲染时的标题配置解析逻辑."""
def test_json_format_parsing(self):
"""JSON 格式的 custom_title 能正确解析."""
config = {"text": "测试标题", "font_size": 48, "font_color": "#ff0000"}
custom_title = json.dumps(config, ensure_ascii=False)
ct_stripped = custom_title.strip()
parsed = None
if ct_stripped.startswith("{"):
try:
parsed = json.loads(ct_stripped)
except (json.JSONDecodeError, ValueError):
parsed = None
assert parsed is not None
assert parsed["text"] == "测试标题"
assert parsed["font_size"] == 48
def test_plain_text_fallback(self):
"""纯文本的 custom_title 不触发 JSON 解析."""
custom_title = "简单的标题文字"
ct_stripped = custom_title.strip()
parsed = None
if ct_stripped.startswith("{"):
try:
parsed = json.loads(ct_stripped)
except (json.JSONDecodeError, ValueError):
parsed = None
assert parsed is None
def test_invalid_json_fallback(self):
"""无效 JSON 的 custom_title 降级为纯文本."""
custom_title = "{invalid json"
ct_stripped = custom_title.strip()
parsed = None
if ct_stripped.startswith("{"):
try:
parsed = json.loads(ct_stripped)
except (json.JSONDecodeError, ValueError):
parsed = None
assert parsed is None
def test_json_without_text_skipped(self):
"""JSON 格式但缺少 text 字段时,跳过标题注入."""
config = {"font_size": 48}
custom_title = json.dumps(config, ensure_ascii=False)
ct_stripped = custom_title.strip()
parsed = json.loads(ct_stripped)
title_text = (parsed.get("text") or "").strip()
assert title_text == ""
def test_style_key_mapping(self):
"""前端字段名正确映射到 ASS 字段名."""
config = {
"text": "标题",
"font_size": 48,
"font_color": "#ffffff",
"font_preset": "思源黑体",
}
style_keys = ["font", "font_size", "font_color", "position", "bold", "stroke", "shadow", "font_preset"]
title_cfg = {}
for key in style_keys:
if key in config and config[key] is not None:
mapped_key = {
"font_size": "size",
"font_color": "color",
"font_preset": "font",
}.get(key, key)
title_cfg[mapped_key] = config[key]
assert title_cfg["size"] == 48
assert title_cfg["color"] == "#ffffff"
assert title_cfg["font"] == "思源黑体"
class TestPreviewRouteTitleConfigPassing:
"""测试预览路由正确序列化 title_config 到 custom_title."""
def test_title_config_serialization(self):
"""title_config 序列化为 JSON 字符串."""
title_config = {
"text": "我的标题",
"font_size": 32,
"font_color": "#d4a843",
}
serialized = json.dumps(title_config, ensure_ascii=False)
parsed = json.loads(serialized)
assert parsed["text"] == "我的标题"
assert parsed["font_size"] == 32
def test_empty_title_config_produces_empty_string(self):
"""空 title_config 时 custom_title 为空字符串."""
title_config = {}
title_text = (title_config.get("text") or "").strip()
custom_title_value = ""
if title_text:
custom_title_value = json.dumps(title_config, ensure_ascii=False)
assert custom_title_value == ""
+256
View File
@@ -0,0 +1,256 @@
"""预览视频标题渲染修复测试 — 覆盖3个断点。
断点1: generate_video() → _render_video() 传递 custom_title
断点2: _render_video() 解析 custom_title 并注入 virtual_plan.config["title"]
断点3: generate_ass_from_timeline() ASR路径也渲染标题
"""
import json
import time
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
# ── 断点2: _render_video 标题注入 ─────────────────────────────────────────────
class TestRenderVideoCustomTitleInjection:
"""验证 _render_video 正确接收并注入 custom_title 到 virtual_plan.config['title']。"""
@pytest.fixture
def sample_custom_title(self):
"""模拟前端发送的 custom_title JSON(含 font_size/font_color)。"""
return json.dumps(
{
"text": "测试标题",
"font": "思源黑体",
"font_size": 30,
"font_color": "#FF0000",
"position": "top",
"bold": True,
"stroke": True,
"shadow": False,
},
ensure_ascii=False,
)
def _call_render_video_with_capture(self, custom_title, template_config=None, tmp_path=None):
"""调用 _render_video,在 RenderAdapter 处中断并捕获 virtual_plan.config。"""
from worker_app.tasks.generation import _render_video
captured_config = {}
class FakePlan:
def __init__(self):
self.config = {}
self.id = "test-plan"
fake_plan = FakePlan()
def capture_and_raise(*args, **kwargs):
# 此时 title 已注入到 fake_plan.config
captured_config.update(fake_plan.config or {})
raise RuntimeError("STOP_HERE")
with (
patch("worker_app.tasks.generation._build_plan_and_clips_from_task") as mock_build,
patch("worker_app.tasks.generation._load_template_plan_config", return_value=template_config),
patch("worker_app.tasks.generation.time.monotonic", side_effect=[0.0, 1.0]),
patch("video_processing.render_adapter.RenderAdapter") as mock_adapter_cls,
):
mock_build.return_value = (fake_plan, [], {})
mock_adapter_cls.side_effect = capture_and_raise
with pytest.raises(RuntimeError, match="STOP_HERE"):
_render_video(
task_id="test-task",
downloaded_videos=[tmp_path / "v1.mp4"] if tmp_path else [Path("/tmp/v1.mp4")],
voice_path=None,
editing_mode=MagicMock(value="one_take"),
project_id="proj-1",
template_id="tpl-1",
user_id="user-1",
temp_path=tmp_path or Path("/tmp"),
output_name="test_output",
resolution="1280x720",
bgm_config={},
voice_ids=[],
custom_title=custom_title,
)
return captured_config
def test_custom_title_injected_into_plan_config(self, sample_custom_title, tmp_path):
"""custom_title JSON 应被解析并注入 virtual_plan.config['title']。"""
config = self._call_render_video_with_capture(sample_custom_title, tmp_path=tmp_path)
assert "title" in config
title_cfg = config["title"]
assert title_cfg["text"] == "测试标题"
# 字段归一化: font_size → size
assert title_cfg["size"] == 30
# 字段归一化: font_color → color
assert title_cfg["color"] == "#FF0000"
def test_custom_title_overrides_template_title(self, sample_custom_title, tmp_path):
"""用户自定义标题应覆盖模板默认标题。"""
template_config = {"title": {"text": "模板默认标题", "size": 24}}
config = self._call_render_video_with_capture(
sample_custom_title, template_config=template_config, tmp_path=tmp_path
)
# 用户标题应覆盖模板标题
assert config["title"]["text"] == "测试标题"
assert config["title"]["size"] == 30
def test_empty_custom_title_no_injection(self, tmp_path):
"""空 custom_title 不应注入 title 字段。"""
config = self._call_render_video_with_capture("", tmp_path=tmp_path)
assert "title" not in config
def test_malformed_custom_title_gracefully_ignored(self, tmp_path):
"""非法 JSON 不应崩溃,应跳过注入。"""
config = self._call_render_video_with_capture("{invalid json!!!", tmp_path=tmp_path)
assert "title" not in config
# ── 断点3: generate_ass_from_timeline ASR路径支持标题 ──────────────────────────
class TestGenerateAssFromTimelineWithTitle:
"""验证 generate_ass_from_timeline 在有标题时生成包含 TitleStyle 的 ASS。"""
def test_title_included_in_ass_output(self, tmp_path):
"""有 title_text 时,ASS 输出应包含 TitleStyle 和标题事件。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(start=0.0, end=2.0, text="你好世界"),
]
)
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={"font": "思源黑体", "size": 24},
title_text="我的标题",
title_config={"font": "思源黑体", "size": 36, "color": "#FFFFFF", "position": "top"},
)
content = result.read_text(encoding="utf-8")
# 应包含 TitleStyle
assert "TitleStyle" in content
# 应包含标题文本
assert "我的标题" in content
# 也应包含 ASR 字幕
assert "你好世界" in content
def test_no_title_no_title_style(self, tmp_path):
"""无标题时,ASS 输出不应包含 TitleStyle。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(start=0.0, end=2.0, text="只有字幕"),
]
)
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="",
title_config={},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" not in content
assert "只有字幕" in content
def test_title_field_normalization_in_ass(self, tmp_path):
"""前端字段名 font_size/font_color 应被正确归一化。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")])
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="归一化测试",
title_config={
"font_size": 30, # 前端字段名
"font_color": "#FF0000", # 前端字段名
"position": "top",
},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "归一化测试" in content
def test_title_boolean_stroke_shadow_compat(self, tmp_path):
"""boolean stroke/shadow 应被兼容处理。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")])
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="描边测试",
title_config={
"size": 36,
"stroke": True, # boolean
"shadow": False, # boolean
},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "描边测试" in content
# ── 断点1: _render_video 签名包含 custom_title ────────────────────────────────
class TestRenderVideoSignature:
"""验证 _render_video 函数签名正确。"""
def test_custom_title_parameter_exists(self):
"""_render_video 应有 custom_title 参数,默认空字符串。"""
import inspect
from worker_app.tasks.generation import _render_video
sig = inspect.signature(_render_video)
assert "custom_title" in sig.parameters
assert sig.parameters["custom_title"].default == ""
@@ -0,0 +1,151 @@
"""Tests for voice_ids fallback in _download_all_assets.
When voice_library_id is empty but voice_ids is non-empty, the Worker
should fallback to voice_ids[0] as the audio asset_id.
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
class TestDownloadAllAssetsVoiceIdsFallback:
"""_download_all_assets 配音下载 fallback 逻辑测试。"""
@patch("worker_app.tasks.generation._download_voice_asset")
@patch("worker_app.tasks.generation._download_library_assets")
def test_voice_library_id_takes_priority(self, mock_download_videos, mock_download_voice, tmp_path):
"""voice_library_id 存在时优先使用,不 fallback 到 voice_ids。"""
from worker_app.tasks.generation import _download_all_assets
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
mock_download_voice.return_value = True
videos, audio = _download_all_assets(
temp_path=tmp_path,
asset_library_id="lib-1",
project_id="proj-1",
task_asset_ids=["a1"],
voice_library_id="voice-lib-123",
task_id="task-1",
voice_ids=["voice-ids-456"],
)
assert audio is not None
mock_download_voice.assert_called_once()
call_args = mock_download_voice.call_args
assert call_args[0][0] == "voice-lib-123" # first positional arg
@patch("worker_app.tasks.generation._download_voice_asset")
@patch("worker_app.tasks.generation._download_library_assets")
def test_fallback_to_voice_ids_when_voice_library_id_empty(
self, mock_download_videos, mock_download_voice, tmp_path
):
"""voice_library_id 为空时 fallback 到 voice_ids[0]。"""
from worker_app.tasks.generation import _download_all_assets
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
mock_download_voice.return_value = True
videos, audio = _download_all_assets(
temp_path=tmp_path,
asset_library_id="lib-1",
project_id="proj-1",
task_asset_ids=["a1"],
voice_library_id="", # 空字符串
task_id="task-2",
voice_ids=["voice-asset-789"],
)
assert audio is not None
mock_download_voice.assert_called_once()
call_args = mock_download_voice.call_args
assert call_args[0][0] == "voice-asset-789"
@patch("worker_app.tasks.generation._download_voice_asset")
@patch("worker_app.tasks.generation._download_library_assets")
def test_no_audio_when_both_empty(self, mock_download_videos, mock_download_voice, tmp_path):
"""voice_library_id 和 voice_ids 都为空时,不下载音频。"""
from worker_app.tasks.generation import _download_all_assets
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
videos, audio = _download_all_assets(
temp_path=tmp_path,
asset_library_id="lib-1",
project_id="proj-1",
task_asset_ids=["a1"],
voice_library_id="",
task_id="task-3",
voice_ids=[],
)
assert audio is None
mock_download_voice.assert_not_called()
@patch("worker_app.tasks.generation._download_voice_asset")
@patch("worker_app.tasks.generation._download_library_assets")
def test_no_audio_when_voice_ids_none(self, mock_download_videos, mock_download_voice, tmp_path):
"""voice_ids 为 None 时,不触发 fallback。"""
from worker_app.tasks.generation import _download_all_assets
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
videos, audio = _download_all_assets(
temp_path=tmp_path,
asset_library_id="lib-1",
project_id="proj-1",
task_asset_ids=["a1"],
voice_library_id="",
task_id="task-4",
voice_ids=None,
)
assert audio is None
mock_download_voice.assert_not_called()
@patch("worker_app.tasks.generation._download_voice_asset")
@patch("worker_app.tasks.generation._download_library_assets")
def test_voice_library_id_empty_string_fallback(self, mock_download_videos, mock_download_voice, tmp_path):
"""voice_library_id 为空字符串且 voice_ids 有多个元素时,取第一个。"""
from worker_app.tasks.generation import _download_all_assets
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
mock_download_voice.return_value = True
videos, audio = _download_all_assets(
temp_path=tmp_path,
asset_library_id="lib-1",
project_id="proj-1",
task_asset_ids=["a1"],
voice_library_id="",
task_id="task-5",
voice_ids=["first-id", "second-id", "third-id"],
)
assert audio is not None
call_args = mock_download_voice.call_args
assert call_args[0][0] == "first-id"
@patch("worker_app.tasks.generation._download_voice_asset")
@patch("worker_app.tasks.generation._download_library_assets")
def test_backward_compat_no_voice_ids_param(self, mock_download_videos, mock_download_voice, tmp_path):
"""不传 voice_ids 参数时,行为与之前一致(向后兼容)。"""
from worker_app.tasks.generation import _download_all_assets
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
mock_download_voice.return_value = True
# 不传 voice_ids
videos, audio = _download_all_assets(
temp_path=tmp_path,
asset_library_id="lib-1",
project_id="proj-1",
task_asset_ids=["a1"],
voice_library_id="voice-lib-999",
task_id="task-6",
)
assert audio is not None
mock_download_voice.assert_called_once_with("voice-lib-999", tmp_path / "voice.mp3")
@@ -1,98 +0,0 @@
"""
回归测试:验证 generation_tasks.py 中兜底关联 edit plan 在 enqueue 之前执行。
根因(PR #1481 后续修复):兜底关联逻辑原来在 safe_enqueue_generation_task 之后执行,
导致 worker 在 enqueue 后立即读取 task 时,source_edit_plan_id 仍为空(竞态条件)。
"""
import ast
import textwrap
def _get_function_source(filepath, func_name):
"""提取函数源码"""
with open(filepath) as f:
source = f.read()
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
lines = source.splitlines()
start = node.lineno - 1
end = node.end_lineno
return textwrap.dedent("\n".join(lines[start:end]))
return None
def _find_try_block_source(func_source):
"""在函数源码中找到包含 safe_enqueue_generation_task 的 try 块"""
tree = ast.parse(textwrap.dedent(func_source))
for node in ast.walk(tree):
if isinstance(node, ast.Try):
# 检查 try 块中是否包含 safe_enqueue_generation_task
block_lines = func_source.splitlines()
block_text = "\n".join(block_lines[node.lineno - 1 : node.end_lineno])
if "safe_enqueue_generation_task" in block_text:
return block_text
return None
def test_fallback_before_enqueue():
"""验证兜底关联 edit plan 的代码在 safe_enqueue_generation_task 调用之前"""
filepath = "apps/api/app/api/routes/generation_tasks.py"
func_source = _get_function_source(filepath, "create_generation_task")
assert func_source is not None, "create_generation_task function not found"
try_block = _find_try_block_source(func_source)
assert try_block is not None, "try block with safe_enqueue_generation_task not found"
# 定位关键标记在 try 块中的行号
lines = try_block.splitlines()
fallback_line = None
enqueue_line = None
writeback_line = None
for i, line in enumerate(lines):
if "not task.source_edit_plan_id and request.template_id" in line and fallback_line is None:
fallback_line = i
if "safe_enqueue_generation_task(" in line and enqueue_line is None:
enqueue_line = i
if "_writeback_edit_plan_config(" in line and "def " not in line and writeback_line is None:
writeback_line = i
assert fallback_line is not None, "兜底关联逻辑 not found in try block"
assert enqueue_line is not None, "safe_enqueue_generation_task call not found in try block"
assert writeback_line is not None, "_writeback_edit_plan_config call not found in try block"
# 核心断言:兜底关联和回写都在 enqueue 之前
assert fallback_line < enqueue_line, f"兜底关联(行{fallback_line})应在 enqueue(行{enqueue_line})之前"
assert writeback_line < enqueue_line, f"回写 config(行{writeback_line})应在 enqueue(行{enqueue_line})之前"
def test_fallback_sets_source_edit_plan_id():
"""验证兜底关联逻辑会设置 task.source_edit_plan_id"""
filepath = "apps/api/app/api/routes/generation_tasks.py"
func_source = _get_function_source(filepath, "create_generation_task")
assert func_source is not None
try_block = _find_try_block_source(func_source)
assert try_block is not None
# 验证兜底逻辑包含赋值语句
assert "task.source_edit_plan_id = _plan_model.id" in try_block, "兜底关联逻辑应设置 task.source_edit_plan_id"
assert "generation_task_repository.update(task)" in try_block, "兜底关联后应持久化 task 到 DB"
def test_writeback_uses_effective_plan_id():
"""验证回写 config 使用的是 effective_plan_id(包含兜底结果),而非仅 request.source_edit_plan_id"""
filepath = "apps/api/app/api/routes/generation_tasks.py"
func_source = _get_function_source(filepath, "create_generation_task")
assert func_source is not None
try_block = _find_try_block_source(func_source)
assert try_block is not None
# 验证使用了 _effective_plan_id 或 task.source_edit_plan_id,而非仅 request.source_edit_plan_id
# 修复前用的是 request.source_edit_plan_id,修复后应该用 task.source_edit_plan_id
uses_effective = "_effective_plan_id" in try_block or "task.source_edit_plan_id" in try_block
assert uses_effective, "回写 config 应使用包含兜底结果的有效 plan_id"
-91
View File
@@ -11,7 +11,6 @@ from video_processing.render_audio import (
RenderContext,
clip_effective_duration,
clip_has_audio,
mix_with_independent_audio,
)
from video_processing.unified_render_service import ResolvedClip
@@ -154,93 +153,3 @@ class TestRenderContext:
"""音频缓存初始为空."""
ctx = _make_ctx()
assert ctx._audio_cache == {}
class TestMixWithIndependentAudioVolume:
"""测试 mix_with_independent_audio 中主视频 clip 音量滤镜是否生效。"""
def _make_clip(self, volume=None, clip_id="c1", duration=5.0):
"""创建测试用 ResolvedClip。"""
config = {}
if volume is not None:
config["volume"] = volume
return ResolvedClip(
clip_id=clip_id,
asset_id=f"a_{clip_id}",
local_path=Path(f"/tmp/{clip_id}.mp4"),
clip_type="main",
order=0,
duration=duration,
actual_duration=duration,
config=config,
)
def test_main_clip_volume_zero_applied_in_filter(self):
"""volume=0 的主视频 clip 应在 filter_complex 中包含 volume=0.0000。"""
from unittest.mock import patch
ctx = _make_ctx()
main_clip = self._make_clip(volume=0, clip_id="m1")
captured_cmd = {}
def fake_run_ffmpeg(cmd):
captured_cmd["cmd"] = cmd
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
mix_with_independent_audio(
ctx=ctx,
main_clips=[main_clip],
audio_clips=[],
output_path=Path("/tmp/out.m4a"),
video_duration=5.0,
)
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
assert "volume=0.0000" in filter_complex, f"volume filter missing in: {filter_complex}"
def test_main_clip_default_volume_no_filter(self):
"""默认 volume=1.0 时不应添加 volume 滤镜。"""
from unittest.mock import patch
ctx = _make_ctx()
main_clip = self._make_clip(clip_id="m1") # no volume set
captured_cmd = {}
def fake_run_ffmpeg(cmd):
captured_cmd["cmd"] = cmd
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
mix_with_independent_audio(
ctx=ctx,
main_clips=[main_clip],
audio_clips=[],
output_path=Path("/tmp/out.m4a"),
video_duration=5.0,
)
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
# 默认音量不应出现 volume= 滤镜
assert "volume=" not in filter_complex, f"unexpected volume filter in: {filter_complex}"
def test_main_clip_partial_volume_applied(self):
"""volume=0.5 的主视频 clip 应包含 volume=0.5000。"""
from unittest.mock import patch
ctx = _make_ctx()
main_clip = self._make_clip(volume=0.5, clip_id="m1")
captured_cmd = {}
def fake_run_ffmpeg(cmd):
captured_cmd["cmd"] = cmd
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
mix_with_independent_audio(
ctx=ctx,
main_clips=[main_clip],
audio_clips=[],
output_path=Path("/tmp/out.m4a"),
video_duration=5.0,
)
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
assert "volume=0.5000" in filter_complex, f"volume filter missing in: {filter_complex}"
+3 -3
View File
@@ -80,13 +80,13 @@ class TestBuildAssStyle:
def test_basic_style(self):
style = _build_ass_style("Default")
assert style.startswith("Style: Default,")
assert "Noto Sans SC" in style
assert "65" in style # font_size 48*1.35=65
assert "思源黑体" in style
assert "48" in style # font_size
def test_custom_font(self):
style = _build_ass_style("Custom", font_name="Arial", font_size=32)
assert "Arial" in style
assert ",43," in style # font_size 32*1.35=43
assert ",32," in style
def test_bold(self):
style = _build_ass_style("Bold", bold=True)

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