Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5100b7b5d |
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
@@ -169,22 +170,8 @@ def _writeback_edit_plan_config(
|
||||
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
|
||||
merged = dict(current_config)
|
||||
merged["generation_task_id"] = task_id
|
||||
|
||||
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
|
||||
if title_config:
|
||||
old_title_config = merged.get("title_config", {}) or {}
|
||||
old_title_text = (old_title_config.get("text") or "").strip()
|
||||
new_title_text = (title_config.get("text") or "").strip()
|
||||
if old_title_text != new_title_text:
|
||||
# 标题变化,清除旧封面
|
||||
if "cover" in merged:
|
||||
del merged["cover"]
|
||||
logger.info(
|
||||
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
|
||||
plan_id, old_title_text, new_title_text,
|
||||
)
|
||||
merged["title_config"] = title_config
|
||||
|
||||
plan_model.config = merged
|
||||
db.commit()
|
||||
logger.info(
|
||||
@@ -307,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 = []
|
||||
@@ -451,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,
|
||||
@@ -506,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:
|
||||
@@ -542,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:
|
||||
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
|
||||
|
||||
@@ -571,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,
|
||||
@@ -635,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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -767,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:
|
||||
|
||||
@@ -15,25 +15,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_asset_repository, get_db_session
|
||||
from app.dependencies import get_asset_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import _calc_random_start_time
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
@@ -54,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 表字段完全对齐"""
|
||||
@@ -167,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,
|
||||
)
|
||||
|
||||
@@ -281,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 ""]
|
||||
@@ -311,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 {
|
||||
@@ -359,484 +354,40 @@ def batch_delete_editor_clips(
|
||||
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
|
||||
|
||||
|
||||
|
||||
def _safe_segment_duration(value, default: float) -> float:
|
||||
"""安全地将数据库中的时长值转换为正浮点数.
|
||||
|
||||
处理 None、无效类型、负数、NaN 等异常情况。
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
result = float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
if result != result or result <= 0: # NaN check or non-positive
|
||||
return default
|
||||
return result
|
||||
|
||||
|
||||
def _get_template_segments(
|
||||
template_id: str,
|
||||
tpl_svc: EditTemplateService,
|
||||
db: Session,
|
||||
) -> list[tuple[int, float, float]]:
|
||||
"""获取模板的片段配置(顺序、最短时长、最长时长).
|
||||
|
||||
优先从新模板系统(template_clip_configs)查询,
|
||||
若不存在则回退到旧模板系统(template_segments)。
|
||||
|
||||
Returns:
|
||||
[(segment_order, duration_min, duration_max), ...] 按 order 排序
|
||||
"""
|
||||
# 优先查新模板系统
|
||||
try:
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
if clip_configs:
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(
|
||||
cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION
|
||||
)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
|
||||
|
||||
# 回退到旧模板系统(template_segments表)
|
||||
try:
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = old_repo.list_segments(template_id)
|
||||
if segments:
|
||||
result = []
|
||||
for s in segments:
|
||||
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((s.segment_order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("旧模板系统查询segments失败", exc_info=True)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _recommended_time_conflicts(
|
||||
start: float,
|
||||
duration: float,
|
||||
used: list[tuple[float, float]],
|
||||
) -> bool:
|
||||
"""检查推荐起始时间是否与已使用时间段冲突."""
|
||||
end = start + duration
|
||||
for used_start, used_end in used:
|
||||
if start < used_end and end > used_start:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_mediakit_recommendations(
|
||||
asset_ids: list[str],
|
||||
asset_repo,
|
||||
) -> dict[str, float]:
|
||||
"""调用 MediaKit 视频理解,获取智能选片推荐起始时间.
|
||||
|
||||
尝试让 MediaKit 分析视频内容,返回每个素材的推荐起始时间。
|
||||
任何异常都优雅降级,返回空字典(调用方降级到随机选择)。
|
||||
"""
|
||||
try:
|
||||
client = get_mediakit_client()
|
||||
if not client.is_available:
|
||||
logger.info("MediaKit 未配置,使用随机起始时间")
|
||||
return {}
|
||||
|
||||
storage = get_storage_service()
|
||||
|
||||
video_urls: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
for asset_id in asset_ids[:10]:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if not asset or not getattr(asset, "storage_key", None):
|
||||
continue
|
||||
mime = getattr(asset, "mime_type", "")
|
||||
if not mime.startswith("video/"):
|
||||
continue
|
||||
try:
|
||||
url = storage.get_download_url(asset.storage_key)
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(asset_id)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
if not video_urls:
|
||||
return {}
|
||||
|
||||
prompt = (
|
||||
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
|
||||
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
|
||||
'请严格以JSON数组格式返回,不要包含其他文字:'
|
||||
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
|
||||
)
|
||||
|
||||
contents = client.analyze_videos(
|
||||
video_urls=video_urls,
|
||||
prompt=prompt,
|
||||
level="Economy",
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=15,
|
||||
)
|
||||
|
||||
if not contents:
|
||||
logger.info("MediaKit 分析无结果,降级为随机选择")
|
||||
return {}
|
||||
|
||||
# 按索引映射结果:contents[i] 对应 valid_asset_ids[i]
|
||||
recommendations: dict[str, float] = {}
|
||||
for idx, content_text in enumerate(contents):
|
||||
if idx >= len(valid_asset_ids):
|
||||
break
|
||||
asset_id = valid_asset_ids[idx]
|
||||
if not content_text:
|
||||
continue
|
||||
|
||||
# 尝试从文本中提取 JSON
|
||||
parsed = False
|
||||
# 尝试直接解析
|
||||
try:
|
||||
data = json.loads(content_text.strip())
|
||||
if isinstance(data, list) and data:
|
||||
for item in data:
|
||||
if isinstance(item, dict) and "recommended_start_time" in item:
|
||||
recommendations[asset_id] = float(item["recommended_start_time"])
|
||||
parsed = True
|
||||
break
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 尝试从 markdown 代码块中提取 JSON
|
||||
if not parsed:
|
||||
json_match = re.search(r"\[\s*(\{.*?\})\s*\]", content_text, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
item = json.loads(json_match.group(1))
|
||||
if isinstance(item, dict) and "recommended_start_time" in item:
|
||||
recommendations[asset_id] = float(item["recommended_start_time"])
|
||||
parsed = True
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 尝试正则提取
|
||||
if not parsed:
|
||||
time_match = re.search(
|
||||
r'recommended_start_time["\s:]+([\d.]+)', content_text
|
||||
)
|
||||
if time_match:
|
||||
try:
|
||||
recommendations[asset_id] = float(time_match.group(1))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if recommendations:
|
||||
logger.info("MediaKit 智能选片推荐: %s", recommendations)
|
||||
else:
|
||||
logger.info("MediaKit 结果解析失败,降级为随机选择")
|
||||
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets_editor(
|
||||
template_id: str,
|
||||
body: ClipsFromAssetsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipsFromAssetsResponse:
|
||||
"""从素材批量创建片段(按模板segment配置创建,MediaKit异步更新).
|
||||
|
||||
逻辑:
|
||||
1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count)
|
||||
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段
|
||||
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的(随机起始时间)
|
||||
5. 立即返回响应(目标 <1秒)
|
||||
6. 后台异步任务:调用 MediaKit 智能选片并更新片段的 start_time
|
||||
7. 素材时长为 0 或缺失时报 400,不创建无效片段
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
|
||||
# 1. 查询模板 segments
|
||||
segments = _get_template_segments(template_id, tpl_svc, db)
|
||||
if not segments:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="模板没有片段配置,无法创建片段",
|
||||
)
|
||||
|
||||
if not body.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材列表为空,无法创建片段",
|
||||
)
|
||||
|
||||
# 2. 获取素材实际时长(去重查询)
|
||||
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)
|
||||
|
||||
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
|
||||
used_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
clips_data: list[dict] = []
|
||||
|
||||
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
|
||||
# 轮询分配素材
|
||||
asset_id = body.asset_ids[i % len(body.asset_ids)]
|
||||
asset_total = asset_durations.get(asset_id, 0.0)
|
||||
|
||||
# 素材时长为 0 或缺失时无法创建有效片段
|
||||
if asset_total <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"素材 {asset_id} 时长信息缺失或为0,无法创建片段",
|
||||
"""从素材批量创建片段"""
|
||||
_, plan_svc = services
|
||||
clips = []
|
||||
for i, asset_id in enumerate(body.asset_ids):
|
||||
try:
|
||||
clip = plan_svc.create_clip(
|
||||
plan_id,
|
||||
clip_type="main",
|
||||
order=body.start_order + i if hasattr(body, "start_order") else i,
|
||||
duration=5.0,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
|
||||
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
raw_duration = random.uniform(dur_min, dur_max)
|
||||
clip_duration = round(raw_duration, 1)
|
||||
|
||||
# 素材时长不足时缩短 clip duration
|
||||
clip_duration = min(clip_duration, asset_total)
|
||||
|
||||
if clip_duration <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"素材 {asset_id} 时长不足,无法创建有效片段",
|
||||
)
|
||||
|
||||
# 使用随机起始时间(不调用MediaKit,保证接口快速返回)
|
||||
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)
|
||||
)
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
"order": i,
|
||||
"asset_id": asset_id,
|
||||
"start_time": start_time,
|
||||
"duration": clip_duration,
|
||||
"clip_type": body.clip_type or "main",
|
||||
}
|
||||
)
|
||||
|
||||
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
|
||||
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
|
||||
clips.append(clip)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"from-assets按模板创建片段(异步): template_id=%s plan_id=%s segments=%d created=%d by user=%s",
|
||||
"模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
len(segments),
|
||||
created_count,
|
||||
len(clips),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
# 5. 触发后台任务:异步调用 MediaKit 并更新片段起始时间
|
||||
background_tasks.add_task(
|
||||
_update_mediakit_recommendations_async,
|
||||
plan_id,
|
||||
unique_asset_ids,
|
||||
)
|
||||
|
||||
# 6. 立即返回响应
|
||||
return ClipsFromAssetsResponse(
|
||||
created_count=created_count,
|
||||
created_count=len(clips),
|
||||
plan_id=plan_id,
|
||||
clip_ids=[],
|
||||
clip_ids=[c.id for c in clips],
|
||||
)
|
||||
|
||||
|
||||
def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
plan_id: str,
|
||||
asset_ids: list[str],
|
||||
) -> None:
|
||||
"""后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
|
||||
|
||||
此函数在后台异步执行,不影响接口响应时间。
|
||||
失败时静默处理,不影响已创建的片段。
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
|
||||
db = None
|
||||
try:
|
||||
# 复用应用全局 Session(避免每次创建新连接池导致资源泄漏)
|
||||
if SessionLocal is None:
|
||||
logger.warning("后台任务: SessionLocal 未初始化,跳过 MediaKit 更新")
|
||||
return
|
||||
db = SessionLocal()
|
||||
|
||||
# 初始化服务
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
plan_svc = EditPlanService(db)
|
||||
|
||||
# 调用 MediaKit 获取推荐时间
|
||||
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
|
||||
if not recommendations:
|
||||
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
|
||||
return
|
||||
|
||||
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
|
||||
batch_size = 500
|
||||
all_clips = []
|
||||
offset = 0
|
||||
while True:
|
||||
batch = plan_svc.list_clips(plan_id, skip=offset, limit=batch_size)
|
||||
if not batch:
|
||||
break
|
||||
all_clips.extend(batch)
|
||||
if len(batch) < batch_size:
|
||||
break
|
||||
offset += batch_size
|
||||
clips = all_clips
|
||||
|
||||
if not clips:
|
||||
logger.info("后台任务: plan_id=%s 无片段,跳过更新", plan_id)
|
||||
return
|
||||
|
||||
# 批量预加载所有涉及的素材(消除 N+1 查询)
|
||||
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
|
||||
assets_map: dict[str, object] = {
|
||||
a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)
|
||||
}
|
||||
|
||||
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
|
||||
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
|
||||
for clip in clips:
|
||||
aid = getattr(clip, "asset_id", "") or ""
|
||||
if aid and clip.start_time is not None:
|
||||
clips_by_asset[aid].append(
|
||||
(clip.id, clip.start_time, clip.start_time + clip.duration)
|
||||
)
|
||||
|
||||
# 已更新的片段ID(用于排除已移动的旧时间段)
|
||||
updated_clip_ids: set[str] = set()
|
||||
# 已更新的时间段
|
||||
updated_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
updated_count = 0
|
||||
|
||||
# 遍历片段,按 asset_id 匹配推荐时间
|
||||
for clip in clips:
|
||||
asset_id = getattr(clip, "asset_id", "") or ""
|
||||
if not asset_id or asset_id not in recommendations:
|
||||
continue
|
||||
|
||||
recommended_start = recommendations[asset_id]
|
||||
clip_duration = clip.duration
|
||||
|
||||
# 从预加载字典获取素材(O(1) 查找)
|
||||
asset = assets_map.get(asset_id)
|
||||
if not asset:
|
||||
continue
|
||||
asset_total = float(getattr(asset, "duration", 0.0) or 0.0)
|
||||
if asset_total <= 0:
|
||||
continue
|
||||
|
||||
# 推荐时间 + 片段时长不能超过素材总时长
|
||||
if recommended_start + clip_duration > asset_total:
|
||||
logger.info(
|
||||
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
clip_duration,
|
||||
asset_total,
|
||||
)
|
||||
continue
|
||||
|
||||
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
|
||||
other_segments: list[tuple[float, float]] = [
|
||||
(cs, ce)
|
||||
for cid, cs, ce in clips_by_asset.get(asset_id, [])
|
||||
if cid != clip.id and cid not in updated_clip_ids
|
||||
]
|
||||
other_segments.extend(updated_segments.get(asset_id, []))
|
||||
|
||||
# 检查是否与同素材其他片段时间段冲突
|
||||
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
|
||||
logger.info(
|
||||
"后台任务: 推荐时间冲突,跳过: asset_id=%s recommended=%.2f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
continue
|
||||
|
||||
# 逐个更新并捕获异常(单点失败不影响其他片段)
|
||||
try:
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
except Exception as ue:
|
||||
logger.warning(
|
||||
"后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue
|
||||
)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
updated_segments.setdefault(asset_id, []).append(
|
||||
(recommended_start, recommended_start + clip_duration)
|
||||
)
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
|
||||
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
|
||||
|
||||
except Exception as e:
|
||||
# 后台任务失败不影响已创建的片段,静默处理
|
||||
logger.warning("后台任务异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
|
||||
if db:
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if db:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -167,7 +167,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):
|
||||
|
||||
@@ -381,34 +381,15 @@ 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,
|
||||
voice_id=request.voice_id,
|
||||
speed=request.speed,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -400,7 +400,7 @@ class EditPlanService:
|
||||
order = clip_item.get("order") or i
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_item.get("clip_type", "main"),
|
||||
clip_type="main",
|
||||
order=order,
|
||||
asset_id=clip_item.get("asset_id", ""),
|
||||
start_time=clip_item.get("start_time", 0.0),
|
||||
@@ -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,
|
||||
|
||||
@@ -185,17 +185,15 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 2: select material (card grid UI)
|
||||
// Step 2: select material
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中
|
||||
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开
|
||||
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
|
||||
await expect(materialCard).toBeVisible({ timeout: 10_000 })
|
||||
await materialCard.click({ position: { x: 15, y: 15 } })
|
||||
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
|
||||
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..")
|
||||
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
|
||||
@@ -37,33 +37,14 @@ async function loginWithRetry(
|
||||
})
|
||||
}
|
||||
|
||||
async function registerWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password, username, display_name: displayName },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[register] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password, username, display_name: displayName },
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await registerWithRetry(request, email, username, PASSWORD, `E2E ${label}`)
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 预览任务详情响应 */
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -90,21 +90,10 @@ export async function createClipsFromAssets(
|
||||
templateId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
asset_ids: assetIds,
|
||||
clip_type: clipType,
|
||||
}
|
||||
if (requiredClipsCount !== undefined) {
|
||||
body.required_clips_count = requiredClipsCount
|
||||
}
|
||||
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
body,
|
||||
{ timeout: 60000, signal: opts?.signal },
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface EditPlanConfig {
|
||||
auto_subtitles?: boolean
|
||||
/** 是否启用 BGM */
|
||||
bgm?: boolean
|
||||
/** 生成数量 */
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string
|
||||
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
|
||||
|
||||
@@ -15,6 +15,8 @@ import React, { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import { MODE_LIST } from "./constants"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
import MediaPanel from "./components/MediaPanel"
|
||||
import PreviewPlayer from "./components/PreviewPlayer"
|
||||
import TimelinePanel from "./components/TimelinePanel"
|
||||
@@ -77,6 +79,14 @@ const EditingPlanner: React.FC = () => {
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([])
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
|
||||
|
||||
const handleAssetSelect = (ids: string[]) => {
|
||||
setSelectedAssetIds(ids)
|
||||
}
|
||||
|
||||
/* ── 配音素材 ── */
|
||||
const {
|
||||
voiceMaterials,
|
||||
@@ -103,6 +113,7 @@ const EditingPlanner: React.FC = () => {
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId: clipOps.setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
@@ -153,6 +164,9 @@ const EditingPlanner: React.FC = () => {
|
||||
onLoadTemplate={tpl.handleLoadTemplate}
|
||||
onSearchChange={tpl.setSearchQuery}
|
||||
onFilterChange={tpl.setCurrentFilter}
|
||||
mediaAssets={mediaAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
/>
|
||||
|
||||
{/* 中栏 flex-1 */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 左侧面板 — 模板列表
|
||||
* 模板编辑器只负责定义模板规则(片段数量、时长范围),不承载素材管理。
|
||||
* 左侧面板 — V8 原型 1:1 还原
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import AssetSelector from "@/components/asset-selector/AssetSelector"
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[]
|
||||
@@ -16,6 +18,10 @@ interface MediaPanelProps {
|
||||
onLoadTemplate: (id: string) => void
|
||||
onSearchChange: (q: string) => void
|
||||
onFilterChange: (f: string) => void
|
||||
// 素材相关
|
||||
mediaAssets?: MediaAsset[]
|
||||
onAssetSelect?: (ids: string[]) => void
|
||||
selectedAssetIds?: string[]
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
@@ -28,73 +34,113 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
onLoadTemplate,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
mediaAssets = [],
|
||||
onAssetSelect,
|
||||
selectedAssetIds = [],
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"templates" | "assets">("templates")
|
||||
|
||||
return (
|
||||
<div className="ep-left-panel">
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-left-tabs">
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "templates" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
>
|
||||
📋 模板
|
||||
</button>
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "assets" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("assets")}
|
||||
>
|
||||
📁 素材
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 模板 Tab */}
|
||||
{activeTab === "templates" && (
|
||||
<>
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 素材 Tab */}
|
||||
{activeTab === "assets" && (
|
||||
<div className="ep-assets-tab">
|
||||
<AssetSelector
|
||||
assets={mediaAssets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={onAssetSelect}
|
||||
showQualityFilter={false}
|
||||
showBatchSelect={false}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
type EditingTemplate,
|
||||
type TemplateCategory,
|
||||
} from "@/api/editing-planner"
|
||||
import { getMediaAssets, type MediaAsset } from "@/api/template-editor"
|
||||
import { FILTER_CATEGORIES } from "../../constants"
|
||||
|
||||
/**
|
||||
* 模板列表 + 分类 + 筛选搜索
|
||||
* 模板编辑器只负责模板规则定义,不再加载/管理业务素材。
|
||||
*/
|
||||
export function useTemplateList(initialTemplateId: string | null) {
|
||||
export function useTemplateList(
|
||||
setMediaAssets: (assets: MediaAsset[]) => void,
|
||||
initialTemplateId: string | null,
|
||||
) {
|
||||
const [templates, setTemplates] = useState<EditingTemplate[]>([])
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false)
|
||||
@@ -21,20 +24,26 @@ export function useTemplateList(initialTemplateId: string | null) {
|
||||
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(initialTemplateId)
|
||||
|
||||
/**
|
||||
* 并行加载模板列表和分类(两者无依赖关系)
|
||||
* 并行加载模板列表、分类、素材库
|
||||
* 三个接口无依赖关系,用 Promise.all 并发
|
||||
*/
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoadingTemplates(true)
|
||||
try {
|
||||
const [tpls, cats] = await Promise.all([getEditingTemplates(), getTemplateCategories()])
|
||||
const [tpls, cats, assets] = await Promise.all([
|
||||
getEditingTemplates(),
|
||||
getTemplateCategories(),
|
||||
getMediaAssets(),
|
||||
])
|
||||
setTemplates(tpls)
|
||||
setCategories(cats)
|
||||
setMediaAssets(assets)
|
||||
} catch {
|
||||
message.error("加载模板失败")
|
||||
} finally {
|
||||
setLoadingTemplates(false)
|
||||
}
|
||||
}, [])
|
||||
}, [setMediaAssets])
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, type Dispatch, type SetStateAction } from "react"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
|
||||
import type {
|
||||
ClipData,
|
||||
WatermarkConfig,
|
||||
@@ -24,6 +24,7 @@ interface UseTemplateManagementParams {
|
||||
resetClips: (clips: ClipData[]) => void
|
||||
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
|
||||
setSelectedClipId: (id: string | null) => void
|
||||
setMediaAssets: (assets: MediaAsset[]) => void
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
@@ -51,6 +52,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
@@ -84,7 +86,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filteredTemplates,
|
||||
currentTemplate,
|
||||
loadTemplates,
|
||||
} = useTemplateList(urlTemplateId || null)
|
||||
} = useTemplateList(setMediaAssets, urlTemplateId || null)
|
||||
|
||||
/* ── 保存 ── */
|
||||
const {
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
/**
|
||||
* 智能剪辑页面 — 前端实时预览架构
|
||||
* 智能剪辑页面 — 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 { 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 { previewTts } from "@/api/tts"
|
||||
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 +33,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,6 +65,8 @@ const GeneratePage: React.FC = () => {
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
@@ -70,21 +74,54 @@ const GeneratePage: React.FC = () => {
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
previewTaskId,
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
} = formState
|
||||
|
||||
/* ── 标题样式回调 ── */
|
||||
/* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */
|
||||
const styleUpdaters = useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
onTitleSettingsChange: setTitleSettings,
|
||||
})
|
||||
|
||||
/* ── 配音素材库(TTS 试听)── */
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
setCloneModalOpen(false)
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── 前端预览:加载选中素材的视频文件信息 ── */
|
||||
const previewAssetIds = useMemo(
|
||||
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
|
||||
[materialMode, smartSelectedIds, selectedMaterials],
|
||||
)
|
||||
const previewAssetsEnabled = currentStep >= 4 && previewAssetIds.length > 0
|
||||
const {
|
||||
assets: previewAssets,
|
||||
loading: previewAssetsLoading,
|
||||
ready: previewAssetsReady,
|
||||
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
|
||||
/* ── 当前模板对象(传给前端预览播放器) ── */
|
||||
const currentTemplate = useMemo(
|
||||
() => userTemplates.find((t) => t.id === selectedTemplate) || null,
|
||||
[userTemplates, selectedTemplate],
|
||||
)
|
||||
|
||||
/* ── 视频总时长计算(用于配音时长校验) ── */
|
||||
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 }),
|
||||
@@ -94,24 +131,30 @@ const GeneratePage: React.FC = () => {
|
||||
const ttsAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 如果 selectedVoice 是已上传的配音素材,直接用 file_url
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
if (voiceAsset?.file_url) {
|
||||
setPreviewVoiceAudioUrl(voiceAsset.file_url)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有选中的 voice 或标题,跳过
|
||||
const voiceId = selectedClonedVoice || selectedVoice
|
||||
if (!voiceId || !titleSettings.title) {
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 预设音色 / 克隆音色 → 调 TTS 合成
|
||||
ttsAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
ttsAbortRef.current = controller
|
||||
let cancelled = false
|
||||
|
||||
previewTts({ text: titleSettings.title, voice_id: voiceId })
|
||||
previewTts({
|
||||
text: titleSettings.title,
|
||||
voice_id: voiceId,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled && res.audio_url) {
|
||||
setPreviewVoiceAudioUrl(res.audio_url)
|
||||
@@ -128,49 +171,9 @@ const GeneratePage: React.FC = () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
setCloneModalOpen(false)
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── 素材 ID 列表 ── */
|
||||
const previewAssetIds = useMemo(
|
||||
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
|
||||
[materialMode, smartSelectedIds, selectedMaterials],
|
||||
)
|
||||
|
||||
/* ── 当前模板对象 ── */
|
||||
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(() => {
|
||||
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
|
||||
if (exact > 0) return exact
|
||||
return estimateTotalVideoDuration(currentTemplate ?? undefined)
|
||||
}, [previewAssets, currentTemplate])
|
||||
const voiceAudioUrl = previewVoiceAudioUrl || undefined
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
@@ -181,6 +184,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: previewAssetsReady,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -210,26 +214,24 @@ const GeneratePage: React.FC = () => {
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
|
||||
previewTaskId,
|
||||
bgmConfig,
|
||||
onGenerationSuccess: () => {
|
||||
setPreviewTaskId(null)
|
||||
setStoredSourceEditPlanId(null)
|
||||
},
|
||||
generateCount,
|
||||
sourceEditPlanId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
渲染 — 主页面
|
||||
================================================================ */
|
||||
|
||||
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
|
||||
@@ -245,6 +247,7 @@ const GeneratePage: React.FC = () => {
|
||||
onSmartSelectedIdsChange={setSmartSelectedIds}
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={setTitleSettings}
|
||||
/* 标题样式回调 */
|
||||
onUpdatePosition={styleUpdaters.updatePosition}
|
||||
onUpdateFont={styleUpdaters.updateFont}
|
||||
onUpdateSize={styleUpdaters.updateSize}
|
||||
@@ -255,17 +258,12 @@ 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}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
onServerClipsChange={setServerClips}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
@@ -275,6 +273,8 @@ const GeneratePage: React.FC = () => {
|
||||
hasProcessing={hasProcessing}
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneModalOpenChange={setCloneModalOpen}
|
||||
generateCount={generateCount}
|
||||
onGenerateCountChange={setGenerateCount}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
@@ -296,58 +296,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}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
titleSettings={{
|
||||
title: titleSettings.title,
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom",
|
||||
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)}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
|
||||
|
||||
@@ -25,7 +24,6 @@ interface FrontendPreviewPlayerProps {
|
||||
template: EditingTemplate | null
|
||||
videoRatio: string
|
||||
ready: boolean
|
||||
serverClips?: EditPlanClip[]
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
title: string
|
||||
@@ -52,31 +50,9 @@ function formatTime(seconds: number): string {
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
template: EditingTemplate | null,
|
||||
serverClips?: EditPlanClip[],
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length) return []
|
||||
|
||||
// Build asset lookup map
|
||||
const assetMap = new Map(assets.map((a) => [a.id, a]))
|
||||
|
||||
// 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致
|
||||
if (serverClips && serverClips.length > 0) {
|
||||
const segments: PlaybackSegment[] = []
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const startTime = clip.start_time || 0
|
||||
const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration)
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: 本地构建片段(与旧行为一致)
|
||||
const templateSegments = template?.segments || []
|
||||
const segments: PlaybackSegment[] = []
|
||||
|
||||
@@ -100,63 +76,12 @@ function buildPlaybackSegments(
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
videoRatio: _videoRatio,
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips),
|
||||
[assets, template, serverClips],
|
||||
)
|
||||
|
||||
// ── 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
|
||||
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
// 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
|
||||
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
|
||||
const useWebCodecs = false
|
||||
@@ -352,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>
|
||||
)
|
||||
}
|
||||
@@ -384,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>
|
||||
@@ -433,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>
|
||||
@@ -448,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
|
||||
@@ -510,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,
|
||||
@@ -632,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 />}
|
||||
@@ -669,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)}
|
||||
@@ -684,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",
|
||||
@@ -695,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",
|
||||
}}
|
||||
@@ -709,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
@@ -56,7 +55,6 @@ export interface GenerateStepContentProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
@@ -67,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
|
||||
@@ -76,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) => {
|
||||
@@ -116,10 +108,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration,
|
||||
onServerClipsChange,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
clonedVoices,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
@@ -128,10 +121,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
} = props
|
||||
|
||||
/* 当前模板的 segments,传给 Step2 构建 clips */
|
||||
@@ -158,7 +147,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
templateSegments={templateSegments}
|
||||
onServerClipsChange={onServerClipsChange}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -202,13 +190,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:
|
||||
@@ -226,6 +207,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>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useStep2Materials } from "../hooks/useStep2Materials"
|
||||
import MaterialModeTabs from "./material/MaterialModeTabs"
|
||||
import ManualMaterialList from "./material/ManualMaterialList"
|
||||
@@ -21,8 +20,6 @@ interface Step2MaterialSelectProps {
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
}
|
||||
|
||||
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,8 +1,7 @@
|
||||
/**
|
||||
* 手动选择素材列表 — 竖屏 9:16 卡片网格
|
||||
* 交互:默认显示封面,点击播放按钮播放,播放中隐藏按钮,点击视频区域暂停
|
||||
* 手动选择素材列表
|
||||
*/
|
||||
import React, { useRef, useState, useCallback } from "react"
|
||||
import React, { useRef, useCallback } from "react"
|
||||
import { Typography } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
@@ -15,241 +14,39 @@ interface ManualMaterialListProps {
|
||||
onToggle: (materialId: string) => void
|
||||
}
|
||||
|
||||
/** 秒数格式化为 mm:ss */
|
||||
const fmtDuration = (seconds?: number): string => {
|
||||
if (!seconds && seconds !== 0) return "--:--"
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 单个素材卡片 */
|
||||
const MaterialCard: React.FC<{
|
||||
asset: AssetItem
|
||||
checked: boolean
|
||||
onToggle: () => void
|
||||
}> = ({ asset, checked, onToggle }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const isVideo = asset.mime_type?.startsWith("video/") ?? false
|
||||
const thumbSrc = asset.thumbnail_url || undefined
|
||||
|
||||
const handlePlayToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const video = videoRef.current
|
||||
if (!video || !isVideo) return
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
setIsPlaying(true)
|
||||
}
|
||||
},
|
||||
[isPlaying, isVideo],
|
||||
)
|
||||
|
||||
const handleVideoEnded = useCallback(() => {
|
||||
setIsPlaying(false)
|
||||
}, [])
|
||||
|
||||
const handleCardClick = useCallback(() => {
|
||||
// 如果视频正在播放,点击卡片空白区域暂停视频
|
||||
if (isPlaying) {
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
onToggle()
|
||||
}, [isPlaying, onToggle])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="material-card"
|
||||
onClick={handleCardClick}
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "9 / 16",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
border: checked ? "2px solid var(--primary-color, #4f46e5)" : "2px solid transparent",
|
||||
boxShadow: checked ? "0 0 0 2px rgba(79, 70, 229, 0.2)" : "0 1px 3px rgba(0, 0, 0, 0.1)",
|
||||
background: "#1e293b",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{/* 视频元素 */}
|
||||
{isVideo && asset.file_url ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={asset.file_url}
|
||||
poster={thumbSrc}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onEnded={handleVideoEnded}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
) : thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={asset.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #334155, #1e293b)",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 28,
|
||||
}}
|
||||
>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部渐变遮罩 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
background: "linear-gradient(0deg, rgba(0,0,0,0.6) 0%, transparent 100%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 播放按钮 — 播放中隐藏 */}
|
||||
{!isPlaying && (
|
||||
<div
|
||||
onClick={handlePlayToggle}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(99, 102, 241, 0.85)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 3,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件名(左下角) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
left: 6,
|
||||
right: 50,
|
||||
color: "white",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.5)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{asset.name}
|
||||
</div>
|
||||
|
||||
{/* 时长(右下角) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
color: "white",
|
||||
padding: "1px 5px",
|
||||
borderRadius: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{fmtDuration(asset.duration)}
|
||||
</div>
|
||||
|
||||
{/* 选中勾选标记(左上角) */}
|
||||
{checked && (
|
||||
<div
|
||||
data-testid="material-card-check"
|
||||
aria-label="已选中"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
left: 6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-color, #4f46e5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
materials,
|
||||
materialsLoading,
|
||||
selectedMaterials,
|
||||
onToggle,
|
||||
}) => {
|
||||
// 追踪当前正在播放的视频元素,确保同时只有一个视频播放
|
||||
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget
|
||||
// 暂停之前正在播放的视频(检查是否仍在 DOM 中)
|
||||
if (
|
||||
activeVideoRef.current &&
|
||||
activeVideoRef.current !== video &&
|
||||
document.body.contains(activeVideoRef.current)
|
||||
) {
|
||||
activeVideoRef.current.pause()
|
||||
activeVideoRef.current.currentTime = 0
|
||||
}
|
||||
activeVideoRef.current = video
|
||||
video.play().catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleVideoMouseLeave = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
if (activeVideoRef.current === video) {
|
||||
activeVideoRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
@@ -259,21 +56,145 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
暂无素材,请先在视频库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{materials.items.map((asset) => (
|
||||
<MaterialCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
checked={selectedMaterials.includes(asset.id)}
|
||||
onToggle={() => onToggle(asset.id)}
|
||||
/>
|
||||
))}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.items.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id)
|
||||
const isVideo = m.mime_type?.startsWith("video/") ?? false
|
||||
const thumbSrc = m.thumbnail_url || undefined
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(m.id)}
|
||||
style={{
|
||||
accentColor: "var(--primary-color, #4f46e5)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{/* 缩略图预览 48×48 */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
background: "#e2e8f0",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isVideo && m.file_url ? (
|
||||
<video
|
||||
src={m.file_url}
|
||||
poster={m.thumbnail_url || undefined}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
onMouseEnter={handleVideoMouseEnter}
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
) : thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={m.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.nextElementSibling as HTMLElement | null
|
||||
if (fallback) fallback.style.display = "flex"
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!thumbSrc && !isVideo && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
🎵
|
||||
</span>
|
||||
)}
|
||||
{!thumbSrc && isVideo && !m.file_url && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</span>
|
||||
)}
|
||||
{/* img onError 时显示的 fallback(初始隐藏) */}
|
||||
{thumbSrc && !(isVideo && m.file_url) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "none",
|
||||
}}
|
||||
>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{m.mime_type?.split("/")?.[1]?.toUpperCase() ?? "FILE"}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
============================================================ */
|
||||
@@ -194,16 +186,16 @@
|
||||
============================================================ */
|
||||
.xx-choice-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-choice-item {
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
text-align: center;
|
||||
@@ -219,20 +211,18 @@
|
||||
}
|
||||
|
||||
.xx-choice-thumb {
|
||||
width: 33%;
|
||||
max-width: 52px;
|
||||
height: 24px;
|
||||
height: 60px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 auto 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-choice-item h4 {
|
||||
margin: 0 0 2px;
|
||||
margin: 0 0 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -240,7 +230,7 @@
|
||||
|
||||
.xx-choice-item p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@@ -1065,7 +1055,7 @@
|
||||
}
|
||||
|
||||
.xx-choice-list {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.xx-voice-choice-list {
|
||||
@@ -1290,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;
|
||||
@@ -2488,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,8 +4,8 @@
|
||||
*/
|
||||
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 { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../../constants"
|
||||
@@ -14,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,
|
||||
@@ -47,10 +46,6 @@ export interface GenerateFormState {
|
||||
smartSelectedIds: string[]
|
||||
setSmartSelectedIds: (ids: string[]) => void
|
||||
|
||||
/* 服务端片段(/clips/from-assets 创建后获取) */
|
||||
serverClips: EditPlanClip[]
|
||||
setServerClips: (clips: EditPlanClip[]) => void
|
||||
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
@@ -72,6 +67,10 @@ export interface GenerateFormState {
|
||||
cloneModalOpen: boolean
|
||||
setCloneModalOpen: (open: boolean) => void
|
||||
|
||||
/* 生成数量 */
|
||||
generateCount: number
|
||||
setGenerateCount: (n: number) => void
|
||||
|
||||
/* 高级设置 */
|
||||
videoRatio: string
|
||||
duration: number
|
||||
@@ -91,13 +90,11 @@ export interface GenerateFormState {
|
||||
*/
|
||||
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 => {
|
||||
@@ -121,9 +118,6 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 服务端片段(供预览播放器使用)── */
|
||||
const [serverClips, setServerClips] = useState<EditPlanClip[]>([])
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
@@ -152,6 +146,9 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
/* ── 克隆声音弹窗 ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("9:16")
|
||||
const [duration] = useState(30)
|
||||
@@ -159,29 +156,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({
|
||||
@@ -204,8 +181,6 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
@@ -219,6 +194,8 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
@@ -227,9 +204,9 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
planConfigStr,
|
||||
previewTaskId,
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,16 @@
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { type GeneratedVideo, getEditPlanClips } from "@/api/template-editor"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
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"
|
||||
|
||||
/**
|
||||
* 片段创建轮询:最多等 30 秒。
|
||||
* useStep2Materials 在用户选素材时(debounce 800ms)已调用 from-assets,
|
||||
* 这里只做轻量校验,确认片段已落库就放行,不死等 ready。
|
||||
*/
|
||||
const CLIPS_CREATED_MAX_WAIT_MS = 30_000
|
||||
const CLIPS_CREATED_POLL_INTERVAL_MS = 1_500
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const { selectedTemplate, onGenerationSuccess } = props
|
||||
const { selectedTemplate } = props
|
||||
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -32,15 +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)
|
||||
@@ -52,28 +39,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
onFailed: handleFailed,
|
||||
})
|
||||
|
||||
/**
|
||||
* 轮询确认片段已被创建。
|
||||
* useStep2Materials 在用户选素材时已调用 from-assets 创建片段,
|
||||
* 这里只要该 plan 下存在任意 clips(无论 ready/pending),就立即放行 generate。
|
||||
* 片段是否 ready 由后端生成流程自行等待/兜底,前端不死等 ready,避免:
|
||||
* 1. MediaKit 失败时前端卡死无法生成
|
||||
* 2. E2E/弱网环境下 generate 请求迟迟不发出
|
||||
* 30s 仍查不到片段也放行,由后端返回明确错误。
|
||||
*/
|
||||
const waitForClipsCreated = useCallback(async (templateId: string): Promise<void> => {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < CLIPS_CREATED_MAX_WAIT_MS) {
|
||||
try {
|
||||
const clipList = await getEditPlanClips(templateId, { limit: 500 })
|
||||
if (clipList.items.length > 0) return
|
||||
} catch {
|
||||
// 单次查询失败不终止,继续轮询
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, CLIPS_CREATED_POLL_INTERVAL_MS))
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
@@ -89,32 +54,45 @@ 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
|
||||
|
||||
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用。
|
||||
// 这里轻量确认片段已落库,再发 generate;最多等 30s,超时也放行。
|
||||
// 片段 ready 状态由后端生成流程兜底,前端不死等。
|
||||
if (assetIds.length > 0 && selectedTemplate) {
|
||||
const hide = message.loading("正在准备素材片段...", 0)
|
||||
try {
|
||||
await waitForClipsCreated(selectedTemplate)
|
||||
} finally {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
const voiceLibraryId =
|
||||
props.voiceMode === "clone"
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
|
||||
// 直接创建正式生成任务
|
||||
const taskResp = await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
@@ -124,12 +102,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
voice_library_id: voiceLibraryId,
|
||||
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
|
||||
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
|
||||
? {
|
||||
@@ -146,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,请稍后在任务列表查看")
|
||||
}
|
||||
@@ -161,17 +134,20 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, clearTimer, startPolling, selectedTemplate, waitForClipsCreated])
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
generate()
|
||||
}, [generate])
|
||||
|
||||
/* 清除错误 */
|
||||
const dismissError = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
}, [])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const download = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
@@ -192,6 +168,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const share = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
@@ -205,16 +182,19 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}, [generatedVideos])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
generating,
|
||||
progress,
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
// 操作
|
||||
generate,
|
||||
retry,
|
||||
dismissError,
|
||||
download,
|
||||
share,
|
||||
// 工具
|
||||
getGenerationPhase,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +3,11 @@
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor"
|
||||
import { updateEditPlanClips } from "@/api/template-editor"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { buildClipsFromAssets } from "../utils/buildClipsFromAssets"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
@@ -23,8 +23,6 @@ interface UseStep2MaterialsProps {
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -36,7 +34,6 @@ export function useStep2Materials({
|
||||
onSmartSelectedIdsChange,
|
||||
selectedTemplate,
|
||||
templateSegments,
|
||||
onServerClipsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
@@ -75,30 +72,23 @@ 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 创建新片段
|
||||
*/
|
||||
/* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ── */
|
||||
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
|
||||
const onServerClipsChangeRef = useRef(onServerClipsChange)
|
||||
onServerClipsChangeRef.current = onServerClipsChange
|
||||
const materialsRef = useRef(materials)
|
||||
materialsRef.current = materials
|
||||
const smartMatchedRef = useRef<AssetItem[]>(smartMatch.smartMatchedResults)
|
||||
smartMatchedRef.current = smartMatch.smartMatchedResults
|
||||
|
||||
useEffect(() => {
|
||||
const tid = selectedTemplateRef.current
|
||||
if (!tid) return
|
||||
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
if (!ids.length) {
|
||||
onServerClipsChangeRef.current?.([])
|
||||
return
|
||||
}
|
||||
if (!ids.length) return
|
||||
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
clipsTimerRef.current = setTimeout(async () => {
|
||||
@@ -107,34 +97,20 @@ export function useStep2Materials({
|
||||
const controller = new AbortController()
|
||||
clipsAbortRef.current = controller
|
||||
|
||||
const segs = templateSegmentsRef.current || []
|
||||
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
|
||||
const clips = buildClipsFromAssets({
|
||||
selectedIds: ids,
|
||||
materials: materialsRef.current.items,
|
||||
smartMatchedAssets: smartMatchedRef.current,
|
||||
templateSegments: templateSegmentsRef.current || [],
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. 清空旧片段
|
||||
await updateEditPlanClips(tid, [], controller.signal)
|
||||
// 2. 调用后端 from-assets 接口创建片段(60s 超时,与后端 MediaKit 一致)
|
||||
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
|
||||
const clipList = await getEditPlanClips(tid, { limit: 500 })
|
||||
const readyClips = clipList.items
|
||||
.filter((c) => c.status === "ready")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
onServerClipsChangeRef.current?.(readyClips)
|
||||
await updateEditPlanClips(tid, clips, controller.signal)
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
// 用户切换素材导致的主动取消,静默
|
||||
if (name === "CanceledError" || name === "AbortError") return
|
||||
// from-assets 60s 超时(MediaKit 智能选片未完成)
|
||||
const code = (err as { code?: string })?.code
|
||||
if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) {
|
||||
console.warn("[useStep2Materials] 智能选片超时:", err)
|
||||
message.error("智能选片超时,请重试")
|
||||
return
|
||||
if (name !== "CanceledError" && name !== "AbortError") {
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
}
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 将选中素材 + 模板 segments 构建为 edit_plan_clips 写入数据。
|
||||
*
|
||||
* 逻辑必须与 FrontendPreviewPlayer.tsx 中 buildPlaybackSegments 完全一致:
|
||||
* assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
* tplSeg = templateSegments[i] || lastSegment
|
||||
* segDuration = clamp(assetDuration, tplSeg.duration_min, tplSeg.duration_max)
|
||||
* start_time = 0
|
||||
* // 关键:预览播放器中 endTime = min(startTime + segDuration, assetDuration)
|
||||
* // 因此 clips.duration 也必须用 min(segDuration, assetDuration) 截断,
|
||||
* // 避免素材实际时长比 clamp 后的 segDuration 短时,Worker 尝试读取不存在的片段
|
||||
* duration = min(segDuration, assetDuration)
|
||||
*
|
||||
* 预览播放器(Canvas 实时预览)直接在内存中构建 segments 播放,不读 edit_plan_clips;
|
||||
* 本函数产出的 clips 写入 DB 后由 Worker 渲染。两边用完全相同的时长计算,
|
||||
* 保证用户在编辑过程中看到的预览与最终生成视频一致。
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClipInput } from "@/api/template-editor"
|
||||
|
||||
interface BuildClipsOptions {
|
||||
/** 选中的素材 ID 列表(按选择顺序) */
|
||||
selectedIds: string[]
|
||||
/** 已加载的素材列表(用于查 duration) */
|
||||
materials: AssetItem[]
|
||||
/** 智能匹配返回的素材(auto 模式下可能不在 materials 列表中) */
|
||||
smartMatchedAssets?: AssetItem[]
|
||||
/** 模板 segments */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
export function buildClipsFromAssets({
|
||||
selectedIds,
|
||||
materials,
|
||||
smartMatchedAssets = [],
|
||||
templateSegments = [],
|
||||
}: BuildClipsOptions): EditPlanClipInput[] {
|
||||
if (!selectedIds.length) return []
|
||||
|
||||
// 合并两个素材来源,建立 id → asset 索引
|
||||
const assetMap = new Map<string, AssetItem>()
|
||||
for (const a of materials) assetMap.set(a.id, a)
|
||||
for (const a of smartMatchedAssets) assetMap.set(a.id, a)
|
||||
|
||||
const lastSeg = templateSegments[templateSegments.length - 1]
|
||||
|
||||
return selectedIds.map((assetId, i) => {
|
||||
const asset = assetMap.get(assetId)
|
||||
const assetDuration = asset?.duration || asset?.metadata?.duration || 30
|
||||
|
||||
const tplSeg = templateSegments[i] || lastSeg
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
// 与 FrontendPreviewPlayer.buildPlaybackSegments 中
|
||||
// endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
// 保持一致:duration 不能超过素材实际时长
|
||||
const duration = Math.min(segDuration, assetDuration)
|
||||
|
||||
return {
|
||||
asset_id: assetId,
|
||||
start_time: 0,
|
||||
duration,
|
||||
order: i,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -580,7 +574,7 @@ class RenderAdapter:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnails/{job_id}.jpg"
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
@@ -598,7 +592,7 @@ class RenderAdapter:
|
||||
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
|
||||
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, task_id=job_id, num_frames=3, title_text=""
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=""
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
|
||||
@@ -512,9 +512,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,81 +204,10 @@ 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,
|
||||
*,
|
||||
task_id: str = "",
|
||||
num_frames: int = 3,
|
||||
title_text: str = "",
|
||||
title_color: str = "#ffffff",
|
||||
@@ -287,12 +216,9 @@ def extract_and_upload_cover_frames(
|
||||
) -> list[dict]:
|
||||
"""从视频中抽取多帧作为封面候选,上传到 OSS。
|
||||
|
||||
优先使用 MediaKit 智能抽帧,失败时降级到 ffmpeg 直接抽帧。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||||
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
|
||||
num_frames: 抽取帧数(默认 3)
|
||||
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
|
||||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||||
@@ -303,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
|
||||
|
||||
@@ -313,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}/{task_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)
|
||||
@@ -379,7 +259,7 @@ def extract_and_upload_cover_frames(
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
|
||||
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if url:
|
||||
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
|
||||
|
||||
@@ -824,17 +824,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 +889,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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-19875
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 映射 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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白名单
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,486 +0,0 @@
|
||||
"""测试编辑器 from-assets 端点:按模板segment创建片段 + 事务性替换 + 随机起始.
|
||||
|
||||
覆盖:
|
||||
- 片段数量 = segment 数量(required_clips_count 被忽略)
|
||||
- 素材不足时同一素材轮询切多个片段
|
||||
- 随机 start_time + used_segments 去重
|
||||
- 素材时长不足时 clip duration 缩短
|
||||
- 素材时长为 0 时抛 400
|
||||
- 使用 replace_all_clips_transactional 原子性替换
|
||||
- order 从 0 开始
|
||||
- start_time=None 时抛出 400
|
||||
- mark_clips_ready 在事务方法内部完成
|
||||
"""
|
||||
|
||||
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"
|
||||
|
||||
# 默认测试用 segments:4 个片段,每个 3~5 秒
|
||||
DEFAULT_SEGMENTS = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]
|
||||
|
||||
|
||||
def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
|
||||
return [(i, dur_min, dur_max) for i in range(count)]
|
||||
|
||||
|
||||
def _patch_segments(segments=None):
|
||||
return patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
return_value=segments if segments is not None else DEFAULT_SEGMENTS,
|
||||
)
|
||||
|
||||
|
||||
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_asset(asset_id, duration):
|
||||
asset = MagicMock()
|
||||
asset.id = asset_id
|
||||
asset.duration = duration
|
||||
return asset
|
||||
|
||||
|
||||
def _make_plan_svc(replace_return_count=None):
|
||||
svc = MagicMock()
|
||||
# replace_all_clips_transactional 返回创建的片段数量
|
||||
if replace_return_count is not None:
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
|
||||
else:
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=0)
|
||||
return svc
|
||||
|
||||
|
||||
def _get_clips_data_from_call(mock_plan_svc):
|
||||
"""从 replace_all_clips_transactional 的调用中获取 clips_data。"""
|
||||
assert mock_plan_svc.replace_all_clips_transactional.called, "replace_all_clips_transactional 未被调用"
|
||||
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
|
||||
# call_args = ((plan_id, clips_data), kwargs)
|
||||
if len(call_args.args) >= 2:
|
||||
return call_args.args[1]
|
||||
return call_args.kwargs.get("clips_data", [])
|
||||
|
||||
|
||||
class TestEditorClipsBySegments:
|
||||
"""测试按 segment 数量创建片段 + 素材轮询。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_creates_clips_matching_segment_count(self, mock_storage):
|
||||
"""4 个 segment 即使只有2个素材也创建4个片段,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(replace_return_count=4)
|
||||
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=2)
|
||||
|
||||
with _patch_segments(DEFAULT_SEGMENTS):
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert result.created_count == 4
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 4
|
||||
|
||||
# 验证轮询分配:a1, a2, a1, a2
|
||||
assert clips_data[0]["asset_id"] == "a1"
|
||||
assert clips_data[1]["asset_id"] == "a2"
|
||||
assert clips_data[2]["asset_id"] == "a1"
|
||||
assert clips_data[3]["asset_id"] == "a2"
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_orders_start_at_zero(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(replace_return_count=3)
|
||||
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)
|
||||
|
||||
with _patch_segments(_segments(3)):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["order"] == 0
|
||||
assert clips_data[1]["order"] == 1
|
||||
assert clips_data[2]["order"] == 2
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_uses_transactional_replace(self, mock_storage):
|
||||
"""使用 replace_all_clips_transactional 而不是分别 delete + create。"""
|
||||
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(replace_return_count=2)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with _patch_segments(_segments(2)):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 必须调用事务方法
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_called_once()
|
||||
# 不应调用单独的 delete 或 create
|
||||
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_no_segments_raises_400(self, mock_storage):
|
||||
"""模板没有 segment 配置时返回 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_asset_repo = MagicMock()
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with _patch_segments([]):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "片段配置" in exc_info.value.detail
|
||||
# 不应调用替换方法
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
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(replace_return_count=1)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["short"])
|
||||
|
||||
with _patch_segments(_segments(1, dur_min=5.0, dur_max=10.0)):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["duration"] <= 3.0
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_zero_duration_asset_raises_400(self, mock_storage):
|
||||
"""素材时长为 0 时应抛出 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_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("bad", 0.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["bad"])
|
||||
|
||||
with _patch_segments(_segments(1)):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
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_missing_duration_asset_raises_400(self, mock_storage):
|
||||
"""素材时长缺失(asset_repo.get 返回 None)时抛出 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_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=None)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["missing"])
|
||||
|
||||
with _patch_segments(_segments(1)):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_start_time_passed_to_create(self, mock_storage):
|
||||
"""_calc_random_start_time 返回值被传入 clips_data。"""
|
||||
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(replace_return_count=2)
|
||||
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"], required_clips_count=2)
|
||||
|
||||
with (
|
||||
_patch_segments(_segments(2)),
|
||||
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,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert mock_calc.call_count == 2
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["start_time"] == 12.5
|
||||
assert clips_data[1]["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(replace_return_count=3)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"])
|
||||
|
||||
with _patch_segments(_segments(3)):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
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_maintained_across_clips(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(replace_return_count=3)
|
||||
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_segments(_segments(3)),
|
||||
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,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 第一次没有已使用时间段
|
||||
assert captured_used_segments[0] == {}
|
||||
# 第二次有第一次的记录
|
||||
assert len(captured_used_segments[1]["a1"]) == 1
|
||||
# 第三次有前两次的记录
|
||||
assert len(captured_used_segments[2]["a1"]) == 2
|
||||
|
||||
|
||||
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()
|
||||
mock_asset_repo = MagicMock()
|
||||
# 素材有 duration 但 random 返回 None
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with (
|
||||
_patch_segments(_segments(1)),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
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_transactional_replace_exception_propagates(self, mock_storage):
|
||||
"""replace_all_clips_transactional 抛异常时应向上传播(事务已回滚)。"""
|
||||
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.replace_all_clips_transactional = MagicMock(side_effect=RuntimeError("DB connection lost"))
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with _patch_segments(_segments(1)):
|
||||
with pytest.raises(RuntimeError, match="DB connection lost"):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
@@ -1,657 +0,0 @@
|
||||
"""测试 MediaKit 智能选片 + from-assets 按模板 segment 创建片段。
|
||||
|
||||
覆盖:
|
||||
- _recommended_time_conflicts 冲突检测
|
||||
- _get_mediakit_recommendations 解析与降级
|
||||
- _get_template_segments 查询模板片段配置
|
||||
- from-assets 端点:按模板 segment 数量和时长创建片段
|
||||
- from-assets 端点:事务性原子替换
|
||||
- from-assets 端点:素材轮询分配
|
||||
- from-assets 端点:MediaKit 推荐时间首片段使用
|
||||
- from-assets 端点:同一素材多片段时后续用随机
|
||||
- from-assets 端点:无 segment 配置时报错
|
||||
- from-assets 端点:素材时长为0时报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
|
||||
|
||||
# ── _recommended_time_conflicts 单元测试 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecommendedTimeConflicts:
|
||||
"""测试推荐时间与已使用时间段的冲突检测。"""
|
||||
|
||||
def test_no_conflict_when_empty(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
assert _recommended_time_conflicts(5.0, 5.0, []) is False
|
||||
|
||||
def test_no_conflict_when_before(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(15.0, 20.0)]) is False
|
||||
|
||||
def test_no_conflict_when_after(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
assert _recommended_time_conflicts(20.0, 5.0, [(0.0, 10.0)]) is False
|
||||
|
||||
def test_conflict_overlap_start(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [5, 10],已用 [0, 7]
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(0.0, 7.0)]) is True
|
||||
|
||||
def test_conflict_overlap_end(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [5, 10],已用 [8, 15]
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(8.0, 15.0)]) is True
|
||||
|
||||
def test_conflict_contained(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [5, 10],已用 [0, 20]
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(0.0, 20.0)]) is True
|
||||
|
||||
def test_conflict_exact_boundary_no_overlap(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [10, 15],已用 [0, 10] — 边界相接不算冲突
|
||||
assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)]) is False
|
||||
|
||||
def test_conflict_multiple_used(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
used = [(0.0, 5.0), (10.0, 15.0), (20.0, 25.0)]
|
||||
# 推荐 [6, 11] 与 [10, 15] 冲突
|
||||
assert _recommended_time_conflicts(6.0, 5.0, used) is True
|
||||
# 推荐 [15, 20] 不冲突
|
||||
assert _recommended_time_conflicts(15.0, 5.0, used) is False
|
||||
|
||||
|
||||
# ── _get_mediakit_recommendations 单元测试 ──────────────────────────────────
|
||||
|
||||
|
||||
class TestGetMediakitRecommendations:
|
||||
"""测试 MediaKit 推荐结果解析和降级。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_returns_parsed_recommendations(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = [
|
||||
'[{"asset_id": "a1", "recommended_start_time": 12.5, "reason": "动作场景"}]'
|
||||
]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
asset = MagicMock()
|
||||
asset.storage_key = "v.mp4"
|
||||
asset.mime_type = "video/mp4"
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {"a1": 12.5}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_client_not_available_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], MagicMock())
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_empty_contents_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = []
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
asset = MagicMock()
|
||||
asset.storage_key = "v.mp4"
|
||||
asset.mime_type = "video/mp4"
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_unparseable_response_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = ["这是一段自然语言描述,没有JSON"]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
asset = MagicMock()
|
||||
asset.storage_key = "v.mp4"
|
||||
asset.mime_type = "video/mp4"
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_storage_failure_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client_fn.return_value = mock_client
|
||||
mock_storage.side_effect = RuntimeError("storage unavailable")
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], MagicMock())
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_exception_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client_fn.side_effect = RuntimeError("unexpected error")
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], MagicMock())
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_skips_non_video_assets(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.storage_key = "images/test.jpg"
|
||||
mock_asset.mime_type = "image/jpeg"
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
mock_client.analyze_videos.assert_not_called()
|
||||
|
||||
|
||||
# ── _get_template_segments 单元测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTemplateSegments:
|
||||
"""测试模板片段配置查询。"""
|
||||
|
||||
def test_returns_segments_from_new_template_system(self):
|
||||
"""新模板系统(clip_configs)有数据时优先使用。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
cc1 = MagicMock()
|
||||
cc1.order = 0
|
||||
cc1.min_duration = 3.0
|
||||
cc1.max_duration = 5.0
|
||||
cc2 = MagicMock()
|
||||
cc2.order = 1
|
||||
cc2.min_duration = 4.0
|
||||
cc2.max_duration = 8.0
|
||||
mock_tpl_svc.list_clip_configs.return_value = [cc2, cc1] # 乱序返回
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0, 3.0, 5.0)
|
||||
assert result[1] == (1, 4.0, 8.0)
|
||||
|
||||
def test_falls_back_to_old_template_segments(self):
|
||||
"""新模板系统无数据时回退到旧系统。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
seg1 = MagicMock()
|
||||
seg1.segment_order = 0
|
||||
seg1.duration_min = 2.0
|
||||
seg1.duration_max = 4.0
|
||||
mock_repo.list_segments.return_value = [seg1]
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 2.0, 4.0)
|
||||
|
||||
def test_returns_empty_when_no_segments(self):
|
||||
"""两套系统都没有片段配置时返回空列表。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_segments.return_value = []
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert result == []
|
||||
|
||||
def test_new_system_exception_falls_back(self):
|
||||
"""新模板系统异常时回退到旧系统。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.side_effect = RuntimeError("db error")
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
seg = MagicMock()
|
||||
seg.segment_order = 0
|
||||
seg.duration_min = 1.0
|
||||
seg.duration_max = 3.0
|
||||
mock_repo.list_segments.return_value = [seg]
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
auth = MagicMock()
|
||||
auth.user.id = "user-001"
|
||||
auth.user.email = "test@example.com"
|
||||
auth.user.display_name = "test"
|
||||
auth.user_id = "user-001"
|
||||
return auth
|
||||
|
||||
|
||||
def _make_clip_config(order, min_dur, max_dur):
|
||||
cc = MagicMock()
|
||||
cc.order = order
|
||||
cc.min_duration = min_dur
|
||||
cc.max_duration = max_dur
|
||||
return cc
|
||||
|
||||
|
||||
def _make_plan_svc(replace_return_count=None):
|
||||
svc = MagicMock()
|
||||
svc.get_plan_or_raise = MagicMock()
|
||||
if replace_return_count is not None:
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
|
||||
else:
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=0)
|
||||
return svc
|
||||
|
||||
|
||||
def _make_tpl_svc_with_segments(segments):
|
||||
"""segments: list of (order, min_dur, max_dur)"""
|
||||
svc = MagicMock()
|
||||
clip_configs = [_make_clip_config(o, mn, mx) for o, mn, mx in segments]
|
||||
svc.list_clip_configs.return_value = clip_configs
|
||||
return svc
|
||||
|
||||
|
||||
def _make_rich_asset(asset_id, duration, storage_key="v.mp4", mime="video/mp4"):
|
||||
asset = MagicMock()
|
||||
asset.id = asset_id
|
||||
asset.duration = duration
|
||||
asset.storage_key = storage_key
|
||||
asset.mime_type = mime
|
||||
return asset
|
||||
|
||||
|
||||
def _get_clips_data(mock_plan_svc):
|
||||
"""从 replace_all_clips_transactional 调用中提取 clips_data。"""
|
||||
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
|
||||
if len(call_args.args) >= 2:
|
||||
return call_args.args[1]
|
||||
return call_args.kwargs.get("clips_data", [])
|
||||
|
||||
|
||||
class TestFromAssetsByTemplateSegments:
|
||||
"""测试 from-assets 按模板 segment 创建片段(V2 事务性替换)。"""
|
||||
|
||||
def test_creates_clips_matching_segment_count(self):
|
||||
"""片段数量 = segment 数量,忽略 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
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0), (2, 2.0, 6.0), (3, 5.0, 10.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=4)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert result.created_count == 4
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
assert len(clips_data) == 4
|
||||
|
||||
def test_uses_transactional_replace(self):
|
||||
"""使用 replace_all_clips_transactional 而不是分别 delete + create。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_called_once()
|
||||
assert not mock_plan_svc.delete_all_clips.called
|
||||
assert not mock_plan_svc.create_clip.called
|
||||
|
||||
def test_duration_within_segment_range(self):
|
||||
"""每个片段时长在 segment 的 min~max 范围内。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
assert 3.0 <= clips_data[0]["duration"] <= 5.0
|
||||
assert 4.0 <= clips_data[1]["duration"] <= 8.0
|
||||
|
||||
def test_assets_round_robin_assignment(self):
|
||||
"""素材按片段顺序轮询分配。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=4)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
def get_asset(aid):
|
||||
return _make_rich_asset(aid, 60.0)
|
||||
|
||||
mock_asset_repo.get.side_effect = get_asset
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
asset_ids = [c["asset_id"] for c in clips_data]
|
||||
assert asset_ids == ["a1", "a2", "a1", "a2"]
|
||||
|
||||
def test_orders_start_from_zero(self):
|
||||
"""片段 order 从 0 开始递增。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == [0, 1, 2]
|
||||
|
||||
def test_no_segments_raises_400(self):
|
||||
"""模板没有 segment 配置时返回 400。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_segments.return_value = []
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
def test_duration_capped_by_asset_duration(self):
|
||||
"""素材时长不足时 clip duration 被缩短。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 10.0, 20.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=1)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 5.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
assert clips_data[0]["duration"] <= 5.0
|
||||
|
||||
def test_zero_duration_asset_raises_400(self):
|
||||
"""素材时长为 0 时抛出 400。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
segments = [(0, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("bad", 0.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["bad"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
def test_empty_asset_ids_raises_400(self):
|
||||
"""asset_ids 为空列表时返回 400(defense-in-depth,schema 层也有 min_length=1)。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from fastapi import HTTPException
|
||||
|
||||
segments = [(0, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
# 用 MagicMock 模拟 body,绕过 Pydantic schema 的 min_length 校验
|
||||
mock_body = MagicMock()
|
||||
mock_body.asset_ids = []
|
||||
mock_body.required_clips_count = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=mock_body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "素材" in exc_info.value.detail
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
# ── _safe_segment_duration 单元测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSafeSegmentDuration:
|
||||
"""测试片段时长安全转换。"""
|
||||
|
||||
def test_normal_float(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(3.5, 1.0) == 3.5
|
||||
|
||||
def test_none_returns_default(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(None, 5.0) == 5.0
|
||||
|
||||
def test_string_number(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration("4.2", 1.0) == 4.2
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration("abc", 5.0) == 5.0
|
||||
|
||||
def test_negative_returns_default(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(-1.0, 5.0) == 5.0
|
||||
|
||||
def test_zero_returns_default(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(0, 5.0) == 5.0
|
||||
|
||||
def test_integer_value(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(10, 1.0) == 10.0
|
||||
@@ -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 == {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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 == ""
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -84,8 +84,8 @@ class TestBuildAssStyle:
|
||||
"""基本样式行包含关键字段."""
|
||||
line = _build_ass_style("Default")
|
||||
assert line.startswith("Style: Default,")
|
||||
assert "Noto Sans SC" in line
|
||||
assert "65" in line # font_size 48*1.35=65
|
||||
assert "思源黑体" in line
|
||||
assert "48" in line # font_size
|
||||
|
||||
def test_bold_enabled(self):
|
||||
"""加粗时Bold=-1."""
|
||||
@@ -112,7 +112,7 @@ class TestBuildAssStyle:
|
||||
"""自定义字号."""
|
||||
line = _build_ass_style("Big", font_size=72)
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "97" # Fontsize 72*1.35=97
|
||||
assert parts[2] == "72" # Fontsize
|
||||
|
||||
def test_custom_alignment(self):
|
||||
"""自定义对齐方式."""
|
||||
@@ -280,7 +280,7 @@ class TestGenerateAssSubtitles:
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "有字幕" in content
|
||||
|
||||
def test_title_color_overlay(self, tmp_path):
|
||||
def test_custom_title_color(self, tmp_path):
|
||||
"""自定义标题颜色."""
|
||||
output = tmp_path / "color.ass"
|
||||
result = generate_ass_subtitles(
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""回归测试:渲染产物临时目录不在 render_plan 中提前清理。
|
||||
|
||||
根因:render_plan 的 finally 块在返回前清理了临时目录,
|
||||
但 generation.py 还需要访问其中的文件进行 OSS 上传。
|
||||
修复:将清理责任交给调用方(generation.py),render_plan 只在失败时清理。
|
||||
"""
|
||||
|
||||
import ast
|
||||
|
||||
|
||||
def test_render_adapter_result_has_temp_dir_field():
|
||||
"""RenderAdapterResult 包含 temp_dir 字段"""
|
||||
with open("apps/worker/video_processing/render_adapter.py") as f:
|
||||
source = f.read()
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == "RenderAdapterResult":
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
||||
if item.target.id == "temp_dir":
|
||||
return
|
||||
raise AssertionError("RenderAdapterResult 缺少 temp_dir 字段")
|
||||
|
||||
|
||||
def test_render_plan_does_not_cleanup_on_success():
|
||||
"""render_plan 成功时不在 finally 中清理临时目录(通过将 temp_dir 置为 None)"""
|
||||
with open("apps/worker/video_processing/render_adapter.py") as f:
|
||||
source = f.read()
|
||||
|
||||
# 成功路径必须将 temp_dir 置为 None,以阻止 finally 清理
|
||||
assert "temp_dir = None" in source, "render_plan 成功时应将 temp_dir 置为 None 以阻止 finally 清理"
|
||||
|
||||
|
||||
def test_render_plan_passes_temp_dir_to_result():
|
||||
"""render_plan 将 temp_dir 传递给返回结果"""
|
||||
with open("apps/worker/video_processing/render_adapter.py") as f:
|
||||
source = f.read()
|
||||
|
||||
assert "result.temp_dir = temp_dir" in source, "render_plan 应将 temp_dir 设置到 result 上"
|
||||
|
||||
|
||||
def test_generation_cleans_up_temp_dir():
|
||||
"""generation.py 在上传完成后清理临时目录"""
|
||||
with open("apps/worker/worker_app/tasks/generation.py") as f:
|
||||
source = f.read()
|
||||
|
||||
# 验证 _render_from_edit_plan 返回 temp_dir
|
||||
assert "render_temp_dir" in source, "generation.py 应接收 render_temp_dir"
|
||||
|
||||
# 验证有清理逻辑(shutil.rmtree(render_temp_dir...)
|
||||
assert "rmtree(render_temp_dir" in source, "generation.py 应清理 render_temp_dir"
|
||||
|
||||
# 验证清理发生在上传之后(通过查找顺序)
|
||||
upload_pos = source.find("_upload_and_record")
|
||||
cleanup_pos = source.find("rmtree(render_temp_dir")
|
||||
assert upload_pos > 0 and cleanup_pos > upload_pos, "清理临时目录应在 _upload_and_record 之后执行"
|
||||
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
templates_editor.py 模板编辑器 API 端点单元测试
|
||||
|
||||
覆盖核心端点(23个测试用例):
|
||||
覆盖核心端点(25个测试用例):
|
||||
- 草稿:GET/PUT/发布
|
||||
- 片段:list/create/get/update/delete/split/merge
|
||||
- BGM:GET/PUT
|
||||
- 时间线:GET
|
||||
- 生成状态查询
|
||||
- 预设:BGM预设
|
||||
"""
|
||||
|
||||
@@ -394,6 +395,30 @@ class TestTimelineRoute:
|
||||
mock_plan_svc.list_clips.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 生成端点测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerationRoutes:
|
||||
"""生成端点测试"""
|
||||
|
||||
def test_generation_status_success(self, client):
|
||||
c, _, mock_plan_svc = client
|
||||
resp = c.get(BASE + "/generation-status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "generation_task_id" in data
|
||||
assert "clips" in data
|
||||
|
||||
def test_generations_list_success(self, client):
|
||||
c, _, mock_plan_svc = client
|
||||
resp = c.get(BASE + "/generations")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data or "tasks" in data or isinstance(data, dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 字幕端点测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"""Regression tests for 3 bug fixes: flush, append_log, plan_id fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
|
||||
|
||||
# ── Bug 1: db.flush() before pending query ─────────────────────────────────
|
||||
|
||||
|
||||
class TestReplaceAllClipsFlush:
|
||||
"""replace_all_clips_transactional must flush before querying pending clips."""
|
||||
|
||||
def _make_svc_and_db(self, pending_results):
|
||||
"""Helper: create service + db mock. pending_results = list returned by pending query."""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
db = MagicMock()
|
||||
# Delete query
|
||||
delete_query = MagicMock()
|
||||
delete_query.filter.return_value.delete.return_value = 0
|
||||
# Pending query: single .filter() with multiple conditions
|
||||
ready_query = MagicMock()
|
||||
ready_query.filter.return_value.all.return_value = pending_results
|
||||
db.query.side_effect = [delete_query, ready_query]
|
||||
return db, EditPlanService
|
||||
|
||||
def _setup_clip_mocks(self, mock_clip_cls, mock_model_cls, asset_id="asset-1"):
|
||||
mock_entity = MagicMock()
|
||||
mock_entity.id = "clip-1"
|
||||
mock_entity.plan_id = "plan-1"
|
||||
mock_entity.clip_type = "main"
|
||||
mock_entity.order = 0
|
||||
mock_entity.asset_id = asset_id
|
||||
mock_entity.text_content = ""
|
||||
mock_entity.start_time = 0.0
|
||||
mock_entity.duration = 3.0
|
||||
mock_entity.transition_effect = "cut"
|
||||
mock_entity.transition_duration = 0.0
|
||||
mock_entity.playback_speed = 1.0
|
||||
mock_entity.status.value = "pending"
|
||||
mock_entity.config = {}
|
||||
mock_clip_cls.create.return_value = mock_entity
|
||||
mock_model_cls.return_value = MagicMock()
|
||||
return mock_entity
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.models.EditPlanClipModel")
|
||||
@patch("app.services.edit_plan_service.EditPlanClip")
|
||||
def test_flush_called_between_add_and_query(self, mock_clip_cls, mock_model_cls):
|
||||
"""db.flush() must be called after db.add() and before the pending query."""
|
||||
self._setup_clip_mocks(mock_clip_cls, mock_model_cls)
|
||||
db, SvcClass = self._make_svc_and_db([])
|
||||
|
||||
clip_repo = MagicMock()
|
||||
clip_repo.session = db
|
||||
svc = SvcClass.__new__(SvcClass)
|
||||
svc._clip_repo = clip_repo
|
||||
|
||||
svc.replace_all_clips_transactional(
|
||||
"plan-1",
|
||||
[{"asset_id": "asset-1", "start_time": 0.0, "duration": 3.0, "order": 0}],
|
||||
)
|
||||
|
||||
db.flush.assert_called_once()
|
||||
# Verify ordering: add → flush → query → commit
|
||||
method_names = [c[0] for c in db.method_calls]
|
||||
add_idx = method_names.index("add")
|
||||
flush_idx = method_names.index("flush")
|
||||
commit_idx = method_names.index("commit")
|
||||
assert add_idx < flush_idx < commit_idx
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.models.EditPlanClipModel")
|
||||
@patch("app.services.edit_plan_service.EditPlanClip")
|
||||
def test_flush_marks_new_clips_ready(self, mock_clip_cls, mock_model_cls):
|
||||
"""After flush, new clips with asset_id are found and marked ready."""
|
||||
self._setup_clip_mocks(mock_clip_cls, mock_model_cls)
|
||||
|
||||
# Use a plain object so we can verify attribute mutation
|
||||
class FakeClip:
|
||||
status = "pending"
|
||||
|
||||
pending_clip = FakeClip()
|
||||
db, SvcClass = self._make_svc_and_db([pending_clip])
|
||||
|
||||
clip_repo = MagicMock()
|
||||
clip_repo.session = db
|
||||
svc = SvcClass.__new__(SvcClass)
|
||||
svc._clip_repo = clip_repo
|
||||
|
||||
svc.replace_all_clips_transactional(
|
||||
"plan-1",
|
||||
[{"asset_id": "asset-1", "start_time": 0.0, "duration": 3.0, "order": 0}],
|
||||
)
|
||||
|
||||
assert pending_clip.status == "ready"
|
||||
|
||||
|
||||
# ── Bug 2: append_log no TypeError ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAppendLogNoConflict:
|
||||
"""append_log must not receive duplicate 'stage' parameter."""
|
||||
|
||||
def _make_task(self):
|
||||
from packages.domain.generation_task import GenerationTask
|
||||
|
||||
return GenerationTask.create(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="one_take",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1"],
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
|
||||
def test_append_log_with_stage_as_first_positional(self):
|
||||
"""append_log(stage, message, ...) works correctly."""
|
||||
task = self._make_task()
|
||||
task.append_log("render", "some error", level="ERROR", error_type="RuntimeError")
|
||||
|
||||
entries = json.loads(task.logs)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["stage"] == "render"
|
||||
assert entries[0]["message"] == "some error"
|
||||
assert entries[0]["level"] == "ERROR"
|
||||
assert entries[0]["error_type"] == "RuntimeError"
|
||||
|
||||
def test_duplicate_stage_raises_type_error(self):
|
||||
"""Sanity check: passing stage both positionally and as kwarg raises TypeError."""
|
||||
task = self._make_task()
|
||||
with pytest.raises(TypeError):
|
||||
task.append_log(
|
||||
"任务失败", # positional → stage
|
||||
"some error",
|
||||
level="ERROR",
|
||||
stage="render", # duplicate → TypeError
|
||||
)
|
||||
|
||||
|
||||
# ── Bug 3: plan_id fallback in create_generation_task ──────────────────────
|
||||
|
||||
|
||||
class TestPlanIdFallback:
|
||||
"""Formal generation API should fallback to find plan by template_id + user_id."""
|
||||
|
||||
def test_fallback_code_present_in_source(self):
|
||||
"""Verify the fallback logic is present in the generation_tasks module."""
|
||||
import inspect
|
||||
|
||||
from apps.api.app.api.routes import generation_tasks
|
||||
|
||||
source = inspect.getsource(generation_tasks.create_generation_task)
|
||||
assert "EditPlanModel" in source
|
||||
assert "兜底关联编辑计划" in source
|
||||
assert "自动关联编辑计划" in source
|
||||
|
||||
def test_fallback_only_runs_when_source_edit_plan_id_empty(self):
|
||||
"""Verify the condition checks for empty source_edit_plan_id."""
|
||||
import inspect
|
||||
|
||||
from apps.api.app.api.routes import generation_tasks
|
||||
|
||||
source = inspect.getsource(generation_tasks.create_generation_task)
|
||||
assert "not task.source_edit_plan_id and request.template_id" in source
|
||||
|
||||
def test_list_by_template_method_exists(self):
|
||||
"""Verify SQLAlchemyEditPlanRepository.list_by_template is callable."""
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_db.query.return_value = mock_session
|
||||
mock_session.filter.return_value = mock_session
|
||||
mock_session.order_by.return_value = mock_session
|
||||
mock_session.offset.return_value = mock_session
|
||||
mock_session.limit.return_value.all.return_value = []
|
||||
|
||||
repo = SQLAlchemyEditPlanRepository(mock_db)
|
||||
result = repo.list_by_template("tpl-1", limit=20)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestPlanIdFallbackExecution:
|
||||
"""Test that the fallback logic actually executes when source_edit_plan_id is empty."""
|
||||
|
||||
@staticmethod
|
||||
def _make_mock_task(source_edit_plan_id=""):
|
||||
t = MagicMock()
|
||||
t.id = "task-1"
|
||||
t.project_id = "proj-1"
|
||||
t.asset_library_id = ""
|
||||
t.strategy_id = "one_take"
|
||||
t.voice_library_id = ""
|
||||
t.template_id = "tpl-1"
|
||||
t.asset_ids = []
|
||||
t.title_ids = []
|
||||
t.voice_ids = []
|
||||
t.source_edit_plan_id = source_edit_plan_id
|
||||
t.asset_select_mode = "manual"
|
||||
t.batch_id = ""
|
||||
t.video_title = ""
|
||||
t.resolution = ""
|
||||
t.bgm_config = None
|
||||
t.is_preview = False
|
||||
t.source_task_id = ""
|
||||
t.output_width = 1280
|
||||
t.output_height = 720
|
||||
t.cover_url = ""
|
||||
t.title_config = {}
|
||||
t.logs = "[]"
|
||||
t.status = "pending"
|
||||
t.progress = 0.0
|
||||
t.error_message = ""
|
||||
t.error_info = None
|
||||
t.created_at = "2026-01-01T00:00:00Z"
|
||||
t.updated_at = "2026-01-01T00:00:00Z"
|
||||
t.started_at = None
|
||||
t.completed_at = None
|
||||
t.created_by_user_id = "user-1"
|
||||
t.auto_retry_enabled = False
|
||||
t.auto_retry_max = 0
|
||||
t.auto_retry_count = 0
|
||||
t.result_count = 0
|
||||
return t
|
||||
|
||||
@staticmethod
|
||||
def _make_request(source_edit_plan_id="", template_id="tpl-1"):
|
||||
req = MagicMock()
|
||||
req.template_id = template_id
|
||||
req.source_edit_plan_id = source_edit_plan_id
|
||||
req.asset_ids = []
|
||||
req.asset_select_mode = "manual"
|
||||
req.asset_select_count = 0
|
||||
req.voice_library_id = ""
|
||||
req.title_ids = []
|
||||
req.voice_ids = []
|
||||
req.strategy_id = "one_take"
|
||||
req.count = 1
|
||||
req.video_title = ""
|
||||
req.resolution = ""
|
||||
req.bgm_config = None
|
||||
req.auto_retry_enabled = False
|
||||
req.auto_retry_max = 0
|
||||
req.is_preview = False
|
||||
req.source_task_id = ""
|
||||
req.output_width = 0
|
||||
req.output_height = 0
|
||||
req.cover_url = ""
|
||||
req.title_config = {}
|
||||
req.project_id = None
|
||||
req.asset_library_id = None
|
||||
return req
|
||||
|
||||
def _run_create_task(self, mock_task, mock_request, mock_db, mock_gen_repo):
|
||||
"""Helper to run create_generation_task with common mocks."""
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
|
||||
with (
|
||||
patch(
|
||||
"apps.api.app.api.routes.generation_tasks._resolve_project_and_library", return_value=("proj-1", None)
|
||||
),
|
||||
patch("apps.api.app.api.routes.generation_tasks.CreateGenerationTaskUseCase") as mock_uc_cls,
|
||||
patch("apps.api.app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True),
|
||||
patch("apps.api.app.api.routes.generation_tasks._to_generation_task_response") as mock_resp_fn,
|
||||
):
|
||||
mock_uc_cls.return_value.execute.return_value = mock_task
|
||||
mock_resp_fn.return_value = GenerationTaskResponse(
|
||||
id="task-1",
|
||||
project_id="proj-1",
|
||||
asset_library_id="",
|
||||
strategy_id="one_take",
|
||||
voice_library_id="",
|
||||
template_id="tpl-1",
|
||||
asset_ids=[],
|
||||
title_ids=[],
|
||||
voice_ids=[],
|
||||
source_edit_plan_id="",
|
||||
asset_select_mode="",
|
||||
batch_id="",
|
||||
video_title="",
|
||||
resolution="",
|
||||
bgm_config={},
|
||||
is_preview=False,
|
||||
source_task_id="",
|
||||
output_width=1280,
|
||||
output_height=720,
|
||||
cover_url="",
|
||||
title_config={},
|
||||
logs="[]",
|
||||
status="pending",
|
||||
progress=0.0,
|
||||
error_message="",
|
||||
created_at="2026-01-01T00:00:00Z",
|
||||
updated_at="2026-01-01T00:00:00Z",
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
created_by_user_id="user-1",
|
||||
auto_retry_enabled=False,
|
||||
auto_retry_max=0,
|
||||
auto_retry_count=0,
|
||||
result_count=0,
|
||||
)
|
||||
|
||||
from apps.api.app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
return create_generation_task(
|
||||
request=mock_request,
|
||||
authenticated_user=MagicMock(user=MagicMock(id="user-1")),
|
||||
generation_task_repository=mock_gen_repo,
|
||||
project_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
db=mock_db,
|
||||
)
|
||||
|
||||
def test_fallback_sets_plan_id_when_empty(self):
|
||||
"""When source_edit_plan_id is empty, fallback finds plan via DB query."""
|
||||
mock_task = self._make_mock_task(source_edit_plan_id="")
|
||||
mock_request = self._make_request(source_edit_plan_id="", template_id="tpl-1")
|
||||
|
||||
# Mock DB query chain: db.query(EditPlanModel).filter(...).order_by(...).first()
|
||||
mock_plan_model = MagicMock()
|
||||
mock_plan_model.id = "plan-found-123"
|
||||
|
||||
mock_db = MagicMock()
|
||||
query_chain = MagicMock()
|
||||
query_chain.filter.return_value = query_chain
|
||||
query_chain.order_by.return_value = query_chain
|
||||
query_chain.first.return_value = mock_plan_model
|
||||
mock_db.query.return_value = query_chain
|
||||
|
||||
mock_gen_repo = MagicMock()
|
||||
mock_gen_repo.count_pending_by_user.return_value = 0
|
||||
mock_gen_repo.count_pending_total.return_value = 0
|
||||
|
||||
self._run_create_task(mock_task, mock_request, mock_db, mock_gen_repo)
|
||||
|
||||
assert mock_task.source_edit_plan_id == "plan-found-123"
|
||||
mock_gen_repo.update.assert_called_once_with(mock_task)
|
||||
|
||||
def test_no_fallback_when_plan_id_already_set(self):
|
||||
"""When source_edit_plan_id is already set, fallback should NOT run."""
|
||||
mock_task = self._make_mock_task(source_edit_plan_id="plan-already-set")
|
||||
mock_request = self._make_request(source_edit_plan_id="plan-already-set", template_id="tpl-1")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_gen_repo = MagicMock()
|
||||
mock_gen_repo.count_pending_by_user.return_value = 0
|
||||
mock_gen_repo.count_pending_total.return_value = 0
|
||||
|
||||
self._run_create_task(mock_task, mock_request, mock_db, mock_gen_repo)
|
||||
|
||||
assert mock_task.source_edit_plan_id == "plan-already-set"
|
||||
mock_gen_repo.update.assert_not_called()
|
||||
|
||||
def test_fallback_no_match_leaves_plan_id_empty(self):
|
||||
"""When no plan matches, source_edit_plan_id stays empty."""
|
||||
mock_task = self._make_mock_task(source_edit_plan_id="")
|
||||
mock_request = self._make_request(source_edit_plan_id="", template_id="tpl-1")
|
||||
|
||||
mock_db = MagicMock()
|
||||
query_chain = MagicMock()
|
||||
query_chain.filter.return_value = query_chain
|
||||
query_chain.order_by.return_value = query_chain
|
||||
query_chain.first.return_value = None # no matching plan
|
||||
mock_db.query.return_value = query_chain
|
||||
|
||||
mock_gen_repo = MagicMock()
|
||||
mock_gen_repo.count_pending_by_user.return_value = 0
|
||||
mock_gen_repo.count_pending_total.return_value = 0
|
||||
|
||||
self._run_create_task(mock_task, mock_request, mock_db, mock_gen_repo)
|
||||
|
||||
assert mock_task.source_edit_plan_id == ""
|
||||
mock_gen_repo.update.assert_not_called()
|
||||
|
||||
def test_fallback_handles_exception_gracefully(self):
|
||||
"""When DB query fails, the fallback should not break the main flow."""
|
||||
mock_task = self._make_mock_task(source_edit_plan_id="")
|
||||
mock_request = self._make_request(source_edit_plan_id="", template_id="tpl-1")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = Exception("DB connection error")
|
||||
|
||||
mock_gen_repo = MagicMock()
|
||||
mock_gen_repo.count_pending_by_user.return_value = 0
|
||||
mock_gen_repo.count_pending_total.return_value = 0
|
||||
|
||||
self._run_create_task(mock_task, mock_request, mock_db, mock_gen_repo)
|
||||
|
||||
# Task should still be created (fallback error doesn't break main flow)
|
||||
assert mock_task.source_edit_plan_id == ""
|
||||
mock_gen_repo.update.assert_not_called()
|
||||
@@ -86,138 +86,3 @@ class TestFormatSeekTime:
|
||||
sec_parts = parts[2].split(".")
|
||||
assert len(sec_parts) == 2
|
||||
assert len(sec_parts[1]) == 2 # 两位小数
|
||||
|
||||
|
||||
# ── MediaKit 智能抽帧集成测试 ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractFramesViaMediakit:
|
||||
"""_extract_frames_via_mediakit 函数测试."""
|
||||
|
||||
def test_mediakit_not_configured_returns_none(self, tmp_path, monkeypatch):
|
||||
"""MediaKit 未配置时返回 None."""
|
||||
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
|
||||
|
||||
# Mock get_mediakit_client 返回不可用客户端
|
||||
class FakeClient:
|
||||
is_available = False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"packages.shared.mediakit_client.get_mediakit_client",
|
||||
lambda: FakeClient(),
|
||||
)
|
||||
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake video")
|
||||
|
||||
result = _extract_frames_via_mediakit(str(video_file), "plan1", 3)
|
||||
assert result is None
|
||||
|
||||
def test_mediakit_success_returns_frames(self, tmp_path, monkeypatch):
|
||||
"""MediaKit 成功时返回帧列表."""
|
||||
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
|
||||
|
||||
class FakeClient:
|
||||
is_available = True
|
||||
|
||||
def extract_frames(self, video_url, strategy, max_frames):
|
||||
return [
|
||||
{"image_url": "https://example.com/frame1.jpg", "timestamp": 1.5},
|
||||
{"image_url": "https://example.com/frame2.jpg", "timestamp": 3.2},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"packages.shared.mediakit_client.get_mediakit_client",
|
||||
lambda: FakeClient(),
|
||||
)
|
||||
# Mock upload_to_oss
|
||||
monkeypatch.setattr(
|
||||
"video_processing.oss_helpers.upload_to_oss",
|
||||
lambda path, key: f"https://oss.example.com/{key}",
|
||||
)
|
||||
# Mock httpx.get for downloading frame
|
||||
import httpx
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
content = b"fake image data"
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("httpx.get", lambda url, **kw: FakeResponse())
|
||||
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake video")
|
||||
|
||||
result = _extract_frames_via_mediakit(str(video_file), "plan1", 2)
|
||||
assert result is not None
|
||||
assert len(result) == 2
|
||||
assert result[0]["timestamp"] == 1.5
|
||||
assert result[1]["timestamp"] == 3.2
|
||||
|
||||
def test_mediakit_failure_returns_none(self, tmp_path, monkeypatch):
|
||||
"""MediaKit 调用失败时返回 None."""
|
||||
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
|
||||
|
||||
class FakeClient:
|
||||
is_available = True
|
||||
|
||||
def extract_frames(self, video_url, strategy, max_frames):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"packages.shared.mediakit_client.get_mediakit_client",
|
||||
lambda: FakeClient(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"video_processing.oss_helpers.upload_to_oss",
|
||||
lambda path, key: f"https://oss.example.com/{key}",
|
||||
)
|
||||
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake video")
|
||||
|
||||
result = _extract_frames_via_mediakit(str(video_file), "plan1", 3)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractAndUploadCoverFramesFallback:
|
||||
"""extract_and_upload_cover_frames 降级逻辑测试."""
|
||||
|
||||
def test_fallback_to_ffmpeg_when_mediakit_fails(self, tmp_path, monkeypatch):
|
||||
"""MediaKit 失败时降级到 ffmpeg 抽帧."""
|
||||
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
|
||||
|
||||
# Mock MediaKit 返回 None(未配置或失败)
|
||||
class FakeClient:
|
||||
is_available = False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"packages.shared.mediakit_client.get_mediakit_client",
|
||||
lambda: FakeClient(),
|
||||
)
|
||||
|
||||
# Mock ffmpeg 抽帧
|
||||
monkeypatch.setattr(
|
||||
"video_processing.thumbnail_generator.extract_first_frame",
|
||||
lambda video_path, output_path, **kw: output_path,
|
||||
)
|
||||
# Mock upload
|
||||
monkeypatch.setattr(
|
||||
"video_processing.oss_helpers.upload_to_oss",
|
||||
lambda path, key: f"https://oss.example.com/{key}",
|
||||
)
|
||||
# Mock probe_duration
|
||||
monkeypatch.setattr(
|
||||
"video_processing.ffmpeg_utils.probe_duration",
|
||||
lambda path: 60.0,
|
||||
)
|
||||
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake video")
|
||||
|
||||
result = extract_and_upload_cover_frames(str(video_file), "plan1", num_frames=2)
|
||||
assert len(result) == 2
|
||||
assert all("url" in item for item in result)
|
||||
assert all("position" in item for item in result)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""标题渲染前后端一致性测试。
|
||||
|
||||
验证 build_ass_content 生成的 ASS 样式参数与前端 drawTitleOnCanvas.ts 一致:
|
||||
- 字号不再设置上限,由前端/调用方控制
|
||||
- 字号上限 36px
|
||||
- 描边宽度 2px
|
||||
- 阴影 blur=4, offset=2
|
||||
- boolean stroke/shadow 自动转换
|
||||
@@ -10,16 +10,14 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure packages is importable
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages"))
|
||||
|
||||
from domain.ass_subtitle_builder import build_ass_content, build_ass_style
|
||||
|
||||
|
||||
class TestFontSize:
|
||||
"""字号处理:默认值与保留逻辑,不再做上限截断。"""
|
||||
class TestFontSizeCap:
|
||||
"""字号上限应与前端 Math.min(settings.size, 36) 一致。"""
|
||||
|
||||
def test_default_font_size_is_36(self):
|
||||
"""无 size 字段时,默认字号应为 36。"""
|
||||
@@ -31,7 +29,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",49," in content, f"默认字号36应补偿为49(36*1.35),实际内容: {content}"
|
||||
assert ",36," in content, f"默认字号应为36,实际内容: {content}"
|
||||
|
||||
def test_size_32_preserved(self):
|
||||
"""size=32 应原样使用。"""
|
||||
@@ -43,10 +41,10 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",43," in content # 32*1.35=43
|
||||
assert ",32," in content
|
||||
|
||||
def test_size_60_preserved(self):
|
||||
"""size=60 应原样保留(字号上限已移除)。"""
|
||||
def test_size_60_capped_at_36(self):
|
||||
"""size=60 应被 cap 到 36。"""
|
||||
config = {"size": 60}
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
@@ -55,40 +53,14 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
# 解析 Style 行的 Fontsize 字段(第3个字段,索引2)
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
font_size = int(fields[2])
|
||||
assert font_size == 81, f"字号60应补偿为81(60*1.35), 实际={font_size}"
|
||||
|
||||
def test_font_size_alias_normalized(self):
|
||||
"""前端传 font_size 应归一化为 size。"""
|
||||
config = {"font_size": 52}
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
assert fields[2] == "70", f"font_size=52 应补偿为70(52*1.35), 实际={fields[2]}"
|
||||
|
||||
def test_font_color_alias_normalized(self):
|
||||
"""前端传 font_color 应归一化为 color。"""
|
||||
config = {"font_color": "#00FF00"}
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
# 绿色 → &H00FF00
|
||||
assert "&H00FF00" in content
|
||||
assert font_size == 36, f"字号60应被cap到36, 实际={font_size}"
|
||||
|
||||
def test_size_24_preserved(self):
|
||||
"""size=24 应原样使用。"""
|
||||
"""size=24 应原样使用(小于36,不cap)。"""
|
||||
config = {"size": 24}
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
@@ -97,7 +69,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",32," in content # 24*1.35=32.4→32
|
||||
assert ",24," in content
|
||||
|
||||
|
||||
class TestBooleanStrokeNormalization:
|
||||
@@ -230,9 +202,9 @@ class TestFullStyleConsistency:
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
|
||||
# Fontname
|
||||
assert fields[1] == "Noto Sans SC"
|
||||
# Fontsize = 28*1.35=37.8→38
|
||||
assert fields[2] == "38"
|
||||
assert fields[1] == "思源黑体"
|
||||
# Fontsize = 28 (小于36,不cap)
|
||||
assert fields[2] == "28"
|
||||
# Bold = -1 (True)
|
||||
assert fields[7] == "-1"
|
||||
# Outline width = 2 (前端默认 stroke width)
|
||||
@@ -241,39 +213,3 @@ class TestFullStyleConsistency:
|
||||
assert int(fields[17]) == 2
|
||||
# Alignment = 8 (top)
|
||||
assert int(fields[18]) == 8
|
||||
|
||||
|
||||
class TestTitleSlashNewline:
|
||||
"""用户输入 / 或 / 应触发标题换行。"""
|
||||
|
||||
def test_halfwidth_slash_in_title(self):
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="第一行/第二行",
|
||||
title_config={"size": 48},
|
||||
)
|
||||
for line in content.splitlines():
|
||||
if "Dialogue" in line and "TitleStyle" in line:
|
||||
assert "\\N" in line, f"斜杠应转为换行: {line}"
|
||||
assert "第一行" in line
|
||||
assert "第二行" in line
|
||||
break
|
||||
else:
|
||||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||||
|
||||
def test_fullwidth_slash_in_title(self):
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="第一行/第二行",
|
||||
title_config={"size": 48},
|
||||
)
|
||||
for line in content.splitlines():
|
||||
if "Dialogue" in line and "TitleStyle" in line:
|
||||
assert "\\N" in line, f"全角斜杠应转为换行: {line}"
|
||||
break
|
||||
else:
|
||||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||||
|
||||
@@ -81,12 +81,6 @@ class TestTTSPreviewEndpoint:
|
||||
)
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -126,12 +120,6 @@ class TestTTSPreviewEndpoint:
|
||||
)
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -170,12 +158,6 @@ class TestTTSPreviewEndpoint:
|
||||
mock_service.synthesize_speech.side_effect = CosyVoiceError("API timeout")
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -204,12 +186,6 @@ class TestTTSPreviewEndpoint:
|
||||
mock_service.synthesize_speech.side_effect = ValueError("text 不能为空")
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -237,12 +213,6 @@ class TestTTSPreviewEndpoint:
|
||||
mock_service = MagicMock()
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -269,173 +239,9 @@ class TestTTSPreviewEndpoint:
|
||||
mock_service = MagicMock()
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "hello", "voice_id": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_preview_clone_voice_resolves_to_cosyvoice_id(self):
|
||||
"""Clone voice UUID is resolved to CosyVoice voice_id."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
from app.api.routes.tts import router
|
||||
|
||||
app.include_router(router, prefix="/tts")
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
|
||||
fake_user = MagicMock()
|
||||
fake_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: fake_user
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
|
||||
audio_url="https://x.com/cloned.mp3",
|
||||
duration=1.8,
|
||||
)
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
# Mock voice clone profile with voice_id
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.user_id = "user-1"
|
||||
mock_profile.voice_id = "cosyvoice_actual_voice_123"
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = mock_profile
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
# Frontend sends the profile UUID as voice_id
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "克隆音色测试", "voice_id": "abc123-uuid-of-profile"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["audio_url"] == "https://x.com/cloned.mp3"
|
||||
|
||||
# Verify CosyVoice was called with the resolved voice_id, not the UUID
|
||||
mock_service.synthesize_speech.assert_called_once_with(
|
||||
text="克隆音色测试",
|
||||
voice_id="cosyvoice_actual_voice_123",
|
||||
speed=1.0,
|
||||
)
|
||||
# Verify repo was queried with the UUID
|
||||
mock_clone_repo.get.assert_called_once_with("abc123-uuid-of-profile")
|
||||
|
||||
def test_preview_clone_voice_incomplete_returns_400(self):
|
||||
"""Clone profile with empty voice_id returns 400."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
from app.api.routes.tts import router
|
||||
|
||||
app.include_router(router, prefix="/tts")
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
fake_user = MagicMock()
|
||||
fake_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: fake_user
|
||||
|
||||
# Mock voice clone profile with empty voice_id (clone not finished)
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.user_id = "user-1"
|
||||
mock_profile.voice_id = ""
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = mock_profile
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "测试未完成克隆", "voice_id": "abc123-uuid"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "音色克隆尚未完成" in resp.json()["detail"]
|
||||
|
||||
def test_preview_preset_voice_passthrough(self):
|
||||
"""Preset voice ID (not a profile UUID) passes through unchanged."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
from app.api.routes.tts import router
|
||||
|
||||
app.include_router(router, prefix="/tts")
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
|
||||
fake_user = MagicMock()
|
||||
fake_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: fake_user
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
|
||||
audio_url="https://x.com/preset.mp3",
|
||||
duration=2.0,
|
||||
)
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
# Mock repo returns None (preset voice, not a clone profile)
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "预设音色测试", "voice_id": "longxiaoxia_v3"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify CosyVoice was called with the original preset voice_id
|
||||
mock_service.synthesize_speech.assert_called_once_with(
|
||||
text="预设音色测试",
|
||||
voice_id="longxiaoxia_v3",
|
||||
speed=1.0,
|
||||
)
|
||||
|
||||
def test_preview_clone_voice_wrong_user_returns_403(self):
|
||||
"""Accessing another user's clone profile returns 403."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
from app.api.routes.tts import router
|
||||
|
||||
app.include_router(router, prefix="/tts")
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
fake_user = MagicMock()
|
||||
fake_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: fake_user
|
||||
|
||||
# Mock profile belonging to a different user
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.user_id = "user-2"
|
||||
mock_profile.voice_id = "cosyvoice_voice_xyz"
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = mock_profile
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "越权测试", "voice_id": "other-user-profile-uuid"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "无权访问该音色" in resp.json()["detail"]
|
||||
|
||||
@@ -46,10 +46,13 @@ class TestAssetAnalysesDoesNotOverwriteStatus:
|
||||
"Use an independent session to update only extra_meta."
|
||||
)
|
||||
|
||||
def test_no_stale_repo_update(self):
|
||||
"""旧路径的 _repo.update(gen_task) 模式已随 DEPRECATED 代码一起删除。"""
|
||||
def test_asset_analyses_uses_independent_session(self):
|
||||
"""asset_analyses 持久化必须用独立 session 查询最新模型再提交。"""
|
||||
source = _read_source()
|
||||
assert "_repo.update(gen_task)" not in source
|
||||
assert "_meta_session" in source
|
||||
assert "GenerationTaskModel" in source
|
||||
# 必须只更新 extra_meta 字段
|
||||
assert 'existing["asset_analyses"]' in source
|
||||
|
||||
|
||||
class TestGenerationTaskModelOrmAttribute:
|
||||
|
||||
@@ -6,63 +6,28 @@ _sync_task_config_to_plan directly under the @celery_app.task decorator,
|
||||
so Celery registered the helper as "worker.generate_video". Calling the
|
||||
task with a single task_id raised TypeError and every generation job
|
||||
failed immediately. This test pins the decorator target.
|
||||
|
||||
NOTE: CI conftest may mock Celery so that @celery_app.task does NOT return
|
||||
a fully functional Task/PromiseProxy object. Tests therefore use multiple
|
||||
defensive strategies: source-code inspection, __wrapped__.__func__ chain
|
||||
traversal, and direct attribute checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
|
||||
|
||||
def _get_original_function(generate_video):
|
||||
"""Walk the __wrapped__ chain to find the original function object."""
|
||||
obj = generate_video
|
||||
seen = set()
|
||||
while hasattr(obj, "__wrapped__"):
|
||||
obj_id = id(obj)
|
||||
if obj_id in seen:
|
||||
break
|
||||
seen.add(obj_id)
|
||||
obj = obj.__wrapped__
|
||||
# __wrapped__ may be a bound method — unwrap to the underlying function
|
||||
if hasattr(obj, "__func__"):
|
||||
return obj.__func__
|
||||
return obj
|
||||
|
||||
|
||||
def test_generate_video_task_has_bind_true():
|
||||
"""The decorator must use bind=True — verified via the original function's
|
||||
first parameter being 'self' (bind=True convention)."""
|
||||
def test_generate_video_task_registered_under_expected_name():
|
||||
from worker_app.tasks.generation import generate_video
|
||||
|
||||
original = _get_original_function(generate_video)
|
||||
sig = inspect.signature(original)
|
||||
params = list(sig.parameters)
|
||||
assert params[0] == "self", f"bind=True requires 'self' as first param, got {params}"
|
||||
# Celery task object exposes its registered name
|
||||
assert generate_video.name == "worker.generate_video"
|
||||
|
||||
|
||||
def test_generate_video_task_signature_has_task_id():
|
||||
"""The original generate_video function must accept task_id as a parameter."""
|
||||
from worker_app.tasks.generation import generate_video
|
||||
|
||||
original = _get_original_function(generate_video)
|
||||
sig = inspect.signature(original)
|
||||
# For bind=True tasks Celery binds self at call time, so run() signature
|
||||
# starts directly with task_id (verified on Celery 5.x).
|
||||
sig = inspect.signature(generate_video.run)
|
||||
params = list(sig.parameters)
|
||||
assert "task_id" in params, f"expected 'task_id' in params, got {params}"
|
||||
|
||||
|
||||
def test_generate_video_preserves_original_function():
|
||||
"""The original function wrapped by @celery_app.task must be named
|
||||
'generate_video' — not '_sync_task_config_to_plan'."""
|
||||
from worker_app.tasks.generation import generate_video
|
||||
|
||||
original = _get_original_function(generate_video)
|
||||
assert original.__name__ == "generate_video", f"expected __name__='generate_video', got '{original.__name__}'"
|
||||
assert params[0] == "task_id", f"expected task_id as first param, got {params}"
|
||||
|
||||
|
||||
def test_sync_task_config_to_plan_is_plain_function():
|
||||
|
||||
Reference in New Issue
Block a user