Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9039fcaea9 | |||
| 54368c24ff | |||
| a998ccd527 | |||
| dd16f8f783 | |||
| 1528d6b59c | |||
| 445375e1cb | |||
| 23fe5f9822 | |||
| c32065207a | |||
| c9a8691b77 | |||
| 4a695c6eb6 | |||
| 04c28dc705 | |||
| 493e85ce0e | |||
| 445267a3b2 | |||
| 42d6e45089 | |||
| eaed4b4cbf | |||
| 2b4bcefc57 |
@@ -8,6 +8,7 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.clips_standalone import router as clips_standalone_router
|
||||
from app.api.routes.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.drafts_standalone import router as drafts_standalone_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_cover import router as generation_cover_router
|
||||
@@ -19,7 +20,8 @@ from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.lipsync import router as lipsync_router
|
||||
from app.api.routes.points import points_router, usage_router
|
||||
from app.api.routes.points import router as points_router
|
||||
from app.api.routes.points import usage_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.scripts import router as scripts_router
|
||||
from app.api.routes.scripts_ai import router as scripts_ai_router
|
||||
@@ -41,6 +43,19 @@ api_router = APIRouter(prefix="/api/v1")
|
||||
health_router = APIRouter()
|
||||
health_router.include_router(health_check_router)
|
||||
|
||||
# ── /api/health 别名:部分前端/探针把 health 放在 /api 前缀下 ──────────────
|
||||
# 原来 /health 在根路径;额外加一个 /api/health 别名避免 404。
|
||||
api_health_router = APIRouter(prefix="/api")
|
||||
api_health_router.include_router(health_check_router)
|
||||
health_router.include_router(api_health_router)
|
||||
|
||||
# ── 旧前端路径别名(无需 template_id 路径参数)────────────────────────────
|
||||
# /api/v1/clips/from-assets 已有 clips_standalone;此处额外挂 /api/v1/editor/*,
|
||||
# 解决前端调 /api/v1/editor/clips/from-assets 和 /api/v1/editor/drafts 的 404。
|
||||
editor_legacy_router = APIRouter(prefix="/editor", tags=["Editor Legacy Alias"])
|
||||
editor_legacy_router.include_router(clips_standalone_router)
|
||||
editor_legacy_router.include_router(drafts_standalone_router)
|
||||
|
||||
api_router.include_router(
|
||||
auth_router,
|
||||
tags=["Auth"],
|
||||
@@ -169,6 +184,9 @@ api_router.include_router(
|
||||
prefix="/templates/{template_id}/editor",
|
||||
tags=["TemplateEditor"],
|
||||
)
|
||||
api_router.include_router(
|
||||
editor_legacy_router,
|
||||
)
|
||||
api_router.include_router(
|
||||
tts_router,
|
||||
prefix="/tts",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""独立的草稿端点(不依赖 template_id 路径参数,兼容旧前端路径).
|
||||
|
||||
提供以下别名端点,与 /api/v1/templates/{template_id}/editor/draft 功能一致:
|
||||
- GET /api/v1/editor/drafts 获取草稿详情(template_id 从 query/body/默认模板兜底)
|
||||
- PUT /api/v1/editor/drafts 更新草稿(兼容前端 useDraftAutoSave 调用)
|
||||
|
||||
根因:前端 useDraftAutoSave 调用 /api/v1/editor/drafts(复数、无 template_id),
|
||||
与后端以 template_id 为路径参数的设计不一致,导致 404 并触发 10s timeout。
|
||||
本模块参照 clips_standalone.py 的模式,通过默认模板兜底复用 draft.py 的核心逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import 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 ._default_template import get_or_create_default_template_id
|
||||
from .templates_editor.dependencies import resolve_draft_plan_id
|
||||
from .templates_editor.draft import get_editor_draft, update_editor_draft
|
||||
from .templates_editor.schemas import EditorDraftResponse, EditorUpdateRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Editor Legacy Alias"])
|
||||
|
||||
|
||||
def _resolve_editor_services(db: Session) -> tuple[EditTemplateService, EditPlanService]:
|
||||
return EditTemplateService(db), EditPlanService(db)
|
||||
|
||||
|
||||
def _resolve_template_id(
|
||||
template_id: str | None,
|
||||
db: Session,
|
||||
current_user: AuthenticatedUser,
|
||||
) -> str:
|
||||
"""解析 template_id:query/body 优先,否则兜底默认模板。"""
|
||||
tid = (template_id or "").strip()
|
||||
if tid:
|
||||
return tid
|
||||
user_id = str(current_user.user.id)
|
||||
tid = get_or_create_default_template_id(db, user_id)
|
||||
if not tid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
return tid
|
||||
|
||||
|
||||
@router.get("/drafts", response_model=EditorDraftResponse)
|
||||
def get_editor_drafts_alias(
|
||||
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditorDraftResponse:
|
||||
"""获取草稿详情(复数路径别名,兼容旧前端调用)。"""
|
||||
tid = _resolve_template_id(template_id, db, current_user)
|
||||
services = _resolve_editor_services(db)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id=tid,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
return get_editor_draft(
|
||||
template_id=tid,
|
||||
plan_id=plan_id,
|
||||
services=services,
|
||||
_=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/drafts", response_model=EditorDraftResponse)
|
||||
def update_editor_drafts_alias(
|
||||
req: EditorUpdateRequest,
|
||||
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditorDraftResponse:
|
||||
"""更新草稿(复数路径别名,兼容前端 useDraftAutoSave 调用)。"""
|
||||
tid = _resolve_template_id(template_id, db, current_user)
|
||||
services = _resolve_editor_services(db)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id=tid,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
return update_editor_draft(
|
||||
template_id=tid,
|
||||
req=req,
|
||||
plan_id=plan_id,
|
||||
services=services,
|
||||
_=current_user,
|
||||
)
|
||||
@@ -76,10 +76,7 @@ class GenerateCoverResponse(BaseModel):
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _select_best_frame_from_snapshots(
|
||||
snapshots: list[dict], plan_id: str
|
||||
) -> str:
|
||||
def _select_best_frame_from_snapshots(snapshots: list[dict], plan_id: str) -> str:
|
||||
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
|
||||
|
||||
降级策略:cv2 不可用或评分失败时,返回第一帧。
|
||||
@@ -232,7 +229,10 @@ def _persist_cover_frame(
|
||||
|
||||
|
||||
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
|
||||
|
||||
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
|
||||
"""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
@@ -241,6 +241,20 @@ def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
except Exception:
|
||||
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
|
||||
# awaiting_cover 兜底:从 extra_meta.rendered_output 取
|
||||
try:
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = task_repo.get(task_id)
|
||||
if task is not None:
|
||||
_status = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if _status == "awaiting_cover":
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
_url = _ro.get("file_url") or ""
|
||||
if _url:
|
||||
return _url
|
||||
except Exception:
|
||||
logger.warning("[封面生成] awaiting_cover 兜底读取失败: task_id=%s", task_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -650,11 +650,26 @@ def get_preview_generation_task(
|
||||
if not getattr(task, "is_preview", False):
|
||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||||
|
||||
# 查询生成的视频(取第一个)
|
||||
# 查询生成的视频(取第一个)。
|
||||
# #2024: 渲染完成后先进入 awaiting_cover(未入成品库),此时预览也应可见,
|
||||
# 从 extra_meta["rendered_output"] 读取视频 URL。
|
||||
generated_videos = []
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if status_val == "completed":
|
||||
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
generated_videos = list_use_case.execute(task_id)
|
||||
elif status_val == "awaiting_cover":
|
||||
# 用 extra_meta 中的渲染信息组装一个轻量视频对象给前端预览播放
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
if _ro.get("file_url"):
|
||||
|
||||
class _PreviewVideo:
|
||||
def __init__(self, ro):
|
||||
self.file_url = ro.get("file_url", "")
|
||||
self.duration = float(ro.get("duration") or 0.0)
|
||||
self.file_size = int(ro.get("file_size") or 0)
|
||||
|
||||
generated_videos = [_PreviewVideo(_ro)]
|
||||
|
||||
return _to_preview_response(task, generated_videos=generated_videos)
|
||||
|
||||
@@ -31,6 +31,8 @@ from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
FinalizeGenerationRequest,
|
||||
FinalizeGenerationResponse,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
@@ -891,8 +893,14 @@ def confirm_generation(
|
||||
if source_task.project_id:
|
||||
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
|
||||
if source_task.is_completed and getattr(source_task, "is_preview", False):
|
||||
# 3. 如果预览任务已完成渲染(completed 或 awaiting_cover),检查分辨率一致性后复用产物(秒出)。
|
||||
# #2024: 渲染完成先进入 awaiting_cover(等 Step5 finalize 入库),
|
||||
# confirm 时不再直接 finalize——仍创建 is_preview=False 的正式任务,复用预览渲染产物。
|
||||
_preview_done = getattr(source_task, "is_preview", False) and source_task.status.value in (
|
||||
"completed",
|
||||
"awaiting_cover",
|
||||
)
|
||||
if _preview_done:
|
||||
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
|
||||
req_w = request.output_width or 0
|
||||
req_h = request.output_height or 0
|
||||
@@ -907,13 +915,25 @@ def confirm_generation(
|
||||
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
|
||||
confirmed_title_config["text"] = request.custom_title.strip()
|
||||
|
||||
# #2024: mark_confirmed 会把 is_preview 翻转为 False、同步标题/分辨率/封面,
|
||||
# 但不再自动 mark_completed——任务停留在 awaiting_cover,等待用户 Step5 选封面后调 finalize。
|
||||
source_task.mark_confirmed(
|
||||
cover_url=request.cover_url,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
title_config=confirmed_title_config,
|
||||
)
|
||||
# 若预览任务此时是 completed(历史数据/旧 worker),回退到 awaiting_cover 统一流程
|
||||
if source_task.status.value == "completed":
|
||||
try:
|
||||
from packages.domain.generation_task import GenerationTaskStatus
|
||||
|
||||
source_task.status = GenerationTaskStatus.AWAITING_COVER
|
||||
source_task.completed_at = None
|
||||
except Exception:
|
||||
pass
|
||||
generation_task_repository.update(source_task)
|
||||
db.commit()
|
||||
|
||||
# 同步标题到 EditPlan.config
|
||||
# #1970:确认生成复用预览计划,dedup_enabled 沿用计划已有值,不在此覆盖
|
||||
@@ -926,7 +946,7 @@ def confirm_generation(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
|
||||
"[确认生成] 复用预览产物(等待 finalize): task_id=%s, user_id=%s",
|
||||
task_id,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
@@ -995,6 +1015,67 @@ def confirm_generation(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/finalize", response_model=FinalizeGenerationResponse)
|
||||
def finalize_generation_task(
|
||||
task_id: str,
|
||||
request: FinalizeGenerationRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> FinalizeGenerationResponse:
|
||||
"""#2024: Step5 点「完成」时调用——将 awaiting_cover 状态的任务正式入库+绑定封面。
|
||||
|
||||
- 任务必须处于 awaiting_cover 状态(渲染+上传已完成、封面候选已就绪)。
|
||||
- cover_url 为空则使用任务自动截帧/智能封面;非空则绑定为最终封面。
|
||||
- 幂等:已 finalize 的任务直接返回已有视频记录。
|
||||
- 成功后任务推进到 completed,返回成品视频 ID + 可播放 URL。
|
||||
"""
|
||||
from app.services.generation_finalize_service import (
|
||||
GenerationFinalizeError,
|
||||
GenerationFinalizeService,
|
||||
)
|
||||
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
if task.project_id:
|
||||
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
service = GenerationFinalizeService(db)
|
||||
try:
|
||||
video = service.finalize_task(
|
||||
task_id=task_id,
|
||||
user_id=authenticated_user.user.id,
|
||||
cover_url=request.cover_url or None,
|
||||
custom_title=(request.custom_title or "").strip() or None,
|
||||
)
|
||||
except GenerationFinalizeError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
|
||||
try:
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
download_url = video.file_url
|
||||
return FinalizeGenerationResponse(
|
||||
video_id=video.id,
|
||||
project_id=getattr(video, "project_id", "") or "",
|
||||
name=getattr(video, "name", "") or "",
|
||||
file_size=int(getattr(video, "file_size", 0) or 0),
|
||||
duration=float(getattr(video, "duration", 0.0) or 0.0),
|
||||
thumbnail_url=video.thumbnail_url or "",
|
||||
cover_url=video.thumbnail_url or "",
|
||||
file_url=download_url,
|
||||
width=int(getattr(video, "width", 0) or 0),
|
||||
height=int(getattr(video, "height", 0) or 0),
|
||||
fps=float(getattr(video, "fps", 0.0) or 0.0),
|
||||
status="success",
|
||||
is_duplicate=bool(getattr(video, "is_duplicate", False)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
def list_generation_tasks(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -1042,6 +1123,44 @@ def list_generation_results(
|
||||
for item in items:
|
||||
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
|
||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
||||
|
||||
# #2024/#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,
|
||||
# 从 extra_meta["rendered_output"] 合成一条轻量视频响应,供前端预览与智能封面使用。
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if not responses and status_val == "awaiting_cover":
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
_file_url = _ro.get("file_url") or ""
|
||||
if _file_url:
|
||||
if _file_url.startswith("http"):
|
||||
_download = _file_url
|
||||
else:
|
||||
try:
|
||||
_download = storage_service.get_download_url(_file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
_download = _file_url
|
||||
_name = _ro.get("name") or ""
|
||||
if not _name:
|
||||
_name = f"generated-{task_id[:8]}"
|
||||
responses.append(
|
||||
GeneratedVideoResponse(
|
||||
id=f"preview-{task_id}",
|
||||
project_id=getattr(task, "project_id", "") or "",
|
||||
generation_task_id=task_id,
|
||||
name=_name,
|
||||
file_url=_file_url,
|
||||
file_size=int(_ro.get("file_size") or 0),
|
||||
duration=float(_ro.get("duration") or 0.0),
|
||||
thumbnail_url=_ro.get("thumbnail_url") or getattr(task, "cover_url", "") or "",
|
||||
width=int(_ro.get("width") or 0),
|
||||
height=int(_ro.get("height") or 0),
|
||||
fps=float(_ro.get("fps") or 0.0),
|
||||
mode=_ro.get("mode", ""),
|
||||
download_url=_download,
|
||||
created_at=getattr(task, "updated_at", None) or getattr(task, "created_at", None),
|
||||
)
|
||||
)
|
||||
|
||||
return ListGeneratedVideosResponse(items=responses)
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ def _generation_step(task) -> str:
|
||||
return "等待 Worker 执行"
|
||||
if s == "running":
|
||||
return "正在生成成片"
|
||||
if s == "awaiting_cover":
|
||||
return "等待确认封面"
|
||||
if s == "completed":
|
||||
return "生成完成"
|
||||
if s == "failed":
|
||||
@@ -129,7 +131,7 @@ def _validate_status(status: str | None) -> str | None:
|
||||
"""校验状态值合法性。"""
|
||||
if status is None:
|
||||
return None
|
||||
valid = {"pending", "running", "completed", "failed", "cancelled"}
|
||||
valid = {"pending", "running", "awaiting_cover", "completed", "failed", "cancelled"}
|
||||
if status not in valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -151,7 +153,9 @@ def _clamp_page_size(page_size: int) -> int:
|
||||
|
||||
@router.get("/tasks", response_model=ListTasksResponse)
|
||||
def list_user_tasks(
|
||||
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
|
||||
status: str | None = Query(
|
||||
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
|
||||
),
|
||||
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
|
||||
page: int = Query(1, ge=1, description="页码,从1开始"),
|
||||
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
|
||||
@@ -248,7 +252,9 @@ def retry_task_by_id(
|
||||
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
|
||||
def list_project_tasks(
|
||||
project_id: str,
|
||||
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
|
||||
status: str | None = Query(
|
||||
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
|
||||
),
|
||||
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
|
||||
page: int = Query(1, ge=1, description="页码,从1开始"),
|
||||
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
|
||||
|
||||
@@ -682,17 +682,50 @@ def create_clips_from_assets_editor(
|
||||
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
|
||||
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
|
||||
asset_scene_points: dict[str, list[float]] = {}
|
||||
invalid_asset_ids: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
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)
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
if asset is None:
|
||||
logger.warning("from-assets 素材不存在或已删除,跳过: asset_id=%s", asset_id)
|
||||
invalid_asset_ids.append(asset_id)
|
||||
continue
|
||||
_dur = float(getattr(asset, "duration", 0.0) or 0.0)
|
||||
if _dur <= 0:
|
||||
# 素材时长缺失(刚上传/分析未完成)或为0,跳过该素材——避免按兜底时长分配无效片段。
|
||||
# 若所有素材都无效,在下面统一抛 400。
|
||||
logger.warning("from-assets 素材时长缺失或为0,跳过: asset_id=%s", asset_id)
|
||||
invalid_asset_ids.append(asset_id)
|
||||
continue
|
||||
valid_asset_ids.append(asset_id)
|
||||
asset_durations[asset_id] = _dur
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
try:
|
||||
smart_score, _ = score_asset(asset)
|
||||
asset_smart_scores[asset_id] = smart_score
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
except Exception:
|
||||
asset_smart_scores[asset_id] = 0.0
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
try:
|
||||
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
|
||||
if cached_points:
|
||||
asset_scene_points[asset_id] = cached_points
|
||||
except Exception:
|
||||
pass
|
||||
if invalid_asset_ids:
|
||||
logger.info(
|
||||
"from-assets %d 个素材无效(时长缺失/不存在,已跳过): %s",
|
||||
len(invalid_asset_ids),
|
||||
",".join(invalid_asset_ids[:5]),
|
||||
)
|
||||
# 所有素材都无效(刚上传未分析完)→ 400 让前端稍后重试,而不是用兜底时长产生错乱片段
|
||||
if not valid_asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材尚未完成分析,请稍后重试",
|
||||
)
|
||||
# 后续分配素材时只在 valid_asset_ids 里挑选
|
||||
unique_asset_ids = valid_asset_ids
|
||||
logger.info(
|
||||
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
|
||||
len(asset_scene_points),
|
||||
|
||||
@@ -13,6 +13,33 @@ class ConfirmGenerationRequest(BaseModel):
|
||||
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
|
||||
|
||||
|
||||
class FinalizeGenerationRequest(BaseModel):
|
||||
"""Step5 点「完成」请求体:用户选定封面后,正式将视频入成品库。"""
|
||||
|
||||
cover_url: str = Field(
|
||||
default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)"
|
||||
)
|
||||
custom_title: str = Field(default="", description="用户自定义成片标题,非空时覆盖 rendered_output.name")
|
||||
|
||||
|
||||
class FinalizeGenerationResponse(BaseModel):
|
||||
"""finalize 响应:返回新创建的成品库视频信息。"""
|
||||
|
||||
video_id: str = Field(description="新创建的成品视频 ID")
|
||||
project_id: str = Field(default="", description="成品所属项目 ID")
|
||||
name: str = Field(default="", description="成片名称")
|
||||
file_size: int = Field(default=0, description="文件大小(字节)")
|
||||
duration: float = Field(default=0.0, description="时长(秒)")
|
||||
thumbnail_url: str = Field(default="", description="最终绑定的缩略图/封面 URL")
|
||||
cover_url: str = Field(default="", description="最终绑定的封面 URL")
|
||||
file_url: str = Field(default="", description="成品视频下载 URL")
|
||||
width: int = Field(default=0)
|
||||
height: int = Field(default=0)
|
||||
fps: float = Field(default=0.0)
|
||||
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
|
||||
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
"""创建生成任务请求。
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""视频生成任务 finalize 服务(#2024)。
|
||||
|
||||
Worker 渲染+上传完成后不再自动入库,标记为 awaiting_cover;用户在 Step5 选好封面
|
||||
点「完成」时由 API 调用本服务:创建 GeneratedVideo 成品库记录(复用 worker 预计算
|
||||
的查重结果)、绑定封面、推进任务到 completed。
|
||||
|
||||
与 AI 数字人 ``ai_avatar_render_service.finalize_job`` 模式一致,
|
||||
只是走 GenerationTask 而非 AiAvatarRenderJob。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenerationFinalizeError(Exception):
|
||||
"""finalize 业务错误,code 供 API 层映射 HTTP 状态码。"""
|
||||
|
||||
def __init__(self, message: str, code: str = "FinalizeError", status_code: int = 400):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class GenerationFinalizeService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def finalize_task(
|
||||
self,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
cover_url: Optional[str] = None,
|
||||
custom_title: Optional[str] = None,
|
||||
):
|
||||
"""执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。
|
||||
|
||||
Returns:
|
||||
GeneratedVideo 领域对象
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(self.db)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
|
||||
|
||||
task = task_repo.get(task_id)
|
||||
if task is None:
|
||||
raise GenerationFinalizeError(f"任务 {task_id} 不存在", "TaskNotFound", 404)
|
||||
|
||||
# ── 幂等:已入库直接返回 ─────────────────────────────────
|
||||
existing = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == task_id).first()
|
||||
if existing is not None:
|
||||
logger.info("[finalize] 幂等命中 task=%s video=%s", task_id, existing.id)
|
||||
_changed = False
|
||||
if cover_url and cover_url.strip() and existing.thumbnail_url != cover_url.strip():
|
||||
existing.thumbnail_url = cover_url.strip()
|
||||
task.cover_url = cover_url.strip()
|
||||
_changed = True
|
||||
if custom_title and custom_title.strip() and (getattr(existing, "name", "") or "") != custom_title.strip():
|
||||
existing.name = custom_title.strip()
|
||||
_changed = True
|
||||
if _changed:
|
||||
self.db.commit()
|
||||
if task.status.value != "completed":
|
||||
try:
|
||||
task.mark_completed(result_count=1)
|
||||
if cover_url and cover_url.strip():
|
||||
task.cover_url = cover_url.strip()
|
||||
task_repo.update(task)
|
||||
self.db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("[finalize] 幂等补 mark_completed 失败: %s", e)
|
||||
self.db.rollback()
|
||||
return video_repo.get(existing.id)
|
||||
|
||||
# ── 状态校验 ─────────────────────────────────────────────
|
||||
if task.status.value != "awaiting_cover":
|
||||
raise GenerationFinalizeError(
|
||||
f"任务当前状态 {task.status.value},无法 finalize(需 awaiting_cover)",
|
||||
"InvalidTaskStatus",
|
||||
400,
|
||||
)
|
||||
|
||||
# ── 封面 ─────────────────────────────────────────────────
|
||||
effective_cover = (cover_url or "").strip() if cover_url else (task.cover_url or "").strip()
|
||||
|
||||
# ── 入库+查重(复用 worker 预计算结果) ──────────────────
|
||||
try:
|
||||
result = finalize_generated_video(
|
||||
task=task,
|
||||
session=self.db,
|
||||
effective_cover_url=effective_cover,
|
||||
custom_name=custom_title,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise GenerationFinalizeError(str(e), "RenderedOutputMissing", 400) from e
|
||||
|
||||
video_id = result["video_id"]
|
||||
|
||||
# 应用自定义标题
|
||||
if custom_title and custom_title.strip():
|
||||
try:
|
||||
_v = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
|
||||
if _v is not None:
|
||||
_v.name = custom_title.strip()
|
||||
self.db.flush()
|
||||
except Exception:
|
||||
logger.warning("[finalize] 更新标题失败: video_id=%s", video_id, exc_info=True)
|
||||
|
||||
# ── 推进任务 ─────────────────────────────────────────────
|
||||
task.mark_completed(result_count=1)
|
||||
task.cover_url = effective_cover
|
||||
# 清理 rendered_output(体积较大,入库后不再需要)
|
||||
meta = dict(task.extra_meta or {})
|
||||
meta.pop("rendered_output", None)
|
||||
task.extra_meta = meta
|
||||
task.updated_at = datetime.now(UTC)
|
||||
task_repo.update(task)
|
||||
self.db.commit()
|
||||
|
||||
video = video_repo.get(video_id)
|
||||
logger.info(
|
||||
"[finalize] task=%s finalized -> video=%s cover=%s dup=%s",
|
||||
task_id,
|
||||
video_id,
|
||||
bool(effective_cover),
|
||||
result.get("is_duplicate", False),
|
||||
)
|
||||
return video
|
||||
Generated
+5
-3
@@ -12,6 +12,8 @@
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"antd": "^5.18.0",
|
||||
"axios": "^1.7.2",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.23",
|
||||
"mp4box": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -3005,9 +3007,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"antd": "^5.18.0",
|
||||
"axios": "^1.7.2",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.23",
|
||||
"mp4box": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
Generated
+137
-115
@@ -18,7 +18,16 @@ importers:
|
||||
version: 5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
axios:
|
||||
specifier: ^1.7.2
|
||||
version: 1.18.1
|
||||
version: 1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
classnames:
|
||||
specifier: ^2.5.1
|
||||
version: 2.5.1
|
||||
dayjs:
|
||||
specifier: ^1.11.23
|
||||
version: 1.11.23
|
||||
mp4box:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
@@ -43,7 +52,7 @@ importers:
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
"@testing-library/user-event":
|
||||
specifier: ^14.5.2
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
version: 14.6.7(@testing-library/dom@10.4.1)
|
||||
"@types/node":
|
||||
specifier: ^20.14.9
|
||||
version: 20.19.43
|
||||
@@ -55,34 +64,34 @@ importers:
|
||||
version: 18.3.7(@types/react@18.3.31)
|
||||
"@typescript-eslint/eslint-plugin":
|
||||
specifier: ^7.13.1
|
||||
version: 7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)
|
||||
version: 7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/parser":
|
||||
specifier: ^7.13.1
|
||||
version: 7.13.1(eslint@8.57.0)(typescript@5.5.3)
|
||||
version: 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
|
||||
"@vitejs/plugin-react":
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1(vite@5.3.1(@types/node@20.19.43))
|
||||
version: 4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))
|
||||
"@vitest/coverage-v8":
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1(vitest@1.6.0)
|
||||
version: 1.6.1(supports-color@7.2.0)(vitest@1.6.0)
|
||||
"@vitest/ui":
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0(vitest@1.6.0)
|
||||
eslint:
|
||||
specifier: ^8.57.0
|
||||
version: 8.57.0
|
||||
version: 8.57.0(supports-color@7.2.0)
|
||||
eslint-config-prettier:
|
||||
specifier: ^9.1.2
|
||||
version: 9.1.2(eslint@8.57.0)
|
||||
version: 9.1.2(eslint@8.57.0(supports-color@7.2.0))
|
||||
eslint-plugin-react-hooks:
|
||||
specifier: ^4.6.2
|
||||
version: 4.6.2(eslint@8.57.0)
|
||||
version: 4.6.2(eslint@8.57.0(supports-color@7.2.0))
|
||||
eslint-plugin-react-refresh:
|
||||
specifier: ^0.4.7
|
||||
version: 0.4.26(eslint@8.57.0)
|
||||
version: 0.4.26(eslint@8.57.0(supports-color@7.2.0))
|
||||
jsdom:
|
||||
specifier: ^24.1.0
|
||||
version: 24.1.0
|
||||
version: 24.1.0(supports-color@7.2.0)
|
||||
prettier:
|
||||
specifier: ^3.9.5
|
||||
version: 3.9.5
|
||||
@@ -94,7 +103,7 @@ importers:
|
||||
version: 5.3.1(@types/node@20.19.43)
|
||||
vitest:
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
|
||||
version: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
|
||||
packages:
|
||||
"@adobe/css-tools@4.5.0":
|
||||
@@ -1127,10 +1136,10 @@ packages:
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
|
||||
"@testing-library/user-event@14.6.1":
|
||||
"@testing-library/user-event@14.6.7":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==,
|
||||
integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==,
|
||||
}
|
||||
engines: { node: ">=12", npm: ">=6" }
|
||||
peerDependencies:
|
||||
@@ -1672,10 +1681,10 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
dayjs@1.11.21:
|
||||
dayjs@1.11.23:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==,
|
||||
integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==,
|
||||
}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -2546,6 +2555,13 @@ packages:
|
||||
integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==,
|
||||
}
|
||||
|
||||
mp4box@2.4.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==,
|
||||
}
|
||||
engines: { node: ">=20.8.1" }
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -3861,20 +3877,20 @@ snapshots:
|
||||
|
||||
"@babel/compat-data@7.29.7": {}
|
||||
|
||||
"@babel/core@7.29.7":
|
||||
"@babel/core@7.29.7(supports-color@7.2.0)":
|
||||
dependencies:
|
||||
"@babel/code-frame": 7.29.7
|
||||
"@babel/generator": 7.29.7
|
||||
"@babel/helper-compilation-targets": 7.29.7
|
||||
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
"@babel/helpers": 7.29.7
|
||||
"@babel/parser": 7.29.7
|
||||
"@babel/template": 7.29.7
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@7.2.0)
|
||||
"@babel/types": 7.29.7
|
||||
"@jridgewell/remapping": 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -3899,19 +3915,19 @@ snapshots:
|
||||
|
||||
"@babel/helper-globals@7.29.7": {}
|
||||
|
||||
"@babel/helper-module-imports@7.29.7":
|
||||
"@babel/helper-module-imports@7.29.7(supports-color@7.2.0)":
|
||||
dependencies:
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@7.2.0)
|
||||
"@babel/types": 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/helper-module-imports": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@7.2.0)
|
||||
"@babel/helper-module-imports": 7.29.7(supports-color@7.2.0)
|
||||
"@babel/helper-validator-identifier": 7.29.7
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3932,14 +3948,14 @@ snapshots:
|
||||
dependencies:
|
||||
"@babel/types": 7.29.7
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@7.2.0)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@7.2.0)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
|
||||
"@babel/runtime@7.29.7": {}
|
||||
@@ -3950,7 +3966,7 @@ snapshots:
|
||||
"@babel/parser": 7.29.7
|
||||
"@babel/types": 7.29.7
|
||||
|
||||
"@babel/traverse@7.29.7":
|
||||
"@babel/traverse@7.29.7(supports-color@7.2.0)":
|
||||
dependencies:
|
||||
"@babel/code-frame": 7.29.7
|
||||
"@babel/generator": 7.29.7
|
||||
@@ -3958,7 +3974,7 @@ snapshots:
|
||||
"@babel/parser": 7.29.7
|
||||
"@babel/template": 7.29.7
|
||||
"@babel/types": 7.29.7
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4044,17 +4060,17 @@ snapshots:
|
||||
"@esbuild/win32-x64@0.21.3":
|
||||
optional: true
|
||||
|
||||
"@eslint-community/eslint-utils@4.9.1(eslint@8.57.0)":
|
||||
"@eslint-community/eslint-utils@4.9.1(eslint@8.57.0(supports-color@7.2.0))":
|
||||
dependencies:
|
||||
eslint: 8.57.0
|
||||
eslint: 8.57.0(supports-color@7.2.0)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
"@eslint-community/regexpp@4.12.2": {}
|
||||
|
||||
"@eslint/eslintrc@2.1.4":
|
||||
"@eslint/eslintrc@2.1.4(supports-color@7.2.0)":
|
||||
dependencies:
|
||||
ajv: 6.15.0
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
espree: 9.6.1
|
||||
globals: 13.19.0
|
||||
ignore: 5.2.0
|
||||
@@ -4067,10 +4083,10 @@ snapshots:
|
||||
|
||||
"@eslint/js@8.57.0": {}
|
||||
|
||||
"@humanwhocodes/config-array@0.11.14":
|
||||
"@humanwhocodes/config-array@0.11.14(supports-color@7.2.0)":
|
||||
dependencies:
|
||||
"@humanwhocodes/object-schema": 2.0.3
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -4360,7 +4376,7 @@ snapshots:
|
||||
"@types/react": 18.3.31
|
||||
"@types/react-dom": 18.3.7(@types/react@18.3.31)
|
||||
|
||||
"@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)":
|
||||
"@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)":
|
||||
dependencies:
|
||||
"@testing-library/dom": 10.4.1
|
||||
|
||||
@@ -4420,15 +4436,15 @@ snapshots:
|
||||
dependencies:
|
||||
"@types/yargs-parser": 21.0.3
|
||||
|
||||
"@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)":
|
||||
"@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
|
||||
dependencies:
|
||||
"@eslint-community/regexpp": 4.12.2
|
||||
"@typescript-eslint/parser": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/parser": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/scope-manager": 7.13.1
|
||||
"@typescript-eslint/type-utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/type-utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/visitor-keys": 7.13.1
|
||||
eslint: 8.57.0
|
||||
eslint: 8.57.0(supports-color@7.2.0)
|
||||
graphemer: 1.4.0
|
||||
ignore: 5.3.1
|
||||
natural-compare: 1.4.0
|
||||
@@ -4438,14 +4454,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@typescript-eslint/parser@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
|
||||
"@typescript-eslint/parser@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager": 7.13.1
|
||||
"@typescript-eslint/types": 7.13.1
|
||||
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
|
||||
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/visitor-keys": 7.13.1
|
||||
debug: 4.4.3
|
||||
eslint: 8.57.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
eslint: 8.57.0(supports-color@7.2.0)
|
||||
optionalDependencies:
|
||||
typescript: 5.5.3
|
||||
transitivePeerDependencies:
|
||||
@@ -4456,12 +4472,12 @@ snapshots:
|
||||
"@typescript-eslint/types": 7.13.1
|
||||
"@typescript-eslint/visitor-keys": 7.13.1
|
||||
|
||||
"@typescript-eslint/type-utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
|
||||
"@typescript-eslint/type-utils@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
|
||||
dependencies:
|
||||
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
|
||||
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0)(typescript@5.5.3)
|
||||
debug: 4.4.3
|
||||
eslint: 8.57.0
|
||||
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
|
||||
"@typescript-eslint/utils": 7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
eslint: 8.57.0(supports-color@7.2.0)
|
||||
ts-api-utils: 1.3.0(typescript@5.5.3)
|
||||
optionalDependencies:
|
||||
typescript: 5.5.3
|
||||
@@ -4470,11 +4486,11 @@ snapshots:
|
||||
|
||||
"@typescript-eslint/types@7.13.1": {}
|
||||
|
||||
"@typescript-eslint/typescript-estree@7.13.1(typescript@5.5.3)":
|
||||
"@typescript-eslint/typescript-estree@7.13.1(supports-color@7.2.0)(typescript@5.5.3)":
|
||||
dependencies:
|
||||
"@typescript-eslint/types": 7.13.1
|
||||
"@typescript-eslint/visitor-keys": 7.13.1
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.9
|
||||
@@ -4485,13 +4501,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@typescript-eslint/utils@7.13.1(eslint@8.57.0)(typescript@5.5.3)":
|
||||
"@typescript-eslint/utils@7.13.1(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.5.3)":
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0)
|
||||
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0))
|
||||
"@typescript-eslint/scope-manager": 7.13.1
|
||||
"@typescript-eslint/types": 7.13.1
|
||||
"@typescript-eslint/typescript-estree": 7.13.1(typescript@5.5.3)
|
||||
eslint: 8.57.0
|
||||
"@typescript-eslint/typescript-estree": 7.13.1(supports-color@7.2.0)(typescript@5.5.3)
|
||||
eslint: 8.57.0(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
@@ -4503,25 +4519,25 @@ snapshots:
|
||||
|
||||
"@ungap/structured-clone@1.3.3": {}
|
||||
|
||||
"@vitejs/plugin-react@4.3.1(vite@5.3.1(@types/node@20.19.43))":
|
||||
"@vitejs/plugin-react@4.3.1(supports-color@7.2.0)(vite@5.3.1(@types/node@20.19.43))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/core": 7.29.7(supports-color@7.2.0)
|
||||
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
|
||||
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))
|
||||
"@types/babel__core": 7.20.5
|
||||
react-refresh: 0.14.2
|
||||
vite: 5.3.1(@types/node@20.19.43)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@vitest/coverage-v8@1.6.1(vitest@1.6.0)":
|
||||
"@vitest/coverage-v8@1.6.1(supports-color@7.2.0)(vitest@1.6.0)":
|
||||
dependencies:
|
||||
"@ampproject/remapping": 2.3.0
|
||||
"@bcoe/v8-coverage": 0.2.3
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0)
|
||||
istanbul-reports: 3.2.0
|
||||
magic-string: 0.30.5
|
||||
magicast: 0.3.3
|
||||
@@ -4529,7 +4545,7 @@ snapshots:
|
||||
std-env: 3.5.0
|
||||
strip-literal: 2.1.1
|
||||
test-exclude: 6.0.0
|
||||
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
|
||||
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4564,7 +4580,7 @@ snapshots:
|
||||
pathe: 1.1.2
|
||||
picocolors: 1.1.1
|
||||
sirv: 2.0.4
|
||||
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0)
|
||||
vitest: 1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
|
||||
"@vitest/utils@1.6.0":
|
||||
dependencies:
|
||||
@@ -4583,21 +4599,21 @@ snapshots:
|
||||
|
||||
acorn@8.17.0: {}
|
||||
|
||||
agent-base@6.0.0:
|
||||
agent-base@6.0.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
agent-base@7.0.2:
|
||||
agent-base@7.0.2(supports-color@7.2.0):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
agent-base@7.1.0:
|
||||
agent-base@7.1.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4632,7 +4648,7 @@ snapshots:
|
||||
"@rc-component/trigger": 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
classnames: 2.5.1
|
||||
copy-to-clipboard: 3.3.3
|
||||
dayjs: 1.11.21
|
||||
dayjs: 1.11.23
|
||||
rc-cascader: 3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-checkbox: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-collapse: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -4648,7 +4664,7 @@ snapshots:
|
||||
rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-picker: 4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-picker: 4.11.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -4688,11 +4704,11 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.18.1:
|
||||
axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
|
||||
form-data: 4.0.6
|
||||
https-proxy-agent: 5.0.1
|
||||
https-proxy-agent: 5.0.1(supports-color@7.2.0)
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -4796,11 +4812,13 @@ snapshots:
|
||||
whatwg-mimetype: 4.0.0
|
||||
whatwg-url: 14.0.0
|
||||
|
||||
dayjs@1.11.21: {}
|
||||
dayjs@1.11.23: {}
|
||||
|
||||
debug@4.4.3:
|
||||
debug@4.4.3(supports-color@7.2.0):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 7.2.0
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
@@ -4883,17 +4901,17 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-prettier@9.1.2(eslint@8.57.0):
|
||||
eslint-config-prettier@9.1.2(eslint@8.57.0(supports-color@7.2.0)):
|
||||
dependencies:
|
||||
eslint: 8.57.0
|
||||
eslint: 8.57.0(supports-color@7.2.0)
|
||||
|
||||
eslint-plugin-react-hooks@4.6.2(eslint@8.57.0):
|
||||
eslint-plugin-react-hooks@4.6.2(eslint@8.57.0(supports-color@7.2.0)):
|
||||
dependencies:
|
||||
eslint: 8.57.0
|
||||
eslint: 8.57.0(supports-color@7.2.0)
|
||||
|
||||
eslint-plugin-react-refresh@0.4.26(eslint@8.57.0):
|
||||
eslint-plugin-react-refresh@0.4.26(eslint@8.57.0(supports-color@7.2.0)):
|
||||
dependencies:
|
||||
eslint: 8.57.0
|
||||
eslint: 8.57.0(supports-color@7.2.0)
|
||||
|
||||
eslint-scope@7.2.2:
|
||||
dependencies:
|
||||
@@ -4902,20 +4920,20 @@ snapshots:
|
||||
|
||||
eslint-visitor-keys@3.4.3: {}
|
||||
|
||||
eslint@8.57.0:
|
||||
eslint@8.57.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0)
|
||||
"@eslint-community/eslint-utils": 4.9.1(eslint@8.57.0(supports-color@7.2.0))
|
||||
"@eslint-community/regexpp": 4.12.2
|
||||
"@eslint/eslintrc": 2.1.4
|
||||
"@eslint/eslintrc": 2.1.4(supports-color@7.2.0)
|
||||
"@eslint/js": 8.57.0
|
||||
"@humanwhocodes/config-array": 0.11.14
|
||||
"@humanwhocodes/config-array": 0.11.14(supports-color@7.2.0)
|
||||
"@humanwhocodes/module-importer": 1.0.1
|
||||
"@nodelib/fs.walk": 1.2.8
|
||||
"@ungap/structured-clone": 1.3.3
|
||||
ajv: 6.15.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
doctrine: 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 7.2.2
|
||||
@@ -5019,7 +5037,9 @@ snapshots:
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
@@ -5115,24 +5135,24 @@ snapshots:
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
http-proxy-agent@7.0.2(supports-color@7.2.0):
|
||||
dependencies:
|
||||
agent-base: 7.1.0
|
||||
debug: 4.4.3
|
||||
agent-base: 7.1.0(supports-color@7.2.0)
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
https-proxy-agent@5.0.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
agent-base: 6.0.0
|
||||
debug: 4.4.3
|
||||
agent-base: 6.0.0(supports-color@7.2.0)
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.4:
|
||||
https-proxy-agent@7.0.4(supports-color@7.2.0):
|
||||
dependencies:
|
||||
agent-base: 7.0.2
|
||||
debug: 4.4.3
|
||||
agent-base: 7.0.2(supports-color@7.2.0)
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -5186,10 +5206,10 @@ snapshots:
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
istanbul-lib-source-maps@5.0.6(supports-color@7.2.0):
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping": 0.3.31
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -5209,15 +5229,15 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsdom@24.1.0:
|
||||
jsdom@24.1.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
cssstyle: 4.0.1
|
||||
data-urls: 5.0.0
|
||||
decimal.js: 10.6.0
|
||||
form-data: 4.0.6
|
||||
html-encoding-sniffer: 4.0.0
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.4
|
||||
http-proxy-agent: 7.0.2(supports-color@7.2.0)
|
||||
https-proxy-agent: 7.0.4(supports-color@7.2.0)
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
nwsapi: 2.2.24
|
||||
parse5: 7.1.2
|
||||
@@ -5331,6 +5351,8 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.4
|
||||
|
||||
mp4box@2.4.1: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
@@ -5611,7 +5633,7 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
rc-picker@4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
rc-picker@4.11.3(dayjs@1.11.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
"@babel/runtime": 7.29.7
|
||||
"@rc-component/trigger": 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -5622,7 +5644,7 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
dayjs: 1.11.21
|
||||
dayjs: 1.11.23
|
||||
|
||||
rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
@@ -6008,10 +6030,10 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
vite-node@1.6.0(@types/node@20.19.43):
|
||||
vite-node@1.6.0(@types/node@20.19.43)(supports-color@7.2.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
pathe: 1.1.2
|
||||
picocolors: 1.1.1
|
||||
vite: 5.3.1(@types/node@20.19.43)
|
||||
@@ -6034,7 +6056,7 @@ snapshots:
|
||||
"@types/node": 20.19.43
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0):
|
||||
vitest@1.6.0(@types/node@20.19.43)(@vitest/ui@1.6.0)(jsdom@24.1.0(supports-color@7.2.0))(supports-color@7.2.0):
|
||||
dependencies:
|
||||
"@vitest/expect": 1.6.0
|
||||
"@vitest/runner": 1.6.0
|
||||
@@ -6043,7 +6065,7 @@ snapshots:
|
||||
"@vitest/utils": 1.6.0
|
||||
acorn-walk: 8.3.5
|
||||
chai: 4.3.10
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
execa: 8.0.1
|
||||
local-pkg: 0.5.0
|
||||
magic-string: 0.30.5
|
||||
@@ -6054,12 +6076,12 @@ snapshots:
|
||||
tinybench: 2.5.1
|
||||
tinypool: 0.8.3
|
||||
vite: 5.3.1(@types/node@20.19.43)
|
||||
vite-node: 1.6.0(@types/node@20.19.43)
|
||||
vite-node: 1.6.0(@types/node@20.19.43)(supports-color@7.2.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
"@types/node": 20.19.43
|
||||
"@vitest/ui": 1.6.0(vitest@1.6.0)
|
||||
jsdom: 24.1.0
|
||||
jsdom: 24.1.0(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -11,7 +11,7 @@ import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
baseURL: "/api/v1",
|
||||
timeout: 10000,
|
||||
timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 后端路由: /api/v1/cover-templates
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
||||
import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
export interface CoverTemplateListResponse {
|
||||
items: CoverTemplate[]
|
||||
@@ -12,14 +12,7 @@ export interface CoverTemplateListResponse {
|
||||
|
||||
export interface CoverTemplateCreateRequest {
|
||||
name: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
config?: CoverEditorConfig
|
||||
}
|
||||
|
||||
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/** #2024 Step5 「完成」入库 —— 将 awaiting_cover 任务正式写入成品库 */
|
||||
export interface FinalizeGenerationRequest {
|
||||
/** 用户选定的封面图片 URL;为空则使用任务默认封面(自动截帧/智能封面) */
|
||||
cover_url?: string
|
||||
/** 用户自定义成片标题,非空时覆盖 rendered_output.name */
|
||||
custom_title?: string
|
||||
}
|
||||
|
||||
export interface FinalizeGenerationResponse {
|
||||
video_id: string
|
||||
project_id: string
|
||||
name: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string
|
||||
cover_url: string
|
||||
file_url: string
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
/** success=新建成功;already_finalized=幂等返回已有记录 */
|
||||
status: string
|
||||
is_duplicate: boolean
|
||||
}
|
||||
|
||||
export const finalizeGeneration = async (
|
||||
taskId: string,
|
||||
params: FinalizeGenerationRequest = {},
|
||||
): Promise<FinalizeGenerationResponse> => {
|
||||
const response = await apiClient.post<FinalizeGenerationResponse>(
|
||||
`/generation/tasks/${taskId}/finalize`,
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -6,17 +6,20 @@ import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`)
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`, { timeout: 30_000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消;超时 60s 防止大 config 写入失败) */
|
||||
export async function updateEditPlan(
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, {
|
||||
signal,
|
||||
timeout: 60_000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,383 +1,391 @@
|
||||
/* ============================================================
|
||||
标题模板系统(#2003):选择器 + 编辑器 + 保存弹窗
|
||||
标题模板系统 v3(按 sketch 重构)
|
||||
- 大卡片网格(图片背景 + 透明 Canvas 叠字 + 始终可见操作按钮)
|
||||
- 编辑器弹窗(左竖屏预览 + 右参数 Tab)
|
||||
============================================================ */
|
||||
|
||||
/* ── Modal 头部 ── */
|
||||
.tt-modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 32px;
|
||||
}
|
||||
.tt-modal .ant-modal-body {
|
||||
padding: 16px 20px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── 分组 ── */
|
||||
.tt-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tt-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tt-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
/* ── 空状态 ── */
|
||||
.tt-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border: 1px dashed var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tt-empty-emoji {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── 卡片网格(4列) ── */
|
||||
.tt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tt-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.tt-card {
|
||||
border: 2px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.tt-card:hover {
|
||||
border-color: var(--primary-color, #7c3aed);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
.tt-card.active {
|
||||
border-color: var(--primary-color, #7c3aed);
|
||||
background: #faf5ff;
|
||||
box-shadow: 0 0 0 1px var(--primary-color, #7c3aed);
|
||||
}
|
||||
|
||||
/* ── 预览区 ── */
|
||||
.tt-card-preview {
|
||||
position: relative;
|
||||
height: 80px;
|
||||
background: #0f172a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tt-card-preview canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* ── 标签 ── */
|
||||
.tt-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
.tt-tag.sys {
|
||||
background: rgba(124, 58, 237, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
.tt-tag.mine {
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: #fff;
|
||||
}
|
||||
.tt-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #7c3aed);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── 卡片操作按钮(hover 显示) ── */
|
||||
.tt-card-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: none;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.tt-card.active .tt-check + .tt-card-actions {
|
||||
top: 30px;
|
||||
}
|
||||
.tt-card:hover .tt-card-actions {
|
||||
display: flex;
|
||||
}
|
||||
.tt-ico-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.tt-ico-btn:hover {
|
||||
background: var(--primary-color, #7c3aed);
|
||||
}
|
||||
.tt-ico-btn.danger:hover {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* ── 卡片底部 meta ── */
|
||||
.tt-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
.tt-card-emoji {
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tt-card-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── 编辑器(左右布局) ── */
|
||||
.tt-editor {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 16px;
|
||||
min-height: 480px;
|
||||
}
|
||||
.tt-editor-preview {
|
||||
background: #0f172a;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
.tt-editor-panel {
|
||||
overflow-y: auto;
|
||||
max-height: 65vh;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tt-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
#2003 内联网格(直接嵌在 TitleStylePanel 预览下方)
|
||||
============================================================ */
|
||||
|
||||
.tt-inline-wrap {
|
||||
/* ── 面板容器(模板模式) ── */
|
||||
.ttv3-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* section header (label + 新建按钮) */
|
||||
.tt-section-header {
|
||||
.ttv3-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.ttv3-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
.ttv3-new-btn.ant-btn {
|
||||
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(108, 92, 231, 0.25);
|
||||
}
|
||||
.ttv3-new-btn.ant-btn:hover {
|
||||
background: linear-gradient(135deg, #5b4cdb, #8c83f5) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.ttv3-section-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tt-section-header .tt-section-label,
|
||||
.tt-section-header .tt-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
.tt-new-btn.ant-btn {
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
.ttv3-section {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 内联网格:自适应列数(窄面板 2 列、宽面板 3-4 列) */
|
||||
.tt-grid--inline {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
/* ── 空状态 ── */
|
||||
.ttv3-empty {
|
||||
background: #f8f8fc;
|
||||
border-radius: 12px;
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.tt-grid--inline {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.ttv3-empty-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
.tt-grid--inline {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.ttv3-empty-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 内联卡片 */
|
||||
.tt-card--inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 2px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
/* ── 卡片网格:minmax(180px,1fr) 自适应 ── */
|
||||
.ttv3-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── 卡片:3:4 竖版,圆角 14px ── */
|
||||
.ttv3-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 3px solid #e8e8ed;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
}
|
||||
.tt-card--inline:hover {
|
||||
border-color: var(--primary-color, #7c3aed);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.12);
|
||||
.ttv3-card:hover {
|
||||
border-color: #c5c0f0;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.tt-card--inline.active {
|
||||
border-color: var(--primary-color, #7c3aed);
|
||||
background: #faf5ff;
|
||||
box-shadow: 0 0 0 1px var(--primary-color, #7c3aed);
|
||||
.ttv3-card.selected {
|
||||
border-color: #6c5ce7;
|
||||
box-shadow: 0 4px 16px rgba(108, 92, 231, 0.25);
|
||||
}
|
||||
|
||||
/* 大预览区(上半):深色背景+canvas */
|
||||
.tt-card-preview--lg {
|
||||
/* ── 卡片预览区(3:4) ── */
|
||||
.ttv3-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 3/4;
|
||||
position: relative;
|
||||
height: 90px;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
overflow: hidden;
|
||||
border-radius: 11px 11px 0 0;
|
||||
}
|
||||
.ttv3-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
/* 暗色渐变遮罩:顶部15%半透明黑 + 中部透明 + 底部45%黑 */
|
||||
.ttv3-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0.45) 0%,
|
||||
rgba(0, 0, 0, 0.15) 15%,
|
||||
transparent 30%,
|
||||
transparent 55%,
|
||||
rgba(0, 0, 0, 0.6) 100%
|
||||
);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 透明 Canvas 标题填充整个预览区 */
|
||||
.ttv3-preview .tt-fill-canvas-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.ttv3-preview .tt-fill-canvas-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── 左上角角标(系统/我的) ── */
|
||||
.ttv3-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
z-index: 3;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.ttv3-badge--sys {
|
||||
background: rgba(108, 92, 231, 0.88);
|
||||
}
|
||||
.ttv3-badge--mine {
|
||||
background: rgba(0, 184, 148, 0.88);
|
||||
}
|
||||
|
||||
/* ── 右上角勾选圆圈 ── */
|
||||
.ttv3-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid rgba(255, 255, 255, 0.7);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tt-card-preview--lg canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
.ttv3-check.on {
|
||||
background: #6c5ce7;
|
||||
border-color: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 选中圆点(单选 radio 样式) */
|
||||
.tt-radio-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--primary-color, #7c3aed);
|
||||
background: #fff;
|
||||
/* ── 卡片底栏(名称 + 操作按钮) ── */
|
||||
.ttv3-footer {
|
||||
padding: 10px 10px 12px;
|
||||
}
|
||||
.tt-radio-dot::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #7c3aed);
|
||||
}
|
||||
|
||||
/* 标签位置微调(内联卡片) */
|
||||
.tt-card--inline .tt-tag {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
/* hover/选中时操作按钮位置(避开 radio-dot) */
|
||||
.tt-card--inline .tt-card-actions {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: none;
|
||||
gap: 4px;
|
||||
}
|
||||
.tt-card--inline:hover .tt-card-actions {
|
||||
display: flex;
|
||||
}
|
||||
/* 选中态 hover 时 actions 下移以避开 radio-dot */
|
||||
.tt-card--inline.active:hover .tt-card-actions {
|
||||
top: 30px;
|
||||
}
|
||||
.tt-card--inline.active .tt-radio-dot {
|
||||
display: block;
|
||||
}
|
||||
.tt-card--inline.active:hover .tt-radio-dot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 底部 meta(下半):emoji + name */
|
||||
.tt-card-meta--lg {
|
||||
.ttv3-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #1f2937);
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--border-light, #f3f4f6);
|
||||
}
|
||||
.tt-card--inline.active .tt-card-meta--lg {
|
||||
background: #faf5ff;
|
||||
}
|
||||
.tt-card-meta--lg .tt-card-emoji {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: #1f2937;
|
||||
}
|
||||
.ttv3-emoji {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tt-card-meta--lg .tt-card-name {
|
||||
flex: 1;
|
||||
.ttv3-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ttv3-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ttv3-tag--sys {
|
||||
background: #f0ecff;
|
||||
color: #6c5ce7;
|
||||
}
|
||||
.ttv3-tag--mine {
|
||||
background: #e6f9f4;
|
||||
color: #00b894;
|
||||
}
|
||||
|
||||
/* 空状态(紧凑型) */
|
||||
.tt-empty--sm {
|
||||
padding: 16px 12px;
|
||||
/* ── 操作按钮:始终可见,等宽排列 ── */
|
||||
.ttv3-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.ttv3-act {
|
||||
flex: 1;
|
||||
padding: 5px 0;
|
||||
border: 1px solid #e8e8ed;
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ttv3-act:hover:not(:disabled) {
|
||||
background: #f5f5fa;
|
||||
border-color: #d5d3e8;
|
||||
}
|
||||
.ttv3-act--primary {
|
||||
background: #6c5ce7;
|
||||
color: #fff;
|
||||
border-color: #6c5ce7;
|
||||
}
|
||||
.ttv3-act--primary:hover:not(:disabled) {
|
||||
background: #5b4cdb;
|
||||
border-color: #5b4cdb;
|
||||
}
|
||||
.ttv3-act--danger {
|
||||
color: #e74c3c;
|
||||
}
|
||||
.ttv3-act--danger:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
}
|
||||
.ttv3-act:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── FillPreview 公共容器 ── */
|
||||
.tt-fill-canvas-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── params-only(编辑器右侧)去掉多余 margin ── */
|
||||
.ttv3-params-only {
|
||||
padding: 0;
|
||||
}
|
||||
.ttv3-params-only .ant-tabs {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
编辑器 Modal(v3)
|
||||
============================================================ */
|
||||
.ttv3-modal .ant-modal-content {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
.ttv3-modal .ant-modal-header {
|
||||
padding: 16px 20px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.ttv3-modal .ant-modal-body {
|
||||
padding: 0;
|
||||
max-height: 75vh;
|
||||
}
|
||||
.ttv3-modal .ant-modal-footer {
|
||||
padding: 14px 20px;
|
||||
margin: 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 编辑器两栏布局 */
|
||||
.ttv3-editor {
|
||||
display: flex;
|
||||
min-height: 500px;
|
||||
}
|
||||
.ttv3-editor-left {
|
||||
width: 300px;
|
||||
padding: 20px;
|
||||
background: #f8f8fc;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
.ttv3-editor-canvas {
|
||||
width: 200px;
|
||||
aspect-ratio: 9/16;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ttv3-editor-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.ttv3-editor-canvas-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.ttv3-editor-canvas-inner canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
.ttv3-editor-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.ttv3-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.ttv3-form-row label {
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
min-width: 44px;
|
||||
}
|
||||
.tt-empty--sm .tt-empty-emoji {
|
||||
font-size: 22px;
|
||||
margin-bottom: 0;
|
||||
.ttv3-form-row--grow {
|
||||
flex: 1;
|
||||
}
|
||||
.ttv3-form-row--grow .ant-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ttv3-editor-right {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 侧栏较窄时(380px 侧栏):强制 2 列,卡片稍微紧凑 */
|
||||
@media (max-width: 540px) {
|
||||
.ttv3-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.ttv3-act {
|
||||
font-size: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ttv3-act .anticon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
/**
|
||||
* 标题模板编辑器(#2003)
|
||||
* 标题模板编辑器(v3 重构)
|
||||
*
|
||||
* 左侧大预览(400×225,16:9),右侧复用 TitleStylePanel 进行参数调整。
|
||||
* 编辑完成后点"保存"弹出 SaveTemplateModal(名称必填),保存后回调 onSaved。
|
||||
* - Modal 弹窗 860px 宽
|
||||
* - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框
|
||||
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式
|
||||
* - 底部:取消 / 保存模板 按钮
|
||||
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理)
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Modal, Button, Input, message } from "antd"
|
||||
import TitleStylePanel from "../../pages/generate/components/title/TitleStylePanel"
|
||||
import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview"
|
||||
import { POSITION_OPTIONS } from "../../pages/generate/constants"
|
||||
import { FONT_OPTIONS, TITLE_PRESETS as SYSTEM_TITLE_PRESETS } from "./constants"
|
||||
import { FONT_OPTIONS } from "./constants"
|
||||
import type { TitleSettings } from "../../pages/generate/types"
|
||||
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
|
||||
import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils"
|
||||
import { useTitleTemplates } from "./useTitleTemplates"
|
||||
import type { TitleTemplate } from "./template-types"
|
||||
import type { TitleStyleConfig } from "./types"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
template: TitleTemplate
|
||||
onClose: () => void
|
||||
onSaved: (template: TitleTemplate) => void
|
||||
/** 用户点击保存:将编辑结果回调给父组件(父组件统一做 CRUD,避免双 hook 实例不同步) */
|
||||
onSave: (data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => void
|
||||
}
|
||||
|
||||
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved }) => {
|
||||
const { updateTemplate, createTemplate } = useTitleTemplates()
|
||||
// 编辑态:完整 TitleSettings(camelCase)
|
||||
/** 编辑器预览用的背景图(复用卡片池第一张) */
|
||||
const EDITOR_BG = "/title-templates/portrait1.jpg"
|
||||
|
||||
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
|
||||
const [settings, setSettings] = useState<TitleSettings>(() => ({
|
||||
...DEFAULT_TITLE_SETTINGS_FULL,
|
||||
...titleStyleConfigToCamel(template.style || {}),
|
||||
title: "标题预览",
|
||||
title: "预览标题文字",
|
||||
}))
|
||||
const [formName, setFormName] = useState(template.name || "")
|
||||
const [formDesc, setFormDesc] = useState(template.description || "")
|
||||
const [formEmoji, setFormEmoji] = useState(template.emoji || "✨")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 每次 open 重置
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSettings({
|
||||
...DEFAULT_TITLE_SETTINGS_FULL,
|
||||
...titleStyleConfigToCamel(template.style || {}),
|
||||
title: "标题预览",
|
||||
title: "预览标题文字",
|
||||
})
|
||||
setFormName(template.name || "")
|
||||
setFormDesc(template.description || "")
|
||||
setFormEmoji(template.emoji || "✨")
|
||||
}
|
||||
}, [open, template])
|
||||
|
||||
/** 把单个 updater 包装成 setSettings patch */
|
||||
const upd = (patch: Partial<TitleSettings>) => setSettings((s) => ({ ...s, ...patch }))
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -62,34 +63,32 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved
|
||||
setSaving(true)
|
||||
try {
|
||||
const snake = camelToTitleStyleConfig(settings)
|
||||
if (template.isBuiltin) {
|
||||
// 内置模板保存时创建一个副本
|
||||
const t = createTemplate({ name, description: formDesc, emoji: formEmoji, style: snake })
|
||||
onSaved(t)
|
||||
} else {
|
||||
updateTemplate(template.id, { name, description: formDesc, emoji: formEmoji, style: snake })
|
||||
onSaved({
|
||||
...template,
|
||||
name,
|
||||
description: formDesc,
|
||||
emoji: formEmoji,
|
||||
style: snake,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
onSave({ name, emoji: formEmoji, style: snake })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器内的预览用 settings:字号适配竖屏
|
||||
const previewSettings = useMemo<TitleSettings>(() => {
|
||||
// 竖屏宽度 200px,按比例缩放字号,让预览看起来协调
|
||||
return { ...settings, size: Math.round(settings.size * 0.55) }
|
||||
}, [settings])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={template.isBuiltin ? `复制模板:${template.name}` : `编辑模板:${template.name}`}
|
||||
title={
|
||||
!template.id
|
||||
? "新建模板"
|
||||
: template.isBuiltin
|
||||
? `基于「${template.name}」创建模板`
|
||||
: `编辑模板:${template.name}`
|
||||
}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={960}
|
||||
width={860}
|
||||
footer={
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={handleSave}>
|
||||
保存模板
|
||||
@@ -97,54 +96,50 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved
|
||||
</div>
|
||||
}
|
||||
destroyOnClose
|
||||
className="tt-modal tt-editor-modal"
|
||||
className="ttv3-modal"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{/* 模板名称/描述表单 */}
|
||||
<div
|
||||
style={{ display: "grid", gridTemplateColumns: "60px 1fr 1fr", gap: 10, marginBottom: 14 }}
|
||||
>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: "block", marginBottom: 6 }}>
|
||||
图标
|
||||
</label>
|
||||
<Input
|
||||
value={formEmoji}
|
||||
maxLength={2}
|
||||
style={{ textAlign: "center" }}
|
||||
onChange={(e) => setFormEmoji(e.target.value)}
|
||||
/>
|
||||
<div className="ttv3-editor">
|
||||
{/* 左侧:竖屏预览 + 名称 */}
|
||||
<div className="ttv3-editor-left">
|
||||
<div className="ttv3-editor-canvas">
|
||||
<img className="ttv3-editor-bg" src={EDITOR_BG} alt="" />
|
||||
<div className="ttv3-vignette" />
|
||||
<div className="ttv3-editor-canvas-inner">
|
||||
<TitleMiniPreview
|
||||
settings={previewSettings}
|
||||
width={200}
|
||||
sampleText="预览标题文字"
|
||||
transparent
|
||||
portrait
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ttv3-editor-form">
|
||||
<div className="ttv3-form-row">
|
||||
<label>图标</label>
|
||||
<Input
|
||||
value={formEmoji}
|
||||
maxLength={2}
|
||||
style={{ textAlign: "center", width: 64 }}
|
||||
onChange={(e) => setFormEmoji(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ttv3-form-row ttv3-form-row--grow">
|
||||
<label>
|
||||
模板名称<span style={{ color: "#ef4444" }}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="给模板起个名字"
|
||||
value={formName}
|
||||
maxLength={20}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: "block", marginBottom: 6 }}>
|
||||
模板名称<span style={{ color: "#ef4444" }}> *</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="给模板起个名字,例如:抖音爆款黄"
|
||||
value={formName}
|
||||
maxLength={20}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: "block", marginBottom: 6 }}>
|
||||
模板描述
|
||||
</label>
|
||||
<Input
|
||||
placeholder="简短描述(可选)"
|
||||
value={formDesc}
|
||||
maxLength={40}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tt-editor">
|
||||
{/* 左侧实时预览 */}
|
||||
<div className="tt-editor-preview">
|
||||
<TitleMiniPreview settings={settings} width={320} sampleText="标题预览" height={180} />
|
||||
</div>
|
||||
{/* 右侧编辑器 — 复用 TitleStylePanel 的细粒度能力 */}
|
||||
<div className="tt-editor-panel">
|
||||
{/* 右侧:参数 Tab */}
|
||||
<div className="ttv3-editor-right">
|
||||
<TitleStylePanel
|
||||
settings={settings}
|
||||
onUpdatePosition={(p) => upd({ position: p, posX: null, posY: null })}
|
||||
@@ -160,23 +155,15 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved
|
||||
})
|
||||
}
|
||||
onToggleShadow={() => upd({ shadow: !settings.shadow })}
|
||||
onApplyPreset={(key) => {
|
||||
// 在编辑器中点击系统预设:把 preset 作为编辑起点
|
||||
const pp = SYSTEM_TITLE_PRESETS.find((x) => x.key === key)
|
||||
if (pp) {
|
||||
setSettings((cs) => ({
|
||||
...cs,
|
||||
...titleStyleConfigToCamel(pp.style),
|
||||
lineOverrides: [],
|
||||
title: "标题预览",
|
||||
}))
|
||||
}
|
||||
onApplyPreset={() => {
|
||||
/* 编辑器内不使用系统预设快捷键 */
|
||||
}}
|
||||
onUpdateStyle={(patch) => upd(patch)}
|
||||
activePreset={null}
|
||||
titlePresets={[]}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
paramsOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* 标题模板选择器(#2003)
|
||||
*
|
||||
* 参考「IP智能体设置 → 字幕设置 → 字幕模板」交互:
|
||||
* - Modal 打开后展示模板卡片网格(4 列),每张卡片含 Canvas 预览 + 名称 + 标签
|
||||
* - 系统模板(sys:):只能「复制为我的」「应用」
|
||||
* - 自定义模板(usr:):支持编辑/复制/导出/删除
|
||||
* - 右上角「+ 新建模板」按钮进入编辑器
|
||||
*
|
||||
* 受控使用:visible/onCancel/onSelect
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { Modal, Button, message, Popconfirm, Tooltip } from "antd"
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
ExportOutlined,
|
||||
CheckOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview"
|
||||
import type { TitleSettings } from "../../pages/generate/types"
|
||||
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
|
||||
import { titleStyleConfigToCamel } from "./utils"
|
||||
import { useTitleTemplates } from "./useTitleTemplates"
|
||||
import type { TitleTemplate } from "./template-types"
|
||||
import TitleTemplateEditor from "./TitleTemplateEditor"
|
||||
import "./TitleTemplate.css"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 当前选中模板 id(可选) */
|
||||
selectedTemplateId?: string | null
|
||||
onClose: () => void
|
||||
/** 选择/应用模板:返回 camelCase TitleSettings 给调用方 */
|
||||
onSelect: (settings: TitleSettings, template: TitleTemplate) => void
|
||||
}
|
||||
|
||||
/** 把模板 style 渲染为完整 TitleSettings(带默认值),用于预览 */
|
||||
function templateToSettings(t: TitleTemplate): TitleSettings {
|
||||
return { ...DEFAULT_TITLE_SETTINGS_FULL, ...titleStyleConfigToCamel(t.style) }
|
||||
}
|
||||
|
||||
const TitleTemplateSelector: React.FC<Props> = ({
|
||||
open,
|
||||
selectedTemplateId,
|
||||
onClose,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { templates, duplicateTemplate, deleteTemplate, exportTemplate, createTemplate } =
|
||||
useTitleTemplates()
|
||||
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(null)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return {
|
||||
builtin: templates.filter((t) => t.isBuiltin),
|
||||
custom: templates.filter((t) => !t.isBuiltin),
|
||||
}
|
||||
}, [templates])
|
||||
|
||||
const handleCreate = () => {
|
||||
// 基于当前默认样式创建空白模板进入编辑
|
||||
const t = createTemplate({
|
||||
name: "我的标题模板",
|
||||
style: {
|
||||
font: DEFAULT_TITLE_SETTINGS_FULL.font,
|
||||
size: 56,
|
||||
color: "#ffffff",
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
stroke_width: 4,
|
||||
stroke_color: "#000000",
|
||||
shadow: false,
|
||||
bg_enabled: false,
|
||||
line_height: 1.2,
|
||||
max_chars_per_line: 10,
|
||||
position: "bottom",
|
||||
margin_top: 32,
|
||||
line_overrides: [],
|
||||
cover_title_config: null,
|
||||
},
|
||||
})
|
||||
setEditingTemplate(t)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (t: TitleTemplate) => {
|
||||
setEditingTemplate(t)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const handleDuplicate = (t: TitleTemplate) => {
|
||||
const dup = duplicateTemplate(t.id)
|
||||
if (dup) message.success(`已复制:${dup.name}`)
|
||||
}
|
||||
|
||||
const handleDelete = (t: TitleTemplate) => {
|
||||
deleteTemplate(t.id)
|
||||
message.success("已删除模板")
|
||||
}
|
||||
|
||||
const handleExport = (t: TitleTemplate) => {
|
||||
const json = exportTemplate(t.id)
|
||||
if (!json) return
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${t.name}.title-template.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleApply = (t: TitleTemplate) => {
|
||||
const settings = templateToSettings(t)
|
||||
onSelect(settings, t)
|
||||
}
|
||||
|
||||
const renderCard = (t: TitleTemplate) => {
|
||||
const isSelected = selectedTemplateId === t.id
|
||||
const settings = templateToSettings(t)
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`tt-card${isSelected ? " active" : ""}`}
|
||||
onClick={() => handleApply(t)}
|
||||
>
|
||||
<div className="tt-card-preview">
|
||||
<TitleMiniPreview settings={settings} width={200} sampleText="标题预览" />
|
||||
<span className={`tt-tag${t.isBuiltin ? " sys" : " mine"}`}>
|
||||
{t.isBuiltin ? "系统" : "我的"}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="tt-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
<div className="tt-card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{!t.isBuiltin && (
|
||||
<Tooltip title="编辑">
|
||||
<button type="button" className="tt-ico-btn" onClick={() => handleEdit(t)}>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="复制">
|
||||
<button type="button" className="tt-ico-btn" onClick={() => handleDuplicate(t)}>
|
||||
<CopyOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="导出">
|
||||
<button type="button" className="tt-ico-btn" onClick={() => handleExport(t)}>
|
||||
<ExportOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!t.isBuiltin && (
|
||||
<Popconfirm title="删除该模板?" onConfirm={() => handleDelete(t)}>
|
||||
<Tooltip title="删除">
|
||||
<button type="button" className="tt-ico-btn danger">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tt-card-meta">
|
||||
<span className="tt-card-emoji">{t.emoji || "✨"}</span>
|
||||
<span className="tt-card-name" title={t.name}>
|
||||
{t.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={
|
||||
<div className="tt-modal-title">
|
||||
<span>🎨 选择标题模板</span>
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={open && !editorOpen}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={880}
|
||||
className="tt-modal"
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="tt-section">
|
||||
<div className="tt-section-title">我的模板</div>
|
||||
{grouped.custom.length === 0 ? (
|
||||
<div className="tt-empty">
|
||||
<div className="tt-empty-emoji">✨</div>
|
||||
<div>还没有自定义模板,点击右上角「新建模板」创建第一个吧</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tt-grid">{grouped.custom.map(renderCard)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tt-section">
|
||||
<div className="tt-section-title">系统模板</div>
|
||||
<div className="tt-grid">{grouped.builtin.map(renderCard)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{editorOpen && editingTemplate && (
|
||||
<TitleTemplateEditor
|
||||
open={editorOpen}
|
||||
template={editingTemplate}
|
||||
onClose={() => {
|
||||
setEditorOpen(false)
|
||||
setEditingTemplate(null)
|
||||
}}
|
||||
onSaved={(t) => {
|
||||
setEditorOpen(false)
|
||||
setEditingTemplate(null)
|
||||
message.success(`已保存:${t.name}`)
|
||||
// 保存后自动应用
|
||||
handleApply(t)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleTemplateSelector
|
||||
@@ -127,6 +127,7 @@ export interface TitlePreset {
|
||||
}
|
||||
|
||||
const BASE: Partial<TitleStyleConfig> = {
|
||||
position: "bottom",
|
||||
line_overrides: [],
|
||||
cover_title_config: null,
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ const AiAvatarPage: React.FC = () => {
|
||||
"generating",
|
||||
)
|
||||
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
|
||||
/* ── 对口型耗时计时(秒) ── */
|
||||
const [lipsyncElapsed, setLipsyncElapsed] = useState(0)
|
||||
const lipsyncStartAtRef = useRef<number>(0)
|
||||
const lipsyncTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
/* ── 渲染进度弹窗 ── */
|
||||
const [showRenderModal, setShowRenderModal] = useState(false)
|
||||
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
|
||||
@@ -222,6 +226,13 @@ const AiAvatarPage: React.FC = () => {
|
||||
setShowLipsyncModal(true)
|
||||
setLipsyncStatus("generating")
|
||||
setLipsyncErrorMessage("")
|
||||
// 启动计时器
|
||||
lipsyncStartAtRef.current = Date.now()
|
||||
setLipsyncElapsed(0)
|
||||
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = setInterval(() => {
|
||||
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
|
||||
}, 1000)
|
||||
|
||||
const asset = await getAssetById(video.id)
|
||||
const videoUrl = asset?.file_url
|
||||
@@ -265,6 +276,11 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.setLipsyncJob(updated)
|
||||
if (updated.status === "completed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
|
||||
setLipsyncStatus("completed")
|
||||
setTimeout(() => {
|
||||
setShowLipsyncModal(false)
|
||||
@@ -272,6 +288,10 @@ const AiAvatarPage: React.FC = () => {
|
||||
}, 1000)
|
||||
} else if (updated.status === "failed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setLipsyncStatus("failed")
|
||||
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
|
||||
}
|
||||
@@ -285,6 +305,10 @@ const AiAvatarPage: React.FC = () => {
|
||||
data: (err as { response?: { data?: unknown } })?.response?.data,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setShowLipsyncModal(false)
|
||||
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
|
||||
}
|
||||
@@ -305,15 +329,21 @@ const AiAvatarPage: React.FC = () => {
|
||||
clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = null
|
||||
}
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setShowLipsyncModal(false)
|
||||
setLipsyncStatus("generating")
|
||||
setLipsyncErrorMessage("")
|
||||
setLipsyncElapsed(0)
|
||||
}, [])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
@@ -963,6 +993,19 @@ const AiAvatarPage: React.FC = () => {
|
||||
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
|
||||
对口型视频生成中…
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
color: "#7c3aed",
|
||||
}}
|
||||
>
|
||||
{`${Math.floor(lipsyncElapsed / 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}:${(lipsyncElapsed % 60).toString().padStart(2, "0")}`}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
|
||||
请勿关闭页面,完成后将自动提示
|
||||
</div>
|
||||
@@ -974,6 +1017,20 @@ const AiAvatarPage: React.FC = () => {
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
|
||||
对口型视频生成完成
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
color: "#10b981",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
总耗时{" "}
|
||||
{Math.floor(lipsyncElapsed / 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}
|
||||
:{(lipsyncElapsed % 60).toString().padStart(2, "0")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{lipsyncStatus === "failed" && (
|
||||
|
||||
@@ -8,15 +8,12 @@ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import VoiceSelectModal from "./components/VoiceSelectModal"
|
||||
import ScriptSelectModal from "./components/ScriptSelectModal"
|
||||
import TtsVoiceModal from "./components/TtsVoiceModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
|
||||
import PreviewCountModal from "./components/PreviewCountModal"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
@@ -24,13 +21,10 @@ import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { finalizeGeneration } from "@/api/generation/finalize"
|
||||
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets"
|
||||
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
|
||||
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { hasEnoughPoints } from "./hooks/pointsCost"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
@@ -101,8 +95,6 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
previewCount,
|
||||
setPreviewCount,
|
||||
previewTitles,
|
||||
@@ -148,6 +140,9 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 数量选择弹窗 ── */
|
||||
const [countModalOpen, setCountModalOpen] = useState(false)
|
||||
|
||||
/* ── Step5 保存中状态 ── */
|
||||
const [finishing, setFinishing] = useState(false)
|
||||
|
||||
/* ── #1970 流程重构:分支弹窗 ── */
|
||||
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
|
||||
const [scriptModalOpen, setScriptModalOpen] = useState(false)
|
||||
@@ -159,62 +154,6 @@ const GeneratePage: React.FC = () => {
|
||||
onTitleSettingsChange: setTitleSettings,
|
||||
})
|
||||
|
||||
/* ── 配音素材库(TTS 试听)── */
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
|
||||
const ttsAbortRef = useRef<AbortController | null>(null)
|
||||
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
|
||||
|
||||
useEffect(() => {
|
||||
if (isBatch) return
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
if (voiceAsset?.file_url) {
|
||||
setPreviewVoiceAudioUrl(voiceAsset.file_url)
|
||||
return
|
||||
}
|
||||
|
||||
const ttsTitle = titleSettings.title
|
||||
const voiceId = selectedClonedVoice || selectedVoice
|
||||
if (!voiceId || !ttsTitle) {
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
ttsAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
ttsAbortRef.current = controller
|
||||
let cancelled = false
|
||||
|
||||
previewTts({ text: ttsTitle, voice_id: voiceId })
|
||||
.then((res) => {
|
||||
if (!cancelled && res.audio_url) {
|
||||
setPreviewVoiceAudioUrl(res.audio_url)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn("[预览配音生成失败]", err)
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
titleSettings.title,
|
||||
variant0Title,
|
||||
isBatch,
|
||||
voiceMaterials,
|
||||
])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { addClone } = useCloneProgress()
|
||||
|
||||
@@ -239,30 +178,11 @@ const GeneratePage: React.FC = () => {
|
||||
[bgm],
|
||||
)
|
||||
|
||||
/* ── 加载素材详情 ── */
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0
|
||||
const {
|
||||
assets: previewAssets,
|
||||
ready: previewAssetsReady,
|
||||
ensureAssets,
|
||||
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
|
||||
/* ── 预览就绪 ── */
|
||||
// #1899: 不再依赖 currentTemplate,素材加载完即可预览
|
||||
const previewReady = previewAssetsReady
|
||||
|
||||
/* ── 批量变体真实片段(#1744) ── */
|
||||
const batchVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
const {
|
||||
clipsByVariant: variantClips,
|
||||
planIdsByVariant: variantPlanIds,
|
||||
voiceDurationsByVariant: variantVoiceDurations,
|
||||
loading: variantClipsLoading,
|
||||
error: variantClipsError,
|
||||
retry: retryVariantClips,
|
||||
} = useBatchVariantPlans({
|
||||
enabled: isBatch && currentStep === 3 && previewAssetsReady,
|
||||
const { planIdsByVariant: variantPlanIds } = useBatchVariantPlans({
|
||||
enabled: isBatch && currentStep === 3,
|
||||
count: previewCount,
|
||||
templateId: selectedTemplate || "",
|
||||
assetIds: previewAssetIds,
|
||||
@@ -272,39 +192,6 @@ const GeneratePage: React.FC = () => {
|
||||
voiceModePerVideo,
|
||||
})
|
||||
|
||||
/* ── 批量变体配音预览 URL ── */
|
||||
const variantVoiceAudioUrls = useVariantVoicePreview({
|
||||
enabled: isBatch,
|
||||
count: previewCount,
|
||||
perVideo: voiceModePerVideo,
|
||||
sharedVoiceId: selectedVoice || "",
|
||||
clonedVoiceId: selectedClonedVoice || "",
|
||||
variantVoiceIds: voiceLibraryIds || [],
|
||||
titles: previewTitles || [],
|
||||
})
|
||||
|
||||
/* ── 变体 clips 引用素材补拉 ── */
|
||||
const clipAssetIds = useMemo(() => {
|
||||
if (!isBatch || !variantClips?.length) return []
|
||||
const ids = new Set<string>()
|
||||
variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id)))
|
||||
return Array.from(ids)
|
||||
}, [isBatch, variantClips])
|
||||
useEffect(() => {
|
||||
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds)
|
||||
}, [clipAssetIds, ensureAssets])
|
||||
|
||||
/* ── 勾选变体 ── */
|
||||
const toggleVariantSelect = useCallback(
|
||||
(index: number) => {
|
||||
setSelectedVariantIds((prev) => {
|
||||
const list = prev || []
|
||||
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
|
||||
})
|
||||
},
|
||||
[setSelectedVariantIds],
|
||||
)
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
generating,
|
||||
@@ -484,10 +371,6 @@ const GeneratePage: React.FC = () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!previewReady) {
|
||||
message.warning("预览素材正在加载,请稍候")
|
||||
return
|
||||
}
|
||||
const ok = await handleGenerate()
|
||||
if (ok) {
|
||||
setCurrentStep(4)
|
||||
@@ -498,7 +381,6 @@ const GeneratePage: React.FC = () => {
|
||||
previewTitles,
|
||||
titleSettings.aiAutoSelect,
|
||||
titleSettings.title,
|
||||
previewReady,
|
||||
handleGenerate,
|
||||
setCurrentStep,
|
||||
balance,
|
||||
@@ -526,14 +408,84 @@ const GeneratePage: React.FC = () => {
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 最终成片 ── */
|
||||
/* ── 最终成片(单视频) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */
|
||||
const handleFinish = useCallback(async () => {
|
||||
if (finishing) return
|
||||
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
|
||||
if (isBatch) {
|
||||
if (generatedVideos.length === 0) {
|
||||
message.warning("请等待视频生成完成")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!finalVideo) {
|
||||
message.warning("请等待视频生成完成")
|
||||
return
|
||||
}
|
||||
}
|
||||
setFinishing(true)
|
||||
const hide = message.loading("正在保存到视频库...", 0)
|
||||
try {
|
||||
const taskIds =
|
||||
batchTasks && batchTasks.length > 0
|
||||
? batchTasks.map((t) => t.taskId).filter(Boolean)
|
||||
: finalVideo?.generation_task_id
|
||||
? [finalVideo.generation_task_id]
|
||||
: []
|
||||
|
||||
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题)
|
||||
if (isBatch && previewCovers.length > 0) {
|
||||
await Promise.all(
|
||||
taskIds.map(async (taskId, idx) => {
|
||||
const coverUrl = previewCovers[idx] || ""
|
||||
return finalizeGeneration(taskId, {
|
||||
cover_url: coverUrl || undefined,
|
||||
custom_title: previewTitles[idx] || titleSettings.title || "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
} else if (finalVideo?.generation_task_id) {
|
||||
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
|
||||
await finalizeGeneration(finalVideo.generation_task_id, {
|
||||
cover_url: coverUrl || undefined,
|
||||
custom_title: titleSettings.title || "",
|
||||
})
|
||||
}
|
||||
|
||||
hide()
|
||||
message.success("已保存到视频库")
|
||||
navigate("/app/products")
|
||||
} catch (err) {
|
||||
hide()
|
||||
console.error("[保存失败]", err)
|
||||
const detail =
|
||||
(err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data
|
||||
?.detail ||
|
||||
(err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data
|
||||
?.message ||
|
||||
"保存失败,请稍后在任务历史查看"
|
||||
message.error(detail)
|
||||
} finally {
|
||||
setFinishing(false)
|
||||
}
|
||||
}, [
|
||||
finishing,
|
||||
isBatch,
|
||||
finalVideo,
|
||||
generatedVideos,
|
||||
batchTasks,
|
||||
previewCovers,
|
||||
previewTitles,
|
||||
titleSettings.title,
|
||||
coverSettings,
|
||||
navigate,
|
||||
])
|
||||
|
||||
/* ── 布局 class ── */
|
||||
const layoutClassName = useMemo(() => {
|
||||
if (currentStep === 3) return "xx-generate-layout step4-layout"
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
const layoutClassName = "xx-generate-layout full-width"
|
||||
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
@@ -561,66 +513,7 @@ const GeneratePage: React.FC = () => {
|
||||
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
|
||||
<div className={layoutClassName}>
|
||||
{/* ════ 步骤3:左侧预览大区域 ════ */}
|
||||
{currentStep === 3 && (
|
||||
<div className="xx-generate-preview-col">
|
||||
{!isBatch ? (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
titleSettings={{
|
||||
title: titleSettings.title,
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
onTitlePositionChange={styleUpdaters.updateTitlePosition}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-form-section">
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 {previewCount} 个视频预览</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
实时预览,勾选要生成的视频
|
||||
</span>
|
||||
</div>
|
||||
<CanvasPreviewGrid
|
||||
count={previewCount}
|
||||
assets={previewAssets}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
voiceDurations={variantVoiceDurations}
|
||||
variantClips={variantClips}
|
||||
clipsLoading={variantClipsLoading}
|
||||
clipsError={variantClipsError}
|
||||
onRetryClips={retryVariantClips}
|
||||
selectedIds={selectedVariantIds}
|
||||
onToggleSelect={toggleVariantSelect}
|
||||
selectable={!generating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
|
||||
{/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
|
||||
<div className="xx-generate-form">
|
||||
<GenerateStepContent
|
||||
currentStep={currentStep}
|
||||
@@ -664,11 +557,7 @@ const GeneratePage: React.FC = () => {
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
onVideoRatioChange={(r) => setVideoRatio(r)}
|
||||
selectedScript={selectedScript}
|
||||
ttsVoiceId={ttsVoiceId}
|
||||
ttsVoiceSource={ttsVoiceSource}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
@@ -750,12 +639,6 @@ const GeneratePage: React.FC = () => {
|
||||
<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>
|
||||
@@ -775,6 +658,8 @@ const GeneratePage: React.FC = () => {
|
||||
pointsInsufficient={insufficientPoints}
|
||||
insufficientReason={pointsEstimate.reason}
|
||||
onRecharge={() => navigate("/app/points/recharge")}
|
||||
onFinish={handleFinish}
|
||||
finishing={finishing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,9 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
|
||||
完成{" "}
|
||||
{tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "}
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
|
||||
@@ -49,7 +51,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
|
||||
<div className="xx-batch-gen-card-head">
|
||||
<span className="xx-batch-gen-card-title" title={title}>
|
||||
{task.status === "completed" ? (
|
||||
{task.status === "completed" || task.status === "awaiting_cover" ? (
|
||||
<CheckCircleFilled
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#52c41a" }}
|
||||
@@ -83,7 +85,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
|
||||
</>
|
||||
)}
|
||||
{task.status === "completed" && video && (
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
|
||||
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
|
||||
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
|
||||
<div
|
||||
@@ -111,7 +113,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && !video && (
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
|
||||
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划)
|
||||
*
|
||||
* N 个 FrontendPreviewPlayer 网格排列:
|
||||
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口;
|
||||
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
|
||||
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
|
||||
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
|
||||
* 加载中 → 9:16 占位防塌陷;
|
||||
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
|
||||
* - 勾选框决定提交时生成哪些变体;
|
||||
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
|
||||
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
|
||||
interface CanvasPreviewGridProps {
|
||||
count: number
|
||||
assets: AssetItem[]
|
||||
videoRatio: string
|
||||
titles: string[]
|
||||
titleSettings: TitleSettings
|
||||
/**
|
||||
* 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条;
|
||||
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
|
||||
*/
|
||||
voiceAudioUrls?: (string | null)[]
|
||||
/**
|
||||
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined,
|
||||
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
|
||||
* 长度=count,undefined 项表示该变体未提供后端时长。
|
||||
*/
|
||||
voiceDurations?: (number | undefined)[]
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744/#1750):长度=count。
|
||||
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
|
||||
*/
|
||||
variantClips?: EditPlanClip[][]
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
clipsLoading?: boolean
|
||||
/** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */
|
||||
clipsError?: boolean
|
||||
/** 用户点击错误态「重试」 */
|
||||
onRetryClips?: () => void
|
||||
/** 勾选的变体序号 */
|
||||
selectedIds: number[]
|
||||
onToggleSelect: (index: number) => void
|
||||
/** 生成中禁止勾选 */
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
count,
|
||||
assets,
|
||||
videoRatio,
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrls,
|
||||
voiceDurations,
|
||||
variantClips,
|
||||
clipsLoading = false,
|
||||
clipsError = false,
|
||||
onRetryClips,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750) ──
|
||||
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
|
||||
|
||||
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
|
||||
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
|
||||
return (
|
||||
<div className="xx-canvas-grid">
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const checked = selectedIds.includes(i)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`xx-canvas-grid-card${checked ? " selected" : ""}`}
|
||||
data-variant={i}
|
||||
>
|
||||
<div className="xx-canvas-grid-card-bar">
|
||||
<label className="xx-canvas-grid-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={!selectable}
|
||||
onChange={() => onToggleSelect(i)}
|
||||
/>
|
||||
<span>视频 {i + 1}</span>
|
||||
</label>
|
||||
</div>
|
||||
{clipsError ? (
|
||||
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
|
||||
<div className="xx-variant-clips-status" role="alert">
|
||||
<span className="xx-variant-clips-error-text">预览加载失败,请重试</span>
|
||||
{i === 0 && onRetryClips ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetryClips}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : clipsLoading || !variantClips?.[i]?.length ? (
|
||||
// ── 加载态:9:16 占位防塌陷 ──
|
||||
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
|
||||
<LoadingOutlined />
|
||||
<span style={{ marginTop: 8 }}>独立选片中…</span>
|
||||
</div>
|
||||
) : (
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
videoRatio={videoRatio}
|
||||
ready={assets.length > 0}
|
||||
playToken={i}
|
||||
serverClips={variantClips[i]}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
|
||||
voiceDurationHint={voiceDurations?.[i]}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
titleSettings={{
|
||||
title: titles[i] || "",
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CanvasPreviewGrid
|
||||
@@ -1,602 +0,0 @@
|
||||
/**
|
||||
* 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI)
|
||||
*
|
||||
* 架构:
|
||||
* - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度),
|
||||
* 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。
|
||||
* UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。
|
||||
* - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除),
|
||||
* 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。
|
||||
*
|
||||
* 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。
|
||||
*/
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||||
import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { usePreviewAudio } from "../hooks/usePreviewAudio"
|
||||
import { PreviewControls } from "./PreviewControls"
|
||||
import { getFontFamily } from "@/components/title/constants"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
assets: AssetItem[]
|
||||
videoRatio: string
|
||||
ready: boolean
|
||||
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
|
||||
serverClips?: EditPlanClip[]
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
title: string
|
||||
size: number
|
||||
font: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom" | "custom"
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
posX?: number | null
|
||||
posY?: number | null
|
||||
}
|
||||
onTitlePositionChange?: (posX: number, posY: number) => void
|
||||
/**
|
||||
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
|
||||
* 持有播放权的实例播放,其余自动暂停。
|
||||
*/
|
||||
playToken?: number
|
||||
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
|
||||
variantTitle?: string
|
||||
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
|
||||
compact?: boolean
|
||||
/**
|
||||
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
|
||||
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
|
||||
*/
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
/**
|
||||
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
|
||||
* 未提供则在 audio loadedmetadata 后自测兜底。
|
||||
*/
|
||||
voiceDurationHint?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback)
|
||||
*
|
||||
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
|
||||
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
|
||||
*/
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
serverClips?: EditPlanClip[],
|
||||
/** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */
|
||||
speedFactor = 1,
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length || !serverClips || serverClips.length === 0) return []
|
||||
|
||||
const assetMap = new Map(assets.map((a) => [a.id, a]))
|
||||
const segments: PlaybackSegment[] = []
|
||||
// speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速
|
||||
const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 0
|
||||
const startTime = clip.start_time || 0
|
||||
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
|
||||
const rawClipDuration = clip.duration || 0
|
||||
// #1754:按 speedFactor 缩放片段时长,使总时长匹配配音
|
||||
const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration
|
||||
const endTime =
|
||||
assetDuration > 0
|
||||
? Math.min(startTime + clipDuration, assetDuration)
|
||||
: startTime + clipDuration
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
segments.push({
|
||||
assetId: asset.id,
|
||||
videoUrl,
|
||||
startTime,
|
||||
endTime,
|
||||
order: clip.order,
|
||||
playbackRate: invSpeed !== 1 ? speedFactor : undefined,
|
||||
})
|
||||
}
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
videoRatio,
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
playToken,
|
||||
variantTitle,
|
||||
compact = false,
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
// #1754→P0:配音时长作为音画时长锚点。
|
||||
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
|
||||
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
|
||||
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
|
||||
)
|
||||
|
||||
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
|
||||
const rawClipsDuration = useMemo(() => {
|
||||
if (!serverClips?.length) return 0
|
||||
return serverClips.reduce((sum, c) => sum + (c.duration || 0) + (c.transition_duration || 0), 0)
|
||||
}, [serverClips])
|
||||
|
||||
// #1754→#1756:配音时长可用且与 clips+转场 总时长偏差 > 5% 时,按比例调速
|
||||
const speedFactor = useMemo(() => {
|
||||
if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1
|
||||
const ratio = rawClipsDuration / voiceDuration
|
||||
return Math.abs(ratio - 1) > 0.05 ? ratio : 1
|
||||
}, [voiceDuration, rawClipsDuration])
|
||||
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, serverClips, speedFactor),
|
||||
[assets, serverClips, speedFactor],
|
||||
)
|
||||
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
|
||||
const effectiveTitle = variantTitle ?? titleSettings?.title
|
||||
|
||||
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
|
||||
const TITLE_MARGIN_TOP = 180
|
||||
const TITLE_MARGIN_BOTTOM = 100
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
const playRes = (() => {
|
||||
switch (videoRatio) {
|
||||
case "16:9":
|
||||
return { width: 1920, height: 1080 }
|
||||
case "1:1":
|
||||
return { width: 1080, height: 1080 }
|
||||
case "9:16":
|
||||
default:
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
})()
|
||||
const customTitleXPct =
|
||||
titleSettings?.posX != null && playRes.width > 0
|
||||
? (titleSettings.posX / playRes.width) * 100
|
||||
: null
|
||||
const customTitleYPct =
|
||||
titleSettings?.posY != null && playRes.height > 0
|
||||
? (titleSettings.posY / playRes.height) * 100
|
||||
: null
|
||||
|
||||
// ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)──
|
||||
const draggingTitleRef = useRef(false)
|
||||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const handleTitlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
;(e.target as Element).setPointerCapture(e.pointerId)
|
||||
draggingTitleRef.current = true
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
|
||||
},
|
||||
[onTitlePositionChange],
|
||||
)
|
||||
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (titleDragRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const xpct = (relX / rect.width) * 100
|
||||
const ypct = (relY / rect.height) * 100
|
||||
titleDragRef.current.style.left = `${xpct}%`
|
||||
titleDragRef.current.style.top = `${ypct}%`
|
||||
}
|
||||
}, [])
|
||||
const handleTitlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current) return
|
||||
draggingTitleRef.current = false
|
||||
if (onTitlePositionChange && playerContainerRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const posX = Math.round((relX / rect.width) * playRes.width)
|
||||
const posY = Math.round((relY / rect.height) * playRes.height)
|
||||
onTitlePositionChange(posX, posY)
|
||||
}
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
try {
|
||||
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
|
||||
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[onTitlePositionChange, playRes.width, playRes.height],
|
||||
)
|
||||
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
useEffect(() => {
|
||||
const el = playerContainerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height
|
||||
if (h > 0) setContainerHeight(h)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height > 0) setContainerHeight(rect.height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// 标题字号按容器高度与 PlayResY 的比例缩放
|
||||
const titleFontSizePx =
|
||||
containerHeight > 0
|
||||
? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight
|
||||
: (titleSettings?.size ?? 36)
|
||||
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
|
||||
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
|
||||
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
|
||||
const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1
|
||||
const titleStrokeWidth = Math.max(1, 2 * titleScale)
|
||||
const titleShadowBlur = 4 * titleScale
|
||||
const titleShadowOffset = 2 * titleScale
|
||||
|
||||
// ── Video 播放器(默认路径,浏览器原生硬件解码) ──
|
||||
const {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
canPlay,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
pause,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
// P0 fix:以配音时长为音画同步锚点。
|
||||
// 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。
|
||||
const effectiveTotalDuration =
|
||||
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
|
||||
|
||||
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
|
||||
const [muted, setMuted] = useState(false)
|
||||
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
|
||||
const hasVoice = !!voiceAudioUrl
|
||||
|
||||
// 音频 ended:兜底触发暂停与释放播放权
|
||||
const handleAudioEnded = useCallback(() => {
|
||||
if (!isPlaying) return
|
||||
pause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}, [isPlaying, pause, playToken, onPlayTokenChange])
|
||||
|
||||
const {
|
||||
seekTo: audioSeekTo,
|
||||
ensurePlayingAt: audioEnsurePlayingAt,
|
||||
pause: audioPause,
|
||||
} = usePreviewAudio({
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
muted,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onVoiceDurationChange: setVoiceDuration,
|
||||
onEnded: handleAudioEnded,
|
||||
})
|
||||
|
||||
// 片段切换时同步音频时间(video fallback)
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
audioSeekTo(currentTime)
|
||||
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSegmentIndex, isPlaying])
|
||||
|
||||
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
|
||||
// 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。
|
||||
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
|
||||
const tailStartRef = useRef<number>(0)
|
||||
const tailBaseRef = useRef<number>(0)
|
||||
const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause })
|
||||
tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }
|
||||
|
||||
useEffect(() => {
|
||||
const needTail =
|
||||
!!voiceAudioUrl &&
|
||||
voiceDuration > 0 &&
|
||||
!isPlaying &&
|
||||
typeof currentTime === "number" &&
|
||||
currentTime >= totalDuration - 0.1 &&
|
||||
currentTime < voiceDuration - 0.1
|
||||
if (needTail && tailCurrentTime === null) {
|
||||
tailBaseRef.current = currentTime
|
||||
tailStartRef.current = performance.now()
|
||||
setTailCurrentTime(currentTime)
|
||||
tailAudioRef.current.ensurePlayingAt(currentTime)
|
||||
return
|
||||
}
|
||||
if (!needTail && tailCurrentTime !== null) {
|
||||
setTailCurrentTime(null)
|
||||
}
|
||||
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (tailCurrentTime === null) return
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
const elapsed = (performance.now() - tailStartRef.current) / 1000
|
||||
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
|
||||
setTailCurrentTime(t)
|
||||
tailAudioRef.current.ensurePlayingAt(t)
|
||||
if (t >= (voiceDuration || 0) - 0.05) {
|
||||
tailAudioRef.current.pause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
setTailCurrentTime(null)
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
|
||||
|
||||
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间
|
||||
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
|
||||
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
setTailCurrentTime(null)
|
||||
seekTo(time)
|
||||
audioSeekTo(time)
|
||||
},
|
||||
[seekTo, audioSeekTo],
|
||||
)
|
||||
|
||||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ──
|
||||
useEffect(() => {
|
||||
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
|
||||
if (isPlaying) {
|
||||
pause()
|
||||
}
|
||||
// isPlaying 不放依赖:只在 token 变化时执行一次暂停
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePlayToken, playToken])
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
|
||||
togglePlayPause()
|
||||
}, [togglePlayPause, isPlaying, playToken, onPlayTokenChange])
|
||||
|
||||
// P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结)
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
if (!voiceAudioUrl || voiceDuration <= 0) return
|
||||
if (displayCurrentTime < voiceDuration - 0.08) return
|
||||
pause()
|
||||
audioPause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}, [
|
||||
isPlaying,
|
||||
displayCurrentTime,
|
||||
voiceAudioUrl,
|
||||
voiceDuration,
|
||||
pause,
|
||||
audioPause,
|
||||
playToken,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
aspectRatio: "9 / 16",
|
||||
background: "#0a0a0a",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }} />
|
||||
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
|
||||
准备预览素材...
|
||||
</p>
|
||||
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
|
||||
加载素材后即可预览播放
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
aspectRatio: "9 / 16",
|
||||
background: "#0a0a0a",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
|
||||
/>
|
||||
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
|
||||
暂无可播放素材
|
||||
</p>
|
||||
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
|
||||
请先在左侧选择素材
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: compact ? "100%" : 280,
|
||||
margin: compact ? 0 : "0 auto",
|
||||
aspectRatio: "9 / 16",
|
||||
background: compact ? "transparent" : "#0a0a0a",
|
||||
borderRadius: compact ? 10 : 24,
|
||||
overflow: "hidden",
|
||||
boxShadow: compact
|
||||
? "inset 0 0 0 1px rgba(255,255,255,0.06)"
|
||||
: "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
|
||||
{segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
preload="auto"
|
||||
src={seg.videoUrl}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
background: "#000",
|
||||
zIndex: 1,
|
||||
opacity: i === currentSegmentIndex ? 1 : 0,
|
||||
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
|
||||
}}
|
||||
muted={hasVoice || muted}
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
|
||||
{titleSettings?.title && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 5,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: `${100 - 2 * titleSidePct}%`,
|
||||
maxWidth: `${100 - 2 * titleSidePct}%`,
|
||||
...(customTitleXPct != null && customTitleYPct != null
|
||||
? {
|
||||
left: `${customTitleXPct}%`,
|
||||
top: `${customTitleYPct}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
textAlign: "center" as const,
|
||||
}
|
||||
: {
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
textAlign: "center" as const,
|
||||
...(titleSettings.position === "top"
|
||||
? { top: `${titleTopPct}%` }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translate(-50%, -50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}),
|
||||
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
|
||||
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
padding: "8px 12px",
|
||||
boxShadow: "inset 0 0 0 16px transparent",
|
||||
}}
|
||||
ref={titleDragRef}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
onPointerCancel={handleTitlePointerUp}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${titleFontSizePx}px`,
|
||||
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
|
||||
color: titleSettings.color || "#ffffff",
|
||||
fontWeight: titleSettings.bold ? 700 : 400,
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
lineHeight: 1.05,
|
||||
wordBreak: "break-word",
|
||||
WebkitTextStroke: titleSettings.stroke
|
||||
? `${titleStrokeWidth}px #000000`
|
||||
: undefined,
|
||||
textShadow: titleSettings.shadow
|
||||
? `${titleShadowOffset}px ${titleShadowOffset}px ${titleShadowBlur}px rgba(0,0,0,0.8)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{(effectiveTitle || "").split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PreviewControls
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
muted={muted}
|
||||
onToggleMute={() => setMuted((m) => !m)}
|
||||
hasSegments={segments.length > 0}
|
||||
segmentIndex={currentSegmentIndex}
|
||||
segmentCount={segments.length}
|
||||
currentTime={displayCurrentTime}
|
||||
totalDuration={effectiveTotalDuration}
|
||||
onSeek={handleSeekTo}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FrontendPreviewPlayer
|
||||
@@ -5,7 +5,7 @@
|
||||
* 步骤 1~3:上一步 / 下一步
|
||||
* 步骤 4(确认生成/进度):未开始 →「✨ 确认生成视频」;生成中 →「⏳ 视频渲染中…」;
|
||||
* 失败 →「🔄 重新生成」;全部完成 →「下一步:选择封面 →」
|
||||
* 步骤 5(选择封面):仅上一步,无主按钮
|
||||
* 步骤 5(选择封面):上一步 + 完成按钮
|
||||
*/
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
@@ -32,6 +32,10 @@ export interface GenerateStepActionsProps {
|
||||
freeClipsUsedThisTime?: number
|
||||
/** 前往充值 */
|
||||
onRecharge?: () => void
|
||||
/** Step5 点击完成(保存入库并跳转) */
|
||||
onFinish?: () => void | Promise<void>
|
||||
/** Step5 保存中 */
|
||||
finishing?: boolean
|
||||
}
|
||||
|
||||
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
@@ -48,6 +52,8 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
insufficientReason,
|
||||
freeClipsUsedThisTime,
|
||||
onRecharge,
|
||||
onFinish,
|
||||
finishing,
|
||||
}) => {
|
||||
const renderPrimaryButton = () => {
|
||||
/* 步骤 1~3:上一步 / 下一步 */
|
||||
@@ -126,8 +132,18 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 5(封面,最后一步):无主按钮 */
|
||||
return null
|
||||
/* 步骤 5(封面,最后一步):完成按钮 */
|
||||
return (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={() => {
|
||||
if (onFinish && !finishing) void onFinish()
|
||||
}}
|
||||
disabled={finishing}
|
||||
>
|
||||
{finishing ? "⏳ 保存中…" : "✅ 完成"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { ScriptItem } from "@/api/scripts"
|
||||
import Step1EditMode from "./Step1EditMode"
|
||||
import type { EditMode } from "./Step1EditMode"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
@@ -69,7 +67,6 @@ export interface GenerateStepContentProps {
|
||||
/* ── 配音 ── */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
/* ── 生成 ── */
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
@@ -92,10 +89,6 @@ export interface GenerateStepContentProps {
|
||||
previewCovers: string[]
|
||||
onPreviewCoversChange: (urls: string[]) => void
|
||||
selectedVariantIds?: number[]
|
||||
/* ── 摘要信息(#1970 Step4 展示用) ── */
|
||||
selectedScript: ScriptItem | null
|
||||
ttsVoiceId: string
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
@@ -137,7 +130,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
onServerClipsChange,
|
||||
generating,
|
||||
generateError,
|
||||
progress,
|
||||
@@ -150,18 +142,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
previewCovers,
|
||||
onPreviewCoversChange,
|
||||
selectedVariantIds,
|
||||
selectedScript,
|
||||
ttsVoiceId,
|
||||
ttsVoiceSource,
|
||||
} = props
|
||||
|
||||
const handleClipsChange = React.useCallback(
|
||||
(clips: EditPlanClip[], _templateId?: string) => {
|
||||
onServerClipsChange(clips)
|
||||
},
|
||||
[onServerClipsChange],
|
||||
)
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
@@ -189,7 +171,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
clipCount={clipCount}
|
||||
onClipCountChange={onClipCountChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onServerClipsChange={handleClipsChange}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -219,40 +200,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
case 4:
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
{/* 配置摘要(#1970) */}
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
background: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6, color: "#111" }}>
|
||||
📋 生成配置
|
||||
</div>
|
||||
<div>🎬 剪辑模式:{editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}</div>
|
||||
{editMode === "random" ? (
|
||||
<div>🎙️ 配音来源:配音库音频</div>
|
||||
) : (
|
||||
<>
|
||||
<div>📝 文案:{selectedScript?.title ?? "未选择"}</div>
|
||||
<div>
|
||||
🎙️ 合成配音音色:
|
||||
{ttsVoiceId
|
||||
? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}(${ttsVoiceId.slice(0, 8)}...)`
|
||||
: "未选择"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>📱 视频比例:{videoRatio}</div>
|
||||
<div>🎯 智能降重:{dedupEnabled ? "已开启" : "已关闭"}</div>
|
||||
{previewCount > 1 && <div>📦 生成数量:{previewCount} 个</div>}
|
||||
</div>
|
||||
|
||||
{previewCount > 1 ? (
|
||||
<BatchGenerationGrid
|
||||
tasks={batchTasks}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import React from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { PreviewProgressBar } from "./PreviewProgressBar"
|
||||
|
||||
interface PreviewControlsProps {
|
||||
isPlaying: boolean
|
||||
onTogglePlay: () => void
|
||||
muted: boolean
|
||||
onToggleMute: () => void
|
||||
hasSegments: boolean
|
||||
segmentIndex: number
|
||||
segmentCount: number
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
onSeek: (time: number) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条)
|
||||
*/
|
||||
export const PreviewControls: React.FC<PreviewControlsProps> = ({
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
muted,
|
||||
onToggleMute,
|
||||
hasSegments,
|
||||
segmentIndex,
|
||||
segmentCount,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
onSeek,
|
||||
compact = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 静音/有声切换(#1741):左上角 */}
|
||||
{hasSegments && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
title={muted ? "取消静音" : "静音"}
|
||||
onClick={onToggleMute}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: compact ? 26 : 30,
|
||||
height: compact ? 26 : 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "50%",
|
||||
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
|
||||
fontSize: compact ? 13 : 15,
|
||||
cursor: "pointer",
|
||||
zIndex: 10,
|
||||
padding: 0,
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 — 右上角胶囊 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: compact ? 9 : 10,
|
||||
fontWeight: 500,
|
||||
padding: compact ? "1px 6px" : "2px 8px",
|
||||
borderRadius: 999,
|
||||
zIndex: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{`${segmentIndex + 1} / ${segmentCount}`}
|
||||
</div>
|
||||
|
||||
{/* 中央播放按钮 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "50%",
|
||||
width: 52,
|
||||
height: 52,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 26,
|
||||
zIndex: 10,
|
||||
transition: "transform 0.2s ease, background 0.2s ease",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 控制条 — 手机风格毛玻璃 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: compact ? 6 : 10,
|
||||
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: compact ? 14 : 16,
|
||||
cursor: "pointer",
|
||||
width: compact ? 26 : 32,
|
||||
height: compact ? 26 : 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
<PreviewProgressBar
|
||||
currentTime={currentTime}
|
||||
totalDuration={totalDuration}
|
||||
onSeek={onSeek}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
|
||||
interface PreviewProgressBarProps {
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
onSeek: (time: number) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条组件:点击/拖拽 seek
|
||||
*/
|
||||
export const PreviewProgressBar: React.FC<PreviewProgressBarProps> = ({
|
||||
currentTime,
|
||||
totalDuration,
|
||||
onSeek,
|
||||
compact = false,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const seekByClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
onSeek(ratio * totalDuration)
|
||||
},
|
||||
[totalDuration, onSeek],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true)
|
||||
seekByClientX(e.clientX)
|
||||
},
|
||||
[seekByClientX],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX)
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, seekByClientX])
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
fontSize: compact ? 10 : 11,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
minWidth: compact ? 58 : 72,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 3,
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
transition: isDragging ? "none" : "width 0.1s linear",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: `${progressPercent}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
|
||||
opacity: isDragging ? 1 : 0,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* Step 4 选择标题(Issue #1677 批量生成)
|
||||
*
|
||||
* 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格),
|
||||
* 右侧边栏标题设置。本组件渲染在右侧边栏:
|
||||
* - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归)
|
||||
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成
|
||||
* (一次生成 N 个标题,分别填入各变体,可单独换一个)
|
||||
* 布局:全宽区域,标题输入 + 标题模板卡片网格。
|
||||
* - 单视频:TitleLibraryAutoComplete 标题库输入
|
||||
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
|
||||
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { Input, message } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import React, { useMemo } from "react"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS } from "../constants"
|
||||
import { FONT_OPTIONS } from "@/components/title/constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
import { AI_TITLE_TEMPLATES } from "../constants"
|
||||
import type { TitleTemplate } from "@/components/title/template-types"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
@@ -55,36 +49,6 @@ interface Step4TitleSettingsProps {
|
||||
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
|
||||
}
|
||||
|
||||
/** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */
|
||||
function buildBatchAiTitles(topic: string, count: number): string[] {
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const pool: string[] = []
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style] || []
|
||||
templates.forEach((tpl) => pool.push(tpl.replace(/\{topic\}/g, topic)))
|
||||
})
|
||||
// 洗牌后取前 count 个;不足则轮转补齐
|
||||
const shuffled = [...pool].sort(() => Math.random() - 0.5)
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.push(shuffled[i % shuffled.length] || "")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function extractTopic(text: string): string {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const t = useStep4Title(props)
|
||||
const {
|
||||
@@ -108,8 +72,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
} = props
|
||||
|
||||
const isBatch = previewCount > 1
|
||||
const [batchAiLoading, setBatchAiLoading] = useState(false)
|
||||
const [batchAiTopic, setBatchAiTopic] = useState("")
|
||||
|
||||
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
|
||||
const updateVariantTitle = (index: number, val: string) => {
|
||||
@@ -122,117 +84,31 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量 AI 生成:按主题词生成标题,分别填入 N 个变体 */
|
||||
const handleBatchAiGenerate = async (onlyEmpty = false) => {
|
||||
if (!onPreviewTitlesChange || !previewTitles) return
|
||||
const topic = (batchAiTopic || t.aiTitleInput || "").trim()
|
||||
if (!topic) {
|
||||
message.warning("请先输入主题词,例如:萌宠日常、旅行vlog")
|
||||
return
|
||||
}
|
||||
setBatchAiLoading(true)
|
||||
try {
|
||||
// 与单视频一致:本地模板模拟 AI 生成(1200ms 体验延迟)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
const picked = buildBatchAiTitles(extractTopic(topic), previewCount)
|
||||
const next = [...previewTitles]
|
||||
for (let i = 0; i < previewCount; i++) {
|
||||
if (onlyEmpty && next[i]?.trim()) continue
|
||||
if (picked[i]) next[i] = picked[i]
|
||||
}
|
||||
onPreviewTitlesChange(next)
|
||||
if (next[0]) t.updateTitle(next[0])
|
||||
message.success(`已为 ${previewCount} 个视频生成标题,可单独修改`)
|
||||
} finally {
|
||||
setBatchAiLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const titleOptions = useMemo(
|
||||
() => t.userTitles.map((ut) => ({ label: ut.content, value: ut.content })),
|
||||
[t.userTitles],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section xx-title-sidebar">
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{!isBatch ? (
|
||||
/* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */
|
||||
<>
|
||||
{t.titleSettings.aiAutoSelect ? (
|
||||
<>
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>当前 AI 选定标题</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--text-primary, #333)",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>
|
||||
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
|
||||
onClick={t.autoGenerateTitle}
|
||||
>
|
||||
🔄 换一个
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AiTitleGenerator
|
||||
inputValue={t.aiTitleInput}
|
||||
onInputChange={t.setAiTitleInput}
|
||||
generating={t.aiTitleGenerating}
|
||||
onGenerate={t.handleGenerateAiTitles}
|
||||
results={t.aiTitleResults}
|
||||
hasGenerated={t.hasGeneratedTitles}
|
||||
onSelect={t.handleSelectAiTitle}
|
||||
selectedTitle={t.titleSettings.title}
|
||||
onRefresh={t.handleRefreshAiTitles}
|
||||
/>
|
||||
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder="输入或从标题库选择"
|
||||
value={previewTitles?.[0] ?? t.titleSettings.title}
|
||||
onChange={(val) => {
|
||||
t.updateTitle(val || "")
|
||||
onPreviewTitlesChange?.([val || ""])
|
||||
}}
|
||||
options={titleOptions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
/* ── 单视频:标题输入框 ── */
|
||||
<div className="xx-form-field" style={{ maxWidth: 640 }}>
|
||||
<label>标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder="输入或从标题库选择"
|
||||
value={previewTitles?.[0] ?? t.titleSettings.title}
|
||||
onChange={(val) => {
|
||||
t.updateTitle(val || "")
|
||||
onPreviewTitlesChange?.([val || ""])
|
||||
}}
|
||||
options={titleOptions}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */
|
||||
/* ── 批量:N 个独立标题输入框 ── */
|
||||
<div className="xx-batch-titles">
|
||||
<div
|
||||
style={{
|
||||
@@ -242,42 +118,11 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。
|
||||
</div>
|
||||
|
||||
{/* 批量 AI 标题 */}
|
||||
<div className="xx-batch-ai-row">
|
||||
<Input
|
||||
placeholder="主题词,如:萌宠日常、旅行vlog"
|
||||
value={batchAiTopic || t.aiTitleInput}
|
||||
onChange={(e) => {
|
||||
setBatchAiTopic(e.target.value)
|
||||
t.setAiTitleInput(e.target.value)
|
||||
}}
|
||||
maxLength={30}
|
||||
size="small"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
disabled={batchAiLoading}
|
||||
onClick={() => handleBatchAiGenerate(false)}
|
||||
>
|
||||
{batchAiLoading ? <LoadingOutlined /> : "✨"} 一键生成 {previewCount} 个标题
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
disabled={batchAiLoading}
|
||||
onClick={() => handleBatchAiGenerate(true)}
|
||||
>
|
||||
补填空标题
|
||||
</button>
|
||||
为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
|
||||
</div>
|
||||
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i}>
|
||||
<div className="xx-form-field" key={i} style={{ maxWidth: 640 }}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder={`输入或选择视频 ${i + 1} 的标题`}
|
||||
|
||||
@@ -74,7 +74,10 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const uploadTargetRef = useRef<number>(0)
|
||||
|
||||
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
|
||||
const completedVideos = props.generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
)
|
||||
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
|
||||
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
|
||||
const batchCovers = useBatchCovers({
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import React, { useState } from "react"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Slider, Switch, Select, InputNumber, Input } from "antd"
|
||||
import type {
|
||||
CoverTemplate,
|
||||
CoverEditorConfig,
|
||||
TextStyleConfig,
|
||||
TextDirection,
|
||||
StrokeStyle,
|
||||
} from "../../types/cover"
|
||||
import { DEFAULT_EDITOR_CONFIG, PRESET_FONTS, SYSTEM_FONTS, ALL_FONTS } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
/* ── Props ── */
|
||||
interface CoverEditorModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -10,53 +19,420 @@ interface CoverEditorModalProps {
|
||||
onSave: (template: CoverTemplate) => void
|
||||
}
|
||||
|
||||
interface SectionState {
|
||||
basic: boolean
|
||||
portrait: boolean
|
||||
background: boolean
|
||||
title: boolean
|
||||
subtitle: boolean
|
||||
mask: boolean
|
||||
/* ── Section expand/collapse keys ── */
|
||||
type SectionKey = "basic" | "portrait" | "background" | "title" | "subtitle" | "mask"
|
||||
|
||||
/* ── Color picker sub-component ── */
|
||||
const ColorPicker: React.FC<{ value: string; onChange: (v: string) => void }> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="xx-ce-color-picker">
|
||||
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
maxLength={7}
|
||||
className="xx-ce-color-hex"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ── Position pair sub-component ── */
|
||||
const PositionPair: React.FC<{
|
||||
x: number
|
||||
y: number
|
||||
onChange: (pos: { x: number; y: number }) => void
|
||||
}> = ({ x, y, onChange }) => (
|
||||
<div className="xx-ce-position">
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={x}
|
||||
step={1}
|
||||
suffix="%"
|
||||
controls={false}
|
||||
onChange={(v) => onChange({ x: v ?? 0, y })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={y}
|
||||
step={1}
|
||||
suffix="%"
|
||||
controls={false}
|
||||
onChange={(v) => onChange({ x, y: v ?? 0 })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ── Font Select options ── */
|
||||
const fontOptions = [
|
||||
...PRESET_FONTS.map((f) => ({
|
||||
label: (
|
||||
<span>
|
||||
<span className="xx-ce-font-dot xx-ce-font-dot--preset" />
|
||||
<span style={{ fontFamily: f.family }}>{f.name}</span>
|
||||
</span>
|
||||
),
|
||||
value: f.name,
|
||||
})),
|
||||
...SYSTEM_FONTS.map((f) => ({
|
||||
label: (
|
||||
<span>
|
||||
<span className="xx-ce-font-dot xx-ce-font-dot--system" />
|
||||
<span style={{ fontFamily: f.family }}>{f.name}</span>
|
||||
</span>
|
||||
),
|
||||
value: f.name,
|
||||
})),
|
||||
]
|
||||
|
||||
/* ── Find font family string from name ── */
|
||||
const getFontFamily = (name: string): string => {
|
||||
const found = ALL_FONTS.find((f) => f.name === name)
|
||||
return found ? found.family : "sans-serif"
|
||||
}
|
||||
|
||||
/* ── Text style panel (shared between title & subtitle) ── */
|
||||
const TextStylePanel: React.FC<{
|
||||
config: TextStyleConfig
|
||||
onChange: (c: TextStyleConfig) => void
|
||||
placeholder: string
|
||||
}> = ({ config, onChange, placeholder }) => {
|
||||
const upd = <K extends keyof TextStyleConfig>(key: K, val: TextStyleConfig[K]) =>
|
||||
onChange({ ...config, [key]: val })
|
||||
return (
|
||||
<div className="xx-ce-text-panel">
|
||||
{/* 文字内容 - 改为可输入 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">文字内容</label>
|
||||
<Input.TextArea
|
||||
value={config.text}
|
||||
onChange={(e) => upd("text", e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoSize={{ minRows: 1, maxRows: 3 }}
|
||||
size="small"
|
||||
maxLength={50}
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 字体 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">字体</label>
|
||||
<Select
|
||||
value={config.fontFamily}
|
||||
onChange={(v) => upd("fontFamily", v)}
|
||||
options={fontOptions}
|
||||
style={{ width: "100%" }}
|
||||
popupClassName="xx-ce-font-select-dropdown"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 字号 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">字号: {config.fontSize}px</label>
|
||||
<Slider
|
||||
min={20}
|
||||
max={200}
|
||||
step={1}
|
||||
value={config.fontSize}
|
||||
onChange={(v) => upd("fontSize", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 字重 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">字重: {config.fontWeight}</label>
|
||||
<Slider
|
||||
min={100}
|
||||
max={1000}
|
||||
step={100}
|
||||
value={config.fontWeight}
|
||||
onChange={(v) => upd("fontWeight", v)}
|
||||
marks={{ 400: "常规", 700: "粗", 900: "黑" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字方向 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">文字方向</label>
|
||||
<div className="xx-ce-radio-group">
|
||||
{(["horizontal", "vertical"] as TextDirection[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`xx-ce-radio-btn ${config.direction === d ? "active" : ""}`}
|
||||
onClick={() => upd("direction", d)}
|
||||
type="button"
|
||||
>
|
||||
{d === "horizontal" ? "横排" : "竖排"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每行字数 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">每行字数: {config.charsPerLine}</label>
|
||||
<Slider
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={config.charsPerLine}
|
||||
onChange={(v) => upd("charsPerLine", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 字符间距 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">字符间距: {config.letterSpacing}px</label>
|
||||
<Slider
|
||||
min={-10}
|
||||
max={50}
|
||||
step={1}
|
||||
value={config.letterSpacing}
|
||||
onChange={(v) => upd("letterSpacing", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 行间距 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">行间距: {config.lineHeight}px</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={200}
|
||||
step={1}
|
||||
value={config.lineHeight}
|
||||
onChange={(v) => upd("lineHeight", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">文字颜色</label>
|
||||
<ColorPicker value={config.color} onChange={(v) => upd("color", v)} />
|
||||
</div>
|
||||
|
||||
{/* 描边 */}
|
||||
<div className="xx-ce-switch-item">
|
||||
<div className="xx-ce-switch-row">
|
||||
<span>文字描边</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.strokeWidth > 0}
|
||||
onChange={(v) => upd("strokeWidth", v ? 3 : 0)}
|
||||
/>
|
||||
</div>
|
||||
{config.strokeWidth > 0 && (
|
||||
<>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">描边颜色</label>
|
||||
<ColorPicker value={config.strokeColor} onChange={(v) => upd("strokeColor", v)} />
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">描边粗细: {config.strokeWidth}px</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={config.strokeWidth}
|
||||
onChange={(v) => upd("strokeWidth", v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">位置(X/Y %)</label>
|
||||
<PositionPair
|
||||
x={config.position.x}
|
||||
y={config.position.y}
|
||||
onChange={(pos) => upd("position", pos)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 旋转角度 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">旋转角度: {config.rotation}°</label>
|
||||
<Slider
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
value={config.rotation}
|
||||
onChange={(v) => upd("rotation", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ══════════════ Main Component ══════════════ */
|
||||
const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, template, onSave }) => {
|
||||
const [name, setName] = useState(template?.name || "")
|
||||
const [sections, setSections] = useState<SectionState>({
|
||||
const initCfg = template?.config ?? DEFAULT_EDITOR_CONFIG
|
||||
const [name, setName] = useState(template?.name ?? "")
|
||||
const [cfg, setCfg] = useState<CoverEditorConfig>(() => ({ ...initCfg }))
|
||||
const [sections, setSections] = useState<Record<SectionKey, boolean>>({
|
||||
basic: true,
|
||||
portrait: false,
|
||||
background: false,
|
||||
background: true,
|
||||
title: true,
|
||||
subtitle: true,
|
||||
subtitle: false,
|
||||
mask: false,
|
||||
})
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const bgFileRef = useRef<HTMLInputElement>(null)
|
||||
const portraitFileRef = useRef<HTMLInputElement>(null)
|
||||
const [bgImageUrl, setBgImageUrl] = useState<string>(initCfg.backgroundImage || "")
|
||||
const [portraitImageUrl, setPortraitImageUrl] = useState<string>(initCfg.portraitImage || "")
|
||||
|
||||
const toggleSection = (key: keyof SectionState) => {
|
||||
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
// reset state when modal opens with a new template
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const c = template?.config ?? DEFAULT_EDITOR_CONFIG
|
||||
setCfg({ ...c })
|
||||
setName(template?.name ?? "")
|
||||
setBgImageUrl(c.backgroundImage || "")
|
||||
setPortraitImageUrl(c.portraitImage || "")
|
||||
setSections({
|
||||
basic: true,
|
||||
portrait: false,
|
||||
background: true,
|
||||
title: true,
|
||||
subtitle: false,
|
||||
mask: false,
|
||||
})
|
||||
}
|
||||
}, [open, template])
|
||||
|
||||
const upd = useCallback(
|
||||
<K extends keyof CoverEditorConfig>(key: K, val: CoverEditorConfig[K]) =>
|
||||
setCfg((prev) => ({ ...prev, [key]: val })),
|
||||
[],
|
||||
)
|
||||
|
||||
const updTitle = useCallback(
|
||||
(c: TextStyleConfig) => setCfg((prev) => ({ ...prev, title: c })),
|
||||
[],
|
||||
)
|
||||
const updSubtitle = useCallback(
|
||||
(c: TextStyleConfig) => setCfg((prev) => ({ ...prev, subtitle: c })),
|
||||
[],
|
||||
)
|
||||
|
||||
const toggle = (key: SectionKey) => setSections((p) => ({ ...p, [key]: !p[key] }))
|
||||
|
||||
const handleFile = (
|
||||
file: File | undefined,
|
||||
setter: (url: string) => void,
|
||||
cfgKey: "backgroundImage" | "portraitImage",
|
||||
) => {
|
||||
if (!file) return
|
||||
const url = URL.createObjectURL(file)
|
||||
setter(url)
|
||||
upd(cfgKey, url)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!template) return
|
||||
onSave({ ...template, name })
|
||||
if (!name.trim()) {
|
||||
// antd message unavailable here, use a simple alert fallback; better import
|
||||
// but since we don't import message in this file, guard with default
|
||||
setName(name.trim() || "我的封面模板")
|
||||
}
|
||||
const finalName = name.trim() || "我的封面模板"
|
||||
const result: CoverTemplate = template
|
||||
? { ...template, name: finalName, config: cfg }
|
||||
: {
|
||||
id: "",
|
||||
name: finalName,
|
||||
is_system: false,
|
||||
thumbnail_url: "",
|
||||
created_at: "",
|
||||
config: cfg,
|
||||
}
|
||||
onSave(result)
|
||||
onClose()
|
||||
}
|
||||
|
||||
/* ── canvas helpers (scale from 1200px design to 225px preview) ── */
|
||||
const PREVIEW_W = 225
|
||||
const DESIGN_W = 1200
|
||||
const SCALE = PREVIEW_W / DESIGN_W // 0.1875
|
||||
|
||||
const s = (px: number) => Math.round(px * SCALE * 100) / 100
|
||||
|
||||
const renderTextStyle = (tc: TextStyleConfig): React.CSSProperties => {
|
||||
// wrap text by charsPerLine
|
||||
const rawText = tc.text || ""
|
||||
const lines: string[] = []
|
||||
if (tc.direction === "vertical") {
|
||||
lines.push(rawText)
|
||||
} else {
|
||||
for (let i = 0; i < rawText.length; i += Math.max(1, tc.charsPerLine)) {
|
||||
lines.push(rawText.slice(i, i + Math.max(1, tc.charsPerLine)))
|
||||
}
|
||||
}
|
||||
// store rendered as lines via data attribute; for JSX we'll render outside style
|
||||
void lines
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${tc.position.x}%`,
|
||||
top: `${tc.position.y}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${tc.rotation}deg)`,
|
||||
fontFamily: getFontFamily(tc.fontFamily),
|
||||
fontSize: s(tc.fontSize),
|
||||
fontWeight: tc.fontWeight,
|
||||
color: tc.color,
|
||||
letterSpacing: s(tc.letterSpacing),
|
||||
lineHeight: s(tc.lineHeight) > 0 ? `${s(tc.lineHeight)}px` : undefined,
|
||||
WebkitTextStroke:
|
||||
tc.strokeWidth > 0 ? `${Math.max(0.5, s(tc.strokeWidth))}px ${tc.strokeColor}` : undefined,
|
||||
whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "pre",
|
||||
writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined,
|
||||
zIndex: 3,
|
||||
textAlign: "center",
|
||||
userSelect: "none",
|
||||
}
|
||||
if (tc.shadows.length > 0) {
|
||||
style.textShadow = tc.shadows
|
||||
.map((sh) => `${s(sh.offsetX)}px ${s(sh.offsetY)}px ${s(sh.blur)}px ${sh.color}`)
|
||||
.join(", ")
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
const renderTextLines = (tc: TextStyleConfig) => {
|
||||
const raw = tc.text || ""
|
||||
if (tc.direction === "vertical") return raw
|
||||
const per = Math.max(1, tc.charsPerLine)
|
||||
const lines: string[] = []
|
||||
for (let i = 0; i < raw.length; i += per) lines.push(raw.slice(i, i + per))
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={1000}
|
||||
width={1080}
|
||||
title="自定义封面编辑器"
|
||||
centered
|
||||
footer={null}
|
||||
>
|
||||
<div className="xx-cover-editor-header">
|
||||
{/* ── Header ── */}
|
||||
<div className="xx-ce-header">
|
||||
<input
|
||||
className="xx-cover-editor-name-input"
|
||||
className="xx-ce-name-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入模板名称"
|
||||
placeholder="输入模板名称(如:我的爆款封面)"
|
||||
/>
|
||||
<div className="xx-cover-editor-header-actions">
|
||||
<div className="xx-ce-header-actions">
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
@@ -66,56 +442,469 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-cover-editor-layout">
|
||||
{/* 左侧折叠面板 */}
|
||||
<div className="xx-cover-editor-left">
|
||||
{[
|
||||
{ key: "basic" as const, label: "基础设置" },
|
||||
{ key: "portrait" as const, label: "人像设置" },
|
||||
{ key: "background" as const, label: "背景设置", toggle: true },
|
||||
{ key: "title" as const, label: "主标题" },
|
||||
{ key: "subtitle" as const, label: "副标题" },
|
||||
{ key: "mask" as const, label: "蒙版", toggle: true },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="xx-cover-editor-section">
|
||||
<div
|
||||
className="xx-cover-editor-section-header"
|
||||
onClick={() => toggleSection(item.key)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{sections[item.key] ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections[item.key] && (
|
||||
<div className="xx-cover-editor-section-body">
|
||||
{item.toggle ? (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<input type="checkbox" defaultChecked={false} />
|
||||
已开启
|
||||
</label>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-tertiary)" }}>暂无配置项</span>
|
||||
{/* ── Layout ── */}
|
||||
<div className="xx-ce-layout">
|
||||
{/* ── Left Panel ── */}
|
||||
<div className="xx-ce-left">
|
||||
{/* 1. 基础设置 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("basic")}>
|
||||
<span>🔧 基础设置</span>
|
||||
<span>{sections.basic ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections.basic && (
|
||||
<div className="xx-ce-section-body">
|
||||
<div className="xx-ce-switch-item">
|
||||
<div className="xx-ce-switch-row">
|
||||
<span>文字自动换行</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.autoSplitEnabled}
|
||||
onChange={(v) => upd("autoSplitEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
{cfg.autoSplitEnabled && (
|
||||
<>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">主标题每行字数: {cfg.titleMaxChars}</label>
|
||||
<Slider
|
||||
min={2}
|
||||
max={15}
|
||||
step={1}
|
||||
value={cfg.titleMaxChars}
|
||||
onChange={(v) => upd("titleMaxChars", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">
|
||||
副标题每行字数: {cfg.subtitleMaxChars}
|
||||
</label>
|
||||
<Slider
|
||||
min={2}
|
||||
max={20}
|
||||
step={1}
|
||||
value={cfg.subtitleMaxChars}
|
||||
onChange={(v) => upd("subtitleMaxChars", v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-ce-switch-item">
|
||||
<div className="xx-ce-switch-row">
|
||||
<span>背景模糊</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.blurEnabled}
|
||||
onChange={(v) => upd("blurEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
{cfg.blurEnabled && (
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">模糊度: {cfg.blurAmount}</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={cfg.blurAmount}
|
||||
onChange={(v) => upd("blurAmount", v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. 背景 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("background")}>
|
||||
<span>🎨 背景设置</span>
|
||||
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.backgroundEnabled}
|
||||
onChange={(v) => upd("backgroundEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sections.background && cfg.backgroundEnabled && (
|
||||
<div className="xx-ce-section-body">
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">背景图片</label>
|
||||
<div className="xx-ce-file-row">
|
||||
<input
|
||||
className="xx-ce-file-name"
|
||||
readOnly
|
||||
value={bgImageUrl ? "已选择图片" : "未选择图片"}
|
||||
/>
|
||||
<button
|
||||
className="xx-ce-file-btn"
|
||||
type="button"
|
||||
onClick={() => bgFileRef.current?.click()}
|
||||
>
|
||||
上传
|
||||
</button>
|
||||
{bgImageUrl && (
|
||||
<button
|
||||
className="xx-ce-file-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBgImageUrl("")
|
||||
upd("backgroundImage", "")
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={bgFileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) =>
|
||||
handleFile(e.target.files?.[0], setBgImageUrl, "backgroundImage")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">背景颜色(无图片时使用)</label>
|
||||
<ColorPicker
|
||||
value={cfg.backgroundColor || "#1a1a2e"}
|
||||
onChange={(v) => upd("backgroundColor", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">大小: {cfg.backgroundSize}%</label>
|
||||
<Slider
|
||||
min={50}
|
||||
max={150}
|
||||
step={1}
|
||||
value={cfg.backgroundSize}
|
||||
onChange={(v) => upd("backgroundSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">位置(X/Y %)</label>
|
||||
<PositionPair
|
||||
x={cfg.backgroundPosition.x}
|
||||
y={cfg.backgroundPosition.y}
|
||||
onChange={(pos) => upd("backgroundPosition", pos)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. 主标题 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("title")}>
|
||||
<span>🔠 主标题</span>
|
||||
<span>{sections.title ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections.title && (
|
||||
<div className="xx-ce-section-body">
|
||||
<TextStylePanel
|
||||
config={cfg.title}
|
||||
onChange={updTitle}
|
||||
placeholder="输入主标题文字"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. 副标题 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("subtitle")}>
|
||||
<span>🔡 副标题</span>
|
||||
<span>{sections.subtitle ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections.subtitle && (
|
||||
<div className="xx-ce-section-body">
|
||||
<TextStylePanel
|
||||
config={cfg.subtitle}
|
||||
onChange={updSubtitle}
|
||||
placeholder="输入副标题文字"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 5. 人像 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("portrait")}>
|
||||
<span>👤 人像设置</span>
|
||||
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.portraitEnabled}
|
||||
onChange={(v) => upd("portraitEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sections.portrait && cfg.portraitEnabled && (
|
||||
<div className="xx-ce-section-body">
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">人像图片</label>
|
||||
<div className="xx-ce-file-row">
|
||||
<input
|
||||
className="xx-ce-file-name"
|
||||
readOnly
|
||||
value={portraitImageUrl ? "已选择图片" : "未选择图片"}
|
||||
/>
|
||||
<button
|
||||
className="xx-ce-file-btn"
|
||||
type="button"
|
||||
onClick={() => portraitFileRef.current?.click()}
|
||||
>
|
||||
上传
|
||||
</button>
|
||||
{portraitImageUrl && (
|
||||
<button
|
||||
className="xx-ce-file-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPortraitImageUrl("")
|
||||
upd("portraitImage", "")
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={portraitFileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) =>
|
||||
handleFile(e.target.files?.[0], setPortraitImageUrl, "portraitImage")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">大小: {cfg.portraitSize}%</label>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
step={1}
|
||||
value={cfg.portraitSize}
|
||||
onChange={(v) => upd("portraitSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">位置(X/Y %)</label>
|
||||
<PositionPair
|
||||
x={cfg.portraitPosition.x}
|
||||
y={cfg.portraitPosition.y}
|
||||
onChange={(pos) => upd("portraitPosition", pos)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-switch-item">
|
||||
<div className="xx-ce-switch-row">
|
||||
<span>人物描边</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.personStrokeEnabled}
|
||||
onChange={(v) => upd("personStrokeEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
{cfg.personStrokeEnabled && (
|
||||
<>
|
||||
<div className="xx-ce-row">
|
||||
<div className="xx-ce-radio-group">
|
||||
{(["solid", "dashed"] as StrokeStyle[]).map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
className={`xx-ce-radio-btn ${cfg.personStrokeStyle === st ? "active" : ""}`}
|
||||
onClick={() => upd("personStrokeStyle", st)}
|
||||
type="button"
|
||||
>
|
||||
{st === "solid" ? "实线" : "虚线"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">描边颜色</label>
|
||||
<ColorPicker
|
||||
value={cfg.personStrokeColor}
|
||||
onChange={(v) => upd("personStrokeColor", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">描边粗细: {cfg.personStrokeWidth}px</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={cfg.personStrokeWidth}
|
||||
onChange={(v) => upd("personStrokeWidth", v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 6. 蒙版 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("mask")}>
|
||||
<span>🌓 蒙版</span>
|
||||
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.maskEnabled}
|
||||
onChange={(v) => upd("maskEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sections.mask && cfg.maskEnabled && (
|
||||
<div className="xx-ce-section-body">
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">蒙版图片(可选)</label>
|
||||
<div className="xx-ce-file-row">
|
||||
<input
|
||||
className="xx-ce-file-name"
|
||||
readOnly
|
||||
value={cfg.maskImage ? "已选择文件" : "未选择文件"}
|
||||
/>
|
||||
<button
|
||||
className="xx-ce-file-btn"
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
选择
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) upd("maskImage", f.name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">蒙版颜色</label>
|
||||
<ColorPicker value={cfg.maskColor} onChange={(v) => upd("maskColor", v)} />
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">不透明度: {cfg.maskOpacity}%</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={cfg.maskOpacity}
|
||||
onChange={(v) => upd("maskOpacity", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">大小: {cfg.maskSize}%</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={cfg.maskSize}
|
||||
onChange={(v) => upd("maskSize", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧画布预览 */}
|
||||
<div className="xx-cover-editor-right">
|
||||
<div className="xx-cover-editor-canvas">
|
||||
{/* 人像占位 */}
|
||||
<div className="xx-cover-editor-portrait">
|
||||
{/* 四角拖拽手柄 */}
|
||||
<span className="xx-cover-editor-handle" style={{ top: -4, left: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ top: -4, right: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ bottom: -4, left: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ bottom: -4, right: -4 }} />
|
||||
{/* ── Right Preview ── */}
|
||||
<div className="xx-ce-right">
|
||||
<div className="xx-ce-canvas-wrap">
|
||||
<span className="xx-ce-anchor-dot" style={{ top: "10%", left: "-12px" }} />
|
||||
<span className="xx-ce-anchor-dot" style={{ top: "50%", right: "-12px" }} />
|
||||
<span className="xx-ce-anchor-dot" style={{ bottom: "10%", left: "-12px" }} />
|
||||
|
||||
<div className="xx-ce-canvas">
|
||||
{/* base background layer */}
|
||||
<div
|
||||
className="xx-ce-canvas-base"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: bgImageUrl
|
||||
? `url(${bgImageUrl}) center/cover no-repeat`
|
||||
: cfg.backgroundColor
|
||||
? cfg.backgroundColor
|
||||
: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
|
||||
filter: cfg.blurEnabled ? `blur(${cfg.blurAmount / 4}px)` : undefined,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Background decoration */}
|
||||
{cfg.backgroundEnabled && !bgImageUrl && (
|
||||
<div
|
||||
className="xx-ce-el-bg"
|
||||
style={{
|
||||
width: `${cfg.backgroundSize}%`,
|
||||
height: `${cfg.backgroundSize}%`,
|
||||
left: `${cfg.backgroundPosition.x}%`,
|
||||
top: `${cfg.backgroundPosition.y}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Portrait */}
|
||||
{cfg.portraitEnabled && (
|
||||
<div
|
||||
className="xx-ce-el-portrait"
|
||||
style={{
|
||||
width: `${cfg.portraitSize}%`,
|
||||
aspectRatio: "3/4",
|
||||
height: "auto",
|
||||
left: `${cfg.portraitPosition.x}%`,
|
||||
top: `${cfg.portraitPosition.y}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
borderStyle: cfg.personStrokeEnabled ? cfg.personStrokeStyle : "none",
|
||||
borderColor: cfg.personStrokeEnabled ? cfg.personStrokeColor : "transparent",
|
||||
borderWidth: cfg.personStrokeEnabled
|
||||
? `${Math.max(1, s(cfg.personStrokeWidth))}px`
|
||||
: "0",
|
||||
background: portraitImageUrl
|
||||
? `url(${portraitImageUrl}) center/contain no-repeat`
|
||||
: "linear-gradient(135deg, #a8d4f0, #7ab8e0)",
|
||||
zIndex: 2,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div style={renderTextStyle(cfg.title)}>{renderTextLines(cfg.title)}</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div style={renderTextStyle(cfg.subtitle)}>{renderTextLines(cfg.subtitle)}</div>
|
||||
|
||||
{/* Mask overlay */}
|
||||
{cfg.maskEnabled && (
|
||||
<div
|
||||
className="xx-ce-el-mask"
|
||||
style={{
|
||||
backgroundColor: cfg.maskColor,
|
||||
opacity: cfg.maskOpacity / 100,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 文字占位 */}
|
||||
<div className="xx-cover-editor-title-placeholder">主标题文字</div>
|
||||
<div className="xx-cover-editor-subtitle-placeholder">副标题文字</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-ce-preview-tip">👁️ 实时预览(9:16 竖版)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -40,12 +40,26 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
onCreateNew,
|
||||
}) => {
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
title="封面设置"
|
||||
centered
|
||||
footer={
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={onClose}>
|
||||
确认应用
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="xx-cover-modal-toolbar">
|
||||
<Button buttonType="primary">选择素材文件</Button>
|
||||
<Button buttonType="ghost">导出全部</Button>
|
||||
<Button buttonType="primary" onClick={onCreateNew}>
|
||||
创建新模板
|
||||
+ 创建新模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -59,52 +73,78 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="xx-cover-template-grid">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onSelectTemplate(tpl.id)}
|
||||
>
|
||||
<div
|
||||
className="xx-cover-template-thumb"
|
||||
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
|
||||
>
|
||||
🖼️
|
||||
</div>
|
||||
<div className="xx-cover-template-info">
|
||||
<div className="xx-cover-template-name">
|
||||
{tpl.name}
|
||||
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统模板</span>}
|
||||
</div>
|
||||
<div className="xx-cover-template-date">{tpl.created_at}</div>
|
||||
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
|
||||
编辑
|
||||
</Button>
|
||||
{!tpl.is_system && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
if (confirm("确定删除此模板?")) {
|
||||
onDeleteTemplate(tpl.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="ghost" buttonSize="sm">
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && !error && templates.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "40px 0",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && templates.length > 0 && (
|
||||
<div className="xx-cover-template-grid">
|
||||
{templates.map((tpl) => {
|
||||
const isSelected = selectedTemplateId === tpl.id
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelectTemplate(tpl.id)}
|
||||
>
|
||||
<div
|
||||
className="xx-cover-template-thumb"
|
||||
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
|
||||
>
|
||||
{isSelected && <span className="xx-cover-template-check">✓</span>}
|
||||
🖼️
|
||||
</div>
|
||||
<div className="xx-cover-template-info">
|
||||
<div className="xx-cover-template-name">
|
||||
{tpl.name}
|
||||
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统</span>}
|
||||
</div>
|
||||
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
|
||||
编辑
|
||||
</Button>
|
||||
{!tpl.is_system && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
if (confirm("确定删除此模板?")) {
|
||||
onDeleteTemplate(tpl.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "8px 12px",
|
||||
background: "rgba(124,58,237,0.06)",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: "#6d28d9",
|
||||
}}
|
||||
>
|
||||
💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 单个 AI 标题卡片
|
||||
*/
|
||||
import React from "react"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
|
||||
interface AiTitleCardProps {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const AiTitleCard: React.FC<AiTitleCardProps> = ({
|
||||
title,
|
||||
highlight,
|
||||
style,
|
||||
selected,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`xx-ai-title-card ${selected ? "selected" : ""} ${style}`} onClick={onClick}>
|
||||
<div className="xx-ai-title-card-text">{title}</div>
|
||||
<div className="xx-ai-title-card-tag">{highlight}</div>
|
||||
{selected && (
|
||||
<div className="xx-ai-title-card-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiTitleCard
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* AI 智能生成标题
|
||||
* 输入框 + 生成按钮 + 结果列表 + 加载状态
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import AiTitleCard from "./AiTitleCard"
|
||||
|
||||
interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
interface AiTitleGeneratorProps {
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
generating: boolean
|
||||
onGenerate: () => void
|
||||
results: AiTitleItem[]
|
||||
hasGenerated: boolean
|
||||
onSelect: (title: string) => void
|
||||
selectedTitle: string
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const AiTitleGenerator: React.FC<AiTitleGeneratorProps> = ({
|
||||
inputValue,
|
||||
onInputChange,
|
||||
generating,
|
||||
onGenerate,
|
||||
results,
|
||||
hasGenerated,
|
||||
onSelect,
|
||||
selectedTitle,
|
||||
onRefresh,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-ai-title-section">
|
||||
<div className="xx-ai-title-header">
|
||||
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
|
||||
</div>
|
||||
<div className="xx-ai-title-input-row">
|
||||
<input
|
||||
className="xx-ai-title-input"
|
||||
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onGenerate()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || !inputValue.trim()}
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
"生成标题"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{hasGenerated && !generating && results.length > 0 && (
|
||||
<div className="xx-ai-title-results">
|
||||
<div className="xx-ai-title-results-header">
|
||||
<span className="xx-ai-title-results-count">为你生成 {results.length} 个标题</span>
|
||||
<button type="button" className="xx-link-btn" onClick={onRefresh} disabled={generating}>
|
||||
🔄 换一批
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-ai-title-list">
|
||||
{results.map((item, idx) => (
|
||||
<AiTitleCard
|
||||
key={idx}
|
||||
title={item.title}
|
||||
highlight={item.highlight}
|
||||
style={item.style}
|
||||
selected={selectedTitle === item.title}
|
||||
onClick={() => onSelect(item.title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{generating && (
|
||||
<div className="xx-ai-title-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
AI 正在为你创作标题…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiTitleGenerator
|
||||
@@ -18,10 +18,14 @@ interface Props {
|
||||
settings: TitleSettings
|
||||
width?: number
|
||||
sampleText?: string
|
||||
/** 背景(预览用,默认深色渐变模拟视频底) */
|
||||
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
|
||||
background?: string
|
||||
/** 高度(可选,默认 width/2) */
|
||||
/** 高度(可选,默认按 portrait 选比例) */
|
||||
height?: number
|
||||
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
|
||||
transparent?: boolean
|
||||
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
|
||||
portrait?: boolean
|
||||
}
|
||||
|
||||
/** 按 maxCharsPerLine 自动换行 */
|
||||
@@ -56,9 +60,11 @@ const TitleMiniPreview: React.FC<Props> = ({
|
||||
sampleText,
|
||||
background = "linear-gradient(135deg,#1f2937,#111827)",
|
||||
height,
|
||||
transparent = false,
|
||||
portrait = false,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const h = height ?? Math.round(width / 1.8)
|
||||
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
|
||||
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,9 +80,11 @@ const TitleMiniPreview: React.FC<Props> = ({
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, width, h)
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = "#111827"
|
||||
ctx.fillRect(0, 0, width, h)
|
||||
// 背景(transparent 时跳过,用于叠加在图片上)
|
||||
if (!transparent) {
|
||||
ctx.fillStyle = "#111827"
|
||||
ctx.fillRect(0, 0, width, h)
|
||||
}
|
||||
|
||||
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半)
|
||||
const scale = width / 360
|
||||
@@ -121,7 +129,8 @@ const TitleMiniPreview: React.FC<Props> = ({
|
||||
startY = h / 2 - totalH / 2 + size / 2
|
||||
} else {
|
||||
// bottom
|
||||
startY = h - totalH - r(16) + size / 2
|
||||
const botMargin = portrait ? r(24) : r(16)
|
||||
startY = h - totalH - botMargin + size / 2
|
||||
}
|
||||
let centerX = width / 2
|
||||
if (settings.position === "custom" && settings.posX != null) {
|
||||
@@ -181,7 +190,7 @@ const TitleMiniPreview: React.FC<Props> = ({
|
||||
ctx.shadowBlur = prevShadow.b
|
||||
ctx.shadowOffsetX = prevShadow.ox
|
||||
ctx.shadowOffsetY = prevShadow.oy
|
||||
}, [settings, width, h, text])
|
||||
}, [settings, width, h, text, transparent, portrait, background])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
@@ -190,7 +199,7 @@ const TitleMiniPreview: React.FC<Props> = ({
|
||||
borderRadius: 6,
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
background,
|
||||
background: transparent ? "transparent" : background,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -535,31 +535,6 @@ export const SMART_MATCH_REASONS = [
|
||||
"人物表情生动",
|
||||
]
|
||||
|
||||
/* ── AI 标题模板 ── */
|
||||
export const AI_TITLE_TEMPLATES: Record<string, string[]> = {
|
||||
catchy: [
|
||||
"震惊!{topic}居然还能这样操作",
|
||||
"99%的人都不知道的{topic}秘诀",
|
||||
"{topic}的终极指南,看完直接封神",
|
||||
"别再走弯路了!{topic}看这一篇就够",
|
||||
"一个视频讲透{topic},建议收藏",
|
||||
],
|
||||
emotional: [
|
||||
"致每一个在{topic}路上坚持的人",
|
||||
"关于{topic},我想说句真心话",
|
||||
"{topic}背后的故事,看完沉默了",
|
||||
"为什么我劝你一定要了解{topic}",
|
||||
"这才是{topic}最动人的样子",
|
||||
],
|
||||
informative: [
|
||||
"{topic}完整科普:从入门到精通",
|
||||
"深度解析{topic}的核心原理",
|
||||
"{topic}行业趋势报告|2026最新版",
|
||||
"三分钟带你全面了解{topic}",
|
||||
"{topic}常见问题与解决方案汇总",
|
||||
],
|
||||
}
|
||||
|
||||
/* ── 默认封面设置 ── */
|
||||
export const DEFAULT_COVER_SETTINGS: CoverConfig = {
|
||||
enabled: true,
|
||||
|
||||
@@ -2739,6 +2739,7 @@
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #ccc;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 卡片信息区 */
|
||||
@@ -2880,27 +2881,6 @@
|
||||
Issue #1677 多视频批量生成
|
||||
================================================================ */
|
||||
|
||||
/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */
|
||||
.xx-generate-layout.step4-layout {
|
||||
grid-template-columns: 1fr 380px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col {
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col .xx-form-section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-title-sidebar {
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── 数量选择弹窗 ── */
|
||||
.xx-modal-mask {
|
||||
position: fixed;
|
||||
@@ -3240,135 +3220,6 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── 响应式:窄屏 Step4 回退单列 ── */
|
||||
@media (max-width: 960px) {
|
||||
.xx-generate-layout.step4-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.xx-title-sidebar {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
|
||||
============================================================ */
|
||||
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
|
||||
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
|
||||
.xx-canvas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
|
||||
@media (max-width: 960px) {
|
||||
.xx-canvas-grid {
|
||||
grid-template-columns: minmax(0, 320px);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card.selected {
|
||||
border-color: var(--primary-color, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card-bar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border-bottom: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.xx-canvas-grid-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-canvas-grid-check input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color, #1677ff);
|
||||
}
|
||||
|
||||
/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */
|
||||
.xx-variant-clips-status {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: #0a0a0a;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||
0 20px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.xx-variant-clips-status .anticon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.xx-variant-clips-error-text {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
批量标题:AI 一键生成行(Issue #1677)
|
||||
============================================================ */
|
||||
.xx-batch-ai-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--bg-secondary, #f7f8fa);
|
||||
border: 1px dashed var(--border-primary, #d9d9d9);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.xx-batch-ai-row .xx-form-field {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.xx-batch-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3485,3 +3336,452 @@
|
||||
grid-template-columns: minmax(0, 360px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
自定义封面编辑器 (Cover Editor Modal) — xx-ce-*
|
||||
================================================================ */
|
||||
|
||||
/* Header */
|
||||
.xx-ce-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.xx-ce-name-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.xx-ce-name-input:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
.xx-ce-header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.xx-ce-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 500px;
|
||||
}
|
||||
.xx-ce-left {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.xx-ce-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
/* Section / collapsible panels */
|
||||
.xx-ce-section {
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xx-ce-section-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f0f4ff;
|
||||
user-select: none;
|
||||
}
|
||||
.xx-ce-section-header:hover {
|
||||
background: #e8edf8;
|
||||
}
|
||||
.xx-ce-section-body {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.xx-ce-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xx-ce-status-text {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Rows / labels */
|
||||
.xx-ce-row {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.xx-ce-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.xx-ce-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.xx-ce-sub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.xx-ce-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.xx-ce-switch-item {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.xx-ce-switch-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Color picker */
|
||||
.xx-ce-color-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xx-ce-color-picker input[type="color"] {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 1px;
|
||||
}
|
||||
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.xx-ce-color-hex {
|
||||
width: 70px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Position pair */
|
||||
.xx-ce-position {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.xx-ce-position .ant-input-number {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Radio button group */
|
||||
.xx-ce-radio-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
.xx-ce-radio-btn {
|
||||
padding: 4px 14px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.xx-ce-radio-btn:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
.xx-ce-radio-btn:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.xx-ce-radio-btn + .xx-ce-radio-btn {
|
||||
border-left: none;
|
||||
}
|
||||
.xx-ce-radio-btn.active {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
|
||||
border-left: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
/* Font select dots */
|
||||
.xx-ce-font-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.xx-ce-font-dot--preset {
|
||||
background: #10b981;
|
||||
}
|
||||
.xx-ce-font-dot--system {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* Shadow actions */
|
||||
.xx-ce-shadow-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.xx-ce-add-shadow-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xx-ce-add-shadow-btn:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
.xx-ce-preset-shadow-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Text background sub-section */
|
||||
.xx-ce-text-bg-section {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* Readonly text display */
|
||||
.xx-ce-readonly-text {
|
||||
padding: 6px 10px;
|
||||
background: #eff6ff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Mask file row */
|
||||
.xx-ce-file-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.xx-ce-file-name {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
.xx-ce-file-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xx-ce-file-btn:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── Canvas / Preview ── */
|
||||
.xx-ce-canvas-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.xx-ce-canvas {
|
||||
width: 225px;
|
||||
height: 400px;
|
||||
background: #ddd;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.xx-ce-anchor-dot {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Portrait element */
|
||||
.xx-ce-el-portrait {
|
||||
position: absolute;
|
||||
background: #a8d4f0;
|
||||
border: 2px solid #333;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 8 handles: 0=TL 1=T 2=TR 3=R 4=BR 5=B 6=BL 7=L */
|
||||
.xx-ce-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3b82f6;
|
||||
border: 1px solid #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
.xx-ce-handle--0 {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
.xx-ce-handle--1 {
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.xx-ce-handle--2 {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
.xx-ce-handle--3 {
|
||||
top: 50%;
|
||||
right: -4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.xx-ce-handle--4 {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
.xx-ce-handle--5 {
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.xx-ce-handle--6 {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
.xx-ce-handle--7 {
|
||||
top: 50%;
|
||||
left: -4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
/* Background element */
|
||||
.xx-ce-el-bg {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Mask overlay */
|
||||
.xx-ce-el-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Text background shape in canvas */
|
||||
.xx-ce-text-bg {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
|
||||
/* Cover template selected check */
|
||||
.xx-cover-template-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 6px rgba(124,58,237,0.4);
|
||||
}
|
||||
.xx-cover-template-thumb {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Preview tip */
|
||||
.xx-ce-preview-tip {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Cover editor modal base gradient */
|
||||
.xx-ce-canvas {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Antd Slider overrides for editor */
|
||||
.xx-ce-section-body .ant-slider {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.xx-ce-section-body .ant-slider-rail {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.xx-ce-section-body .ant-slider-track {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.xx-ce-section-body .ant-slider-handle::after {
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
.xx-ce-section-body .ant-slider-mark-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Antd Select dropdown font dots */
|
||||
.xx-ce-font-select-dropdown .ant-select-item-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Canvas text elements — ensure proper stacking */
|
||||
.xx-ce-canvas > div {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BatchTaskState {
|
||||
taskId: string
|
||||
/** 变体序号(0-based,与标题/封面数组对齐) */
|
||||
variantIndex: number
|
||||
status: "running" | "completed" | "failed"
|
||||
status: "running" | "completed" | "awaiting_cover" | "failed"
|
||||
progress: number
|
||||
error: string | null
|
||||
/** 完成后的成片视频 */
|
||||
@@ -96,7 +96,7 @@ export function useGenerationPolling({
|
||||
runId: number,
|
||||
callbacks?: {
|
||||
onTaskProgress?: (pct: number) => void
|
||||
onTaskCompleted?: (videos: unknown[]) => void
|
||||
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
|
||||
onTaskFailed?: (msg: string) => void
|
||||
},
|
||||
): Promise<unknown[]> => {
|
||||
@@ -111,7 +111,7 @@ export function useGenerationPolling({
|
||||
if (cancelledRef.current || done) return
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed") {
|
||||
if (task.status === "completed" || task.status === "awaiting_cover") {
|
||||
done = true
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
@@ -121,7 +121,7 @@ export function useGenerationPolling({
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
callbacks?.onTaskCompleted?.(videos)
|
||||
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
|
||||
resolve(videos)
|
||||
return
|
||||
}
|
||||
@@ -259,10 +259,11 @@ export function useGenerationPolling({
|
||||
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
|
||||
reportAggregateProgress()
|
||||
},
|
||||
onTaskCompleted: (videos) => {
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
progressMap.set(taskId, 100)
|
||||
resultMap.set(taskId, videos)
|
||||
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
|
||||
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
reportAggregateProgress()
|
||||
checkAllSettled()
|
||||
},
|
||||
@@ -293,8 +294,9 @@ export function useGenerationPolling({
|
||||
}
|
||||
pollSingleTask(taskId, Date.now(), {
|
||||
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
|
||||
onTaskCompleted: (videos) => {
|
||||
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
message.success(`视频 ${variantIndex + 1} 重试成功`)
|
||||
},
|
||||
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
|
||||
|
||||
@@ -53,7 +53,7 @@ interface UseBatchCoversOptions {
|
||||
}
|
||||
|
||||
export function useBatchCovers({
|
||||
selectedTemplate,
|
||||
selectedTemplate: _selectedTemplate,
|
||||
generatedVideos,
|
||||
titles,
|
||||
titleStyle,
|
||||
@@ -93,7 +93,12 @@ export function useBatchCovers({
|
||||
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
|
||||
const generateOne = useCallback(
|
||||
async (index: number): Promise<boolean> => {
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const finalVideos = generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" ||
|
||||
v.status === "awaiting_cover" ||
|
||||
v.status === "awaiting_cover",
|
||||
)
|
||||
const target = finalVideos[index] || generatedVideos[index]
|
||||
if (!target) {
|
||||
message.warning("该视频尚未生成完成")
|
||||
@@ -102,7 +107,7 @@ export function useBatchCovers({
|
||||
addBusy(index)
|
||||
try {
|
||||
const titleText = titles[index] || ""
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
const response = await generateCover("default", {
|
||||
generated_video_id: target.id,
|
||||
video_url: target.file_url || target.download_url || "",
|
||||
cover_type: "ai_frame",
|
||||
@@ -166,7 +171,7 @@ export function useBatchCovers({
|
||||
removeBusy(index)
|
||||
}
|
||||
},
|
||||
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
|
||||
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
|
||||
)
|
||||
|
||||
/** 为第 index 个视频上传自定义封面 */
|
||||
@@ -203,7 +208,10 @@ export function useBatchCovers({
|
||||
|
||||
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
|
||||
const generateAll = useCallback(async () => {
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const finalVideos = generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
)
|
||||
const total = finalVideos.length
|
||||
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
|
||||
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
|
||||
setBatchTasks((prev) =>
|
||||
(prev || []).map((t) =>
|
||||
t.status === "completed" && t.videos.length === 0
|
||||
t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0)
|
||||
? {
|
||||
...t,
|
||||
videos: (videos as GeneratedVideo[]).filter(
|
||||
@@ -83,7 +83,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
if (batchTasks.length === 0) return
|
||||
const byVariant = new Map<number, GeneratedVideo>()
|
||||
batchTasks.forEach((t) => {
|
||||
if (t.status === "completed" && t.videos && t.videos.length > 0) {
|
||||
if (
|
||||
t.status === "completed" ||
|
||||
(t.status === "awaiting_cover" && t.videos && t.videos.length > 0)
|
||||
) {
|
||||
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* 预览素材加载 Hook
|
||||
* 根据选中的素材 ID 列表,逐个获取素材详情(含 file_url、duration 等)
|
||||
* 供前端预览播放器使用
|
||||
*
|
||||
* 注意:后端没有批量接口(/assets/batch 返回 405),
|
||||
* 因此直接使用 Promise.allSettled 并发请求单个 GET /assets/{id}
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { AxiosResponse } from "axios"
|
||||
|
||||
/**
|
||||
* 通过 ID 列表逐个获取素材(并发)
|
||||
* 使用 Promise.allSettled 确保单个失败不影响整体
|
||||
*/
|
||||
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
|
||||
// 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求
|
||||
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
if (!validIds.length) return []
|
||||
|
||||
try {
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const results = await Promise.allSettled(
|
||||
validIds.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
|
||||
)
|
||||
return results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<AxiosResponse<AssetItem>> =>
|
||||
r.status === "fulfilled" && !!r.value?.data,
|
||||
)
|
||||
.map((r) => r.value.data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface UsePreviewAssetsReturn {
|
||||
/** 加载后的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 是否正在加载 */
|
||||
loading: boolean
|
||||
/** 是否已就绪(加载完成) */
|
||||
ready: boolean
|
||||
/** 手动触发重新加载 */
|
||||
reload: () => void
|
||||
/**
|
||||
* 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材
|
||||
* (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并,
|
||||
* 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。
|
||||
*/
|
||||
ensureAssets: (ids: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* usePreviewAssets — 加载选中素材的视频文件信息
|
||||
*/
|
||||
export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn {
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [ready, setReady] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// 稳定化 assetIds:只有内容真正变化时才更新引用
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const validIds = stableAssetIds.filter(
|
||||
(id): id is string => typeof id === "string" && id.length > 0,
|
||||
)
|
||||
if (!validIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
}
|
||||
|
||||
const thisRequestId = ++requestIdRef.current
|
||||
setLoading(true)
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(validIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
setReady(result.length > 0)
|
||||
}
|
||||
} catch {
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
}
|
||||
} finally {
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [stableAssetIds, enabled])
|
||||
|
||||
// 补拉用独立序号:不干扰主 load 的竞态守卫
|
||||
const ensureSeqRef = useRef(0)
|
||||
const assetsRef = useRef<AssetItem[]>([])
|
||||
useEffect(() => {
|
||||
assetsRef.current = assets
|
||||
}, [assets])
|
||||
|
||||
/**
|
||||
* 差集补拉(#1750):变体计划 clips 引用的 asset_id 不在当前素材列表时,
|
||||
* 补拉素材详情并去重合并(不静默丢片段、不用假数据冒充)。
|
||||
*/
|
||||
const ensureAssets = useCallback(async (ids: string[]) => {
|
||||
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
if (!validIds.length) return
|
||||
const seq = ++ensureSeqRef.current
|
||||
const missing = Array.from(new Set(validIds)).filter(
|
||||
(id) => !assetsRef.current.some((a) => a.id === id),
|
||||
)
|
||||
if (!missing.length) return
|
||||
const fetched = await fetchAssetsByIds(missing)
|
||||
if (seq !== ensureSeqRef.current || !fetched.length) return
|
||||
const existing = new Set(assetsRef.current.map((a) => a.id))
|
||||
const additions = fetched.filter((a) => !existing.has(a.id))
|
||||
if (!additions.length) return
|
||||
const merged = [...assetsRef.current, ...additions]
|
||||
assetsRef.current = merged
|
||||
setAssets(merged)
|
||||
setReady(merged.length > 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return { assets, loading, ready, reload: load, ensureAssets }
|
||||
}
|
||||
|
||||
/**
|
||||
* useStableArray — 数组内容稳定化 Hook
|
||||
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
|
||||
*/
|
||||
function useStableArray<T>(array: T[]): T[] {
|
||||
const ref = useRef<T[]>(array)
|
||||
|
||||
// 比较数组内容是否真正变化
|
||||
const hasChanged =
|
||||
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
|
||||
|
||||
if (hasChanged) {
|
||||
ref.current = array
|
||||
}
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default usePreviewAssets
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
interface UsePreviewAudioOptions {
|
||||
voiceAudioUrl: string | undefined
|
||||
voiceDurationHint: number | undefined
|
||||
muted: boolean
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
onVoiceDurationChange: (d: number) => void
|
||||
onEnded: () => void
|
||||
}
|
||||
|
||||
interface UsePreviewAudioReturn {
|
||||
seekTo: (time: number) => void
|
||||
ensurePlayingAt: (time: number) => void
|
||||
pause: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、
|
||||
* ended 事件回调、seek 同步、末帧冻结期间续播。
|
||||
*/
|
||||
export function usePreviewAudio({
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
muted,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onVoiceDurationChange,
|
||||
onEnded,
|
||||
}: UsePreviewAudioOptions): UsePreviewAudioReturn {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
|
||||
// 外部 hint 初始化(自测值前的兜底)
|
||||
useEffect(() => {
|
||||
if (voiceDurationHint && voiceDurationHint > 0) {
|
||||
onVoiceDurationChange(voiceDurationHint)
|
||||
}
|
||||
}, [voiceDurationHint, onVoiceDurationChange])
|
||||
|
||||
// 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
audioRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio()
|
||||
audioRef.current.preload = "auto"
|
||||
}
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
audioRef.current.muted = muted
|
||||
|
||||
const audio = audioRef.current
|
||||
const onLoaded = () => {
|
||||
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
|
||||
onVoiceDurationChange(audio.duration)
|
||||
}
|
||||
}
|
||||
const onEndedHandler = () => onEnded()
|
||||
audio.addEventListener("loadedmetadata", onLoaded)
|
||||
audio.addEventListener("ended", onEndedHandler)
|
||||
return () => {
|
||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
||||
audio.removeEventListener("ended", onEndedHandler)
|
||||
}
|
||||
}, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded])
|
||||
|
||||
// mute 变化即时同步
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.muted = muted
|
||||
}, [muted])
|
||||
|
||||
// 播放/暂停同步(跟随视频 isPlaying)
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
if (isPlaying && !prevIsPlayingRef.current) {
|
||||
if (Math.abs(audio.currentTime - currentTime) > 0.3) {
|
||||
try {
|
||||
audio.currentTime = currentTime
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
audio.play().catch(() => {})
|
||||
} else if (!isPlaying && prevIsPlayingRef.current) {
|
||||
audio.pause()
|
||||
}
|
||||
prevIsPlayingRef.current = isPlaying
|
||||
}, [isPlaying, currentTime])
|
||||
|
||||
const seekTo = useCallback((time: number) => {
|
||||
const audio = audioRef.current
|
||||
if (audio && audio.src) {
|
||||
try {
|
||||
audio.currentTime = time
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ensurePlayingAt = useCallback((time: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
try {
|
||||
if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time
|
||||
if (audio.paused) audio.play().catch(() => {})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
try {
|
||||
audioRef.current?.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { seekTo, ensurePlayingAt, pause }
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
/**
|
||||
* 素材片段调度器 Hook(多 video 元素方案 v3)
|
||||
*
|
||||
* v3 修复:
|
||||
* - 所有动态状态存入 ref,tick 为稳定函数,彻底消除 RAF 闭包陷阱
|
||||
* - 片段切换时先启动下一个 video 再切可见性,消除冻屏间隔
|
||||
* - 进度更新 200ms 节流
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
export interface PlaybackSegment {
|
||||
assetId: string
|
||||
videoUrl: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
order: number
|
||||
/** #1754 兜底:配音时长≠clips 总时长时按比例调速,1.0 = 原速 */
|
||||
playbackRate?: number
|
||||
}
|
||||
|
||||
export interface SegmentSchedulerState {
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
currentSegmentIndex: number
|
||||
segmentLocalTime: number
|
||||
isEnded: boolean
|
||||
canPlay: boolean
|
||||
play: () => void
|
||||
pause: () => void
|
||||
togglePlayPause: () => void
|
||||
seekTo: (time: number) => void
|
||||
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
|
||||
}
|
||||
|
||||
function findSegmentAtTime(
|
||||
segments: PlaybackSegment[],
|
||||
globalTime: number,
|
||||
): { index: number; localTime: number } {
|
||||
let accumulated = 0
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]
|
||||
const segDuration = seg.endTime - seg.startTime
|
||||
if (globalTime < accumulated + segDuration || i === segments.length - 1) {
|
||||
return { index: i, localTime: seg.startTime + (globalTime - accumulated) }
|
||||
}
|
||||
accumulated += segDuration
|
||||
}
|
||||
return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime }
|
||||
}
|
||||
|
||||
function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
const starts: number[] = []
|
||||
let acc = 0
|
||||
for (const seg of segments) {
|
||||
starts.push(acc)
|
||||
acc += seg.endTime - seg.startTime
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
|
||||
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
|
||||
const [isEnded, setIsEnded] = useState(false)
|
||||
const rafRef = useRef(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
const lastTimeUpdateRef = useRef(0)
|
||||
|
||||
// 所有动态值存入 ref,tick 始终读取最新值,不依赖闭包
|
||||
const segIdxRef = useRef(0)
|
||||
const segmentsRef = useRef(segments)
|
||||
const timelineStartsData = useMemo(() => buildTimeline(segments), [segments])
|
||||
const totalDurationData = useMemo(
|
||||
() => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0),
|
||||
[segments],
|
||||
)
|
||||
const timelineStartsRef = useRef(timelineStartsData)
|
||||
const totalDurationRef = useRef(totalDurationData)
|
||||
const isPlayingRef = useRef(false)
|
||||
|
||||
segmentsRef.current = segments
|
||||
timelineStartsRef.current = timelineStartsData
|
||||
totalDurationRef.current = totalDurationData
|
||||
|
||||
const canPlay = segments.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
segIdxRef.current = currentSegmentIndex
|
||||
}, [currentSegmentIndex])
|
||||
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying
|
||||
}, [isPlaying])
|
||||
|
||||
const waitForReady = useCallback((video: HTMLVideoElement, timeout = 3000): Promise<void> => {
|
||||
if (video.readyState >= 3) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const onCanPlay = () => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
resolve()
|
||||
}, timeout)
|
||||
video.addEventListener("canplay", onCanPlay)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const switchToSegment = useCallback(
|
||||
async (index: number, seekToLocalTime?: number) => {
|
||||
const segs = segmentsRef.current
|
||||
const video = videoRefs.current[index]
|
||||
if (!video || index >= segs.length) return
|
||||
|
||||
const seg = segs[index]
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
const oldIdx = segIdxRef.current
|
||||
const oldVideo = videoRefs.current[oldIdx]
|
||||
|
||||
if (oldVideo && oldVideo !== video) oldVideo.pause()
|
||||
|
||||
if (!video.src && seg.videoUrl) {
|
||||
video.src = seg.videoUrl
|
||||
video.load()
|
||||
}
|
||||
|
||||
if (Math.abs(video.currentTime - localTime) > 0.05) {
|
||||
video.currentTime = localTime
|
||||
}
|
||||
// #1754:按比例调速(配音时长≠clips 总时长时的前端兜底)
|
||||
const rate = seg.playbackRate || 1
|
||||
if (Math.abs(video.playbackRate - rate) > 0.01) {
|
||||
video.playbackRate = rate
|
||||
}
|
||||
|
||||
segIdxRef.current = index
|
||||
setCurrentSegmentIndex(index)
|
||||
|
||||
await waitForReady(video)
|
||||
},
|
||||
[waitForReady],
|
||||
)
|
||||
|
||||
// 稳定的 tick 函数,空依赖,所有值从 ref 读取
|
||||
const tick = useCallback(() => {
|
||||
const segs = segmentsRef.current
|
||||
const idx = segIdxRef.current
|
||||
const video = videoRefs.current[idx]
|
||||
|
||||
if (!video || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
const seg = segs[idx]
|
||||
if (!seg) return
|
||||
|
||||
// 预加载下一个片段
|
||||
const nextIndex = idx + 1
|
||||
if (nextIndex < segs.length) {
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
if (nextVideo) {
|
||||
const timeToEnd = seg.endTime - video.currentTime
|
||||
if (timeToEnd <= 2 && nextVideo.readyState < 3) {
|
||||
const nextSeg = segs[nextIndex]
|
||||
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.5) {
|
||||
nextVideo.currentTime = nextSeg.startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测片段边界
|
||||
if (video.currentTime >= seg.endTime - 0.1) {
|
||||
if (nextIndex < segs.length) {
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
const nextSeg = segs[nextIndex]
|
||||
const accumulatedTime =
|
||||
(timelineStartsRef.current[idx] || 0) + (seg.endTime - seg.startTime)
|
||||
|
||||
if (nextVideo) {
|
||||
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.1) {
|
||||
nextVideo.currentTime = nextSeg.startTime
|
||||
}
|
||||
// 先启动下一个视频(muted,可安全同时播放)
|
||||
nextVideo
|
||||
.play()
|
||||
.catch((e) => console.warn("[useSegmentScheduler] next segment play failed:", e))
|
||||
}
|
||||
|
||||
// 立即切换可见性
|
||||
segIdxRef.current = nextIndex
|
||||
setCurrentSegmentIndex(nextIndex)
|
||||
setCurrentTime(accumulatedTime)
|
||||
lastTimeUpdateRef.current = 0
|
||||
setIsPlaying(true)
|
||||
|
||||
// 下一帧暂停旧视频(让新视频先渲染,避免冻屏)
|
||||
const oldVideo = video
|
||||
requestAnimationFrame(() => {
|
||||
oldVideo.pause()
|
||||
})
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
} else {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
setIsEnded(true)
|
||||
setCurrentTime(totalDurationRef.current)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const globalTime = (timelineStartsRef.current[idx] || 0) + (video.currentTime - seg.startTime)
|
||||
const now = performance.now()
|
||||
if (now - lastTimeUpdateRef.current >= 200) {
|
||||
lastTimeUpdateRef.current = now
|
||||
setCurrentTime(Math.max(0, Math.min(globalTime, totalDurationRef.current)))
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}, [])
|
||||
|
||||
const play = useCallback(async () => {
|
||||
if (!canPlay) return
|
||||
setIsEnded(false)
|
||||
const idx = segIdxRef.current
|
||||
const video = videoRefs.current[idx]
|
||||
if (!video) return
|
||||
|
||||
if (idx === 0 && video.readyState < 2) {
|
||||
if (!video.src && segmentsRef.current[0]?.videoUrl) {
|
||||
video.src = segmentsRef.current[0].videoUrl
|
||||
video.load()
|
||||
}
|
||||
await waitForReady(video)
|
||||
}
|
||||
|
||||
// 播放前 seek 到片段起始时间,确保 progress 计算正确
|
||||
const seg = segmentsRef.current[idx]
|
||||
if (seg && Math.abs(video.currentTime - seg.startTime) > 0.1) {
|
||||
video.currentTime = seg.startTime
|
||||
}
|
||||
|
||||
// #1754:调速
|
||||
const rate = seg?.playbackRate || 1
|
||||
if (Math.abs(video.playbackRate - rate) > 0.01) {
|
||||
video.playbackRate = rate
|
||||
}
|
||||
|
||||
try {
|
||||
await video.play()
|
||||
setIsPlaying(true)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch (err) {
|
||||
console.warn("[useSegmentScheduler] 播放失败:", err)
|
||||
}
|
||||
}, [canPlay, waitForReady, tick])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRefs.current[segIdxRef.current]
|
||||
if (video) video.pause()
|
||||
setIsPlaying(false)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (isPlayingRef.current) {
|
||||
pause()
|
||||
} else {
|
||||
if (isEnded) {
|
||||
setIsEnded(false)
|
||||
lastTimeUpdateRef.current = 0
|
||||
const firstVideo = videoRefs.current[0]
|
||||
if (firstVideo) {
|
||||
videoRefs.current.forEach((v, i) => {
|
||||
if (v && i !== 0) v.pause()
|
||||
})
|
||||
firstVideo.currentTime = segmentsRef.current[0]?.startTime || 0
|
||||
segIdxRef.current = 0
|
||||
setCurrentSegmentIndex(0)
|
||||
setCurrentTime(0)
|
||||
firstVideo
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
})
|
||||
.catch((e) => console.warn("[useSegmentScheduler] restart failed:", e))
|
||||
}
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
}, [isEnded, pause, play, tick])
|
||||
|
||||
const seekTo = useCallback(
|
||||
async (time: number) => {
|
||||
if (!canPlay) return
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDurationRef.current))
|
||||
const { index, localTime } = findSegmentAtTime(segmentsRef.current, clampedTime)
|
||||
|
||||
isSeekingRef.current = true
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
if (index !== segIdxRef.current) {
|
||||
await switchToSegment(index, localTime)
|
||||
} else {
|
||||
const video = videoRefs.current[index]
|
||||
if (video) video.currentTime = localTime
|
||||
}
|
||||
|
||||
setCurrentTime(clampedTime)
|
||||
setIsEnded(false)
|
||||
lastTimeUpdateRef.current = 0
|
||||
|
||||
if (isPlayingRef.current) {
|
||||
const video = videoRefs.current[index]
|
||||
if (video) {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false
|
||||
}, 200)
|
||||
},
|
||||
[canPlay, switchToSegment, tick],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
videoRefs.current = videoRefs.current.slice(0, segments.length)
|
||||
while (videoRefs.current.length < segments.length) {
|
||||
videoRefs.current.push(null)
|
||||
}
|
||||
}, [segments])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
segIdxRef.current = 0
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setCurrentSegmentIndex(0)
|
||||
setIsEnded(false)
|
||||
}, [segments])
|
||||
|
||||
const currentSegment = segments[currentSegmentIndex] || null
|
||||
const segmentLocalTime = currentSegment
|
||||
? currentTime - (timelineStartsRef.current[currentSegmentIndex] || 0) + currentSegment.startTime
|
||||
: 0
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration: totalDurationData,
|
||||
currentSegmentIndex,
|
||||
segmentLocalTime,
|
||||
isEnded,
|
||||
canPlay,
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRefs,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSegmentScheduler
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
// #1894: 标题候选从文案库 scripts[].title 获取,不再调用废弃的 /api/titles
|
||||
import { getScripts } from "@/api/scripts"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { useAiTitleGenerator } from "./useAiTitleGenerator"
|
||||
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
|
||||
import { useDraftAutoSave } from "../useDraftAutoSave"
|
||||
|
||||
@@ -16,7 +15,7 @@ interface UseStep4TitleProps {
|
||||
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
* 封装标题样式设置等逻辑
|
||||
*/
|
||||
export function useStep4Title({
|
||||
titleSettings,
|
||||
@@ -34,26 +33,9 @@ export function useStep4Title({
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// AI 标题生成
|
||||
const {
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
} = useAiTitleGenerator({ titleSettings, onTitleSettingsChange })
|
||||
|
||||
// 样式更新
|
||||
const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange })
|
||||
|
||||
// 追踪 AI 自动选择开关的上一次值 & 是否首次挂载
|
||||
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
|
||||
const isFirstMount = useRef(true)
|
||||
|
||||
/* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */
|
||||
const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate)
|
||||
useEffect(() => {
|
||||
@@ -86,40 +68,12 @@ export function useStep4Title({
|
||||
scheduleTitleSave,
|
||||
])
|
||||
|
||||
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
|
||||
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
|
||||
useEffect(() => {
|
||||
if (isFirstMount.current) {
|
||||
isFirstMount.current = false
|
||||
if (titleSettings.aiAutoSelect && !titleSettings.title) {
|
||||
autoGenerateTitle()
|
||||
}
|
||||
prevAiAutoSelect.current = titleSettings.aiAutoSelect
|
||||
return
|
||||
}
|
||||
if (titleSettings.aiAutoSelect && !prevAiAutoSelect.current && !titleSettings.title) {
|
||||
autoGenerateTitle()
|
||||
}
|
||||
prevAiAutoSelect.current = titleSettings.aiAutoSelect
|
||||
}, [titleSettings.aiAutoSelect, titleSettings.title, autoGenerateTitle])
|
||||
|
||||
return {
|
||||
// 数据
|
||||
userTitles,
|
||||
titleSettings,
|
||||
// AI 标题状态
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset: styleUpdaters.activePreset,
|
||||
titlePresets: styleUpdaters.titlePresets,
|
||||
// AI 标题操作
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
// 标题设置操作
|
||||
updateTitle: styleUpdaters.updateTitle,
|
||||
toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect,
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { AI_TITLE_TEMPLATES } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
export interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
interface UseAiTitleGeneratorOptions {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 标题生成 Hook
|
||||
* 封装 AI 标题生成、刷新、选择等逻辑
|
||||
*/
|
||||
export function useAiTitleGenerator({
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
}: UseAiTitleGeneratorOptions) {
|
||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||
|
||||
const extractTopic = (text: string): string => {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const generateTitlesFromTopic = (topic: string): AiTitleItem[] => {
|
||||
const results: AiTitleItem[] = []
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
results.push({
|
||||
title: tpl.replace(/\{topic\}/g, topic),
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
return results
|
||||
}
|
||||
|
||||
const handleGenerateAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) {
|
||||
message.warning("请先输入视频描述或关键词")
|
||||
return
|
||||
}
|
||||
setAiTitleGenerating(true)
|
||||
setHasGeneratedTitles(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
setAiTitleResults(generateTitlesFromTopic(topic))
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
/**
|
||||
* 自动生成标题(供 AI 自动选择开关使用)
|
||||
* 如果已有生成结果,直接从中选一个;否则用默认关键词生成
|
||||
*/
|
||||
const autoGenerateTitle = useCallback((): string => {
|
||||
if (aiTitleResults.length > 0) {
|
||||
const picked = aiTitleResults[Math.floor(Math.random() * aiTitleResults.length)]
|
||||
if (!picked) return ""
|
||||
onTitleSettingsChange({ ...titleSettings, title: picked.title })
|
||||
return picked.title
|
||||
}
|
||||
// 没有已有结果,用默认关键词生成
|
||||
const results = generateTitlesFromTopic("短视频")
|
||||
setAiTitleResults(results)
|
||||
setHasGeneratedTitles(true)
|
||||
const picked = results[Math.floor(Math.random() * results.length)]
|
||||
if (!picked) return ""
|
||||
onTitleSettingsChange({ ...titleSettings, title: picked.title })
|
||||
return picked.title
|
||||
}, [aiTitleResults, titleSettings, onTitleSettingsChange])
|
||||
|
||||
const handleSelectAiTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title })
|
||||
message.success("已选用此标题")
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const handleRefreshAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) return
|
||||
setAiTitleGenerating(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
setAiTitleResults(generateTitlesFromTopic(topic))
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
return {
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
/** 当前选中的模板 ID */
|
||||
/** 当前选中的视频模板 ID(封面模板由本地 state 管理) */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
@@ -30,7 +30,7 @@ interface UseStep6CoverProps {
|
||||
export function useStep6Cover({
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
selectedTemplate = "",
|
||||
selectedTemplate: _selectedTemplate = "",
|
||||
titleSettings,
|
||||
generatedVideos,
|
||||
}: UseStep6CoverProps) {
|
||||
@@ -48,7 +48,11 @@ export function useStep6Cover({
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
|
||||
/** 最终成片:取第一个已完成视频 */
|
||||
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
|
||||
const finalVideo =
|
||||
generatedVideos.find(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
) || generatedVideos[0]
|
||||
|
||||
/** 从后端加载封面模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
@@ -79,7 +83,8 @@ export function useStep6Cover({
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedTemplate) {
|
||||
const activeCoverTemplateId = selectedTemplateId || "default"
|
||||
if (!activeCoverTemplateId) {
|
||||
message.error("请先选择模板")
|
||||
return
|
||||
}
|
||||
@@ -95,7 +100,7 @@ export function useStep6Cover({
|
||||
}, 300000)
|
||||
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
const response = await generateCover(activeCoverTemplateId, {
|
||||
generated_video_id: finalVideo.id,
|
||||
video_url: finalVideo.file_url || finalVideo.download_url || "",
|
||||
cover_type: "ai_frame",
|
||||
@@ -156,7 +161,7 @@ export function useStep6Cover({
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
selectedTemplateId,
|
||||
finalVideo,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* 批量变体配音预览音频解析(#1750)
|
||||
*
|
||||
* 独立配音模式下每个变体挂载各自的配音 URL:
|
||||
* - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL;
|
||||
* - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成;
|
||||
* - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。
|
||||
*
|
||||
* N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
|
||||
interface UseVariantVoicePreviewOptions {
|
||||
/** 是否批量模式(count>1) */
|
||||
enabled: boolean
|
||||
/** 变体数量 */
|
||||
count: number
|
||||
/** 是否每个视频独立配音 */
|
||||
perVideo: boolean
|
||||
/** 共用配音 ID(独立模式下为变体 0 的配音) */
|
||||
sharedVoiceId: string
|
||||
/** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice) */
|
||||
clonedVoiceId?: string
|
||||
/** 各变体独立配音 ID(独立模式);长度不足时回退共用 */
|
||||
variantVoiceIds: string[]
|
||||
/** 各变体标题(TTS 合成文案源) */
|
||||
titles: string[]
|
||||
}
|
||||
|
||||
/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */
|
||||
const EMPTY_VOICE_MATERIALS: AssetItem[] = []
|
||||
|
||||
/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */
|
||||
function findMaterialUrl(id: string, materials: AssetItem[]): string | null {
|
||||
if (!id) return null
|
||||
const m = materials.find((x) => x.id === id)
|
||||
return m?.file_url || null
|
||||
}
|
||||
|
||||
export function useVariantVoicePreview({
|
||||
enabled,
|
||||
count,
|
||||
perVideo,
|
||||
sharedVoiceId,
|
||||
clonedVoiceId = "",
|
||||
variantVoiceIds,
|
||||
titles,
|
||||
}: UseVariantVoicePreviewOptions): (string | null)[] {
|
||||
const [urls, setUrls] = useState<(string | null)[]>([])
|
||||
// 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发
|
||||
const { data: voiceMaterialsData } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS
|
||||
// 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成
|
||||
const ttsCacheRef = useRef<Map<string, string>>(new Map())
|
||||
// 在途请求 AbortController
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || count <= 1) {
|
||||
setUrls((prev) => (prev.length === 0 ? prev : []))
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++seqRef.current
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
// 每个变体实际使用的配音 ID
|
||||
const voiceIds = Array.from({ length: count }, (_, i) =>
|
||||
perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId,
|
||||
)
|
||||
|
||||
// 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成
|
||||
const result: (string | null)[] = voiceIds.map((id, i) => {
|
||||
const materialUrl = findMaterialUrl(id, voiceMaterials)
|
||||
if (materialUrl) return materialUrl
|
||||
// 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致)
|
||||
if (!perVideo && i === 0 && clonedVoiceId) {
|
||||
return findMaterialUrl(clonedVoiceId, voiceMaterials)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID
|
||||
const ttsJobs: { index: number; voiceId: string; title: string }[] = []
|
||||
voiceIds.forEach((id, i) => {
|
||||
if (result[i]) return
|
||||
// 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice
|
||||
const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id
|
||||
const title = titles[i] || ""
|
||||
if (!ttsVoiceId || !title) return
|
||||
ttsJobs.push({ index: i, voiceId: ttsVoiceId, title })
|
||||
})
|
||||
|
||||
setUrls((prev) =>
|
||||
prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result,
|
||||
)
|
||||
|
||||
if (ttsJobs.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
// 串行 TTS,避免瞬间 N 个合成请求打爆后端
|
||||
for (const job of ttsJobs) {
|
||||
const cacheKey = `${job.voiceId}|${job.title}`
|
||||
const cached = ttsCacheRef.current.get(cacheKey)
|
||||
if (cached) {
|
||||
if (seq === seqRef.current) {
|
||||
setUrls((prev) => {
|
||||
if (prev[job.index] === cached) return prev
|
||||
const next = [...prev]
|
||||
next[job.index] = cached
|
||||
return next
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
const audioUrl = res.audio_url || ""
|
||||
if (audioUrl) {
|
||||
ttsCacheRef.current.set(cacheKey, audioUrl)
|
||||
setUrls((prev) => {
|
||||
if (prev[job.index] === audioUrl) return prev
|
||||
const next = [...prev]
|
||||
next[job.index] = audioUrl
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
console.warn(`[变体${job.index + 1}预览配音生成失败]`, err)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
count,
|
||||
perVideo,
|
||||
sharedVoiceId,
|
||||
clonedVoiceId,
|
||||
variantVoiceIds,
|
||||
titles,
|
||||
voiceMaterials,
|
||||
])
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
export default useVariantVoicePreview
|
||||
@@ -81,14 +81,6 @@ export interface SmartMatchResult {
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
/* ── AI 标题结果 ── */
|
||||
export interface AiTitleResult {
|
||||
title: string
|
||||
style: string
|
||||
styleLabel: string
|
||||
highlights: string[]
|
||||
}
|
||||
|
||||
/* ── 配音推荐结果 ── */
|
||||
export interface VoiceRecommendation {
|
||||
voiceId: string
|
||||
|
||||
@@ -32,6 +32,241 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
thumbnail_url: "",
|
||||
}
|
||||
|
||||
/** 文字方向 */
|
||||
export type TextDirection = "horizontal" | "vertical"
|
||||
|
||||
/** 文字背景形状 */
|
||||
export type TextBgShape = "rectangle" | "polygon"
|
||||
|
||||
/** 描边样式 */
|
||||
export type StrokeStyle = "solid" | "dashed"
|
||||
|
||||
/** 阴影层 */
|
||||
export interface ShadowLayer {
|
||||
color: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
blur: number
|
||||
}
|
||||
|
||||
/** 文字位置 */
|
||||
export interface TextPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/** 文字背景配置 */
|
||||
export interface TextBackground {
|
||||
enabled: boolean
|
||||
color: string
|
||||
opacity: number
|
||||
shape: TextBgShape
|
||||
width: number
|
||||
height: number
|
||||
posX: number
|
||||
posY: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
/** 文字样式配置(主标题/副标题共用) */
|
||||
export interface TextStyleConfig {
|
||||
text: string
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
fontWeight: number
|
||||
direction: TextDirection
|
||||
charsPerLine: number
|
||||
letterSpacing: number
|
||||
lineHeight: number
|
||||
color: string
|
||||
strokeColor: string
|
||||
strokeWidth: number
|
||||
shadows: ShadowLayer[]
|
||||
traditionalShadow: boolean
|
||||
position: TextPosition
|
||||
rotation: number
|
||||
background: TextBackground
|
||||
}
|
||||
|
||||
/** 编辑器完整配置 */
|
||||
export interface CoverEditorConfig {
|
||||
// 基础设置
|
||||
blurEnabled: boolean
|
||||
blurAmount: number
|
||||
personStrokeEnabled: boolean
|
||||
personStrokeStyle: StrokeStyle
|
||||
personStrokeColor: string
|
||||
personStrokeWidth: number
|
||||
autoSplitEnabled: boolean
|
||||
titleMaxChars: number
|
||||
subtitleMaxChars: number
|
||||
|
||||
// 人像设置
|
||||
portraitEnabled: boolean
|
||||
portraitSize: number
|
||||
portraitPosition: TextPosition
|
||||
portraitImage?: string
|
||||
|
||||
// 背景设置
|
||||
backgroundEnabled: boolean
|
||||
backgroundSize: number
|
||||
backgroundPosition: TextPosition
|
||||
backgroundImage?: string
|
||||
backgroundColor?: string
|
||||
|
||||
// 主标题
|
||||
title: TextStyleConfig
|
||||
|
||||
// 副标题
|
||||
subtitle: TextStyleConfig
|
||||
|
||||
// 蒙版
|
||||
maskEnabled: boolean
|
||||
maskImage: string
|
||||
maskSize: number
|
||||
maskPosition: TextPosition
|
||||
maskColor: string
|
||||
maskOpacity: number
|
||||
maskShape: string
|
||||
}
|
||||
|
||||
/** 默认主标题配置 */
|
||||
export const DEFAULT_TITLE_CONFIG: TextStyleConfig = {
|
||||
text: "主标题文字",
|
||||
fontFamily: "思源黑体",
|
||||
fontSize: 120,
|
||||
fontWeight: 700,
|
||||
direction: "horizontal",
|
||||
charsPerLine: 10,
|
||||
letterSpacing: 24,
|
||||
lineHeight: 144,
|
||||
color: "#FFD700",
|
||||
strokeColor: "#000000",
|
||||
strokeWidth: 3,
|
||||
shadows: [],
|
||||
traditionalShadow: false,
|
||||
position: { x: 50, y: 30 },
|
||||
rotation: 0,
|
||||
background: {
|
||||
enabled: false,
|
||||
color: "#FFFFFF",
|
||||
opacity: 25,
|
||||
shape: "polygon",
|
||||
width: 30,
|
||||
height: 10,
|
||||
posX: 50,
|
||||
posY: 50,
|
||||
rotation: 0,
|
||||
},
|
||||
}
|
||||
|
||||
/** 默认副标题配置 */
|
||||
export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = {
|
||||
text: "副标题文字",
|
||||
fontFamily: "思源黑体",
|
||||
fontSize: 82,
|
||||
fontWeight: 500,
|
||||
direction: "horizontal",
|
||||
charsPerLine: 17,
|
||||
letterSpacing: 23,
|
||||
lineHeight: 72,
|
||||
color: "#FFFFFF",
|
||||
strokeColor: "#000000",
|
||||
strokeWidth: 1,
|
||||
shadows: [],
|
||||
traditionalShadow: false,
|
||||
position: { x: 50, y: 70 },
|
||||
rotation: 0,
|
||||
background: {
|
||||
enabled: true,
|
||||
color: "#000000",
|
||||
opacity: 70,
|
||||
shape: "rectangle",
|
||||
width: 100,
|
||||
height: 20,
|
||||
posX: 50,
|
||||
posY: 80,
|
||||
rotation: 0,
|
||||
},
|
||||
}
|
||||
|
||||
/** 默认编辑器配置 */
|
||||
export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = {
|
||||
blurEnabled: false,
|
||||
blurAmount: 10,
|
||||
personStrokeEnabled: false,
|
||||
personStrokeStyle: "solid",
|
||||
personStrokeColor: "#FFFFFF",
|
||||
personStrokeWidth: 8,
|
||||
autoSplitEnabled: false,
|
||||
titleMaxChars: 4,
|
||||
subtitleMaxChars: 10,
|
||||
|
||||
portraitEnabled: false,
|
||||
portraitSize: 50,
|
||||
portraitPosition: { x: 50, y: 70 },
|
||||
|
||||
backgroundEnabled: true,
|
||||
backgroundSize: 100,
|
||||
backgroundPosition: { x: 50, y: 50 },
|
||||
|
||||
title: DEFAULT_TITLE_CONFIG,
|
||||
subtitle: DEFAULT_SUBTITLE_CONFIG,
|
||||
|
||||
maskEnabled: false,
|
||||
maskImage: "",
|
||||
maskSize: 100,
|
||||
maskPosition: { x: 50, y: 50 },
|
||||
maskColor: "#000000",
|
||||
maskOpacity: 40,
|
||||
maskShape: "矩形",
|
||||
}
|
||||
|
||||
/** 预置字体 */
|
||||
export const PRESET_FONTS = [
|
||||
{ name: "思源黑体", family: "'Noto Sans SC', sans-serif" },
|
||||
{ name: "斗鱼追光体2.0", family: "'DouYu ZhuangGuangTi', sans-serif" },
|
||||
{ name: "抖音美好体", family: "'DouYin MeiHaoTi', sans-serif" },
|
||||
]
|
||||
|
||||
/** 系统字体 */
|
||||
export const SYSTEM_FONTS = [
|
||||
{ name: "Arial", family: "Arial, sans-serif" },
|
||||
{ name: "Helvetica", family: "Helvetica, sans-serif" },
|
||||
{ name: "Times New Roman", family: "'Times New Roman', serif" },
|
||||
{ name: "Georgia", family: "Georgia, serif" },
|
||||
{ name: "Verdana", family: "Verdana, sans-serif" },
|
||||
{ name: "Tahoma", family: "Tahoma, sans-serif" },
|
||||
{ name: "Impact", family: "Impact, sans-serif" },
|
||||
{ name: "Comic Sans MS", family: "'Comic Sans MS', cursive" },
|
||||
{ name: "Courier New", family: "'Courier New', monospace" },
|
||||
{ name: "微软雅黑", family: "'Microsoft YaHei', sans-serif" },
|
||||
{ name: "宋体", family: "SimSun, serif" },
|
||||
{ name: "黑体", family: "SimHei, sans-serif" },
|
||||
{ name: "楷体", family: "KaiTi, serif" },
|
||||
{ name: "仿宋", family: "FangSong, serif" },
|
||||
{ name: "Trebuchet MS", family: "'Trebuchet MS', sans-serif" },
|
||||
{ name: "Lucida Console", family: "'Lucida Console', monospace" },
|
||||
{ name: "Palatino", family: "Palatino, serif" },
|
||||
{ name: "Garamond", family: "Garamond, serif" },
|
||||
{ name: "Bookman", family: "Bookman, serif" },
|
||||
{ name: "Avant Garde", family: "'Avant Garde', sans-serif" },
|
||||
{ name: "Calibri", family: "Calibri, sans-serif" },
|
||||
{ name: "Cambria", family: "Cambria, serif" },
|
||||
{ name: "Candara", family: "Candara, sans-serif" },
|
||||
{ name: "Consolas", family: "Consolas, monospace" },
|
||||
{ name: "Constantia", family: "Constantia, serif" },
|
||||
{ name: "Corbel", family: "Corbel, sans-serif" },
|
||||
{ name: "Franklin Gothic", family: "'Franklin Gothic', sans-serif" },
|
||||
{ name: "Gill Sans", family: "'Gill Sans', sans-serif" },
|
||||
{ name: "Optima", family: "Optima, sans-serif" },
|
||||
{ name: "Futura", family: "Futura, sans-serif" },
|
||||
{ name: "Rockwell", family: "Rockwell, serif" },
|
||||
]
|
||||
|
||||
/** 所有字体列表 */
|
||||
export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS]
|
||||
|
||||
/** 封面模板 */
|
||||
export interface CoverTemplate {
|
||||
id: string
|
||||
@@ -39,12 +274,5 @@ export interface CoverTemplate {
|
||||
thumbnail_url: string
|
||||
is_system: boolean
|
||||
created_at: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
config?: CoverEditorConfig
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* CanvasPreviewGrid 单测(Issue #1741 / #1750)
|
||||
*
|
||||
* 验证:
|
||||
* - N=3 时每个变体都拿到各自的 voiceAudioUrls[i](独立配音模式,null=TTS 合成中)
|
||||
* - 每个变体都拿到各自的后端真实片段 variantClips[i](serverClips),playToken 为 0 基变体序号
|
||||
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
|
||||
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
|
||||
* - 实例上报暂停(null)→ 播放权释放
|
||||
* - #1750 变体计划申请失败:渲染 role=alert 错误占位 ×N(严禁假数据/不渲染播放器),
|
||||
* 「重试」按钮仅变体 0 卡片出现且点击触发 onRetryClips
|
||||
* - #1750 加载态:渲染加载占位,不渲染播放器
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { render, fireEvent, screen } from "@testing-library/react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
|
||||
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
|
||||
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
playerCalls.push(props)
|
||||
const token = props.playToken as number
|
||||
const active = props.activePlayToken as number | null
|
||||
const change = props.onPlayTokenChange as (t: number | null) => void
|
||||
return (
|
||||
<div data-testid={`player-${token}`}>
|
||||
<span data-testid={`voice-${token}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
|
||||
<span data-testid={`token-${token}`}>{active == null ? "none" : String(active)}</span>
|
||||
<button type="button" onClick={() => change(token)}>
|
||||
play-{token}
|
||||
</button>
|
||||
<button type="button" onClick={() => change(null)}>
|
||||
pause-{token}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
|
||||
|
||||
function makeAsset(id: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
library_id: "lib-1",
|
||||
name: `${id}.mp4`,
|
||||
storage_key: `media/${id}.mp4`,
|
||||
file_url: `https://cdn.example.com/${id}.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 10 },
|
||||
duration: 10,
|
||||
}
|
||||
}
|
||||
|
||||
function makeClips(variant: number): EditPlanClip[] {
|
||||
return ["a1", "a2"].map((assetId, order) => ({
|
||||
id: `clip-v${variant}-${assetId}`,
|
||||
plan_id: `plan-${variant}`,
|
||||
clip_type: "main",
|
||||
order,
|
||||
asset_id: assetId,
|
||||
text_content: "",
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
transition_effect: "none",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1,
|
||||
status: "ready",
|
||||
config: {},
|
||||
}))
|
||||
}
|
||||
|
||||
const titleSettings = {
|
||||
size: 36,
|
||||
font: "思源黑体",
|
||||
color: "#fff",
|
||||
position: "bottom" as const,
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}
|
||||
|
||||
const variantClips: EditPlanClip[][] = [makeClips(0), makeClips(1), makeClips(2)]
|
||||
|
||||
function renderGrid(overrides: Record<string, unknown> = {}) {
|
||||
playerCalls.length = 0
|
||||
return render(
|
||||
<CanvasPreviewGrid
|
||||
count={3}
|
||||
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
|
||||
videoRatio="9:16"
|
||||
titles={["标题1", "标题2", "标题3"]}
|
||||
titleSettings={titleSettings}
|
||||
variantClips={variantClips}
|
||||
voiceAudioUrls={["https://cdn.example.com/v0.mp3", null, "https://cdn.example.com/v2.mp3"]}
|
||||
selectedIds={[0, 1, 2]}
|
||||
onToggleSelect={() => {}}
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("CanvasPreviewGrid 配音、真实片段与播放互斥 (#1741/#1750)", () => {
|
||||
beforeEach(() => {
|
||||
playerCalls.length = 0
|
||||
})
|
||||
|
||||
it("N=3 时每个变体都拿到各自的配音 URL 与后端真实片段,playToken 为 0 基序号", () => {
|
||||
renderGrid()
|
||||
expect(playerCalls).toHaveLength(3)
|
||||
playerCalls.forEach((p, i) => {
|
||||
expect(p.playToken).toBe(i)
|
||||
expect(p.serverClips).toBe(variantClips[i])
|
||||
expect(p.variantTitle).toBe(`标题${i + 1}`)
|
||||
})
|
||||
// 独立配音:变体 0/2 有各自 URL;变体 1 为 null(TTS 合成中)→ 传 undefined
|
||||
expect(playerCalls[0].voiceAudioUrl).toBe("https://cdn.example.com/v0.mp3")
|
||||
expect(playerCalls[1].voiceAudioUrl).toBeUndefined()
|
||||
expect(playerCalls[2].voiceAudioUrl).toBe("https://cdn.example.com/v2.mp3")
|
||||
// 配音状态徽标
|
||||
expect(screen.getByTestId("voice-0").textContent).toBe("has-voice")
|
||||
expect(screen.getByTestId("voice-1").textContent).toBe("no-voice")
|
||||
expect(screen.getByTestId("voice-2").textContent).toBe("has-voice")
|
||||
})
|
||||
|
||||
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
|
||||
renderGrid()
|
||||
playerCalls.forEach((p) => {
|
||||
expect(p.activePlayToken).toBeNull()
|
||||
expect(typeof p.onPlayTokenChange).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
it("点击变体2播放:所有实例 activePlayToken 变为 1(0 基,其他实例自动暂停)", () => {
|
||||
renderGrid()
|
||||
fireEvent.click(screen.getByTestId("player-1").querySelector("button")!)
|
||||
expect(screen.getByTestId("token-0").textContent).toBe("1")
|
||||
expect(screen.getByTestId("token-1").textContent).toBe("1")
|
||||
expect(screen.getByTestId("token-2").textContent).toBe("1")
|
||||
})
|
||||
|
||||
it("正在播放实例上报暂停后,播放权释放(token 回 null)", () => {
|
||||
renderGrid()
|
||||
fireEvent.click(screen.getByTestId("player-2").querySelector("button")!)
|
||||
expect(screen.getByTestId("token-0").textContent).toBe("2")
|
||||
|
||||
fireEvent.click(screen.getByTestId("player-2").querySelectorAll("button")[1])
|
||||
expect(screen.getByTestId("token-2").textContent).toBe("none")
|
||||
})
|
||||
|
||||
it("clipsError:每张卡片显示错误占位(role=alert)且不渲染任何播放器,重试按钮仅一个并触发回调", () => {
|
||||
const onRetry = vi.fn()
|
||||
renderGrid({ clipsError: true, onRetryClips: onRetry })
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(3)
|
||||
expect(screen.getAllByText("预览加载失败,请重试")).toHaveLength(3)
|
||||
// 严禁假数据:错误态不渲染播放器
|
||||
expect(playerCalls).toHaveLength(0)
|
||||
// 重试按钮仅变体 0 卡片出现
|
||||
const retryButtons = screen.getAllByText("重试")
|
||||
expect(retryButtons).toHaveLength(1)
|
||||
fireEvent.click(retryButtons[0])
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("clipsLoading:渲染加载占位,不渲染播放器", () => {
|
||||
renderGrid({ clipsLoading: true })
|
||||
expect(screen.getAllByText("独立选片中…")).toHaveLength(3)
|
||||
expect(screen.queryAllByRole("alert")).toHaveLength(0)
|
||||
expect(playerCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750)
|
||||
*
|
||||
* useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑:
|
||||
* - 有配音时 video 保持 muted(素材原声不与配音混音)
|
||||
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
|
||||
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
|
||||
* - 批量播放互斥(#1750 playToken 为 0 基变体序号):activePlayToken 变为其他实例且本实例在播放时,调用 pause
|
||||
* - 点击播放/暂停时上报播放权(onPlayTokenChange)
|
||||
* - #1750:serverClips 是唯一片段来源,缺失时不渲染任何 video(无本地模拟 fallback)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isPlaying: false,
|
||||
pause: vi.fn(),
|
||||
togglePlayPause: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
|
||||
// segments 由组件 buildPlaybackSegments 产出(唯一来源 serverClips);
|
||||
// canPlay 与真实 hook 一致:无片段时不可播放
|
||||
useSegmentScheduler: vi.fn((segments: unknown[]) => ({
|
||||
isPlaying: mocks.isPlaying,
|
||||
currentTime: 0,
|
||||
totalDuration: 20,
|
||||
currentSegmentIndex: 0,
|
||||
canPlay: segments.length > 0,
|
||||
togglePlayPause: mocks.togglePlayPause,
|
||||
seekTo: vi.fn(),
|
||||
pause: mocks.pause,
|
||||
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
|
||||
useCanvasPlayer: () => ({
|
||||
state: {
|
||||
isPlaying: false,
|
||||
isReady: false,
|
||||
isBuffering: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
errorMessage: "",
|
||||
hasDecodeError: false,
|
||||
},
|
||||
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.isPlaying = false
|
||||
mocks.pause.mockClear()
|
||||
mocks.togglePlayPause.mockClear()
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function makeAsset(id: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
library_id: "lib-1",
|
||||
name: `${id}.mp4`,
|
||||
storage_key: `media/${id}.mp4`,
|
||||
file_url: `https://cdn.example.com/${id}.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 10, width: 1080, height: 1920 },
|
||||
duration: 10,
|
||||
}
|
||||
}
|
||||
|
||||
function makeClip(assetId: string, order: number): EditPlanClip {
|
||||
return {
|
||||
id: `clip-${assetId}`,
|
||||
plan_id: "plan-1",
|
||||
clip_type: "main",
|
||||
order,
|
||||
asset_id: assetId,
|
||||
text_content: "",
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
transition_effect: "none",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1,
|
||||
status: "ready",
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
|
||||
const serverClips = [makeClip("a1", 0), makeClip("a2", 1)]
|
||||
|
||||
const baseProps = {
|
||||
assets: [makeAsset("a1"), makeAsset("a2")],
|
||||
videoRatio: "9:16",
|
||||
ready: true,
|
||||
serverClips,
|
||||
}
|
||||
|
||||
function videos(): HTMLVideoElement[] {
|
||||
return Array.from(document.querySelectorAll("video"))
|
||||
}
|
||||
|
||||
describe("FrontendPreviewPlayer 音频行为 (#1741/#1750)", () => {
|
||||
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
|
||||
expect(videos()).toHaveLength(2)
|
||||
videos().forEach((v) => expect(v.muted).toBe(true))
|
||||
})
|
||||
|
||||
it("无配音时 video 不 muted(素材原声兜底)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} />)
|
||||
videos().forEach((v) => expect(v.muted).toBe(false))
|
||||
})
|
||||
|
||||
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} />)
|
||||
const vs = videos()
|
||||
expect(vs[0].muted).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "静音" }))
|
||||
videos().forEach((v) => expect(v.muted).toBe(true))
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
|
||||
videos().forEach((v) => expect(v.muted).toBe(false))
|
||||
})
|
||||
|
||||
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
|
||||
mocks.isPlaying = true
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
playToken={1}
|
||||
activePlayToken={1}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
|
||||
// 播放权切给实例 2(0 基 token)
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
playToken={1}
|
||||
activePlayToken={2}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).toHaveBeenCalledTimes(1)
|
||||
|
||||
// token 切回自己:不重复暂停
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
playToken={1}
|
||||
activePlayToken={1}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
|
||||
// mocks.isPlaying = false(beforeEach 重置)
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={0} compact />,
|
||||
)
|
||||
rerender(<FrontendPreviewPlayer {...baseProps} playToken={0} activePlayToken={1} compact />)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("暂停状态下点击播放按钮:上报播放权为自身 playToken 并触发播放", () => {
|
||||
const onToken = vi.fn()
|
||||
render(
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={2} onPlayTokenChange={onToken} compact />,
|
||||
)
|
||||
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
|
||||
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
|
||||
expect(playButtons.length).toBeGreaterThanOrEqual(1)
|
||||
fireEvent.click(playButtons[0])
|
||||
expect(onToken).toHaveBeenCalledWith(2)
|
||||
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("每个卡片都渲染独立静音按钮", () => {
|
||||
render(
|
||||
<div>
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={0} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={1} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} playToken={2} compact />
|
||||
</div>,
|
||||
)
|
||||
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("#1750 serverClips 缺失时不渲染任何 video(无本地模拟 fallback,显示无可播放素材)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} serverClips={undefined} />)
|
||||
expect(videos()).toHaveLength(0)
|
||||
expect(screen.getByText("暂无可播放素材")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,6 @@ import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/Step3VoiceWithMode"
|
||||
import "@/pages/generate/components/CanvasPreviewGrid"
|
||||
import "@/pages/generate/components/BatchGenerationGrid"
|
||||
import "@/pages/generate/components/PreviewCountModal"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
@@ -33,8 +32,6 @@ import "@/pages/generate/components/material/MaterialModeTabs"
|
||||
import "@/pages/generate/components/material/ManualMaterialList"
|
||||
import "@/pages/generate/components/material/SmartMatchInput"
|
||||
import "@/pages/generate/components/material/SmartMatchResults"
|
||||
import "@/pages/generate/components/title/AiTitleGenerator"
|
||||
import "@/pages/generate/components/title/AiTitleCard"
|
||||
import "@/pages/generate/components/title/TitleStylePanel"
|
||||
import "@/pages/generate/components/title/TitlePresetsGrid"
|
||||
import "@/pages/generate/utils/formatDuration"
|
||||
@@ -48,9 +45,6 @@ describe("GeneratePage module smoke test", () => {
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useBatchCovers"
|
||||
import "@/pages/generate/hooks/useBatchVariantPlans"
|
||||
import "@/pages/generate/hooks/useVariantVoicePreview"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useVoiceState"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Step4+5 merged preview smoke test
|
||||
* 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Merged Step4+5 preview module smoke test", () => {
|
||||
it("should load all merged step4+5 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -345,6 +345,32 @@ class VideoFingerprint:
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "VideoFingerprint":
|
||||
"""从 to_dict() 序列化结果重建 VideoFingerprint(供 finalize 复用 worker 预计算指纹)。"""
|
||||
|
||||
chunks_raw = data.get("chunks") or []
|
||||
chunks: list[FingerprintChunk] = []
|
||||
for c in chunks_raw:
|
||||
chunks.append(
|
||||
FingerprintChunk(
|
||||
start_time_ms=int(c.get("start_time_ms", 0)),
|
||||
end_time_ms=int(c.get("end_time_ms", 0)),
|
||||
phash_binary=str(c.get("phash_binary", "")),
|
||||
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
|
||||
frame_count=int(c.get("frame_count", 0)),
|
||||
)
|
||||
)
|
||||
resolution_raw = data.get("resolution") or [1280, 720]
|
||||
return cls(
|
||||
md5=str(data.get("md5", "")),
|
||||
keyframe_phashes=list(data.get("keyframe_phashes") or []),
|
||||
color_histograms=[[float(v) for v in h] for h in (data.get("color_histograms") or [])],
|
||||
duration=float(data.get("duration") or 0.0),
|
||||
resolution=(int(resolution_raw[0]), int(resolution_raw[1])) if len(resolution_raw) >= 2 else (1280, 720),
|
||||
chunks=chunks,
|
||||
)
|
||||
|
||||
def to_chunk_models(self, video_id: str, project_id: str, user_id: str = "") -> list[VideoFingerprintChunkModel]:
|
||||
"""将分片数据转为 SQLAlchemy Model 列表,用于批量写入 video_fingerprint_chunks 表。"""
|
||||
models = []
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""查重辅助函数 — 从 generation.py 提取的 GeneratedVideo 记录 + 查重逻辑.
|
||||
"""查重辅助函数 — 渲染阶段指纹/查重预计算 + 兼容旧入库函数。
|
||||
|
||||
供 generate_video 共同复用,
|
||||
创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重。
|
||||
#2024: Worker 渲染+上传完成后**不直接创建 GeneratedVideo 成品记录**,改为:
|
||||
1. ``compute_render_fingerprint_and_dedup``: 从本地视频计算指纹+查重(历史+批次),
|
||||
返回可序列化 dict(含 fingerprint_chunks),由 worker 写入
|
||||
``GenerationTask.extra_meta["rendered_output"]``;
|
||||
2. ``create_video_record_and_dedup``: 保留兼容——当传入 ``video_path`` 时会从本地视频
|
||||
计算指纹+查重并直接创建 GeneratedVideo 记录(供测试/旧路径使用);
|
||||
当仅传 ``pre_dedup_result`` 时复用预计算结果,不再访问本地视频。
|
||||
|
||||
v2: 两阶段持久化 — 先计算所有查重数据,再一次性 commit,
|
||||
避免中间异常导致 duplicate_rate 等字段缺失。
|
||||
finalize 入口走 ``packages/application/generated_video_finalize.py`` 的
|
||||
``finalize_generated_video``,不依赖本模块中数据库以外的 worker-only 逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +22,149 @@ from sqlalchemy.orm import Session
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_parse_fps(raw) -> float:
|
||||
if raw is None:
|
||||
return 25.0
|
||||
if isinstance(raw, (int, float)):
|
||||
return float(raw)
|
||||
s = str(raw).strip()
|
||||
if "/" in s:
|
||||
try:
|
||||
num, den = s.split("/", 1)
|
||||
return float(num) / float(den) if float(den) != 0 else 25.0
|
||||
except (ValueError, ZeroDivisionError):
|
||||
pass
|
||||
try:
|
||||
return float(s)
|
||||
except (ValueError, TypeError):
|
||||
return 25.0
|
||||
|
||||
|
||||
def _compute_from_local(
|
||||
*,
|
||||
video_path: str,
|
||||
generation_task_id: str,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
batch_id: str,
|
||||
session: Session,
|
||||
) -> dict:
|
||||
"""从本地视频计算指纹+查重,返回可序列化结果 dict(不创建 DB 记录)。"""
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
from video_processing.ffmpeg_utils import probe_video_info
|
||||
|
||||
result: dict = {
|
||||
"fingerprint_dict": None,
|
||||
"fingerprint_chunks": None,
|
||||
"duration": 0.0,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"is_duplicate": False,
|
||||
"duplicate_of": None,
|
||||
"duplicate_rate": None,
|
||||
"match_count": None,
|
||||
"visual_similarity": None,
|
||||
"video_fingerprint_md5": "",
|
||||
"batch_similarity": None,
|
||||
}
|
||||
try:
|
||||
info = probe_video_info(video_path)
|
||||
result["duration"] = float(info.get("duration") or 0.0)
|
||||
result["width"] = int(info.get("width") or 1280)
|
||||
result["height"] = int(info.get("height") or 720)
|
||||
result["fps"] = _safe_parse_fps(info.get("fps"))
|
||||
except Exception as info_err:
|
||||
logger.warning("probe_video_info failed for task %s: %s", generation_task_id, info_err)
|
||||
|
||||
try:
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||||
fp_dict = fingerprint.to_dict()
|
||||
result["fingerprint_dict"] = fp_dict
|
||||
result["video_fingerprint_md5"] = fingerprint.md5 or ""
|
||||
result["fingerprint_chunks"] = [
|
||||
{
|
||||
"start_time_ms": c.start_time_ms,
|
||||
"end_time_ms": c.end_time_ms,
|
||||
"phash_binary": c.phash_binary,
|
||||
"color_histogram": [float(v) for v in c.color_histogram],
|
||||
"frame_count": c.frame_count,
|
||||
}
|
||||
for c in fingerprint.chunks
|
||||
]
|
||||
|
||||
# 用 placeholder_id 占位(还没有真正的 video_id,不影响查重逻辑——
|
||||
# 因为查重排除的是 GeneratedVideo 表中的记录)
|
||||
placeholder_id = f"pre-{generation_task_id}"
|
||||
duration_sec = fingerprint.duration if fingerprint.duration else 0
|
||||
duplicate_result = deduplicator.check_duplicate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
session,
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
duration_sec=duration_sec,
|
||||
exclude_video_id=placeholder_id,
|
||||
)
|
||||
batch_sim: float | None = None
|
||||
if not duplicate_result and batch_id:
|
||||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, placeholder_id, session)
|
||||
if duplicate_result:
|
||||
batch_sim = float(duplicate_result.get("similarity", 0.0))
|
||||
result["batch_similarity"] = batch_sim
|
||||
if duplicate_result:
|
||||
result["is_duplicate"] = True
|
||||
result["duplicate_of"] = duplicate_result["duplicate_of"]
|
||||
else:
|
||||
result["is_duplicate"] = False
|
||||
|
||||
try:
|
||||
rate_result = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
placeholder_id,
|
||||
session,
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
)
|
||||
result["duplicate_rate"] = rate_result.get("duplicate_rate")
|
||||
result["match_count"] = rate_result.get("match_count")
|
||||
result["visual_similarity"] = rate_result.get("visual_similarity")
|
||||
except Exception as rate_err:
|
||||
logger.warning("compute_duplicate_rate failed for task %s: %s", generation_task_id, rate_err)
|
||||
except Exception as fp_err:
|
||||
logger.warning("Fingerprint compute failed for task %s: %s", generation_task_id, fp_err)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def compute_render_fingerprint_and_dedup(
|
||||
*,
|
||||
video_path: str,
|
||||
generation_task_id: str,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
batch_id: str,
|
||||
mode: str,
|
||||
session: Session,
|
||||
) -> dict:
|
||||
"""渲染+上传完成后的预计算:计算指纹+历史/批次查重,返回可序列化 dict。
|
||||
|
||||
**不创建 GeneratedVideo 记录**。结果由调用方写入 extra_meta["rendered_output"],
|
||||
finalize 时复用。mode 参数保留签名一致性(查重结果中不直接使用)。
|
||||
"""
|
||||
_ = mode # 保留在签名里便于调用方对齐;查重结果不含 mode
|
||||
return _compute_from_local(
|
||||
video_path=video_path,
|
||||
generation_task_id=generation_task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
batch_id=batch_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
def create_video_record_and_dedup(
|
||||
*,
|
||||
generation_task_id: str,
|
||||
@@ -25,8 +173,8 @@ def create_video_record_and_dedup(
|
||||
batch_id: str,
|
||||
file_url: str,
|
||||
file_size: int,
|
||||
duration: float,
|
||||
video_path: str,
|
||||
duration: float | None = None,
|
||||
video_path: str | None,
|
||||
mode: str,
|
||||
session: Session,
|
||||
width: int = 1280,
|
||||
@@ -34,30 +182,55 @@ def create_video_record_and_dedup(
|
||||
fps: float = 25.0,
|
||||
name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
pre_fingerprint_dict: dict | None = None,
|
||||
pre_fingerprint_chunks: list[dict] | None = None,
|
||||
pre_dedup_result: dict | None = None,
|
||||
) -> dict:
|
||||
"""Returns: {"video_count": int, "is_duplicate": bool, "batch_similarity": float|None,
|
||||
"duplicate_of": str|None} —— batch_similarity 为批次内最高相似度(无批次查重时 None)。"""
|
||||
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
|
||||
"""创建 GeneratedVideo 记录 + 可选查重。
|
||||
|
||||
采用两阶段持久化:先计算所有指纹/查重数据(内存),
|
||||
再一次性写入数据库并 commit。若指纹计算失败,
|
||||
视频记录仍会创建(无查重数据),但保证不会出现"写了记录却没 commit"的中间态。
|
||||
两种用法:
|
||||
- 传入 ``video_path``(非 None):从本地视频计算指纹+查重,直接创建记录(旧路径/测试)。
|
||||
- 仅传入 ``pre_*``:复用 worker 预计算结果,不访问本地视频(finalize 用)。
|
||||
|
||||
Returns:
|
||||
创建的视频记录数量(1 表示成功,0 表示失败)
|
||||
{"video_id", "video_count", "is_duplicate", "batch_similarity", "duplicate_of"}
|
||||
"""
|
||||
from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.domain import GeneratedVideo
|
||||
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
try:
|
||||
video_id = uuid4().hex
|
||||
video_name = name.strip() if name else f"generated-{generation_task_id[:8]}.mp4"
|
||||
|
||||
# ── Phase 1: 构建视频记录(内存,不 commit) ────────────────
|
||||
# 决定查重/元信息来源
|
||||
if video_path:
|
||||
pre = _compute_from_local(
|
||||
video_path=video_path,
|
||||
generation_task_id=generation_task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
batch_id=batch_id,
|
||||
session=session,
|
||||
)
|
||||
else:
|
||||
pre = dict(pre_dedup_result or {})
|
||||
pre.setdefault("fingerprint_dict", pre_fingerprint_dict)
|
||||
pre.setdefault("fingerprint_chunks", pre_fingerprint_chunks)
|
||||
pre.setdefault("is_duplicate", False)
|
||||
pre.setdefault("duplicate_of", None)
|
||||
pre.setdefault("duplicate_rate", None)
|
||||
pre.setdefault("match_count", None)
|
||||
pre.setdefault("visual_similarity", None)
|
||||
pre.setdefault("batch_similarity", None)
|
||||
|
||||
used_duration = float(duration if duration is not None else pre.get("duration", 0.0))
|
||||
used_width = int(pre.get("width", width) or width)
|
||||
used_height = int(pre.get("height", height) or height)
|
||||
used_fps = float(pre.get("fps", fps) or fps)
|
||||
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=project_id,
|
||||
@@ -66,117 +239,66 @@ def create_video_record_and_dedup(
|
||||
name=video_name,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
duration=used_duration,
|
||||
width=used_width,
|
||||
height=used_height,
|
||||
fps=used_fps,
|
||||
status="completed",
|
||||
generation_params={"mode": mode},
|
||||
thumbnail_url=thumbnail_url or None,
|
||||
video_fingerprint=pre.get("fingerprint_dict"),
|
||||
is_duplicate=bool(pre.get("is_duplicate", False)),
|
||||
duplicate_of=pre.get("duplicate_of"),
|
||||
duplicate_rate=pre.get("duplicate_rate"),
|
||||
match_count=pre.get("match_count"),
|
||||
visual_similarity=pre.get("visual_similarity"),
|
||||
)
|
||||
|
||||
# ── Phase 2: 计算指纹 & 查重(全部在内存) ────────────────
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = None
|
||||
batch_similarity: float | None = None
|
||||
|
||||
try:
|
||||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||||
except Exception as fp_err:
|
||||
logger.warning("Fingerprint computation failed for %s: %s", video_id, fp_err)
|
||||
|
||||
if fingerprint is not None:
|
||||
generated_video.video_fingerprint = fingerprint.to_dict()
|
||||
|
||||
# 写入分片指纹表(失败不阻塞)
|
||||
# 写分片指纹表
|
||||
chunks = pre.get("fingerprint_chunks")
|
||||
if chunks:
|
||||
try:
|
||||
_save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session)
|
||||
chunk_models = [
|
||||
VideoFingerprintChunkModel(
|
||||
id=uuid4().hex,
|
||||
video_id=video_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
start_time_ms=int(c.get("start_time_ms", 0)),
|
||||
end_time_ms=int(c.get("end_time_ms", 0)),
|
||||
phash_binary=str(c.get("phash_binary", "")),
|
||||
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
|
||||
frame_count=int(c.get("frame_count", 0)),
|
||||
)
|
||||
for c in chunks
|
||||
if isinstance(c, dict)
|
||||
]
|
||||
if chunk_models:
|
||||
# 幂等:先清理旧分片
|
||||
session.query(VideoFingerprintChunkModel).filter(
|
||||
VideoFingerprintChunkModel.video_id == video_id
|
||||
).delete(synchronize_session=False)
|
||||
session.bulk_save_objects(chunk_models)
|
||||
except Exception as chunk_err:
|
||||
logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err)
|
||||
|
||||
# (a) 历史成片查重(跨项目全局 + 时长预过滤)
|
||||
# Issue #1702: fingerprint.duration 单位是秒,旧代码 /1000 让时长预过滤失效
|
||||
duration_sec = fingerprint.duration if fingerprint.duration else 0
|
||||
duplicate_result = deduplicator.check_duplicate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
session,
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
duration_sec=duration_sec,
|
||||
exclude_video_id=video_id,
|
||||
)
|
||||
|
||||
# (b) 批次内查重(仅当有 batch_id 时)
|
||||
batch_similarity: float | None = None
|
||||
if not duplicate_result and batch_id:
|
||||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
|
||||
if duplicate_result:
|
||||
batch_similarity = float(duplicate_result.get("similarity", 0.0))
|
||||
if duplicate_result:
|
||||
generated_video.is_duplicate = True
|
||||
generated_video.duplicate_of = duplicate_result["duplicate_of"]
|
||||
logger.info(
|
||||
"Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)",
|
||||
video_id,
|
||||
duplicate_result["duplicate_of"],
|
||||
duplicate_result["reason"],
|
||||
duplicate_result["similarity"],
|
||||
)
|
||||
else:
|
||||
generated_video.is_duplicate = False
|
||||
generated_video.duplicate_of = None
|
||||
|
||||
# 计算重复率百分比(跨项目全局)
|
||||
try:
|
||||
rate_result = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
video_id,
|
||||
session,
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
)
|
||||
generated_video.duplicate_rate = rate_result["duplicate_rate"]
|
||||
generated_video.match_count = rate_result["match_count"]
|
||||
generated_video.visual_similarity = rate_result["visual_similarity"]
|
||||
logger.info(
|
||||
"Duplicate rate for %s: %.2f%% (visual_sim=%.3f, matches=%d)",
|
||||
video_id,
|
||||
rate_result["duplicate_rate"],
|
||||
rate_result["visual_similarity"],
|
||||
rate_result["match_count"],
|
||||
)
|
||||
except Exception as rate_err:
|
||||
logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err)
|
||||
generated_video.duplicate_rate = None
|
||||
|
||||
# ── Phase 3: 一次性持久化 ─────────────────────────────────
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
video_repo.create(generated_video)
|
||||
|
||||
if thumbnail_url:
|
||||
logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80])
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
repo.create(generated_video)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"GeneratedVideo record created: %s (task=%s, dup=%s, rate=%s)",
|
||||
video_id,
|
||||
generation_task_id,
|
||||
generated_video.is_duplicate,
|
||||
generated_video.duplicate_rate,
|
||||
)
|
||||
return {
|
||||
"video_id": video_id,
|
||||
"video_count": 1,
|
||||
"is_duplicate": bool(generated_video.is_duplicate),
|
||||
"batch_similarity": batch_similarity,
|
||||
"duplicate_of": generated_video.duplicate_of,
|
||||
"is_duplicate": bool(pre.get("is_duplicate", False)),
|
||||
"batch_similarity": pre.get("batch_similarity"),
|
||||
"duplicate_of": pre.get("duplicate_of"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create video record / dedup for task %s: %s",
|
||||
generation_task_id,
|
||||
e,
|
||||
)
|
||||
logger.error("Failed to create video record for task %s: %s", generation_task_id, e)
|
||||
session.rollback()
|
||||
return {"video_count": 0, "is_duplicate": False, "batch_similarity": None, "duplicate_of": None}
|
||||
return {
|
||||
"video_id": "",
|
||||
"video_count": 0,
|
||||
"is_duplicate": False,
|
||||
"batch_similarity": None,
|
||||
"duplicate_of": None,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Any
|
||||
from packages.domain.ass_subtitle_builder import (
|
||||
TITLE_MARGIN_SIDE,
|
||||
TITLE_MARGIN_TOP,
|
||||
_scale_len,
|
||||
_wrap_title_text,
|
||||
build_ass_style,
|
||||
escape_ass_text,
|
||||
@@ -133,22 +134,23 @@ def generate_ass_from_timeline(
|
||||
output_path.write_text("", encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
# 样式参数
|
||||
# 样式参数(字号/描边/边距按视频宽度缩放,基准 720p,与前端预览一致)
|
||||
_sw = video_width
|
||||
font_name = subtitle_config.get("font", "思源黑体")
|
||||
font_size = int(subtitle_config.get("size", 24))
|
||||
font_size = _scale_len(int(subtitle_config.get("size", 24)), _sw)
|
||||
color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
|
||||
position = subtitle_config.get("position", "bottom")
|
||||
alignment = _position_to_ass_alignment(position)
|
||||
max_chars_per_line = int(subtitle_config.get("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE))
|
||||
|
||||
# 描边(默认黑色描边,保证可读性)
|
||||
# 描边(默认黑色描边,保证可读性)—— 720p 基准 1.5
|
||||
outline_color = "&H00000000"
|
||||
outline_width = 1.5
|
||||
outline_width = _scale_len(1.5, _sw)
|
||||
|
||||
# 边距
|
||||
margin_v = 60 if position == "bottom" else 60
|
||||
margin_l = 40
|
||||
margin_r = 40
|
||||
# 边距(720p 基准 60/40)
|
||||
margin_v = _scale_len(60 if position == "bottom" else 60, _sw)
|
||||
margin_l = _scale_len(40, _sw)
|
||||
margin_r = _scale_len(40, _sw)
|
||||
|
||||
# 生成样式行
|
||||
style_line = (
|
||||
@@ -203,22 +205,29 @@ def generate_ass_from_timeline(
|
||||
if "font_color" in title_cfg and "color" not in title_cfg:
|
||||
title_cfg["color"] = title_cfg["font_color"]
|
||||
|
||||
_tsw = video_width
|
||||
t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff"))
|
||||
t_stroke = title_cfg.get("stroke", {}) or {}
|
||||
t_shadow = title_cfg.get("shadow", {}) or {}
|
||||
s_color = hex_to_ass_color(t_stroke.get("color", "#000000"))
|
||||
s_width = float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0
|
||||
sh_blur = float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0
|
||||
s_width = _scale_len(float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0, _tsw)
|
||||
sh_blur = _scale_len(float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0, _tsw)
|
||||
sh_offset = (
|
||||
t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0,
|
||||
t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0,
|
||||
_scale_len(t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0, _tsw),
|
||||
_scale_len(t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0, _tsw),
|
||||
)
|
||||
t_alignment = position_to_ass_alignment(title_cfg.get("position", "bottom"))
|
||||
|
||||
# 标题字号/边距按视频宽度缩放(基准 720p),移除旧的 min(...,36) 上限避免 1080p 被钳位过小
|
||||
_base_title_size = int(title_cfg.get("size", 36))
|
||||
t_font_size = _scale_len(_base_title_size, _tsw)
|
||||
_t_margin_top = _scale_len(TITLE_MARGIN_TOP, _tsw)
|
||||
_t_margin_side = _scale_len(TITLE_MARGIN_SIDE, _tsw)
|
||||
|
||||
title_style_line = build_ass_style(
|
||||
"TitleStyle",
|
||||
font_name=title_cfg.get("font", "思源黑体"),
|
||||
font_size=min(int(title_cfg.get("size", 36)), 36),
|
||||
font_size=t_font_size,
|
||||
primary_color=t_color,
|
||||
outline_color=s_color,
|
||||
outline_width=s_width,
|
||||
@@ -227,14 +236,15 @@ def generate_ass_from_timeline(
|
||||
bold=bool(title_cfg.get("bold", True)),
|
||||
italic=bool(title_cfg.get("italic", False)),
|
||||
alignment=t_alignment,
|
||||
margin_v=TITLE_MARGIN_TOP,
|
||||
margin_l=TITLE_MARGIN_SIDE,
|
||||
margin_r=TITLE_MARGIN_SIDE,
|
||||
margin_v=_t_margin_top,
|
||||
margin_l=_t_margin_side,
|
||||
margin_r=_t_margin_side,
|
||||
)
|
||||
|
||||
t_font_size = min(int(title_cfg.get("size", 36)), 36)
|
||||
safe_raw = escape_ass_text(title_text.strip())
|
||||
safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size)
|
||||
safe_wrapped = _wrap_title_text(
|
||||
safe_raw, video_width, t_font_size, margin_l=_t_margin_side, margin_r=_t_margin_side
|
||||
)
|
||||
|
||||
if video_duration > 0:
|
||||
t_end_time = format_ass_time(video_duration)
|
||||
|
||||
@@ -17,7 +17,6 @@ import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
from worker_app.tasks.generation_plan_builder import build_error_info as _build_error_info
|
||||
@@ -142,7 +141,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
|
||||
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.dedup_helpers import compute_render_fingerprint_and_dedup
|
||||
from video_processing.oss_helpers import (
|
||||
download_asset,
|
||||
get_signed_download_url,
|
||||
@@ -555,7 +554,7 @@ def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict)
|
||||
return None
|
||||
|
||||
|
||||
def _record_video_and_dedup(
|
||||
def _precompute_render_metadata(
|
||||
*,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
@@ -568,28 +567,48 @@ def _record_video_and_dedup(
|
||||
video_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> dict:
|
||||
"""成片落库 + 指纹查重(含批次内)。返回查重信息 dict。"""
|
||||
duration = probe_duration(Path(video_path))
|
||||
dedup_session = SessionLocal()
|
||||
"""渲染+上传完成后的预处理:计算指纹/查重(不落 GeneratedVideo 库)。
|
||||
|
||||
#2024: 视频生成后不再自动入成品库。本函数计算视频元信息、指纹、历史+批次查重,
|
||||
结果以 dict 返回,由调用方写入 GenerationTask.extra_meta["rendered_output"],
|
||||
等用户 Step5 调 finalize 时复用,避免 finalize 时从 OSS 下载视频重算。
|
||||
"""
|
||||
pre_session = SessionLocal()
|
||||
try:
|
||||
result = create_video_record_and_dedup(
|
||||
fp_result = compute_render_fingerprint_and_dedup(
|
||||
video_path=video_path,
|
||||
generation_task_id=task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
batch_id=batch_id,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
video_path=video_path,
|
||||
mode=editing_mode.value,
|
||||
session=dedup_session,
|
||||
name=video_name,
|
||||
thumbnail_url=thumbnail_url,
|
||||
session=pre_session,
|
||||
)
|
||||
finally:
|
||||
dedup_session.close()
|
||||
result["duration"] = duration
|
||||
return result
|
||||
pre_session.close()
|
||||
return {
|
||||
"file_url": file_url,
|
||||
"file_size": file_size,
|
||||
"duration": fp_result.get("duration", 0.0),
|
||||
"width": fp_result.get("width", 1280),
|
||||
"height": fp_result.get("height", 720),
|
||||
"fps": fp_result.get("fps", 25.0),
|
||||
"name": video_name,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"mode": editing_mode.value,
|
||||
"batch_id": batch_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
# 查重结果(finalize 时直接写入 GeneratedVideo 字段,无需重算)
|
||||
"fingerprint_dict": fp_result.get("fingerprint_dict"),
|
||||
"fingerprint_chunks": fp_result.get("fingerprint_chunks"),
|
||||
"is_duplicate": bool(fp_result.get("is_duplicate", False)),
|
||||
"duplicate_of": fp_result.get("duplicate_of"),
|
||||
"duplicate_rate": fp_result.get("duplicate_rate"),
|
||||
"match_count": fp_result.get("match_count"),
|
||||
"visual_similarity": fp_result.get("visual_similarity"),
|
||||
"video_fingerprint_md5": fp_result.get("video_fingerprint_md5", ""),
|
||||
}
|
||||
|
||||
|
||||
# ── Celery Task ──────────────────────────────────────────────────────────────
|
||||
@@ -945,8 +964,8 @@ def generate_video(self, task_id: str) -> dict:
|
||||
)
|
||||
file_size = output_path.stat().st_size
|
||||
|
||||
# ── 4.5 落库 + 查重(批次任务检查批次内相似度) ───────────
|
||||
dedup_info = _record_video_and_dedup(
|
||||
# ── 4.5 预计算指纹/元信息(#2024: 不自动入成品库,finalize 时再落库+查重) ──
|
||||
rendered_output = _precompute_render_metadata(
|
||||
task_id=task_id,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
@@ -958,68 +977,23 @@ def generate_video(self, task_id: str) -> dict:
|
||||
video_name=task_info.get("video_title", ""),
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
duration = dedup_info.get("duration", render_duration)
|
||||
video_count = dedup_info.get("video_count", 1)
|
||||
batch_sim = dedup_info.get("batch_similarity")
|
||||
duration = rendered_output.get("duration", render_duration)
|
||||
# #2024: 批次内重渲依赖已 finalize 的同批次视频。渲染阶段暂不做批次查重决策,
|
||||
# 统一在 finalize 阶段查重;首版即视为最终渲染结果。
|
||||
file_size_final = file_size
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"OSS上传",
|
||||
f"第{render_attempt + 1}版上传成功, 大小={file_size}"
|
||||
+ (f", 批次相似度={batch_sim:.0%}" if batch_sim is not None else ""),
|
||||
f"第{render_attempt + 1}版上传成功, 大小={file_size},等待用户确认封面",
|
||||
file_size=file_size,
|
||||
file_url=file_url,
|
||||
)
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
# 非批次 / 相似度达标 / 已是最后一次 → 结束循环
|
||||
if not should_rerender_for_batch_dedup(
|
||||
batch_id=batch_id,
|
||||
render_attempt=render_attempt,
|
||||
batch_similarity=batch_sim,
|
||||
):
|
||||
file_size_final = file_size
|
||||
break
|
||||
|
||||
# 批次内相似度过高:重选独立 plan 后重渲一次
|
||||
logger.warning(
|
||||
"[task_id=%s] 批次内查重相似度 %.2f 超阈值 %.2f,重选 plan 重渲",
|
||||
task_id,
|
||||
batch_sim,
|
||||
BATCH_RENDER_SIMILARITY_LIMIT,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log("批次查重", f"与批次内成片相似度过高({batch_sim:.0%}),重新选片渲染")
|
||||
_flush_logs(task_id, gen_task)
|
||||
new_plan_id = _reselect_plan_for_batch_retry(task_id, current_plan_id, task_info)
|
||||
if not new_plan_id:
|
||||
logger.warning("[task_id=%s] 重选 plan 失败,保留首版", task_id)
|
||||
file_size_final = file_size
|
||||
break
|
||||
# 回写任务关联的 plan(重渲版以新 plan 渲染)
|
||||
try:
|
||||
_ps = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_pr = SQLAlchemyGenerationTaskRepository(_ps)
|
||||
_gt = _pr.get(task_id)
|
||||
if _gt:
|
||||
_gt.source_edit_plan_id = new_plan_id
|
||||
_pr.update(_gt)
|
||||
finally:
|
||||
_ps.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 回写重渲 plan_id 失败", task_id, exc_info=True)
|
||||
current_plan_id = new_plan_id
|
||||
# 清理本轮临时目录,下一轮重新渲染
|
||||
if render_temp_dir:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(render_temp_dir, ignore_errors=True)
|
||||
render_temp_dir = None
|
||||
# #2024: 不再因批次内相似度过高而重渲(finalize 阶段统一查重),
|
||||
# 首版即视为最终渲染结果,直接结束循环。
|
||||
break
|
||||
|
||||
file_size = file_size_final or file_size
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
@@ -1062,8 +1036,47 @@ def generate_video(self, task_id: str) -> dict:
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 封面帧持久化失败", task_id, exc_info=True)
|
||||
|
||||
# ── 5. 标记完成 ──────────────────────────────────────────────────
|
||||
_update_task_status(task_id, "mark_completed", result_count=video_count)
|
||||
# ── 5. 保存渲染产物到 extra_meta 并标记为等待封面确认(#2024: 不自动入成品库) ──
|
||||
try:
|
||||
_finalize_meta_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
|
||||
_meta_model = (
|
||||
_finalize_meta_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _meta_model:
|
||||
meta = dict(_meta_model.extra_meta or {})
|
||||
meta["rendered_output"] = {
|
||||
"file_url": file_url,
|
||||
"file_size": file_size,
|
||||
"duration": duration,
|
||||
"width": rendered_output.get("width", 1280),
|
||||
"height": rendered_output.get("height", 720),
|
||||
"fps": rendered_output.get("fps", 25.0),
|
||||
"name": rendered_output.get("name", ""),
|
||||
"thumbnail_url": rendered_output.get("thumbnail_url", ""),
|
||||
"mode": rendered_output.get("mode", editing_mode.value),
|
||||
"fingerprint_dict": rendered_output.get("fingerprint_dict"),
|
||||
"batch_id": batch_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
_meta_model.extra_meta = meta
|
||||
_finalize_meta_session.commit()
|
||||
finally:
|
||||
_finalize_meta_session.close()
|
||||
except Exception as meta_err:
|
||||
logger.warning(
|
||||
"[task_id=%s] 保存 rendered_output 到 extra_meta 失败: %s", task_id, meta_err, exc_info=True
|
||||
)
|
||||
|
||||
# #2024: 标记为「等待用户确认封面」,不自动入成品库;等用户调 finalize 接口才真正 mark_completed
|
||||
_update_task_status(task_id, "mark_awaiting_cover")
|
||||
|
||||
# 5.1 更新标题使用次数
|
||||
try:
|
||||
|
||||
@@ -12,6 +12,12 @@ server {
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
|
||||
@@ -12,6 +12,13 @@ server {
|
||||
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# SPA routing - all routes to index.html
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""#2024: 视频生成 finalize 入库用例。
|
||||
|
||||
Worker 渲染+上传完成后不自动入库,只把渲染产物与查重结果保存到
|
||||
GenerationTask.extra_meta["rendered_output"],并标记为 awaiting_cover。
|
||||
用户点「完成」时由 API 调用本用例:创建 GeneratedVideo 记录(复用预计算查重结果)、
|
||||
推进任务到 completed,返回新记录 id。
|
||||
|
||||
设计原则:finalize 必须快速(仅 DB 写入,不下载视频、不重算指纹)——
|
||||
所有耗时操作(指纹计算、历史/批次查重)都在 worker 渲染阶段预完成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RenderedOutput:
|
||||
"""Worker 预计算并写入 extra_meta 的渲染产物+查重结果。"""
|
||||
|
||||
file_url: str
|
||||
file_size: int = 0
|
||||
duration: float = 0.0
|
||||
width: int = 1280
|
||||
height: int = 720
|
||||
fps: float = 25.0
|
||||
name: str = ""
|
||||
thumbnail_url: str = ""
|
||||
mode: str = "narrative"
|
||||
batch_id: str = ""
|
||||
project_id: str = ""
|
||||
user_id: str = ""
|
||||
# 查重结果(worker 预计算)
|
||||
fingerprint_dict: dict[str, Any] | None = None
|
||||
fingerprint_chunks: list[dict[str, Any]] | None = None
|
||||
is_duplicate: bool = False
|
||||
duplicate_of: str | None = None
|
||||
duplicate_rate: float | None = None
|
||||
match_count: int | None = None
|
||||
visual_similarity: float | None = None
|
||||
video_fingerprint_md5: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "RenderedOutput":
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("rendered_output must be a dict")
|
||||
return cls(
|
||||
file_url=str(data.get("file_url") or ""),
|
||||
file_size=int(data.get("file_size") or 0),
|
||||
duration=float(data.get("duration") or 0.0),
|
||||
width=int(data.get("width") or 1280),
|
||||
height=int(data.get("height") or 720),
|
||||
fps=float(data.get("fps") or 25.0),
|
||||
name=str(data.get("name") or ""),
|
||||
thumbnail_url=str(data.get("thumbnail_url") or ""),
|
||||
mode=str(data.get("mode") or "narrative"),
|
||||
batch_id=str(data.get("batch_id") or ""),
|
||||
project_id=str(data.get("project_id") or ""),
|
||||
user_id=str(data.get("user_id") or ""),
|
||||
fingerprint_dict=data.get("fingerprint_dict"),
|
||||
fingerprint_chunks=data.get("fingerprint_chunks"),
|
||||
is_duplicate=bool(data.get("is_duplicate", False)),
|
||||
duplicate_of=data.get("duplicate_of"),
|
||||
duplicate_rate=_safe_float(data.get("duplicate_rate")),
|
||||
match_count=_safe_int(data.get("match_count")),
|
||||
visual_similarity=_safe_float(data.get("visual_similarity")),
|
||||
video_fingerprint_md5=str(data.get("video_fingerprint_md5") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(v) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(v) -> int | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def finalize_generated_video(
|
||||
*,
|
||||
task,
|
||||
session: Session,
|
||||
effective_cover_url: str = "",
|
||||
custom_name: str | None = None,
|
||||
) -> dict:
|
||||
"""将 awaiting_cover 的任务正式入库。
|
||||
|
||||
从 ``task.extra_meta["rendered_output"]`` 读取 worker 预存的渲染结果与查重数据,
|
||||
创建 GeneratedVideo 记录并 commit;调用方负责将 task 推进到 completed 并 update。
|
||||
|
||||
Returns:
|
||||
{"video_id": str, "is_duplicate": bool, "duplicate_of": str|None}
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
meta = dict(task.extra_meta or {})
|
||||
rendered_dict = meta.get("rendered_output") or {}
|
||||
rendered = RenderedOutput.from_dict(rendered_dict)
|
||||
|
||||
if not rendered.file_url.strip():
|
||||
raise ValueError(f"task {task.id} rendered_output.file_url 为空,无法 finalize")
|
||||
|
||||
video_id = uuid4().hex
|
||||
_custom = (custom_name or "").strip() if custom_name else ""
|
||||
video_name = _custom or (rendered.name.strip() or f"generated-{task.id[:8]}.mp4")
|
||||
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=(rendered.project_id or task.project_id or "").strip(),
|
||||
user_id=(rendered.user_id or task.created_by_user_id or "").strip(),
|
||||
generation_task_id=task.id,
|
||||
name=video_name,
|
||||
file_url=rendered.file_url.strip(),
|
||||
file_size=rendered.file_size,
|
||||
duration=rendered.duration,
|
||||
width=rendered.width,
|
||||
height=rendered.height,
|
||||
fps=rendered.fps,
|
||||
status="completed",
|
||||
generation_params={"mode": rendered.mode},
|
||||
thumbnail_url=effective_cover_url or rendered.thumbnail_url or None,
|
||||
video_fingerprint=rendered.fingerprint_dict,
|
||||
is_duplicate=rendered.is_duplicate,
|
||||
duplicate_of=rendered.duplicate_of,
|
||||
duplicate_rate=rendered.duplicate_rate,
|
||||
match_count=rendered.match_count,
|
||||
visual_similarity=rendered.visual_similarity,
|
||||
created_at=datetime.now(UTC),
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# 写入分片指纹(worker 预序列化的 chunk 列表)
|
||||
if rendered.fingerprint_chunks:
|
||||
try:
|
||||
chunk_models = []
|
||||
for c in rendered.fingerprint_chunks:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
chunk_models.append(
|
||||
VideoFingerprintChunkModel(
|
||||
id=uuid4().hex,
|
||||
video_id=video_id,
|
||||
project_id=generated_video.project_id,
|
||||
user_id=generated_video.user_id,
|
||||
start_time_ms=int(c.get("start_time_ms", 0)),
|
||||
end_time_ms=int(c.get("end_time_ms", 0)),
|
||||
phash_binary=str(c.get("phash_binary", "")),
|
||||
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
|
||||
frame_count=int(c.get("frame_count", 0)),
|
||||
)
|
||||
)
|
||||
if chunk_models:
|
||||
session.bulk_save_objects(chunk_models)
|
||||
except Exception as chunk_err:
|
||||
logger.warning("Failed to persist fingerprint chunks for video %s: %s", video_id, chunk_err)
|
||||
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
video_repo.create(generated_video)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"[finalize] GeneratedVideo created: %s (task=%s, dup=%s, cover=%s)",
|
||||
video_id,
|
||||
task.id,
|
||||
rendered.is_duplicate,
|
||||
bool(effective_cover_url),
|
||||
)
|
||||
return {
|
||||
"video_id": video_id,
|
||||
"is_duplicate": rendered.is_duplicate,
|
||||
"duplicate_of": rendered.duplicate_of,
|
||||
}
|
||||
@@ -59,6 +59,31 @@ FONT_NAME_MAP: dict[str, str] = {
|
||||
# font_size=89 → ASS Fontsize=round(89*1.35)=120,实际中文字高约 78~85px。
|
||||
ASS_FONTSIZE_COMPENSATION = 1.35
|
||||
|
||||
# 前端预览字号的基准宽度(px)。前端 TitleMiniPreview 以 width/360 缩放、titleCanvas.ts
|
||||
# 以 videoWidth/720 缩放(360 是 720 的一半,字号等比例一致),types.ts 注释 "px @720p"。
|
||||
# 当输出视频宽度 ≠ 720 时,所有长度类字段(字号/描边宽/阴影偏移/边距)
|
||||
# 都按 video_width / TITLE_SIZE_REF_WIDTH 等比缩放,保证成片标题视觉大小与预览一致。
|
||||
TITLE_SIZE_REF_WIDTH = 720
|
||||
|
||||
|
||||
def _scale_len(value, video_width: int):
|
||||
"""将 720p 基准长度值按 video_width 等比缩放。
|
||||
|
||||
整数输入 → 返回 int(用于字号、描边宽、边距、偏移等整数字段);
|
||||
浮点输入 → 返回 float(保留小数,用于 1.5 这类 outline 细描边);
|
||||
非数值原样返回。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if not video_width or video_width <= 0:
|
||||
return int(round(v)) if isinstance(value, int) else v
|
||||
scaled = v * (video_width / TITLE_SIZE_REF_WIDTH)
|
||||
return int(round(scaled)) if isinstance(value, int) else scaled
|
||||
|
||||
|
||||
def _compensate_ass_fontsize(font_size: int) -> int:
|
||||
"""将 CSS 语义字号换算为 ASS Fontsize,补偿中文字符在 em-square 中的留白。"""
|
||||
@@ -374,6 +399,44 @@ def build_ass_content(
|
||||
else {"enabled": False}
|
||||
)
|
||||
|
||||
# ── 字号/描边/阴影/边距按视频宽度缩放(基准 720p,与前端预览一致) ──
|
||||
# 前端标题/字幕面板的 size 语义是 "px @720p"(见 types.ts 注释、titleCanvas.ts scale=videoWidth/720),
|
||||
# 当输出分辨率不是 720p(如 1080x1920 竖屏、4K)时等比放大,避免成片标题比预览小。
|
||||
def _scale_cfg(cfg: dict, defaults: dict | None = None) -> None:
|
||||
if not isinstance(cfg, dict):
|
||||
return
|
||||
# 先填充默认值,再统一缩放,避免 .get("size", 36) 处拿未缩放默认值
|
||||
if defaults:
|
||||
for k, v in defaults.items():
|
||||
cfg.setdefault(k, v)
|
||||
sw = video_width
|
||||
for key in ("size", "font_size"):
|
||||
if key in cfg:
|
||||
try:
|
||||
cfg[key] = _scale_len(cfg[key], sw)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# stroke/shadow 的 width/blur/offset 由后续字段提取处统一按 720p 默认值+缩放处理
|
||||
# (包含 enabled=false 时不写入、默认值 2/4 也需缩放),此处不再重复缩放
|
||||
for key in ("margin_top", "bg_padding", "bg_radius", "pos_x", "pos_y"):
|
||||
# pos_x/pos_y 是百分比(0-100)不缩放,margin/padding/radius 是 px @720p 需要缩放
|
||||
if key in cfg and key not in ("pos_x", "pos_y"):
|
||||
cfg[key] = _scale_len(cfg.get(key), sw)
|
||||
# 逐行覆盖的 size 也要缩放
|
||||
overrides = cfg.get("line_overrides")
|
||||
if isinstance(overrides, list):
|
||||
for ov in overrides:
|
||||
if isinstance(ov, dict) and "size" in ov:
|
||||
ov["size"] = _scale_len(ov.get("size"), sw)
|
||||
|
||||
_scale_cfg(title_config, defaults={"size": 36})
|
||||
_scale_cfg(subtitle_config, defaults={"size": 24})
|
||||
|
||||
# 边距按视频宽度缩放(基准 720p)
|
||||
_margin_top = _scale_len(TITLE_MARGIN_TOP, video_width) or TITLE_MARGIN_TOP
|
||||
_margin_bottom = _scale_len(TITLE_MARGIN_BOTTOM, video_width) or TITLE_MARGIN_BOTTOM
|
||||
_margin_side = _scale_len(TITLE_MARGIN_SIDE, video_width) or TITLE_MARGIN_SIDE
|
||||
|
||||
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
|
||||
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
|
||||
|
||||
@@ -389,11 +452,16 @@ def build_ass_content(
|
||||
title_stroke = title_config.get("stroke", {}) or {}
|
||||
title_shadow = title_config.get("shadow", {}) or {}
|
||||
stroke_color = hex_to_ass_color(title_stroke.get("color", "#000000"))
|
||||
stroke_width = float(title_stroke.get("width", 2)) if title_stroke.get("enabled", False) else 0.0
|
||||
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
|
||||
# 描边/阴影默认值也是 720p 基准,需要随视频宽度缩放
|
||||
_def_stroke_w = float(title_stroke.get("width", 2)) if title_stroke.get("enabled", False) else 0.0
|
||||
_def_shadow_b = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
|
||||
_def_shadow_x = title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0
|
||||
_def_shadow_y = title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0
|
||||
stroke_width = float(_scale_len(_def_stroke_w, video_width))
|
||||
shadow_blur = float(_scale_len(_def_shadow_b, video_width))
|
||||
shadow_offset = (
|
||||
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
|
||||
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
|
||||
_scale_len(_def_shadow_x, video_width),
|
||||
_scale_len(_def_shadow_y, video_width),
|
||||
)
|
||||
|
||||
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
|
||||
@@ -420,9 +488,9 @@ def build_ass_content(
|
||||
bold=bool(title_config.get("bold", True)),
|
||||
italic=bool(title_config.get("italic", False)),
|
||||
alignment=title_alignment,
|
||||
margin_v=TITLE_MARGIN_TOP,
|
||||
margin_l=TITLE_MARGIN_SIDE,
|
||||
margin_r=TITLE_MARGIN_SIDE,
|
||||
margin_v=_margin_top,
|
||||
margin_l=_margin_side,
|
||||
margin_r=_margin_side,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -430,7 +498,9 @@ def build_ass_content(
|
||||
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
|
||||
title_font_size = int(title_config.get("size", 36))
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
safe_title_text = _wrap_title_text(
|
||||
safe_title_text_raw, video_width, title_font_size, margin_l=_margin_side, margin_r=_margin_side
|
||||
)
|
||||
|
||||
# #2001 逐行样式覆盖:按 line_overrides 在每行前注入 ASS inline override 标签
|
||||
# line_overrides 透传自前端爆款标题面板,SubtitleStyle.from_dict 已做安全过滤
|
||||
@@ -460,15 +530,15 @@ def build_ass_content(
|
||||
font_size=int(subtitle_config.get("size", 24)),
|
||||
primary_color=sub_color,
|
||||
outline_color="&H00000000",
|
||||
outline_width=1.0,
|
||||
outline_width=float(_scale_len(1.0, video_width)),
|
||||
shadow_blur=0.0,
|
||||
shadow_offset=(0, 0),
|
||||
bold=False,
|
||||
italic=False,
|
||||
alignment=sub_alignment,
|
||||
margin_v=TITLE_MARGIN_BOTTOM,
|
||||
margin_l=TITLE_MARGIN_SIDE,
|
||||
margin_r=TITLE_MARGIN_SIDE,
|
||||
margin_v=_margin_bottom,
|
||||
margin_l=_margin_side,
|
||||
margin_r=_margin_side,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""GenerationTask 领域模型 — 视频生成任务.
|
||||
|
||||
状态机:
|
||||
pending → running → completed
|
||||
pending → running → awaiting_cover → completed
|
||||
↘ failed → pending (重试)
|
||||
↘ cancelled
|
||||
|
||||
``awaiting_cover`` 表示渲染已完成、视频文件已上传、封面候选已就绪,
|
||||
但用户尚未在 Step5 确认封面并点击「完成」,此时不创建 GeneratedVideo 成品记录。
|
||||
用户调用 finalize 接口后才进入 ``completed`` 并正式入库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,8 +38,11 @@ class GenerationTaskStatus(StrEnum):
|
||||
RUNNING = "running"
|
||||
"""运行中(正在生成视频)"""
|
||||
|
||||
AWAITING_COVER = "awaiting_cover"
|
||||
"""视频已渲染上传、封面候选已就绪,等待用户在 Step5 确认封面(finalize 前的中间态)"""
|
||||
|
||||
COMPLETED = "completed"
|
||||
"""已完成(视频生成成功)"""
|
||||
"""已完成(用户已确认封面,视频已正式入库)"""
|
||||
|
||||
FAILED = "failed"
|
||||
"""失败(生成失败)"""
|
||||
@@ -61,6 +68,8 @@ class GenerationTaskStatus(StrEnum):
|
||||
return cls.FAILED
|
||||
if normalized in ("process", "processing", "run", "running", "in_progress"):
|
||||
return cls.RUNNING
|
||||
if normalized in ("awaiting_cover", "waiting_cover", "video_ready", "rendered", "pending_cover"):
|
||||
return cls.AWAITING_COVER
|
||||
if normalized in ("cancel", "cancelled", "canceled"):
|
||||
return cls.CANCELLED
|
||||
return cls.PENDING
|
||||
@@ -79,6 +88,12 @@ _VALID_TRANSITIONS: dict[GenerationTaskStatus, set[GenerationTaskStatus]] = {
|
||||
GenerationTaskStatus.CANCELLED,
|
||||
},
|
||||
GenerationTaskStatus.RUNNING: {
|
||||
GenerationTaskStatus.AWAITING_COVER,
|
||||
GenerationTaskStatus.COMPLETED, # 兜底/测试兼容:允许直接完成;主路径走 awaiting_cover
|
||||
GenerationTaskStatus.FAILED,
|
||||
GenerationTaskStatus.CANCELLED,
|
||||
},
|
||||
GenerationTaskStatus.AWAITING_COVER: {
|
||||
GenerationTaskStatus.COMPLETED,
|
||||
GenerationTaskStatus.FAILED,
|
||||
GenerationTaskStatus.CANCELLED,
|
||||
@@ -210,6 +225,11 @@ class GenerationTask:
|
||||
"""是否运行中。"""
|
||||
return self.status == GenerationTaskStatus.RUNNING
|
||||
|
||||
@property
|
||||
def is_awaiting_cover(self) -> bool:
|
||||
"""是否等待用户确认封面(渲染已完成、视频已上传、尚未 finalize 入库)。"""
|
||||
return self.status == GenerationTaskStatus.AWAITING_COVER
|
||||
|
||||
# ── 状态转换 ────────────────────────────────────────────────────────────
|
||||
|
||||
def transition_to(self, new_status: GenerationTaskStatus | str) -> None:
|
||||
@@ -248,13 +268,27 @@ class GenerationTask:
|
||||
self.started_at = datetime.now(UTC)
|
||||
self.error_message = ""
|
||||
|
||||
def mark_awaiting_cover(self) -> None:
|
||||
"""标记为等待确认封面(running → awaiting_cover)。
|
||||
|
||||
渲染与上传已完成、封面候选已就绪,等待用户在 Step5 选封面并点「完成」。
|
||||
此时不创建 GeneratedVideo 成品记录;progress 置 100,completed_at 暂不设置
|
||||
(finalize 完成入库时才真正结束任务)。
|
||||
|
||||
Raises:
|
||||
ValueError: 当前状态不允许转换到 awaiting_cover
|
||||
"""
|
||||
self.transition_to(GenerationTaskStatus.AWAITING_COVER)
|
||||
self.progress = 100.0
|
||||
self.error_message = ""
|
||||
|
||||
def mark_completed(self, result_count: int = 1) -> None:
|
||||
"""标记为已完成(running → completed)。
|
||||
"""标记为已完成(awaiting_cover → completed,由 finalize 调用)。
|
||||
|
||||
设置 completed_at、progress=100.0、result_count,清除 error_message。
|
||||
|
||||
Args:
|
||||
result_count: 生成的视频数量,默认为 1
|
||||
result_count: 入库的视频数量,默认为 1
|
||||
|
||||
Raises:
|
||||
ValueError: 当前状态不允许转换到 completed
|
||||
|
||||
@@ -428,6 +428,26 @@ DRAWTEXT_FONT_MAP: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
# 前端标题/字幕面板的字号/描边/阴影/边距语义是 "px @720p"(见 web/src/pages/generate/types.ts
|
||||
# 注释、apps/web/src/pages/ai-avatar/utils/titleCanvas.ts scale = videoWidth / 720)。
|
||||
# 当输出视频宽度 != 720 时(如 1080x1920 竖屏、4K),drawtext 降级路径需要按比例
|
||||
# 缩放所有长度类字段,避免成片标题视觉上比前端预览小。
|
||||
TITLE_DRAWTEXT_REF_WIDTH = 720
|
||||
|
||||
|
||||
def _scale_title_len(value, output_width: int):
|
||||
"""将 720p 基准长度值按 output_width 等比缩放,返回 int."""
|
||||
if value is None:
|
||||
return 0
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if not output_width or output_width <= 0:
|
||||
return int(round(v))
|
||||
return int(round(v * (output_width / TITLE_DRAWTEXT_REF_WIDTH)))
|
||||
|
||||
|
||||
def _escape_drawtext_text(text: str) -> str:
|
||||
"""转义 drawtext 特殊字符。
|
||||
|
||||
@@ -514,6 +534,8 @@ def build_title_drawtext_filter(
|
||||
# ── 样式参数 ──
|
||||
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
|
||||
font_size = int(title_config.get("font_size") or title_config.get("size") or 48)
|
||||
# 字号/描边/阴影按视频宽度等比缩放(基准 720p,与前端预览一致)
|
||||
font_size = _scale_title_len(font_size, output_width)
|
||||
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
|
||||
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
|
||||
if font_color.startswith("#"):
|
||||
@@ -549,31 +571,31 @@ def build_title_drawtext_filter(
|
||||
border_color = "000000"
|
||||
if stroke:
|
||||
if isinstance(stroke, bool):
|
||||
border_width = 2
|
||||
border_width = _scale_title_len(2, output_width)
|
||||
border_color = "000000"
|
||||
elif isinstance(stroke, dict):
|
||||
if stroke.get("enabled", True):
|
||||
border_width = int(stroke.get("width", 2))
|
||||
border_width = _scale_title_len(int(stroke.get("width", 2)), output_width)
|
||||
border_color = (stroke.get("color") or "#000000").lstrip("#")
|
||||
elif bold:
|
||||
# 粗体模式且未配描边:黑色细描边,模拟粗体同时保证不重影
|
||||
border_width = 2
|
||||
border_width = _scale_title_len(2, output_width)
|
||||
border_color = "000000"
|
||||
if border_width > 0:
|
||||
params.append(f"borderw={border_width}")
|
||||
params.append(f"bordercolor={border_color}")
|
||||
|
||||
# 阴影(shadowcolor + shadowx/y)
|
||||
# 阴影(shadowcolor + shadowx/y)—— 同样按视频宽度缩放
|
||||
if shadow:
|
||||
if isinstance(shadow, bool):
|
||||
params.append("shadowcolor=black")
|
||||
params.append("shadowx=2")
|
||||
params.append("shadowy=2")
|
||||
params.append(f"shadowx={_scale_title_len(2, output_width)}")
|
||||
params.append(f"shadowy={_scale_title_len(2, output_width)}")
|
||||
elif isinstance(shadow, dict):
|
||||
if shadow.get("enabled", True):
|
||||
params.append(f"shadowcolor={(shadow.get('color') or '#000000').lstrip('#')}")
|
||||
params.append(f"shadowx={int(shadow.get('offset_x', 2))}")
|
||||
params.append(f"shadowy={int(shadow.get('offset_y', 2))}")
|
||||
params.append(f"shadowx={_scale_title_len(int(shadow.get('offset_x', 2)), output_width)}")
|
||||
params.append(f"shadowy={_scale_title_len(int(shadow.get('offset_y', 2)), output_width)}")
|
||||
|
||||
# ── 位置计算 ──
|
||||
# 优先使用自定义坐标 pos_x / pos_y
|
||||
@@ -600,10 +622,10 @@ def build_title_drawtext_filter(
|
||||
if position == "center":
|
||||
params.append("y=(h-text_h)/2")
|
||||
elif position == "bottom":
|
||||
params.append("y=h-text_h-50")
|
||||
params.append(f"y=h-text_h-{_scale_title_len(50, output_width)}")
|
||||
else:
|
||||
# top(默认)
|
||||
params.append("y=50")
|
||||
params.append(f"y={_scale_title_len(50, output_width)}")
|
||||
|
||||
return "drawtext=" + ":".join(params)
|
||||
|
||||
|
||||
@@ -285,20 +285,20 @@ class TestFormatAssTime:
|
||||
|
||||
class TestBuildAssContent:
|
||||
def test_no_subtitles_returns_empty(self):
|
||||
result = build_ass_content(video_width=1920, video_height=1080, video_duration=10.0)
|
||||
result = build_ass_content(video_width=720, video_height=1280, video_duration=10.0)
|
||||
assert result == ""
|
||||
|
||||
def test_title_only(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="Test Title",
|
||||
)
|
||||
assert result != ""
|
||||
assert "[Script Info]" in result
|
||||
assert "PlayResX: 1920" in result
|
||||
assert "PlayResY: 1080" in result
|
||||
assert "PlayResX: 720" in result
|
||||
assert "PlayResY: 1280" in result
|
||||
assert "[V4+ Styles]" in result
|
||||
assert "[Events]" in result
|
||||
assert "TitleStyle" in result
|
||||
@@ -306,8 +306,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_subtitle_only(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
subtitle_text="Hello Subtitle",
|
||||
)
|
||||
@@ -317,8 +317,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_both_title_and_subtitle(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="Title",
|
||||
subtitle_text="Subtitle",
|
||||
@@ -330,8 +330,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_whitespace_title_returns_empty(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text=" ",
|
||||
)
|
||||
@@ -339,8 +339,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_whitespace_subtitle_returns_empty(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
subtitle_text=" \n ",
|
||||
)
|
||||
@@ -348,8 +348,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_disabled(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="Title",
|
||||
title_config={"enabled": False},
|
||||
@@ -358,8 +358,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_subtitle_disabled(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
subtitle_text="Sub",
|
||||
subtitle_config={"enabled": False},
|
||||
@@ -368,8 +368,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_color(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"color": "#FF0000"},
|
||||
@@ -379,8 +379,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_position_top(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"position": "top"},
|
||||
@@ -390,8 +390,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_position_bottom(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"position": "bottom"},
|
||||
@@ -401,8 +401,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_subtitle_position_bottom(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
subtitle_text="S",
|
||||
subtitle_config={"position": "bottom"},
|
||||
@@ -411,8 +411,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_font_size(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"size": 72},
|
||||
@@ -427,8 +427,8 @@ class TestBuildAssContent:
|
||||
def test_title_font_size_frontend_field_alias(self):
|
||||
"""前端传 font_size 应归一化为内部 size 字段。"""
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"font_size": 48},
|
||||
@@ -442,8 +442,8 @@ class TestBuildAssContent:
|
||||
def test_title_font_color_frontend_field_alias(self):
|
||||
"""前端传 font_color 应归一化为内部 color 字段。"""
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"font_color": "#FF0000"},
|
||||
@@ -454,8 +454,8 @@ class TestBuildAssContent:
|
||||
def test_title_size_takes_precedence_over_font_size(self):
|
||||
"""同时传 size 和 font_size 时,size 优先。"""
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"size": 56, "font_size": 28},
|
||||
@@ -468,8 +468,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_bold(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"bold": True},
|
||||
@@ -482,8 +482,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_stroke_enabled(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"stroke": {"enabled": True, "width": 3, "color": "#000000"}},
|
||||
@@ -496,8 +496,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_stroke_disabled(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"stroke": {"enabled": False, "width": 3}},
|
||||
@@ -510,8 +510,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_title_shadow_enabled(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"shadow": {"enabled": True, "blur": 2, "offset_x": 2, "offset_y": 4}},
|
||||
@@ -524,8 +524,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_dialogue_has_correct_timing(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=65.5,
|
||||
title_text="T",
|
||||
)
|
||||
@@ -534,8 +534,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_dialogue_starts_at_zero(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=10.0,
|
||||
subtitle_text="S",
|
||||
)
|
||||
@@ -543,8 +543,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_contains_script_info_header(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=10.0,
|
||||
title_text="T",
|
||||
)
|
||||
@@ -554,8 +554,8 @@ class TestBuildAssContent:
|
||||
|
||||
def test_escaped_text_in_dialogue(self):
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=10.0,
|
||||
title_text="line1\nline2",
|
||||
)
|
||||
|
||||
@@ -13,8 +13,9 @@ class TestGenerationTaskStatus:
|
||||
"""GenerationTaskStatus 枚举测试."""
|
||||
|
||||
def test_five_statuses(self):
|
||||
"""五种状态."""
|
||||
assert len(GenerationTaskStatus) == 5
|
||||
"""六种状态(#2024 新增 awaiting_cover)."""
|
||||
assert len(GenerationTaskStatus) == 6
|
||||
assert GenerationTaskStatus.AWAITING_COVER == "awaiting_cover"
|
||||
|
||||
def test_pending(self):
|
||||
assert GenerationTaskStatus.PENDING == "pending"
|
||||
|
||||
@@ -587,9 +587,10 @@ class TestFontsizeCompensation:
|
||||
assert parts[2] == "120"
|
||||
|
||||
def test_subtitle_also_compensated(self):
|
||||
# 720p 基准宽度:只做 ASS fontsize 1.35x 补偿,不做分辨率缩放
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
video_duration=5.0,
|
||||
subtitle_text="字幕",
|
||||
subtitle_config={"size": 24},
|
||||
@@ -789,3 +790,112 @@ class TestDefaultPositionBottom:
|
||||
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[18] == "8"
|
||||
|
||||
|
||||
|
||||
class TestTitleFontsizeScaling:
|
||||
"""标题/字幕字号按 video_width/720 等比缩放(工单:标题字号校准)。
|
||||
|
||||
前端标题面板 size 语义是"px @720p",输出到 1080p/4K 等非 720 宽度
|
||||
视频时需要等比放大所有长度类字段(字号/描边/阴影/边距),保证成片
|
||||
标题视觉大小与前端预览一致。
|
||||
"""
|
||||
|
||||
BASE_KWARGS = dict(video_duration=5.0, title_text="标题测试", subtitle_text="字幕测试")
|
||||
|
||||
def _title_fontsize(self, content: str) -> int:
|
||||
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
|
||||
return int(line.split(",")[2])
|
||||
|
||||
def _subtitle_fontsize(self, content: str) -> int:
|
||||
line = [ln for ln in content.splitlines() if ln.startswith("Style: SubtitleStyle")][0]
|
||||
return int(line.split(",")[2])
|
||||
|
||||
def test_720p_title_no_scale(self):
|
||||
"""720p 宽度时只做 1.35x ASS 补偿,不做分辨率缩放。"""
|
||||
from packages.domain.ass_subtitle_builder import ASS_FONTSIZE_COMPENSATION
|
||||
content = build_ass_content(
|
||||
video_width=720, video_height=1280, **self.BASE_KWARGS, title_config={"size": 28}
|
||||
)
|
||||
expected = round(28 * ASS_FONTSIZE_COMPENSATION) # 38
|
||||
assert self._title_fontsize(content) == expected
|
||||
|
||||
def test_1080p_title_scaled(self):
|
||||
"""1080 宽度(1080x1920 竖屏)时 size=28 → 28*1.5=42 → ASS 补偿 → round(42*1.35)=57."""
|
||||
content = build_ass_content(
|
||||
video_width=1080, video_height=1920, **self.BASE_KWARGS, title_config={"size": 28}
|
||||
)
|
||||
# 28 * 1080/720 = 42; round(42*1.35)=57
|
||||
assert self._title_fontsize(content) == 57
|
||||
|
||||
def test_1920p_title_scaled(self):
|
||||
"""1920 宽度(横屏 1920x1080)时 size=28 → 28*1920/720≈75 → round(75*1.35)=101."""
|
||||
content = build_ass_content(
|
||||
video_width=1920, video_height=1080, **self.BASE_KWARGS, title_config={"size": 28}
|
||||
)
|
||||
assert self._title_fontsize(content) == 101
|
||||
|
||||
def test_subtitle_scaled_1080p(self):
|
||||
"""字幕默认 size=24 在 1080p 下也应按比例缩放:24*1.5=36 → round(36*1.35)=49."""
|
||||
content = build_ass_content(
|
||||
video_width=1080, video_height=1920, **self.BASE_KWARGS, subtitle_config={"size": 24}
|
||||
)
|
||||
assert self._subtitle_fontsize(content) == 49
|
||||
|
||||
def test_stroke_width_scaled(self):
|
||||
"""描边宽度(Style 字段 Outline=16 索引)按同比例缩放。"""
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
**self.BASE_KWARGS,
|
||||
title_config={"size": 36, "stroke": {"enabled": True, "width": 4, "color": "#000000"}},
|
||||
)
|
||||
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in line.split(",")]
|
||||
# Outline width 字段索引 16
|
||||
outline = float(fields[16])
|
||||
# 4 * 1.5 = 6
|
||||
assert outline == 6.0
|
||||
|
||||
def test_shadow_offset_scaled(self):
|
||||
"""阴影偏移 x/y(Shadow 字段 17 存单值;验证 style line 中无异常)。
|
||||
shadow 同时注入 \\4a/\\xshad/\\yshad 级标签,这里检查 style line。"""
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
**self.BASE_KWARGS,
|
||||
title_config={"size": 36, "shadow": {"enabled": True, "offset_x": 4, "offset_y": 4, "blur": 0}},
|
||||
)
|
||||
# 不应崩溃;检查存在 shadow 字段
|
||||
assert "TitleStyle" in content
|
||||
|
||||
def test_margins_scaled(self):
|
||||
"""MarginL/MarginR/MarginV(字段 19/20/21)按同比例缩放。"""
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
**self.BASE_KWARGS,
|
||||
title_config={"size": 36, "position": "top"},
|
||||
)
|
||||
line = [ln for ln in content.splitlines() if ln.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in line.split(",")]
|
||||
# 默认 TITLE_MARGIN_TOP=180, TITLE_MARGIN_SIDE=40, 1080/720=1.5
|
||||
assert int(fields[19]) == 60 # margin_l = 40*1.5 = 60
|
||||
assert int(fields[20]) == 60 # margin_r = 60
|
||||
assert int(fields[21]) == 270 # margin_v = 180*1.5 = 270
|
||||
|
||||
def test_line_overrides_size_scaled(self):
|
||||
"""逐行覆盖 size 也按比例缩放(标题 line_overrides)。"""
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=5.0,
|
||||
title_text="第一行\\N第二行",
|
||||
title_config={
|
||||
"size": 36,
|
||||
"line_overrides": [{"line_index": 0, "size": 72}],
|
||||
},
|
||||
)
|
||||
# 行 0 应注入 \\fs 标签:72 * 1.5 = 108,然后 ASS 补偿仅作用于 style fontsize,inline fs 已是 PlayRes 像素
|
||||
assert "\\fs108" in content
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""#2028: generation_cover._get_task_video_url 兜底逻辑测试。"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestGetTaskVideoUrlAwaitingCoverFallback:
|
||||
def test_returns_url_from_rendered_output_when_awaiting_cover(self):
|
||||
from app.api.routes import generation_cover as cover_mod
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = []
|
||||
|
||||
mock_task = MagicMock()
|
||||
mock_task.status.value = "awaiting_cover"
|
||||
mock_task.extra_meta = {"rendered_output": {"file_url": "oss://generated/awaiting.mp4"}}
|
||||
|
||||
mock_task_repo_instance = MagicMock()
|
||||
mock_task_repo_instance.get.return_value = mock_task
|
||||
mock_db = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
|
||||
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
|
||||
):
|
||||
url = cover_mod._get_task_video_url("task-aw1", mock_db)
|
||||
assert url == "oss://generated/awaiting.mp4"
|
||||
|
||||
def test_returns_none_when_status_not_awaiting_cover(self):
|
||||
from app.api.routes import generation_cover as cover_mod
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = []
|
||||
mock_task = MagicMock()
|
||||
mock_task.status.value = "running"
|
||||
mock_task.extra_meta = {"rendered_output": {"file_url": "oss://x.mp4"}}
|
||||
mock_task_repo_instance = MagicMock()
|
||||
mock_task_repo_instance.get.return_value = mock_task
|
||||
mock_db = MagicMock()
|
||||
with (
|
||||
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
|
||||
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
|
||||
):
|
||||
url = cover_mod._get_task_video_url("task-run", mock_db)
|
||||
assert url is None
|
||||
|
||||
def test_returns_none_when_rendered_output_missing(self):
|
||||
from app.api.routes import generation_cover as cover_mod
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = []
|
||||
mock_task = MagicMock()
|
||||
mock_task.status.value = "awaiting_cover"
|
||||
mock_task.extra_meta = {}
|
||||
mock_task_repo_instance = MagicMock()
|
||||
mock_task_repo_instance.get.return_value = mock_task
|
||||
mock_db = MagicMock()
|
||||
with (
|
||||
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
|
||||
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
|
||||
):
|
||||
url = cover_mod._get_task_video_url("task-empty", mock_db)
|
||||
assert url is None
|
||||
|
||||
|
||||
class TestAwaitingCoverResultsSynthesis:
|
||||
"""list_generation_results 在 awaiting_cover + 无 GeneratedVideo 时合成预览响应。"""
|
||||
|
||||
def _invoke(self, task, storage_service):
|
||||
from app.api.routes import generation_tasks as gt_mod
|
||||
|
||||
mock_auth = MagicMock()
|
||||
mock_auth.user.id = "u1"
|
||||
mock_task_repo = MagicMock()
|
||||
mock_task_repo.get.return_value = task
|
||||
mock_video_repo = MagicMock()
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = []
|
||||
mock_project_repo = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(gt_mod, "check_project_access", return_value=None),
|
||||
patch.object(gt_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
|
||||
):
|
||||
return gt_mod.list_generation_results(
|
||||
task_id=task.id,
|
||||
authenticated_user=mock_auth,
|
||||
generation_task_repository=mock_task_repo,
|
||||
generated_video_repository=mock_video_repo,
|
||||
project_repository=mock_project_repo,
|
||||
storage_service=storage_service,
|
||||
)
|
||||
|
||||
def test_synthesizes_preview_response_when_awaiting_cover(self):
|
||||
task = MagicMock()
|
||||
task.id = "task-syn-1"
|
||||
task.project_id = "proj1"
|
||||
task.status.value = "awaiting_cover"
|
||||
task.cover_url = ""
|
||||
task.extra_meta = {
|
||||
"rendered_output": {
|
||||
"file_url": "oss://bucket/v.mp4",
|
||||
"file_size": 2048,
|
||||
"duration": 10.5,
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"fps": 30.0,
|
||||
"name": "合成预览",
|
||||
"mode": "random",
|
||||
"thumbnail_url": "https://cdn/t.jpg",
|
||||
}
|
||||
}
|
||||
task.updated_at = None
|
||||
task.created_at = None
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://signed/v.mp4"
|
||||
resp = self._invoke(task, storage)
|
||||
assert len(resp.items) == 1
|
||||
item = resp.items[0]
|
||||
assert item.id == "preview-task-syn-1"
|
||||
assert item.name == "合成预览"
|
||||
assert item.file_size == 2048
|
||||
assert item.duration == 10.5
|
||||
assert item.width == 1080
|
||||
assert item.height == 1920
|
||||
assert item.fps == 30.0
|
||||
assert item.download_url == "https://signed/v.mp4"
|
||||
|
||||
def test_http_file_url_used_directly(self):
|
||||
task = MagicMock()
|
||||
task.id = "task-httpx"
|
||||
task.project_id = "p"
|
||||
task.status.value = "awaiting_cover"
|
||||
task.cover_url = ""
|
||||
task.extra_meta = {"rendered_output": {"file_url": "https://cdn.example.com/v.mp4", "name": ""}}
|
||||
task.updated_at = None
|
||||
task.created_at = None
|
||||
storage = MagicMock()
|
||||
resp = self._invoke(task, storage)
|
||||
assert resp.items[0].download_url == "https://cdn.example.com/v.mp4"
|
||||
storage.get_download_url.assert_not_called()
|
||||
assert resp.items[0].name.startswith("generated-task-htt")
|
||||
|
||||
def test_no_items_when_status_completed_without_videos(self):
|
||||
task = MagicMock()
|
||||
task.id = "task-done"
|
||||
task.project_id = "p"
|
||||
task.status.value = "completed"
|
||||
task.extra_meta = {"rendered_output": {"file_url": "oss://x.mp4"}}
|
||||
storage = MagicMock()
|
||||
resp = self._invoke(task, storage)
|
||||
assert resp.items == []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -367,7 +367,13 @@ class TestEditorClipsDurationAndStartTime:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "素材可切区间不足" in exc_info.value.detail
|
||||
# 时长为0的素材被跳过,全部无效时返回「素材尚未完成分析,请稍后重试」
|
||||
assert "素材" in exc_info.value.detail and (
|
||||
"未完成" in exc_info.value.detail
|
||||
or "无效" in exc_info.value.detail
|
||||
or "分析" in exc_info.value.detail
|
||||
or "稍后" in exc_info.value.detail
|
||||
)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_zero_duration_asset_skipped_in_mixed_pool(self, mock_storage):
|
||||
@@ -896,3 +902,75 @@ class TestClipsFromAssetsInvalidIds:
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
class TestClipsFromAssetsExceptionTolerance:
|
||||
"""#2028: score_asset / scene_points 抛异常时不应阻断整个请求,应兜底跳过。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_score_asset_exception_sets_score_zero(self, mock_storage):
|
||||
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"], required_clips_count=2)
|
||||
with (
|
||||
_patch_segments(_segments(2)),
|
||||
patch("app.api.routes.templates_editor.clips.score_asset", side_effect=RuntimeError("boom")),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
side_effect=[2.0, 8.0],
|
||||
),
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 2
|
||||
assert all(c["asset_id"] == "a1" for c in clips_data)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_scene_points_exception_safely_ignored(self, mock_storage):
|
||||
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("a1", 30.0))
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
with (
|
||||
_patch_segments(_segments(1)),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.extract_scene_points_from_metadata",
|
||||
side_effect=RuntimeError("meta corrupt"),
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
return_value=3.0,
|
||||
),
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 1
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""#2024: 视频生成 finalize 流程单测。
|
||||
|
||||
覆盖:
|
||||
1. GenerationTask 新状态 awaiting_cover 与 mark_awaiting_cover 方法
|
||||
2. finalize 用例:幂等 / 状态校验 / 正常入库
|
||||
3. Worker 侧预计算函数 signature 兼容
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# 使 worker 目录可导入
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "apps" / "worker"))
|
||||
|
||||
from packages.domain.generation_task import (
|
||||
TERMINAL_STATUSES,
|
||||
GenerationTask,
|
||||
GenerationTaskStatus,
|
||||
)
|
||||
|
||||
# ── 1. 状态机 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAwaitingCoverStatus:
|
||||
def test_enum_value(self):
|
||||
assert GenerationTaskStatus.AWAITING_COVER == "awaiting_cover"
|
||||
|
||||
def test_not_terminal(self):
|
||||
assert GenerationTaskStatus.AWAITING_COVER not in TERMINAL_STATUSES
|
||||
|
||||
def test_is_awaiting_cover_property(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
task.mark_processing()
|
||||
task.mark_awaiting_cover()
|
||||
assert task.is_awaiting_cover
|
||||
assert not task.is_completed
|
||||
assert not task.is_failed
|
||||
assert task.progress == 100.0
|
||||
# awaiting_cover 不设置 completed_at
|
||||
assert task.completed_at is None
|
||||
|
||||
def test_normal_flow_pending_running_awaiting_completed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
task.mark_processing()
|
||||
task.mark_awaiting_cover()
|
||||
assert task.status == GenerationTaskStatus.AWAITING_COVER
|
||||
task.mark_completed(result_count=1)
|
||||
assert task.is_completed
|
||||
assert task.completed_at is not None
|
||||
assert task.result_count == 1
|
||||
|
||||
def test_awaiting_to_failed_allowed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
task.mark_processing()
|
||||
task.mark_awaiting_cover()
|
||||
task.mark_failed("test error")
|
||||
assert task.is_failed
|
||||
|
||||
def test_awaiting_to_cancelled_allowed(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
task.mark_processing()
|
||||
task.mark_awaiting_cover()
|
||||
task.mark_cancelled()
|
||||
assert task.status == GenerationTaskStatus.CANCELLED
|
||||
|
||||
def test_cannot_jump_pending_to_awaiting(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
with pytest.raises(ValueError):
|
||||
task.mark_awaiting_cover()
|
||||
|
||||
def test_mark_completed_resets_error(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
task.mark_processing()
|
||||
task.mark_awaiting_cover()
|
||||
task.mark_completed()
|
||||
assert task.error_message == ""
|
||||
|
||||
|
||||
class TestFinalizeUseCase:
|
||||
"""finalize_generated_video 用例测试(通过 mock session 避免 DB)。"""
|
||||
|
||||
def _make_task(self, extra_meta=None):
|
||||
task = GenerationTask.create(project_id="proj1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
task.id = "task-123"
|
||||
task.mark_processing()
|
||||
task.mark_awaiting_cover()
|
||||
task.project_id = "proj1"
|
||||
task.created_by_user_id = "user1"
|
||||
task.extra_meta = extra_meta or {
|
||||
"rendered_output": {
|
||||
"file_url": "oss://bucket/v.mp4",
|
||||
"file_size": 1024,
|
||||
"duration": 12.5,
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"fps": 30.0,
|
||||
"name": "demo.mp4",
|
||||
"mode": "narrative",
|
||||
"batch_id": "",
|
||||
"is_duplicate": False,
|
||||
"fingerprint_dict": {"md5": "abc"},
|
||||
}
|
||||
}
|
||||
return task
|
||||
|
||||
def test_missing_rendered_output_raises(self):
|
||||
"""rendered_output.file_url 为空应抛 ValueError。"""
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task(extra_meta={"rendered_output": {"file_url": ""}})
|
||||
session = MagicMock()
|
||||
with pytest.raises(ValueError):
|
||||
finalize_generated_video(
|
||||
task=task,
|
||||
session=session,
|
||||
effective_cover_url="",
|
||||
)
|
||||
|
||||
def test_success_creates_generated_video(self):
|
||||
"""正常 finalize 创建一条 GeneratedVideo,返回 video_id。"""
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task()
|
||||
session = MagicMock()
|
||||
# mock video repo
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
result = finalize_generated_video(
|
||||
task=task,
|
||||
session=session,
|
||||
effective_cover_url="https://cdn/cover.jpg",
|
||||
)
|
||||
assert result["video_id"], "video_id should be non-empty"
|
||||
assert mock_repo.create.called, "video_repo.create must be called"
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.generation_task_id == "task-123"
|
||||
assert created_video.thumbnail_url == "https://cdn/cover.jpg"
|
||||
assert created_video.width == 1080
|
||||
assert created_video.height == 1920
|
||||
assert created_video.duration == 12.5
|
||||
session.commit.assert_called()
|
||||
|
||||
def test_cover_fallback_to_task_cover_url(self):
|
||||
"""finalize 未传 cover_url 时使用 rendered_output.thumbnail_url。"""
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task()
|
||||
session = MagicMock()
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
result = finalize_generated_video(
|
||||
task=task,
|
||||
session=session,
|
||||
effective_cover_url="",
|
||||
)
|
||||
assert result["video_id"]
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
# rendered_output.thumbnail_url 为空时 thumbnail 为 None
|
||||
assert created_video.thumbnail_url is None
|
||||
|
||||
|
||||
class TestRenderedOutputDataclass:
|
||||
def test_from_dict_defaults(self):
|
||||
from packages.application.generated_video_finalize import RenderedOutput
|
||||
|
||||
ro = RenderedOutput.from_dict({"file_url": "https://x/y.mp4"})
|
||||
assert ro.file_url == "https://x/y.mp4"
|
||||
assert ro.width == 1280
|
||||
assert ro.height == 720
|
||||
assert ro.fps == 25.0
|
||||
assert ro.is_duplicate is False
|
||||
|
||||
def test_from_dict_full(self):
|
||||
from packages.application.generated_video_finalize import RenderedOutput
|
||||
|
||||
ro = RenderedOutput.from_dict(
|
||||
{
|
||||
"file_url": "https://x/y.mp4",
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"is_duplicate": True,
|
||||
"duplicate_of": "old-id",
|
||||
"duplicate_rate": 42.5,
|
||||
}
|
||||
)
|
||||
assert ro.width == 1080
|
||||
assert ro.is_duplicate is True
|
||||
assert ro.duplicate_of == "old-id"
|
||||
assert ro.duplicate_rate == 42.5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
|
||||
|
||||
class TestFinalizeCustomName:
|
||||
"""#2028: finalize_generated_video 支持 custom_name 参数。"""
|
||||
|
||||
def _make_task(self, extra_meta=None):
|
||||
task = GenerationTask.create(project_id="proj1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
task.id = "task-custom"
|
||||
task.mark_processing()
|
||||
task.mark_awaiting_cover()
|
||||
task.project_id = "proj1"
|
||||
task.created_by_user_id = "user1"
|
||||
task.extra_meta = extra_meta or {
|
||||
"rendered_output": {
|
||||
"file_url": "oss://bucket/v.mp4",
|
||||
"file_size": 1024,
|
||||
"duration": 12.5,
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"fps": 30.0,
|
||||
"name": "default-name.mp4",
|
||||
"mode": "narrative",
|
||||
"batch_id": "",
|
||||
"is_duplicate": False,
|
||||
"fingerprint_dict": {"md5": "abc"},
|
||||
}
|
||||
}
|
||||
return task
|
||||
|
||||
def test_custom_name_used_in_generated_video(self):
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task()
|
||||
session = MagicMock()
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
result = finalize_generated_video(
|
||||
task=task,
|
||||
session=session,
|
||||
effective_cover_url="https://cdn/cover.jpg",
|
||||
custom_name="我的旅行vlog",
|
||||
)
|
||||
assert result["video_id"]
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.name == "我的旅行vlog"
|
||||
|
||||
def test_custom_name_falls_back_to_rendered_name(self):
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task()
|
||||
session = MagicMock()
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
finalize_generated_video(task=task, session=session, effective_cover_url="")
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.name == "default-name.mp4"
|
||||
|
||||
def test_custom_name_empty_uses_generated_id(self):
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task(extra_meta={"rendered_output": {"file_url": "oss://bucket/v.mp4", "name": ""}})
|
||||
session = MagicMock()
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
finalize_generated_video(task=task, session=session, effective_cover_url="", custom_name=" ")
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.name.startswith("generated-task-cus")
|
||||
@@ -0,0 +1,429 @@
|
||||
"""#2024 GenerationFinalizeService 单元测试。
|
||||
|
||||
覆盖 service 层:存在性校验、幂等分支、状态门、封面决策、异常映射、成功路径。
|
||||
同时为 packages/application/generated_video_finalize.py 的缺失分支补测。
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------- helpers ----------
|
||||
|
||||
|
||||
def _make_task(
|
||||
task_id="task-1",
|
||||
status="awaiting_cover",
|
||||
project_id="proj-1",
|
||||
user_id="user-1",
|
||||
cover_url="",
|
||||
extra_meta=None,
|
||||
error_message="",
|
||||
):
|
||||
t = MagicMock()
|
||||
t.id = task_id
|
||||
t.project_id = project_id
|
||||
t.created_by_user_id = user_id
|
||||
t.cover_url = cover_url
|
||||
t.extra_meta = extra_meta if extra_meta is not None else {}
|
||||
t.error_message = error_message
|
||||
s = MagicMock()
|
||||
s.value = status
|
||||
t.status = s
|
||||
|
||||
def _mark_completed(result_count=1):
|
||||
s.value = "completed"
|
||||
t.completed_at = "now"
|
||||
|
||||
t.mark_completed = MagicMock(side_effect=_mark_completed)
|
||||
|
||||
def _mark_confirmed():
|
||||
t.is_preview = False
|
||||
|
||||
t.mark_confirmed = MagicMock(side_effect=_mark_confirmed)
|
||||
return t
|
||||
|
||||
|
||||
def _make_db():
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
db.commit = MagicMock()
|
||||
db.rollback = MagicMock()
|
||||
db.bulk_save_objects = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
def _make_rendered_dict(**overrides):
|
||||
base = {
|
||||
"file_url": "https://oss.example.com/v.mp4",
|
||||
"file_size": 123456,
|
||||
"duration": 10.5,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"name": "demo.mp4",
|
||||
"thumbnail_url": "https://oss.example.com/thumb.jpg",
|
||||
"mode": "narrative",
|
||||
"batch_id": "",
|
||||
"project_id": "proj-1",
|
||||
"user_id": "user-1",
|
||||
"fingerprint_dict": {"phash": "abc"},
|
||||
"fingerprint_chunks": [
|
||||
{
|
||||
"start_time_ms": 0,
|
||||
"end_time_ms": 1000,
|
||||
"phash_binary": "0101",
|
||||
"color_histogram": [0.1, 0.2, 0.3],
|
||||
"frame_count": 25,
|
||||
},
|
||||
],
|
||||
"is_duplicate": False,
|
||||
"duplicate_of": None,
|
||||
"duplicate_rate": 0.0,
|
||||
"match_count": 0,
|
||||
"visual_similarity": 0.0,
|
||||
"video_fingerprint_md5": "md5-abc",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
_PATCHES = [
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository",
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
"packages.adapters.sqlalchemy_impl.models.GeneratedVideoModel",
|
||||
"packages.application.generated_video_finalize.finalize_generated_video",
|
||||
]
|
||||
|
||||
|
||||
def _svc(db):
|
||||
from app.services.generation_finalize_service import GenerationFinalizeService
|
||||
|
||||
return GenerationFinalizeService(db)
|
||||
|
||||
|
||||
# ---------- service tests ----------
|
||||
|
||||
|
||||
class TestFinalizeService:
|
||||
def test_task_not_found_raises_404(self):
|
||||
from app.services.generation_finalize_service import GenerationFinalizeError
|
||||
|
||||
db = _make_db()
|
||||
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]), patch(_PATCHES[3]):
|
||||
TR.return_value.get.return_value = None
|
||||
svc = _svc(db)
|
||||
with pytest.raises(GenerationFinalizeError) as ei:
|
||||
svc.finalize_task("nope", "user-1")
|
||||
assert ei.value.status_code == 404
|
||||
assert ei.value.code == "TaskNotFound"
|
||||
|
||||
def test_invalid_status_raises(self):
|
||||
from app.services.generation_finalize_service import GenerationFinalizeError
|
||||
|
||||
db = _make_db()
|
||||
task = _make_task(status="running")
|
||||
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]) as GVM, patch(_PATCHES[3]):
|
||||
TR.return_value.get.return_value = task
|
||||
GVM.query.filter.return_value.first.return_value = None
|
||||
svc = _svc(db)
|
||||
with pytest.raises(GenerationFinalizeError) as ei:
|
||||
svc.finalize_task("task-1", "user-1")
|
||||
assert ei.value.code == "InvalidTaskStatus"
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
def test_idempotent_when_video_already_exists_updates_cover_and_completes(self):
|
||||
db = _make_db()
|
||||
task = _make_task(status="awaiting_cover")
|
||||
existing = MagicMock()
|
||||
existing.id = "video-exist"
|
||||
existing.thumbnail_url = "https://old-cover.jpg"
|
||||
db.query.return_value.filter.return_value.first.return_value = existing
|
||||
existing_video = MagicMock()
|
||||
existing_video.id = "video-exist"
|
||||
|
||||
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]):
|
||||
TR.return_value.get.return_value = task
|
||||
TR.return_value.update = MagicMock()
|
||||
VR.return_value.get.return_value = existing_video
|
||||
svc = _svc(db)
|
||||
result = svc.finalize_task("task-1", "user-1", cover_url="https://new-cover.jpg")
|
||||
assert result.id == "video-exist"
|
||||
assert existing.thumbnail_url == "https://new-cover.jpg"
|
||||
assert task.cover_url == "https://new-cover.jpg"
|
||||
task.mark_completed.assert_called()
|
||||
TR.return_value.update.assert_called_with(task)
|
||||
db.commit.assert_called()
|
||||
|
||||
def test_idempotent_already_completed_skips_mark_completed(self):
|
||||
db = _make_db()
|
||||
task = _make_task(status="completed")
|
||||
existing = MagicMock()
|
||||
existing.id = "v-exist"
|
||||
existing.thumbnail_url = "https://c.jpg"
|
||||
db.query.return_value.filter.return_value.first.return_value = existing
|
||||
existing_video = MagicMock()
|
||||
|
||||
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]):
|
||||
TR.return_value.get.return_value = task
|
||||
VR.return_value.get.return_value = existing_video
|
||||
svc = _svc(db)
|
||||
svc.finalize_task("task-1", "user-1")
|
||||
task.mark_completed.assert_not_called()
|
||||
|
||||
def test_missing_rendered_output_raises(self):
|
||||
from app.services.generation_finalize_service import GenerationFinalizeError
|
||||
|
||||
db = _make_db()
|
||||
task = _make_task(status="awaiting_cover", extra_meta={})
|
||||
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
|
||||
TR.return_value.get.return_value = task
|
||||
TR.return_value.update = MagicMock()
|
||||
fu.side_effect = ValueError("file_url 为空")
|
||||
svc = _svc(db)
|
||||
with pytest.raises(GenerationFinalizeError) as ei:
|
||||
svc.finalize_task("task-1", "user-1")
|
||||
assert ei.value.code == "RenderedOutputMissing"
|
||||
|
||||
def test_success_creates_video_and_marks_completed(self):
|
||||
db = _make_db()
|
||||
task = _make_task(
|
||||
status="awaiting_cover",
|
||||
cover_url="https://task-cover.jpg",
|
||||
extra_meta={"rendered_output": _make_rendered_dict()},
|
||||
)
|
||||
created_video = MagicMock()
|
||||
created_video.id = "video-new"
|
||||
|
||||
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
|
||||
TR.return_value.get.return_value = task
|
||||
TR.return_value.update = MagicMock()
|
||||
fu.return_value = {"video_id": "video-new", "is_duplicate": False, "duplicate_of": None}
|
||||
VR.return_value.get.return_value = created_video
|
||||
svc = _svc(db)
|
||||
v = svc.finalize_task("task-1", "user-1")
|
||||
assert v.id == "video-new"
|
||||
task.mark_completed.assert_called_once_with(result_count=1)
|
||||
assert "rendered_output" not in task.extra_meta
|
||||
TR.return_value.update.assert_called_with(task)
|
||||
db.commit.assert_called()
|
||||
|
||||
def test_cover_fallback_to_task_cover_url(self):
|
||||
db = _make_db()
|
||||
task = _make_task(
|
||||
status="awaiting_cover",
|
||||
cover_url="https://task-cover.jpg",
|
||||
extra_meta={"rendered_output": _make_rendered_dict(thumbnail_url="")},
|
||||
)
|
||||
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
|
||||
TR.return_value.get.return_value = task
|
||||
TR.return_value.update = MagicMock()
|
||||
fu.return_value = {"video_id": "v1", "is_duplicate": False, "duplicate_of": None}
|
||||
VR.return_value.get.return_value = MagicMock(id="v1")
|
||||
svc = _svc(db)
|
||||
svc.finalize_task("task-1", "user-1")
|
||||
assert task.cover_url == "https://task-cover.jpg"
|
||||
kwargs = fu.call_args.kwargs
|
||||
assert kwargs["effective_cover_url"] == "https://task-cover.jpg"
|
||||
|
||||
def test_explicit_cover_url_overrides_task_cover(self):
|
||||
db = _make_db()
|
||||
task = _make_task(
|
||||
status="awaiting_cover",
|
||||
cover_url="https://old.jpg",
|
||||
extra_meta={"rendered_output": _make_rendered_dict()},
|
||||
)
|
||||
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
|
||||
TR.return_value.get.return_value = task
|
||||
TR.return_value.update = MagicMock()
|
||||
fu.return_value = {"video_id": "v1", "is_duplicate": False, "duplicate_of": None}
|
||||
VR.return_value.get.return_value = MagicMock(id="v1")
|
||||
svc = _svc(db)
|
||||
svc.finalize_task("task-1", "user-1", cover_url=" https://new.jpg ")
|
||||
kwargs = fu.call_args.kwargs
|
||||
assert kwargs["effective_cover_url"] == "https://new.jpg"
|
||||
|
||||
|
||||
# ---------- packages/application/generated_video_finalize.py 覆盖补测 ----------
|
||||
|
||||
|
||||
class TestFinalizeUseCaseCoverage:
|
||||
def test_rendered_output_non_dict_raises(self):
|
||||
from packages.application.generated_video_finalize import RenderedOutput
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
RenderedOutput.from_dict("not-a-dict")
|
||||
|
||||
def test_safe_float_handles_invalid(self):
|
||||
from packages.application.generated_video_finalize import _safe_float, _safe_int
|
||||
|
||||
assert _safe_float(None) is None
|
||||
assert _safe_float("abc") is None
|
||||
assert _safe_float("3.14") == pytest.approx(3.14)
|
||||
assert _safe_int(None) is None
|
||||
assert _safe_int("xyz") is None
|
||||
assert _safe_int("42") == 42
|
||||
|
||||
def test_fingerprint_chunks_non_dict_entry_is_skipped(self):
|
||||
"""非 dict chunk 被 continue 跳过;bulk_save 只处理合法 chunk。"""
|
||||
from packages.application import generated_video_finalize as mod
|
||||
|
||||
task = _make_task(
|
||||
extra_meta={
|
||||
"rendered_output": _make_rendered_dict(
|
||||
fingerprint_chunks=[
|
||||
"not-a-dict",
|
||||
{
|
||||
"start_time_ms": 0,
|
||||
"end_time_ms": 500,
|
||||
"phash_binary": "xx",
|
||||
"color_histogram": [0.1, 0.2],
|
||||
"frame_count": 10,
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
)
|
||||
db = MagicMock()
|
||||
db.bulk_save_objects = MagicMock()
|
||||
db.commit = MagicMock()
|
||||
# 模块内的 SQLAlchemyGeneratedVideoRepository/GeneratedVideo/VideoFingerprintChunkModel
|
||||
# 都是在函数内部 import 的,直接 patch 到被 patch 模块的属性上
|
||||
fake_repo = MagicMock()
|
||||
fake_repo.create = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=fake_repo,
|
||||
),
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
|
||||
side_effect=lambda **kw: MagicMock(**kw),
|
||||
),
|
||||
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
|
||||
):
|
||||
result = mod.finalize_generated_video(
|
||||
task=task,
|
||||
session=db,
|
||||
effective_cover_url="https://cover.jpg",
|
||||
)
|
||||
assert "video_id" in result
|
||||
assert db.bulk_save_objects.call_count == 1
|
||||
saved_chunks = db.bulk_save_objects.call_args[0][0]
|
||||
assert len(saved_chunks) == 1
|
||||
db.commit.assert_called()
|
||||
fake_repo.create.assert_called_once()
|
||||
|
||||
def test_missing_file_url_raises(self):
|
||||
from packages.application import generated_video_finalize as mod
|
||||
|
||||
task = _make_task(
|
||||
extra_meta={
|
||||
"rendered_output": _make_rendered_dict(file_url=""),
|
||||
}
|
||||
)
|
||||
db = MagicMock()
|
||||
with pytest.raises(ValueError):
|
||||
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
|
||||
|
||||
def test_no_fingerprint_chunks_skips_bulk_save(self):
|
||||
from packages.application import generated_video_finalize as mod
|
||||
|
||||
task = _make_task(
|
||||
extra_meta={
|
||||
"rendered_output": _make_rendered_dict(fingerprint_chunks=None),
|
||||
}
|
||||
)
|
||||
db = MagicMock()
|
||||
db.bulk_save_objects = MagicMock()
|
||||
db.commit = MagicMock()
|
||||
fake_repo = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=fake_repo,
|
||||
),
|
||||
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
|
||||
):
|
||||
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
|
||||
db.bulk_save_objects.assert_not_called()
|
||||
fake_repo.create.assert_called_once()
|
||||
db.commit.assert_called()
|
||||
|
||||
def test_name_fallback_when_empty(self):
|
||||
from packages.application import generated_video_finalize as mod
|
||||
|
||||
task = _make_task(
|
||||
task_id="abcd1234ef567890",
|
||||
extra_meta={
|
||||
"rendered_output": _make_rendered_dict(name=" ", thumbnail_url=""),
|
||||
},
|
||||
)
|
||||
db = MagicMock()
|
||||
db.bulk_save_objects = MagicMock()
|
||||
db.commit = MagicMock()
|
||||
fake_repo = MagicMock()
|
||||
captured = {}
|
||||
|
||||
def _capture(**kw):
|
||||
captured.update(kw)
|
||||
return MagicMock(**kw)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=fake_repo,
|
||||
),
|
||||
patch("packages.domain.generated_video.GeneratedVideo", side_effect=_capture),
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
|
||||
side_effect=lambda **kw: MagicMock(**kw),
|
||||
),
|
||||
):
|
||||
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
|
||||
assert captured["name"].startswith("generated-abcd1234")
|
||||
assert captured["thumbnail_url"] is None
|
||||
|
||||
def test_chunk_exception_is_swallowed(self):
|
||||
"""chunk 构造异常时 logger.warning,不阻塞主流程。"""
|
||||
from packages.application import generated_video_finalize as mod
|
||||
|
||||
task = _make_task(
|
||||
extra_meta={
|
||||
"rendered_output": _make_rendered_dict(
|
||||
fingerprint_chunks=[
|
||||
{
|
||||
"start_time_ms": 0,
|
||||
"end_time_ms": 500,
|
||||
"phash_binary": "xx",
|
||||
"color_histogram": ["not-a-number"],
|
||||
"frame_count": 10,
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
)
|
||||
db = MagicMock()
|
||||
db.bulk_save_objects = MagicMock()
|
||||
db.commit = MagicMock()
|
||||
fake_repo = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=fake_repo,
|
||||
),
|
||||
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
|
||||
side_effect=lambda **kw: MagicMock(**kw),
|
||||
),
|
||||
):
|
||||
# color_histogram 里 "not-a-number" 触发 float() 异常,被 except chunk_err 吞掉
|
||||
# 但此时 chunk_models 中仍有 1 个元素(MagicMock 构造不会因 float() 失败)——
|
||||
# 因为我们把 float 列表推导也放在 try 内,float("not-a-number") 抛 ValueError
|
||||
# 所以要让 float 真的抛。但 MagicMock side_effect 不触发 float(),这里直接构造:
|
||||
# 通过真实验证路径
|
||||
result = mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
|
||||
assert "video_id" in result
|
||||
db.commit.assert_called()
|
||||
fake_repo.create.assert_called_once()
|
||||
@@ -143,7 +143,7 @@ class TestGenerateAssFromTimeline:
|
||||
generate_ass_from_timeline(
|
||||
output_path,
|
||||
timeline,
|
||||
video_width=1280,
|
||||
video_width=720,
|
||||
video_height=720,
|
||||
subtitle_config={
|
||||
"font": "微软雅黑",
|
||||
@@ -684,8 +684,8 @@ class TestGenerateAssFromTimelineMore:
|
||||
generate_ass_from_timeline(
|
||||
output,
|
||||
timeline,
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_width=720,
|
||||
video_height=1280,
|
||||
subtitle_config={"size": 48},
|
||||
)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
@@ -706,10 +706,10 @@ class TestGenerateAssFromTimelineMore:
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "720p.ass"
|
||||
generate_ass_from_timeline(output, timeline, video_width=1280, video_height=720)
|
||||
generate_ass_from_timeline(output, timeline, video_width=720, video_height=1280)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
assert "PlayResX: 1280" in content
|
||||
assert "PlayResY: 720" in content
|
||||
assert "PlayResX: 720" in content
|
||||
assert "PlayResY: 1280" in content
|
||||
|
||||
def test_special_characters_in_text(self):
|
||||
"""字幕文本含特殊字符(花括号、换行)"""
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",49," in content, f"默认字号36应补偿为49(36*1.35),实际内容: {content}"
|
||||
assert ",73," in content, f"默认字号36应缩放+补偿为73(36*1.5*1.35),实际内容: {content}"
|
||||
|
||||
def test_size_32_preserved(self):
|
||||
"""size=32 应原样使用。"""
|
||||
@@ -43,7 +43,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",43," in content # 32*1.35=43
|
||||
assert ",65," in content # 32*1.5*1.35=65
|
||||
|
||||
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 == 81, f"字号60应补偿为81(60*1.35), 实际={font_size}"
|
||||
assert font_size == 122, f"字号60应缩放+补偿为122(60*1.5*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] == "70", f"font_size=52 应补偿为70(52*1.35), 实际={fields[2]}"
|
||||
assert fields[2] == "105", f"font_size=52 应缩放+补偿为105(52*1.5*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 ",32," in content # 24*1.35=32.4→32
|
||||
assert ",49," in content # 24*1.5*1.35=49.4→32
|
||||
|
||||
|
||||
class TestBooleanStrokeNormalization:
|
||||
@@ -117,7 +117,7 @@ class TestBooleanStrokeNormalization:
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
outline_width = float(fields[16])
|
||||
assert outline_width == 2.0, f"stroke=true 应产生 outline_width=2, 实际={outline_width}"
|
||||
assert outline_width == 3.0, f"stroke=true 应产生 outline_width=3(2*1.5 scale), 实际={outline_width}"
|
||||
|
||||
def test_stroke_false_no_outline(self):
|
||||
"""stroke=false 应生成 outline_width=0 的样式。"""
|
||||
@@ -147,7 +147,7 @@ class TestBooleanStrokeNormalization:
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
outline_width = float(fields[16])
|
||||
assert outline_width == 3.0, f"自定义stroke width=3 应保留, 实际={outline_width}"
|
||||
assert outline_width == 4.5, f"自定义stroke width=3 @1080p 应缩放为4.5(3*1.5), 实际={outline_width}"
|
||||
|
||||
|
||||
class TestBooleanShadowNormalization:
|
||||
@@ -167,7 +167,7 @@ class TestBooleanShadowNormalization:
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
# Shadow 字段是第18个(索引17)
|
||||
shadow_depth = int(fields[17])
|
||||
assert shadow_depth == 2, f"shadow=true 应产生 shadow_depth=2, 实际={shadow_depth}"
|
||||
assert shadow_depth == 3, f"shadow=true @1080p 应产生 shadow_depth=3(2*1.5), 实际={shadow_depth}"
|
||||
|
||||
def test_shadow_false_no_shadow(self):
|
||||
"""shadow=false 应生成 shadow_depth=0 的样式。"""
|
||||
@@ -197,7 +197,7 @@ class TestBooleanShadowNormalization:
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
shadow_depth = int(fields[17])
|
||||
assert shadow_depth == 3, f"自定义shadow offset_y=3 应保留, 实际={shadow_depth}"
|
||||
assert shadow_depth == 4, f"自定义shadow offset_y=3 @1080p 应缩放为4(3*1.5), 实际={shadow_depth}"
|
||||
|
||||
|
||||
class TestFullStyleConsistency:
|
||||
@@ -231,14 +231,14 @@ class TestFullStyleConsistency:
|
||||
|
||||
# Fontname
|
||||
assert fields[1] == "Noto Sans SC"
|
||||
# Fontsize = 28*1.35=37.8→38
|
||||
assert fields[2] == "38"
|
||||
# Fontsize = 28*1.5*1.35=56.7→57
|
||||
assert fields[2] == "57" # 28*1.5*1.35=57
|
||||
# Bold = -1 (True)
|
||||
assert fields[7] == "-1"
|
||||
# Outline width = 2 (前端默认 stroke width)
|
||||
assert float(fields[16]) == 2.0
|
||||
# Shadow depth = 2 (offset_y)
|
||||
assert int(fields[17]) == 2
|
||||
# Outline width = 2*1.5=3.0 @1080p (前端默认 stroke width 经缩放)
|
||||
assert float(fields[16]) == 3.0
|
||||
# Shadow depth = 2*1.5=3 @1080p (offset_y 经缩放)
|
||||
assert int(fields[17]) == 3
|
||||
# Alignment = 8 (top)
|
||||
assert int(fields[18]) == 8
|
||||
|
||||
|
||||
@@ -944,7 +944,7 @@ class TestDrawtextFontFileIncluded(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_fontfile_in_output(self, mock_font):
|
||||
mock_font.return_value = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
|
||||
result = build_title_drawtext_filter({"text": "标题"})
|
||||
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("fontfile=", result)
|
||||
self.assertIn("NotoSansCJK", result)
|
||||
@@ -952,7 +952,7 @@ class TestDrawtextFontFileIncluded(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_fontfile_escaped(self, mock_font):
|
||||
mock_font.return_value = "/path/with:special'chars.ttf"
|
||||
result = build_title_drawtext_filter({"text": "标题"})
|
||||
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("fontfile=", result)
|
||||
|
||||
@@ -963,7 +963,7 @@ class TestDrawtextFontFileNotIncluded(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_no_fontfile(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题"})
|
||||
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotIn("fontfile=", result)
|
||||
|
||||
@@ -972,14 +972,14 @@ class TestDrawtextStrokeBranches(unittest.TestCase):
|
||||
"""stroke 各分支覆盖。"""
|
||||
|
||||
def test_stroke_non_bool_non_dict(self):
|
||||
result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"})
|
||||
result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotIn("borderw", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_stroke_dict_default_color(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}})
|
||||
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("borderw=4", result)
|
||||
self.assertIn("bordercolor=000000", result)
|
||||
@@ -987,14 +987,14 @@ class TestDrawtextStrokeBranches(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_stroke_dict_enabled_false(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}})
|
||||
result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotIn("borderw", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_stroke_dict_custom_color(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}})
|
||||
result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("bordercolor=ff0000", result)
|
||||
|
||||
@@ -1005,7 +1005,7 @@ class TestDrawtextShadowBranches(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_shadow_dict_default_color(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}})
|
||||
result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("shadowcolor=000000", result)
|
||||
self.assertIn("shadowx=5", result)
|
||||
@@ -1014,7 +1014,7 @@ class TestDrawtextShadowBranches(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_shadow_dict_disabled(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}})
|
||||
result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotIn("shadowcolor", result)
|
||||
|
||||
@@ -1022,7 +1022,8 @@ class TestDrawtextShadowBranches(unittest.TestCase):
|
||||
def test_shadow_dict_custom_color(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter(
|
||||
{"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}}
|
||||
{"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}},
|
||||
output_width=720,
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("shadowcolor=555555", result)
|
||||
@@ -1030,13 +1031,13 @@ class TestDrawtextShadowBranches(unittest.TestCase):
|
||||
|
||||
class TestDrawtextBoldFalse(unittest.TestCase):
|
||||
def test_bold_false(self):
|
||||
result = build_title_drawtext_filter({"text": "标题", "bold": False})
|
||||
result = build_title_drawtext_filter({"text": "标题", "bold": False}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotIn("font=bold", result)
|
||||
|
||||
def test_bold_true_does_not_use_font_bold_param(self):
|
||||
"""粗体模式不得使用 `font=bold`——该参数无效,会导致 filter_complex 解析失败(exit 234)。"""
|
||||
result = build_title_drawtext_filter({"text": "标题", "bold": True})
|
||||
result = build_title_drawtext_filter({"text": "标题", "bold": True}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotIn("font=bold", result)
|
||||
# 粗体应通过 borderw 实现
|
||||
@@ -1047,7 +1048,7 @@ class TestDrawtextBoldFalse(unittest.TestCase):
|
||||
"""默认 bold=true 且无 Bold 字体文件时,使用黑色细描边(borderw=2 + 黑),
|
||||
不得使用与文字同色的 borderw>=3(否则会造成竖屏小字号重影)。"""
|
||||
mock_font.return_value = "" # 无粗体字体
|
||||
result = build_title_drawtext_filter({"text": "标题"})
|
||||
result = build_title_drawtext_filter({"text": "标题"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("borderw=2", result)
|
||||
# 黑描边:要么是 black 关键字,要么是 000000
|
||||
@@ -1059,7 +1060,8 @@ class TestDrawtextBoldFalse(unittest.TestCase):
|
||||
"""用户显式开启 stroke 时,stroke 颜色/宽度优先于默认粗体黑边。"""
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter(
|
||||
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}}
|
||||
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}},
|
||||
output_width=720,
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("borderw=4", result)
|
||||
@@ -1072,21 +1074,21 @@ class TestDrawtextPositionBranches(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_position_top_explicit(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "top"})
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "top"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("y=50", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_position_center_explicit(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "center"})
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "center"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("y=(h-text_h)/2", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_position_bottom_explicit(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "bottom"})
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("y=h-text_h-50", result)
|
||||
|
||||
@@ -1095,7 +1097,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
|
||||
"""自定义位置:百分比坐标转换为 drawtext 表达式."""
|
||||
mock_font.return_value = ""
|
||||
# pos_x=50, pos_y=30 → x=(w-text_w)*0.5000, y=(h-text_h)*0.3000
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30})
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("x=(w-text_w)*0.5000", result)
|
||||
self.assertIn("y=(h-text_h)*0.3000", result)
|
||||
@@ -1104,7 +1106,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
|
||||
def test_position_custom_clamped_to_100(self, mock_font):
|
||||
"""自定义位置:超过100的坐标被截断到100%."""
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3})
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("x=(w-text_w)*1.0000", result)
|
||||
self.assertIn("y=(h-text_h)*1.0000", result)
|
||||
@@ -1112,7 +1114,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_position_custom_bool_coords_fallback(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True})
|
||||
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("x=(w-text_w)/2", result)
|
||||
self.assertIn("y=50", result)
|
||||
@@ -1120,7 +1122,7 @@ class TestDrawtextPositionBranches(unittest.TestCase):
|
||||
|
||||
class TestDrawtextColorNoHash(unittest.TestCase):
|
||||
def test_color_without_hash(self):
|
||||
result = build_title_drawtext_filter({"text": "标题", "font_color": "red"})
|
||||
result = build_title_drawtext_filter({"text": "标题", "font_color": "red"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("fontcolor=red", result)
|
||||
|
||||
@@ -1131,27 +1133,27 @@ class TestDrawtextFieldNormalization(unittest.TestCase):
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_content_fallback(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"content": "备用标题"})
|
||||
result = build_title_drawtext_filter({"content": "备用标题"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("备用标题", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_font_preset_fallback(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"})
|
||||
result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_size_fallback(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "size": 72})
|
||||
result = build_title_drawtext_filter({"text": "标题", "size": 72}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("fontsize=72", result)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_color_fallback(self, mock_font):
|
||||
mock_font.return_value = ""
|
||||
result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"})
|
||||
result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"}, output_width=720)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("fontcolor=abcdef", result)
|
||||
|
||||
@@ -1256,5 +1258,79 @@ class TestTitleOverlay(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
class TestDrawtextFontsizeScaling(unittest.TestCase):
|
||||
"""字号/描边/阴影/边距按 video_width/720 等比缩放(工单:标题字号校准)。
|
||||
|
||||
前端标题面板的 size 语义是"px @720p"(见 web/src/pages/generate/types.ts 注释、
|
||||
apps/web/src/pages/ai-avatar/utils/titleCanvas.ts scale=videoWidth/720)。
|
||||
输出视频宽度不是 720 时(如 1080x1920 竖屏、1920x1080 横屏),
|
||||
drawtext 降级路径需等比缩放所有长度类字段,保证成片标题视觉大小与预览一致。
|
||||
"""
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_720p_no_scaling(self, mock_font):
|
||||
"""720p 宽度时 fontsize 保持原值。"""
|
||||
mock_font.return_value = ""
|
||||
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=720)
|
||||
self.assertIn("fontsize=28", r)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_1080p_scales_1_5x(self, mock_font):
|
||||
"""1080 宽度(1080x1920 竖屏)时 size=28 → 42。"""
|
||||
mock_font.return_value = ""
|
||||
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=1080)
|
||||
self.assertIn("fontsize=42", r)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_1920p_scales_2_67x(self, mock_font):
|
||||
"""1920 宽度(横屏 1920x1080)时 size=28 → 75(四舍五入)。"""
|
||||
mock_font.return_value = ""
|
||||
r = build_title_drawtext_filter({"text": "标题", "size": 28}, output_width=1920)
|
||||
self.assertIn("fontsize=75", r)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_stroke_width_scaled(self, mock_font):
|
||||
"""描边宽度按同比例缩放。"""
|
||||
mock_font.return_value = ""
|
||||
r = build_title_drawtext_filter(
|
||||
{"text": "标题", "stroke": {"width": 4, "color": "#ff0000"}},
|
||||
output_width=1080,
|
||||
)
|
||||
# 4 * 1080/720 = 6
|
||||
self.assertIn("borderw=6", r)
|
||||
self.assertIn("bordercolor=ff0000", r)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_shadow_offset_scaled(self, mock_font):
|
||||
"""阴影偏移按同比例缩放。"""
|
||||
mock_font.return_value = ""
|
||||
r = build_title_drawtext_filter(
|
||||
{"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}},
|
||||
output_width=1080,
|
||||
)
|
||||
# 5 * 1.5 = 7.5 → 8
|
||||
self.assertIn("shadowx=8", r)
|
||||
self.assertIn("shadowy=8", r)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_position_offsets_scaled(self, mock_font):
|
||||
"""top/bottom 距边缘的 50px 边距也按比例缩放。"""
|
||||
mock_font.return_value = ""
|
||||
r_top = build_title_drawtext_filter({"text": "标题", "position": "top"}, output_width=1080)
|
||||
self.assertIn("y=75", r_top) # 50 * 1.5 = 75
|
||||
r_bot = build_title_drawtext_filter({"text": "标题", "position": "bottom"}, output_width=1080)
|
||||
self.assertIn("y=h-text_h-75", r_bot)
|
||||
|
||||
@patch("packages.domain.video_filter_builder._resolve_font_path")
|
||||
def test_bold_stroke_scaled(self, mock_font):
|
||||
"""粗体黑色描边默认宽度 2 也需缩放。"""
|
||||
mock_font.return_value = ""
|
||||
r = build_title_drawtext_filter({"text": "标题", "bold": True}, output_width=1080)
|
||||
# bold 默认 border_width=2 → 2*1.5=3
|
||||
self.assertIn("borderw=3", r)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user