Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 691c811cd4 | |||
| 2114b7e7ae | |||
| 7766ba1479 | |||
| ef686dde8f | |||
| 01026156ae | |||
| 42c0885813 | |||
| cd6d8615e6 | |||
| fcc7863b31 | |||
| 6af2f3c08d | |||
| 410c390cf0 | |||
| 9f85d40855 | |||
| 034eaac695 | |||
| 27dccf6591 | |||
| ac7ab679b7 | |||
| bb98620a8a | |||
| 2a0b75c007 | |||
| 0d9d4e584f | |||
| eb089fa26d | |||
| 1ec274b9f5 | |||
| 62820391c3 | |||
| 322b0a082c |
@@ -106,6 +106,10 @@ def list_templates(
|
||||
tag: str | None = Query(None, description="按标签筛选"),
|
||||
keyword: str | None = Query(None, description="按名称关键词搜索"),
|
||||
mode: str | None = Query(None, description="按剪辑模式筛选"),
|
||||
valid_only: bool = Query(
|
||||
False,
|
||||
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTemplatesResponse:
|
||||
@@ -116,6 +120,7 @@ def list_templates(
|
||||
tag=tag,
|
||||
keyword=keyword,
|
||||
mode=mode,
|
||||
valid_only=valid_only,
|
||||
)
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
|
||||
|
||||
@@ -36,17 +36,11 @@ from app.services.asset_segment_tracker import (
|
||||
remove_used_segment,
|
||||
)
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import (
|
||||
_calc_random_start_time,
|
||||
build_scene_segments,
|
||||
@@ -399,68 +393,42 @@ def _safe_segment_duration(value, default: float) -> float:
|
||||
|
||||
def _get_template_segments(
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
tpl_svc: EditTemplateService,
|
||||
db: Session,
|
||||
) -> list[tuple[int, float, float]]:
|
||||
"""获取模板的片段配置(顺序、最短时长、最长时长).
|
||||
|
||||
优先从新模板系统(template_clip_configs)查询,
|
||||
若不存在则回退到旧模板系统(template_segments)。
|
||||
单一数据源:模板主表为 ``templates``(用户自建,归属 user_id)/
|
||||
``edit_templates``(全局模板库),片段配置主表为 ``template_clip_configs``
|
||||
(由 ``EditTemplateService.list_clip_configs_for_editor`` 统一读取)。
|
||||
|
||||
不再使用"新表抛异常 → 降级直查配置表 → 再降级查 segments"的异常控制流,
|
||||
也不在正常请求中打印 ``ValueError: 模板不存在`` 堆栈。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 当前登录用户 ID(用于归属校验)
|
||||
tpl_svc: 模板编辑器服务
|
||||
|
||||
Returns:
|
||||
[(segment_order, duration_min, duration_max), ...] 按 order 排序
|
||||
[(segment_order, duration_min, duration_max), ...] 按 order 排序;
|
||||
模板存在但未配置片段时返回空列表。
|
||||
|
||||
Raises:
|
||||
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
|
||||
"""
|
||||
# 优先查新模板系统
|
||||
try:
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
if clip_configs:
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
|
||||
clip_configs = tpl_svc.list_clip_configs_for_editor(template_id, user_id)
|
||||
|
||||
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
|
||||
try:
|
||||
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
direct_configs = direct_repo.list_by_template(template_id)
|
||||
if direct_configs:
|
||||
result = []
|
||||
for cc in direct_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
|
||||
|
||||
# 回退到旧模板系统(template_segments表)
|
||||
try:
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = old_repo.list_segments(template_id)
|
||||
if segments:
|
||||
result = []
|
||||
for s in segments:
|
||||
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((s.segment_order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("旧模板系统查询segments失败", exc_info=True)
|
||||
|
||||
return []
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
|
||||
|
||||
def _recommended_time_conflicts(
|
||||
@@ -663,13 +631,21 @@ def create_clips_from_assets_editor(
|
||||
7. 素材时长为 0 或缺失时报 400,不创建无效片段
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 1. 查询模板 segments
|
||||
segments = _get_template_segments(template_id, tpl_svc, db)
|
||||
# 1. 查询模板片段配置。模板不存在/已删除/无权限 → 404;
|
||||
# 模板存在但确实未配置片段 → 422(配置错误,与 404 区分)。
|
||||
try:
|
||||
segments = _get_template_segments(template_id, user_id, tpl_svc)
|
||||
except TemplateNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在或无权访问",
|
||||
) from exc
|
||||
if not segments:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="模板没有片段配置,无法创建片段",
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="模板未配置片段",
|
||||
)
|
||||
|
||||
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
|
||||
|
||||
@@ -41,29 +41,33 @@ def get_draft_plan_id(
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
兼容策略:优先从新模板系统(edit_templates 表)查找,
|
||||
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
|
||||
模板读取遵循单一数据源、显式判定(不使用异常降级):
|
||||
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
|
||||
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
|
||||
模板不存在、已删除或不归属于当前用户时,一律返回 404。
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
|
||||
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get_active(template_id, user_id)
|
||||
is_global_template = tpl_svc.get_template(template_id) is not None
|
||||
if old_template is None and not is_global_template:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 2. 新系统有模板 → 用新服务创建草稿
|
||||
if tpl_svc.get_template(template_id) is not None:
|
||||
# 2. 全局模板(新系统)→ 用新服务创建草稿
|
||||
if is_global_template:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 3. 回退到旧模板系统(templates 表)
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id=user_id)
|
||||
if old_template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 4. 基于旧模板创建草稿计划
|
||||
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
|
||||
@@ -150,7 +150,7 @@ def rollback_template(
|
||||
try:
|
||||
tpl = tpl_svc.rollback_to_version(template_id, request.version)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
return EditorRollbackResponse(
|
||||
|
||||
@@ -784,11 +784,17 @@ class EditPlanService:
|
||||
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration
|
||||
|
||||
# #1764:从 plan config 读取节奏模板
|
||||
rhythm_template = None
|
||||
if plan and hasattr(plan, "config") and plan.config:
|
||||
rhythm_template = plan.config.get("rhythm_template")
|
||||
|
||||
target = plan_clip_durations(
|
||||
len(clips),
|
||||
voice,
|
||||
transition_effects=[c.transition_effect for c in clips],
|
||||
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
|
||||
rhythm_template=rhythm_template,
|
||||
)
|
||||
if not target:
|
||||
return None
|
||||
@@ -907,6 +913,25 @@ class EditPlanService:
|
||||
)
|
||||
plan_ids.append(variant.id)
|
||||
|
||||
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
|
||||
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
|
||||
|
||||
clip_count = 0
|
||||
if voice_durations and len(voice_durations) > 0:
|
||||
# 从源 plan 获取片段数
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and hasattr(source_plan, "clips"):
|
||||
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
|
||||
|
||||
rhythm_templates_for_variants = []
|
||||
if clip_count > 0:
|
||||
for idx in range(len(plan_ids)):
|
||||
# 每个变体用不同的 seed 选择节奏模板
|
||||
variant_seed = rng.randint(0, 999999)
|
||||
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
|
||||
rhythm_templates_for_variants.append(template)
|
||||
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
|
||||
|
||||
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
|
||||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||||
|
||||
@@ -916,8 +941,17 @@ class EditPlanService:
|
||||
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
|
||||
if idx == 0:
|
||||
perturbation["hflip"] = False
|
||||
self.update_plan_config(pid, {"visual_perturbation": perturbation})
|
||||
logger.info("变体 %d 视觉扰动: plan=%s perturbation=%s", idx, pid, perturbation)
|
||||
config_update = {"visual_perturbation": perturbation}
|
||||
# #1764:写入节奏模板
|
||||
if idx < len(rhythm_templates_for_variants):
|
||||
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
|
||||
# #1765:写入像素级扰动滤镜
|
||||
from packages.domain.variant_plan_selector import generate_pixel_perturbation
|
||||
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
config_update["pixel_perturbation"] = pixel_pert
|
||||
self.update_plan_config(pid, config_update)
|
||||
logger.info("变体 %d 视觉扰动+像素扰动: plan=%s vis=%s pix=%s", idx, pid, perturbation, pixel_pert)
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
|
||||
|
||||
|
||||
@@ -34,6 +34,17 @@ from packages.domain.template_clip_converter import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateNotFoundError(Exception):
|
||||
"""模板不存在、已删除或当前用户无权访问.
|
||||
|
||||
与"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
|
||||
"""
|
||||
|
||||
def __init__(self, template_id: str) -> None:
|
||||
self.template_id = template_id
|
||||
super().__init__(f"模板不存在: {template_id}")
|
||||
|
||||
|
||||
class EditTemplateService:
|
||||
"""模板管理服务
|
||||
|
||||
@@ -217,7 +228,14 @@ class EditTemplateService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""列出模板的片段配置"""
|
||||
"""列出模板的片段配置
|
||||
|
||||
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
|
||||
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
|
||||
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
|
||||
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
|
||||
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
|
||||
"""
|
||||
# 确保模板存在
|
||||
self.get_template_or_raise(template_id)
|
||||
return self._clip_config_repo.list_by_template(
|
||||
@@ -227,6 +245,52 @@ class EditTemplateService:
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def list_clip_configs_for_editor(
|
||||
self,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""编辑器读取模板片段配置的单一数据源入口.
|
||||
|
||||
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
|
||||
模板主表按双表现状显式判定,不使用 try/except 控制流:
|
||||
|
||||
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
|
||||
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
|
||||
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 当前登录用户 ID(用于旧表模板归属校验)
|
||||
|
||||
Raises:
|
||||
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
|
||||
"""
|
||||
# 1) 用户自建模板(旧表 templates,归属 user_id)
|
||||
if self._clip_config_repo.template_owned_by(template_id, user_id):
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
|
||||
if self._template_repo.get(template_id) is not None:
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 3) 两表都没有:不存在 / 已删除 / 无权限
|
||||
raise TemplateNotFoundError(template_id)
|
||||
|
||||
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""获取片段配置详情"""
|
||||
return self._clip_config_repo.get(config_id)
|
||||
|
||||
@@ -5,10 +5,22 @@ import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { AssetLibraryItem } from "./types"
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get("/asset-libraries")
|
||||
return response.data.items || []
|
||||
/**
|
||||
* 获取当前用户的素材库
|
||||
*
|
||||
* @param kind 可选,按素材库类型过滤(video/voice/image)。
|
||||
* 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的
|
||||
* kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库
|
||||
* 混进来(#1777:视频选择器只展示视频库)。
|
||||
*/
|
||||
export const getAssetLibraries = async (
|
||||
kind?: AssetLibraryItem["kind"],
|
||||
): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", {
|
||||
params: kind ? { kind } : undefined,
|
||||
})
|
||||
const items = response.data.items || []
|
||||
return kind ? items.filter((lib) => lib.kind === kind) : items
|
||||
}
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
|
||||
@@ -55,6 +55,19 @@ apiClient.interceptors.response.use(
|
||||
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean
|
||||
/**
|
||||
* 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。
|
||||
* 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」,
|
||||
* 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。
|
||||
*/
|
||||
_silentErrorToast?: boolean
|
||||
}
|
||||
|
||||
// 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗
|
||||
if (originalRequest?._silentErrorToast) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(error as any).__msgShown = true
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
|
||||
@@ -12,17 +12,25 @@ import type {
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取模板列表 */
|
||||
/** 获取模板列表
|
||||
*
|
||||
* valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用,
|
||||
* 避免选中无片段配置的模板导致 from-assets 400,#1769/#1772);
|
||||
* 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。
|
||||
* 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。
|
||||
*/
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
validOnly?: boolean
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
...(params?.validOnly ? { valid_only: true } : {}),
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function createClipsFromAssets(
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: { signal?: AbortSignal; silentErrorToast?: boolean },
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
asset_ids: assetIds,
|
||||
@@ -104,7 +104,12 @@ export async function createClipsFromAssets(
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
body,
|
||||
{ timeout: 60000, signal: opts?.signal },
|
||||
{
|
||||
timeout: 60000,
|
||||
signal: opts?.signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
|
||||
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -43,11 +43,17 @@ export async function updateEditPlanClips(
|
||||
templateId: string,
|
||||
clips: EditPlanClipInput[],
|
||||
signal?: AbortSignal,
|
||||
/** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */
|
||||
silentErrorToast?: boolean,
|
||||
): Promise<{ count: number }> {
|
||||
const response = await apiClient.put(
|
||||
`/templates/${templateId}/editor/clips`,
|
||||
{ clips },
|
||||
{ signal },
|
||||
{
|
||||
signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast)
|
||||
...(silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
handleInvalidTemplate,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
@@ -416,13 +417,11 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 最终成片(单视频右侧播放) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ── 布局 class:步骤4标题页=预览+标题侧栏;步骤5/6批量=整行宽;步骤1~3=整行宽 ── */
|
||||
/* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */
|
||||
const layoutClassName = useMemo(() => {
|
||||
if (currentStep < 4) return "xx-generate-layout full-width"
|
||||
if (currentStep === 4) return "xx-generate-layout step4-layout"
|
||||
// 步骤5/6:批量网格需要整行宽度;单视频保持 表单+右侧成片 两栏
|
||||
return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout"
|
||||
}, [currentStep, isBatch])
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -522,6 +521,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
onTemplateInvalid={handleInvalidTemplate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
@@ -543,6 +543,59 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVariantIds={selectedVariantIds}
|
||||
/>
|
||||
|
||||
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
|
||||
{currentStep === 5 && !isBatch && generated && finalVideo && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
maxWidth: 320,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
|
||||
🔗 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
📁 前往成片库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GenerateStepActions
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
@@ -554,54 +607,6 @@ const GeneratePage: React.FC = () => {
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 步骤5/6(单视频):右侧成片播放器 ════ */}
|
||||
{currentStep >= 5 && !isBatch && generated && finalVideo && (
|
||||
<div className="xx-generate-right-col">
|
||||
<div
|
||||
className="xx-inline-video-player"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay={currentStep === 5}
|
||||
// 竖屏自适应(#1750):成片固定 1080×1920(9:16),元数据到达前按 9:16 占位,
|
||||
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
|
||||
🔗 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
📁 前往成片库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 数量选择弹窗 */}
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface GenerateStepContentProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
/** 当前模板创建片段被判失效(404/400/422)时的自动回退回调(#1777) */
|
||||
onTemplateInvalid?: () => boolean
|
||||
/* 生成 */
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
@@ -108,6 +110,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
onServerClipsChange,
|
||||
onTemplateInvalid,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
@@ -153,6 +156,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedTemplate={selectedTemplate}
|
||||
templateSegments={templateSegments}
|
||||
onServerClipsChange={onServerClipsChange}
|
||||
onTemplateInvalid={onTemplateInvalid}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -189,7 +193,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=进度状态卡(成片播放器在左侧大区域) */
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态(完成后只显示成片播放器,播放器在按钮上方) */
|
||||
if (previewCount > 1) {
|
||||
return (
|
||||
<BatchGenerationGrid
|
||||
@@ -199,10 +203,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
}
|
||||
/* 单视频:渲染进度 / 失败重试 / 完成提示(成片播放器在右侧栏) */
|
||||
/* 单视频:生成中显示进度卡、失败显示重试卡;生成完成后不再渲染提示卡,页面只保留成片播放器+操作按钮 */
|
||||
if (generated && !generating && !generateError) return null
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 确认生成</h3>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
@@ -234,14 +238,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">右侧可预览成片,点击「下一步」选择封面</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 6:
|
||||
|
||||
@@ -23,6 +23,8 @@ interface Step2MaterialSelectProps {
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
|
||||
onTemplateInvalid?: () => boolean
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
@@ -36,16 +38,25 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择视频库</label>
|
||||
<select
|
||||
value={m.selectedLibraryId}
|
||||
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
|
||||
>
|
||||
{m.libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{m.libraries.length === 0 && !m.materialsLoading ? (
|
||||
<div className="xx-empty-state">
|
||||
<p>暂无视频素材库</p>
|
||||
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
|
||||
请先在「素材库」中创建视频素材库并上传视频
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={m.selectedLibraryId}
|
||||
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
|
||||
>
|
||||
{m.libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{m.materialMode === "manual" && (
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
interface GenerationStatusProps {
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
getGenerationPhase: (progress: number) => { icon: string; label: string }
|
||||
onScrollToPreview: () => void
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const GenerationStatus: React.FC<GenerationStatusProps> = ({
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
onScrollToPreview,
|
||||
onRetry,
|
||||
onDismissError,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{!generating && !generated && !generateError && (
|
||||
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">🎬</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">尚未开始生成视频</div>
|
||||
<div className="xx-gen-progress-sub">
|
||||
请返回「选择标题」步骤,点击「确认生成视频」开始渲染最终视频
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationStatus
|
||||
@@ -117,10 +117,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-generate-layout.full-width .xx-generate-right-col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
左侧表单区 generate-form
|
||||
============================================================ */
|
||||
@@ -2323,28 +2319,6 @@
|
||||
生成结果(右侧)
|
||||
================================================================ */
|
||||
|
||||
.xx-generate-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── 内联视频播放器(右侧) ── */
|
||||
.xx-inline-video-player {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.xx-inline-video-player video {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.xx-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2710,11 +2684,12 @@
|
||||
|
||||
/* ── 封面设置区域改造样式 ── */
|
||||
|
||||
/* 封面操作按钮区 */
|
||||
/* 封面操作按钮区(单视频全宽页居中) */
|
||||
.xx-cover-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 已选模板文字 */
|
||||
@@ -3202,10 +3177,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 批量封面网格 ── */
|
||||
/* ── 批量封面网格(单卡/少卡时居中排列,卡片限宽不拉伸) ── */
|
||||
.xx-cover-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 220px));
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,22 @@ import type { AssetItem } from "@/api/assets"
|
||||
* 管理素材库列表、当前选中库、素材列表加载
|
||||
*/
|
||||
export function useMaterialLibrary() {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
/* ── 素材库数据 API ──
|
||||
* Step2 是视频选片,只拉取 kind=video 的素材库(#1777):
|
||||
* 后端按 kind 查询参数过滤,前端 getAssetLibraries("video") 再兜底过滤一次,
|
||||
* 避免配音库(voice)/图片库(image) 混进「选择视频库」下拉。
|
||||
* queryKey 带 kind,与素材管理页/配音页的 ["asset-libraries"] 全量缓存隔离。
|
||||
*/
|
||||
const { data: allLibraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries", "video"],
|
||||
queryFn: () => getAssetLibraries("video"),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
// 前端兜底过滤:仅保留 kind=video 的素材库(后端按 kind 查询参数过滤)
|
||||
const libraries = useMemo(
|
||||
() => allLibraries.filter((lib) => lib.kind === "video"),
|
||||
[allLibraries],
|
||||
)
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface GenerateFormState {
|
||||
selectedTemplate: string
|
||||
setSelectedTemplate: (id: string) => void
|
||||
userTemplates: EditingTemplate[]
|
||||
/** 当前选中模板在创建片段时被判失效(404/400/422)后的运行时自动回退 */
|
||||
handleInvalidTemplate: () => boolean
|
||||
|
||||
/* 素材 */
|
||||
selectedMaterials: string[]
|
||||
@@ -131,7 +133,8 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
/* ── 模板选择 ── */
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates, handleInvalidTemplate } =
|
||||
useTemplateSelection()
|
||||
|
||||
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
|
||||
// selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆;
|
||||
@@ -228,6 +231,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
handleInvalidTemplate,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 失效模板判定与自动回退工具(#1777)
|
||||
*
|
||||
* 背景:用户进入生成页后,之前选中的模板可能已被删除、或从未配置片段。
|
||||
* 调用片段相关接口(PUT/POST /templates/{id}/editor/clips[...]/from-assets)时:
|
||||
* - 模板不存在 → 后端返回 404(并行工单 #1774 把「模板不存在」统一为该状态码)
|
||||
* - 模板无片段配置 → 当前部分场景返回 400(detail 含「片段配置」),
|
||||
* 参数校验类错误返回 422
|
||||
* 这三类响应都说明「当前选中的模板不可用于生成」,应清除失效选择并自动切换到
|
||||
* 第一个有效模板,同时提示用户,而不是让页面卡死、无任何反馈。
|
||||
*/
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
/** 失效模板相关的 HTTP 状态码 */
|
||||
const INVALID_TEMPLATE_STATUSES = new Set([404, 400, 422])
|
||||
|
||||
/**
|
||||
* 从任意抛出值(axios 错误)提取 HTTP 状态码。
|
||||
* 非 axios 错误 / 无响应时返回 null。
|
||||
*/
|
||||
export function getHttpStatus(err: unknown): number | null {
|
||||
if (!err || typeof err !== "object") return null
|
||||
const status = (err as { response?: { status?: number }; status?: number })?.response?.status
|
||||
return typeof status === "number" ? status : null
|
||||
}
|
||||
|
||||
/** 安全提取后端错误文本(detail/message/msg,422 数组也兜底拼一下) */
|
||||
function extractErrorText(err: unknown): string {
|
||||
if (!err || typeof err !== "object") return ""
|
||||
const data = (err as { response?: { data?: unknown } })?.response?.data
|
||||
if (!data) return ""
|
||||
try {
|
||||
const text = JSON.stringify(data)
|
||||
return typeof text === "string" ? text : ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一次 clips/from-assets 请求失败是否因为「模板失效」。
|
||||
*
|
||||
* 严格判定,避免把无关的 400/422(例如素材参数问题)误判为模板失效:
|
||||
* - 404:模板/编辑计划不存在,一定是模板失效
|
||||
* - 400:仅当后端文本明确提到「片段配置」(无片段配置无法创建片段)才判定
|
||||
* - 422:参数校验类,from-assets 场景下命中「片段/segments」相关字段才判定
|
||||
*/
|
||||
export function isInvalidTemplateError(err: unknown): boolean {
|
||||
const status = getHttpStatus(err)
|
||||
if (status === null || !INVALID_TEMPLATE_STATUSES.has(status)) return false
|
||||
if (status === 404) return true
|
||||
|
||||
const text = extractErrorText(err)
|
||||
if (status === 400) {
|
||||
// 后端当前返回:「模板没有片段配置,无法创建片段」
|
||||
return /片段配置|没有片段|无片段|segments?|clip.*config/i.test(text)
|
||||
}
|
||||
// 422:FastAPI 校验错误,命中模板片段相关字段
|
||||
return /segment|clip|片段|模板/i.test(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模板是否可用于生成(有效模板)。
|
||||
*
|
||||
* 有效 = 处于激活态(is_active !== false,字段缺失视为 true 兼容旧后端)
|
||||
* 且至少配置了一个片段。
|
||||
* 与后端 valid_only 过滤口径保持一致(#1769/#1772),这里是前端双保险。
|
||||
*/
|
||||
export function isValidTemplate(template: EditingTemplate | null | undefined): boolean {
|
||||
if (!template) return false
|
||||
if (template.is_active === false) return false
|
||||
return (template.segments?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
/** 从模板列表中取出第一个有效模板,没有则返回 null */
|
||||
export function findFirstValidTemplate(
|
||||
templates: EditingTemplate[] | null | undefined,
|
||||
): EditingTemplate | null {
|
||||
if (!Array.isArray(templates)) return null
|
||||
return templates.find(isValidTemplate) ?? null
|
||||
}
|
||||
@@ -1,22 +1,87 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { findFirstValidTemplate, isValidTemplate } from "./templateFallback"
|
||||
|
||||
/** 失效模板自动切换的提示文案 */
|
||||
export const INVALID_TEMPLATE_FALLBACK_TOAST = "原模板已失效,已自动切换"
|
||||
|
||||
export function useTemplateSelection() {
|
||||
// selectedTemplate 纯内存状态,绝不写入 localStorage/sessionStorage/URL,
|
||||
// 因此失效模板 ID 不会被持久化、刷新后也不会恢复(#1777 要求 4)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
|
||||
|
||||
const { data: allTemplates = [] } = useQuery<EditingTemplate[]>({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
// valid_only:后端过滤掉没有片段配置的无效模板(#1769/#1772)。
|
||||
// 旧后端忽略该 query 参数时,下方 isValidTemplate 前端兜底再过滤一次。
|
||||
queryFn: () => getEditingTemplates({ validOnly: true }),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id)
|
||||
}
|
||||
}, [userTemplates, selectedTemplate])
|
||||
// 双保险:后端 valid_only 已过滤,前端再按 is_active + segments 兜底,
|
||||
// 保证下拉/自动选择只包含可用于生成的有效模板
|
||||
const validTemplates = allTemplates.filter(isValidTemplate)
|
||||
const userTemplates = validTemplates
|
||||
|
||||
return { selectedTemplate, setSelectedTemplate, userTemplates }
|
||||
// 用 ref 持有最新值,供稳定回调 handleInvalidTemplate 使用(避免闭包拿到旧值)
|
||||
const templatesRef = useRef(validTemplates)
|
||||
templatesRef.current = validTemplates
|
||||
const selectedRef = useRef(selectedTemplate)
|
||||
selectedRef.current = selectedTemplate
|
||||
// 已提示过失效的模板 ID,避免用户停留在失效模板上时 clips 防抖请求反复弹 toast;
|
||||
// 用户手动切换/成功切换后重置,保证下一个失效模板仍能提示
|
||||
const fallbackNotifiedRef = useRef<string>("")
|
||||
|
||||
/* 自动选择:模板加载完成且当前未选中时,自动选中第一个有效模板。
|
||||
* 用户手动选择(setSelectedTemplate 被显式调用)后 selectedTemplate 非空,
|
||||
* 本 effect 直接 return,绝不覆盖用户的手动选择(#1777 要求 4:手动优先)。 */
|
||||
useEffect(() => {
|
||||
if (selectedTemplate) return
|
||||
const firstValid = validTemplates[0]
|
||||
if (firstValid) {
|
||||
setSelectedTemplate(firstValid.id)
|
||||
}
|
||||
}, [validTemplates, selectedTemplate])
|
||||
|
||||
/** 用户手动选择模板:优先级最高,重置失效提示标记 */
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
fallbackNotifiedRef.current = ""
|
||||
setSelectedTemplate(id)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 运行时失效回退(#1777 要求 3):
|
||||
* 创建片段接口返回 404(模板不存在)/ 400/422(模板无片段配置)时调用。
|
||||
* - 清除失效选择,自动切换到第一个有效模板,并 toast 提示;
|
||||
* - 没有有效模板时清空选择,Step1 展示明确的「暂无可用模板」空状态引导,
|
||||
* 不让用户卡在失效模板上。
|
||||
* 返回 true 表示已按「模板失效」处理(调用方可据此静默原始错误提示)。
|
||||
*/
|
||||
const handleInvalidTemplate = useCallback((): boolean => {
|
||||
const current = selectedRef.current
|
||||
// 同一个失效模板只提示一次(clips 防抖 effect 在素材/模板变化时会反复触发)
|
||||
if (current && fallbackNotifiedRef.current === current) return true
|
||||
|
||||
const fallback = findFirstValidTemplate(templatesRef.current)
|
||||
fallbackNotifiedRef.current = current || "__empty__"
|
||||
if (fallback) {
|
||||
setSelectedTemplate(fallback.id)
|
||||
message.warning(INVALID_TEMPLATE_FALLBACK_TOAST)
|
||||
} else {
|
||||
// 没有任何有效模板:清空选择,交由 Step1 空状态引导用户去模板编辑器创建
|
||||
setSelectedTemplate("")
|
||||
message.warning("当前没有可用模板,请先在「模板编辑器」中创建并配置片段")
|
||||
}
|
||||
return true
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedTemplate,
|
||||
setSelectedTemplate: handleSelectTemplate,
|
||||
userTemplates,
|
||||
handleInvalidTemplate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
import { isInvalidTemplateError } from "./useGenerateFormState/templateFallback"
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -24,6 +25,8 @@ interface UseStep2MaterialsProps {
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
|
||||
onTemplateInvalid?: () => boolean
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -36,6 +39,7 @@ export function useStep2Materials({
|
||||
selectedTemplate,
|
||||
templateSegments,
|
||||
onServerClipsChange,
|
||||
onTemplateInvalid,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const {
|
||||
libraries,
|
||||
@@ -102,6 +106,8 @@ export function useStep2Materials({
|
||||
selectedTemplateRef.current = selectedTemplate
|
||||
const onServerClipsChangeRef = useRef(onServerClipsChange)
|
||||
onServerClipsChangeRef.current = onServerClipsChange
|
||||
const onTemplateInvalidRef = useRef(onTemplateInvalid)
|
||||
onTemplateInvalidRef.current = onTemplateInvalid
|
||||
|
||||
useEffect(() => {
|
||||
const tid = selectedTemplateRef.current
|
||||
@@ -123,11 +129,12 @@ export function useStep2Materials({
|
||||
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
|
||||
|
||||
try {
|
||||
// 1. 清空旧片段
|
||||
await updateEditPlanClips(tid, [], controller.signal)
|
||||
// 1. 清空旧片段(静默全局 toast:模板失效时由下方回退统一提示)
|
||||
await updateEditPlanClips(tid, [], controller.signal, true)
|
||||
// 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底)
|
||||
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
|
||||
signal: controller.signal,
|
||||
silentErrorToast: true,
|
||||
})
|
||||
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
|
||||
const clipList = await getEditPlanClips(tid, { limit: 500 })
|
||||
@@ -146,6 +153,14 @@ export function useStep2Materials({
|
||||
message.error("智能选片失败,请重试")
|
||||
return
|
||||
}
|
||||
// 模板失效(404 模板不存在 / 400/422 无片段配置):
|
||||
// 清空失效选择并自动切到第一个有效模板 + toast,避免页面卡死无提示(#1777)
|
||||
if (isInvalidTemplateError(err)) {
|
||||
console.warn("[useStep2Materials] 当前模板已失效,触发自动回退:", err)
|
||||
onServerClipsChangeRef.current?.([])
|
||||
onTemplateInvalidRef.current?.()
|
||||
return
|
||||
}
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
}
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
queryFn: () => getAssetLibraries(),
|
||||
})
|
||||
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMateri
|
||||
// ── 获取 voice 类型素材库 ─────────────────────────────────
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
queryFn: () => getAssetLibraries(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
/* 获取或创建默认配音库 */
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
queryFn: () => getAssetLibraries(),
|
||||
})
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 失效模板判定/回退纯函数单测(#1777)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import {
|
||||
getHttpStatus,
|
||||
isInvalidTemplateError,
|
||||
isValidTemplate,
|
||||
findFirstValidTemplate,
|
||||
} from "@/pages/generate/hooks/useGenerateFormState/templateFallback"
|
||||
|
||||
function makeTemplate(partial: Partial<EditingTemplate> & { id: string }): EditingTemplate {
|
||||
return {
|
||||
name: partial.id,
|
||||
mode: "pip",
|
||||
category: "默认",
|
||||
tags: [],
|
||||
title_config: {
|
||||
ai_auto_select: false,
|
||||
content: "",
|
||||
font_preset: "",
|
||||
font_color: "",
|
||||
font_size: 28,
|
||||
position: "top",
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: true,
|
||||
position: "bottom",
|
||||
font: "",
|
||||
color: "",
|
||||
size: 20,
|
||||
animation: "",
|
||||
},
|
||||
bgm_config: { enabled: false, music_id: "" },
|
||||
segments: [{ segment_order: 0, material_type: null }],
|
||||
is_active: true,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...partial,
|
||||
} as EditingTemplate
|
||||
}
|
||||
|
||||
function axiosError(status: number, data?: unknown) {
|
||||
return { isAxiosError: true, response: { status, data } }
|
||||
}
|
||||
|
||||
describe("getHttpStatus", () => {
|
||||
it("提取 axios 错误的 HTTP 状态码", () => {
|
||||
expect(getHttpStatus(axiosError(404))).toBe(404)
|
||||
expect(getHttpStatus(axiosError(400))).toBe(400)
|
||||
})
|
||||
it("非 axios/无响应错误返回 null", () => {
|
||||
expect(getHttpStatus(new Error("network"))).toBeNull()
|
||||
expect(getHttpStatus(null)).toBeNull()
|
||||
expect(getHttpStatus(undefined)).toBeNull()
|
||||
expect(getHttpStatus({ isAxiosError: true })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isInvalidTemplateError", () => {
|
||||
it("404 始终判定为模板失效(模板不存在)", () => {
|
||||
expect(isInvalidTemplateError(axiosError(404))).toBe(true)
|
||||
expect(isInvalidTemplateError(axiosError(404, { detail: "Not Found" }))).toBe(true)
|
||||
})
|
||||
|
||||
it("400 且后端文案提到「片段配置」判定为模板无片段配置", () => {
|
||||
expect(
|
||||
isInvalidTemplateError(axiosError(400, { detail: "模板没有片段配置,无法创建片段" })),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("400 但文案与片段配置无关 → 不误判", () => {
|
||||
expect(isInvalidTemplateError(axiosError(400, { detail: "素材参数错误" }))).toBe(false)
|
||||
})
|
||||
|
||||
it("422 命中片段/模板字段判定为失效", () => {
|
||||
expect(
|
||||
isInvalidTemplateError(
|
||||
axiosError(422, { detail: [{ loc: ["body", "segments"], msg: "field required" }] }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("其他状态码(401/403/500/超时/网络)不判定为模板失效", () => {
|
||||
expect(isInvalidTemplateError(axiosError(401))).toBe(false)
|
||||
expect(isInvalidTemplateError(axiosError(403))).toBe(false)
|
||||
expect(isInvalidTemplateError(axiosError(500))).toBe(false)
|
||||
expect(isInvalidTemplateError({ code: "ECONNABORTED", message: "timeout of 60000ms" })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isInvalidTemplateError(new Error("Network Error"))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isValidTemplate", () => {
|
||||
it("有片段且未被标记 inactive → 有效", () => {
|
||||
expect(isValidTemplate(makeTemplate({ id: "t1" }))).toBe(true)
|
||||
})
|
||||
it("segments 为空 → 无效(无片段配置)", () => {
|
||||
expect(isValidTemplate(makeTemplate({ id: "t2", segments: [] }))).toBe(false)
|
||||
})
|
||||
it("is_active=false → 无效(已停用/删除)", () => {
|
||||
expect(isValidTemplate(makeTemplate({ id: "t3", is_active: false }))).toBe(false)
|
||||
})
|
||||
it("is_active 字段缺失时视为有效(兼容旧后端)", () => {
|
||||
const t = makeTemplate({ id: "t4" })
|
||||
delete (t as Partial<EditingTemplate>).is_active
|
||||
expect(isValidTemplate(t)).toBe(true)
|
||||
})
|
||||
it("null/undefined → 无效", () => {
|
||||
expect(isValidTemplate(null)).toBe(false)
|
||||
expect(isValidTemplate(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findFirstValidTemplate", () => {
|
||||
it("跳过无效模板,返回第一个有效模板", () => {
|
||||
const list = [
|
||||
makeTemplate({ id: "empty", segments: [] }),
|
||||
makeTemplate({ id: "inactive", is_active: false }),
|
||||
makeTemplate({ id: "valid1" }),
|
||||
makeTemplate({ id: "valid2" }),
|
||||
]
|
||||
expect(findFirstValidTemplate(list)?.id).toBe("valid1")
|
||||
})
|
||||
it("全部无效 → null(用于空状态引导)", () => {
|
||||
expect(
|
||||
findFirstValidTemplate([
|
||||
makeTemplate({ id: "a", segments: [] }),
|
||||
makeTemplate({ id: "b", is_active: false }),
|
||||
]),
|
||||
).toBeNull()
|
||||
})
|
||||
it("空数组/null → null", () => {
|
||||
expect(findFirstValidTemplate([])).toBeNull()
|
||||
expect(findFirstValidTemplate(null)).toBeNull()
|
||||
expect(findFirstValidTemplate(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* useMaterialLibrary Hook 单测(#1777)
|
||||
* - Step2 视频库选择器只拉取 kind=video 的素材库,配音库(voice)/图片库(image) 不混入
|
||||
* - 自动选中第一个视频库
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
import type { AssetItem, AssetLibraryItem } from "@/api/assets"
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
getAssetLibraries: vi.fn(),
|
||||
getAssets: vi.fn(),
|
||||
isAssetUsable: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
import { getAssetLibraries, getAssets } from "@/api/assets"
|
||||
import { useMaterialLibrary } from "@/pages/generate/hooks/step2-materials/useMaterialLibrary"
|
||||
|
||||
const mockGetLibraries = vi.mocked(getAssetLibraries)
|
||||
const mockGetAssets = vi.mocked(getAssets)
|
||||
|
||||
function lib(id: string, kind: AssetLibraryItem["kind"], name = id): AssetLibraryItem {
|
||||
return { id, name, kind }
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAssets.mockResolvedValue({ items: [] as AssetItem[], total: 0 })
|
||||
})
|
||||
|
||||
describe("useMaterialLibrary (#1777 kind=video 过滤)", () => {
|
||||
it("按 kind=video 拉取素材库(后端参数过滤)", async () => {
|
||||
mockGetLibraries.mockResolvedValueOnce([lib("v1", "video")])
|
||||
renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalledTimes(1))
|
||||
expect(mockGetLibraries).toHaveBeenCalledWith("video")
|
||||
})
|
||||
|
||||
it("下拉库列表只包含视频库(自动选中第一个视频库)", async () => {
|
||||
mockGetLibraries.mockResolvedValueOnce([
|
||||
lib("voice-1", "voice"),
|
||||
lib("img-1", "image"),
|
||||
lib("video-1", "video"),
|
||||
lib("video-2", "video"),
|
||||
])
|
||||
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(result.current.libraries).toHaveLength(2))
|
||||
expect(result.current.libraries.map((l) => l.id)).toEqual(["video-1", "video-2"])
|
||||
expect(result.current.libraries.every((l) => l.kind === "video")).toBe(true)
|
||||
// 自动选中第一个视频库
|
||||
expect(result.current.selectedLibraryId).toBe("video-1")
|
||||
})
|
||||
|
||||
it("没有视频库时库列表为空且不自动选中(UI 展示空状态)", async () => {
|
||||
mockGetLibraries.mockResolvedValueOnce([lib("voice-1", "voice"), lib("img-1", "image")])
|
||||
const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(mockGetLibraries).toHaveBeenCalled())
|
||||
expect(result.current.libraries).toEqual([])
|
||||
expect(result.current.selectedLibraryId).toBe("")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* useTemplateSelection Hook 单测(#1777)
|
||||
* - 自动选择跳过无片段/inactive 模板,只选第一个有效模板
|
||||
* - 传 validOnly=true 给后端
|
||||
* - 用户手动选择优先,自动逻辑不覆盖
|
||||
* - handleInvalidTemplate:失效时自动切到第一个有效模板 + toast;无有效模板时清空
|
||||
* - selectedTemplate 仅内存态,不写入 localStorage/sessionStorage
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { renderHook, waitFor, act } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
// antd message mock(拦截 toast)——vi.hoisted 保证 mock 工厂可引用
|
||||
const { messageMock } = vi.hoisted(() => ({
|
||||
messageMock: {
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(() => vi.fn()),
|
||||
},
|
||||
}))
|
||||
vi.mock("antd", () => ({ message: messageMock }))
|
||||
|
||||
vi.mock("@/api/editing-planner", () => ({
|
||||
getEditingTemplates: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useTemplateSelection } from "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
const mockGetTemplates = vi.mocked(getEditingTemplates)
|
||||
|
||||
function tpl(id: string, partial: Partial<EditingTemplate> = {}): EditingTemplate {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
mode: "pip",
|
||||
category: "默认",
|
||||
tags: [],
|
||||
title_config: {
|
||||
ai_auto_select: false,
|
||||
content: "",
|
||||
font_preset: "",
|
||||
font_color: "",
|
||||
font_size: 28,
|
||||
position: "top",
|
||||
},
|
||||
subtitle_config: {
|
||||
enabled: true,
|
||||
position: "bottom",
|
||||
font: "",
|
||||
color: "",
|
||||
size: 20,
|
||||
animation: "",
|
||||
},
|
||||
bgm_config: { enabled: false, music_id: "" },
|
||||
segments: [{ segment_order: 0, material_type: null }],
|
||||
is_active: true,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...partial,
|
||||
} as EditingTemplate
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) as ReactNode
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe("useTemplateSelection (#1777)", () => {
|
||||
it("请求模板时传 validOnly=true,并自动选中第一个有片段的有效模板", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([
|
||||
tpl("empty", { segments: [] }),
|
||||
tpl("inactive", { is_active: false }),
|
||||
tpl("valid-a"),
|
||||
tpl("valid-b"),
|
||||
])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("valid-a"))
|
||||
expect(mockGetTemplates).toHaveBeenCalledWith({ validOnly: true })
|
||||
// 暴露给 UI 的 userTemplates 已过滤掉无效模板
|
||||
expect(result.current.userTemplates.map((t) => t.id)).toEqual(["valid-a", "valid-b"])
|
||||
})
|
||||
|
||||
it("列表全部无效时 selectedTemplate 为空(交空状态引导),不选中失效模板", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([
|
||||
tpl("empty", { segments: [] }),
|
||||
tpl("inactive", { is_active: false }),
|
||||
])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
await waitFor(() => expect(mockGetTemplates).toHaveBeenCalled())
|
||||
// 给 effect 一个 tick
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
|
||||
expect(result.current.userTemplates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("用户手动选择优先:自动逻辑不会覆盖手动选择", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([tpl("a"), tpl("b")])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("a"))
|
||||
|
||||
act(() => result.current.setSelectedTemplate("b"))
|
||||
expect(result.current.selectedTemplate).toBe("b")
|
||||
|
||||
// 重新渲染 / refetch 后仍保持用户的手动选择
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("b"))
|
||||
})
|
||||
|
||||
it("handleInvalidTemplate:当前模板失效时自动切到第一个有效模板并 toast", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] }), tpl("good")])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
// 自动选中有效模板 good(bad 无片段不会被自动选中)
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
|
||||
messageMock.warning.mockClear()
|
||||
|
||||
// 模拟运行时用户停留在一个已失效的模板 id(外部/草稿态),触发回退
|
||||
act(() => result.current.setSelectedTemplate("stale-id"))
|
||||
expect(result.current.selectedTemplate).toBe("stale-id")
|
||||
|
||||
act(() => {
|
||||
const handled = result.current.handleInvalidTemplate()
|
||||
expect(handled).toBe(true)
|
||||
})
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
|
||||
expect(messageMock.warning).toHaveBeenCalledWith("原模板已失效,已自动切换")
|
||||
})
|
||||
|
||||
it("handleInvalidTemplate:无有效模板时清空选择并提示去创建", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] })])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
await waitFor(() => expect(result.current.userTemplates).toHaveLength(0))
|
||||
|
||||
act(() => result.current.setSelectedTemplate("stale-id"))
|
||||
act(() => {
|
||||
result.current.handleInvalidTemplate()
|
||||
})
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe(""))
|
||||
expect(messageMock.warning).toHaveBeenCalledWith(expect.stringContaining("没有可用模板"))
|
||||
})
|
||||
|
||||
it("失效模板 ID 不写入任何持久化存储", async () => {
|
||||
mockGetTemplates.mockResolvedValueOnce([tpl("good")])
|
||||
const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() })
|
||||
await waitFor(() => expect(result.current.selectedTemplate).toBe("good"))
|
||||
|
||||
act(() => result.current.setSelectedTemplate("stale-invalid-id"))
|
||||
act(() => result.current.handleInvalidTemplate())
|
||||
|
||||
const ls = JSON.stringify(localStorage)
|
||||
const ss = JSON.stringify(sessionStorage)
|
||||
expect(ls).not.toContain("stale-invalid-id")
|
||||
expect(ss).not.toContain("stale-invalid-id")
|
||||
// URL 也不含
|
||||
expect(window.location.href).not.toContain("stale-invalid-id")
|
||||
})
|
||||
})
|
||||
@@ -1051,110 +1051,6 @@ class VideoDeduplicator:
|
||||
logger.info("check_batch_duplicate no match (batch=%s): best_fusion=%.3f", batch_id, best_score)
|
||||
return None
|
||||
|
||||
|
||||
# ── 文案 & 结构维度查重(Issue #P2-后端3) ────────────────────────
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
"""文本标准化:去空白、转小写、去标点。"""
|
||||
if not text:
|
||||
return ""
|
||||
# 去空白字符
|
||||
text = re.sub(r"\s+", "", text)
|
||||
# 转小写
|
||||
text = text.lower()
|
||||
# 去标点(只保留中文、字母、数字)
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff]", "", text)
|
||||
return text
|
||||
|
||||
|
||||
def compute_text_similarity(text1: str, text2: str) -> float:
|
||||
"""计算两段文本的相似度(0~1)。
|
||||
|
||||
使用字符级 Jaccard 相似度:交集 / 并集。
|
||||
适合短文本(配音脚本)的相似度比对。
|
||||
|
||||
Args:
|
||||
text1: 第一段文本
|
||||
text2: 第二段文本
|
||||
|
||||
Returns:
|
||||
0~1 之间的相似度
|
||||
"""
|
||||
t1 = _normalize_text(text1)
|
||||
t2 = _normalize_text(text2)
|
||||
|
||||
if not t1 and not t2:
|
||||
return 1.0 # 都为空,视为完全相同
|
||||
if not t1 or not t2:
|
||||
return 0.0 # 一个为空,完全不同
|
||||
|
||||
# 字符级 Jaccard
|
||||
set1 = set(t1)
|
||||
set2 = set(t2)
|
||||
intersection = set1 & set2
|
||||
union = set1 | set2
|
||||
|
||||
if not union:
|
||||
return 0.0
|
||||
|
||||
return len(intersection) / len(union)
|
||||
|
||||
|
||||
def compute_structure_similarity(clips1: list[dict], clips2: list[dict]) -> float:
|
||||
"""计算两个视频的结构相似度(0~1)。
|
||||
|
||||
结构维度包括:
|
||||
1. 片段数差异(数量越接近越相似)
|
||||
2. 片段类型序列(相同位置的片段类型是否一致)
|
||||
3. 时长分布(各片段时长占比是否相似)
|
||||
|
||||
Args:
|
||||
clips1: 第一个视频的片段列表,每项包含 {clip_type, duration}
|
||||
clips2: 第二个视频的片段列表
|
||||
|
||||
Returns:
|
||||
0~1 之间的相似度
|
||||
"""
|
||||
if not clips1 and not clips2:
|
||||
return 1.0
|
||||
if not clips1 or not clips2:
|
||||
return 0.0
|
||||
|
||||
# 1. 片段数相似度(数量差异越大越低)
|
||||
n1, n2 = len(clips1), len(clips2)
|
||||
count_sim = min(n1, n2) / max(n1, n2)
|
||||
|
||||
# 2. 类型序列相似度(逐位比较,相同位置类型是否一致)
|
||||
min_len = min(n1, n2)
|
||||
type_matches = sum(1 for i in range(min_len) if clips1[i].get("clip_type") == clips2[i].get("clip_type"))
|
||||
type_sim = type_matches / min_len if min_len > 0 else 0.0
|
||||
|
||||
# 3. 时长分布相似度(归一化后比较分布)
|
||||
total1 = sum(c.get("duration", 0) for c in clips1)
|
||||
total2 = sum(c.get("duration", 0) for c in clips2)
|
||||
|
||||
if total1 > 0 and total2 > 0:
|
||||
# 归一化为占比
|
||||
dist1 = [c.get("duration", 0) / total1 for c in clips1]
|
||||
dist2 = [c.get("duration", 0) / total2 for c in clips2]
|
||||
|
||||
# 比较前 min_len 个片段的占比差异(L1 距离转相似度)
|
||||
l1_dist = sum(abs(dist1[i] - dist2[i]) for i in range(min_len))
|
||||
# 加上多出的片段占比
|
||||
if n1 > n2:
|
||||
l1_dist += sum(dist1[i] for i in range(n2, n1))
|
||||
elif n2 > n1:
|
||||
l1_dist += sum(dist2[i] for i in range(n1, n2))
|
||||
|
||||
# L1 距离范围 [0, 2],转为相似度 [0, 1]
|
||||
duration_sim = 1.0 - (l1_dist / 2.0)
|
||||
else:
|
||||
duration_sim = 0.0
|
||||
|
||||
# 三维度加权:数量 0.3 + 类型 0.4 + 时长 0.3
|
||||
return count_sim * 0.3 + type_sim * 0.4 + duration_sim * 0.3
|
||||
|
||||
def compute_duplicate_rate(
|
||||
self,
|
||||
fingerprint: VideoFingerprint,
|
||||
@@ -1361,6 +1257,110 @@ def compute_structure_similarity(clips1: list[dict], clips2: list[dict]) -> floa
|
||||
}
|
||||
|
||||
|
||||
# ── 文案 & 结构维度查重(Issue #P2-后端3) ────────────────────────
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
"""文本标准化:去空白、转小写、去标点。"""
|
||||
if not text:
|
||||
return ""
|
||||
# 去空白字符
|
||||
text = re.sub(r"\s+", "", text)
|
||||
# 转小写
|
||||
text = text.lower()
|
||||
# 去标点(只保留中文、字母、数字)
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff]", "", text)
|
||||
return text
|
||||
|
||||
|
||||
def compute_text_similarity(text1: str, text2: str) -> float:
|
||||
"""计算两段文本的相似度(0~1)。
|
||||
|
||||
使用字符级 Jaccard 相似度:交集 / 并集。
|
||||
适合短文本(配音脚本)的相似度比对。
|
||||
|
||||
Args:
|
||||
text1: 第一段文本
|
||||
text2: 第二段文本
|
||||
|
||||
Returns:
|
||||
0~1 之间的相似度
|
||||
"""
|
||||
t1 = _normalize_text(text1)
|
||||
t2 = _normalize_text(text2)
|
||||
|
||||
if not t1 and not t2:
|
||||
return 1.0 # 都为空,视为完全相同
|
||||
if not t1 or not t2:
|
||||
return 0.0 # 一个为空,完全不同
|
||||
|
||||
# 字符级 Jaccard
|
||||
set1 = set(t1)
|
||||
set2 = set(t2)
|
||||
intersection = set1 & set2
|
||||
union = set1 | set2
|
||||
|
||||
if not union:
|
||||
return 0.0
|
||||
|
||||
return len(intersection) / len(union)
|
||||
|
||||
|
||||
def compute_structure_similarity(clips1: list[dict], clips2: list[dict]) -> float:
|
||||
"""计算两个视频的结构相似度(0~1)。
|
||||
|
||||
结构维度包括:
|
||||
1. 片段数差异(数量越接近越相似)
|
||||
2. 片段类型序列(相同位置的片段类型是否一致)
|
||||
3. 时长分布(各片段时长占比是否相似)
|
||||
|
||||
Args:
|
||||
clips1: 第一个视频的片段列表,每项包含 {clip_type, duration}
|
||||
clips2: 第二个视频的片段列表
|
||||
|
||||
Returns:
|
||||
0~1 之间的相似度
|
||||
"""
|
||||
if not clips1 and not clips2:
|
||||
return 1.0
|
||||
if not clips1 or not clips2:
|
||||
return 0.0
|
||||
|
||||
# 1. 片段数相似度(数量差异越大越低)
|
||||
n1, n2 = len(clips1), len(clips2)
|
||||
count_sim = min(n1, n2) / max(n1, n2)
|
||||
|
||||
# 2. 类型序列相似度(逐位比较,相同位置类型是否一致)
|
||||
min_len = min(n1, n2)
|
||||
type_matches = sum(1 for i in range(min_len) if clips1[i].get("clip_type") == clips2[i].get("clip_type"))
|
||||
type_sim = type_matches / min_len if min_len > 0 else 0.0
|
||||
|
||||
# 3. 时长分布相似度(归一化后比较分布)
|
||||
total1 = sum(c.get("duration", 0) for c in clips1)
|
||||
total2 = sum(c.get("duration", 0) for c in clips2)
|
||||
|
||||
if total1 > 0 and total2 > 0:
|
||||
# 归一化为占比
|
||||
dist1 = [c.get("duration", 0) / total1 for c in clips1]
|
||||
dist2 = [c.get("duration", 0) / total2 for c in clips2]
|
||||
|
||||
# 比较前 min_len 个片段的占比差异(L1 距离转相似度)
|
||||
l1_dist = sum(abs(dist1[i] - dist2[i]) for i in range(min_len))
|
||||
# 加上多出的片段占比
|
||||
if n1 > n2:
|
||||
l1_dist += sum(dist1[i] for i in range(n2, n1))
|
||||
elif n2 > n1:
|
||||
l1_dist += sum(dist2[i] for i in range(n1, n2))
|
||||
|
||||
# L1 距离范围 [0, 2],转为相似度 [0, 1]
|
||||
duration_sim = 1.0 - (l1_dist / 2.0)
|
||||
else:
|
||||
duration_sim = 0.0
|
||||
|
||||
# 三维度加权:数量 0.3 + 类型 0.4 + 时长 0.3
|
||||
return count_sim * 0.3 + type_sim * 0.4 + duration_sim * 0.3
|
||||
|
||||
|
||||
def _save_fingerprint_chunks(
|
||||
fingerprint: VideoFingerprint,
|
||||
video_id: str,
|
||||
|
||||
@@ -2227,12 +2227,17 @@ class UnifiedRenderService:
|
||||
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
|
||||
if not perturbation:
|
||||
return {}
|
||||
return {
|
||||
result = {
|
||||
"hflip": bool(perturbation.get("hflip", False)),
|
||||
"zoom_ratio": max(1.0, min(1.2, float(perturbation.get("zoom_ratio", 1.0) or 1.0))),
|
||||
"speed_factor": max(0.8, min(1.2, float(perturbation.get("speed_factor", 1.0) or 1.0))),
|
||||
"brightness_shift": max(-30, min(30, int(perturbation.get("brightness_shift", 0) or 0))),
|
||||
}
|
||||
# #1765:同时读取像素级扰动滤镜
|
||||
pixel_pert = (self.plan.config or {}).get("pixel_perturbation") or {}
|
||||
if pixel_pert:
|
||||
result["pixel_perturbation"] = pixel_pert
|
||||
return result
|
||||
|
||||
def _apply_visual_perturbation_pre_scale(self, filters: list[str], perturbation: dict) -> None:
|
||||
# scale+pad 之前的扰动(hflip),就地修改 filters
|
||||
@@ -2251,6 +2256,48 @@ class UnifiedRenderService:
|
||||
if brightness != 0:
|
||||
filters.append(f"eq=brightness={brightness / 100.0:.3f}")
|
||||
|
||||
# #1765:追加像素级扰动滤镜
|
||||
pixel_pert = perturbation.get("pixel_perturbation") or {}
|
||||
if pixel_pert:
|
||||
self._apply_pixel_perturbation(filters, pixel_pert)
|
||||
|
||||
def _apply_pixel_perturbation(self, filters: list[str], pixel_pert: dict) -> None:
|
||||
"""应用像素级扰动滤镜(Issue #1765)。
|
||||
|
||||
滤镜参数幅度确保肉眼不可见(SSIM > 0.95),但能让同素材不同变体
|
||||
在帧级产生 > 3% 的差异,降低平台查重风险。
|
||||
"""
|
||||
filter_list = pixel_pert.get("filters") or []
|
||||
|
||||
for filt in filter_list:
|
||||
if filt == "noise":
|
||||
# 轻微噪声:noise=alls=0.015:allf=t+u
|
||||
strength = pixel_pert.get("noise_strength", 0.015)
|
||||
filters.append(f"noise=alls={strength}:allf=t+u")
|
||||
|
||||
elif filt == "unsharp":
|
||||
# 锐化/柔化:unsharp=3:3:amount
|
||||
# amount > 0 锐化,< 0 柔化
|
||||
amount = pixel_pert.get("unsharp_amount", 0.0)
|
||||
if abs(amount) > 0.01:
|
||||
filters.append(f"unsharp=3:3:{amount:.2f}")
|
||||
|
||||
elif filt == "curves":
|
||||
# 对比度微调:curves 用 preset 或手动定义
|
||||
# 简单方案:用 eq=contrast 代替(curves 语法复杂)
|
||||
contrast = pixel_pert.get("curves_contrast", 1.0)
|
||||
if abs(contrast - 1.0) > 0.01:
|
||||
filters.append(f"eq=contrast={contrast:.3f}")
|
||||
|
||||
elif filt == "color_balance":
|
||||
# RGB 通道偏移:color_balance=rs=...:gs=...:bs=...
|
||||
r = pixel_pert.get("color_r", 0)
|
||||
g = pixel_pert.get("color_g", 0)
|
||||
b = pixel_pert.get("color_b", 0)
|
||||
if r != 0 or g != 0 or b != 0:
|
||||
# color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换
|
||||
filters.append(f"color_balance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
|
||||
|
||||
@staticmethod
|
||||
def _clip_volume(clip: ResolvedClip) -> float:
|
||||
"""获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。"""
|
||||
|
||||
@@ -6,7 +6,10 @@ from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
)
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
@@ -38,6 +41,24 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
models = query.offset(skip).limit(limit).all()
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def template_owned_by(self, template_id: str, user_id: str) -> bool:
|
||||
"""校验旧模板主表 ``templates`` 中模板归属当前用户且未删除(is_active=True).
|
||||
|
||||
片段配置主表 ``template_clip_configs`` 本身没有 user_id 列,
|
||||
归属关系通过模板主表 ``templates.user_id`` 确定。
|
||||
新表 ``edit_templates`` 为全局模板库(无 user_id 列),不走此校验。
|
||||
"""
|
||||
return (
|
||||
self.session.query(TemplateModel.id)
|
||||
.filter(
|
||||
TemplateModel.id == template_id,
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""根据 ID 获取配置"""
|
||||
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first()
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
@@ -28,6 +29,20 @@ class SQLAlchemyTemplateRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def _filter_with_segment_configs(self, query):
|
||||
"""只保留在 template_clip_configs 或 template_segments 中存在片段配置的模板。
|
||||
|
||||
两张表都没有记录的模板无法用于生成(from-assets 会 400),
|
||||
剪辑页选模板时应排除;模板编辑器不传 valid_only,仍可见全部模板。
|
||||
"""
|
||||
has_clip_config = self.session.query(TemplateClipConfigModel.id).filter(
|
||||
TemplateClipConfigModel.template_id == TemplateModel.id,
|
||||
)
|
||||
has_segment = self.session.query(TemplateSegmentModel.id).filter(
|
||||
TemplateSegmentModel.template_id == TemplateModel.id,
|
||||
)
|
||||
return query.filter(or_(has_clip_config.exists(), has_segment.exists()))
|
||||
|
||||
# ── Template CRUD ──
|
||||
|
||||
def list_by_user(
|
||||
@@ -40,11 +55,14 @@ class SQLAlchemyTemplateRepository:
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> List[Template]:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
if valid_only:
|
||||
query = self._filter_with_segment_configs(query)
|
||||
if category:
|
||||
query = query.filter(TemplateModel.category == category)
|
||||
if mode:
|
||||
@@ -102,6 +120,27 @@ class SQLAlchemyTemplateRepository:
|
||||
template.segments = self.list_segments(template.id)
|
||||
return template
|
||||
|
||||
def get_active(self, template_id: str, user_id: str) -> Optional[Template]:
|
||||
"""获取归属当前用户且未删除(is_active=True)的模板,否则返回 None.
|
||||
|
||||
用于编辑器访问门禁:模板不存在、已软删除或不属于当前用户时返回 None,
|
||||
由调用方映射为 404。与 :meth:`get` 的区别是额外过滤 is_active。
|
||||
"""
|
||||
model = (
|
||||
self.session.query(TemplateModel)
|
||||
.filter(
|
||||
TemplateModel.id == template_id,
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
template = self._model_to_entity(model)
|
||||
template.segments = self.list_segments(template.id)
|
||||
return template
|
||||
|
||||
def create(self, template: Template) -> Template:
|
||||
model = TemplateModel(
|
||||
id=template.id,
|
||||
@@ -173,11 +212,14 @@ class SQLAlchemyTemplateRepository:
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> int:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
if valid_only:
|
||||
query = self._filter_with_segment_configs(query)
|
||||
if category:
|
||||
query = query.filter(TemplateModel.category == category)
|
||||
if mode:
|
||||
|
||||
@@ -62,6 +62,7 @@ class ListTemplatesFilter:
|
||||
tag: Optional[str] = None
|
||||
keyword: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
valid_only: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -106,6 +106,7 @@ class ListTemplatesUseCase:
|
||||
tag=filter.tag,
|
||||
keyword=filter.keyword,
|
||||
mode=filter.mode,
|
||||
valid_only=filter.valid_only,
|
||||
)
|
||||
|
||||
|
||||
@@ -127,6 +128,7 @@ class CountTemplatesUseCase:
|
||||
tag=filter.tag,
|
||||
keyword=filter.keyword,
|
||||
mode=filter.mode,
|
||||
valid_only=filter.valid_only,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -129,7 +129,10 @@ def reselect_clips_for_variant(
|
||||
Raises:
|
||||
ValueError: 源片段为空 / 素材池为空 / 素材时长全为 0(无法差异化选片)。
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
elif isinstance(rng, int):
|
||||
rng = random.Random(rng)
|
||||
if not source_clips:
|
||||
raise ValueError("源 plan 无片段,无法为变体重新选片")
|
||||
if not candidate_asset_ids:
|
||||
@@ -312,7 +315,10 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
|
||||
- speed_factor: 0.95~1.05 速度微调(±5%,肉眼不太敏感但时间轴不同)
|
||||
- brightness_shift: -10~+10 亮度偏移(eq=brightness,画面明暗差异)
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
elif isinstance(rng, int):
|
||||
rng = random.Random(rng)
|
||||
return {
|
||||
"hflip": rng.random() < 0.3,
|
||||
"zoom_ratio": round(1.0 + rng.uniform(0, 0.08), 4),
|
||||
@@ -321,6 +327,57 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def generate_pixel_perturbation(rng: random.Random | int | None = None) -> dict:
|
||||
"""为一个变体生成像素级扰动滤镜参数(Issue #1765)。
|
||||
|
||||
在现有视觉扰动(hflip/zoom/brightness)基础上,额外叠加 2-3 种
|
||||
像素级滤镜,让同素材不同变体在帧级 SSIM 差异 > 3%,肉眼看不出差异。
|
||||
|
||||
滤镜选项(随机选 2-3 种叠加):
|
||||
- noise: 轻微噪声 (noise=alls=0.015:allf=t+u)
|
||||
- unsharp: 锐化或柔化 (unsharp=3:3:-0.5 ~ 3:3:0.5)
|
||||
- curves: 对比度微调 (curves 轻微调整)
|
||||
- color_balance: RGB 通道偏移 (color_balance 微调)
|
||||
|
||||
返回 dict,可直接存入 plan.config["pixel_perturbation"]。
|
||||
渲染侧读取后追加到 ffmpeg filter chain。
|
||||
"""
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
elif isinstance(rng, int):
|
||||
rng = random.Random(rng)
|
||||
|
||||
# 可用滤镜池
|
||||
filter_options = ["noise", "unsharp", "curves", "color_balance"]
|
||||
|
||||
# 随机选 2-3 种
|
||||
num_filters = rng.choice([2, 2, 3])
|
||||
selected = rng.sample(filter_options, num_filters)
|
||||
|
||||
result: dict = {"filters": selected}
|
||||
|
||||
# 为每种滤镜生成具体参数
|
||||
if "noise" in selected:
|
||||
# 噪声强度 0.01~0.02(肉眼不可见)
|
||||
result["noise_strength"] = round(rng.uniform(0.01, 0.02), 4)
|
||||
|
||||
if "unsharp" in selected:
|
||||
# 锐化/柔化:-0.5 ~ +0.5(正值锐化,负值柔化)
|
||||
result["unsharp_amount"] = round(rng.uniform(-0.5, 0.5), 2)
|
||||
|
||||
if "curves" in selected:
|
||||
# 对比度微调:0.95 ~ 1.05
|
||||
result["curves_contrast"] = round(rng.uniform(0.95, 1.05), 3)
|
||||
|
||||
if "color_balance" in selected:
|
||||
# RGB 通道偏移:-5 ~ +5(极轻微色偏)
|
||||
result["color_r"] = rng.choice([-5, -3, 0, 0, 3, 5])
|
||||
result["color_g"] = rng.choice([-5, -3, 0, 0, 3, 5])
|
||||
result["color_b"] = rng.choice([-5, -3, 0, 0, 3, 5])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _base_clip_data(src: dict, *, asset_id: str, start: float, duration: float | None = None) -> dict:
|
||||
"""从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。"""
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""配音时长 → 片段时长分配纯函数(#1749)。
|
||||
"""配音时长 → 片段时长分配纯函数(#1749 + #1764 节奏模板)。
|
||||
|
||||
定稿规则(工单 #1749):
|
||||
1. 片段数 = 模板片段数,定死,不因素材增减;
|
||||
@@ -8,6 +8,12 @@
|
||||
禁止慢放、禁止截断配音;
|
||||
4. 任何情况下不得因素材时长/数量报错打断用户。
|
||||
|
||||
#1764 节奏模板:
|
||||
- 预设 6 种权重序列,不同变体用不同节奏模板
|
||||
- 片段时长 = 配音总时长 × 该片段权重 / 权重总和
|
||||
- 平均分配作为权重全 1 的特例保留
|
||||
- 每个片段 >= MIN_CLIP_DURATION(2秒)
|
||||
|
||||
本模块为纯函数:输入片段骨架(每段转场效果/时长)与配音总时长,
|
||||
输出每段目标时长(target duration)与成片总时长。不碰 DB、不碰素材。
|
||||
"""
|
||||
@@ -15,10 +21,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 单段最小时长(秒):低于此值播放器/渲染链路易出问题
|
||||
MIN_CLIP_DURATION = 2.0
|
||||
|
||||
#: 成片总时长与配音时长的可接受误差(秒)
|
||||
TOTAL_DURATION_TOLERANCE = 0.5
|
||||
|
||||
# ── #1764 节奏模板池 ──────────────────────────────────────────────────────
|
||||
# 每种模板是权重序列,权重值代表相对时长比例
|
||||
# 变体基于 variant_seed 随机选一个模板,实现不同变体时长结构不同
|
||||
RHYTHM_TEMPLATES: list[list[int]] = [
|
||||
[1, 1, 1, 1, 1], # 平均(基准)
|
||||
[2, 1, 3, 1, 2], # 中间长,两端短
|
||||
[1, 2, 1, 2, 1], # 偶数段长
|
||||
[3, 1, 1, 1, 3], # 两端长,中间短
|
||||
[1, 1, 3, 2, 1], # 后段渐长
|
||||
[2, 1, 1, 3, 1], # 前段较长 + 第4段最长
|
||||
]
|
||||
|
||||
|
||||
def get_rhythm_template(variant_seed: int | None = None) -> list[int]:
|
||||
"""根据 variant_seed 选择一个节奏模板。
|
||||
|
||||
Args:
|
||||
variant_seed: 变体随机种子;None 时返回平均模板
|
||||
|
||||
Returns:
|
||||
权重序列(list[int])
|
||||
"""
|
||||
if variant_seed is None:
|
||||
return RHYTHM_TEMPLATES[0] # 默认平均
|
||||
rng = random.Random(variant_seed)
|
||||
return rng.choice(RHYTHM_TEMPLATES)
|
||||
|
||||
|
||||
def adapt_template_length(template: list[int], clip_count: int) -> list[int]:
|
||||
"""将节奏模板适配到实际片段数。
|
||||
|
||||
片段数 != 模板长度时:
|
||||
- clip_count < len(template): 截断
|
||||
- clip_count > len(template): 循环填充
|
||||
|
||||
Args:
|
||||
template: 原始权重序列
|
||||
clip_count: 实际片段数
|
||||
|
||||
Returns:
|
||||
适配后的权重序列(长度 == clip_count)
|
||||
"""
|
||||
if clip_count <= 0:
|
||||
return []
|
||||
if clip_count == len(template):
|
||||
return template[:]
|
||||
if clip_count < len(template):
|
||||
return template[:clip_count]
|
||||
# clip_count > len(template): 循环填充
|
||||
result = []
|
||||
for i in range(clip_count):
|
||||
result.append(template[i % len(template)])
|
||||
return result
|
||||
|
||||
|
||||
#: 单段最小时长(秒):低于此值播放器/渲染链路易出问题
|
||||
MIN_CLIP_DURATION = 1.0
|
||||
|
||||
@@ -43,9 +111,12 @@ def plan_clip_durations(
|
||||
voice_duration: float,
|
||||
transition_effects: Optional[list[Optional[str]]] = None,
|
||||
transition_durations: Optional[list[float]] = None,
|
||||
rhythm_template: Optional[list[int]] = None,
|
||||
) -> list[float]:
|
||||
"""把配音总时长分配到 clip_count 段,返回每段目标时长(秒)。
|
||||
|
||||
#1764:支持节奏模板,按权重比例分配时长;无模板时平均分配(向后兼容)。
|
||||
|
||||
分配口径:Σ段长 − Σ转场重叠 = 配音时长(成片净时长 = 配音)。
|
||||
转场重叠发生在相邻片段之间,共 clip_count-1 处;第 i 处重叠取
|
||||
**后一段(i+1)** 的转场设置(与 xfade 构建口径一致:转场挂在后段)。
|
||||
@@ -92,13 +163,36 @@ def plan_clip_durations(
|
||||
MIN_CLIP_DURATION,
|
||||
)
|
||||
|
||||
per_clip = gross / clip_count
|
||||
result = [round(per_clip, 3) for _ in range(clip_count)]
|
||||
# 末段吸收舍入误差:直接用 gross - 前段之和
|
||||
result[-1] = round(gross - sum(result[:-1]), 3)
|
||||
# #1764:按节奏模板权重分配(无模板时全 1 = 平均分配)
|
||||
weights = rhythm_template if rhythm_template and len(rhythm_template) == clip_count else [1] * clip_count
|
||||
|
||||
# 确保每个片段 >= MIN_CLIP_DURATION
|
||||
# 先按权重分配,再检查最小值
|
||||
total_weight = sum(weights)
|
||||
raw_durations = [(w / total_weight) * gross for w in weights]
|
||||
|
||||
# 保底检查:如果有片段 < MIN_CLIP_DURATION,提升它并从最长片段扣
|
||||
result = [round(d, 3) for d in raw_durations]
|
||||
for _ in range(3): # 最多迭代 3 次
|
||||
min_idx = min(range(len(result)), key=lambda i: result[i])
|
||||
if result[min_idx] >= MIN_CLIP_DURATION:
|
||||
break
|
||||
# 从最长片段借时长
|
||||
max_idx = max(range(len(result)), key=lambda i: result[i])
|
||||
if max_idx == min_idx or result[max_idx] <= MIN_CLIP_DURATION:
|
||||
# 无法再调整,强制保底
|
||||
result[min_idx] = MIN_CLIP_DURATION
|
||||
break
|
||||
deficit = MIN_CLIP_DURATION - result[min_idx]
|
||||
result[min_idx] = MIN_CLIP_DURATION
|
||||
result[max_idx] = round(result[max_idx] - deficit, 3)
|
||||
|
||||
# 末段吸收舍入误差
|
||||
total_assigned = sum(result[:-1])
|
||||
result[-1] = round(gross - total_assigned, 3)
|
||||
if result[-1] < MIN_CLIP_DURATION:
|
||||
# 极端情况下末段被舍入压得过小,摊平
|
||||
result[-1] = MIN_CLIP_DURATION
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class TemplateRepositoryPort(Protocol):
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> List[Template]: ...
|
||||
def get(self, template_id: str, user_id: str) -> Optional[Template]: ...
|
||||
def create(self, template: Template) -> Template: ...
|
||||
@@ -31,6 +32,7 @@ class TemplateRepositoryPort(Protocol):
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> int: ...
|
||||
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template: ...
|
||||
def list_segments(self, template_id: str) -> List[TemplateSegment]: ...
|
||||
|
||||
@@ -239,8 +239,8 @@ class TestEditorClipsBySegments:
|
||||
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_no_segments_raises_400(self, mock_storage):
|
||||
"""模板没有 segment 配置时返回 400。"""
|
||||
def test_no_segments_raises_422(self, mock_storage):
|
||||
"""模板存在但未配置片段时返回 422(配置错误,与 404 区分)。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
@@ -264,11 +264,44 @@ class TestEditorClipsBySegments:
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "片段配置" in exc_info.value.detail
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "片段" in exc_info.value.detail
|
||||
# 不应调用替换方法
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_template_not_found_raises_404(self, mock_storage):
|
||||
"""模板不存在/已删除/无权限(服务层抛 TemplateNotFoundError)时返回 404。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from app.services.edit_template_service import TemplateNotFoundError
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
side_effect=TemplateNotFoundError("tpl-missing"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-missing",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
class TestEditorClipsDurationAndStartTime:
|
||||
"""测试素材时长获取、clip duration 缩短、start_time 传入。"""
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""_get_template_segments 回退路径测试.
|
||||
"""模板片段配置读取路径测试(#1774).
|
||||
|
||||
验证三级回退链:
|
||||
1. 新模板系统(tpl_svc.list_clip_configs)正常 → 直接返回
|
||||
2. 新模板系统主表不存在(ValueError)→ 直接查 template_clip_configs 表兜底
|
||||
3. 直接查表也失败 → 回退旧模板系统(template_segments)
|
||||
4. 全部失败 → 返回空列表
|
||||
收敛后模板读取走单一数据源,不再有"新表抛异常→降级查旧表→再降级查 segments"
|
||||
的异常控制流:
|
||||
|
||||
覆盖 P0 修复:自建模板在 edit_templates 主表不存在但在 template_clip_configs 有记录时,
|
||||
from-assets 流程不再 400。
|
||||
- ``EditTemplateService.list_clip_configs_for_editor`` 显式判定模板归属/存在性:
|
||||
1. 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True)→ 直接读
|
||||
``template_clip_configs``;
|
||||
2. 全局模板在新表 ``edit_templates``(无 user_id)→ 直接读 ``template_clip_configs``;
|
||||
3. 两表都没有 → 抛 ``TemplateNotFoundError``(路由层映射 404)。
|
||||
- ``_get_template_segments`` 仅做配置→(order, min, max) 的映射与排序,
|
||||
模板存在但无配置返回空列表(路由层映射 422)。
|
||||
|
||||
使用真实 SQLite 内存库 + 真实仓储,验证端到端读路径不抛 ``ValueError: 模板不存在``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,21 +19,198 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
import pytest # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import ( # noqa: E402
|
||||
Base,
|
||||
EditTemplateModel,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
)
|
||||
|
||||
TEST_TEMPLATE_ID = "tmpl-orphan-001"
|
||||
DEFAULT_DUR = 5.0 # _DEFAULT_EDITOR_CLIP_DURATION
|
||||
USER_ID = "user-001"
|
||||
OTHER_USER_ID = "user-002"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 真实内存 DB fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _seed_legacy_template(session, template_id: str, user_id: str, *, active: bool = True, clip_count: int = 3):
|
||||
"""创建旧表 templates 模板(+ template_clip_configs 片段配置)。"""
|
||||
session.add(
|
||||
TemplateModel(
|
||||
id=template_id,
|
||||
user_id=user_id,
|
||||
name=f"模板-{template_id}",
|
||||
mode="one_take",
|
||||
is_active=active,
|
||||
)
|
||||
)
|
||||
for order in range(clip_count):
|
||||
session.add(
|
||||
TemplateClipConfigModel(
|
||||
id=f"cc-{template_id}-{order}",
|
||||
template_id=template_id,
|
||||
clip_type="main",
|
||||
order=order,
|
||||
min_duration=5.0,
|
||||
max_duration=8.0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _seed_global_template(session, template_id: str, *, status: str = "active", clip_count: int = 2):
|
||||
"""创建新表 edit_templates 全局模板(+ template_clip_configs 片段配置)。"""
|
||||
session.add(
|
||||
EditTemplateModel(
|
||||
id=template_id,
|
||||
name=f"全局模板-{template_id}",
|
||||
template_type="default",
|
||||
editing_mode="one_take",
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
for order in range(clip_count):
|
||||
session.add(
|
||||
TemplateClipConfigModel(
|
||||
id=f"gcc-{template_id}-{order}",
|
||||
template_id=template_id,
|
||||
clip_type="main",
|
||||
order=order,
|
||||
min_duration=3.0,
|
||||
max_duration=6.0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service 读路径测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListClipConfigsForEditor:
|
||||
"""list_clip_configs_for_editor 单一数据源 + 归属/存在性判定。"""
|
||||
|
||||
def test_legacy_user_template_returns_configs(self):
|
||||
"""用户自建模板(templates 表 + 3 条 clip_configs)→ 正常返回,不抛异常。"""
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-legacy", USER_ID, clip_count=3)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
configs = svc.list_clip_configs_for_editor("tmpl-legacy", USER_ID)
|
||||
|
||||
assert len(configs) == 3
|
||||
assert [c.order for c in configs] == [0, 1, 2]
|
||||
assert all(c.min_duration == 5.0 for c in configs)
|
||||
|
||||
def test_missing_template_raises_not_found(self):
|
||||
"""模板不存在(两表都没有)→ TemplateNotFoundError。"""
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
|
||||
session = _make_session()
|
||||
svc = EditTemplateService(session)
|
||||
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
svc.list_clip_configs_for_editor("tmpl-not-exist", USER_ID)
|
||||
|
||||
def test_other_users_template_raises_not_found(self):
|
||||
"""他人模板(user_id 不匹配)→ TemplateNotFoundError(归属校验)。"""
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-owner", OTHER_USER_ID, clip_count=3)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
svc.list_clip_configs_for_editor("tmpl-owner", USER_ID)
|
||||
|
||||
def test_deleted_legacy_template_raises_not_found(self):
|
||||
"""已软删除(is_active=False)的旧表模板 → TemplateNotFoundError。"""
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-deleted", USER_ID, active=False, clip_count=3)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
svc.list_clip_configs_for_editor("tmpl-deleted", USER_ID)
|
||||
|
||||
def test_legacy_template_without_configs_returns_empty(self):
|
||||
"""模板存在且归属正确但无片段配置 → 返回空列表(不抛异常,路由层映射 422)。"""
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-noconfig", USER_ID, clip_count=0)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
configs = svc.list_clip_configs_for_editor("tmpl-noconfig", USER_ID)
|
||||
assert configs == []
|
||||
|
||||
def test_global_template_returns_configs(self):
|
||||
"""新表 edit_templates 全局模板(无 user_id)→ 任意用户可读,正常返回。"""
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
session = _make_session()
|
||||
_seed_global_template(session, "tmpl-global", clip_count=2)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
configs = svc.list_clip_configs_for_editor("tmpl-global", USER_ID)
|
||||
|
||||
assert len(configs) == 2
|
||||
assert [c.order for c in configs] == [0, 1]
|
||||
|
||||
def test_normal_legacy_request_does_not_raise_valueerror(self):
|
||||
"""正常旧表模板请求绝不在读路径抛 ValueError: 模板不存在(回归保护)。"""
|
||||
import logging
|
||||
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-ok", USER_ID, clip_count=3)
|
||||
svc = EditTemplateService(session)
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
# 若读路径意外抛 ValueError 并被记录为异常堆栈,测试能感知
|
||||
errors: list[str] = []
|
||||
mp.setattr(
|
||||
logging.getLogger("app.services.edit_template_service"),
|
||||
"exception",
|
||||
lambda *a, **k: errors.append(str(a)),
|
||||
)
|
||||
configs = svc.list_clip_configs_for_editor("tmpl-ok", USER_ID)
|
||||
|
||||
assert len(configs) == 3
|
||||
assert errors == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_template_segments 映射测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
|
||||
"""构造 mock TemplateClipConfig 领域实体."""
|
||||
cc = MagicMock()
|
||||
cc.order = order
|
||||
cc.min_duration = min_dur
|
||||
@@ -37,207 +218,53 @@ def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
|
||||
return cc
|
||||
|
||||
|
||||
def _make_old_segment(segment_order: int, dur_min: float = 4.0, dur_max: float = 7.0):
|
||||
"""构造 mock 旧 TemplateSegment."""
|
||||
s = MagicMock()
|
||||
s.segment_order = segment_order
|
||||
s.duration_min = dur_min
|
||||
s.duration_max = dur_max
|
||||
return s
|
||||
class TestGetTemplateSegments:
|
||||
"""_get_template_segments 仅做映射/排序,异常与空配置语义明确。"""
|
||||
|
||||
def test_maps_and_sorts_configs(self):
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTemplateSegmentsFallback:
|
||||
"""_get_template_segments 三级回退链."""
|
||||
|
||||
def test_new_system_works(self):
|
||||
"""路径1:新模板系统正常返回 → 直接使用."""
|
||||
configs = [_make_clip_config(0, 2.0, 6.0), _make_clip_config(1, 3.0, 9.0)]
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = configs
|
||||
db = MagicMock()
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0, 2.0, 6.0)
|
||||
assert result[1] == (1, 3.0, 9.0)
|
||||
tpl_svc.list_clip_configs.assert_called_once_with(TEST_TEMPLATE_ID)
|
||||
|
||||
def test_main_table_missing_direct_query_succeeds(self):
|
||||
"""路径2(P0修复):主表不存在 ValueError → 直接查表成功.
|
||||
|
||||
模拟自建模板在 edit_templates 主表已删除/不存在,
|
||||
但 template_clip_configs 表有记录。
|
||||
"""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError(f"模板不存在: {TEST_TEMPLATE_ID}")
|
||||
db = MagicMock()
|
||||
|
||||
# Mock SQLAlchemyTemplateClipConfigRepository
|
||||
direct_configs = [
|
||||
_make_clip_config(0, 2.0, 5.0),
|
||||
_make_clip_config(1, 3.0, 7.0),
|
||||
_make_clip_config(2, 4.0, 8.0),
|
||||
]
|
||||
with (
|
||||
__import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = direct_configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0] == (0, 2.0, 5.0)
|
||||
assert result[1] == (1, 3.0, 7.0)
|
||||
assert result[2] == (2, 4.0, 8.0)
|
||||
mock_repo.list_by_template.assert_called_once_with(TEST_TEMPLATE_ID)
|
||||
|
||||
def test_main_table_missing_direct_query_empty_falls_to_old(self):
|
||||
"""路径2→3:主表不存在 + 直接查表为空 → 回退旧系统."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
old_segments = [_make_old_segment(0, 3.0, 6.0)]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = [] # 新表也没记录
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
|
||||
) as mock_old_cls:
|
||||
mock_old = MagicMock()
|
||||
mock_old.list_segments.return_value = old_segments
|
||||
mock_old_cls.return_value = mock_old
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 3.0, 6.0)
|
||||
|
||||
def test_all_fail_returns_empty(self):
|
||||
"""路径4:三级全部失败 → 返回空列表."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.side_effect = Exception("DB error")
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
|
||||
) as mock_old_cls:
|
||||
mock_old = MagicMock()
|
||||
mock_old.list_segments.return_value = [] # 旧表也空
|
||||
mock_old_cls.return_value = mock_old
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_direct_query_sorts_by_order(self):
|
||||
"""直接查表返回的结果按 order 排序."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
# 故意乱序
|
||||
configs = [
|
||||
tpl_svc.list_clip_configs_for_editor.return_value = [
|
||||
_make_clip_config(2, 5.0, 10.0),
|
||||
_make_clip_config(0, 2.0, 4.0),
|
||||
_make_clip_config(1, 3.0, 6.0),
|
||||
]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
result = _get_template_segments("tmpl-1", USER_ID, tpl_svc)
|
||||
|
||||
assert [r[0] for r in result] == [0, 1, 2]
|
||||
assert result[0] == (0, 2.0, 4.0)
|
||||
assert result[1] == (1, 3.0, 6.0)
|
||||
assert result[2] == (2, 5.0, 10.0)
|
||||
tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", USER_ID)
|
||||
|
||||
def test_empty_configs_returns_empty(self):
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
def test_direct_query_handles_none_durations(self):
|
||||
"""直接查表时 min/max_duration 为 None → 使用默认值."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
tpl_svc.list_clip_configs_for_editor.return_value = []
|
||||
|
||||
assert _get_template_segments("tmpl-1", USER_ID, tpl_svc) == []
|
||||
|
||||
def test_missing_template_propagates_not_found(self):
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
from app.services.edit_template_service import TemplateNotFoundError
|
||||
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
|
||||
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
_get_template_segments("tmpl-x", USER_ID, tpl_svc)
|
||||
|
||||
def test_none_durations_use_default(self):
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
tpl_svc = MagicMock()
|
||||
cc = MagicMock()
|
||||
cc.order = 0
|
||||
cc.min_duration = None
|
||||
cc.max_duration = None
|
||||
tpl_svc.list_clip_configs_for_editor.return_value = [cc]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = [cc]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 1
|
||||
# None → default (5.0), max(None or None) → default (5.0)
|
||||
assert result[0] == (0, DEFAULT_DUR, DEFAULT_DUR)
|
||||
|
||||
def test_new_system_returns_empty_tries_direct(self):
|
||||
"""新模板系统返回空列表(非异常)→ 继续尝试直接查表."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = [] # 空列表,非异常
|
||||
db = MagicMock()
|
||||
|
||||
direct_configs = [_make_clip_config(0, 3.0, 6.0)]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = direct_configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
# 新系统返回空 → 不走 except → 但也没 return → 继续往下走
|
||||
# 直接查表有数据 → 返回
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 3.0, 6.0)
|
||||
|
||||
def test_existing_template_unaffected(self):
|
||||
"""正常模板(主表存在)行为不变."""
|
||||
configs = [_make_clip_config(0, 2.0, 5.0)]
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = configs
|
||||
db = MagicMock()
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
# 直接查表不应被调用(新系统已返回)
|
||||
mock_repo_cls.assert_not_called()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 2.0, 5.0)
|
||||
result = _get_template_segments("tmpl-1", USER_ID, tpl_svc)
|
||||
assert result == [(0, DEFAULT_DUR, DEFAULT_DUR)]
|
||||
|
||||
@@ -226,10 +226,10 @@ class TestGetMediakitRecommendations:
|
||||
|
||||
|
||||
class TestGetTemplateSegments:
|
||||
"""测试模板片段配置查询。"""
|
||||
"""测试模板片段配置查询(单一数据源:template_clip_configs)。"""
|
||||
|
||||
def test_returns_segments_from_new_template_system(self):
|
||||
"""新模板系统(clip_configs)有数据时优先使用。"""
|
||||
def test_returns_segments_from_clip_configs(self):
|
||||
"""片段配置主表(clip_configs)有数据时按 order 排序返回。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
@@ -241,66 +241,34 @@ class TestGetTemplateSegments:
|
||||
cc2.order = 1
|
||||
cc2.min_duration = 4.0
|
||||
cc2.max_duration = 8.0
|
||||
mock_tpl_svc.list_clip_configs.return_value = [cc2, cc1] # 乱序返回
|
||||
mock_tpl_svc.list_clip_configs_for_editor.return_value = [cc2, cc1] # 乱序返回
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
result = _get_template_segments("tmpl-1", "user-1", mock_tpl_svc)
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0, 3.0, 5.0)
|
||||
assert result[1] == (1, 4.0, 8.0)
|
||||
mock_tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", "user-1")
|
||||
|
||||
def test_falls_back_to_old_template_segments(self):
|
||||
"""新模板系统无数据时回退到旧系统。"""
|
||||
def test_returns_empty_when_no_configs(self):
|
||||
"""模板存在但没有片段配置时返回空列表(路由层据此返回 422)。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
mock_tpl_svc.list_clip_configs_for_editor.return_value = []
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
seg1 = MagicMock()
|
||||
seg1.segment_order = 0
|
||||
seg1.duration_min = 2.0
|
||||
seg1.duration_max = 4.0
|
||||
mock_repo.list_segments.return_value = [seg1]
|
||||
MockRepo.return_value = mock_repo
|
||||
result = _get_template_segments("tmpl-1", "user-1", mock_tpl_svc)
|
||||
assert result == []
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 2.0, 4.0)
|
||||
|
||||
def test_returns_empty_when_no_segments(self):
|
||||
"""两套系统都没有片段配置时返回空列表。"""
|
||||
def test_missing_template_raises(self):
|
||||
"""模板不存在/无权限时服务层抛 TemplateNotFoundError(路由层据此返回 404)。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
from app.services.edit_template_service import TemplateNotFoundError
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
mock_tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_segments.return_value = []
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert result == []
|
||||
|
||||
def test_new_system_exception_falls_back(self):
|
||||
"""新模板系统异常时回退到旧系统。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.side_effect = RuntimeError("db error")
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
seg = MagicMock()
|
||||
seg.segment_order = 0
|
||||
seg.duration_min = 1.0
|
||||
seg.duration_max = 3.0
|
||||
mock_repo.list_segments.return_value = [seg]
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 1
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
_get_template_segments("tmpl-x", "user-1", mock_tpl_svc)
|
||||
|
||||
|
||||
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
|
||||
@@ -358,7 +326,7 @@ def _make_tpl_svc_with_segments(segments):
|
||||
"""segments: list of (order, min_dur, max_dur)"""
|
||||
svc = MagicMock()
|
||||
clip_configs = [_make_clip_config(o, mn, mx) for o, mn, mx in segments]
|
||||
svc.list_clip_configs.return_value = clip_configs
|
||||
svc.list_clip_configs_for_editor.return_value = clip_configs
|
||||
return svc
|
||||
|
||||
|
||||
@@ -540,34 +508,56 @@ class TestFromAssetsByTemplateSegments:
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == [0, 1, 2]
|
||||
|
||||
def test_no_segments_raises_400(self):
|
||||
"""模板没有 segment 配置时返回 400。"""
|
||||
def test_no_segments_raises_422(self):
|
||||
"""模板存在但未配置片段时返回 422(与模板不存在的 404 区分)。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
mock_tpl_svc.list_clip_configs_for_editor.return_value = []
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_segments.return_value = []
|
||||
MockRepo.return_value = mock_repo
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
def test_template_not_found_raises_404(self):
|
||||
"""模板不存在/已删除/无权限时返回 404。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from app.services.edit_template_service import TemplateNotFoundError
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-x",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""像素级扰动滤镜单元测试(Issue #1765)。
|
||||
|
||||
覆盖:
|
||||
- generate_pixel_perturbation:生成像素级扰动参数
|
||||
- 滤镜组合:2-3 种滤镜随机组合
|
||||
- 参数范围:肉眼不可见但帧级可检测
|
||||
- FFmpeg 滤镜语法生成
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.variant_plan_selector import generate_pixel_perturbation
|
||||
|
||||
|
||||
class TestGeneratePixelPerturbation:
|
||||
"""generate_pixel_perturbation 测试。"""
|
||||
|
||||
def test_returns_dict(self):
|
||||
"""返回 dict。"""
|
||||
result = generate_pixel_perturbation()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_has_filters_key(self):
|
||||
"""包含 filters 键。"""
|
||||
result = generate_pixel_perturbation()
|
||||
assert "filters" in result
|
||||
|
||||
def test_filters_count_2_or_3(self):
|
||||
"""选 2-3 种滤镜。"""
|
||||
for _ in range(50):
|
||||
result = generate_pixel_perturbation()
|
||||
assert len(result["filters"]) in [2, 3]
|
||||
|
||||
def test_filters_from_valid_options(self):
|
||||
"""滤镜来自有效选项。"""
|
||||
valid_options = {"noise", "unsharp", "curves", "color_balance"}
|
||||
for _ in range(50):
|
||||
result = generate_pixel_perturbation()
|
||||
for f in result["filters"]:
|
||||
assert f in valid_options
|
||||
|
||||
def test_noise_parameters(self):
|
||||
"""noise 滤镜有正确参数范围。"""
|
||||
for _ in range(20):
|
||||
result = generate_pixel_perturbation()
|
||||
if "noise" in result["filters"]:
|
||||
strength = result.get("noise_strength", 0)
|
||||
assert 0.01 <= strength <= 0.02
|
||||
|
||||
def test_unsharp_parameters(self):
|
||||
"""unsharp 滤镜有正确参数范围。"""
|
||||
for _ in range(20):
|
||||
result = generate_pixel_perturbation()
|
||||
if "unsharp" in result["filters"]:
|
||||
amount = result.get("unsharp_amount", 0)
|
||||
assert -0.5 <= amount <= 0.5
|
||||
|
||||
def test_curves_parameters(self):
|
||||
"""curves 滤镜有正确参数范围。"""
|
||||
for _ in range(20):
|
||||
result = generate_pixel_perturbation()
|
||||
if "curves" in result["filters"]:
|
||||
contrast = result.get("curves_contrast", 1.0)
|
||||
assert 0.95 <= contrast <= 1.05
|
||||
|
||||
def test_color_balance_parameters(self):
|
||||
"""color_balance 滤镜有正确参数范围。"""
|
||||
valid_colors = [-5, -3, 0, 3, 5]
|
||||
for _ in range(20):
|
||||
result = generate_pixel_perturbation()
|
||||
if "color_balance" in result["filters"]:
|
||||
assert result.get("color_r") in valid_colors
|
||||
assert result.get("color_g") in valid_colors
|
||||
assert result.get("color_b") in valid_colors
|
||||
|
||||
def test_same_seed_same_result(self):
|
||||
"""相同 seed 返回相同结果。"""
|
||||
rng1 = random.Random(42)
|
||||
rng2 = random.Random(42)
|
||||
result1 = generate_pixel_perturbation(rng1)
|
||||
result2 = generate_pixel_perturbation(rng2)
|
||||
assert result1 == result2
|
||||
|
||||
def test_different_seeds_may_differ(self):
|
||||
"""不同 seed 可能返回不同结果。"""
|
||||
results = set()
|
||||
for seed in range(20):
|
||||
rng = random.Random(seed)
|
||||
result = generate_pixel_perturbation(rng)
|
||||
results.add(tuple(result["filters"]))
|
||||
# 20 个 seed 至少看到 3 种不同组合
|
||||
assert len(results) >= 3
|
||||
|
||||
|
||||
class TestPixelPerturbationAcceptance:
|
||||
"""Issue #1765 验收测试。"""
|
||||
|
||||
def test_batch_3_variants_have_different_filters(self):
|
||||
"""批量 3 个变体有不同的滤镜组合。"""
|
||||
results = []
|
||||
for seed in [100, 200, 300]:
|
||||
rng = random.Random(seed)
|
||||
result = generate_pixel_perturbation(rng)
|
||||
results.append(tuple(result["filters"]))
|
||||
|
||||
# 至少 2 种不同组合
|
||||
unique = len(set(results))
|
||||
assert unique >= 2, f"Expected >= 2 unique filter combos, got {unique}: {results}"
|
||||
|
||||
|
||||
class TestIntSeedSupport:
|
||||
"""int seed 入参支持(与 get_rhythm_template(seed) 接口一致)。"""
|
||||
|
||||
def test_int_seed_returns_dict(self):
|
||||
"""int seed 正常返回 dict。"""
|
||||
result = generate_pixel_perturbation(42)
|
||||
assert isinstance(result, dict)
|
||||
assert "filters" in result
|
||||
|
||||
def test_int_seed_reproducible(self):
|
||||
"""相同 int seed 结果一致。"""
|
||||
assert generate_pixel_perturbation(42) == generate_pixel_perturbation(42)
|
||||
|
||||
def test_int_seed_differs_across_seeds(self):
|
||||
"""不同 int seed 大概率不同(遍历确认至少 2 种组合)。"""
|
||||
results = {tuple(generate_pixel_perturbation(s)["filters"]) for s in range(30)}
|
||||
assert len(results) >= 2
|
||||
|
||||
def test_int_seed_matches_random_obj(self):
|
||||
"""int seed 与等价 random.Random(seed) 结果一致。"""
|
||||
assert generate_pixel_perturbation(7) == generate_pixel_perturbation(random.Random(7))
|
||||
|
||||
def test_none_seed_works(self):
|
||||
"""None 入参(默认随机)正常返回。"""
|
||||
result = generate_pixel_perturbation(None)
|
||||
assert isinstance(result, dict)
|
||||
assert len(result["filters"]) in [2, 3]
|
||||
@@ -0,0 +1,190 @@
|
||||
"""节奏模板单元测试(Issue #1764)。
|
||||
|
||||
覆盖:
|
||||
- RHYTHM_TEMPLATES 池定义(6 种模板)
|
||||
- get_rhythm_template:根据 seed 选择模板
|
||||
- adapt_template_length:适配不同片段数
|
||||
- plan_clip_durations:按权重分配时长
|
||||
- 时长约束:总时长 ≈ 配音时长,每段 >= 2s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.voice_duration_planner import (
|
||||
MIN_CLIP_DURATION,
|
||||
RHYTHM_TEMPLATES,
|
||||
adapt_template_length,
|
||||
get_rhythm_template,
|
||||
plan_clip_durations,
|
||||
total_output_duration,
|
||||
)
|
||||
|
||||
|
||||
class TestRhythmTemplates:
|
||||
"""节奏模板池测试。"""
|
||||
|
||||
def test_six_templates_defined(self):
|
||||
"""预设 6 种节奏模板。"""
|
||||
assert len(RHYTHM_TEMPLATES) == 6
|
||||
|
||||
def test_average_template_is_all_ones(self):
|
||||
"""第一种模板是平均(全 1)。"""
|
||||
assert RHYTHM_TEMPLATES[0] == [1, 1, 1, 1, 1]
|
||||
|
||||
def test_all_templates_have_5_elements(self):
|
||||
"""所有模板长度为 5(会被 adapt 适配)。"""
|
||||
for tpl in RHYTHM_TEMPLATES:
|
||||
assert len(tpl) == 5
|
||||
|
||||
|
||||
class TestGetRhythmTemplate:
|
||||
"""get_rhythm_template 测试。"""
|
||||
|
||||
def test_none_seed_returns_average(self):
|
||||
"""None seed 返回平均模板。"""
|
||||
assert get_rhythm_template(None) == [1, 1, 1, 1, 1]
|
||||
|
||||
def test_same_seed_same_template(self):
|
||||
"""相同 seed 返回相同模板。"""
|
||||
tpl1 = get_rhythm_template(42)
|
||||
tpl2 = get_rhythm_template(42)
|
||||
assert tpl1 == tpl2
|
||||
|
||||
def test_different_seeds_may_differ(self):
|
||||
"""不同 seed 可能返回不同模板。"""
|
||||
templates_seen = set()
|
||||
for seed in range(100):
|
||||
tpl = tuple(get_rhythm_template(seed))
|
||||
templates_seen.add(tpl)
|
||||
# 100 个 seed 应该至少看到 3 种不同模板
|
||||
assert len(templates_seen) >= 3
|
||||
|
||||
|
||||
class TestAdaptTemplateLength:
|
||||
"""adapt_template_length 测试。"""
|
||||
|
||||
def test_same_length(self):
|
||||
"""片段数 == 模板长度时直接返回。"""
|
||||
tpl = [2, 1, 3, 1, 2]
|
||||
assert adapt_template_length(tpl, 5) == [2, 1, 3, 1, 2]
|
||||
|
||||
def test_shorter_clip_count(self):
|
||||
"""片段数 < 模板长度时截断。"""
|
||||
tpl = [2, 1, 3, 1, 2]
|
||||
assert adapt_template_length(tpl, 3) == [2, 1, 3]
|
||||
|
||||
def test_longer_clip_count(self):
|
||||
"""片段数 > 模板长度时循环填充。"""
|
||||
tpl = [2, 1, 3]
|
||||
result = adapt_template_length(tpl, 7)
|
||||
assert result == [2, 1, 3, 2, 1, 3, 2]
|
||||
|
||||
def test_zero_clip_count(self):
|
||||
"""片段数 0 返回空列表。"""
|
||||
assert adapt_template_length([1, 2, 3], 0) == []
|
||||
|
||||
|
||||
class TestPlanClipDurationsWithRhythm:
|
||||
"""plan_clip_durations 节奏模板测试。"""
|
||||
|
||||
def test_average_template_equals_old_behavior(self):
|
||||
"""全 1 模板 = 原来的平均分配。"""
|
||||
voice = 20.0
|
||||
clips = 4
|
||||
result = plan_clip_durations(clips, voice, rhythm_template=[1, 1, 1, 1])
|
||||
# 每段应该 ≈ 5s
|
||||
assert all(abs(d - 5.0) < 0.1 for d in result)
|
||||
assert abs(sum(result) - voice) < 0.1
|
||||
|
||||
def test_weighted_template_different_durations(self):
|
||||
"""权重模板产生不同时长的片段。"""
|
||||
voice = 18.0
|
||||
clips = 5
|
||||
# 权重 [2, 1, 3, 1, 2]:第 3 段最长,第 2/4 段最短
|
||||
template = [2, 1, 3, 1, 2]
|
||||
result = plan_clip_durations(clips, voice, rhythm_template=template)
|
||||
|
||||
# 总时长 ≈ 配音时长
|
||||
assert abs(sum(result) - voice) < 0.5
|
||||
|
||||
# 第 3 段应该最长
|
||||
assert result[2] > result[1]
|
||||
assert result[2] > result[3]
|
||||
|
||||
def test_min_clip_duration_enforced(self):
|
||||
"""每段 >= MIN_CLIP_DURATION (2s)。"""
|
||||
voice = 15.0
|
||||
clips = 5
|
||||
# 极端权重:某段权重极低
|
||||
template = [10, 1, 1, 1, 1]
|
||||
result = plan_clip_durations(clips, voice, rhythm_template=template)
|
||||
|
||||
for d in result:
|
||||
assert d >= MIN_CLIP_DURATION
|
||||
|
||||
def test_total_duration_with_transitions(self):
|
||||
"""含转场时总时长仍然正确。"""
|
||||
voice = 20.0
|
||||
clips = 4
|
||||
effects = [None, "xfade", "fade", "cut"]
|
||||
durations = [0.0, 0.5, 0.3, 0.0]
|
||||
template = [2, 1, 1, 2]
|
||||
|
||||
result = plan_clip_durations(
|
||||
clips,
|
||||
voice,
|
||||
transition_effects=effects,
|
||||
transition_durations=durations,
|
||||
rhythm_template=template,
|
||||
)
|
||||
|
||||
# 成片净时长 = Σ段长 - Σ转场重叠 ≈ 配音时长
|
||||
output = total_output_duration(result, effects, durations)
|
||||
assert abs(output - voice) < 0.5
|
||||
|
||||
def test_no_template_backward_compatible(self):
|
||||
"""不传模板时行为与旧版一致(平均分配)。"""
|
||||
voice = 16.0
|
||||
clips = 4
|
||||
result = plan_clip_durations(clips, voice)
|
||||
assert all(abs(d - 4.0) < 0.1 for d in result)
|
||||
|
||||
def test_six_templates_produce_different_structures(self):
|
||||
"""6 种模板产生不同的时长结构。"""
|
||||
voice = 25.0
|
||||
clips = 5
|
||||
structures = set()
|
||||
|
||||
for tpl in RHYTHM_TEMPLATES:
|
||||
result = plan_clip_durations(clips, voice, rhythm_template=tpl)
|
||||
# 用 round 后的元组作为结构指纹
|
||||
structure = tuple(round(d, 1) for d in result)
|
||||
structures.add(structure)
|
||||
|
||||
# 至少 4 种不同结构
|
||||
assert len(structures) >= 4
|
||||
|
||||
|
||||
class TestIssue1764Acceptance:
|
||||
"""Issue #1764 验收测试。"""
|
||||
|
||||
def test_batch_3_variants_at_least_2_different(self):
|
||||
"""批量 3 个变体,至少 2 组不同片段时长序列。"""
|
||||
voice = 20.0
|
||||
clips = 5
|
||||
|
||||
# 模拟 3 个变体用不同 seed
|
||||
seeds = [100, 200, 300]
|
||||
structures = []
|
||||
|
||||
for seed in seeds:
|
||||
template = get_rhythm_template(seed)
|
||||
adapted = adapt_template_length(template, clips)
|
||||
durations = plan_clip_durations(clips, voice, rhythm_template=adapted)
|
||||
structures.append(tuple(round(d, 1) for d in durations))
|
||||
|
||||
# 至少 2 种不同结构
|
||||
unique = len(set(structures))
|
||||
assert unique >= 2, f"Expected >= 2 unique structures, got {unique}: {structures}"
|
||||
@@ -188,6 +188,7 @@ class TestListTemplatesUseCase:
|
||||
tag="tag1",
|
||||
keyword="test",
|
||||
mode="one_take",
|
||||
valid_only=False,
|
||||
)
|
||||
|
||||
def test_list_pagination(self):
|
||||
@@ -226,6 +227,7 @@ class TestCountTemplatesUseCase:
|
||||
tag="tag1",
|
||||
keyword="kw",
|
||||
mode="pip",
|
||||
valid_only=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -158,6 +158,52 @@ class TestListByUser:
|
||||
assert len(result[0].segments) == 1
|
||||
assert result[0].segments[0].duration_min == 2.0
|
||||
|
||||
def test_valid_only_filters_templates_without_segments(self, repo, session):
|
||||
"""#1769: valid_only=True 时排除两张片段表都没有记录的无效模板."""
|
||||
# 有效模板:有 clip_configs
|
||||
valid_clip = _make_template(name="有效模板-clip_configs")
|
||||
repo.create(valid_clip)
|
||||
repo.create_segments([_make_segment(valid_clip.id, order=1)])
|
||||
# 有效模板:仅有旧表 template_segments 记录
|
||||
valid_old = _make_template(name="有效模板-old_segments")
|
||||
repo.create(valid_old)
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=valid_old.id,
|
||||
segment_order=1,
|
||||
duration_min=2.0,
|
||||
duration_max=6.0,
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
# 无效模板:两张表都没有记录
|
||||
invalid = _make_template(name="无效模板-无片段")
|
||||
repo.create(invalid)
|
||||
|
||||
# 默认不过滤:编辑器视角能看到全部 3 个模板
|
||||
all_templates = repo.list_by_user("u1")
|
||||
assert len(all_templates) == 3
|
||||
assert repo.count_by_user("u1") == 3
|
||||
|
||||
# valid_only=True:剪辑页视角只返回 2 个有效模板
|
||||
valid_templates = repo.list_by_user("u1", valid_only=True)
|
||||
assert {t.name for t in valid_templates} == {"有效模板-clip_configs", "有效模板-old_segments"}
|
||||
assert all(len(t.segments) > 0 for t in valid_templates)
|
||||
assert repo.count_by_user("u1", valid_only=True) == 2
|
||||
|
||||
def test_valid_only_with_filters_and_pagination(self, repo, session):
|
||||
"""valid_only 与其他过滤/分页条件组合使用."""
|
||||
tpl = _make_template(name="口播模板", mode="voice_over")
|
||||
repo.create(tpl)
|
||||
repo.create_segments([_make_segment(tpl.id, order=1, material_type="人物")])
|
||||
_invalid = _make_template(name="口播无效模板", mode="voice_over")
|
||||
repo.create(_invalid)
|
||||
|
||||
result = repo.list_by_user("u1", mode="voice_over", valid_only=True)
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "口播模板"
|
||||
assert repo.count_by_user("u1", mode="voice_over", valid_only=True) == 1
|
||||
|
||||
|
||||
class TestCopyTemplate:
|
||||
def test_copy_writes_to_clip_configs(self, repo, session):
|
||||
|
||||
Reference in New Issue
Block a user