Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6af6892cd | |||
| 506b29991b | |||
| ab81c0115a | |||
| ecfa478a24 | |||
| 9711b6a545 | |||
| d771fee45a | |||
| 753a3206a7 | |||
| 5c62dad798 | |||
| fdc9573baa | |||
| 60263703a6 | |||
| 030220f24d | |||
| 6c047cdc20 | |||
| 9510f69816 | |||
| 42c4f22b21 | |||
| a2b37e376d | |||
| 185c240557 | |||
| 5e8dcb9843 | |||
| 00067907f2 | |||
| 1667b3878d | |||
| 89cd853bbe | |||
| 3bbe87b5c8 | |||
| 0c216bc545 | |||
| f0dce3d41d | |||
| c2fe02cf06 | |||
| 195339d0f8 | |||
| 29c0d76677 | |||
| 608e200c0c | |||
| b12bb24d08 | |||
| 7cdd06802d | |||
| 24c862c741 | |||
| 1330b436dc | |||
| 9bd3e7e30a | |||
| 9709d5471b | |||
| e1482a4b11 | |||
| d4c8064cdc | |||
| 9178e06bb3 | |||
| 2c1af458b9 | |||
| 004ccb1af2 |
@@ -169,8 +169,22 @@ def _writeback_edit_plan_config(
|
||||
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
|
||||
merged = dict(current_config)
|
||||
merged["generation_task_id"] = task_id
|
||||
|
||||
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
|
||||
if title_config:
|
||||
old_title_config = merged.get("title_config", {}) or {}
|
||||
old_title_text = (old_title_config.get("text") or "").strip()
|
||||
new_title_text = (title_config.get("text") or "").strip()
|
||||
if old_title_text != new_title_text:
|
||||
# 标题变化,清除旧封面
|
||||
if "cover" in merged:
|
||||
del merged["cover"]
|
||||
logger.info(
|
||||
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
|
||||
plan_id, old_title_text, new_title_text,
|
||||
)
|
||||
merged["title_config"] = title_config
|
||||
|
||||
plan_model.config = merged
|
||||
db.commit()
|
||||
logger.info(
|
||||
|
||||
@@ -15,16 +15,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_asset_repository
|
||||
from app.dependencies import get_asset_repository, get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import _calc_random_start_time
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
@@ -45,6 +54,9 @@ from .schemas import (
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
# 编辑器默认片段时长(秒)
|
||||
_DEFAULT_EDITOR_CLIP_DURATION = 5.0
|
||||
|
||||
|
||||
def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse:
|
||||
"""统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐"""
|
||||
@@ -155,10 +167,7 @@ def list_draft_clips(
|
||||
url_map = _build_asset_url_map(asset_ids, asset_repo)
|
||||
|
||||
return EditorClipListResponse(
|
||||
items=[
|
||||
_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or ""))
|
||||
for c in clips
|
||||
],
|
||||
items=[_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or "")) for c in clips],
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -272,9 +281,7 @@ def split_draft_clip(
|
||||
try:
|
||||
result = plan_svc.split_clip(clip_id, body.split_time)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""]
|
||||
@@ -304,9 +311,7 @@ def merge_draft_clips(
|
||||
try:
|
||||
merged = plan_svc.merge_clips(body.clip_ids)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
asset_id = getattr(merged, "asset_id", "") or ""
|
||||
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
|
||||
return {
|
||||
@@ -354,40 +359,351 @@ def batch_delete_editor_clips(
|
||||
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
|
||||
|
||||
|
||||
|
||||
def _safe_segment_duration(value, default: float) -> float:
|
||||
"""安全地将数据库中的时长值转换为正浮点数.
|
||||
|
||||
处理 None、无效类型、负数、NaN 等异常情况。
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
result = float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
if result != result or result <= 0: # NaN check or non-positive
|
||||
return default
|
||||
return result
|
||||
|
||||
|
||||
def _get_template_segments(
|
||||
template_id: str,
|
||||
tpl_svc: EditTemplateService,
|
||||
db: Session,
|
||||
) -> list[tuple[int, float, float]]:
|
||||
"""获取模板的片段配置(顺序、最短时长、最长时长).
|
||||
|
||||
优先从新模板系统(template_clip_configs)查询,
|
||||
若不存在则回退到旧模板系统(template_segments)。
|
||||
|
||||
Returns:
|
||||
[(segment_order, duration_min, duration_max), ...] 按 order 排序
|
||||
"""
|
||||
# 优先查新模板系统
|
||||
try:
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
if clip_configs:
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(
|
||||
cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION
|
||||
)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
|
||||
|
||||
# 回退到旧模板系统(template_segments表)
|
||||
try:
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = old_repo.list_segments(template_id)
|
||||
if segments:
|
||||
result = []
|
||||
for s in segments:
|
||||
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((s.segment_order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("旧模板系统查询segments失败", exc_info=True)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _recommended_time_conflicts(
|
||||
start: float,
|
||||
duration: float,
|
||||
used: list[tuple[float, float]],
|
||||
) -> bool:
|
||||
"""检查推荐起始时间是否与已使用时间段冲突."""
|
||||
end = start + duration
|
||||
for used_start, used_end in used:
|
||||
if start < used_end and end > used_start:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_mediakit_recommendations(
|
||||
asset_ids: list[str],
|
||||
asset_repo,
|
||||
) -> dict[str, float]:
|
||||
"""调用 MediaKit 视频理解,获取智能选片推荐起始时间.
|
||||
|
||||
尝试让 MediaKit 分析视频内容,返回每个素材的推荐起始时间。
|
||||
任何异常都优雅降级,返回空字典(调用方降级到随机选择)。
|
||||
"""
|
||||
try:
|
||||
client = get_mediakit_client()
|
||||
if not client.is_available:
|
||||
logger.info("MediaKit 未配置,使用随机起始时间")
|
||||
return {}
|
||||
|
||||
storage = get_storage_service()
|
||||
|
||||
video_urls: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
for asset_id in asset_ids[:10]:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if not asset or not getattr(asset, "storage_key", None):
|
||||
continue
|
||||
mime = getattr(asset, "mime_type", "")
|
||||
if not mime.startswith("video/"):
|
||||
continue
|
||||
try:
|
||||
url = storage.get_download_url(asset.storage_key)
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(asset_id)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
if not video_urls:
|
||||
return {}
|
||||
|
||||
prompt = (
|
||||
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
|
||||
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
|
||||
'请严格以JSON数组格式返回,不要包含其他文字:'
|
||||
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
|
||||
)
|
||||
|
||||
contents = client.analyze_videos(
|
||||
video_urls=video_urls,
|
||||
prompt=prompt,
|
||||
level="Economy",
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=15,
|
||||
)
|
||||
|
||||
if not contents:
|
||||
logger.info("MediaKit 分析无结果,降级为随机选择")
|
||||
return {}
|
||||
|
||||
# 按索引映射结果:contents[i] 对应 valid_asset_ids[i]
|
||||
recommendations: dict[str, float] = {}
|
||||
for idx, content_text in enumerate(contents):
|
||||
if idx >= len(valid_asset_ids):
|
||||
break
|
||||
asset_id = valid_asset_ids[idx]
|
||||
if not content_text:
|
||||
continue
|
||||
|
||||
# 尝试从文本中提取 JSON
|
||||
parsed = False
|
||||
# 尝试直接解析
|
||||
try:
|
||||
data = json.loads(content_text.strip())
|
||||
if isinstance(data, list) and data:
|
||||
for item in data:
|
||||
if isinstance(item, dict) and "recommended_start_time" in item:
|
||||
recommendations[asset_id] = float(item["recommended_start_time"])
|
||||
parsed = True
|
||||
break
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 尝试从 markdown 代码块中提取 JSON
|
||||
if not parsed:
|
||||
json_match = re.search(r"\[\s*(\{.*?\})\s*\]", content_text, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
item = json.loads(json_match.group(1))
|
||||
if isinstance(item, dict) and "recommended_start_time" in item:
|
||||
recommendations[asset_id] = float(item["recommended_start_time"])
|
||||
parsed = True
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 尝试正则提取
|
||||
if not parsed:
|
||||
time_match = re.search(
|
||||
r'recommended_start_time["\s:]+([\d.]+)', content_text
|
||||
)
|
||||
if time_match:
|
||||
try:
|
||||
recommendations[asset_id] = float(time_match.group(1))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if recommendations:
|
||||
logger.info("MediaKit 智能选片推荐: %s", recommendations)
|
||||
else:
|
||||
logger.info("MediaKit 结果解析失败,降级为随机选择")
|
||||
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets_editor(
|
||||
template_id: str,
|
||||
body: ClipsFromAssetsRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ClipsFromAssetsResponse:
|
||||
"""从素材批量创建片段"""
|
||||
_, plan_svc = services
|
||||
clips = []
|
||||
for i, asset_id in enumerate(body.asset_ids):
|
||||
try:
|
||||
clip = plan_svc.create_clip(
|
||||
plan_id,
|
||||
clip_type="main",
|
||||
order=body.start_order + i if hasattr(body, "start_order") else i,
|
||||
duration=5.0,
|
||||
asset_id=asset_id,
|
||||
"""从素材批量创建片段(按模板segment配置创建,事务性替换).
|
||||
|
||||
逻辑:
|
||||
1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count)
|
||||
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段
|
||||
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的
|
||||
5. MediaKit 智能选片:第一个使用某素材的片段用推荐起始时间,后续用随机
|
||||
6. 素材时长为 0 或缺失时报 400,不创建无效片段
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
|
||||
# 1. 查询模板 segments
|
||||
segments = _get_template_segments(template_id, tpl_svc, db)
|
||||
if not segments:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="模板没有片段配置,无法创建片段",
|
||||
)
|
||||
|
||||
if not body.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材列表为空,无法创建片段",
|
||||
)
|
||||
|
||||
# 2. 获取素材实际时长(去重查询)
|
||||
unique_asset_ids = list(dict.fromkeys(body.asset_ids))
|
||||
asset_durations: dict[str, float] = {}
|
||||
for asset_id in unique_asset_ids:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if asset and hasattr(asset, "duration"):
|
||||
asset_durations[asset_id] = float(asset.duration or 0.0)
|
||||
|
||||
# 3. 获取 MediaKit 智能选片推荐(保持60s timeout + poll 2s + 15次)
|
||||
mediakit_recommendations = _get_mediakit_recommendations(
|
||||
unique_asset_ids, asset_repo
|
||||
)
|
||||
|
||||
# 4. 在内存中计算所有片段数据
|
||||
asset_first_used: set[str] = set()
|
||||
used_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
clips_data: list[dict] = []
|
||||
|
||||
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
|
||||
# 轮询分配素材
|
||||
asset_id = body.asset_ids[i % len(body.asset_ids)]
|
||||
asset_total = asset_durations.get(asset_id, 0.0)
|
||||
|
||||
# 素材时长为 0 或缺失时无法创建有效片段
|
||||
if asset_total <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"素材 {asset_id} 时长信息缺失或为0,无法创建片段",
|
||||
)
|
||||
clips.append(clip)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
raw_duration = random.uniform(dur_min, dur_max)
|
||||
clip_duration = round(raw_duration, 1)
|
||||
|
||||
# 素材时长不足时缩短 clip duration
|
||||
clip_duration = min(clip_duration, asset_total)
|
||||
|
||||
if clip_duration <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"素材 {asset_id} 时长不足,无法创建有效片段",
|
||||
)
|
||||
|
||||
# 确定起始时间
|
||||
is_first_use = asset_id not in asset_first_used
|
||||
recommended_start = mediakit_recommendations.get(asset_id)
|
||||
|
||||
if (
|
||||
is_first_use
|
||||
and recommended_start is not None
|
||||
and recommended_start + clip_duration <= asset_total
|
||||
and not _recommended_time_conflicts(
|
||||
recommended_start, clip_duration, used_segments.get(asset_id, [])
|
||||
)
|
||||
):
|
||||
start_time = recommended_start
|
||||
logger.info(
|
||||
"使用MediaKit推荐起始时间: asset_id=%s start_time=%.2f duration=%.1f",
|
||||
asset_id,
|
||||
start_time,
|
||||
clip_duration,
|
||||
)
|
||||
else:
|
||||
if is_first_use and recommended_start is not None:
|
||||
logger.info(
|
||||
"MediaKit推荐时间冲突或越界,降级为随机: asset_id=%s recommended=%.2f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
elif not is_first_use:
|
||||
logger.info(
|
||||
"素材%s非首次使用,使用随机起始时间",
|
||||
asset_id,
|
||||
)
|
||||
start_time = _calc_random_start_time(
|
||||
asset_id, clip_duration, asset_durations, used_segments
|
||||
)
|
||||
|
||||
if start_time is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"素材 {asset_id} 时长信息缺失,无法计算起始时间",
|
||||
)
|
||||
|
||||
# 记录已使用时间段
|
||||
used_segments.setdefault(asset_id, []).append(
|
||||
(start_time, start_time + clip_duration)
|
||||
)
|
||||
asset_first_used.add(asset_id)
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
"order": i,
|
||||
"asset_id": asset_id,
|
||||
"start_time": start_time,
|
||||
"duration": clip_duration,
|
||||
"clip_type": body.clip_type or "main",
|
||||
}
|
||||
)
|
||||
|
||||
# 5. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
|
||||
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
logger.info(
|
||||
"模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s",
|
||||
"from-assets按模板创建片段: template_id=%s plan_id=%s segments=%d created=%d by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
len(clips),
|
||||
len(segments),
|
||||
created_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
# 返回事务后查询到的 clip IDs(replace 方法不返回 ID 列表,用 created_count 构造响应)
|
||||
return ClipsFromAssetsResponse(
|
||||
created_count=len(clips),
|
||||
created_count=created_count,
|
||||
plan_id=plan_id,
|
||||
clip_ids=[c.id for c in clips],
|
||||
clip_ids=[], # 事务方法不返回 ID;前端不需要逐个 ID
|
||||
)
|
||||
|
||||
@@ -167,6 +167,7 @@ class ClipsFromAssetsRequest(BaseModel):
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
required_clips_count: Optional[int] = Field(default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量")
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
|
||||
@@ -400,7 +400,7 @@ class EditPlanService:
|
||||
order = clip_item.get("order") or i
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
clip_type=clip_item.get("clip_type", "main"),
|
||||
order=order,
|
||||
asset_id=clip_item.get("asset_id", ""),
|
||||
start_time=clip_item.get("start_time", 0.0),
|
||||
|
||||
@@ -185,15 +185,17 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 2: select material
|
||||
// Step 2: select material (card grid UI)
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..")
|
||||
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中
|
||||
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开
|
||||
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
|
||||
await expect(materialCard).toBeVisible({ timeout: 10_000 })
|
||||
await materialCard.click({ position: { x: 15, y: 15 } })
|
||||
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
|
||||
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
|
||||
@@ -37,14 +37,33 @@ async function loginWithRetry(
|
||||
})
|
||||
}
|
||||
|
||||
async function registerWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password, username, display_name: displayName },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[register] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password, username, display_name: displayName },
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
const reg = await registerWithRetry(request, email, username, PASSWORD, `E2E ${label}`)
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
|
||||
@@ -90,10 +90,21 @@ export async function createClipsFromAssets(
|
||||
templateId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
asset_ids: assetIds,
|
||||
clip_type: clipType,
|
||||
}
|
||||
if (requiredClipsCount !== undefined) {
|
||||
body.required_clips_count = requiredClipsCount
|
||||
}
|
||||
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
body,
|
||||
{ timeout: 60000, signal: opts?.signal },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -114,8 +114,6 @@ export interface EditPlanConfig {
|
||||
auto_subtitles?: boolean
|
||||
/** 是否启用 BGM */
|
||||
bgm?: boolean
|
||||
/** 生成数量 */
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string
|
||||
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
|
||||
|
||||
@@ -15,8 +15,6 @@ import React, { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import { MODE_LIST } from "./constants"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
import MediaPanel from "./components/MediaPanel"
|
||||
import PreviewPlayer from "./components/PreviewPlayer"
|
||||
import TimelinePanel from "./components/TimelinePanel"
|
||||
@@ -79,14 +77,6 @@ const EditingPlanner: React.FC = () => {
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([])
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
|
||||
|
||||
const handleAssetSelect = (ids: string[]) => {
|
||||
setSelectedAssetIds(ids)
|
||||
}
|
||||
|
||||
/* ── 配音素材 ── */
|
||||
const {
|
||||
voiceMaterials,
|
||||
@@ -113,7 +103,6 @@ const EditingPlanner: React.FC = () => {
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId: clipOps.setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
@@ -164,9 +153,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onLoadTemplate={tpl.handleLoadTemplate}
|
||||
onSearchChange={tpl.setSearchQuery}
|
||||
onFilterChange={tpl.setCurrentFilter}
|
||||
mediaAssets={mediaAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
/>
|
||||
|
||||
{/* 中栏 flex-1 */}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* 左侧面板 — V8 原型 1:1 还原
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
* 左侧面板 — 模板列表
|
||||
* 模板编辑器只负责定义模板规则(片段数量、时长范围),不承载素材管理。
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import AssetSelector from "@/components/asset-selector/AssetSelector"
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[]
|
||||
@@ -18,10 +16,6 @@ interface MediaPanelProps {
|
||||
onLoadTemplate: (id: string) => void
|
||||
onSearchChange: (q: string) => void
|
||||
onFilterChange: (f: string) => void
|
||||
// 素材相关
|
||||
mediaAssets?: MediaAsset[]
|
||||
onAssetSelect?: (ids: string[]) => void
|
||||
selectedAssetIds?: string[]
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
@@ -34,113 +28,73 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
onLoadTemplate,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
mediaAssets = [],
|
||||
onAssetSelect,
|
||||
selectedAssetIds = [],
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"templates" | "assets">("templates")
|
||||
|
||||
return (
|
||||
<div className="ep-left-panel">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-left-tabs">
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "templates" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
>
|
||||
📋 模板
|
||||
</button>
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "assets" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("assets")}
|
||||
>
|
||||
📁 素材
|
||||
</button>
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模板 Tab */}
|
||||
{activeTab === "templates" && (
|
||||
<>
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 素材 Tab */}
|
||||
{activeTab === "assets" && (
|
||||
<div className="ep-assets-tab">
|
||||
<AssetSelector
|
||||
assets={mediaAssets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={onAssetSelect}
|
||||
showQualityFilter={false}
|
||||
showBatchSelect={false}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,13 @@ import {
|
||||
type EditingTemplate,
|
||||
type TemplateCategory,
|
||||
} from "@/api/editing-planner"
|
||||
import { getMediaAssets, type MediaAsset } from "@/api/template-editor"
|
||||
import { FILTER_CATEGORIES } from "../../constants"
|
||||
|
||||
/**
|
||||
* 模板列表 + 分类 + 筛选搜索
|
||||
* 模板编辑器只负责模板规则定义,不再加载/管理业务素材。
|
||||
*/
|
||||
export function useTemplateList(
|
||||
setMediaAssets: (assets: MediaAsset[]) => void,
|
||||
initialTemplateId: string | null,
|
||||
) {
|
||||
export function useTemplateList(initialTemplateId: string | null) {
|
||||
const [templates, setTemplates] = useState<EditingTemplate[]>([])
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false)
|
||||
@@ -24,26 +21,20 @@ export function useTemplateList(
|
||||
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(initialTemplateId)
|
||||
|
||||
/**
|
||||
* 并行加载模板列表、分类、素材库
|
||||
* 三个接口无依赖关系,用 Promise.all 并发
|
||||
* 并行加载模板列表和分类(两者无依赖关系)
|
||||
*/
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoadingTemplates(true)
|
||||
try {
|
||||
const [tpls, cats, assets] = await Promise.all([
|
||||
getEditingTemplates(),
|
||||
getTemplateCategories(),
|
||||
getMediaAssets(),
|
||||
])
|
||||
const [tpls, cats] = await Promise.all([getEditingTemplates(), getTemplateCategories()])
|
||||
setTemplates(tpls)
|
||||
setCategories(cats)
|
||||
setMediaAssets(assets)
|
||||
} catch {
|
||||
message.error("加载模板失败")
|
||||
} finally {
|
||||
setLoadingTemplates(false)
|
||||
}
|
||||
}, [setMediaAssets])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, type Dispatch, type SetStateAction } from "react"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type {
|
||||
ClipData,
|
||||
WatermarkConfig,
|
||||
@@ -24,7 +24,6 @@ interface UseTemplateManagementParams {
|
||||
resetClips: (clips: ClipData[]) => void
|
||||
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
|
||||
setSelectedClipId: (id: string | null) => void
|
||||
setMediaAssets: (assets: MediaAsset[]) => void
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
@@ -52,7 +51,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
@@ -86,7 +84,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filteredTemplates,
|
||||
currentTemplate,
|
||||
loadTemplates,
|
||||
} = useTemplateList(setMediaAssets, urlTemplateId || null)
|
||||
} = useTemplateList(urlTemplateId || null)
|
||||
|
||||
/* ── 保存 ── */
|
||||
const {
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
*
|
||||
* 架构:
|
||||
* - Step4+ 右侧预览面板使用 FrontendPreviewPlayer 实时播放素材片段
|
||||
* - 标题样式编辑时 CSS 层实时叠加预览,所见即所得
|
||||
* - 步骤 4-6 右侧显示 FrontendPreviewPlayer 实时预览
|
||||
* - 步骤 7 右侧内联播放生成的最终视频
|
||||
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
|
||||
*/
|
||||
import React, { useMemo, useState, useEffect, useRef } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
@@ -20,9 +20,8 @@ import {
|
||||
calculateTotalVideoDuration,
|
||||
estimateTotalVideoDuration,
|
||||
} from "./utils/calculateTotalVideoDuration"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
@@ -64,8 +63,6 @@ const GeneratePage: React.FC = () => {
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
@@ -73,14 +70,12 @@ const GeneratePage: React.FC = () => {
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
previewTaskId,
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
} = formState
|
||||
|
||||
/* ── 标题样式回调 ── */
|
||||
@@ -89,7 +84,7 @@ const GeneratePage: React.FC = () => {
|
||||
onTitleSettingsChange: setTitleSettings,
|
||||
})
|
||||
|
||||
/* ── 配音预览音频(TTS 试听)── */
|
||||
/* ── 配音素材库(TTS 试听)── */
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
@@ -99,30 +94,24 @@ const GeneratePage: React.FC = () => {
|
||||
const ttsAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 如果 selectedVoice 是已上传的配音素材,直接用 file_url
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
if (voiceAsset?.file_url) {
|
||||
setPreviewVoiceAudioUrl(voiceAsset.file_url)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有选中的 voice 或标题,跳过
|
||||
const voiceId = selectedClonedVoice || selectedVoice
|
||||
if (!voiceId || !titleSettings.title) {
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 预设音色 / 克隆音色 → 调 TTS 合成
|
||||
ttsAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
ttsAbortRef.current = controller
|
||||
let cancelled = false
|
||||
|
||||
previewTts({
|
||||
text: titleSettings.title,
|
||||
voice_id: voiceId,
|
||||
})
|
||||
previewTts({ text: titleSettings.title, voice_id: voiceId })
|
||||
.then((res) => {
|
||||
if (!cancelled && res.audio_url) {
|
||||
setPreviewVoiceAudioUrl(res.audio_url)
|
||||
@@ -221,7 +210,6 @@ const GeneratePage: React.FC = () => {
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
|
||||
previewTaskId,
|
||||
bgmConfig,
|
||||
@@ -241,7 +229,7 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
|
||||
<div className="xx-generate-layout">
|
||||
<div className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}`}>
|
||||
{/* ════ 左侧:表单区 ════ */}
|
||||
<div className="xx-generate-form">
|
||||
<GenerateStepContent
|
||||
@@ -277,6 +265,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
onServerClipsChange={setServerClips}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
@@ -286,8 +275,6 @@ const GeneratePage: React.FC = () => {
|
||||
hasProcessing={hasProcessing}
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneModalOpenChange={setCloneModalOpen}
|
||||
generateCount={generateCount}
|
||||
onGenerateCountChange={setGenerateCount}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
@@ -309,14 +296,15 @@ const GeneratePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:预览 + 结果 ════ */}
|
||||
{/* ════ 右侧:步骤 4-6 实时预览,步骤 7 最终视频 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{currentStep >= 4 && !!currentTemplate && (
|
||||
{currentStep >= 4 && currentStep <= 6 && !!currentTemplate && (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
titleSettings={{
|
||||
title: titleSettings.title,
|
||||
@@ -331,48 +319,34 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentStep >= 6 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
progress={progress}
|
||||
generateError={generateError}
|
||||
generatedVideos={generatedVideos}
|
||||
onVideoPreview={(video) => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onGoToLibrary={() => navigate("/app/products")}
|
||||
/>
|
||||
{currentStep === 7 && generated && generatedVideos.length > 0 && (
|
||||
<div className="xx-inline-video-player">
|
||||
<video
|
||||
src={generatedVideos[0].download_url || generatedVideos[0].file_url}
|
||||
controls
|
||||
autoPlay
|
||||
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
|
||||
poster={generatedVideos[0].thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
|
||||
🔗 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
📁 前往成片库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频预览弹窗 */}
|
||||
<Modal
|
||||
className="xx-preview-modal"
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
footer={null}
|
||||
width="80vw"
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
{previewVideo && (
|
||||
<div className="xx-preview-modal-content">
|
||||
<video
|
||||
src={previewVideo.download_url || previewVideo.file_url}
|
||||
controls
|
||||
autoPlay
|
||||
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain" }}
|
||||
poster={previewVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 音色克隆弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
|
||||
|
||||
@@ -24,6 +25,7 @@ interface FrontendPreviewPlayerProps {
|
||||
template: EditingTemplate | null
|
||||
videoRatio: string
|
||||
ready: boolean
|
||||
serverClips?: EditPlanClip[]
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
title: string
|
||||
@@ -50,9 +52,31 @@ function formatTime(seconds: number): string {
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
template: EditingTemplate | null,
|
||||
serverClips?: EditPlanClip[],
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length) return []
|
||||
|
||||
// Build asset lookup map
|
||||
const assetMap = new Map(assets.map((a) => [a.id, a]))
|
||||
|
||||
// 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致
|
||||
if (serverClips && serverClips.length > 0) {
|
||||
const segments: PlaybackSegment[] = []
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const startTime = clip.start_time || 0
|
||||
const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration)
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: 本地构建片段(与旧行为一致)
|
||||
const templateSegments = template?.segments || []
|
||||
const segments: PlaybackSegment[] = []
|
||||
|
||||
@@ -78,10 +102,14 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
template,
|
||||
videoRatio,
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips),
|
||||
[assets, template, serverClips],
|
||||
)
|
||||
|
||||
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
|
||||
const TITLE_MARGIN_TOP = 120
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
@@ -55,6 +56,7 @@ export interface GenerateStepContentProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
@@ -65,8 +67,6 @@ export interface GenerateStepContentProps {
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
/* 生成 */
|
||||
generateCount: number
|
||||
onGenerateCountChange: (n: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
@@ -116,11 +116,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration,
|
||||
onServerClipsChange,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
clonedVoices,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
@@ -159,6 +158,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
templateSegments={templateSegments}
|
||||
onServerClipsChange={onServerClipsChange}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -226,8 +226,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
coverSettings={coverSettings}
|
||||
generateCount={generateCount}
|
||||
onGenerateCountChange={onGenerateCountChange}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useStep2Materials } from "../hooks/useStep2Materials"
|
||||
import MaterialModeTabs from "./material/MaterialModeTabs"
|
||||
import ManualMaterialList from "./material/ManualMaterialList"
|
||||
@@ -20,6 +21,8 @@ interface Step2MaterialSelectProps {
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
@@ -24,8 +24,6 @@ interface Step7ConfirmGenerateProps {
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
@@ -42,9 +40,6 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
@@ -65,10 +60,6 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
title={title}
|
||||
voiceName={voiceName}
|
||||
coverSummary={coverSummary}
|
||||
generateCount={generateCount}
|
||||
generating={generating}
|
||||
onDecrement={handleDecrement}
|
||||
onIncrement={handleIncrement}
|
||||
/>
|
||||
<GenerationStatus
|
||||
generating={generating}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 手动选择素材列表
|
||||
* 手动选择素材列表 — 竖屏 9:16 卡片网格
|
||||
* 交互:默认显示封面,点击播放按钮播放,播放中隐藏按钮,点击视频区域暂停
|
||||
*/
|
||||
import React, { useRef, useCallback } from "react"
|
||||
import React, { useRef, useState, useCallback } from "react"
|
||||
import { Typography } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
@@ -14,39 +15,241 @@ interface ManualMaterialListProps {
|
||||
onToggle: (materialId: string) => void
|
||||
}
|
||||
|
||||
/** 秒数格式化为 mm:ss */
|
||||
const fmtDuration = (seconds?: number): string => {
|
||||
if (!seconds && seconds !== 0) return "--:--"
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 单个素材卡片 */
|
||||
const MaterialCard: React.FC<{
|
||||
asset: AssetItem
|
||||
checked: boolean
|
||||
onToggle: () => void
|
||||
}> = ({ asset, checked, onToggle }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const isVideo = asset.mime_type?.startsWith("video/") ?? false
|
||||
const thumbSrc = asset.thumbnail_url || undefined
|
||||
|
||||
const handlePlayToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const video = videoRef.current
|
||||
if (!video || !isVideo) return
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
setIsPlaying(true)
|
||||
}
|
||||
},
|
||||
[isPlaying, isVideo],
|
||||
)
|
||||
|
||||
const handleVideoEnded = useCallback(() => {
|
||||
setIsPlaying(false)
|
||||
}, [])
|
||||
|
||||
const handleCardClick = useCallback(() => {
|
||||
// 如果视频正在播放,点击卡片空白区域暂停视频
|
||||
if (isPlaying) {
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
onToggle()
|
||||
}, [isPlaying, onToggle])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="material-card"
|
||||
onClick={handleCardClick}
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "9 / 16",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
border: checked ? "2px solid var(--primary-color, #4f46e5)" : "2px solid transparent",
|
||||
boxShadow: checked ? "0 0 0 2px rgba(79, 70, 229, 0.2)" : "0 1px 3px rgba(0, 0, 0, 0.1)",
|
||||
background: "#1e293b",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{/* 视频元素 */}
|
||||
{isVideo && asset.file_url ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={asset.file_url}
|
||||
poster={thumbSrc}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onEnded={handleVideoEnded}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
) : thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={asset.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #334155, #1e293b)",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 28,
|
||||
}}
|
||||
>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部渐变遮罩 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
background: "linear-gradient(0deg, rgba(0,0,0,0.6) 0%, transparent 100%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 播放按钮 — 播放中隐藏 */}
|
||||
{!isPlaying && (
|
||||
<div
|
||||
onClick={handlePlayToggle}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(99, 102, 241, 0.85)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 3,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件名(左下角) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
left: 6,
|
||||
right: 50,
|
||||
color: "white",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.5)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{asset.name}
|
||||
</div>
|
||||
|
||||
{/* 时长(右下角) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
color: "white",
|
||||
padding: "1px 5px",
|
||||
borderRadius: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{fmtDuration(asset.duration)}
|
||||
</div>
|
||||
|
||||
{/* 选中勾选标记(左上角) */}
|
||||
{checked && (
|
||||
<div
|
||||
data-testid="material-card-check"
|
||||
aria-label="已选中"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
left: 6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-color, #4f46e5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
materials,
|
||||
materialsLoading,
|
||||
selectedMaterials,
|
||||
onToggle,
|
||||
}) => {
|
||||
// 追踪当前正在播放的视频元素,确保同时只有一个视频播放
|
||||
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget
|
||||
// 暂停之前正在播放的视频(检查是否仍在 DOM 中)
|
||||
if (
|
||||
activeVideoRef.current &&
|
||||
activeVideoRef.current !== video &&
|
||||
document.body.contains(activeVideoRef.current)
|
||||
) {
|
||||
activeVideoRef.current.pause()
|
||||
activeVideoRef.current.currentTime = 0
|
||||
}
|
||||
activeVideoRef.current = video
|
||||
video.play().catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleVideoMouseLeave = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
if (activeVideoRef.current === video) {
|
||||
activeVideoRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
@@ -56,145 +259,21 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
暂无素材,请先在视频库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.items.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id)
|
||||
const isVideo = m.mime_type?.startsWith("video/") ?? false
|
||||
const thumbSrc = m.thumbnail_url || undefined
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(m.id)}
|
||||
style={{
|
||||
accentColor: "var(--primary-color, #4f46e5)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{/* 缩略图预览 48×48 */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
background: "#e2e8f0",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isVideo && m.file_url ? (
|
||||
<video
|
||||
src={m.file_url}
|
||||
poster={m.thumbnail_url || undefined}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
onMouseEnter={handleVideoMouseEnter}
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
) : thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={m.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.nextElementSibling as HTMLElement | null
|
||||
if (fallback) fallback.style.display = "flex"
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!thumbSrc && !isVideo && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
🎵
|
||||
</span>
|
||||
)}
|
||||
{!thumbSrc && isVideo && !m.file_url && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</span>
|
||||
)}
|
||||
{/* img onError 时显示的 fallback(初始隐藏) */}
|
||||
{thumbSrc && !(isVideo && m.file_url) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "none",
|
||||
}}
|
||||
>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{m.mime_type?.split("/")?.[1]?.toUpperCase() ?? "FILE"}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{materials.items.map((asset) => (
|
||||
<MaterialCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
checked={selectedMaterials.includes(asset.id)}
|
||||
onToggle={() => onToggle(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react"
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
|
||||
interface SummaryCardProps {
|
||||
templateName: string
|
||||
@@ -7,10 +6,6 @@ interface SummaryCardProps {
|
||||
title: string
|
||||
voiceName: string
|
||||
coverSummary: string
|
||||
generateCount: number
|
||||
generating: boolean
|
||||
onDecrement: () => void
|
||||
onIncrement: () => void
|
||||
}
|
||||
|
||||
const SummaryCard: React.FC<SummaryCardProps> = ({
|
||||
@@ -19,10 +14,6 @@ const SummaryCard: React.FC<SummaryCardProps> = ({
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
generating,
|
||||
onDecrement,
|
||||
onIncrement,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-summary-card">
|
||||
@@ -46,29 +37,6 @@ const SummaryCard: React.FC<SummaryCardProps> = ({
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={onDecrement}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={onIncrement}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,14 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.xx-generate-layout.full-width {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-generate-layout.full-width .xx-generate-right-col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
左侧表单区 generate-form
|
||||
============================================================ */
|
||||
@@ -186,16 +194,16 @@
|
||||
============================================================ */
|
||||
.xx-choice-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-choice-item {
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
text-align: center;
|
||||
@@ -211,18 +219,20 @@
|
||||
}
|
||||
|
||||
.xx-choice-thumb {
|
||||
height: 60px;
|
||||
width: 33%;
|
||||
max-width: 52px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
margin: 0 auto 4px;
|
||||
}
|
||||
|
||||
.xx-choice-item h4 {
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -230,7 +240,7 @@
|
||||
|
||||
.xx-choice-item p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@@ -1055,7 +1065,7 @@
|
||||
}
|
||||
|
||||
.xx-choice-list {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.xx-voice-choice-list {
|
||||
@@ -1280,53 +1290,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 生成数量步进器 ── */
|
||||
.xx-count-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary-400, #818cf8);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: var(--primary-50, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.xx-count-stepper-value {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-count-stepper-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ── 素材选择模式切换 Tab ── */
|
||||
.xx-material-mode-tabs {
|
||||
display: flex;
|
||||
@@ -2529,6 +2492,21 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── 内联视频播放器(右侧) ── */
|
||||
.xx-inline-video-player {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.xx-inline-video-player video {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.xx-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface UseGenerateVideoProps {
|
||||
duration: number
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */
|
||||
sourceEditPlanId?: string | null
|
||||
/** 预览任务 ID(由 useStep6Cover 创建后写入,供 confirmGeneration 复用预览产物) */
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../../constants"
|
||||
@@ -47,6 +47,10 @@ export interface GenerateFormState {
|
||||
smartSelectedIds: string[]
|
||||
setSmartSelectedIds: (ids: string[]) => void
|
||||
|
||||
/* 服务端片段(/clips/from-assets 创建后获取) */
|
||||
serverClips: EditPlanClip[]
|
||||
setServerClips: (clips: EditPlanClip[]) => void
|
||||
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
@@ -68,10 +72,6 @@ export interface GenerateFormState {
|
||||
cloneModalOpen: boolean
|
||||
setCloneModalOpen: (open: boolean) => void
|
||||
|
||||
/* 生成数量 */
|
||||
generateCount: number
|
||||
setGenerateCount: (n: number) => void
|
||||
|
||||
/* 高级设置 */
|
||||
videoRatio: string
|
||||
duration: number
|
||||
@@ -91,12 +91,6 @@ export interface GenerateFormState {
|
||||
*/
|
||||
sourceEditPlanId: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
previewModalOpen: boolean
|
||||
setPreviewModalOpen: (open: boolean) => void
|
||||
|
||||
/** 预览任务 ID(由 useStep6Cover 创建后写入,供 useGenerateVideo 复用) */
|
||||
previewTaskId: string | null
|
||||
setPreviewTaskId: (id: string | null) => void
|
||||
@@ -127,6 +121,9 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 服务端片段(供预览播放器使用)── */
|
||||
const [serverClips, setServerClips] = useState<EditPlanClip[]>([])
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
@@ -155,9 +152,6 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
/* ── 克隆声音弹窗 ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("9:16")
|
||||
const [duration] = useState(30)
|
||||
@@ -165,10 +159,6 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [autoSubtitles] = useState(true)
|
||||
const [bgm] = useState(true)
|
||||
|
||||
/* ── 预览弹窗 ── */
|
||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
/* ── 预览任务 ID(useStep6Cover 创建预览时写入,useGenerateVideo 复用) ── */
|
||||
// 持久化到 localStorage,key 按 editPlanId/templateId 区分,刷新页面后可恢复
|
||||
const previewStorageKey = editPlanId
|
||||
@@ -214,6 +204,8 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
@@ -227,8 +219,6 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
presetVoices,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
generateCount,
|
||||
setGenerateCount,
|
||||
videoRatio,
|
||||
duration,
|
||||
style,
|
||||
@@ -237,10 +227,6 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
previewTaskId,
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { type GeneratedVideo, getEditPlanClips } from "@/api/template-editor"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
@@ -13,6 +13,14 @@ import { validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { calculateResolution } from "../utils/calculateResolution"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
/**
|
||||
* 片段创建轮询:最多等 30 秒。
|
||||
* useStep2Materials 在用户选素材时(debounce 800ms)已调用 from-assets,
|
||||
* 这里只做轻量校验,确认片段已落库就放行,不死等 ready。
|
||||
*/
|
||||
const CLIPS_CREATED_MAX_WAIT_MS = 30_000
|
||||
const CLIPS_CREATED_POLL_INTERVAL_MS = 1_500
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const { selectedTemplate, onGenerationSuccess } = props
|
||||
|
||||
@@ -29,7 +37,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
setGeneratedVideos(videos as GeneratedVideo[])
|
||||
// 生成成功后清除持久化的预览状态,避免下次进入复用旧任务
|
||||
onGenerationSuccess?.()
|
||||
},
|
||||
[onGenerationSuccess],
|
||||
@@ -45,6 +52,28 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
onFailed: handleFailed,
|
||||
})
|
||||
|
||||
/**
|
||||
* 轮询确认片段已被创建。
|
||||
* useStep2Materials 在用户选素材时已调用 from-assets 创建片段,
|
||||
* 这里只要该 plan 下存在任意 clips(无论 ready/pending),就立即放行 generate。
|
||||
* 片段是否 ready 由后端生成流程自行等待/兜底,前端不死等 ready,避免:
|
||||
* 1. MediaKit 失败时前端卡死无法生成
|
||||
* 2. E2E/弱网环境下 generate 请求迟迟不发出
|
||||
* 30s 仍查不到片段也放行,由后端返回明确错误。
|
||||
*/
|
||||
const waitForClipsCreated = useCallback(async (templateId: string): Promise<void> => {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < CLIPS_CREATED_MAX_WAIT_MS) {
|
||||
try {
|
||||
const clipList = await getEditPlanClips(templateId, { limit: 500 })
|
||||
if (clipList.items.length > 0) return
|
||||
} catch {
|
||||
// 单次查询失败不终止,继续轮询
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, CLIPS_CREATED_POLL_INTERVAL_MS))
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
@@ -60,7 +89,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 解析分辨率(共享工具函数)
|
||||
const { width: outputWidth, height: outputHeight } = calculateResolution(
|
||||
props.videoRatio || "9:16",
|
||||
)
|
||||
@@ -68,14 +96,25 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
|
||||
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
|
||||
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用。
|
||||
// 这里轻量确认片段已落库,再发 generate;最多等 30s,超时也放行。
|
||||
// 片段 ready 状态由后端生成流程兜底,前端不死等。
|
||||
if (assetIds.length > 0 && selectedTemplate) {
|
||||
const hide = message.loading("正在准备素材片段...", 0)
|
||||
try {
|
||||
await waitForClipsCreated(selectedTemplate)
|
||||
} finally {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
|
||||
const voiceLibraryId =
|
||||
props.voiceMode === "clone" ? props.selectedClonedVoice || "" : props.selectedVoice || ""
|
||||
props.voiceMode === "clone"
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
|
||||
// 创建生成任务(服务器渲染)
|
||||
const taskResp = await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
@@ -85,10 +124,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
// 配音:优先用 voice_library_id(配音素材库 asset),兜底 voice_ids
|
||||
...(voiceLibraryId ? { voice_library_id: voiceLibraryId } : {}),
|
||||
voice_library_id: voiceLibraryId,
|
||||
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
|
||||
// BGM 配置:受 bgm 开关控制,enabled=false 时也显式传覆盖模板 BGM
|
||||
bgm_config: {
|
||||
enabled: props.bgm !== false,
|
||||
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
|
||||
@@ -124,20 +161,17 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
}, [props, clearTimer, startPolling, selectedTemplate, waitForClipsCreated])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
generate()
|
||||
}, [generate])
|
||||
|
||||
/* 清除错误 */
|
||||
const dismissError = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
}, [])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const download = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
@@ -158,7 +192,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const share = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
@@ -172,19 +205,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}, [generatedVideos])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
generating,
|
||||
progress,
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
// 操作
|
||||
generate,
|
||||
retry,
|
||||
dismissError,
|
||||
download,
|
||||
share,
|
||||
// 工具
|
||||
getGenerationPhase,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { message } from "antd"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { updateEditPlanClips } from "@/api/template-editor"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { buildClipsFromAssets } from "../utils/buildClipsFromAssets"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
@@ -23,6 +23,8 @@ interface UseStep2MaterialsProps {
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -34,6 +36,7 @@ export function useStep2Materials({
|
||||
onSmartSelectedIdsChange,
|
||||
selectedTemplate,
|
||||
templateSegments,
|
||||
onServerClipsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
@@ -72,23 +75,30 @@ export function useStep2Materials({
|
||||
scheduleSave({ asset_ids: ids }, 500)
|
||||
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
|
||||
|
||||
/* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ── */
|
||||
/* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ──
|
||||
* 调用后端 POST /clips/from-assets,由后端处理:
|
||||
* - 素材不够时同一素材切多个片段
|
||||
* - 随机 start_time,不重复
|
||||
* - required_clips_count 保证片段数与模板 segments 一致
|
||||
* 先 PUT /clips(空数组)清空旧片段,再调用 from-assets 创建新片段
|
||||
*/
|
||||
const clipsTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const clipsAbortRef = useRef<AbortController | null>(null)
|
||||
const templateSegmentsRef = useRef(templateSegments)
|
||||
templateSegmentsRef.current = templateSegments
|
||||
const selectedTemplateRef = useRef(selectedTemplate)
|
||||
selectedTemplateRef.current = selectedTemplate
|
||||
const materialsRef = useRef(materials)
|
||||
materialsRef.current = materials
|
||||
const smartMatchedRef = useRef<AssetItem[]>(smartMatch.smartMatchedResults)
|
||||
smartMatchedRef.current = smartMatch.smartMatchedResults
|
||||
const onServerClipsChangeRef = useRef(onServerClipsChange)
|
||||
onServerClipsChangeRef.current = onServerClipsChange
|
||||
|
||||
useEffect(() => {
|
||||
const tid = selectedTemplateRef.current
|
||||
if (!tid) return
|
||||
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
if (!ids.length) return
|
||||
if (!ids.length) {
|
||||
onServerClipsChangeRef.current?.([])
|
||||
return
|
||||
}
|
||||
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
clipsTimerRef.current = setTimeout(async () => {
|
||||
@@ -97,20 +107,34 @@ export function useStep2Materials({
|
||||
const controller = new AbortController()
|
||||
clipsAbortRef.current = controller
|
||||
|
||||
const clips = buildClipsFromAssets({
|
||||
selectedIds: ids,
|
||||
materials: materialsRef.current.items,
|
||||
smartMatchedAssets: smartMatchedRef.current,
|
||||
templateSegments: templateSegmentsRef.current || [],
|
||||
})
|
||||
const segs = templateSegmentsRef.current || []
|
||||
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
|
||||
|
||||
try {
|
||||
await updateEditPlanClips(tid, clips, controller.signal)
|
||||
// 1. 清空旧片段
|
||||
await updateEditPlanClips(tid, [], controller.signal)
|
||||
// 2. 调用后端 from-assets 接口创建片段(60s 超时,与后端 MediaKit 一致)
|
||||
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
|
||||
const clipList = await getEditPlanClips(tid, { limit: 500 })
|
||||
const readyClips = clipList.items
|
||||
.filter((c) => c.status === "ready")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
onServerClipsChangeRef.current?.(readyClips)
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name !== "CanceledError" && name !== "AbortError") {
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
// 用户切换素材导致的主动取消,静默
|
||||
if (name === "CanceledError" || name === "AbortError") return
|
||||
// from-assets 60s 超时(MediaKit 智能选片未完成)
|
||||
const code = (err as { code?: string })?.code
|
||||
if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) {
|
||||
console.warn("[useStep2Materials] 智能选片超时:", err)
|
||||
message.error("智能选片超时,请重试")
|
||||
return
|
||||
}
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
|
||||
@@ -178,13 +178,15 @@ export function useStep6Cover({
|
||||
previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
|
||||
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
|
||||
const previewVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || "" : selectedVoice || ""
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || 30,
|
||||
// 配音:voice_library_id 是配音素材库 asset ID(用户上传的音频或 AI 配音)
|
||||
...(previewVoiceLibraryId ? { voice_library_id: previewVoiceLibraryId } : {}),
|
||||
// 配音:始终传递 voice_library_id,确保后端能正确接收
|
||||
voice_library_id: previewVoiceLibraryId,
|
||||
// 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
|
||||
...(selectedVoice && !previewVoiceLibraryId ? { voice_ids: [selectedVoice] } : {}),
|
||||
// BGM 配置:受 bgm 开关控制
|
||||
bgm_config: {
|
||||
enabled: bgm !== false,
|
||||
|
||||
@@ -25,8 +25,6 @@ interface UseStep7GenerateProps {
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generateCount: number
|
||||
onGenerateCountChange: (count: number) => void
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
@@ -47,8 +45,6 @@ export function useStep7Generate({
|
||||
presetVoices: _presetVoices,
|
||||
clonedVoices: _clonedVoices,
|
||||
coverSettings,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
@@ -90,14 +86,6 @@ export function useStep7Generate({
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
const handleDecrement = () => {
|
||||
onGenerateCountChange(Math.max(1, generateCount - 1))
|
||||
}
|
||||
|
||||
const handleIncrement = () => {
|
||||
onGenerateCountChange(Math.min(10, generateCount + 1))
|
||||
}
|
||||
|
||||
const handleScrollToPreview = () => {
|
||||
const el = document.querySelector(".xx-preview-section")
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
@@ -109,9 +97,6 @@ export function useStep7Generate({
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generateCount,
|
||||
handleDecrement,
|
||||
handleIncrement,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* 将选中素材 + 模板 segments 构建为 edit_plan_clips 写入数据。
|
||||
*
|
||||
* 逻辑必须与 FrontendPreviewPlayer.tsx 中 buildPlaybackSegments 完全一致:
|
||||
* assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
* tplSeg = templateSegments[i] || lastSegment
|
||||
* segDuration = clamp(assetDuration, tplSeg.duration_min, tplSeg.duration_max)
|
||||
* start_time = 0
|
||||
* // 关键:预览播放器中 endTime = min(startTime + segDuration, assetDuration)
|
||||
* // 因此 clips.duration 也必须用 min(segDuration, assetDuration) 截断,
|
||||
* // 避免素材实际时长比 clamp 后的 segDuration 短时,Worker 尝试读取不存在的片段
|
||||
* duration = min(segDuration, assetDuration)
|
||||
*
|
||||
* 预览播放器(Canvas 实时预览)直接在内存中构建 segments 播放,不读 edit_plan_clips;
|
||||
* 本函数产出的 clips 写入 DB 后由 Worker 渲染。两边用完全相同的时长计算,
|
||||
* 保证用户在编辑过程中看到的预览与最终生成视频一致。
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClipInput } from "@/api/template-editor"
|
||||
|
||||
interface BuildClipsOptions {
|
||||
/** 选中的素材 ID 列表(按选择顺序) */
|
||||
selectedIds: string[]
|
||||
/** 已加载的素材列表(用于查 duration) */
|
||||
materials: AssetItem[]
|
||||
/** 智能匹配返回的素材(auto 模式下可能不在 materials 列表中) */
|
||||
smartMatchedAssets?: AssetItem[]
|
||||
/** 模板 segments */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
export function buildClipsFromAssets({
|
||||
selectedIds,
|
||||
materials,
|
||||
smartMatchedAssets = [],
|
||||
templateSegments = [],
|
||||
}: BuildClipsOptions): EditPlanClipInput[] {
|
||||
if (!selectedIds.length) return []
|
||||
|
||||
// 合并两个素材来源,建立 id → asset 索引
|
||||
const assetMap = new Map<string, AssetItem>()
|
||||
for (const a of materials) assetMap.set(a.id, a)
|
||||
for (const a of smartMatchedAssets) assetMap.set(a.id, a)
|
||||
|
||||
const lastSeg = templateSegments[templateSegments.length - 1]
|
||||
|
||||
return selectedIds.map((assetId, i) => {
|
||||
const asset = assetMap.get(assetId)
|
||||
const assetDuration = asset?.duration || asset?.metadata?.duration || 30
|
||||
|
||||
const tplSeg = templateSegments[i] || lastSeg
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
// 与 FrontendPreviewPlayer.buildPlaybackSegments 中
|
||||
// endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
// 保持一致:duration 不能超过素材实际时长
|
||||
const duration = Math.min(segDuration, assetDuration)
|
||||
|
||||
return {
|
||||
asset_id: assetId,
|
||||
start_time: 0,
|
||||
duration,
|
||||
order: i,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -598,7 +598,7 @@ class RenderAdapter:
|
||||
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
|
||||
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=""
|
||||
str(result.output_path), plan_id, task_id=job_id, num_frames=3, title_text=""
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
|
||||
@@ -278,6 +278,7 @@ def extract_and_upload_cover_frames(
|
||||
video_path: str,
|
||||
plan_id: str,
|
||||
*,
|
||||
task_id: str = "",
|
||||
num_frames: int = 3,
|
||||
title_text: str = "",
|
||||
title_color: str = "#ffffff",
|
||||
@@ -291,6 +292,7 @@ def extract_and_upload_cover_frames(
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||||
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
|
||||
num_frames: 抽取帧数(默认 3)
|
||||
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
|
||||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||||
@@ -338,7 +340,7 @@ def extract_and_upload_cover_frames(
|
||||
font_size=title_font_size,
|
||||
)
|
||||
|
||||
storage_key = f"covers/{plan_id}/mediakit_frame_{i}.jpg"
|
||||
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
|
||||
url = upload_to_oss(tmp.name, storage_key)
|
||||
if url:
|
||||
seek_time = frame.get("timestamp", 0.0)
|
||||
@@ -377,7 +379,7 @@ def extract_and_upload_cover_frames(
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
|
||||
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if url:
|
||||
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
|
||||
|
||||
@@ -26,15 +26,26 @@ TITLE_MARGIN_SIDE = 40
|
||||
|
||||
# 字体名称映射:前端中文字体名 → 服务器实际注册名(ffmpeg/ASS 通过注册名匹配字体)
|
||||
FONT_NAME_MAP: dict[str, str] = {
|
||||
"思源黑体": "Noto Sans CJK SC",
|
||||
"思源黑体": "Noto Sans SC",
|
||||
"思源宋体": "Noto Serif CJK SC",
|
||||
"苹方": "Noto Sans CJK SC",
|
||||
"PingFang": "Noto Sans CJK SC",
|
||||
"微软雅黑": "Noto Sans CJK SC",
|
||||
"苹方": "Noto Sans SC",
|
||||
"PingFang": "Noto Sans SC",
|
||||
"微软雅黑": "Noto Sans SC",
|
||||
"楷体": "Noto Serif CJK SC",
|
||||
"华康俪金黑": "Noto Sans CJK SC",
|
||||
"华康俪金黑": "Noto Sans SC",
|
||||
}
|
||||
|
||||
# ASS Fontsize 是字体 em-square 高度(含 Latin 升降部留白),
|
||||
# 中文字符实际只占声明字号的约 65%~75%;浏览器 CSS font-size 让中文字符占满声明高度。
|
||||
# 为让成片中文字高与前端 CSS 预览一致,写入 ASS 时对字号乘以补偿系数。
|
||||
# font_size=89 → ASS Fontsize=round(89*1.35)=120,实际中文字高约 78~85px。
|
||||
ASS_FONTSIZE_COMPENSATION = 1.35
|
||||
|
||||
|
||||
def _compensate_ass_fontsize(font_size: int) -> int:
|
||||
"""将 CSS 语义字号换算为 ASS Fontsize,补偿中文字符在 em-square 中的留白。"""
|
||||
return max(1, round(font_size * ASS_FONTSIZE_COMPENSATION))
|
||||
|
||||
|
||||
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -137,8 +148,11 @@ def build_ass_style(
|
||||
# Shadow 深度:shadow_offset[1] 作为纵向偏移
|
||||
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
|
||||
|
||||
# 写入 ASS Style 时对字号做补偿,使成片中文字高与前端 CSS 预览一致
|
||||
ass_font_size = _compensate_ass_fontsize(font_size)
|
||||
|
||||
return (
|
||||
f"Style: {style_name},{actual_font},{font_size},{primary_color},"
|
||||
f"Style: {style_name},{actual_font},{ass_font_size},{primary_color},"
|
||||
f"&H000000FF,{outline_color},{back_color},"
|
||||
f"{bold_val},{italic_val},0,0,100,100,0,0,"
|
||||
f"1,{outline_width},{shadow_depth},{alignment},"
|
||||
@@ -191,7 +205,6 @@ def format_ass_time(seconds: float) -> str:
|
||||
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _wrap_title_text(
|
||||
text: str,
|
||||
video_width: int,
|
||||
@@ -211,6 +224,9 @@ def _wrap_title_text(
|
||||
if available_width <= 0:
|
||||
return text
|
||||
|
||||
# 换行计算使用原始 font_size,与 CSS 预览一致;1.35x 补偿仅用于 ASS Fontsize 渲染
|
||||
|
||||
|
||||
# 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回
|
||||
segments = text.split("\\N")
|
||||
wrapped_segments: list[str] = []
|
||||
@@ -282,20 +298,28 @@ def build_ass_content(
|
||||
if title_config:
|
||||
_stroke_val = title_config.get("stroke")
|
||||
if isinstance(_stroke_val, bool):
|
||||
title_config["stroke"] = {
|
||||
"enabled": _stroke_val,
|
||||
"color": "#000000",
|
||||
"width": 2,
|
||||
} if _stroke_val else {"enabled": False}
|
||||
title_config["stroke"] = (
|
||||
{
|
||||
"enabled": _stroke_val,
|
||||
"color": "#000000",
|
||||
"width": 2,
|
||||
}
|
||||
if _stroke_val
|
||||
else {"enabled": False}
|
||||
)
|
||||
_shadow_val = title_config.get("shadow")
|
||||
if isinstance(_shadow_val, bool):
|
||||
title_config["shadow"] = {
|
||||
"enabled": _shadow_val,
|
||||
"color": "#000000",
|
||||
"blur": 4,
|
||||
"offset_x": 2,
|
||||
"offset_y": 2,
|
||||
} if _shadow_val else {"enabled": False}
|
||||
title_config["shadow"] = (
|
||||
{
|
||||
"enabled": _shadow_val,
|
||||
"color": "#000000",
|
||||
"blur": 4,
|
||||
"offset_x": 2,
|
||||
"offset_y": 2,
|
||||
}
|
||||
if _shadow_val
|
||||
else {"enabled": False}
|
||||
)
|
||||
|
||||
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
|
||||
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
|
||||
|
||||
@@ -15,6 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 按优先级查找 CJK 字体(Debian/Ubuntu fonts-noto-cjk 安装路径)
|
||||
_FONT_CANDIDATES = (
|
||||
"/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
|
||||
|
||||
@@ -15,6 +15,7 @@ from packages.domain.ass_subtitle_builder import (
|
||||
TITLE_MARGIN_BOTTOM,
|
||||
TITLE_MARGIN_SIDE,
|
||||
TITLE_MARGIN_TOP,
|
||||
_wrap_title_text,
|
||||
build_ass_content,
|
||||
build_ass_style,
|
||||
escape_ass_text,
|
||||
@@ -105,9 +106,9 @@ class TestBuildAssStyle:
|
||||
|
||||
def test_contains_font_size(self):
|
||||
result = build_ass_style("S1", font_size=36)
|
||||
# Style行格式:Name, Fontname, Fontsize, ...
|
||||
# Style行格式:Name, Fontname, Fontsize, ...(36*1.35=48.6→49)
|
||||
parts = result.split(",")
|
||||
assert parts[2] == "36"
|
||||
assert parts[2] == "49"
|
||||
|
||||
def test_bold_true(self):
|
||||
result = build_ass_style("S1", bold=True)
|
||||
@@ -420,7 +421,7 @@ class TestBuildAssContent:
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "72"
|
||||
assert parts[2] == "97" # 72*1.35=97.2→97
|
||||
break
|
||||
|
||||
def test_title_font_size_frontend_field_alias(self):
|
||||
@@ -435,7 +436,7 @@ class TestBuildAssContent:
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "48"
|
||||
assert parts[2] == "65" # 48*1.35=64.8→65
|
||||
break
|
||||
|
||||
def test_title_font_color_frontend_field_alias(self):
|
||||
@@ -462,7 +463,7 @@ class TestBuildAssContent:
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "56"
|
||||
assert parts[2] == "76" # 56*1.35=75.6→76
|
||||
break
|
||||
|
||||
def test_title_bold(self):
|
||||
@@ -579,3 +580,44 @@ class TestConstants:
|
||||
assert isinstance(TITLE_MARGIN_TOP, int)
|
||||
assert isinstance(TITLE_MARGIN_BOTTOM, int)
|
||||
assert isinstance(TITLE_MARGIN_SIDE, int)
|
||||
|
||||
# ============================================================
|
||||
# _wrap_title_text 换行逻辑验证
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestWrapTitleTextFontSizeConsistency:
|
||||
"""验证换行计算使用原始 font_size,与 CSS 预览一致。"""
|
||||
|
||||
def test_font_size_113_uses_original_not_compensated(self):
|
||||
"""font_size=113 时,每行应容纳8个字(113px字宽),而非6个字(153px字宽)。"""
|
||||
# 标题"永康拾掇脚阔头"共7个字
|
||||
# 可用宽度 = 1080 - 60 - 60 = 960px
|
||||
# 用 font_size=113:char_width=113,960/113 ≈ 8.5,每行8个字
|
||||
# 7个字 < 8个字,应该在一行内
|
||||
title = "永康拾掇脚阔头"
|
||||
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
|
||||
# 不应该有换行
|
||||
assert "\\N" not in result
|
||||
assert result == title
|
||||
|
||||
def test_long_title_wraps_correctly(self):
|
||||
"""长标题应该按 font_size 字宽正确换行。"""
|
||||
# 16个中文字,每行8个字,应该换行为2行
|
||||
title = "永康拾掇脚阔头来一个笑一个哈哈哈"
|
||||
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
|
||||
# 应该有一个换行
|
||||
assert result.count("\\N") == 1
|
||||
# 每行8个字
|
||||
lines = result.split("\\N")
|
||||
assert len(lines) == 2
|
||||
assert len(lines[0]) == 8
|
||||
assert len(lines[1]) == 8
|
||||
|
||||
def test_mixed_cjk_and_ascii(self):
|
||||
"""混合中英文时,英文按半角宽度计算。"""
|
||||
# "测试test" = 2个中文(2*113=226) + 4个英文(4*113*0.55=248.6) = 474.6px
|
||||
title = "测试test"
|
||||
result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60)
|
||||
# 总宽度474.6px < 960px,应该在一行内
|
||||
assert "\\N" not in result
|
||||
|
||||
@@ -79,12 +79,12 @@ class TestBuildAssStyle:
|
||||
def test_minimal_style(self):
|
||||
result = build_ass_style("TestStyle")
|
||||
assert result.startswith("Style: TestStyle,")
|
||||
assert "Noto Sans CJK SC" in result
|
||||
assert ",48," in result
|
||||
assert "Noto Sans SC" in result
|
||||
assert ",65," in result # 48*1.35=64.8→65
|
||||
|
||||
def test_custom_font_size(self):
|
||||
result = build_ass_style("Title", font_size=64)
|
||||
assert ",64," in result
|
||||
assert ",86," in result # 64*1.35=86.4→86
|
||||
|
||||
def test_bold_enabled(self):
|
||||
result = build_ass_style("BoldStyle", bold=True)
|
||||
@@ -152,21 +152,20 @@ class TestBuildAssStyle:
|
||||
# Style: 行有 23 个字段(去掉 "Style: " 前缀后)
|
||||
assert len(parts) == 23
|
||||
|
||||
|
||||
def test_font_name_mapping_siyuan(self):
|
||||
"""思源黑体 → Noto Sans CJK SC"""
|
||||
"""思源黑体 → Noto Sans SC"""
|
||||
result = build_ass_style("Test", font_name="思源黑体")
|
||||
assert "Noto Sans CJK SC" in result
|
||||
assert "Noto Sans SC" in result
|
||||
|
||||
def test_font_name_mapping_apple(self):
|
||||
"""苹方 → Noto Sans CJK SC"""
|
||||
"""苹方 → Noto Sans SC"""
|
||||
result = build_ass_style("Test", font_name="苹方")
|
||||
assert "Noto Sans CJK SC" in result
|
||||
assert "Noto Sans SC" in result
|
||||
|
||||
def test_font_name_mapping_msyh(self):
|
||||
"""微软雅黑 → Noto Sans CJK SC"""
|
||||
"""微软雅黑 → Noto Sans SC"""
|
||||
result = build_ass_style("Test", font_name="微软雅黑")
|
||||
assert "Noto Sans CJK SC" in result
|
||||
assert "Noto Sans SC" in result
|
||||
|
||||
def test_font_name_mapping_unknown_passthrough(self):
|
||||
"""未映射字体原样使用"""
|
||||
@@ -174,7 +173,6 @@ class TestBuildAssStyle:
|
||||
assert "CustomFont" in result
|
||||
|
||||
|
||||
|
||||
# ── 文本转义 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -483,6 +481,7 @@ class TestConstants:
|
||||
def test_title_margin_side(self):
|
||||
assert TITLE_MARGIN_SIDE == 40
|
||||
|
||||
|
||||
# ── 标题自动换行 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -554,7 +553,6 @@ class TestWrapTitleText:
|
||||
assert result == text
|
||||
assert result.count("\\N") == 2
|
||||
|
||||
|
||||
def test_build_ass_content_integration(self):
|
||||
"""集成测试:build_ass_content 中的标题应该自动换行。"""
|
||||
long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作"
|
||||
@@ -572,3 +570,35 @@ class TestWrapTitleText:
|
||||
break
|
||||
else:
|
||||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||||
|
||||
|
||||
class TestFontsizeCompensation:
|
||||
"""ASS Fontsize 补偿系数(CSS 字号 → ASS em-square 字号)。"""
|
||||
|
||||
def test_default_48_compensated_to_65(self):
|
||||
result = build_ass_style("S")
|
||||
parts = result.split(",")
|
||||
assert parts[2] == "65" # round(48*1.35)=65
|
||||
|
||||
def test_89_compensated_to_120(self):
|
||||
"""实测对齐点:font_size=89 → ASS Fontsize=120。"""
|
||||
result = build_ass_style("S", font_size=89)
|
||||
parts = result.split(",")
|
||||
assert parts[2] == "120"
|
||||
|
||||
def test_subtitle_also_compensated(self):
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=5.0,
|
||||
subtitle_text="字幕",
|
||||
subtitle_config={"size": 24},
|
||||
)
|
||||
sub_line = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
|
||||
fields = [f.strip() for f in sub_line.split(",")]
|
||||
assert fields[2] == "32" # round(24*1.35)=32
|
||||
|
||||
def test_minimum_fontsize_at_least_one(self):
|
||||
result = build_ass_style("S", font_size=0)
|
||||
parts = result.split(",")
|
||||
assert int(parts[2]) >= 1
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
"""测试编辑器 from-assets 端点:按模板segment创建片段 + 事务性替换 + 随机起始.
|
||||
|
||||
覆盖:
|
||||
- 片段数量 = segment 数量(required_clips_count 被忽略)
|
||||
- 素材不足时同一素材轮询切多个片段
|
||||
- 随机 start_time + used_segments 去重
|
||||
- 素材时长不足时 clip duration 缩短
|
||||
- 素材时长为 0 时抛 400
|
||||
- 使用 replace_all_clips_transactional 原子性替换
|
||||
- order 从 0 开始
|
||||
- start_time=None 时抛出 400
|
||||
- mark_clips_ready 在事务方法内部完成
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
TEST_PLAN_ID = "plan-draft-001"
|
||||
TEST_USER_ID = "user-001"
|
||||
|
||||
# 默认测试用 segments:4 个片段,每个 3~5 秒
|
||||
DEFAULT_SEGMENTS = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]
|
||||
|
||||
|
||||
def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
|
||||
return [(i, dur_min, dur_max) for i in range(count)]
|
||||
|
||||
|
||||
def _patch_segments(segments=None):
|
||||
return patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
return_value=segments if segments is not None else DEFAULT_SEGMENTS,
|
||||
)
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
auth = MagicMock()
|
||||
auth.user.id = TEST_USER_ID
|
||||
auth.user.email = "test@example.com"
|
||||
auth.user.display_name = "测试用户"
|
||||
auth.user_id = TEST_USER_ID
|
||||
return auth
|
||||
|
||||
|
||||
def _make_mock_asset(asset_id, duration):
|
||||
asset = MagicMock()
|
||||
asset.id = asset_id
|
||||
asset.duration = duration
|
||||
return asset
|
||||
|
||||
|
||||
def _make_plan_svc(replace_return_count=None):
|
||||
svc = MagicMock()
|
||||
# replace_all_clips_transactional 返回创建的片段数量
|
||||
if replace_return_count is not None:
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
|
||||
else:
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=0)
|
||||
return svc
|
||||
|
||||
|
||||
def _get_clips_data_from_call(mock_plan_svc):
|
||||
"""从 replace_all_clips_transactional 的调用中获取 clips_data。"""
|
||||
assert mock_plan_svc.replace_all_clips_transactional.called, "replace_all_clips_transactional 未被调用"
|
||||
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
|
||||
# call_args = ((plan_id, clips_data), kwargs)
|
||||
if len(call_args.args) >= 2:
|
||||
return call_args.args[1]
|
||||
return call_args.kwargs.get("clips_data", [])
|
||||
|
||||
|
||||
class TestEditorClipsBySegments:
|
||||
"""测试按 segment 数量创建片段 + 素材轮询。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_creates_clips_matching_segment_count(self, mock_storage):
|
||||
"""4 个 segment 即使只有2个素材也创建4个片段,required_clips_count 被忽略。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=4)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
||||
|
||||
with _patch_segments(DEFAULT_SEGMENTS):
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert result.created_count == 4
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 4
|
||||
|
||||
# 验证轮询分配:a1, a2, a1, a2
|
||||
assert clips_data[0]["asset_id"] == "a1"
|
||||
assert clips_data[1]["asset_id"] == "a2"
|
||||
assert clips_data[2]["asset_id"] == "a1"
|
||||
assert clips_data[3]["asset_id"] == "a2"
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_orders_start_at_zero(self, mock_storage):
|
||||
"""片段 order 从 0 开始递增。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
|
||||
|
||||
with _patch_segments(_segments(3)):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["order"] == 0
|
||||
assert clips_data[1]["order"] == 1
|
||||
assert clips_data[2]["order"] == 2
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_uses_transactional_replace(self, mock_storage):
|
||||
"""使用 replace_all_clips_transactional 而不是分别 delete + create。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with _patch_segments(_segments(2)):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 必须调用事务方法
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_called_once()
|
||||
# 不应调用单独的 delete 或 create
|
||||
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_no_segments_raises_400(self, mock_storage):
|
||||
"""模板没有 segment 配置时返回 400。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with _patch_segments([]):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "片段配置" in exc_info.value.detail
|
||||
# 不应调用替换方法
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
class TestEditorClipsDurationAndStartTime:
|
||||
"""测试素材时长获取、clip duration 缩短、start_time 传入。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_clip_duration_shortened_for_short_assets(self, mock_storage):
|
||||
"""素材只有 3s 时 clip duration 缩短到不超过 3.0。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=1)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["short"])
|
||||
|
||||
with _patch_segments(_segments(1, dur_min=5.0, dur_max=10.0)):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["duration"] <= 3.0
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_zero_duration_asset_raises_400(self, mock_storage):
|
||||
"""素材时长为 0 时应抛出 400,而不是创建无效片段。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("bad", 0.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["bad"])
|
||||
|
||||
with _patch_segments(_segments(1)):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "时长" in exc_info.value.detail
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_missing_duration_asset_raises_400(self, mock_storage):
|
||||
"""素材时长缺失(asset_repo.get 返回 None)时抛出 400。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=None)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["missing"])
|
||||
|
||||
with _patch_segments(_segments(1)):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_start_time_passed_to_create(self, mock_storage):
|
||||
"""_calc_random_start_time 返回值被传入 clips_data。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
||||
|
||||
with (
|
||||
_patch_segments(_segments(2)),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
side_effect=[12.5, 18.0],
|
||||
) as mock_calc,
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert mock_calc.call_count == 2
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["start_time"] == 12.5
|
||||
assert clips_data[1]["start_time"] == 18.0
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_asset_durations_deduped(self, mock_storage):
|
||||
"""asset_ids 有重复时只查询一次素材时长。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"])
|
||||
|
||||
with _patch_segments(_segments(3)):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 去重后只调用 1 次获取素材时长
|
||||
assert mock_asset_repo.get.call_count == 1
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_used_segments_maintained_across_clips(self, mock_storage):
|
||||
"""同一素材切多个片段时,used_segments 应被维护。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
|
||||
|
||||
captured_used_segments = []
|
||||
|
||||
def fake_calc(asset_id, clip_duration, asset_durations, used_segments):
|
||||
captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()})
|
||||
return (len(captured_used_segments) - 1) * 5.0
|
||||
|
||||
with (
|
||||
_patch_segments(_segments(3)),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
side_effect=fake_calc,
|
||||
),
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 第一次没有已使用时间段
|
||||
assert captured_used_segments[0] == {}
|
||||
# 第二次有第一次的记录
|
||||
assert len(captured_used_segments[1]["a1"]) == 1
|
||||
# 第三次有前两次的记录
|
||||
assert len(captured_used_segments[2]["a1"]) == 2
|
||||
|
||||
|
||||
class TestEditorClipsErrorHandling:
|
||||
"""测试异常处理。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_none_start_time_raises_400(self, mock_storage):
|
||||
"""_calc_random_start_time 返回 None 时应抛出 HTTPException 400。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
# 素材有 duration 但 random 返回 None
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with (
|
||||
_patch_segments(_segments(1)),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "时长" in exc_info.value.detail
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_transactional_replace_exception_propagates(self, mock_storage):
|
||||
"""replace_all_clips_transactional 抛异常时应向上传播(事务已回滚)。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_plan_svc.replace_all_clips_transactional = MagicMock(side_effect=RuntimeError("DB connection lost"))
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with _patch_segments(_segments(1)):
|
||||
with pytest.raises(RuntimeError, match="DB connection lost"):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
@@ -0,0 +1,690 @@
|
||||
"""测试 MediaKit 智能选片 + from-assets 按模板 segment 创建片段。
|
||||
|
||||
覆盖:
|
||||
- _recommended_time_conflicts 冲突检测
|
||||
- _get_mediakit_recommendations 解析与降级
|
||||
- _get_template_segments 查询模板片段配置
|
||||
- from-assets 端点:按模板 segment 数量和时长创建片段
|
||||
- from-assets 端点:事务性原子替换
|
||||
- from-assets 端点:素材轮询分配
|
||||
- from-assets 端点:MediaKit 推荐时间首片段使用
|
||||
- from-assets 端点:同一素材多片段时后续用随机
|
||||
- from-assets 端点:无 segment 配置时报错
|
||||
- from-assets 端点:素材时长为0时报400
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
|
||||
# ── _recommended_time_conflicts 单元测试 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecommendedTimeConflicts:
|
||||
"""测试推荐时间与已使用时间段的冲突检测。"""
|
||||
|
||||
def test_no_conflict_when_empty(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
assert _recommended_time_conflicts(5.0, 5.0, []) is False
|
||||
|
||||
def test_no_conflict_when_before(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(15.0, 20.0)]) is False
|
||||
|
||||
def test_no_conflict_when_after(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
assert _recommended_time_conflicts(20.0, 5.0, [(0.0, 10.0)]) is False
|
||||
|
||||
def test_conflict_overlap_start(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [5, 10],已用 [0, 7]
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(0.0, 7.0)]) is True
|
||||
|
||||
def test_conflict_overlap_end(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [5, 10],已用 [8, 15]
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(8.0, 15.0)]) is True
|
||||
|
||||
def test_conflict_contained(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [5, 10],已用 [0, 20]
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(0.0, 20.0)]) is True
|
||||
|
||||
def test_conflict_exact_boundary_no_overlap(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [10, 15],已用 [0, 10] — 边界相接不算冲突
|
||||
assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)]) is False
|
||||
|
||||
def test_conflict_multiple_used(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
used = [(0.0, 5.0), (10.0, 15.0), (20.0, 25.0)]
|
||||
# 推荐 [6, 11] 与 [10, 15] 冲突
|
||||
assert _recommended_time_conflicts(6.0, 5.0, used) is True
|
||||
# 推荐 [15, 20] 不冲突
|
||||
assert _recommended_time_conflicts(15.0, 5.0, used) is False
|
||||
|
||||
|
||||
# ── _get_mediakit_recommendations 单元测试 ──────────────────────────────────
|
||||
|
||||
|
||||
class TestGetMediakitRecommendations:
|
||||
"""测试 MediaKit 推荐结果解析和降级。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_returns_parsed_recommendations(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = [
|
||||
'[{"asset_id": "a1", "recommended_start_time": 12.5, "reason": "动作场景"}]'
|
||||
]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
asset = MagicMock()
|
||||
asset.storage_key = "v.mp4"
|
||||
asset.mime_type = "video/mp4"
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {"a1": 12.5}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_client_not_available_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], MagicMock())
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_empty_contents_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = []
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
asset = MagicMock()
|
||||
asset.storage_key = "v.mp4"
|
||||
asset.mime_type = "video/mp4"
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_unparseable_response_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = ["这是一段自然语言描述,没有JSON"]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
asset = MagicMock()
|
||||
asset.storage_key = "v.mp4"
|
||||
asset.mime_type = "video/mp4"
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_storage_failure_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client_fn.return_value = mock_client
|
||||
mock_storage.side_effect = RuntimeError("storage unavailable")
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], MagicMock())
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_exception_returns_empty(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client_fn.side_effect = RuntimeError("unexpected error")
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], MagicMock())
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_skips_non_video_assets(self, mock_storage, mock_client_fn):
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.storage_key = "images/test.jpg"
|
||||
mock_asset.mime_type = "image/jpeg"
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
mock_client.analyze_videos.assert_not_called()
|
||||
|
||||
|
||||
# ── _get_template_segments 单元测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTemplateSegments:
|
||||
"""测试模板片段配置查询。"""
|
||||
|
||||
def test_returns_segments_from_new_template_system(self):
|
||||
"""新模板系统(clip_configs)有数据时优先使用。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
cc1 = MagicMock()
|
||||
cc1.order = 0
|
||||
cc1.min_duration = 3.0
|
||||
cc1.max_duration = 5.0
|
||||
cc2 = MagicMock()
|
||||
cc2.order = 1
|
||||
cc2.min_duration = 4.0
|
||||
cc2.max_duration = 8.0
|
||||
mock_tpl_svc.list_clip_configs.return_value = [cc2, cc1] # 乱序返回
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0, 3.0, 5.0)
|
||||
assert result[1] == (1, 4.0, 8.0)
|
||||
|
||||
def test_falls_back_to_old_template_segments(self):
|
||||
"""新模板系统无数据时回退到旧系统。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
seg1 = MagicMock()
|
||||
seg1.segment_order = 0
|
||||
seg1.duration_min = 2.0
|
||||
seg1.duration_max = 4.0
|
||||
mock_repo.list_segments.return_value = [seg1]
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 2.0, 4.0)
|
||||
|
||||
def test_returns_empty_when_no_segments(self):
|
||||
"""两套系统都没有片段配置时返回空列表。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_segments.return_value = []
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert result == []
|
||||
|
||||
def test_new_system_exception_falls_back(self):
|
||||
"""新模板系统异常时回退到旧系统。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.side_effect = RuntimeError("db error")
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
seg = MagicMock()
|
||||
seg.segment_order = 0
|
||||
seg.duration_min = 1.0
|
||||
seg.duration_max = 3.0
|
||||
mock_repo.list_segments.return_value = [seg]
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
auth = MagicMock()
|
||||
auth.user.id = "user-001"
|
||||
auth.user.email = "test@example.com"
|
||||
auth.user.display_name = "test"
|
||||
auth.user_id = "user-001"
|
||||
return auth
|
||||
|
||||
|
||||
def _make_clip_config(order, min_dur, max_dur):
|
||||
cc = MagicMock()
|
||||
cc.order = order
|
||||
cc.min_duration = min_dur
|
||||
cc.max_duration = max_dur
|
||||
return cc
|
||||
|
||||
|
||||
def _make_plan_svc(replace_return_count=None):
|
||||
svc = MagicMock()
|
||||
svc.get_plan_or_raise = MagicMock()
|
||||
if replace_return_count is not None:
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
|
||||
else:
|
||||
svc.replace_all_clips_transactional = MagicMock(return_value=0)
|
||||
return svc
|
||||
|
||||
|
||||
def _make_tpl_svc_with_segments(segments):
|
||||
"""segments: list of (order, min_dur, max_dur)"""
|
||||
svc = MagicMock()
|
||||
clip_configs = [_make_clip_config(o, mn, mx) for o, mn, mx in segments]
|
||||
svc.list_clip_configs.return_value = clip_configs
|
||||
return svc
|
||||
|
||||
|
||||
def _make_rich_asset(asset_id, duration, storage_key="v.mp4", mime="video/mp4"):
|
||||
asset = MagicMock()
|
||||
asset.id = asset_id
|
||||
asset.duration = duration
|
||||
asset.storage_key = storage_key
|
||||
asset.mime_type = mime
|
||||
return asset
|
||||
|
||||
|
||||
def _get_clips_data(mock_plan_svc):
|
||||
"""从 replace_all_clips_transactional 调用中提取 clips_data。"""
|
||||
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
|
||||
if len(call_args.args) >= 2:
|
||||
return call_args.args[1]
|
||||
return call_args.kwargs.get("clips_data", [])
|
||||
|
||||
|
||||
class TestFromAssetsByTemplateSegments:
|
||||
"""测试 from-assets 按模板 segment 创建片段(V2 事务性替换)。"""
|
||||
|
||||
def test_creates_clips_matching_segment_count(self):
|
||||
"""片段数量 = segment 数量,忽略 required_clips_count。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0), (2, 2.0, 6.0), (3, 5.0, 10.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=4)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert result.created_count == 4
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
assert len(clips_data) == 4
|
||||
|
||||
def test_uses_transactional_replace(self):
|
||||
"""使用 replace_all_clips_transactional 而不是分别 delete + create。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_called_once()
|
||||
assert not mock_plan_svc.delete_all_clips.called
|
||||
assert not mock_plan_svc.create_clip.called
|
||||
|
||||
def test_duration_within_segment_range(self):
|
||||
"""每个片段时长在 segment 的 min~max 范围内。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
assert 3.0 <= clips_data[0]["duration"] <= 5.0
|
||||
assert 4.0 <= clips_data[1]["duration"] <= 8.0
|
||||
|
||||
def test_assets_round_robin_assignment(self):
|
||||
"""素材按片段顺序轮询分配。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=4)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
def get_asset(aid):
|
||||
return _make_rich_asset(aid, 60.0)
|
||||
|
||||
mock_asset_repo.get.side_effect = get_asset
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
asset_ids = [c["asset_id"] for c in clips_data]
|
||||
assert asset_ids == ["a1", "a2", "a1", "a2"]
|
||||
|
||||
def test_orders_start_from_zero(self):
|
||||
"""片段 order 从 0 开始递增。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == [0, 1, 2]
|
||||
|
||||
def test_no_segments_raises_400(self):
|
||||
"""模板没有 segment 配置时返回 400。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_segments.return_value = []
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
def test_duration_capped_by_asset_duration(self):
|
||||
"""素材时长不足时 clip duration 被缩短。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
segments = [(0, 10.0, 20.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=1)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 5.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
assert clips_data[0]["duration"] <= 5.0
|
||||
|
||||
def test_zero_duration_asset_raises_400(self):
|
||||
"""素材时长为 0 时抛出 400。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
segments = [(0, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("bad", 0.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["bad"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
def test_empty_asset_ids_raises_400(self):
|
||||
"""asset_ids 为空列表时返回 400(defense-in-depth,schema 层也有 min_length=1)。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from fastapi import HTTPException
|
||||
|
||||
segments = [(0, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
# 用 MagicMock 模拟 body,绕过 Pydantic schema 的 min_length 校验
|
||||
mock_body = MagicMock()
|
||||
mock_body.asset_ids = []
|
||||
mock_body.required_clips_count = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=mock_body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "素材" in exc_info.value.detail
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_mediakit_first_clip_uses_recommendation(self, mock_storage, mock_client_fn):
|
||||
"""MediaKit 推荐时间用于每个素材的第一个片段。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = [
|
||||
'[{"asset_id": "a1", "recommended_start_time": 15.0, "reason": "test"}]'
|
||||
]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
|
||||
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
# 第一个片段应使用推荐时间 15.0
|
||||
assert clips_data[0]["start_time"] == 15.0
|
||||
# 第二个片段(同一素材)不应使用推荐时间
|
||||
assert clips_data[1]["start_time"] != 15.0
|
||||
|
||||
|
||||
# ── _safe_segment_duration 单元测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSafeSegmentDuration:
|
||||
"""测试片段时长安全转换。"""
|
||||
|
||||
def test_normal_float(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(3.5, 1.0) == 3.5
|
||||
|
||||
def test_none_returns_default(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(None, 5.0) == 5.0
|
||||
|
||||
def test_string_number(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration("4.2", 1.0) == 4.2
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration("abc", 5.0) == 5.0
|
||||
|
||||
def test_negative_returns_default(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(-1.0, 5.0) == 5.0
|
||||
|
||||
def test_zero_returns_default(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(0, 5.0) == 5.0
|
||||
|
||||
def test_integer_value(self):
|
||||
from app.api.routes.templates_editor.clips import _safe_segment_duration
|
||||
|
||||
assert _safe_segment_duration(10, 1.0) == 10.0
|
||||
@@ -80,13 +80,13 @@ class TestBuildAssStyle:
|
||||
def test_basic_style(self):
|
||||
style = _build_ass_style("Default")
|
||||
assert style.startswith("Style: Default,")
|
||||
assert "Noto Sans CJK SC" in style
|
||||
assert "48" in style # font_size
|
||||
assert "Noto Sans SC" in style
|
||||
assert "65" in style # font_size 48*1.35=65
|
||||
|
||||
def test_custom_font(self):
|
||||
style = _build_ass_style("Custom", font_name="Arial", font_size=32)
|
||||
assert "Arial" in style
|
||||
assert ",32," in style
|
||||
assert ",43," in style # font_size 32*1.35=43
|
||||
|
||||
def test_bold(self):
|
||||
style = _build_ass_style("Bold", bold=True)
|
||||
|
||||
@@ -84,8 +84,8 @@ class TestBuildAssStyle:
|
||||
"""基本样式行包含关键字段."""
|
||||
line = _build_ass_style("Default")
|
||||
assert line.startswith("Style: Default,")
|
||||
assert "Noto Sans CJK SC" in line
|
||||
assert "48" in line # font_size
|
||||
assert "Noto Sans SC" in line
|
||||
assert "65" in line # font_size 48*1.35=65
|
||||
|
||||
def test_bold_enabled(self):
|
||||
"""加粗时Bold=-1."""
|
||||
@@ -112,7 +112,7 @@ class TestBuildAssStyle:
|
||||
"""自定义字号."""
|
||||
line = _build_ass_style("Big", font_size=72)
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "72" # Fontsize
|
||||
assert parts[2] == "97" # Fontsize 72*1.35=97
|
||||
|
||||
def test_custom_alignment(self):
|
||||
"""自定义对齐方式."""
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",36," in content, f"默认字号应为36,实际内容: {content}"
|
||||
assert ",49," in content, f"默认字号36应补偿为49(36*1.35),实际内容: {content}"
|
||||
|
||||
def test_size_32_preserved(self):
|
||||
"""size=32 应原样使用。"""
|
||||
@@ -43,7 +43,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",32," in content
|
||||
assert ",43," in content # 32*1.35=43
|
||||
|
||||
def test_size_60_preserved(self):
|
||||
"""size=60 应原样保留(字号上限已移除)。"""
|
||||
@@ -58,7 +58,7 @@ class TestFontSize:
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
font_size = int(fields[2])
|
||||
assert font_size == 60, f"字号60应原样保留, 实际={font_size}"
|
||||
assert font_size == 81, f"字号60应补偿为81(60*1.35), 实际={font_size}"
|
||||
|
||||
def test_font_size_alias_normalized(self):
|
||||
"""前端传 font_size 应归一化为 size。"""
|
||||
@@ -72,7 +72,7 @@ class TestFontSize:
|
||||
)
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
assert fields[2] == "52", f"font_size=52 应归一化, 实际={fields[2]}"
|
||||
assert fields[2] == "70", f"font_size=52 应补偿为70(52*1.35), 实际={fields[2]}"
|
||||
|
||||
def test_font_color_alias_normalized(self):
|
||||
"""前端传 font_color 应归一化为 color。"""
|
||||
@@ -97,7 +97,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",24," in content
|
||||
assert ",32," in content # 24*1.35=32.4→32
|
||||
|
||||
|
||||
class TestBooleanStrokeNormalization:
|
||||
@@ -230,9 +230,9 @@ class TestFullStyleConsistency:
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
|
||||
# Fontname
|
||||
assert fields[1] == "Noto Sans CJK SC"
|
||||
# Fontsize = 28
|
||||
assert fields[2] == "28"
|
||||
assert fields[1] == "Noto Sans SC"
|
||||
# Fontsize = 28*1.35=37.8→38
|
||||
assert fields[2] == "38"
|
||||
# Bold = -1 (True)
|
||||
assert fields[7] == "-1"
|
||||
# Outline width = 2 (前端默认 stroke width)
|
||||
|
||||
Reference in New Issue
Block a user