Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 024aca3557 | |||
| bbf27ec3f6 | |||
| febb1bcfce |
@@ -1,10 +1,9 @@
|
||||
"""模板编辑器 API 路由包.
|
||||
|
||||
将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
|
||||
模块拆分:
|
||||
- schemas.py: 所有 Pydantic model
|
||||
- dependencies.py: 依赖注入
|
||||
- _utils.py: 工具函数
|
||||
- _fallback.py: 自动兜底逻辑
|
||||
- draft.py: 草稿管理(详情/更新/发布/版本/回滚)
|
||||
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
|
||||
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
|
||||
@@ -13,7 +12,6 @@
|
||||
- export.py: 导出配置
|
||||
- subtitles.py: 字幕管理
|
||||
- ai_features.py: AI 推荐
|
||||
- generation.py: 生成(触发/进度/记录)
|
||||
- timeline.py: 时间线
|
||||
|
||||
挂载路径: /api/v1/templates/{template_id}/editor/
|
||||
@@ -34,7 +32,6 @@ from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||
from .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
from .export import router as export_router
|
||||
from .generation import router as generation_router
|
||||
from .subtitles import router as subtitles_router
|
||||
from .timeline import router as timeline_router
|
||||
|
||||
@@ -51,7 +48,6 @@ _sub_routers = [
|
||||
export_router,
|
||||
subtitles_router,
|
||||
ai_features_router,
|
||||
generation_router,
|
||||
timeline_router,
|
||||
]
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
"""模板编辑器自动兜底逻辑.
|
||||
|
||||
generate_editor_draft 触发生成前的自动修复流程:
|
||||
1. draft → editing 状态迁移
|
||||
2. 无片段时从模板复制片段配置
|
||||
3. 为无素材片段分配指定素材
|
||||
4. 项目有素材库时自动选素材
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||
"""自动兜底 1: draft → editing"""
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
|
||||
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||
plan_id,
|
||||
plan_check.template_id,
|
||||
)
|
||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||
if configs:
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
|
||||
plan_id,
|
||||
len(configs),
|
||||
)
|
||||
else:
|
||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||
for seg in segments:
|
||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
duration=avg_duration,
|
||||
config={
|
||||
"material_type": seg.material_type or "",
|
||||
"template_segment_id": seg.id,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段",
|
||||
plan_id,
|
||||
len(segments),
|
||||
)
|
||||
|
||||
|
||||
def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list:
|
||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r",
|
||||
plan_id,
|
||||
len(all_clips),
|
||||
len(clips_without_asset),
|
||||
config_asset_ids[:5] if config_asset_ids else [],
|
||||
)
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
assigned = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
try:
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
|
||||
plan_id,
|
||||
assigned,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新检查剩余无素材片段
|
||||
all_clips_after = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
|
||||
if clips_without_asset:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
elif not clips_without_asset:
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
|
||||
elif not config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
|
||||
plan_id,
|
||||
)
|
||||
|
||||
return clips_without_asset
|
||||
|
||||
|
||||
def _auto_fallback_auto_material_mode(
|
||||
svc: EditPlanService,
|
||||
plan_id: str,
|
||||
plan_check,
|
||||
clips_without_asset: list,
|
||||
asset_library_repo: Any,
|
||||
asset_repo: Any,
|
||||
user_id: str = "",
|
||||
) -> None:
|
||||
"""自动兜底 4: 自动选素材分配给无素材片段
|
||||
|
||||
查找策略(按优先级):
|
||||
1. plan 有 project_id → 从项目素材库查找
|
||||
2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找
|
||||
"""
|
||||
if not clips_without_asset:
|
||||
return
|
||||
|
||||
ready_videos: list = []
|
||||
source_desc = ""
|
||||
|
||||
# 策略 1: 通过 project_id 查找项目素材库
|
||||
if plan_check.project_id:
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
source_desc = f"素材库 {video_lib.name}"
|
||||
|
||||
# 策略 2: 通过 user_id 查找用户上传的素材
|
||||
if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"):
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材",
|
||||
plan_id,
|
||||
user_id,
|
||||
)
|
||||
ready_videos = asset_repo.find_ready_videos_by_user(user_id)
|
||||
source_desc = f"用户上传 (user_id={user_id[:8]}...)"
|
||||
|
||||
if not ready_videos:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)",
|
||||
plan_id,
|
||||
plan_check.project_id or "(empty)",
|
||||
user_id[:8] + "..." if user_id else "(empty)",
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
)
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 从 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
@@ -3,7 +3,6 @@
|
||||
核心依赖:
|
||||
- get_editor_services: 获取模板+计划服务
|
||||
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
|
||||
- _check_queue_limits: 生成队列限流检查
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +10,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||
from app.dependencies import get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
@@ -113,29 +111,3 @@ def get_draft_plan_id(
|
||||
user_id,
|
||||
)
|
||||
return plan.id
|
||||
|
||||
|
||||
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
||||
"""队列限流预检查"""
|
||||
try:
|
||||
has_count = (
|
||||
hasattr(gen_task_repo, "count_pending_by_user")
|
||||
and hasattr(gen_task_repo, "count_pending_total")
|
||||
)
|
||||
if has_count:
|
||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||
global_pending = gen_task_repo.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e)
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
"""草稿生成路由.
|
||||
|
||||
端点:
|
||||
- POST /generate 触发生成
|
||||
- GET /generation-status 生成进度
|
||||
- GET /generations 生成记录列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
)
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
from ._fallback import (
|
||||
_auto_fallback_assign_assets,
|
||||
_auto_fallback_auto_material_mode,
|
||||
_auto_fallback_copy_template_clips,
|
||||
_auto_fallback_draft_to_editing,
|
||||
)
|
||||
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateRequest,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
|
||||
@router.post("/generate", response_model=EditPlanGenerateResponse)
|
||||
def generate_editor_draft(
|
||||
template_id: str,
|
||||
request: Optional[EditPlanGenerateRequest] = None,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发模板草稿渲染生成"""
|
||||
req = request or EditPlanGenerateRequest()
|
||||
_, plan_svc = services
|
||||
plan_check = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# 自动兜底流程
|
||||
_auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check)
|
||||
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
|
||||
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
|
||||
_auto_fallback_auto_material_mode(
|
||||
plan_svc,
|
||||
plan_id,
|
||||
plan_check,
|
||||
clips_without_asset,
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id=str(current_user.user.id),
|
||||
)
|
||||
|
||||
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
# 如果前端传了 title_config,需要创建新任务(因为预览任务的 custom_title 可能不同)
|
||||
title_config_reuse = req.title_config or {}
|
||||
title_text_reuse = (title_config_reuse.get("text") or "").strip()
|
||||
existing_custom_title = getattr(reusable_task, "custom_title", "") or ""
|
||||
if title_text_reuse and existing_custom_title:
|
||||
# 如果新标题和已有标题不同,不能复用,走新建任务流程
|
||||
new_title_json = json.dumps(title_config_reuse, ensure_ascii=False)
|
||||
if new_title_json != existing_custom_title:
|
||||
logger.info(
|
||||
"[模板生成] 标题已变更,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
elif title_text_reuse and not existing_custom_title:
|
||||
# 原来没标题,现在有标题,不能复用
|
||||
logger.info(
|
||||
"[模板生成] 新增标题,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
elif not title_text_reuse and existing_custom_title:
|
||||
# 原来有标题,现在移除了,不能复用
|
||||
logger.info(
|
||||
"[模板生成] 移除标题,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
reusable_task.mark_confirmed()
|
||||
gen_task_repo.update(reusable_task)
|
||||
|
||||
# 将产物 URL 写入 plan config
|
||||
rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db)
|
||||
plan_svc.update_plan_config(
|
||||
plan_id,
|
||||
{
|
||||
"generation_task_id": reusable_task.id,
|
||||
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
|
||||
},
|
||||
)
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
logger.info(
|
||||
"模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
reusable_task.id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=reusable_task.id,
|
||||
clip_count=len((plan_check.config or {}).get("clips", [])),
|
||||
)
|
||||
|
||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
||||
try:
|
||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if not can_gen:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||
|
||||
try:
|
||||
clip_count = plan_svc.mark_clips_ready(plan_id)
|
||||
|
||||
user_id = current_user.user.id
|
||||
_check_queue_limits(gen_task_repo, user_id)
|
||||
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
# 从 plan config 读取封面 URL(由 generate-cover 保存)
|
||||
cover_url_from_config = (plan.config or {}).get("cover", {}).get("image_url", "")
|
||||
|
||||
# 处理标题配置:序列化 title_config 为 JSON 存入 custom_title
|
||||
title_config = req.title_config or {}
|
||||
title_text = (title_config.get("text") or "").strip()
|
||||
custom_title_value = ""
|
||||
if title_text:
|
||||
custom_title_value = json.dumps(title_config, ensure_ascii=False)
|
||||
logger.info(
|
||||
"[模板生成] 标题配置: text=%s, config_keys=%s",
|
||||
title_text[:30],
|
||||
list(title_config.keys()),
|
||||
)
|
||||
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=plan.project_id or "",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
asset_ids=list(config_asset_ids) if config_asset_ids else [],
|
||||
cover_url=cover_url_from_config,
|
||||
custom_title=custom_title_value,
|
||||
),
|
||||
)
|
||||
|
||||
plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
celery_app.send_task("worker.generate_video", args=[gen_task.id])
|
||||
|
||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
logger.info(
|
||||
"模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as _e:
|
||||
logger.exception(
|
||||
"模板编辑器触发生成失败: template_id=%s plan_id=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
)
|
||||
try:
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成失败,请稍后重试",
|
||||
) from _e
|
||||
|
||||
|
||||
def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None":
|
||||
"""查找该 plan 关联的已完成预览任务,判断是否可复用。
|
||||
|
||||
复用条件:
|
||||
1. 存在 source_edit_plan_id == plan_id 的已完成预览任务
|
||||
2. plan 在预览完成后未被修改(updated_at <= 预览完成时间)
|
||||
|
||||
Returns:
|
||||
可复用的 GenerationTask,或 None
|
||||
"""
|
||||
try:
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for task in tasks:
|
||||
if not getattr(task, "is_preview", False):
|
||||
continue
|
||||
if not task.is_completed:
|
||||
continue
|
||||
# 检查 plan 是否在预览完成后被修改
|
||||
completed_at = getattr(task, "completed_at", None)
|
||||
if completed_at and hasattr(plan, "updated_at"):
|
||||
plan_updated = plan.updated_at
|
||||
# 如果 plan.updated_at 为空,无法判断是否修改过,跳过
|
||||
if plan_updated is None:
|
||||
continue
|
||||
# 如果 plan 在预览完成后又被修改了,不能复用
|
||||
if plan_updated > completed_at:
|
||||
continue
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
"""获取任务的输出视频 URL。"""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
url = getattr(videos[0], "file_url", "") or ""
|
||||
# 规范化:合并路径中的双斜杠(保留协议头 ://)
|
||||
if url:
|
||||
import re as _re
|
||||
url = _re.sub(r"(?<!:)//", "/", url)
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
|
||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
||||
def get_editor_generation_status(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditPlanGenerationStatusResponse:
|
||||
"""查询草稿生成进度"""
|
||||
_, plan_svc = services
|
||||
try:
|
||||
gen_status = plan_svc.get_generation_status(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
plan = gen_status["plan"]
|
||||
clips = gen_status["clips"]
|
||||
|
||||
clip_items = [
|
||||
ClipStatusItem(
|
||||
clip_id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
asset_id=c.asset_id or "",
|
||||
text_content=c.text_content or "",
|
||||
duration=c.duration,
|
||||
)
|
||||
for c in clips
|
||||
]
|
||||
|
||||
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
|
||||
video_url = ""
|
||||
if raw_video_url:
|
||||
if raw_video_url.startswith("http"):
|
||||
video_url = raw_video_url # 已经是完整 URL
|
||||
else:
|
||||
try:
|
||||
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
|
||||
except Exception as e:
|
||||
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
|
||||
video_url = raw_video_url
|
||||
|
||||
progress = gen_status.get("progress", 0.0)
|
||||
error_message = gen_status.get("error_message", "")
|
||||
gen_task_status = gen_status.get("generation_task_status")
|
||||
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status_val == "completed" and progress < 100:
|
||||
progress = 100.0
|
||||
|
||||
return EditPlanGenerationStatusResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=plan_status_val,
|
||||
generation_task_id=gen_status["generation_task_id"],
|
||||
generation_task_status=gen_task_status,
|
||||
progress=progress,
|
||||
video_url=video_url,
|
||||
error_message=error_message,
|
||||
clips=clip_items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/generations", response_model=EditPlanGenerationsResponse)
|
||||
def list_editor_generations(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditPlanGenerationsResponse:
|
||||
"""查询草稿关联的生成记录列表"""
|
||||
_, plan_svc = services
|
||||
plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
items = [
|
||||
GenerationTaskResponse(
|
||||
id=t.id,
|
||||
project_id=t.project_id,
|
||||
asset_library_id=t.asset_library_id,
|
||||
strategy_id=t.strategy_id,
|
||||
voice_library_id=t.voice_library_id,
|
||||
template_id=t.template_id,
|
||||
asset_ids=t.asset_ids,
|
||||
title_ids=t.title_ids,
|
||||
voice_ids=t.voice_ids,
|
||||
source_edit_plan_id=t.source_edit_plan_id or "",
|
||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||||
progress=t.progress,
|
||||
result_count=t.result_count,
|
||||
error_message=t.error_message,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||||
@@ -6,9 +6,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re as _re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
|
||||
@@ -16,59 +15,6 @@ _EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best
|
||||
_EXPORT_VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
|
||||
# ── 生成状态相关 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipStatusItem(BaseModel):
|
||||
"""片段生成状态"""
|
||||
|
||||
clip_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
status: str
|
||||
asset_id: str
|
||||
text_content: str
|
||||
duration: float
|
||||
|
||||
|
||||
class EditPlanGenerationStatusResponse(BaseModel):
|
||||
"""剪辑计划生成进度响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: Optional[str] = None
|
||||
generation_task_status: Optional[str] = None
|
||||
progress: float = 0.0
|
||||
video_url: str = ""
|
||||
error_message: str = ""
|
||||
clips: List[ClipStatusItem]
|
||||
|
||||
|
||||
class EditPlanGenerateRequest(BaseModel):
|
||||
"""模板编辑器触发生成请求体"""
|
||||
|
||||
title_config: Optional[Dict[str, Any]] = Field(
|
||||
default_factory=dict,
|
||||
description="标题配置(可选),渲染时烧录到视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
|
||||
)
|
||||
|
||||
|
||||
class EditPlanGenerateResponse(BaseModel):
|
||||
"""剪辑计划触发生成响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: str
|
||||
clip_count: int
|
||||
|
||||
|
||||
class EditPlanGenerationsResponse(BaseModel):
|
||||
"""剪辑计划关联的生成记录列表响应体"""
|
||||
|
||||
items: List[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── AI 推荐 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* 模板草稿 CRUD + 生成相关 API
|
||||
* 模板草稿 CRUD API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
EditPlan,
|
||||
UpdateEditPlanRequest,
|
||||
GenerateResponse,
|
||||
GenerationStatusResponse,
|
||||
EditPlanGeneration,
|
||||
GeneratedVideo,
|
||||
} from "./types"
|
||||
import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
@@ -27,24 +20,6 @@ export async function updateEditPlan(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 触发生成 */
|
||||
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取生成状态(轮询用) */
|
||||
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取模板草稿关联的生成记录 */
|
||||
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
|
||||
@@ -16,10 +16,7 @@ export type {
|
||||
EditPlanConfig,
|
||||
EditPlan,
|
||||
UpdateEditPlanRequest,
|
||||
GenerateResponse,
|
||||
EditPlanGeneration,
|
||||
ClipStatusItem,
|
||||
GenerationStatusResponse,
|
||||
GeneratedVideo,
|
||||
AIRecommendRequest,
|
||||
AIRecommendClipItem,
|
||||
@@ -52,9 +49,6 @@ export {
|
||||
getEditPlan,
|
||||
updateEditPlan,
|
||||
updateEditPlanClips,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
} from "./editPlans"
|
||||
export type { EditPlanClipInput } from "./editPlans"
|
||||
|
||||
@@ -187,31 +187,6 @@ export interface EditPlanListResponse {
|
||||
|
||||
/* ── 生成相关 ── */
|
||||
|
||||
/** 生成响应 */
|
||||
export interface GenerateResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id: string
|
||||
clip_count: number
|
||||
}
|
||||
|
||||
/** 模板草稿关联的生成记录 */
|
||||
export interface EditPlanGeneration {
|
||||
id: string
|
||||
source_edit_plan_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
status: EditPlanStatus
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
error_info: Record<string, unknown>
|
||||
logs: Array<Record<string, unknown>>
|
||||
retry_count: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 片段生成状态 */
|
||||
export interface ClipStatusItem {
|
||||
clip_id: string
|
||||
@@ -224,17 +199,6 @@ export interface ClipStatusItem {
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
/** 生成状态轮询响应 */
|
||||
export interface GenerationStatusResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id?: string
|
||||
error_message?: string
|
||||
clips: ClipStatusItem[]
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** 生成视频详情 */
|
||||
export interface GeneratedVideo {
|
||||
id: string
|
||||
|
||||
@@ -71,7 +71,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
label: "剪辑模板",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
@@ -133,7 +133,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
label: "剪辑模板",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* 生成历史弹窗 — 展示当前模板草稿的生成任务记录
|
||||
* 从 EditingPlanner 拆分,避免主文件过大
|
||||
*/
|
||||
import React from "react"
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
|
||||
import type { EditPlanGeneration } from "@/api/template-editor"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/template-editor"
|
||||
|
||||
interface GenerationHistoryModalProps {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
history: EditPlanGeneration[]
|
||||
onClose: () => void
|
||||
onCancel?: (taskId: string) => void
|
||||
cancelLoading?: boolean
|
||||
}
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
history,
|
||||
onClose,
|
||||
onCancel,
|
||||
cancelLoading,
|
||||
}) => {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="ep-modal-overlay" onClick={onClose}>
|
||||
<div className="ep-modal ep-gh-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ep-modal-header">
|
||||
<h3>生成历史</h3>
|
||||
<button className="ep-modal-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ep-modal-body ep-gh-body">
|
||||
{loading ? (
|
||||
<div className="ep-gh-empty">
|
||||
<div className="ep-skeleton">
|
||||
<div className="ep-skeleton-item ep-skeleton-item--header" />
|
||||
<div className="ep-skeleton-item" />
|
||||
<div className="ep-skeleton-item" />
|
||||
<div className="ep-skeleton-item" />
|
||||
</div>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="ep-gh-empty">
|
||||
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
||||
<span>暂无生成记录</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ep-gh-table">
|
||||
<thead>
|
||||
<tr className="ep-gh-table-header-row">
|
||||
<th className="ep-gh-th">任务ID</th>
|
||||
<th className="ep-gh-th">状态</th>
|
||||
<th className="ep-gh-th">创建时间</th>
|
||||
<th className="ep-gh-th">更新时间</th>
|
||||
{onCancel && <th className="ep-gh-th">操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((gen) => {
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`
|
||||
const canCancel = gen.status === "rendering" || gen.status === "editing"
|
||||
return (
|
||||
<tr key={gen.id} className="ep-gh-table-row">
|
||||
<td className="ep-gh-td ep-gh-td-id">
|
||||
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td">
|
||||
<span className={`ep-gh-status-tag ${statusClass}`}>
|
||||
{PLAN_STATUS_LABELS[gen.status] || gen.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"}
|
||||
</td>
|
||||
{onCancel && (
|
||||
<td className="ep-gh-td ep-gh-td-action">
|
||||
{canCancel ? (
|
||||
<button
|
||||
className="ep-gh-cancel-btn"
|
||||
onClick={() => onCancel(gen.id)}
|
||||
disabled={cancelLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<span className="ep-gh-action-placeholder">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-modal-footer">
|
||||
<button className="ep-btn ep-btn-secondary" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationHistoryModal
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 入口文件(向后兼容)
|
||||
* 实际实现已移至 ./generation-progress-modal/ 目录
|
||||
*/
|
||||
export { default } from "./generation-progress-modal"
|
||||
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
import { getStepLabel, getStatusColor } from "./constants"
|
||||
|
||||
interface ProgressPhaseProps {
|
||||
open: boolean
|
||||
task: TaskItem | null
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** progress(进度轮询)阶段弹窗 */
|
||||
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
|
||||
const progress = task?.progress ?? 0
|
||||
const status = task?.status ?? ""
|
||||
const currentStep = task?.current_step ?? ""
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const stepColor = getStatusColor(status, currentStep)
|
||||
|
||||
return (
|
||||
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
|
||||
<div className="ep-gen-progress">
|
||||
{/* 进度环 */}
|
||||
<div className="ep-gen-progress-ring-wrap">
|
||||
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
||||
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
|
||||
<circle
|
||||
className="ep-gen-progress-ring-fill"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * 52}`,
|
||||
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
|
||||
stroke: stepColor,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 当前步骤 */}
|
||||
<div className="ep-gen-step-text">
|
||||
{userMessage || getStepLabel(currentStep) || "处理中…"}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="ep-gen-progress-bar">
|
||||
<div
|
||||
className="ep-gen-progress-bar-fill"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: stepColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
interface ResultPhaseProps {
|
||||
open: boolean
|
||||
phase: "completed" | "failed"
|
||||
task: TaskItem | null
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** completed / failed(结果)阶段弹窗 */
|
||||
export const ResultPhase: React.FC<ResultPhaseProps> = ({
|
||||
open,
|
||||
phase,
|
||||
task,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const errorMessage = task?.error_message ?? ""
|
||||
const retryable = task?.retryable ?? false
|
||||
const handleClose = onClose || onCancel
|
||||
|
||||
if (phase === "completed") {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="✅ 生成完成"
|
||||
footer={null}
|
||||
onCancel={handleClose}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result">
|
||||
<div className="ep-gen-result-icon">🎉</div>
|
||||
<div className="ep-gen-result-title">视频生成完成!</div>
|
||||
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
|
||||
<div className="ep-gen-result-actions">
|
||||
<Button buttonType="primary" onClick={handleClose}>
|
||||
查看结果
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="❌ 生成失败"
|
||||
footer={null}
|
||||
onCancel={handleClose}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result ep-gen-result--error">
|
||||
<div className="ep-gen-result-icon">😥</div>
|
||||
<div className="ep-gen-result-title">视频生成失败</div>
|
||||
{(errorMessage || userMessage) && (
|
||||
<div className="ep-gen-result-msg ep-gen-result-msg--error">
|
||||
{errorMessage || userMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-gen-result-actions">
|
||||
{retryable && onRetry && (
|
||||
<Button buttonType="primary" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="secondary" onClick={handleClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
|
||||
interface SetupPhaseProps {
|
||||
open: boolean
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
submitting: boolean
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** setup(配置)阶段弹窗 */
|
||||
export const SetupPhase: React.FC<SetupPhaseProps> = ({
|
||||
open,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
submitting,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
}) => (
|
||||
<Modal
|
||||
open={open}
|
||||
title="使用模板生成视频"
|
||||
confirmLoading={submitting}
|
||||
onOk={onGenerate}
|
||||
onCancel={onCancel}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-setup">
|
||||
<label className="ep-gen-field-label">配音时长(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-gen-duration-input"
|
||||
placeholder="请输入配音时长"
|
||||
value={voiceoverDuration ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value ? Number(e.target.value) : null
|
||||
onDurationChange(v)
|
||||
}}
|
||||
min={1}
|
||||
max={600}
|
||||
/>
|
||||
<div className="ep-gen-estimate">
|
||||
预估总时长:<strong>{estimatedDuration}s</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
/* ──────────── 步骤文案映射 ──────────── */
|
||||
|
||||
export const STEP_LABELS: Record<string, string> = {
|
||||
queued: "排队中…",
|
||||
preparing: "准备素材…",
|
||||
generating_video: "渲染视频中…",
|
||||
adding_effects: "添加特效…",
|
||||
composing: "合成中…",
|
||||
encoding: "编码输出中…",
|
||||
completed: "生成完成!",
|
||||
failed: "生成失败",
|
||||
}
|
||||
|
||||
export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
|
||||
|
||||
/* ──────────── 状态徽标颜色 ──────────── */
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
queued: "#6b7280",
|
||||
pending: "#6b7280",
|
||||
preparing: "#f59e0b",
|
||||
generating_video: "#4f46e5",
|
||||
adding_effects: "#7c3aed",
|
||||
composing: "#2563eb",
|
||||
encoding: "#0891b2",
|
||||
completed: "#10b981",
|
||||
failed: "#ef4444",
|
||||
}
|
||||
|
||||
export const getStatusColor = (status: string, currentStep: string) =>
|
||||
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 任务 2.17
|
||||
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
||||
* V21 设计系统,CSS 类名前缀 ep-gen-
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import type { GenPhase, GenerationProgressModalProps } from "./types"
|
||||
import { SetupPhase } from "./SetupPhase"
|
||||
import { ProgressPhase } from "./ProgressPhase"
|
||||
import { ResultPhase } from "./ResultPhase"
|
||||
|
||||
/* 重新导出类型,保持向后兼容 */
|
||||
export type { GenPhase, GenerationProgressModalProps }
|
||||
|
||||
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
open,
|
||||
phase,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
task,
|
||||
submitting,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
|
||||
const prevOpen = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevOpen.current && !open) {
|
||||
/* modal just closed — parent handles reset */
|
||||
}
|
||||
prevOpen.current = open
|
||||
}, [open])
|
||||
|
||||
/* setup 阶段 */
|
||||
if (phase === "setup") {
|
||||
return (
|
||||
<SetupPhase
|
||||
open={open}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={estimatedDuration}
|
||||
submitting={submitting}
|
||||
onDurationChange={onDurationChange}
|
||||
onGenerate={onGenerate}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* progress 阶段 */
|
||||
if (phase === "progress") {
|
||||
return <ProgressPhase open={open} task={task} onCancel={onCancel} />
|
||||
}
|
||||
|
||||
/* completed / failed 阶段 */
|
||||
return (
|
||||
<ResultPhase
|
||||
open={open}
|
||||
phase={phase as "completed" | "failed"}
|
||||
task={task}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationProgressModal
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
export type GenPhase = "setup" | "progress" | "completed" | "failed"
|
||||
|
||||
export interface GenerationProgressModalProps {
|
||||
open: boolean
|
||||
phase: GenPhase
|
||||
|
||||
/* setup 阶段 */
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
|
||||
/* progress / 结果阶段 */
|
||||
task: TaskItem | null
|
||||
|
||||
/* 通用 */
|
||||
submitting: boolean
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -72,6 +72,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
@@ -171,7 +172,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
sourceEditPlanId: editPlanId,
|
||||
sourceEditPlanId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -82,6 +82,13 @@ export interface GenerateFormState {
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
|
||||
/**
|
||||
* 传给 Worker 的 source_edit_plan_id。
|
||||
* 优先使用 URL 中的 edit_plan_id;URL 没有时回退为 selectedTemplate(模板 ID),
|
||||
* 因为 Step2 的 clips 就是用 selectedTemplate 作为 plan_id 写入的。
|
||||
*/
|
||||
sourceEditPlanId: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
@@ -100,6 +107,11 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
/* ── 模板选择 ── */
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
|
||||
|
||||
/* ── source_edit_plan_id:URL 优先,否则回退到 selectedTemplate ── */
|
||||
// selectedTemplate 是异步加载的(react-query),组件重新渲染时此值会自动更新,
|
||||
// 因此最终传给 useGenerateVideo 的 sourceEditPlanId 能在模板就绪后拿到正确值。
|
||||
const sourceEditPlanId = editPlanId || selectedTemplate || null
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
@@ -189,6 +201,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
|
||||
@@ -154,6 +154,7 @@ export function useStep6Cover({
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
source_edit_plan_id: selectedTemplate,
|
||||
duration: duration || 30,
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
* tplSeg = templateSegments[i] || lastSegment
|
||||
* segDuration = clamp(assetDuration, tplSeg.duration_min, tplSeg.duration_max)
|
||||
* start_time = 0
|
||||
* duration = segDuration
|
||||
* // 关键:预览播放器中 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"
|
||||
@@ -47,10 +54,15 @@ export function buildClipsFromAssets({
|
||||
? 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: segDuration,
|
||||
duration,
|
||||
order: i,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -87,7 +87,7 @@ export const useTemplateLibrary = () => {
|
||||
[copyMutation],
|
||||
)
|
||||
|
||||
/* 操作:使用模板 → 跳转剪辑编辑器 */
|
||||
/* 操作:使用模板 → 跳转剪辑模板 */
|
||||
const handleUse = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
navigate(`/app/editing-planner?templateId=${template.id}`)
|
||||
|
||||
@@ -2,10 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
getEditPlan,
|
||||
updateEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
aiRecommendClips,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
getEditPlanClips,
|
||||
getEditPlanClip,
|
||||
@@ -80,38 +77,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(generateEditPlan("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(generateEditPlan("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGenerationStatus", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getGenerationStatus("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(getGenerationStatus("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("aiRecommendClips", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(aiRecommendClips("test-planId")).resolves.not.toThrow()
|
||||
@@ -128,22 +93,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEditPlanGenerations", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlanGenerations("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(getEditPlanGenerations("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGenerationTaskResults", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getGenerationTaskResults("test-taskId")).resolves.not.toThrow()
|
||||
|
||||
@@ -148,11 +148,8 @@ vi.mock("@/api/editing-planner", () => ({
|
||||
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
@@ -223,12 +220,6 @@ vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
|
||||
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }),
|
||||
}))
|
||||
|
||||
// === useUndoRedo hook mock ===
|
||||
vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({
|
||||
|
||||
@@ -214,12 +214,6 @@ vi.mock("@/api/titles", () => ({
|
||||
}))
|
||||
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
generateEditPlan: vi.fn().mockResolvedValue({
|
||||
plan_id: "test-plan",
|
||||
generation_task_id: "test-task",
|
||||
plan_status: "processing",
|
||||
clip_count: 5,
|
||||
}),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({ plan_id: "test-plan", template_id: "test-template" }),
|
||||
getEditPlan: vi.fn().mockResolvedValue({
|
||||
plan_id: "test-plan",
|
||||
@@ -228,9 +222,6 @@ vi.mock("@/api/template-editor", () => ({
|
||||
config: {},
|
||||
status: "draft",
|
||||
}),
|
||||
getGenerationStatus: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ plan_status: "completed", generation_task_id: "test-task", clips: [] }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import GenerationHistoryModal from "@/pages/editing-planner/components/GenerationHistoryModal"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
loading: false,
|
||||
history: [],
|
||||
onClose: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
cancelLoading: false,
|
||||
}
|
||||
|
||||
describe("GenerationHistoryModal", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<GenerationHistoryModal {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should not render when open is false", () => {
|
||||
const { container } = render(<GenerationHistoryModal {...defaultProps} open={false} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it("should render with history items", () => {
|
||||
const history = [
|
||||
{
|
||||
id: "1",
|
||||
status: "completed",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
duration: 60,
|
||||
},
|
||||
]
|
||||
const { container } = render(
|
||||
<GenerationHistoryModal {...defaultProps} history={history as any} />,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import GenerationProgressModal from "@/pages/editing-planner/components/GenerationProgressModal"
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
voiceoverDuration: null,
|
||||
estimatedDuration: 60,
|
||||
onDurationChange: vi.fn(),
|
||||
onGenerate: vi.fn(),
|
||||
task: null,
|
||||
submitting: false,
|
||||
onCancel: vi.fn(),
|
||||
onRetry: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
describe("GenerationProgressModal", () => {
|
||||
it("should render setup phase", () => {
|
||||
const { container } = render(<GenerationProgressModal {...baseProps} phase="setup" />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render progress phase without task", () => {
|
||||
const { container } = render(<GenerationProgressModal {...baseProps} phase="progress" />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render progress phase with task data", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="progress"
|
||||
task={
|
||||
{
|
||||
id: "task-123",
|
||||
status: "generating_video",
|
||||
progress: 50,
|
||||
current_step: "generating_video",
|
||||
user_message: "正在生成视频",
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render completed phase", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="completed"
|
||||
task={{ id: "task-1", status: "completed", progress: 100 } as any}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render failed phase with retry", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="failed"
|
||||
task={
|
||||
{
|
||||
id: "task-1",
|
||||
status: "failed",
|
||||
progress: 30,
|
||||
error_message: "生成失败",
|
||||
retryable: true,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render failed phase without retry", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="failed"
|
||||
task={
|
||||
{
|
||||
id: "task-1",
|
||||
status: "failed",
|
||||
progress: 30,
|
||||
retryable: false,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should not render when closed", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal {...baseProps} phase="setup" open={false} />,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -31,8 +31,6 @@ import "@/pages/editing-planner/components/ClipPropertiesPanel"
|
||||
import "@/pages/editing-planner/components/EditorClipList"
|
||||
import "@/pages/editing-planner/components/EditingDrawers"
|
||||
import "@/pages/editing-planner/components/FilterPanel"
|
||||
import "@/pages/editing-planner/components/GenerationHistoryModal"
|
||||
import "@/pages/editing-planner/components/GenerationProgressModal"
|
||||
import "@/pages/editing-planner/components/GreenScreenPanel"
|
||||
import "@/pages/editing-planner/components/IntroOutroPanel"
|
||||
import "@/pages/editing-planner/components/MediaPanel"
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
"""Unit tests for PR #1338: 确认生成兜底增强 — user_id 查找素材.
|
||||
|
||||
覆盖:
|
||||
- SQLAlchemyAssetRepository.find_ready_videos_by_user
|
||||
- _auto_fallback_auto_material_mode 策略2 (user_id 兜底)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages"))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
def _make_repo():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
return SQLAlchemyAssetRepository(session)
|
||||
|
||||
|
||||
class TestFindReadyVideosByUser:
|
||||
"""SQLAlchemyAssetRepository.find_ready_videos_by_user 测试."""
|
||||
|
||||
def test_returns_ready_videos_for_user(self):
|
||||
repo = _make_repo()
|
||||
user_id = "user-abc-123"
|
||||
v1 = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name="video1.mp4",
|
||||
storage_key="v/v1.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
v2 = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name="video2.mp4",
|
||||
storage_key="v/v2.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
repo.create(v1)
|
||||
repo.create(v2)
|
||||
result = repo.find_ready_videos_by_user(user_id)
|
||||
assert len(result) == 2
|
||||
assert {a.id for a in result} == {v1.id, v2.id}
|
||||
|
||||
def test_excludes_non_video_assets(self):
|
||||
repo = _make_repo()
|
||||
user_id = "user-abc-123"
|
||||
video = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name="clip.mp4",
|
||||
storage_key="v/clip.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
image = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name="photo.jpg",
|
||||
storage_key="v/photo.jpg",
|
||||
mime_type="image/jpeg",
|
||||
status=AssetStatus.READY,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
repo.create(video)
|
||||
repo.create(image)
|
||||
result = repo.find_ready_videos_by_user(user_id)
|
||||
assert len(result) == 1
|
||||
assert result[0].id == video.id
|
||||
|
||||
def test_excludes_non_ready_assets(self):
|
||||
repo = _make_repo()
|
||||
user_id = "user-abc-123"
|
||||
ready = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name="ready.mp4",
|
||||
storage_key="v/ready.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
uploading = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name="uploading.mp4",
|
||||
storage_key="v/uploading.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.UPLOADING,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
repo.create(ready)
|
||||
repo.create(uploading)
|
||||
result = repo.find_ready_videos_by_user(user_id)
|
||||
assert len(result) == 1
|
||||
assert result[0].id == ready.id
|
||||
|
||||
def test_excludes_other_users_assets(self):
|
||||
repo = _make_repo()
|
||||
my_video = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name="mine.mp4",
|
||||
storage_key="v/mine.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
uploaded_by_user_id="user-A",
|
||||
)
|
||||
other_video = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name="other.mp4",
|
||||
storage_key="v/other.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
uploaded_by_user_id="user-B",
|
||||
)
|
||||
repo.create(my_video)
|
||||
repo.create(other_video)
|
||||
result = repo.find_ready_videos_by_user("user-A")
|
||||
assert len(result) == 1
|
||||
assert result[0].id == my_video.id
|
||||
|
||||
def test_empty_result_for_unknown_user(self):
|
||||
repo = _make_repo()
|
||||
result = repo.find_ready_videos_by_user("nonexistent-user")
|
||||
assert result == []
|
||||
|
||||
def test_respects_limit(self):
|
||||
repo = _make_repo()
|
||||
user_id = "user-abc-123"
|
||||
for i in range(10):
|
||||
asset = Asset.create(
|
||||
project_id="",
|
||||
library_id="lib-1",
|
||||
name=f"video_{i}.mp4",
|
||||
storage_key=f"v/v{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
repo.create(asset)
|
||||
result = repo.find_ready_videos_by_user(user_id, limit=3)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestAutoFallbackAutoMaterialModeUserId:
|
||||
"""_auto_fallback_auto_material_mode user_id 兜底策略测试."""
|
||||
|
||||
def _make_plan_check(self, project_id="", template_id="tmpl-1"):
|
||||
plan = MagicMock()
|
||||
plan.project_id = project_id
|
||||
plan.template_id = template_id
|
||||
plan.config = {}
|
||||
return plan
|
||||
|
||||
def _make_clip(self, clip_id="clip-1"):
|
||||
clip = MagicMock()
|
||||
clip.id = clip_id
|
||||
clip.asset_id = ""
|
||||
return clip
|
||||
|
||||
def test_skips_when_no_clips_without_asset(self):
|
||||
from app.api.routes.templates_editor._fallback import (
|
||||
_auto_fallback_auto_material_mode,
|
||||
)
|
||||
|
||||
svc = MagicMock()
|
||||
plan_check = self._make_plan_check()
|
||||
_auto_fallback_auto_material_mode(
|
||||
svc,
|
||||
"plan-1",
|
||||
plan_check,
|
||||
[],
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
user_id="user-1",
|
||||
)
|
||||
svc.assign_asset.assert_not_called()
|
||||
|
||||
def test_strategy2_user_id_fallback(self):
|
||||
from app.api.routes.templates_editor._fallback import (
|
||||
_auto_fallback_auto_material_mode,
|
||||
)
|
||||
|
||||
svc = MagicMock()
|
||||
plan_check = self._make_plan_check(project_id="")
|
||||
clip = self._make_clip("clip-1")
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.id = "asset-from-user"
|
||||
mock_asset.status = AssetStatus.READY
|
||||
mock_asset.mime_type = "video/mp4"
|
||||
asset_repo = MagicMock()
|
||||
asset_repo.find_ready_videos_by_user.return_value = [mock_asset]
|
||||
asset_library_repo = MagicMock()
|
||||
_auto_fallback_auto_material_mode(
|
||||
svc,
|
||||
"plan-1",
|
||||
plan_check,
|
||||
[clip],
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id="user-123",
|
||||
)
|
||||
asset_repo.find_ready_videos_by_user.assert_called_once_with("user-123")
|
||||
svc.assign_asset.assert_called_once_with("clip-1", "asset-from-user")
|
||||
|
||||
def test_strategy1_takes_priority_over_strategy2(self):
|
||||
from app.api.routes.templates_editor._fallback import (
|
||||
_auto_fallback_auto_material_mode,
|
||||
)
|
||||
|
||||
svc = MagicMock()
|
||||
plan_check = self._make_plan_check(project_id="proj-1")
|
||||
clip = self._make_clip("clip-1")
|
||||
mock_lib = MagicMock()
|
||||
mock_lib.id = "lib-video"
|
||||
mock_lib.kind = MagicMock()
|
||||
mock_lib.kind.value = "video"
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.id = "asset-from-project"
|
||||
mock_asset.status = "ready"
|
||||
mock_asset.mime_type = "video/mp4"
|
||||
asset_library_repo = MagicMock()
|
||||
asset_library_repo.find_by_project.return_value = [mock_lib]
|
||||
asset_repo = MagicMock()
|
||||
asset_repo.find_by_library.return_value = [mock_asset]
|
||||
_auto_fallback_auto_material_mode(
|
||||
svc,
|
||||
"plan-1",
|
||||
plan_check,
|
||||
[clip],
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id="user-123",
|
||||
)
|
||||
asset_library_repo.find_by_project.assert_called_once_with("proj-1")
|
||||
asset_repo.find_ready_videos_by_user.assert_not_called()
|
||||
svc.assign_asset.assert_called_once_with("clip-1", "asset-from-project")
|
||||
|
||||
def test_falls_back_when_project_has_no_videos(self):
|
||||
from app.api.routes.templates_editor._fallback import (
|
||||
_auto_fallback_auto_material_mode,
|
||||
)
|
||||
|
||||
svc = MagicMock()
|
||||
plan_check = self._make_plan_check(project_id="proj-1")
|
||||
clip = self._make_clip("clip-1")
|
||||
asset_library_repo = MagicMock()
|
||||
asset_library_repo.find_by_project.return_value = []
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.id = "asset-from-user"
|
||||
mock_asset.status = AssetStatus.READY
|
||||
mock_asset.mime_type = "video/mp4"
|
||||
asset_repo = MagicMock()
|
||||
asset_repo.find_ready_videos_by_user.return_value = [mock_asset]
|
||||
_auto_fallback_auto_material_mode(
|
||||
svc,
|
||||
"plan-1",
|
||||
plan_check,
|
||||
[clip],
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id="user-123",
|
||||
)
|
||||
asset_repo.find_ready_videos_by_user.assert_called_once_with("user-123")
|
||||
svc.assign_asset.assert_called_once_with("clip-1", "asset-from-user")
|
||||
|
||||
def test_no_assets_found_does_nothing(self):
|
||||
from app.api.routes.templates_editor._fallback import (
|
||||
_auto_fallback_auto_material_mode,
|
||||
)
|
||||
|
||||
svc = MagicMock()
|
||||
plan_check = self._make_plan_check(project_id="")
|
||||
clip = self._make_clip("clip-1")
|
||||
asset_repo = MagicMock()
|
||||
asset_repo.find_ready_videos_by_user.return_value = []
|
||||
asset_library_repo = MagicMock()
|
||||
_auto_fallback_auto_material_mode(
|
||||
svc,
|
||||
"plan-1",
|
||||
plan_check,
|
||||
[clip],
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id="user-123",
|
||||
)
|
||||
svc.assign_asset.assert_not_called()
|
||||
|
||||
def test_no_user_id_skips_strategy2(self):
|
||||
from app.api.routes.templates_editor._fallback import (
|
||||
_auto_fallback_auto_material_mode,
|
||||
)
|
||||
|
||||
svc = MagicMock()
|
||||
plan_check = self._make_plan_check(project_id="")
|
||||
clip = self._make_clip("clip-1")
|
||||
asset_repo = MagicMock()
|
||||
asset_library_repo = MagicMock()
|
||||
_auto_fallback_auto_material_mode(
|
||||
svc,
|
||||
"plan-1",
|
||||
plan_check,
|
||||
[clip],
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id="",
|
||||
)
|
||||
asset_repo.find_ready_videos_by_user.assert_not_called()
|
||||
svc.assign_asset.assert_not_called()
|
||||
@@ -1,369 +0,0 @@
|
||||
"""Tests for /generate endpoint — custom_title and cover_url passing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestGenerateEndpointTitleAndCover:
|
||||
"""测试 /generate 端点传递 custom_title 和 cover_url。"""
|
||||
|
||||
def test_generate_passes_cover_url_from_plan_config(self):
|
||||
"""从 plan.config.cover.image_url 读取封面 URL 传递给生成任务。"""
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-123"
|
||||
mock_plan.project_id = "project-1"
|
||||
mock_plan.template_id = "template-1"
|
||||
mock_plan.status = MagicMock(value="editing")
|
||||
mock_plan.config = {
|
||||
"clips": [{"id": "c1"}],
|
||||
"asset_ids": ["a1"],
|
||||
"cover": {"type": "upload", "image_url": "https://oss.example.com/uploaded/cover.jpg"},
|
||||
}
|
||||
mock_plan.updated_at = None
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_plan_svc.can_generate.return_value = (True, "")
|
||||
mock_plan_svc.mark_clips_ready.return_value = 1
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
mock_gen_task = MagicMock()
|
||||
mock_gen_task.id = "task-new"
|
||||
mock_gen_task.project_id = "project-1"
|
||||
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
body = EditPlanGenerateRequest() # No title_config
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-123"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_gen_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-123",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=mock_current_user,
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
# Verify cover_url was passed to CreateGenerationTaskCommand
|
||||
call_args = mock_usecase.execute.call_args
|
||||
command = call_args[0][0]
|
||||
assert command.cover_url == "https://oss.example.com/uploaded/cover.jpg"
|
||||
assert command.custom_title == ""
|
||||
|
||||
def test_generate_passes_custom_title_from_title_config(self):
|
||||
"""前端传 title_config 时,序列化为 JSON 存入 custom_title。"""
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-456"
|
||||
mock_plan.project_id = "project-1"
|
||||
mock_plan.template_id = "template-1"
|
||||
mock_plan.status = MagicMock(value="editing")
|
||||
mock_plan.config = {
|
||||
"clips": [{"id": "c1"}],
|
||||
"asset_ids": ["a1"],
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/cover.jpg"},
|
||||
}
|
||||
mock_plan.updated_at = None
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_plan_svc.can_generate.return_value = (True, "")
|
||||
mock_plan_svc.mark_clips_ready.return_value = 1
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
mock_gen_task = MagicMock()
|
||||
mock_gen_task.id = "task-title"
|
||||
|
||||
mock_current_user = MagicMock()
|
||||
mock_current_user.user.id = "user-1"
|
||||
|
||||
title_config = {
|
||||
"text": "测试标题",
|
||||
"font_size": 36,
|
||||
"font_color": "#ffffff",
|
||||
"position": "center",
|
||||
}
|
||||
body = EditPlanGenerateRequest(title_config=title_config)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_gen_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-456",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=mock_current_user,
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
# Verify custom_title was serialized to JSON
|
||||
call_args = mock_usecase.execute.call_args
|
||||
command = call_args[0][0]
|
||||
parsed_title = json.loads(command.custom_title)
|
||||
assert parsed_title["text"] == "测试标题"
|
||||
assert parsed_title["font_size"] == 36
|
||||
assert command.cover_url == "https://oss.example.com/cover.jpg"
|
||||
|
||||
def test_generate_empty_title_config_passes_empty_custom_title(self):
|
||||
"""title_config 为空时 custom_title 为空字符串。"""
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-789"
|
||||
mock_plan.project_id = "project-1"
|
||||
mock_plan.template_id = "template-1"
|
||||
mock_plan.status = MagicMock(value="editing")
|
||||
mock_plan.config = {"clips": [{"id": "c1"}], "asset_ids": ["a1"]}
|
||||
mock_plan.updated_at = None
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_plan_svc.can_generate.return_value = (True, "")
|
||||
mock_plan_svc.mark_clips_ready.return_value = 1
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
mock_gen_task = MagicMock()
|
||||
mock_gen_task.id = "task-no-title"
|
||||
|
||||
body = EditPlanGenerateRequest() # No title_config
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=None),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_gen_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-789",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=MagicMock(),
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
call_args = mock_usecase.execute.call_args
|
||||
command = call_args[0][0]
|
||||
assert command.custom_title == ""
|
||||
|
||||
|
||||
class TestGenerateEndpointRequestSchema:
|
||||
"""测试 EditPlanGenerateRequest schema。"""
|
||||
|
||||
def test_schema_default_empty_title_config(self):
|
||||
"""默认 title_config 为空 dict。"""
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
req = EditPlanGenerateRequest()
|
||||
assert req.title_config == {}
|
||||
|
||||
def test_schema_accepts_title_config(self):
|
||||
"""可以传入标题配置。"""
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
req = EditPlanGenerateRequest(title_config={"text": "我的标题", "font_size": 48})
|
||||
assert req.title_config["text"] == "我的标题"
|
||||
assert req.title_config["font_size"] == 48
|
||||
|
||||
|
||||
class TestGenerateTitleChangeSkipsReuse:
|
||||
"""测试标题变更时跳过预览产物复用。"""
|
||||
|
||||
def _make_mocks(self, custom_title=""):
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-reuse"
|
||||
mock_plan.project_id = "project-1"
|
||||
mock_plan.template_id = "template-1"
|
||||
mock_plan.status = MagicMock(value="editing")
|
||||
mock_plan.config = {"clips": [{"id": "c1"}], "asset_ids": ["a1"]}
|
||||
mock_plan.updated_at = None
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_plan_svc.can_generate.return_value = (True, "")
|
||||
mock_plan_svc.mark_clips_ready.return_value = 1
|
||||
mock_template_svc = MagicMock()
|
||||
|
||||
reusable_task = MagicMock()
|
||||
reusable_task.id = "task-reusable"
|
||||
reusable_task.is_completed = True
|
||||
reusable_task.is_preview = True
|
||||
reusable_task.custom_title = custom_title
|
||||
reusable_task.project_id = "project-1"
|
||||
reusable_task.source_edit_plan_id = "plan-reuse"
|
||||
|
||||
mock_new_task = MagicMock()
|
||||
mock_new_task.id = "task-new"
|
||||
|
||||
return mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task
|
||||
|
||||
def test_title_removed_skips_reuse(self):
|
||||
"""原来有标题,现在移除了 → 跳过复用,创建新任务。"""
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task = self._make_mocks(
|
||||
custom_title='{"text": "旧标题"}'
|
||||
)
|
||||
|
||||
body = EditPlanGenerateRequest() # No title_config → title removed
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=reusable_task),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-reuse"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_new_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-reuse",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=MagicMock(),
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
# 应该创建新任务而不是复用
|
||||
mock_usecase.execute.assert_called_once()
|
||||
# 不应该 mark_confirmed 在 reusable_task 上
|
||||
reusable_task.mark_confirmed.assert_not_called()
|
||||
|
||||
def test_title_changed_skips_reuse(self):
|
||||
"""标题变更 → 跳过复用。"""
|
||||
import json
|
||||
|
||||
from app.api.routes.templates_editor.generation import generate_editor_draft
|
||||
from app.api.routes.templates_editor.schemas import EditPlanGenerateRequest
|
||||
|
||||
mock_plan, mock_plan_svc, mock_template_svc, reusable_task, mock_new_task = self._make_mocks(
|
||||
custom_title=json.dumps({"text": "旧标题", "font_size": 36}, ensure_ascii=False)
|
||||
)
|
||||
|
||||
body = EditPlanGenerateRequest(title_config={"text": "新标题", "font_size": 48})
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.generation.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.templates_editor.generation.CreateGenerationTaskUseCase") as mock_usecase_cls,
|
||||
patch("app.api.routes.templates_editor.generation._find_reusable_preview_task", return_value=reusable_task),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_draft_to_editing"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_copy_template_clips"),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_assign_assets", return_value=[]),
|
||||
patch("app.api.routes.templates_editor.generation._auto_fallback_auto_material_mode"),
|
||||
patch("app.api.routes.templates_editor.generation._check_queue_limits"),
|
||||
patch("app.api.routes.templates_editor.generation.celery_app"),
|
||||
patch("app.api.routes.templates_editor.generation.get_draft_plan_id", return_value="plan-reuse"),
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = mock_new_task
|
||||
mock_usecase_cls.return_value = mock_usecase
|
||||
|
||||
mock_plan_svc.transition_status = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
|
||||
result = generate_editor_draft(
|
||||
template_id="template-1",
|
||||
request=body,
|
||||
plan_id="plan-reuse",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=MagicMock(),
|
||||
current_user=MagicMock(),
|
||||
asset_library_repo=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
|
||||
mock_usecase.execute.assert_called_once()
|
||||
reusable_task.mark_confirmed.assert_not_called()
|
||||
@@ -1,287 +0,0 @@
|
||||
"""统一渲染路径 — 编辑器预览产物复用逻辑单元测试。
|
||||
|
||||
覆盖:
|
||||
- _find_reusable_preview_task: 查找可复用的预览任务
|
||||
- _get_task_output_url: 获取任务输出 URL
|
||||
- 编辑器 generate 接口复用预览产物路径
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
|
||||
|
||||
# ── Stub Repository ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StubGenTaskRepo:
|
||||
def __init__(self):
|
||||
self._store = {}
|
||||
|
||||
def create(self, task):
|
||||
self._store[task.id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id):
|
||||
return self._store.get(task_id)
|
||||
|
||||
def update(self, task):
|
||||
self._store[task.id] = task
|
||||
return task
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id):
|
||||
return [t for t in self._store.values() if (t.source_edit_plan_id or "") == plan_id]
|
||||
|
||||
|
||||
def _make_task(**kwargs):
|
||||
defaults = dict(
|
||||
id="task-001",
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="one_take",
|
||||
voice_library_id="",
|
||||
template_id="tmpl-1",
|
||||
asset_ids=["a1"],
|
||||
title_ids=[],
|
||||
voice_ids=[],
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
progress=100.0,
|
||||
result_count=1,
|
||||
error_message="",
|
||||
created_by_user_id="user-1",
|
||||
source_edit_plan_id="plan-1",
|
||||
asset_select_mode="all",
|
||||
is_preview=True,
|
||||
source_task_id="",
|
||||
output_width=1920,
|
||||
output_height=1080,
|
||||
cover_url="",
|
||||
custom_title="",
|
||||
video_title="",
|
||||
resolution="",
|
||||
bgm_config={},
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return GenerationTask(**defaults)
|
||||
|
||||
|
||||
def _make_plan(updated_at=None):
|
||||
plan = MagicMock()
|
||||
plan.id = "plan-1"
|
||||
plan.updated_at = updated_at or datetime.now(timezone.utc)
|
||||
plan.status = MagicMock()
|
||||
plan.status.value = "editing"
|
||||
plan.config = {"clips": [{"id": "c1"}, {"id": "c2"}]}
|
||||
return plan
|
||||
|
||||
|
||||
# ── _find_reusable_preview_task ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFindReusablePreviewTask:
|
||||
def test_returns_completed_preview_task(self):
|
||||
from app.api.routes.templates_editor.generation import _find_reusable_preview_task
|
||||
|
||||
repo = StubGenTaskRepo()
|
||||
now = datetime.now(timezone.utc)
|
||||
task = _make_task(
|
||||
source_edit_plan_id="plan-1",
|
||||
is_preview=True,
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
completed_at=now - timedelta(minutes=5),
|
||||
)
|
||||
repo.create(task)
|
||||
|
||||
plan = _make_plan(updated_at=now - timedelta(minutes=10))
|
||||
result = _find_reusable_preview_task(repo, "plan-1", plan)
|
||||
|
||||
assert result is not None
|
||||
assert result.id == "task-001"
|
||||
|
||||
def test_returns_none_when_no_tasks(self):
|
||||
from app.api.routes.templates_editor.generation import _find_reusable_preview_task
|
||||
|
||||
repo = StubGenTaskRepo()
|
||||
plan = _make_plan()
|
||||
result = _find_reusable_preview_task(repo, "plan-1", plan)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_preview_not_completed(self):
|
||||
from app.api.routes.templates_editor.generation import _find_reusable_preview_task
|
||||
|
||||
repo = StubGenTaskRepo()
|
||||
task = _make_task(
|
||||
source_edit_plan_id="plan-1",
|
||||
is_preview=True,
|
||||
status=GenerationTaskStatus.RUNNING,
|
||||
completed_at=None,
|
||||
)
|
||||
repo.create(task)
|
||||
|
||||
plan = _make_plan()
|
||||
result = _find_reusable_preview_task(repo, "plan-1", plan)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_plan_modified_after_preview(self):
|
||||
from app.api.routes.templates_editor.generation import _find_reusable_preview_task
|
||||
|
||||
repo = StubGenTaskRepo()
|
||||
now = datetime.now(timezone.utc)
|
||||
task = _make_task(
|
||||
source_edit_plan_id="plan-1",
|
||||
is_preview=True,
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
completed_at=now - timedelta(minutes=10),
|
||||
)
|
||||
repo.create(task)
|
||||
|
||||
# Plan was updated AFTER preview completed
|
||||
plan = _make_plan(updated_at=now)
|
||||
result = _find_reusable_preview_task(repo, "plan-1", plan)
|
||||
assert result is None
|
||||
|
||||
def test_skips_non_preview_tasks(self):
|
||||
from app.api.routes.templates_editor.generation import _find_reusable_preview_task
|
||||
|
||||
repo = StubGenTaskRepo()
|
||||
task = _make_task(
|
||||
source_edit_plan_id="plan-1",
|
||||
is_preview=False, # not a preview task
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
)
|
||||
repo.create(task)
|
||||
|
||||
plan = _make_plan()
|
||||
result = _find_reusable_preview_task(repo, "plan-1", plan)
|
||||
assert result is None
|
||||
|
||||
def test_handles_repo_exception(self):
|
||||
from app.api.routes.templates_editor.generation import _find_reusable_preview_task
|
||||
|
||||
repo = MagicMock()
|
||||
repo.list_by_source_edit_plan.side_effect = Exception("db error")
|
||||
plan = _make_plan()
|
||||
result = _find_reusable_preview_task(repo, "plan-1", plan)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── _get_task_output_url ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTaskOutputUrl:
|
||||
def test_returns_video_url(self):
|
||||
from app.api.routes.templates_editor.generation import _get_task_output_url
|
||||
|
||||
task = _make_task()
|
||||
repo = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
mock_video = MagicMock()
|
||||
mock_video.file_url = "https://oss.example.com/video.mp4"
|
||||
|
||||
mock_use_case = MagicMock()
|
||||
mock_use_case.execute.return_value = [mock_video]
|
||||
|
||||
with patch(
|
||||
"app.api.routes.templates_editor.generation.ListGeneratedVideosByTaskUseCase",
|
||||
return_value=mock_use_case,
|
||||
):
|
||||
result = _get_task_output_url(task, repo, db)
|
||||
|
||||
assert result == "https://oss.example.com/video.mp4"
|
||||
|
||||
def test_returns_empty_when_no_videos(self):
|
||||
from app.api.routes.templates_editor.generation import _get_task_output_url
|
||||
|
||||
task = _make_task()
|
||||
repo = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
mock_use_case = MagicMock()
|
||||
mock_use_case.execute.return_value = []
|
||||
|
||||
with patch(
|
||||
"app.api.routes.templates_editor.generation.ListGeneratedVideosByTaskUseCase",
|
||||
return_value=mock_use_case,
|
||||
):
|
||||
result = _get_task_output_url(task, repo, db)
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_returns_empty_on_exception(self):
|
||||
from app.api.routes.templates_editor.generation import _get_task_output_url
|
||||
|
||||
task = _make_task()
|
||||
repo = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
with patch(
|
||||
"app.api.routes.templates_editor.generation.ListGeneratedVideosByTaskUseCase",
|
||||
side_effect=Exception("db error"),
|
||||
):
|
||||
result = _get_task_output_url(task, repo, db)
|
||||
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ── mark_confirmed ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMarkConfirmed:
|
||||
def test_sets_is_preview_false(self):
|
||||
task = _make_task(is_preview=True)
|
||||
task.mark_confirmed()
|
||||
assert task.is_preview is False
|
||||
|
||||
def test_sets_cover_url(self):
|
||||
task = _make_task()
|
||||
task.mark_confirmed(cover_url="https://example.com/cover.jpg")
|
||||
assert task.cover_url == "https://example.com/cover.jpg"
|
||||
|
||||
def test_sets_custom_title(self):
|
||||
task = _make_task()
|
||||
task.mark_confirmed(custom_title="My Video")
|
||||
assert task.custom_title == "My Video"
|
||||
|
||||
def test_sets_output_dimensions(self):
|
||||
task = _make_task()
|
||||
task.mark_confirmed(output_width=1080, output_height=1920)
|
||||
assert task.output_width == 1080
|
||||
assert task.output_height == 1920
|
||||
|
||||
def test_zero_dimensions_not_applied(self):
|
||||
task = _make_task(output_width=1920, output_height=1080)
|
||||
task.mark_confirmed(output_width=0, output_height=0)
|
||||
assert task.output_width == 1920
|
||||
assert task.output_height == 1080
|
||||
|
||||
def test_skips_when_plan_updated_at_is_none(self):
|
||||
from app.api.routes.templates_editor.generation import _find_reusable_preview_task
|
||||
|
||||
repo = StubGenTaskRepo()
|
||||
now = datetime.now(timezone.utc)
|
||||
task = _make_task(
|
||||
source_edit_plan_id="plan-1",
|
||||
is_preview=True,
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
completed_at=now - timedelta(minutes=5),
|
||||
)
|
||||
repo.create(task)
|
||||
|
||||
plan = _make_plan(updated_at=None)
|
||||
result = _find_reusable_preview_task(repo, "plan-1", plan)
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user