Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61770cd4e4 | |||
| 024aca3557 | |||
| bbf27ec3f6 | |||
| febb1bcfce | |||
| 9fd1246c6b | |||
| 392a20002c | |||
| 42744241c4 | |||
| ab40c57e9e | |||
| b4e3bb0fe7 | |||
| 4de4b8dc08 | |||
| 622e9742f5 | |||
| ab381b2e74 | |||
| 94caa63436 | |||
| fe49fef1ad | |||
| 0b000f96a6 | |||
| 817c6fa6a3 | |||
| 410f672195 | |||
| 06f68230af | |||
| 5a5c653d2c | |||
| 2148e2bc48 | |||
| 9ac21d37f0 | |||
| f6b50b49ec | |||
| 2837f11123 | |||
| ea42e48a9f | |||
| 39683bda09 | |||
| b3aa05e510 | |||
| 005f500ee1 | |||
| a11178eb13 | |||
| 4a6d84921d | |||
| 98cd806d29 | |||
| 6a57431f68 | |||
| d19fa951ee | |||
| e928068efd | |||
| 18371dc438 | |||
| 4e9c9a2eda | |||
| b8a349d5e0 | |||
| 9c02e69ede | |||
| ece1f25f71 | |||
| 2885ede533 | |||
| ac31c88c96 | |||
| 585a3b6887 | |||
| 412aa83b32 | |||
| c6eeca75fb | |||
| df4de37f73 | |||
| 88c07ef142 | |||
| 0996ba45ec | |||
| a34639975c | |||
| 4b68c9be9c | |||
| fbe0a6e826 | |||
| 7f2861e98e | |||
| b1aa866252 | |||
| f484de79c4 | |||
| 9263db56db | |||
| ebe43f163b | |||
| 3e38395f46 | |||
| 9cb20c38ae | |||
| d784af7f67 | |||
| 1b6ecc39f0 | |||
| 92bd1fe201 | |||
| 955613efd3 | |||
| f08a2bfa0d | |||
| 3441b3fece | |||
| b7be479ae7 | |||
| 95c33f6604 | |||
| 161c1a61b6 | |||
| 8b30193f3e | |||
| 665228a58f | |||
| 56d45c2d49 | |||
| ab4a1b7262 | |||
| a39e5ea936 | |||
| 2d0d8a2777 | |||
| d3469d9799 | |||
| 068010b059 | |||
| 231d5153bf | |||
| f4af6ec8b7 | |||
| 6a3f4e4127 | |||
| ec3ba19342 | |||
| 26728dfffb | |||
| cb903e3bd6 | |||
| b447ad84ea | |||
| 2356f12f11 | |||
| 3c42541ff7 | |||
| 85afadd166 | |||
| 634d05c010 | |||
| 355431b867 | |||
| 041cec8670 | |||
| 472f371d2f | |||
| 709d65ce52 | |||
| ae1b9f8ddb | |||
| 1c9903574a | |||
| cfedc06df0 | |||
| 4429784a79 | |||
| aa36e63591 | |||
| c3373de4f3 | |||
| 60d95e64ab | |||
| 7775d5d118 | |||
| f025514d16 | |||
| e043b0aa6a | |||
| fdce08356b | |||
| 89cccf294c | |||
| 5f3ff6bb8c | |||
| ca78b182af |
@@ -0,0 +1,26 @@
|
||||
"""Add title_config to generation_tasks
|
||||
|
||||
Revision ID: 057_title_config
|
||||
Revises: 056_fix_cover_templates_config
|
||||
Create Date: 2026-08-23
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "057_title_config"
|
||||
down_revision = "056_fix_cover_templates_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "title_config")
|
||||
@@ -18,7 +18,6 @@ from app.schemas.asset import (
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
SmartMatchItem,
|
||||
SmartMatchRequest,
|
||||
@@ -29,11 +28,6 @@ from app.schemas.asset import (
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
CreateAssetUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -677,51 +671,12 @@ def untag_asset(
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
# 先获取素材库,用于推导 project_id(前端可能不传)
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
|
||||
# project_id 自动推导:优先用请求值,否则从 library 关联的项目获取
|
||||
project_id = request.project_id or library.project_id
|
||||
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
# 确保 library 和 project 归属一致
|
||||
if library.project_id != project_id:
|
||||
raise HTTPException(status_code=400, detail="AssetLibrary does not belong to the specified project")
|
||||
|
||||
use_case = CreateAssetUseCase(asset_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetCommand(
|
||||
project_id=project_id,
|
||||
library_id=request.library_id,
|
||||
name=request.name,
|
||||
storage_key=request.storage_key,
|
||||
mime_type=request.mime_type,
|
||||
metadata=request.metadata,
|
||||
file_size=request.file_size,
|
||||
thumbnail_url=request.thumbnail_url,
|
||||
duration=request.duration,
|
||||
width=request.width,
|
||||
height=request.height,
|
||||
fps=request.fps,
|
||||
codec=request.codec,
|
||||
status=AssetStatus(request.status),
|
||||
classification_status=ClassificationStatus(request.classification_status),
|
||||
quality_score=request.quality_score,
|
||||
uploaded_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
def create_asset() -> None:
|
||||
"""
|
||||
已废弃接口。
|
||||
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。",
|
||||
)
|
||||
return _to_asset_response(item)
|
||||
|
||||
@@ -31,8 +31,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Generation"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -65,6 +63,83 @@ class GenerateCoverResponse(BaseModel):
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _persist_cover_frame(
|
||||
frame_url: str,
|
||||
plan_id: str,
|
||||
title_text: str = "",
|
||||
*,
|
||||
title_color: str = "#ffffff",
|
||||
title_position: str = "bottom",
|
||||
title_font_size: int | None = None,
|
||||
) -> str:
|
||||
"""下载 MediaKit 返回的临时帧图,可选叠加标题后转存到 OSS covers/ 路径。
|
||||
|
||||
Args:
|
||||
frame_url: MediaKit 返回的临时帧图 URL
|
||||
plan_id: 剪辑计划 ID(生成 OSS key)
|
||||
title_text: 非空时用 Pillow 在帧上叠加标题(用于 E2 从源素材抽帧,
|
||||
因为源素材本身没有烧录标题)
|
||||
title_color: 标题字体颜色(#RRGGBB)
|
||||
title_position: 标题位置 top/center/bottom
|
||||
title_font_size: 标题字号,None 时自动计算
|
||||
"""
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
tmp_path: str | None = None
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
if not resp.content:
|
||||
return frame_url
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
# E2 从源素材抽帧时,源素材无标题,叠加标题文字
|
||||
if title_text and title_text.strip():
|
||||
try:
|
||||
from packages.shared.title_overlay import apply_title_to_image
|
||||
|
||||
applied = apply_title_to_image(
|
||||
tmp_path,
|
||||
title_text,
|
||||
color=title_color,
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
if applied:
|
||||
logger.info("[封面生成] E2 帧图已叠加标题: plan_id=%s", plan_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] E2 标题叠加失败(返回无标题帧): plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg"
|
||||
storage.upload_file(
|
||||
file_or_path=tmp_path,
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
public_url = storage.get_url(cover_key)
|
||||
return public_url or frame_url
|
||||
except Exception:
|
||||
logger.warning("封面帧转存失败,返回原始 URL: plan_id=%s", plan_id, exc_info=True)
|
||||
return frame_url
|
||||
finally:
|
||||
if tmp_path:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
@@ -198,44 +273,31 @@ def generate_cover(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 仍然找不到才报 400
|
||||
if not rendered_storage_key:
|
||||
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请先生成预览视频,再生成封面",
|
||||
)
|
||||
|
||||
# 回写到 plan.config
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读)
|
||||
# 使用裸 URL(rendered/* 已配置公开读);找不到渲染视频时不立即报错,
|
||||
# 因为步骤 E 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
|
||||
primary_video_url = None
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
if rendered_storage_key:
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
|
||||
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
|
||||
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取预览视频URL失败: {e}",
|
||||
) from e
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取预览视频URL失败: plan_id=%s err=%s", plan_id, e)
|
||||
primary_video_url = None
|
||||
|
||||
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
|
||||
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
|
||||
@@ -310,6 +372,129 @@ def generate_cover(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 D:从 plan.config.cover_candidates 读取(Worker 渲染时写入)
|
||||
if not cover_url_from_task:
|
||||
_candidates = (plan.config or {}).get("cover_candidates") or []
|
||||
if isinstance(_candidates, list) and _candidates:
|
||||
_first = _candidates[0]
|
||||
if isinstance(_first, dict):
|
||||
cover_url_from_task = _first.get("image_url") or _first.get("url") or ""
|
||||
if cover_url_from_task:
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤D-cover_candidates): plan_id=%s url=%s",
|
||||
plan_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
|
||||
# 步骤 E1:如果有已渲染的预览视频 URL 但 cover_url 未持久化(历史数据),
|
||||
# 直接从渲染视频抽帧
|
||||
if not cover_url_from_task and primary_video_url:
|
||||
try:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
mk_client = get_mediakit_client()
|
||||
if mk_client.is_available:
|
||||
logger.info(
|
||||
"[封面生成] 步骤E1-从渲染视频抽帧: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80],
|
||||
)
|
||||
snapshots = mk_client.extract_frames(
|
||||
video_url=primary_video_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=1,
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=5,
|
||||
max_retries=0,
|
||||
)
|
||||
if snapshots:
|
||||
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
if raw:
|
||||
cover_url_from_task = _persist_cover_frame(raw, plan_id)
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤E1-rendered-video): plan_id=%s url=%s",
|
||||
plan_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤E1从渲染视频抽帧失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 E2:当 A/B/C/D/E1 均未命中(如历史预览任务无 cover_url)时,
|
||||
# 直接从用户选择的第一个视频素材中抽取封面帧作为兜底。API 请求内短超时,不阻塞。
|
||||
if not cover_url_from_task and body.asset_ids:
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
storage_svc = get_shared_storage_service()
|
||||
mk_client = get_mediakit_client()
|
||||
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
|
||||
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
|
||||
if not isinstance(_e2_title_cfg, dict):
|
||||
_e2_title_cfg = {}
|
||||
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
|
||||
# 读取标题样式:前端可能传 color 或 font_color,都兼容
|
||||
_e2_title_color = _e2_title_cfg.get("color") or _e2_title_cfg.get("font_color") or "#ffffff"
|
||||
_e2_title_position = _e2_title_cfg.get("position", "bottom") or "bottom"
|
||||
_e2_title_font_size = _e2_title_cfg.get("font_size") or _e2_title_cfg.get("size")
|
||||
if mk_client.is_available:
|
||||
for aid in body.asset_ids:
|
||||
try:
|
||||
asset = asset_repo.get(aid)
|
||||
if not asset or asset.file_type != "video":
|
||||
continue
|
||||
sk = asset.storage_key or ""
|
||||
if not sk:
|
||||
continue
|
||||
src_url = sk if sk.startswith("http") else storage_svc.get_url(sk)
|
||||
if not src_url:
|
||||
continue
|
||||
logger.info(
|
||||
"[封面生成] 步骤E-从素材抽帧: plan_id=%s asset_id=%s url=%s",
|
||||
plan_id,
|
||||
aid,
|
||||
src_url[:80],
|
||||
)
|
||||
snapshots = mk_client.extract_frames(
|
||||
video_url=src_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=1,
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=5,
|
||||
max_retries=0,
|
||||
)
|
||||
if snapshots:
|
||||
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
if raw:
|
||||
cover_url_from_task = _persist_cover_frame(
|
||||
raw,
|
||||
plan_id,
|
||||
title_text=_e2_title_text,
|
||||
title_color=_e2_title_color,
|
||||
title_position=_e2_title_position,
|
||||
title_font_size=_e2_title_font_size,
|
||||
)
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤E-source-asset): plan_id=%s url=%s",
|
||||
plan_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤E从素材抽帧失败: plan_id=%s asset_id=%s",
|
||||
plan_id,
|
||||
aid,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if cover_url_from_task:
|
||||
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
|
||||
cover_data = {
|
||||
@@ -325,13 +510,13 @@ def generate_cover(
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
logger.warning(
|
||||
"[封面生成] 统一管道未找到 cover_url: plan_id=%s",
|
||||
"[封面生成] 统一管道未找到 cover_url (A/B/C/D均未命中): plan_id=%s",
|
||||
plan_id,
|
||||
)
|
||||
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="封面尚未生成,请先重新生成预览视频以触发封面自动提取",
|
||||
detail="封面生成失败:未找到可抽帧的视频素材,请确认已上传视频素材后重试",
|
||||
)
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.core.task_enqueue import (
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
@@ -32,6 +33,7 @@ from app.schemas.generation_task import (
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
@@ -69,6 +71,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
title_config=getattr(task, "title_config", {}) or {},
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -142,6 +145,54 @@ def _select_assets_from_library(
|
||||
return [a.id for a in ready_video_assets]
|
||||
|
||||
|
||||
|
||||
def _writeback_edit_plan_config(
|
||||
plan_id: str,
|
||||
task_id: str,
|
||||
title_config: dict | None,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。
|
||||
|
||||
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
|
||||
失败只记日志,不影响任务创建。
|
||||
"""
|
||||
if not plan_id:
|
||||
return
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
|
||||
if plan_model is None:
|
||||
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
|
||||
return
|
||||
|
||||
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
|
||||
merged = dict(current_config)
|
||||
merged["generation_task_id"] = task_id
|
||||
if title_config:
|
||||
merged["title_config"] = title_config
|
||||
plan_model.config = merged
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
|
||||
plan_id,
|
||||
task_id,
|
||||
list(merged.keys()),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
request: CreateGenerationTaskRequest,
|
||||
project_repository: Any,
|
||||
@@ -187,6 +238,7 @@ def create_generation_task(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
logger.info(
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
|
||||
@@ -304,6 +356,7 @@ def create_generation_task(
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
title_config=request.title_config or {},
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -315,6 +368,15 @@ def create_generation_task(
|
||||
log_task_status=True,
|
||||
):
|
||||
created_tasks.append(task)
|
||||
# 只在首个成功任务时回写一次 plan.config,
|
||||
# 避免批量生成时循环覆盖 generation_task_id
|
||||
if request.source_edit_plan_id and len(created_tasks) == 1:
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=request.source_edit_plan_id,
|
||||
task_id=task.id,
|
||||
title_config=request.title_config,
|
||||
db=db,
|
||||
)
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""模板编辑器 API 路由包.
|
||||
|
||||
将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
|
||||
模块拆分:
|
||||
- schemas.py: 所有 Pydantic model
|
||||
- dependencies.py: 依赖注入
|
||||
- _utils.py: 工具函数
|
||||
- _fallback.py: 自动兜底逻辑
|
||||
- draft.py: 草稿管理(详情/更新/发布/版本/回滚)
|
||||
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
|
||||
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
|
||||
@@ -13,7 +12,6 @@
|
||||
- export.py: 导出配置
|
||||
- subtitles.py: 字幕管理
|
||||
- ai_features.py: AI 推荐
|
||||
- generation.py: 生成(触发/进度/记录)
|
||||
- timeline.py: 时间线
|
||||
|
||||
挂载路径: /api/v1/templates/{template_id}/editor/
|
||||
@@ -34,7 +32,6 @@ from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||
from .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
from .export import router as export_router
|
||||
from .generation import router as generation_router
|
||||
from .subtitles import router as subtitles_router
|
||||
from .timeline import router as timeline_router
|
||||
|
||||
@@ -51,7 +48,6 @@ _sub_routers = [
|
||||
export_router,
|
||||
subtitles_router,
|
||||
ai_features_router,
|
||||
generation_router,
|
||||
timeline_router,
|
||||
]
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
"""模板编辑器自动兜底逻辑.
|
||||
|
||||
generate_editor_draft 触发生成前的自动修复流程:
|
||||
1. draft → editing 状态迁移
|
||||
2. 无片段时从模板复制片段配置
|
||||
3. 为无素材片段分配指定素材
|
||||
4. 项目有素材库时自动选素材
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||
"""自动兜底 1: draft → editing"""
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
|
||||
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||
plan_id,
|
||||
plan_check.template_id,
|
||||
)
|
||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||
if configs:
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
|
||||
plan_id,
|
||||
len(configs),
|
||||
)
|
||||
else:
|
||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||
for seg in segments:
|
||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
duration=avg_duration,
|
||||
config={
|
||||
"material_type": seg.material_type or "",
|
||||
"template_segment_id": seg.id,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段",
|
||||
plan_id,
|
||||
len(segments),
|
||||
)
|
||||
|
||||
|
||||
def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list:
|
||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r",
|
||||
plan_id,
|
||||
len(all_clips),
|
||||
len(clips_without_asset),
|
||||
config_asset_ids[:5] if config_asset_ids else [],
|
||||
)
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
assigned = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
try:
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
|
||||
plan_id,
|
||||
assigned,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新检查剩余无素材片段
|
||||
all_clips_after = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
|
||||
if clips_without_asset:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
elif not clips_without_asset:
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
|
||||
elif not config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
|
||||
plan_id,
|
||||
)
|
||||
|
||||
return clips_without_asset
|
||||
|
||||
|
||||
def _auto_fallback_auto_material_mode(
|
||||
svc: EditPlanService,
|
||||
plan_id: str,
|
||||
plan_check,
|
||||
clips_without_asset: list,
|
||||
asset_library_repo: Any,
|
||||
asset_repo: Any,
|
||||
user_id: str = "",
|
||||
) -> None:
|
||||
"""自动兜底 4: 自动选素材分配给无素材片段
|
||||
|
||||
查找策略(按优先级):
|
||||
1. plan 有 project_id → 从项目素材库查找
|
||||
2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找
|
||||
"""
|
||||
if not clips_without_asset:
|
||||
return
|
||||
|
||||
ready_videos: list = []
|
||||
source_desc = ""
|
||||
|
||||
# 策略 1: 通过 project_id 查找项目素材库
|
||||
if plan_check.project_id:
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
source_desc = f"素材库 {video_lib.name}"
|
||||
|
||||
# 策略 2: 通过 user_id 查找用户上传的素材
|
||||
if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"):
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材",
|
||||
plan_id,
|
||||
user_id,
|
||||
)
|
||||
ready_videos = asset_repo.find_ready_videos_by_user(user_id)
|
||||
source_desc = f"用户上传 (user_id={user_id[:8]}...)"
|
||||
|
||||
if not ready_videos:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)",
|
||||
plan_id,
|
||||
plan_check.project_id or "(empty)",
|
||||
user_id[:8] + "..." if user_id else "(empty)",
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
)
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 从 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
@@ -3,7 +3,6 @@
|
||||
核心依赖:
|
||||
- get_editor_services: 获取模板+计划服务
|
||||
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
|
||||
- _check_queue_limits: 生成队列限流检查
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +10,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||
from app.dependencies import get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
@@ -113,29 +111,3 @@ def get_draft_plan_id(
|
||||
user_id,
|
||||
)
|
||||
return plan.id
|
||||
|
||||
|
||||
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
||||
"""队列限流预检查"""
|
||||
try:
|
||||
has_count = (
|
||||
hasattr(gen_task_repo, "count_pending_by_user")
|
||||
and hasattr(gen_task_repo, "count_pending_total")
|
||||
)
|
||||
if has_count:
|
||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||
global_pending = gen_task_repo.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e)
|
||||
|
||||
@@ -17,6 +17,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
EditorClipBatchUpdateRequest,
|
||||
EditorClipBatchUpdateResponse,
|
||||
EditorDraftResponse,
|
||||
EditorPublishResponse,
|
||||
EditorRollbackRequest,
|
||||
@@ -126,11 +128,7 @@ def list_template_versions(
|
||||
clip_count=len(v.clip_configs),
|
||||
change_note=v.change_note,
|
||||
published_by=v.published_by,
|
||||
created_at=(
|
||||
v.created_at.isoformat()
|
||||
if hasattr(v.created_at, "isoformat")
|
||||
else str(v.created_at)
|
||||
),
|
||||
created_at=(v.created_at.isoformat() if hasattr(v.created_at, "isoformat") else str(v.created_at)),
|
||||
)
|
||||
for v in versions
|
||||
]
|
||||
@@ -162,3 +160,35 @@ def rollback_template(
|
||||
new_version=tpl.version,
|
||||
clip_count=len(clip_configs),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/clips", response_model=EditorClipBatchUpdateResponse)
|
||||
def batch_update_clips(
|
||||
template_id: str,
|
||||
req: EditorClipBatchUpdateRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""批量替换草稿clips(全量覆盖,用于前端选择素材后同步片段)
|
||||
|
||||
事务保证:清空→创建→标记ready 在同一数据库事务内完成,
|
||||
任何步骤失败时自动回滚,避免数据不一致。
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
clips_data = []
|
||||
for clip_item in req.clips:
|
||||
item = {
|
||||
"asset_id": clip_item.asset_id,
|
||||
"start_time": clip_item.start_time,
|
||||
"duration": clip_item.duration,
|
||||
}
|
||||
if clip_item.order is not None:
|
||||
item["order"] = clip_item.order
|
||||
clips_data.append(item)
|
||||
|
||||
plan_svc.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
return EditorClipBatchUpdateResponse(plan_id=plan_id, clip_count=len(req.clips))
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
"""草稿生成路由.
|
||||
|
||||
端点:
|
||||
- POST /generate 触发生成
|
||||
- GET /generation-status 生成进度
|
||||
- GET /generations 生成记录列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
)
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
from ._fallback import (
|
||||
_auto_fallback_assign_assets,
|
||||
_auto_fallback_auto_material_mode,
|
||||
_auto_fallback_copy_template_clips,
|
||||
_auto_fallback_draft_to_editing,
|
||||
)
|
||||
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateRequest,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
@router.post("/generate", response_model=EditPlanGenerateResponse)
|
||||
def generate_editor_draft(
|
||||
template_id: str,
|
||||
request: Optional[EditPlanGenerateRequest] = None,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发模板草稿渲染生成"""
|
||||
req = request or EditPlanGenerateRequest()
|
||||
_, plan_svc = services
|
||||
plan_check = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# 自动兜底流程
|
||||
_auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check)
|
||||
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
|
||||
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
|
||||
_auto_fallback_auto_material_mode(
|
||||
plan_svc,
|
||||
plan_id,
|
||||
plan_check,
|
||||
clips_without_asset,
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id=str(current_user.user.id),
|
||||
)
|
||||
|
||||
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
# 如果前端传了 title_config,需要创建新任务(因为预览任务的 custom_title 可能不同)
|
||||
title_config_reuse = req.title_config or {}
|
||||
title_text_reuse = (title_config_reuse.get("text") or "").strip()
|
||||
existing_custom_title = getattr(reusable_task, "custom_title", "") or ""
|
||||
if title_text_reuse and existing_custom_title:
|
||||
# 如果新标题和已有标题不同,不能复用,走新建任务流程
|
||||
new_title_json = json.dumps(title_config_reuse, ensure_ascii=False)
|
||||
if new_title_json != existing_custom_title:
|
||||
logger.info(
|
||||
"[模板生成] 标题已变更,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
elif title_text_reuse and not existing_custom_title:
|
||||
# 原来没标题,现在有标题,不能复用
|
||||
logger.info(
|
||||
"[模板生成] 新增标题,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
elif not title_text_reuse and existing_custom_title:
|
||||
# 原来有标题,现在移除了,不能复用
|
||||
logger.info(
|
||||
"[模板生成] 移除标题,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
reusable_task.mark_confirmed()
|
||||
gen_task_repo.update(reusable_task)
|
||||
|
||||
# 将产物 URL 写入 plan config
|
||||
rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db)
|
||||
plan_svc.update_plan_config(
|
||||
plan_id,
|
||||
{
|
||||
"generation_task_id": reusable_task.id,
|
||||
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
|
||||
},
|
||||
)
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
logger.info(
|
||||
"模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
reusable_task.id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=reusable_task.id,
|
||||
clip_count=len((plan_check.config or {}).get("clips", [])),
|
||||
)
|
||||
|
||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
||||
try:
|
||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if not can_gen:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||
|
||||
try:
|
||||
clip_count = plan_svc.mark_clips_ready(plan_id)
|
||||
|
||||
user_id = current_user.user.id
|
||||
_check_queue_limits(gen_task_repo, user_id)
|
||||
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
# 从 plan config 读取封面 URL(由 generate-cover 保存)
|
||||
cover_url_from_config = (plan.config or {}).get("cover", {}).get("image_url", "")
|
||||
|
||||
# 处理标题配置:序列化 title_config 为 JSON 存入 custom_title
|
||||
title_config = req.title_config or {}
|
||||
title_text = (title_config.get("text") or "").strip()
|
||||
custom_title_value = ""
|
||||
if title_text:
|
||||
custom_title_value = json.dumps(title_config, ensure_ascii=False)
|
||||
logger.info(
|
||||
"[模板生成] 标题配置: text=%s, config_keys=%s",
|
||||
title_text[:30],
|
||||
list(title_config.keys()),
|
||||
)
|
||||
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=plan.project_id or "",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
asset_ids=list(config_asset_ids) if config_asset_ids else [],
|
||||
cover_url=cover_url_from_config,
|
||||
custom_title=custom_title_value,
|
||||
),
|
||||
)
|
||||
|
||||
plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||
|
||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
logger.info(
|
||||
"模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as _e:
|
||||
logger.exception(
|
||||
"模板编辑器触发生成失败: template_id=%s plan_id=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
)
|
||||
try:
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成失败,请稍后重试",
|
||||
) from _e
|
||||
|
||||
|
||||
def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None":
|
||||
"""查找该 plan 关联的已完成预览任务,判断是否可复用。
|
||||
|
||||
复用条件:
|
||||
1. 存在 source_edit_plan_id == plan_id 的已完成预览任务
|
||||
2. plan 在预览完成后未被修改(updated_at <= 预览完成时间)
|
||||
|
||||
Returns:
|
||||
可复用的 GenerationTask,或 None
|
||||
"""
|
||||
try:
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for task in tasks:
|
||||
if not getattr(task, "is_preview", False):
|
||||
continue
|
||||
if not task.is_completed:
|
||||
continue
|
||||
# 检查 plan 是否在预览完成后被修改
|
||||
completed_at = getattr(task, "completed_at", None)
|
||||
if completed_at and hasattr(plan, "updated_at"):
|
||||
plan_updated = plan.updated_at
|
||||
# 如果 plan.updated_at 为空,无法判断是否修改过,跳过
|
||||
if plan_updated is None:
|
||||
continue
|
||||
# 如果 plan 在预览完成后又被修改了,不能复用
|
||||
if plan_updated > completed_at:
|
||||
continue
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
"""获取任务的输出视频 URL。"""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
url = getattr(videos[0], "file_url", "") or ""
|
||||
# 规范化:合并路径中的双斜杠(保留协议头 ://)
|
||||
if url:
|
||||
import re as _re
|
||||
url = _re.sub(r"(?<!:)//", "/", url)
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
||||
def get_editor_generation_status(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditPlanGenerationStatusResponse:
|
||||
"""查询草稿生成进度"""
|
||||
_, plan_svc = services
|
||||
try:
|
||||
gen_status = plan_svc.get_generation_status(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
plan = gen_status["plan"]
|
||||
clips = gen_status["clips"]
|
||||
|
||||
clip_items = [
|
||||
ClipStatusItem(
|
||||
clip_id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
asset_id=c.asset_id or "",
|
||||
text_content=c.text_content or "",
|
||||
duration=c.duration,
|
||||
)
|
||||
for c in clips
|
||||
]
|
||||
|
||||
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
|
||||
video_url = ""
|
||||
if raw_video_url:
|
||||
if raw_video_url.startswith("http"):
|
||||
video_url = raw_video_url # 已经是完整 URL
|
||||
else:
|
||||
try:
|
||||
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
|
||||
except Exception as e:
|
||||
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
|
||||
video_url = raw_video_url
|
||||
|
||||
progress = gen_status.get("progress", 0.0)
|
||||
error_message = gen_status.get("error_message", "")
|
||||
gen_task_status = gen_status.get("generation_task_status")
|
||||
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status_val == "completed" and progress < 100:
|
||||
progress = 100.0
|
||||
|
||||
return EditPlanGenerationStatusResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=plan_status_val,
|
||||
generation_task_id=gen_status["generation_task_id"],
|
||||
generation_task_status=gen_task_status,
|
||||
progress=progress,
|
||||
video_url=video_url,
|
||||
error_message=error_message,
|
||||
clips=clip_items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/generations", response_model=EditPlanGenerationsResponse)
|
||||
def list_editor_generations(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditPlanGenerationsResponse:
|
||||
"""查询草稿关联的生成记录列表"""
|
||||
_, plan_svc = services
|
||||
plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
items = [
|
||||
GenerationTaskResponse(
|
||||
id=t.id,
|
||||
project_id=t.project_id,
|
||||
asset_library_id=t.asset_library_id,
|
||||
strategy_id=t.strategy_id,
|
||||
voice_library_id=t.voice_library_id,
|
||||
template_id=t.template_id,
|
||||
asset_ids=t.asset_ids,
|
||||
title_ids=t.title_ids,
|
||||
voice_ids=t.voice_ids,
|
||||
source_edit_plan_id=t.source_edit_plan_id or "",
|
||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||||
progress=t.progress,
|
||||
result_count=t.result_count,
|
||||
error_message=t.error_message,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||||
@@ -6,9 +6,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re as _re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
|
||||
@@ -16,58 +15,6 @@ _EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best
|
||||
_EXPORT_VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
|
||||
# ── 生成状态相关 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipStatusItem(BaseModel):
|
||||
"""片段生成状态"""
|
||||
|
||||
clip_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
status: str
|
||||
asset_id: str
|
||||
text_content: str
|
||||
duration: float
|
||||
|
||||
|
||||
class EditPlanGenerationStatusResponse(BaseModel):
|
||||
"""剪辑计划生成进度响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: Optional[str] = None
|
||||
generation_task_status: Optional[str] = None
|
||||
progress: float = 0.0
|
||||
video_url: str = ""
|
||||
error_message: str = ""
|
||||
clips: List[ClipStatusItem]
|
||||
|
||||
|
||||
class EditPlanGenerateRequest(BaseModel):
|
||||
"""模板编辑器触发生成请求体"""
|
||||
title_config: Optional[Dict[str, Any]] = Field(
|
||||
default_factory=dict,
|
||||
description="标题配置(可选),渲染时烧录到视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
|
||||
)
|
||||
|
||||
|
||||
class EditPlanGenerateResponse(BaseModel):
|
||||
"""剪辑计划触发生成响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: str
|
||||
clip_count: int
|
||||
|
||||
|
||||
class EditPlanGenerationsResponse(BaseModel):
|
||||
"""剪辑计划关联的生成记录列表响应体"""
|
||||
|
||||
items: List[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── AI 推荐 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -75,12 +22,8 @@ class AIRecommendRequest(BaseModel):
|
||||
"""AI 推荐片段方案请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
editing_mode: str = Field(
|
||||
default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip"
|
||||
)
|
||||
target_duration: float = Field(
|
||||
default=30.0, ge=1.0, le=600.0, description="目标时长(秒)"
|
||||
)
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
|
||||
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||
|
||||
|
||||
class AIRecommendClipItem(BaseModel):
|
||||
@@ -107,8 +50,6 @@ class AIRecommendResponse(BaseModel):
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── BGM ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -224,9 +165,7 @@ class ClipBatchDeleteResponse(BaseModel):
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: List[str] = Field(
|
||||
..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾"
|
||||
)
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
|
||||
|
||||
@@ -501,6 +440,28 @@ class EditorClipUpdateRequest(BaseModel):
|
||||
config: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class EditorClipBatchItem(BaseModel):
|
||||
"""批量更新clips的单个片段"""
|
||||
|
||||
asset_id: str = Field(default="", max_length=100, description="关联素材ID,可为空(占位片段)")
|
||||
start_time: float = Field(default=0.0, ge=0.0)
|
||||
duration: float = Field(default=0.0, ge=0.0)
|
||||
order: Optional[int] = Field(default=None, ge=0, description="排序,None表示按数组顺序")
|
||||
|
||||
|
||||
class EditorClipBatchUpdateRequest(BaseModel):
|
||||
"""批量替换clips请求(全量覆盖)"""
|
||||
|
||||
clips: List[EditorClipBatchItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EditorClipBatchUpdateResponse(BaseModel):
|
||||
"""批量更新clips响应"""
|
||||
|
||||
plan_id: str
|
||||
clip_count: int
|
||||
|
||||
|
||||
class EditorPublishResponse(BaseModel):
|
||||
"""发布草稿响应"""
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ async def complete_direct_upload(
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
url=storage_service.get_url(normalized_key),
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
@@ -215,7 +216,7 @@ async def complete_direct_upload(
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id, url=storage_service.get_url(normalized_key))
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -33,6 +33,11 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── 标题配置(结构化,优先于 custom_title 纯文本)──
|
||||
title_config: dict | None = Field(
|
||||
default=None,
|
||||
description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。",
|
||||
)
|
||||
# ── 视频标题 ──
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
# ── 批量生成 ──
|
||||
@@ -109,6 +114,7 @@ class GenerationTaskResponse(BaseModel):
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
title_config: dict = Field(default_factory=dict)
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
@@ -39,6 +39,7 @@ class DirectUploadCompleteResponse(BaseModel):
|
||||
ingest_job_id: str
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
url: str = Field(default="", description="Public URL of uploaded file")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
|
||||
@@ -371,6 +371,85 @@ class EditPlanService:
|
||||
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
|
||||
return count
|
||||
|
||||
def replace_all_clips_transactional(
|
||||
self,
|
||||
plan_id: str,
|
||||
clips_data: list[dict],
|
||||
) -> int:
|
||||
"""事务性地替换所有片段:清空→创建→标记ready,单事务保证原子性。
|
||||
|
||||
Args:
|
||||
plan_id: 计划 ID
|
||||
clips_data: 片段数据列表,每项包含 asset_id/start_time/duration/order
|
||||
|
||||
Returns:
|
||||
int: 创建的片段数量
|
||||
|
||||
Raises:
|
||||
Exception: 任何步骤失败时自动回滚
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
|
||||
|
||||
db = self._clip_repo.session
|
||||
try:
|
||||
# 1. 清空现有 clips(不 commit)
|
||||
deleted_count = db.query(EditPlanClipModel).filter(EditPlanClipModel.plan_id == plan_id).delete()
|
||||
|
||||
# 2. 批量创建新 clips(不 commit)
|
||||
for i, clip_item in enumerate(clips_data):
|
||||
order = clip_item.get("order") or i
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=order,
|
||||
asset_id=clip_item.get("asset_id", ""),
|
||||
start_time=clip_item.get("start_time", 0.0),
|
||||
duration=clip_item.get("duration", 0.0),
|
||||
)
|
||||
model = EditPlanClipModel(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id,
|
||||
text_content=clip.text_content,
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect,
|
||||
transition_duration=clip.transition_duration,
|
||||
playback_speed=clip.playback_speed,
|
||||
status=clip.status.value,
|
||||
config=clip.config,
|
||||
)
|
||||
db.add(model)
|
||||
|
||||
# 3. 标记有 asset_id 的 clips 为 ready(不 commit)
|
||||
pending_with_asset = (
|
||||
db.query(EditPlanClipModel)
|
||||
.filter(
|
||||
EditPlanClipModel.plan_id == plan_id,
|
||||
EditPlanClipModel.status == "pending",
|
||||
EditPlanClipModel.asset_id != "",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for m in pending_with_asset:
|
||||
m.status = "ready"
|
||||
|
||||
# 4. 一次性提交
|
||||
db.commit()
|
||||
logger.info(
|
||||
"事务性替换片段: plan_id=%s deleted=%d created=%d",
|
||||
plan_id,
|
||||
deleted_count,
|
||||
len(clips_data),
|
||||
)
|
||||
return len(clips_data)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
|
||||
raise
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
|
||||
@@ -226,16 +226,12 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
|
||||
// Wait for generation API to be called
|
||||
// 确认生成走新流程:POST /tasks/{taskId}/confirm(复用预览产物)
|
||||
// 或旧流程:POST /editor/generate(向后兼容)
|
||||
// 前端直接创建生成任务:POST /generation/tasks
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
(path.endsWith("/confirm") || path.endsWith("/editor/generate"))
|
||||
)
|
||||
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
@@ -178,7 +178,7 @@ test.describe("素材库流程", () => {
|
||||
expect(kinds).toContain("image")
|
||||
})
|
||||
|
||||
test("创建素材记录", async ({ request }) => {
|
||||
test("创建素材记录 — POST /assets 已废弃返回 410", async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-create")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
|
||||
@@ -194,7 +194,7 @@ test.describe("素材库流程", () => {
|
||||
expect(lib.ok()).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
|
||||
// 创建素材记录
|
||||
// POST /assets 已废弃,应返回 410 Gone
|
||||
const response = await request.post(`${apiBase}/assets`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -210,16 +210,9 @@ test.describe("素材库流程", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
|
||||
expect(response.status()).toBe(410)
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回素材 ID").toBeTruthy()
|
||||
expect(data.name).toContain("test_video")
|
||||
expect(data.mime_type).toBe("video/mp4")
|
||||
expect(data.library_id).toBe(libData.id)
|
||||
expect(data.error?.code).toBe("HTTP_410")
|
||||
})
|
||||
|
||||
test("列出素材", async ({ request }) => {
|
||||
@@ -232,51 +225,50 @@ test.describe("素材库流程", () => {
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: `List Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
kind: "image",
|
||||
},
|
||||
})
|
||||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
|
||||
// 创建 2 个素材
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
// 通过 multipart upload 上传 2 个小图片作为测试素材
|
||||
// 创建一个 1x1 的 PNG buffer
|
||||
const tinyPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
)
|
||||
|
||||
await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
data: {
|
||||
multipart: {
|
||||
project_id: projectId,
|
||||
library_id: libData.id,
|
||||
name: `clip_a_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/clip_a.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
file: { name: "clip_a.png", mimeType: "image/png", buffer: tinyPng },
|
||||
},
|
||||
})
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
data: {
|
||||
multipart: {
|
||||
project_id: projectId,
|
||||
library_id: libData.id,
|
||||
name: `clip_b_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/clip_b.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
file: { name: "clip_b.png", mimeType: "image/png", buffer: tinyPng },
|
||||
},
|
||||
})
|
||||
|
||||
// 列出素材
|
||||
const response = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libData.id },
|
||||
})
|
||||
// 列出素材(可能需要等待 ingest job 完成)
|
||||
let items: any[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const response = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libData.id },
|
||||
})
|
||||
expect(response.ok(), `列出素材应返回 2xx`).toBeTruthy()
|
||||
const data = await response.json()
|
||||
items = data.items || []
|
||||
if (items.length >= 2) break
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || []
|
||||
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -60,18 +60,6 @@ export const smartMatchAssets = async (libraryId: string): Promise<{ items: Asse
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||
export const createAsset = async (data: {
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata?: AssetMetadata
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
|
||||
@@ -13,7 +13,6 @@ export type {
|
||||
ClassificationJob,
|
||||
AssetDiagnosis,
|
||||
BatchOperationResult,
|
||||
UploadResult,
|
||||
DirectUploadPrepareResult,
|
||||
DirectUploadCompleteResult,
|
||||
} from "./types"
|
||||
@@ -34,14 +33,13 @@ export {
|
||||
getAssets,
|
||||
getAssetsByKind,
|
||||
smartMatchAssets,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
updateAssetReviewStatus,
|
||||
deleteAsset,
|
||||
} from "./assets"
|
||||
|
||||
// 上传
|
||||
export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
export { prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
|
||||
// 任务
|
||||
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
|
||||
|
||||
@@ -135,4 +135,5 @@ export interface DirectUploadPrepareResult {
|
||||
export interface DirectUploadCompleteResult {
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
@@ -3,16 +3,7 @@
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
||||
|
||||
/** 表单上传素材(小文件) */
|
||||
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
|
||||
const response = await apiClient.post("/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 30 * 60 * 1000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
|
||||
@@ -4,11 +4,20 @@
|
||||
import apiClient from "../client"
|
||||
import type { BgmPreset, BgmPresetsQuery } from "./types"
|
||||
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
|
||||
/**
|
||||
* 获取 BGM 预设列表
|
||||
* @param templateId 模板/草稿 ID
|
||||
* @param params 分类/关键词筛选
|
||||
*/
|
||||
export const getBgmPresets = async (
|
||||
templateId: string,
|
||||
params?: BgmPresetsQuery,
|
||||
): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {}
|
||||
if (params?.category) searchParams.category = params.category
|
||||
if (params?.keyword) searchParams.keyword = params.keyword
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams })
|
||||
const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, {
|
||||
params: searchParams,
|
||||
})
|
||||
return res.data?.data ?? res.data ?? []
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
export interface GenerateCoverTitleConfig {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
/** 标题样式,用于在封面上叠加标题文字 */
|
||||
title_config?: GenerateCoverTitleConfig
|
||||
}
|
||||
|
||||
export interface GenerateCoverResponse {
|
||||
|
||||
@@ -3,13 +3,9 @@ export type {
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
ConfirmGenerationResponse,
|
||||
ConfirmGenerationTaskItem,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
export { generateCover } from "./cover"
|
||||
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
|
||||
|
||||
@@ -6,6 +6,7 @@ import apiClient from "../client"
|
||||
import type {
|
||||
CreateGenerationTaskRequest,
|
||||
CreateGenerationTaskResponse,
|
||||
GenerationTaskDetail,
|
||||
TaskItem,
|
||||
TaskListParams,
|
||||
TaskListResponse,
|
||||
@@ -19,6 +20,12 @@ export const createGenerationTask = async (
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取单个生成任务详情(轮询用) */
|
||||
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskDetail> => {
|
||||
const { data } = await apiClient.get<GenerationTaskDetail>(`/generation/tasks/${taskId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
|
||||
@@ -57,12 +57,37 @@ export interface TaskListResponse {
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
/** 输出视频宽度 */
|
||||
output_width?: number
|
||||
/** 输出视频高度 */
|
||||
output_height?: number
|
||||
/** 自定义封面图片 URL */
|
||||
cover_url?: string
|
||||
/** 自定义视频标题 */
|
||||
custom_title?: string
|
||||
/** 视频时长(秒) */
|
||||
duration?: number
|
||||
/** 视频宽高比,如 "9:16" */
|
||||
video_ratio?: string
|
||||
/** 标题烧录配置 */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
/** 关联的草稿 ID(编辑流程数据链路用) */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
|
||||
export interface GenerationTaskDetail {
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
@@ -72,8 +97,18 @@ export interface CreateGenerationTaskResponse {
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
source_edit_plan_id?: string
|
||||
status: string
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
error_info?: TaskErrorInfo
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
/** 创建生成任务响应(后端返回批量结构 {items, total}) */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
items: GenerationTaskDetail[]
|
||||
total: number
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
/**
|
||||
* 模板草稿 CRUD + 生成相关 API
|
||||
* 模板草稿 CRUD API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
EditPlan,
|
||||
EditPlanListParams,
|
||||
EditPlanListResponse,
|
||||
CreateEditPlanRequest,
|
||||
UpdateEditPlanRequest,
|
||||
GenerateResponse,
|
||||
GenerationStatusResponse,
|
||||
EditPlanGeneration,
|
||||
GeneratedVideo,
|
||||
CopyEditPlanRequest,
|
||||
} from "./types"
|
||||
|
||||
/** 获取模板草稿列表(支持分页和筛选) */
|
||||
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
@@ -29,63 +10,44 @@ export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板草稿 */
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/templates/drafts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿 */
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
|
||||
export async function updateEditPlan(
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板草稿 */
|
||||
export async function deleteEditPlan(templateId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}/editor`)
|
||||
}
|
||||
|
||||
/** 触发生成 */
|
||||
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取生成状态(轮询用) */
|
||||
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取模板草稿关联的生成记录 */
|
||||
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(templateId: string): Promise<void> {
|
||||
await apiClient.post(`/templates/${templateId}/editor/cancel`)
|
||||
/** ── 草稿 clips 批量更新 ── */
|
||||
|
||||
export interface EditPlanClipInput {
|
||||
asset_id: string
|
||||
start_time: number
|
||||
duration: number
|
||||
order: number
|
||||
}
|
||||
|
||||
/** 复制模板草稿(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
/**
|
||||
* 批量替换草稿的 clips(先全删再批量插入)
|
||||
* 后端路由:PUT /templates/{template_id}/editor/clips
|
||||
*/
|
||||
export async function updateEditPlanClips(
|
||||
templateId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/templates/${templateId}/editor/copy`,
|
||||
data || {},
|
||||
clips: EditPlanClipInput[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ count: number }> {
|
||||
const response = await apiClient.put(
|
||||
`/templates/${templateId}/editor/clips`,
|
||||
{ clips },
|
||||
{ signal },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -15,14 +15,8 @@ export type {
|
||||
EditPlanSegment,
|
||||
EditPlanConfig,
|
||||
EditPlan,
|
||||
CreateEditPlanRequest,
|
||||
UpdateEditPlanRequest,
|
||||
EditPlanListParams,
|
||||
EditPlanListResponse,
|
||||
GenerateResponse,
|
||||
EditPlanGeneration,
|
||||
ClipStatusItem,
|
||||
GenerationStatusResponse,
|
||||
GeneratedVideo,
|
||||
AIRecommendRequest,
|
||||
AIRecommendClipItem,
|
||||
@@ -37,7 +31,6 @@ export type {
|
||||
ClipReorderResponse,
|
||||
ClipBatchDeleteResponse,
|
||||
ClipsFromAssetsResponse,
|
||||
CopyEditPlanRequest,
|
||||
TransitionEffect,
|
||||
MediaAsset,
|
||||
} from "./types"
|
||||
@@ -53,18 +46,12 @@ export {
|
||||
|
||||
// 模板草稿 CRUD + 生成
|
||||
export {
|
||||
getEditPlans,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
getEditPlanGenerations,
|
||||
updateEditPlanClips,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
copyEditPlan,
|
||||
} from "./editPlans"
|
||||
export type { EditPlanClipInput } from "./editPlans"
|
||||
|
||||
// 片段 CRUD + 批量操作
|
||||
export {
|
||||
|
||||
@@ -118,6 +118,17 @@ export interface EditPlanConfig {
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string
|
||||
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
|
||||
title?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
/** 预览视频 URL(封面生成用) */
|
||||
rendered_storage_key?: string
|
||||
/** 生成任务 ID */
|
||||
@@ -176,31 +187,6 @@ export interface EditPlanListResponse {
|
||||
|
||||
/* ── 生成相关 ── */
|
||||
|
||||
/** 生成响应 */
|
||||
export interface GenerateResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id: string
|
||||
clip_count: number
|
||||
}
|
||||
|
||||
/** 模板草稿关联的生成记录 */
|
||||
export interface EditPlanGeneration {
|
||||
id: string
|
||||
source_edit_plan_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
status: EditPlanStatus
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
error_info: Record<string, unknown>
|
||||
logs: Array<Record<string, unknown>>
|
||||
retry_count: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 片段生成状态 */
|
||||
export interface ClipStatusItem {
|
||||
clip_id: string
|
||||
@@ -213,17 +199,6 @@ export interface ClipStatusItem {
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
/** 生成状态轮询响应 */
|
||||
export interface GenerationStatusResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id?: string
|
||||
error_message?: string
|
||||
clips: ClipStatusItem[]
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** 生成视频详情 */
|
||||
export interface GeneratedVideo {
|
||||
id: string
|
||||
|
||||
@@ -9,8 +9,6 @@ export type {
|
||||
TemplateSegment,
|
||||
TemplateListParams,
|
||||
TemplateListResponse,
|
||||
GenerateFromTemplateRequest,
|
||||
GenerateFromTemplateResponse,
|
||||
CopyTemplateResponse,
|
||||
} from "./types"
|
||||
|
||||
@@ -24,5 +22,4 @@ export {
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
} from "./templates"
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
CopyTemplateResponse,
|
||||
GenerateFromTemplateRequest,
|
||||
GenerateFromTemplateResponse,
|
||||
TemplateItem,
|
||||
TemplateListParams,
|
||||
TemplateListResponse,
|
||||
@@ -45,15 +43,3 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
|
||||
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从模板生成 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
||||
import { validateFile } from "./utils"
|
||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
||||
@@ -181,9 +182,15 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
})
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
// 直传到 OSS
|
||||
const uploadResult = await uploadAssetDirect({
|
||||
file: fileToUpload,
|
||||
library_id: library.id,
|
||||
})
|
||||
|
||||
// 组件已卸载则中止后续操作
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
@@ -62,15 +62,15 @@ export function useCloneSubmit({
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库(后端 /upload 接口必填)
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
formData.append("project_id", project.id)
|
||||
formData.append("library_id", library.id)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
// 直传到 OSS
|
||||
const uploadResult = await uploadAssetDirect({
|
||||
file: fileToUpload,
|
||||
library_id: library.id,
|
||||
})
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
|
||||
@@ -71,7 +71,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
label: "剪辑模板",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
@@ -133,7 +133,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
label: "剪辑模板",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* 生成历史弹窗 — 展示当前模板草稿的生成任务记录
|
||||
* 从 EditingPlanner 拆分,避免主文件过大
|
||||
*/
|
||||
import React from "react"
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
|
||||
import type { EditPlanGeneration } from "@/api/template-editor"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/template-editor"
|
||||
|
||||
interface GenerationHistoryModalProps {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
history: EditPlanGeneration[]
|
||||
onClose: () => void
|
||||
onCancel?: (taskId: string) => void
|
||||
cancelLoading?: boolean
|
||||
}
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
history,
|
||||
onClose,
|
||||
onCancel,
|
||||
cancelLoading,
|
||||
}) => {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="ep-modal-overlay" onClick={onClose}>
|
||||
<div className="ep-modal ep-gh-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ep-modal-header">
|
||||
<h3>生成历史</h3>
|
||||
<button className="ep-modal-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ep-modal-body ep-gh-body">
|
||||
{loading ? (
|
||||
<div className="ep-gh-empty">
|
||||
<div className="ep-skeleton">
|
||||
<div className="ep-skeleton-item ep-skeleton-item--header" />
|
||||
<div className="ep-skeleton-item" />
|
||||
<div className="ep-skeleton-item" />
|
||||
<div className="ep-skeleton-item" />
|
||||
</div>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="ep-gh-empty">
|
||||
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
||||
<span>暂无生成记录</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ep-gh-table">
|
||||
<thead>
|
||||
<tr className="ep-gh-table-header-row">
|
||||
<th className="ep-gh-th">任务ID</th>
|
||||
<th className="ep-gh-th">状态</th>
|
||||
<th className="ep-gh-th">创建时间</th>
|
||||
<th className="ep-gh-th">更新时间</th>
|
||||
{onCancel && <th className="ep-gh-th">操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((gen) => {
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`
|
||||
const canCancel = gen.status === "rendering" || gen.status === "editing"
|
||||
return (
|
||||
<tr key={gen.id} className="ep-gh-table-row">
|
||||
<td className="ep-gh-td ep-gh-td-id">
|
||||
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td">
|
||||
<span className={`ep-gh-status-tag ${statusClass}`}>
|
||||
{PLAN_STATUS_LABELS[gen.status] || gen.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"}
|
||||
</td>
|
||||
{onCancel && (
|
||||
<td className="ep-gh-td ep-gh-td-action">
|
||||
{canCancel ? (
|
||||
<button
|
||||
className="ep-gh-cancel-btn"
|
||||
onClick={() => onCancel(gen.id)}
|
||||
disabled={cancelLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<span className="ep-gh-action-placeholder">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-modal-footer">
|
||||
<button className="ep-btn ep-btn-secondary" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationHistoryModal
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 入口文件(向后兼容)
|
||||
* 实际实现已移至 ./generation-progress-modal/ 目录
|
||||
*/
|
||||
export { default } from "./generation-progress-modal"
|
||||
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
|
||||
@@ -16,9 +16,17 @@ interface BgmSelectorProps {
|
||||
onClose: () => void
|
||||
config: BgmMixConfig
|
||||
onChange: (config: BgmMixConfig) => void
|
||||
/** 模板/草稿 ID,用于请求 BGM 预设 */
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
templateId,
|
||||
}) => {
|
||||
const {
|
||||
presets,
|
||||
loading,
|
||||
@@ -30,7 +38,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChan
|
||||
loadPresets,
|
||||
handlePreview,
|
||||
stopPreview,
|
||||
} = useBgmSelector(open)
|
||||
} = useBgmSelector(open, templateId)
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CATEGORY_LIST: {
|
||||
* BGM 选择器数据与交互 Hook
|
||||
* 封装列表加载、搜索、分类筛选、试听播放逻辑
|
||||
*/
|
||||
export function useBgmSelector(open: boolean) {
|
||||
export function useBgmSelector(open: boolean, templateId?: string) {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
|
||||
@@ -30,19 +30,23 @@ export function useBgmSelector(open: boolean) {
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
if (!templateId) {
|
||||
setPresets([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {}
|
||||
if (activeCategory !== "all") params.category = activeCategory
|
||||
if (keyword.trim()) params.keyword = keyword.trim()
|
||||
const data = await getBgmPresets(params)
|
||||
const data = await getBgmPresets(templateId, params)
|
||||
setPresets(data)
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeCategory, keyword])
|
||||
}, [activeCategory, keyword, templateId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets()
|
||||
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
import { getStepLabel, getStatusColor } from "./constants"
|
||||
|
||||
interface ProgressPhaseProps {
|
||||
open: boolean
|
||||
task: TaskItem | null
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** progress(进度轮询)阶段弹窗 */
|
||||
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
|
||||
const progress = task?.progress ?? 0
|
||||
const status = task?.status ?? ""
|
||||
const currentStep = task?.current_step ?? ""
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const stepColor = getStatusColor(status, currentStep)
|
||||
|
||||
return (
|
||||
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
|
||||
<div className="ep-gen-progress">
|
||||
{/* 进度环 */}
|
||||
<div className="ep-gen-progress-ring-wrap">
|
||||
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
||||
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
|
||||
<circle
|
||||
className="ep-gen-progress-ring-fill"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * 52}`,
|
||||
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
|
||||
stroke: stepColor,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 当前步骤 */}
|
||||
<div className="ep-gen-step-text">
|
||||
{userMessage || getStepLabel(currentStep) || "处理中…"}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="ep-gen-progress-bar">
|
||||
<div
|
||||
className="ep-gen-progress-bar-fill"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: stepColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
interface ResultPhaseProps {
|
||||
open: boolean
|
||||
phase: "completed" | "failed"
|
||||
task: TaskItem | null
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** completed / failed(结果)阶段弹窗 */
|
||||
export const ResultPhase: React.FC<ResultPhaseProps> = ({
|
||||
open,
|
||||
phase,
|
||||
task,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const errorMessage = task?.error_message ?? ""
|
||||
const retryable = task?.retryable ?? false
|
||||
const handleClose = onClose || onCancel
|
||||
|
||||
if (phase === "completed") {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="✅ 生成完成"
|
||||
footer={null}
|
||||
onCancel={handleClose}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result">
|
||||
<div className="ep-gen-result-icon">🎉</div>
|
||||
<div className="ep-gen-result-title">视频生成完成!</div>
|
||||
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
|
||||
<div className="ep-gen-result-actions">
|
||||
<Button buttonType="primary" onClick={handleClose}>
|
||||
查看结果
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="❌ 生成失败"
|
||||
footer={null}
|
||||
onCancel={handleClose}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result ep-gen-result--error">
|
||||
<div className="ep-gen-result-icon">😥</div>
|
||||
<div className="ep-gen-result-title">视频生成失败</div>
|
||||
{(errorMessage || userMessage) && (
|
||||
<div className="ep-gen-result-msg ep-gen-result-msg--error">
|
||||
{errorMessage || userMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-gen-result-actions">
|
||||
{retryable && onRetry && (
|
||||
<Button buttonType="primary" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="secondary" onClick={handleClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
|
||||
interface SetupPhaseProps {
|
||||
open: boolean
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
submitting: boolean
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** setup(配置)阶段弹窗 */
|
||||
export const SetupPhase: React.FC<SetupPhaseProps> = ({
|
||||
open,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
submitting,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
}) => (
|
||||
<Modal
|
||||
open={open}
|
||||
title="使用模板生成视频"
|
||||
confirmLoading={submitting}
|
||||
onOk={onGenerate}
|
||||
onCancel={onCancel}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-setup">
|
||||
<label className="ep-gen-field-label">配音时长(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-gen-duration-input"
|
||||
placeholder="请输入配音时长"
|
||||
value={voiceoverDuration ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value ? Number(e.target.value) : null
|
||||
onDurationChange(v)
|
||||
}}
|
||||
min={1}
|
||||
max={600}
|
||||
/>
|
||||
<div className="ep-gen-estimate">
|
||||
预估总时长:<strong>{estimatedDuration}s</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
/* ──────────── 步骤文案映射 ──────────── */
|
||||
|
||||
export const STEP_LABELS: Record<string, string> = {
|
||||
queued: "排队中…",
|
||||
preparing: "准备素材…",
|
||||
generating_video: "渲染视频中…",
|
||||
adding_effects: "添加特效…",
|
||||
composing: "合成中…",
|
||||
encoding: "编码输出中…",
|
||||
completed: "生成完成!",
|
||||
failed: "生成失败",
|
||||
}
|
||||
|
||||
export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
|
||||
|
||||
/* ──────────── 状态徽标颜色 ──────────── */
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
queued: "#6b7280",
|
||||
pending: "#6b7280",
|
||||
preparing: "#f59e0b",
|
||||
generating_video: "#4f46e5",
|
||||
adding_effects: "#7c3aed",
|
||||
composing: "#2563eb",
|
||||
encoding: "#0891b2",
|
||||
completed: "#10b981",
|
||||
failed: "#ef4444",
|
||||
}
|
||||
|
||||
export const getStatusColor = (status: string, currentStep: string) =>
|
||||
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 任务 2.17
|
||||
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
||||
* V21 设计系统,CSS 类名前缀 ep-gen-
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import type { GenPhase, GenerationProgressModalProps } from "./types"
|
||||
import { SetupPhase } from "./SetupPhase"
|
||||
import { ProgressPhase } from "./ProgressPhase"
|
||||
import { ResultPhase } from "./ResultPhase"
|
||||
|
||||
/* 重新导出类型,保持向后兼容 */
|
||||
export type { GenPhase, GenerationProgressModalProps }
|
||||
|
||||
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
open,
|
||||
phase,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
task,
|
||||
submitting,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
|
||||
const prevOpen = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevOpen.current && !open) {
|
||||
/* modal just closed — parent handles reset */
|
||||
}
|
||||
prevOpen.current = open
|
||||
}, [open])
|
||||
|
||||
/* setup 阶段 */
|
||||
if (phase === "setup") {
|
||||
return (
|
||||
<SetupPhase
|
||||
open={open}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={estimatedDuration}
|
||||
submitting={submitting}
|
||||
onDurationChange={onDurationChange}
|
||||
onGenerate={onGenerate}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* progress 阶段 */
|
||||
if (phase === "progress") {
|
||||
return <ProgressPhase open={open} task={task} onCancel={onCancel} />
|
||||
}
|
||||
|
||||
/* completed / failed 阶段 */
|
||||
return (
|
||||
<ResultPhase
|
||||
open={open}
|
||||
phase={phase as "completed" | "failed"}
|
||||
task={task}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationProgressModal
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
export type GenPhase = "setup" | "progress" | "completed" | "failed"
|
||||
|
||||
export interface GenerationProgressModalProps {
|
||||
open: boolean
|
||||
phase: GenPhase
|
||||
|
||||
/* setup 阶段 */
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
|
||||
/* progress / 结果阶段 */
|
||||
task: TaskItem | null
|
||||
|
||||
/* 通用 */
|
||||
submitting: boolean
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -72,6 +72,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
@@ -171,7 +172,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
previewTaskId: "",
|
||||
sourceEditPlanId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer, isWebCodecsSupported } from "../hooks/useCanvasPlayer"
|
||||
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
assets: AssetItem[]
|
||||
@@ -82,7 +82,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
const useWebCodecs = isWebCodecsSupported()
|
||||
// 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
|
||||
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
|
||||
const useWebCodecs = false
|
||||
|
||||
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
@@ -112,12 +114,30 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
[segments],
|
||||
)
|
||||
|
||||
// WebCodecs 解码失败后强制走 video fallback
|
||||
const [forceVideoFallback, setForceVideoFallback] = useState(false)
|
||||
|
||||
const handleCanvasError = useCallback((err: Error) => {
|
||||
console.error("[FrontendPreviewPlayer] Canvas decode Error, switching to video fallback:", err)
|
||||
setForceVideoFallback(true)
|
||||
}, [])
|
||||
|
||||
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
|
||||
canvasRef,
|
||||
canvasSegments,
|
||||
useWebCodecs ? canvasTitle : undefined,
|
||||
useWebCodecs && !forceVideoFallback ? canvasSegments : [],
|
||||
useWebCodecs && !forceVideoFallback ? canvasTitle : undefined,
|
||||
handleCanvasError,
|
||||
useWebCodecs && !forceVideoFallback,
|
||||
)
|
||||
|
||||
// WebCodecs 报告解码失败时自动切换到 video fallback
|
||||
useEffect(() => {
|
||||
if (canvasState.hasDecodeError && !forceVideoFallback) {
|
||||
console.warn("[FrontendPreviewPlayer] hasDecodeError detected, forcing video fallback")
|
||||
setForceVideoFallback(true)
|
||||
}
|
||||
}, [canvasState.hasDecodeError, forceVideoFallback])
|
||||
|
||||
// ── Video 播放器(fallback 路径) ──
|
||||
const {
|
||||
isPlaying: videoIsPlaying,
|
||||
@@ -130,12 +150,13 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
// 选择哪条路径的状态
|
||||
const isPlaying = useWebCodecs ? canvasState.isPlaying : videoIsPlaying
|
||||
const currentTime = useWebCodecs ? canvasState.currentTime : videoCurrentTime
|
||||
const totalDuration = useWebCodecs ? canvasState.duration : videoTotalDuration
|
||||
const canPlay = useWebCodecs ? canvasState.isReady : videoCanPlay
|
||||
const isBuffering = useWebCodecs ? canvasState.isBuffering : false
|
||||
// 选择哪条路径的状态(WebCodecs 解码失败时强制走 video fallback)
|
||||
const effectiveUseWebCodecs = useWebCodecs && !forceVideoFallback
|
||||
const isPlaying = effectiveUseWebCodecs ? canvasState.isPlaying : videoIsPlaying
|
||||
const currentTime = effectiveUseWebCodecs ? canvasState.currentTime : videoCurrentTime
|
||||
const totalDuration = effectiveUseWebCodecs ? canvasState.duration : videoTotalDuration
|
||||
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
|
||||
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
|
||||
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
@@ -172,16 +193,18 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
}, [isPlaying, currentTime])
|
||||
|
||||
// 片段切换时同步音频(仅 fallback 路径需要)
|
||||
const segmentSyncKey = useWebCodecs ? -1 : videoCurrentSegIdx
|
||||
const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src || !isPlaying) return
|
||||
audio.currentTime = currentTime
|
||||
}, [segmentSyncKey, isPlaying, currentTime])
|
||||
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segmentSyncKey, isPlaying])
|
||||
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
if (useWebCodecs) {
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.seek(time)
|
||||
} else {
|
||||
videoSeekTo(time)
|
||||
@@ -191,11 +214,11 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
audio.currentTime = time
|
||||
}
|
||||
},
|
||||
[useWebCodecs, canvasControls, videoSeekTo],
|
||||
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
|
||||
)
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (useWebCodecs) {
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) {
|
||||
canvasControls.pause()
|
||||
} else {
|
||||
@@ -204,7 +227,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
} else {
|
||||
videoTogglePlayPause()
|
||||
}
|
||||
}, [useWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
||||
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -247,24 +270,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
// ── Canvas ResizeObserver ──
|
||||
// ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ──
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const container = canvasContainerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect
|
||||
if (width > 0 && height > 0) {
|
||||
canvas.width = width * window.devicePixelRatio
|
||||
canvas.height = height * window.devicePixelRatio
|
||||
}
|
||||
}
|
||||
})
|
||||
ro.observe(container)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
@@ -290,6 +297,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
|
||||
return (
|
||||
<div
|
||||
className="xx-preview-empty"
|
||||
@@ -308,6 +316,19 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)" }}>加载中...</p>
|
||||
</>
|
||||
) : showDecodeError ? (
|
||||
<>
|
||||
<PlayCircleOutlined style={{ fontSize: 48, color: "#ef4444", marginBottom: 12 }} />
|
||||
<p className="xx-preview-empty-title" style={{ color: "rgba(255,255,255,0.9)" }}>
|
||||
视频解码失败
|
||||
</p>
|
||||
<p
|
||||
className="xx-preview-empty-desc"
|
||||
style={{ color: "rgba(255,255,255,0.6)", maxWidth: 300, textAlign: "center" }}
|
||||
>
|
||||
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined
|
||||
@@ -324,7 +345,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
return (
|
||||
<>
|
||||
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
|
||||
{useWebCodecs && (
|
||||
{effectiveUseWebCodecs && (
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
style={{
|
||||
@@ -345,8 +366,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Video 渲染层(fallback 路径) ── */}
|
||||
{!useWebCodecs &&
|
||||
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
|
||||
{!effectiveUseWebCodecs &&
|
||||
segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
@@ -354,9 +375,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
preload={
|
||||
i === videoCurrentSegIdx ? "auto" : i === videoCurrentSegIdx + 1 ? "metadata" : "none"
|
||||
}
|
||||
preload="auto"
|
||||
src={seg.videoUrl}
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -415,7 +434,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{useWebCodecs ? "Canvas" : `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
||||
{`片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
||||
</div>
|
||||
|
||||
{/* 控制条 */}
|
||||
|
||||
@@ -123,6 +123,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
presetVoices,
|
||||
} = props
|
||||
|
||||
/* 当前模板的 segments,传给 Step2 构建 clips */
|
||||
const currentTemplate = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
const templateSegments = currentTemplate?.segments
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
@@ -141,6 +145,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSelectedMaterialsChange={onSelectedMaterialsChange}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
templateSegments={templateSegments}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -156,6 +162,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
@@ -182,6 +189,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
duration={duration}
|
||||
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
|
||||
selectedTemplate={selectedTemplate}
|
||||
titleSettings={titleSettings}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
|
||||
@@ -16,6 +16,7 @@ import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { getFontFamily } from "../constants"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
@@ -87,7 +88,7 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac
|
||||
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 // fallback
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: settings.font || "思源黑体",
|
||||
fontFamily: getFontFamily(settings.font),
|
||||
fontSize: `${fontSizePx}px`,
|
||||
color: settings.color || "#ffffff",
|
||||
fontWeight: settings.bold ? 700 : 400,
|
||||
@@ -176,7 +177,12 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
|
||||
position: "absolute",
|
||||
}}
|
||||
>
|
||||
{displayTitle}
|
||||
{displayTitle.split("/").map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -193,7 +199,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
titleSettings,
|
||||
voiceAudioUrl,
|
||||
}) => {
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Step 2 素材选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { useStep2Materials } from "../hooks/useStep2Materials"
|
||||
import MaterialModeTabs from "./material/MaterialModeTabs"
|
||||
import ManualMaterialList from "./material/ManualMaterialList"
|
||||
@@ -15,6 +16,10 @@ interface Step2MaterialSelectProps {
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
|
||||
@@ -17,6 +17,16 @@ interface Step5VoiceSelectProps {
|
||||
}
|
||||
|
||||
/** 格式化时长 mm:ss */
|
||||
/** 获取素材实际时长(优先顶层 duration,fallback 到 metadata.duration) */
|
||||
const getDuration = (item: AssetItem): number => {
|
||||
return item.duration ?? (item.metadata?.duration as number) ?? 0
|
||||
}
|
||||
|
||||
/** 获取素材实际文件大小 */
|
||||
const getFileSize = (item: AssetItem): number => {
|
||||
return item.file_size ?? (item.metadata?.file_size as number) ?? 0
|
||||
}
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
@@ -89,7 +99,7 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
// 如果启用了时长校验,且配音时长不足
|
||||
if (totalVideoDuration > 0) {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (material && (material.duration || 0) < totalVideoDuration) {
|
||||
if (material && getDuration(material) < totalVideoDuration) {
|
||||
setPendingVoiceId(id)
|
||||
setDurationWarningOpen(true)
|
||||
return
|
||||
@@ -271,25 +281,24 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{formatDuration(item.duration)}
|
||||
{totalVideoDuration > 0 &&
|
||||
(Number(item.duration) || 0) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
{formatDuration(getDuration(item))}
|
||||
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatFileSize(item.file_size)}</span>
|
||||
<span>{formatFileSize(getFileSize(item))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -318,7 +327,9 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
return (
|
||||
<p>
|
||||
该配音时长(
|
||||
<strong>{pendingMaterial ? formatDuration(pendingMaterial.duration) : "--"}</strong>
|
||||
<strong>
|
||||
{pendingMaterial ? formatDuration(getDuration(pendingMaterial)) : "--"}
|
||||
</strong>
|
||||
)短于视频总时长(
|
||||
<strong>{formatDuration(totalVideoDuration)}</strong>
|
||||
),播放时配音可能提前结束,建议选择更长的配音素材。
|
||||
|
||||
@@ -14,6 +14,8 @@ interface Step6CoverSettingsProps {
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
titleSettings?: import("../types").TitleSettings
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
@@ -40,6 +42,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
duration: props.duration,
|
||||
assetIds: props.assetIds,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
titleSettings: props.titleSettings,
|
||||
})
|
||||
|
||||
const handleAutoGenerate = () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl,
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 9:16 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 标题预设样式网格
|
||||
*/
|
||||
import React from "react"
|
||||
import { getFontFamily } from "../../constants"
|
||||
|
||||
interface TitlePresetItem {
|
||||
key: string
|
||||
@@ -35,7 +36,7 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
|
||||
>
|
||||
<span
|
||||
className="xx-title-preset-preview-text"
|
||||
style={{ ...p.previewStyle, ...(fontFamily ? { fontFamily } : {}) }}
|
||||
style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
|
||||
>
|
||||
标题
|
||||
</span>
|
||||
|
||||
@@ -56,6 +56,21 @@ export const FONT_OPTIONS = [
|
||||
"华康俪金黑",
|
||||
]
|
||||
|
||||
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
|
||||
export const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
思源黑体: '"Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
思源宋体: '"Source Han Serif SC", "Noto Serif SC", "Songti SC", "SimSun", serif',
|
||||
苹方: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
|
||||
PingFang: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
|
||||
微软雅黑: '"Microsoft YaHei", "PingFang SC", sans-serif',
|
||||
楷体: '"KaiTi", "STKaiti", "DFKai-SB", serif',
|
||||
华康俪金黑: '"华康俪金黑", "DFLiJinHei-W8", "Source Han Sans SC", "Microsoft YaHei", sans-serif',
|
||||
}
|
||||
|
||||
export function getFontFamily(font: string): string {
|
||||
return FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
|
||||
}
|
||||
|
||||
/* ── 标题样式预设 ── */
|
||||
export const TITLE_PRESETS = [
|
||||
{
|
||||
|
||||
@@ -891,7 +891,7 @@
|
||||
|
||||
/* ── 视频预览 ── */
|
||||
.xx-preview-video {
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
@@ -1516,7 +1516,7 @@
|
||||
.xx-smart-match-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2144,7 +2144,7 @@
|
||||
}
|
||||
|
||||
.xx-cover-frame-placeholder {
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
@@ -2247,7 +2247,7 @@
|
||||
}
|
||||
|
||||
.xx-cover-upload-area {
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
@@ -2308,7 +2308,9 @@
|
||||
|
||||
.xx-cover-preview-box {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-width: 180px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
@@ -2666,7 +2668,7 @@
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2772,7 +2774,7 @@
|
||||
|
||||
.xx-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 预览任务的 task_id(用于新确认生成 API) */
|
||||
previewTaskId: string
|
||||
/** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */
|
||||
sourceEditPlanId?: string | null
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
@@ -1,87 +1,146 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor"
|
||||
import axios from "axios"
|
||||
import { getGenerationTask } from "@/api/tasks/tasks"
|
||||
import { getGenerationTaskResults } from "@/api/template-editor"
|
||||
import { safeExtractError } from "./errorUtils"
|
||||
|
||||
interface UseGenerationPollingOptions {
|
||||
templateId: string
|
||||
onProgress: (progress: number) => void
|
||||
onComplete: (videos: unknown[]) => void
|
||||
onFailed: (errorMsg: string) => void
|
||||
}
|
||||
|
||||
/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */
|
||||
const MAX_RETRYABLE_ERRORS = 10
|
||||
/** 获取结果的最大重试次数 */
|
||||
const MAX_RESULTS_RETRIES = 3
|
||||
|
||||
/**
|
||||
* 生成状态轮询 Hook
|
||||
* 轮询生成状态,更新进度,处理完成/失败
|
||||
* 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id})
|
||||
*
|
||||
* 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态,
|
||||
* 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks
|
||||
* 返回的 task_id 轮询任务详情,不再依赖 plan。
|
||||
*
|
||||
* 错误处理:
|
||||
* - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试
|
||||
* - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次
|
||||
* - 任务完成后获取结果失败会重试 MAX_RESULTS_RETRIES 次,仍失败则 onFailed
|
||||
*/
|
||||
export const useGenerationPolling = ({
|
||||
templateId,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onFailed,
|
||||
}: UseGenerationPollingOptions) => {
|
||||
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const cancelledRef = useRef(false)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
if (progressTimer.current) {
|
||||
clearTimeout(progressTimer.current)
|
||||
progressTimer.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
const poll = async () => {
|
||||
/** 任务完成后拉取结果列表,带重试 */
|
||||
const fetchResultsWithRetry = useCallback(
|
||||
async (taskId: string, attempt = 0): Promise<unknown[] | null> => {
|
||||
try {
|
||||
const data = await getGenerationStatus(templateId)
|
||||
|
||||
if (data.plan_status === "completed") {
|
||||
onProgress(100)
|
||||
// 获取生成的视频结果
|
||||
let videos: unknown[] = []
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
videos = await getGenerationTaskResults(data.generation_task_id)
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err)
|
||||
}
|
||||
}
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
return await getGenerationTaskResults(taskId)
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return null
|
||||
console.error(`[获取生成结果失败] 第 ${attempt + 1} 次`, err)
|
||||
if (attempt < MAX_RESULTS_RETRIES - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)))
|
||||
return fetchResultsWithRetry(taskId, attempt + 1)
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
const dataAny = data as unknown as Record<string, unknown>
|
||||
const rawMsg =
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(Array.isArray(data.clips)
|
||||
? (data.clips as { status: string; error_message?: string }[]).find(
|
||||
(c) => c.status === "failed",
|
||||
)?.error_message
|
||||
: undefined) ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
const errorMsg = safeExtractError(rawMsg)
|
||||
console.error("[生成失败] templateId:", templateId, "响应:", data)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
const clips = data.clips || []
|
||||
const total = clips.length || 1
|
||||
const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length
|
||||
onProgress(Math.round((done / total) * 100))
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
} catch (pollErr) {
|
||||
console.error("[轮询出错] templateId:", templateId, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000)
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
}, [templateId, onProgress, onComplete, onFailed])
|
||||
const startPolling = useCallback(
|
||||
(taskId: string) => {
|
||||
cancelledRef.current = false
|
||||
let consecutiveErrors = 0
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelledRef.current) return
|
||||
try {
|
||||
const task = await getGenerationTask(taskId)
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed") {
|
||||
onProgress(100)
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
if (videos === null) {
|
||||
const errorMsg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
|
||||
console.error("[生成结果获取失败] taskId:", taskId)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
|
||||
if (task.status === "failed" || task.status === "cancelled") {
|
||||
const rawMsg =
|
||||
task.error_info?.error_message ||
|
||||
task.error_message ||
|
||||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
|
||||
const errorMsg = safeExtractError(rawMsg)
|
||||
console.error("[生成失败] taskId:", taskId, "响应:", task)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / waiting / running — 继续轮询
|
||||
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
|
||||
onProgress(pct)
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
} catch (pollErr) {
|
||||
if (cancelledRef.current) return
|
||||
console.error("[轮询出错] taskId:", taskId, pollErr)
|
||||
|
||||
// 4xx 不可恢复,立即失败
|
||||
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
|
||||
if (status && status >= 400 && status < 500) {
|
||||
const msg =
|
||||
(axios.isAxiosError(pollErr) &&
|
||||
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
|
||||
?.detail) ||
|
||||
(axios.isAxiosError(pollErr) &&
|
||||
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
|
||||
?.message) ||
|
||||
`查询任务失败 (${status})`
|
||||
const errorMsg = safeExtractError(msg)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
consecutiveErrors += 1
|
||||
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
|
||||
const errorMsg = "任务状态查询连续失败,请稍后在任务列表查看结果"
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
progressTimer.current = setTimeout(poll, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 1500)
|
||||
},
|
||||
[onProgress, onComplete, onFailed, fetchResultsWithRetry],
|
||||
)
|
||||
|
||||
return { startPolling, clearTimer }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 草稿自动保存工具 Hook
|
||||
*
|
||||
* 背景:后端 PUT /templates/{id}/editor 的 config 是「整体替换」语义,
|
||||
* 直接发送 { config: { asset_ids } } 会把 title 等其他字段覆盖掉。
|
||||
* 本 Hook 统一执行「GET 当前 config → 浅合并新字段 → PUT 回去」,
|
||||
* 并用串行队列 + AbortController 保证:
|
||||
* - 同一时刻只有一个保存请求在飞
|
||||
* - 快速连续变化时只提交最后一次
|
||||
* - 组件卸载时取消未完成请求
|
||||
* - 保存失败时保留补丁,自动重试(指数退避,最多 5 次)
|
||||
*
|
||||
* 保存失败只 console.warn,不弹窗、不阻塞。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { getEditPlan, updateEditPlan } from "@/api/template-editor"
|
||||
|
||||
type ConfigPatch = Record<string, unknown>
|
||||
|
||||
/** 最大自动重试次数 */
|
||||
const MAX_RETRIES = 5
|
||||
/** 初始重试延迟(ms),每次翻倍 */
|
||||
const BASE_RETRY_DELAY = 1000
|
||||
|
||||
export function useDraftAutoSave(templateId?: string) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// 待合并的补丁队列(解决「保存进行中又来了新变化」)
|
||||
const pendingPatchRef = useRef<ConfigPatch | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
const templateIdRef = useRef(templateId)
|
||||
templateIdRef.current = templateId
|
||||
|
||||
const flush = useCallback(async (retryCount = 0) => {
|
||||
const tid = templateIdRef.current
|
||||
if (!tid) return
|
||||
// 已有保存在飞:把新补丁暂存,等当前请求结束后再合并一次
|
||||
if (savingRef.current) return
|
||||
|
||||
// 快照当前补丁,但先不清空 —— 成功后才清除,失败时保留以便重试
|
||||
const patchToSave = pendingPatchRef.current
|
||||
if (!patchToSave) {
|
||||
savingRef.current = false
|
||||
return
|
||||
}
|
||||
savingRef.current = true
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
try {
|
||||
// 1. 读当前 config(拿最新,避免覆盖别人/别的步骤写入的字段)
|
||||
const current = await getEditPlan(tid)
|
||||
if (controller.signal.aborted) return
|
||||
const merged = { ...(current.config || {}), ...patchToSave }
|
||||
// 2. 写回完整合并后的 config
|
||||
await updateEditPlan(tid, { config: merged }, controller.signal)
|
||||
// 3. 保存成功才清除已保存的补丁
|
||||
// (保存期间可能有新补丁进来,只清除我们已经保存的部分)
|
||||
pendingPatchRef.current = null
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name === "CanceledError" || name === "AbortError") {
|
||||
// 组件卸载或新请求取消,不重试
|
||||
return
|
||||
}
|
||||
console.warn("[useDraftAutoSave] 自动保存草稿失败:", err)
|
||||
|
||||
// 保存失败:把本次尝试保存的补丁合并回 pendingPatchRef
|
||||
// (保存期间可能有新补丁,新补丁优先)
|
||||
pendingPatchRef.current = {
|
||||
...patchToSave,
|
||||
...(pendingPatchRef.current || {}),
|
||||
}
|
||||
|
||||
// 指数退避重试
|
||||
if (retryCount < MAX_RETRIES && !controller.signal.aborted) {
|
||||
const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount)
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush(retryCount + 1)
|
||||
}, delay)
|
||||
}
|
||||
// 超过最大重试次数后,补丁仍保留在 pendingPatchRef 中,
|
||||
// 下次 scheduleSave 触发时会一起带上
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
// 保存期间又积累了新变化(且不是在重试路径中),再触发一次
|
||||
if (pendingPatchRef.current && !controller.signal.aborted && retryCount === 0) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 调度一次自动保存(防抖)
|
||||
* @param patch 要合并进 config 的局部字段
|
||||
* @param delay 防抖毫秒数
|
||||
*/
|
||||
const scheduleSave = useCallback(
|
||||
(patch: ConfigPatch, delay = 500) => {
|
||||
const tid = templateIdRef.current
|
||||
if (!tid) return
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
// 累计补丁(同一周期内多次变化合并成一次写入)
|
||||
pendingPatchRef.current = { ...(pendingPatchRef.current || {}), ...patch }
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush()
|
||||
}, delay)
|
||||
},
|
||||
[flush],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { scheduleSave }
|
||||
}
|
||||
|
||||
export default useDraftAutoSave
|
||||
@@ -82,6 +82,13 @@ export interface GenerateFormState {
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
|
||||
/**
|
||||
* 传给 Worker 的 source_edit_plan_id。
|
||||
* 优先使用 URL 中的 edit_plan_id;URL 没有时回退为 selectedTemplate(模板 ID),
|
||||
* 因为 Step2 的 clips 就是用 selectedTemplate 作为 plan_id 写入的。
|
||||
*/
|
||||
sourceEditPlanId: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
@@ -100,6 +107,11 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
/* ── 模板选择 ── */
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
|
||||
|
||||
/* ── source_edit_plan_id:URL 优先,否则回退到 selectedTemplate ── */
|
||||
// selectedTemplate 是异步加载的(react-query),组件重新渲染时此值会自动更新,
|
||||
// 因此最终传给 useGenerateVideo 的 sourceEditPlanId 能在模板就绪后拿到正确值。
|
||||
const sourceEditPlanId = editPlanId || selectedTemplate || null
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
@@ -189,6 +201,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { confirmGeneration, createPreview } from "@/api/generation"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
@@ -34,7 +34,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}, [])
|
||||
|
||||
const { startPolling, clearTimer } = useGenerationPolling({
|
||||
templateId: selectedTemplate,
|
||||
onProgress: handleProgress,
|
||||
onComplete: handleComplete,
|
||||
onFailed: handleFailed,
|
||||
@@ -55,7 +54,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
|
||||
// 解析分辨率
|
||||
const ratio = props.videoRatio || "9:16"
|
||||
let outputWidth: number
|
||||
let outputHeight: number
|
||||
@@ -87,20 +86,26 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
outputHeight = 1920
|
||||
}
|
||||
|
||||
// 获取或创建后端任务 ID
|
||||
// 预览改为前端播放后,不再有预览任务,需要在此处创建
|
||||
let taskId = props.previewTaskId
|
||||
if (!taskId) {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
voice_ids: undefined,
|
||||
title_config: props.titleSettings?.title
|
||||
? {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
|
||||
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
// 直接创建正式生成任务
|
||||
const taskResp = await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: coverUrl,
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
@@ -109,20 +114,17 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
taskId = previewResp.task_id
|
||||
}
|
||||
|
||||
await confirmGeneration(taskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
custom_title: props.titleSettings.title || "",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
startPolling()
|
||||
// 从创建响应直接拿 task_id,改用新接口轮询
|
||||
const taskId = taskResp.items?.[0]?.id
|
||||
if (!taskId) {
|
||||
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
|
||||
}
|
||||
startPolling(taskId)
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
|
||||
@@ -1,55 +1,37 @@
|
||||
/**
|
||||
* 素材片段调度器 Hook(多 video 元素方案 v2)
|
||||
* 每个片段对应一个独立 <video> 元素,全部预加载,通过 display 切换实现无缝播放
|
||||
* 替代单 video + 切 src 方案,消除片段切换延迟
|
||||
* 素材片段调度器 Hook(多 video 元素方案 v3)
|
||||
*
|
||||
* v3 修复:
|
||||
* - 所有动态状态存入 ref,tick 为稳定函数,彻底消除 RAF 闭包陷阱
|
||||
* - 片段切换时先启动下一个 video 再切可见性,消除冻屏间隔
|
||||
* - 进度更新 200ms 节流
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
/** 单个播放片段 */
|
||||
export interface PlaybackSegment {
|
||||
/** 素材 ID */
|
||||
assetId: string
|
||||
/** 素材视频 URL */
|
||||
videoUrl: string
|
||||
/** 片段在素材中的入点(秒) */
|
||||
startTime: number
|
||||
/** 片段在素材中的出点(秒) */
|
||||
endTime: number
|
||||
/** 片段在时间线中的顺序 */
|
||||
order: number
|
||||
}
|
||||
|
||||
/** 调度器返回 */
|
||||
export interface SegmentSchedulerState {
|
||||
/** 是否正在播放 */
|
||||
isPlaying: boolean
|
||||
/** 当前播放的全局时间(秒) */
|
||||
currentTime: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 当前片段索引 */
|
||||
currentSegmentIndex: number
|
||||
/** 当前片段的本地播放时间 */
|
||||
segmentLocalTime: number
|
||||
/** 是否已播完 */
|
||||
isEnded: boolean
|
||||
/** 是否可以播放(至少有 1 个片段) */
|
||||
canPlay: boolean
|
||||
/** 播放 */
|
||||
play: () => void
|
||||
/** 暂停 */
|
||||
pause: () => void
|
||||
/** 切换播放/暂停 */
|
||||
togglePlayPause: () => void
|
||||
/** 跳转到全局时间 */
|
||||
seekTo: (time: number) => void
|
||||
/** 每个片段对应的 video 元素 ref 数组 */
|
||||
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据全局时间定位对应的片段和本地时间
|
||||
*/
|
||||
function findSegmentAtTime(
|
||||
segments: PlaybackSegment[],
|
||||
globalTime: number,
|
||||
@@ -66,9 +48,6 @@ function findSegmentAtTime(
|
||||
return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每个片段的全局起始时间
|
||||
*/
|
||||
function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
const starts: number[] = []
|
||||
let acc = 0
|
||||
@@ -79,224 +58,242 @@ function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
return starts
|
||||
}
|
||||
|
||||
/**
|
||||
* useSegmentScheduler — 多 video 元素版素材片段调度器
|
||||
*
|
||||
* 核心改变:
|
||||
* - 每个片段对应一个独立 <video> 元素(由组件渲染,ref 传入)
|
||||
* - 所有 video 在挂载时即设置 src + preload="auto",浏览器自动预加载
|
||||
* - 切换片段仅改 currentSegmentIndex + display,无需重新 load
|
||||
* - 实现无缝切换,无加载延迟
|
||||
*/
|
||||
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
|
||||
/** 每个片段对应的 video 元素 ref(由组件 JSX 渲染并绑定) */
|
||||
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<number>(0)
|
||||
const rafRef = useRef(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
const lastTimeUpdateRef = useRef(0)
|
||||
|
||||
// 计算时间线
|
||||
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
|
||||
const totalDuration = useMemo(
|
||||
// 所有动态值存入 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
|
||||
|
||||
// 当前片段信息
|
||||
const currentSegment = segments[currentSegmentIndex] || null
|
||||
const segmentLocalTime = currentSegment
|
||||
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
|
||||
: 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)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 切换到指定片段
|
||||
* 不改变 src(video 已在 JSX 中设置),仅 seek + 等待可播
|
||||
*/
|
||||
const switchToSegment = useCallback(
|
||||
(index: number, seekToLocalTime?: number): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
// 暂停当前视频
|
||||
const prevVideo = videoRefs.current[currentSegmentIndex]
|
||||
if (prevVideo) prevVideo.pause()
|
||||
async (index: number, seekToLocalTime?: number) => {
|
||||
const segs = segmentsRef.current
|
||||
const video = videoRefs.current[index]
|
||||
if (!video || index >= segs.length) return
|
||||
|
||||
const video = videoRefs.current[index]
|
||||
if (!video || index >= segments.length) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const seg = segs[index]
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
const oldIdx = segIdxRef.current
|
||||
const oldVideo = videoRefs.current[oldIdx]
|
||||
|
||||
const seg = segments[index]
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
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
|
||||
}
|
||||
|
||||
// 如果已有足够帧数据,直接 resolve
|
||||
if (video.readyState >= 2) {
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
segIdxRef.current = index
|
||||
setCurrentSegmentIndex(index)
|
||||
|
||||
// 等待 canplay 事件
|
||||
const onCanPlay = () => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
clearTimeout(timeoutId)
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 10 秒超时保护
|
||||
const timeoutId = setTimeout(() => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
console.warn(
|
||||
`[useSegmentScheduler] 片段 ${index} 预加载超时 (10s), readyState=${video.readyState}`,
|
||||
)
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
}, 10000)
|
||||
|
||||
video.addEventListener("canplay", onCanPlay)
|
||||
})
|
||||
await waitForReady(video)
|
||||
},
|
||||
[segments, currentSegmentIndex],
|
||||
[waitForReady],
|
||||
)
|
||||
|
||||
/** 播放循环 — 检测片段边界并切换 */
|
||||
// 稳定的 tick 函数,空依赖,所有值从 ref 读取
|
||||
const tick = useCallback(() => {
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
const segs = segmentsRef.current
|
||||
const idx = segIdxRef.current
|
||||
const video = videoRefs.current[idx]
|
||||
|
||||
if (!video || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
const seg = segments[currentSegmentIndex]
|
||||
const seg = segs[idx]
|
||||
if (!seg) return
|
||||
|
||||
// 检查是否到达出点(容差 0.15s)
|
||||
if (video.currentTime >= seg.endTime - 0.15) {
|
||||
video.pause()
|
||||
const nextIndex = currentSegmentIndex + 1
|
||||
if (nextIndex < segments.length) {
|
||||
switchToSegment(nextIndex).then(() => {
|
||||
setIsPlaying(true)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
if (nextVideo) {
|
||||
const canPlay = () => {
|
||||
nextVideo
|
||||
.play()
|
||||
.catch((e) =>
|
||||
console.warn("[useSegmentScheduler] auto-play next segment failed:", e),
|
||||
)
|
||||
}
|
||||
if (nextVideo.readyState >= 3) {
|
||||
canPlay()
|
||||
} else {
|
||||
const timeout = setTimeout(canPlay, 300)
|
||||
nextVideo.addEventListener(
|
||||
"canplay",
|
||||
() => {
|
||||
clearTimeout(timeout)
|
||||
canPlay()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
// 预加载下一个片段
|
||||
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 =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
|
||||
(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(totalDuration)
|
||||
setCurrentTime(totalDurationRef.current)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const globalTime =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (video.currentTime - seg.startTime)
|
||||
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
|
||||
}
|
||||
|
||||
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)
|
||||
}, [segments, currentSegmentIndex, timelineStarts, totalDuration, switchToSegment])
|
||||
}, [])
|
||||
|
||||
/** 播放 */
|
||||
const play = useCallback(async () => {
|
||||
if (!canPlay) return
|
||||
|
||||
setIsEnded(false)
|
||||
|
||||
// 确保第一段可播放
|
||||
const firstVideo = videoRefs.current[0]
|
||||
if (firstVideo && currentSegmentIndex === 0 && firstVideo.readyState < 2) {
|
||||
await switchToSegment(0)
|
||||
}
|
||||
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
const idx = segIdxRef.current
|
||||
const video = videoRefs.current[idx]
|
||||
if (!video) return
|
||||
|
||||
try {
|
||||
const playPromise = video.play()
|
||||
if (playPromise !== undefined) {
|
||||
await playPromise
|
||||
if (idx === 0 && video.readyState < 2) {
|
||||
if (!video.src && segmentsRef.current[0]?.videoUrl) {
|
||||
video.src = segmentsRef.current[0].videoUrl
|
||||
video.load()
|
||||
}
|
||||
await waitForReady(video)
|
||||
}
|
||||
|
||||
try {
|
||||
await video.play()
|
||||
setIsPlaying(true)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch (err) {
|
||||
console.warn("[useSegmentScheduler] 播放失败:", err)
|
||||
}
|
||||
}, [canPlay, switchToSegment, tick, currentSegmentIndex])
|
||||
}, [canPlay, waitForReady, tick])
|
||||
|
||||
/** 暂停 */
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
const video = videoRefs.current[segIdxRef.current]
|
||||
if (video) video.pause()
|
||||
setIsPlaying(false)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}, [currentSegmentIndex])
|
||||
}, [])
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (isPlaying) {
|
||||
if (isPlayingRef.current) {
|
||||
pause()
|
||||
} else {
|
||||
if (isEnded) {
|
||||
// 播放结束后再次播放,从头开始
|
||||
setIsEnded(false)
|
||||
switchToSegment(0, segments[0]?.startTime).then(() => {
|
||||
const video = videoRefs.current[0]
|
||||
if (video) {
|
||||
video.play().catch((e) => console.warn("[useSegmentScheduler] restart play failed:", e))
|
||||
setIsPlaying(true)
|
||||
setCurrentTime(0)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
})
|
||||
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()
|
||||
}
|
||||
}
|
||||
}, [isPlaying, isEnded, pause, play, switchToSegment, segments, tick])
|
||||
}, [isEnded, pause, play, tick])
|
||||
|
||||
/** 跳转到指定全局时间 */
|
||||
const seekTo = useCallback(
|
||||
async (time: number) => {
|
||||
if (!canPlay) return
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDuration))
|
||||
const { index, localTime } = findSegmentAtTime(segments, clampedTime)
|
||||
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 !== currentSegmentIndex) {
|
||||
if (index !== segIdxRef.current) {
|
||||
await switchToSegment(index, localTime)
|
||||
} else {
|
||||
const video = videoRefs.current[index]
|
||||
@@ -305,48 +302,54 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
|
||||
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, totalDuration, segments, currentSegmentIndex, switchToSegment],
|
||||
[canPlay, switchToSegment, tick],
|
||||
)
|
||||
|
||||
// 确保 videoRefs 数组长度与 segments 一致 + 强制预加载
|
||||
useEffect(() => {
|
||||
videoRefs.current = videoRefs.current.slice(0, segments.length)
|
||||
while (videoRefs.current.length < segments.length) {
|
||||
videoRefs.current.push(null)
|
||||
}
|
||||
// 强制预加载:所有 video 元素挂载后,调用 load() 确保浏览器真正开始加载数据
|
||||
videoRefs.current.forEach((video) => {
|
||||
if (video) {
|
||||
video.load()
|
||||
}
|
||||
})
|
||||
}, [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,
|
||||
totalDuration: totalDurationData,
|
||||
currentSegmentIndex,
|
||||
segmentLocalTime,
|
||||
isEnded,
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { updateEditPlanClips } from "@/api/template-editor"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { buildClipsFromAssets } from "../utils/buildClipsFromAssets"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -14,6 +19,10 @@ interface UseStep2MaterialsProps {
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -23,6 +32,8 @@ export function useStep2Materials({
|
||||
onSelectedMaterialsChange,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
selectedTemplate,
|
||||
templateSegments,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
@@ -53,6 +64,69 @@ export function useStep2Materials({
|
||||
handleSmartMatch()
|
||||
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
|
||||
|
||||
/* ── Step2 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */
|
||||
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) return
|
||||
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
scheduleSave({ asset_ids: ids }, 500)
|
||||
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
|
||||
|
||||
/* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ── */
|
||||
const clipsTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const clipsAbortRef = useRef<AbortController | null>(null)
|
||||
const templateSegmentsRef = useRef(templateSegments)
|
||||
templateSegmentsRef.current = templateSegments
|
||||
const selectedTemplateRef = useRef(selectedTemplate)
|
||||
selectedTemplateRef.current = selectedTemplate
|
||||
const materialsRef = useRef(materials)
|
||||
materialsRef.current = materials
|
||||
const smartMatchedRef = useRef<AssetItem[]>(smartMatch.smartMatchedResults)
|
||||
smartMatchedRef.current = smartMatch.smartMatchedResults
|
||||
|
||||
useEffect(() => {
|
||||
const tid = selectedTemplateRef.current
|
||||
if (!tid) return
|
||||
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
if (!ids.length) return
|
||||
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
clipsTimerRef.current = setTimeout(async () => {
|
||||
// 取消上一次未完成的请求
|
||||
if (clipsAbortRef.current) clipsAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
clipsAbortRef.current = controller
|
||||
|
||||
const clips = buildClipsFromAssets({
|
||||
selectedIds: ids,
|
||||
materials: materialsRef.current.items,
|
||||
smartMatchedAssets: smartMatchedRef.current,
|
||||
templateSegments: templateSegmentsRef.current || [],
|
||||
})
|
||||
|
||||
try {
|
||||
await updateEditPlanClips(tid, clips, controller.signal)
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name !== "CanceledError" && name !== "AbortError") {
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
}
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => {
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
}
|
||||
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, templateSegments])
|
||||
|
||||
// 组件卸载时取消未完成请求
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
if (clipsAbortRef.current) clipsAbortRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
|
||||
@@ -4,17 +4,24 @@ import { getTitles } from "@/api/titles"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { useAiTitleGenerator } from "./useAiTitleGenerator"
|
||||
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
|
||||
import { useDraftAutoSave } from "../useDraftAutoSave"
|
||||
|
||||
interface UseStep4TitleProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
*/
|
||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||
export function useStep4Title({
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
selectedTemplate,
|
||||
}: UseStep4TitleProps) {
|
||||
// 标题库数据
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
@@ -42,6 +49,38 @@ export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4
|
||||
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
|
||||
const isFirstMount = useRef(true)
|
||||
|
||||
/* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */
|
||||
const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate)
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) return
|
||||
scheduleTitleSave(
|
||||
{
|
||||
title: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
},
|
||||
800,
|
||||
)
|
||||
}, [
|
||||
selectedTemplate,
|
||||
titleSettings.title,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
titleSettings.position,
|
||||
titleSettings.bold,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
scheduleTitleSave,
|
||||
])
|
||||
|
||||
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
|
||||
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../types/cover"
|
||||
import { generateCover } from "@/api/generation"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import { updateEditPlan } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
@@ -22,6 +24,8 @@ interface UseStep6CoverProps {
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
@@ -30,6 +34,7 @@ export function useStep6Cover({
|
||||
duration,
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
titleSettings,
|
||||
}: UseStep6CoverProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
@@ -92,6 +97,20 @@ export function useStep6Cover({
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
const thumbnailUrl = response.cover?.image_url || ""
|
||||
@@ -114,39 +133,107 @@ export function useStep6Cover({
|
||||
const anyErr = err as any
|
||||
const statusCode = anyErr?.response?.status
|
||||
|
||||
// 400 错误:后端缺少预览视频,自动创建后重试
|
||||
if (statusCode === 400) {
|
||||
console.log("[Step6] 后端返回 400,尝试自动创建预览渲染任务...")
|
||||
// 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errCode = anyErr?.response?.data?.code as string | undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errMsg = (anyErr?.response?.data?.message ||
|
||||
anyErr?.response?.data?.detail ||
|
||||
"") as string
|
||||
const isPreviewMissing =
|
||||
statusCode === 400 &&
|
||||
(errCode?.includes("PREVIEW") ||
|
||||
/预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
|
||||
errMsg,
|
||||
))
|
||||
|
||||
if (isPreviewMissing) {
|
||||
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
|
||||
message.info("正在准备预览视频,请稍候...")
|
||||
try {
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
source_edit_plan_id: selectedTemplate,
|
||||
duration: duration || 30,
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
// 轮询等待预览渲染完成
|
||||
// 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const poll = setInterval(async () => {
|
||||
let finished = false
|
||||
const done = (fn: () => void) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearTimeout(timeoutId)
|
||||
fn()
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
done(() => reject(new Error("预览生成超时,请稍后重试")))
|
||||
}, 120_000)
|
||||
const poll = async () => {
|
||||
if (finished) return
|
||||
try {
|
||||
const status = await getPreviewStatus(previewResp.task_id)
|
||||
if (status.status === "completed") {
|
||||
clearInterval(poll)
|
||||
resolve()
|
||||
// 保存预览视频地址到 plan.config.rendered_storage_key,
|
||||
// 供封面 API 的 E1 兜底路径定位渲染后的视频(含标题烧录)。
|
||||
// video_url 可能是完整 http(s) URL 或 OSS storage_key,两种格式后端都能处理。
|
||||
if (status.video_url) {
|
||||
try {
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
config: { rendered_storage_key: status.video_url },
|
||||
})
|
||||
} catch (saveErr) {
|
||||
console.warn(
|
||||
"[Step6] 保存 rendered_storage_key 失败(不阻塞封面重试):",
|
||||
saveErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
done(() => resolve())
|
||||
} else if (status.status === "failed") {
|
||||
clearInterval(poll)
|
||||
reject(new Error(status.error_message || "预览渲染失败"))
|
||||
done(() => reject(new Error(status.error_message || "预览渲染失败")))
|
||||
} else {
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
clearInterval(poll)
|
||||
reject(e)
|
||||
done(() => reject(e))
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
poll()
|
||||
})
|
||||
message.success("预览视频就绪,重新生成封面...")
|
||||
// 重试封面生成
|
||||
const retryResp = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const retryUrl = retryResp.cover?.image_url || ""
|
||||
if (retryUrl) {
|
||||
@@ -188,7 +275,15 @@ export function useStep6Cover({
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating, duration])
|
||||
}, [
|
||||
selectedTemplate,
|
||||
assetIds,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
generating,
|
||||
duration,
|
||||
titleSettings,
|
||||
])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 将选中素材 + 模板 segments 构建为 edit_plan_clips 写入数据。
|
||||
*
|
||||
* 逻辑必须与 FrontendPreviewPlayer.tsx 中 buildPlaybackSegments 完全一致:
|
||||
* assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
* tplSeg = templateSegments[i] || lastSegment
|
||||
* segDuration = clamp(assetDuration, tplSeg.duration_min, tplSeg.duration_max)
|
||||
* start_time = 0
|
||||
* // 关键:预览播放器中 endTime = min(startTime + segDuration, assetDuration)
|
||||
* // 因此 clips.duration 也必须用 min(segDuration, assetDuration) 截断,
|
||||
* // 避免素材实际时长比 clamp 后的 segDuration 短时,Worker 尝试读取不存在的片段
|
||||
* duration = min(segDuration, assetDuration)
|
||||
*
|
||||
* 预览播放器(Canvas 实时预览)直接在内存中构建 segments 播放,不读 edit_plan_clips;
|
||||
* 本函数产出的 clips 写入 DB 后由 Worker 渲染。两边用完全相同的时长计算,
|
||||
* 保证用户在编辑过程中看到的预览与最终生成视频一致。
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClipInput } from "@/api/template-editor"
|
||||
|
||||
interface BuildClipsOptions {
|
||||
/** 选中的素材 ID 列表(按选择顺序) */
|
||||
selectedIds: string[]
|
||||
/** 已加载的素材列表(用于查 duration) */
|
||||
materials: AssetItem[]
|
||||
/** 智能匹配返回的素材(auto 模式下可能不在 materials 列表中) */
|
||||
smartMatchedAssets?: AssetItem[]
|
||||
/** 模板 segments */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
export function buildClipsFromAssets({
|
||||
selectedIds,
|
||||
materials,
|
||||
smartMatchedAssets = [],
|
||||
templateSegments = [],
|
||||
}: BuildClipsOptions): EditPlanClipInput[] {
|
||||
if (!selectedIds.length) return []
|
||||
|
||||
// 合并两个素材来源,建立 id → asset 索引
|
||||
const assetMap = new Map<string, AssetItem>()
|
||||
for (const a of materials) assetMap.set(a.id, a)
|
||||
for (const a of smartMatchedAssets) assetMap.set(a.id, a)
|
||||
|
||||
const lastSeg = templateSegments[templateSegments.length - 1]
|
||||
|
||||
return selectedIds.map((assetId, i) => {
|
||||
const asset = assetMap.get(assetId)
|
||||
const assetDuration = asset?.duration || asset?.metadata?.duration || 30
|
||||
|
||||
const tplSeg = templateSegments[i] || lastSeg
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
// 与 FrontendPreviewPlayer.buildPlaybackSegments 中
|
||||
// endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
// 保持一致:duration 不能超过素材实际时长
|
||||
const duration = Math.min(segDuration, assetDuration)
|
||||
|
||||
return {
|
||||
asset_id: assetId,
|
||||
start_time: 0,
|
||||
duration,
|
||||
order: i,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export const useTemplateLibrary = () => {
|
||||
[copyMutation],
|
||||
)
|
||||
|
||||
/* 操作:使用模板 → 跳转剪辑编辑器 */
|
||||
/* 操作:使用模板 → 跳转剪辑模板 */
|
||||
const handleUse = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
navigate(`/app/editing-planner?templateId=${template.id}`)
|
||||
|
||||
+23
-22
@@ -2,14 +2,13 @@ import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
createAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
getIngestJob,
|
||||
type AssetLibraryItem,
|
||||
} from "@/api/assets"
|
||||
import { tagAsset } from "@/api/tags"
|
||||
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
|
||||
import { getAudioDuration } from "../../../utils/audio"
|
||||
import { type VoiceGender, type VoiceMaterial } from "../../../types"
|
||||
|
||||
interface UseVoiceUploadOptions {
|
||||
voiceLibrary?: { id: string; kind: string }
|
||||
@@ -48,32 +47,34 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
}
|
||||
|
||||
// 2. 上传文件(带进度)
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
// 2. 上传文件(带进度,后端自动创建 ingest job)
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 3. 获取音频时长
|
||||
const duration = await getAudioDuration(data.file)
|
||||
// 3. 轮询 ingest job 状态
|
||||
let job: Awaited<ReturnType<typeof getIngestJob>> | null = null
|
||||
let retries = 0
|
||||
const maxRetries = 60 // 最多等待 5 分钟
|
||||
while (retries < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
job = await getIngestJob(ingest_job_id)
|
||||
if (job.status === "completed" || job.status === "failed") break
|
||||
retries++
|
||||
}
|
||||
|
||||
// 4. 创建素材记录
|
||||
const asset = await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
if (!job || job.status === "failed") {
|
||||
throw new Error("音频处理失败,请重试")
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
throw new Error("音频处理超时,请稍后在素材库查看")
|
||||
}
|
||||
|
||||
// 5. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0) {
|
||||
await tagAsset(asset.id, data.tagIds)
|
||||
// 4. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0 && job.result_asset_id) {
|
||||
await tagAsset(job.result_asset_id, data.tagIds)
|
||||
}
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface VoiceMaterial {
|
||||
fileUrl?: string
|
||||
}
|
||||
|
||||
/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
|
||||
/** 配音素材上传元数据(上传素材的 metadata) */
|
||||
export interface VoiceAssetMetadata {
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import { getAudioDuration } from "../utils/audio"
|
||||
import { buildVoiceMetadata } from "../types"
|
||||
import { uploadAssetDirect, getAssetLibraries, getIngestJob } from "@/api/assets"
|
||||
|
||||
/**
|
||||
* 配音上传 Hook
|
||||
@@ -33,27 +31,30 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
/* 直传文件 */
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
/* 直传文件(后端会自动创建 ingest job) */
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
/* 获取音频时长 */
|
||||
const duration = await getAudioDuration(data.file)
|
||||
/* 轮询 ingest job 状态,等待 Worker 处理完成 */
|
||||
let jobStatus = ""
|
||||
let retries = 0
|
||||
const maxRetries = 60 // 最多等待 5 分钟(60 * 5秒)
|
||||
while (jobStatus !== "ready" && jobStatus !== "failed" && retries < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
const job = await getIngestJob(ingest_job_id)
|
||||
jobStatus = job.status
|
||||
retries++
|
||||
}
|
||||
|
||||
/* 创建素材记录 */
|
||||
await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildVoiceMetadata({
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
if (jobStatus === "failed") {
|
||||
throw new Error("音频处理失败,请重试")
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
throw new Error("音频处理超时,请稍后在素材库查看")
|
||||
}
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ClonedVoiceDisplay {
|
||||
sampleUrl?: string
|
||||
}
|
||||
|
||||
/** 音色上传元数据(传递给 createAsset 的 metadata) */
|
||||
/** 音色上传元数据(上传素材的 metadata) */
|
||||
export interface VoiceUploadMetadata {
|
||||
gender?: string
|
||||
description?: string
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
deleteAssetLibrary,
|
||||
getAssets,
|
||||
getAssetsByKind,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
updateAssetReviewStatus,
|
||||
deleteAsset,
|
||||
uploadAsset,
|
||||
prepareDirectUpload,
|
||||
completeDirectUpload,
|
||||
uploadAssetDirect,
|
||||
@@ -175,22 +173,6 @@ describe("assets API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAsset", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(createAsset({ name: "test-item" })).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(createAsset({ name: "test-item" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateAsset", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(updateAsset("test-assetId")).resolves.not.toThrow()
|
||||
@@ -239,22 +221,6 @@ describe("assets API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("uploadAsset", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(uploadAsset(new FormData())).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(uploadAsset(new FormData())).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("prepareDirectUpload", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(prepareDirectUpload({ name: "test-item" })).resolves.not.toThrow()
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("bgm API", () => {
|
||||
|
||||
describe("getBgmPresets", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getBgmPresets("test-params?")).resolves.not.toThrow()
|
||||
await expect(getBgmPresets("test-template", { category: "test" })).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
@@ -42,7 +42,7 @@ describe("bgm API", () => {
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(getBgmPresets("test-params?")).rejects.toThrow()
|
||||
await expect(getBgmPresets("test-template", { category: "test" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
getEditPlans,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
aiRecommendClips,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
getEditPlanClips,
|
||||
getEditPlanClip,
|
||||
createEditPlanClip,
|
||||
@@ -19,7 +12,6 @@ import {
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
copyEditPlan,
|
||||
getMediaAssets,
|
||||
getMediaAsset,
|
||||
} from "@/api/template-editor"
|
||||
@@ -53,22 +45,6 @@ describe("editPlans API", () => {
|
||||
mockPatch.mockResolvedValue({ data: { success: true, items: [] } })
|
||||
})
|
||||
|
||||
describe("getEditPlans", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlans("test-params?")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(getEditPlans("test-params?")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlan("test-planId")).resolves.not.toThrow()
|
||||
@@ -85,22 +61,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("createEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(createEditPlan({ name: "test-item" })).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(createEditPlan({ name: "test-item" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(updateEditPlan("test-planId")).resolves.not.toThrow()
|
||||
@@ -117,54 +77,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(deleteEditPlan("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(deleteEditPlan("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(generateEditPlan("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(generateEditPlan("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGenerationStatus", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getGenerationStatus("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(getGenerationStatus("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("aiRecommendClips", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(aiRecommendClips("test-planId")).resolves.not.toThrow()
|
||||
@@ -181,22 +93,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEditPlanGenerations", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlanGenerations("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(getEditPlanGenerations("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGenerationTaskResults", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getGenerationTaskResults("test-taskId")).resolves.not.toThrow()
|
||||
@@ -213,22 +109,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancelGeneration", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(cancelGeneration("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(cancelGeneration("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEditPlanClips", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlanClips("test-planId")).resolves.not.toThrow()
|
||||
@@ -357,22 +237,6 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("copyEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(copyEditPlan("test-planId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(copyEditPlan("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMediaAssets", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getMediaAssets("test-libraryId?")).resolves.not.toThrow()
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
} from "@/api/templates"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
@@ -116,20 +115,4 @@ describe("templates API", () => {
|
||||
await expect(copyTemplate("test-templateId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateFromTemplate", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(generateFromTemplate("test-templateId")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(generateFromTemplate("test-templateId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,9 @@ vi.mock("@/api/voice-clone", () => ({
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
uploadAsset: vi.fn(),
|
||||
uploadAssetDirect: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ storage_key: "test", ingest_job_id: "test", url: "http://test" }),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
|
||||
@@ -148,14 +148,9 @@ vi.mock("@/api/editing-planner", () => ({
|
||||
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
cancelGeneration: vi.fn().mockResolvedValue({}),
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}),
|
||||
@@ -225,12 +220,6 @@ vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
|
||||
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }),
|
||||
}))
|
||||
vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({
|
||||
default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }),
|
||||
}))
|
||||
|
||||
// === useUndoRedo hook mock ===
|
||||
vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({
|
||||
|
||||
@@ -180,7 +180,6 @@ vi.mock("@/api/assets", () => ({
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
smartMatchAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
@@ -215,12 +214,6 @@ vi.mock("@/api/titles", () => ({
|
||||
}))
|
||||
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
generateEditPlan: vi.fn().mockResolvedValue({
|
||||
plan_id: "test-plan",
|
||||
generation_task_id: "test-task",
|
||||
plan_status: "processing",
|
||||
clip_count: 5,
|
||||
}),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({ plan_id: "test-plan", template_id: "test-template" }),
|
||||
getEditPlan: vi.fn().mockResolvedValue({
|
||||
plan_id: "test-plan",
|
||||
@@ -229,9 +222,6 @@ vi.mock("@/api/template-editor", () => ({
|
||||
config: {},
|
||||
status: "draft",
|
||||
}),
|
||||
getGenerationStatus: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ plan_status: "completed", generation_task_id: "test-task", clips: [] }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ vi.mock("@/api/templates", () => ({
|
||||
getTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
|
||||
toggleFavoriteTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
|
||||
copyTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
|
||||
generateFromTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/templates/TemplateLibrary.css", () => ({}))
|
||||
|
||||
@@ -165,7 +165,6 @@ vi.mock("@/api/assets", () => ({
|
||||
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import GenerationHistoryModal from "@/pages/editing-planner/components/GenerationHistoryModal"
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
loading: false,
|
||||
history: [],
|
||||
onClose: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
cancelLoading: false,
|
||||
}
|
||||
|
||||
describe("GenerationHistoryModal", () => {
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<GenerationHistoryModal {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should not render when open is false", () => {
|
||||
const { container } = render(<GenerationHistoryModal {...defaultProps} open={false} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it("should render with history items", () => {
|
||||
const history = [
|
||||
{
|
||||
id: "1",
|
||||
status: "completed",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
duration: 60,
|
||||
},
|
||||
]
|
||||
const { container } = render(
|
||||
<GenerationHistoryModal {...defaultProps} history={history as any} />,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import GenerationProgressModal from "@/pages/editing-planner/components/GenerationProgressModal"
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
voiceoverDuration: null,
|
||||
estimatedDuration: 60,
|
||||
onDurationChange: vi.fn(),
|
||||
onGenerate: vi.fn(),
|
||||
task: null,
|
||||
submitting: false,
|
||||
onCancel: vi.fn(),
|
||||
onRetry: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
describe("GenerationProgressModal", () => {
|
||||
it("should render setup phase", () => {
|
||||
const { container } = render(<GenerationProgressModal {...baseProps} phase="setup" />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render progress phase without task", () => {
|
||||
const { container } = render(<GenerationProgressModal {...baseProps} phase="progress" />)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render progress phase with task data", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="progress"
|
||||
task={
|
||||
{
|
||||
id: "task-123",
|
||||
status: "generating_video",
|
||||
progress: 50,
|
||||
current_step: "generating_video",
|
||||
user_message: "正在生成视频",
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render completed phase", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="completed"
|
||||
task={{ id: "task-1", status: "completed", progress: 100 } as any}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render failed phase with retry", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="failed"
|
||||
task={
|
||||
{
|
||||
id: "task-1",
|
||||
status: "failed",
|
||||
progress: 30,
|
||||
error_message: "生成失败",
|
||||
retryable: true,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render failed phase without retry", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal
|
||||
{...baseProps}
|
||||
phase="failed"
|
||||
task={
|
||||
{
|
||||
id: "task-1",
|
||||
status: "failed",
|
||||
progress: 30,
|
||||
retryable: false,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should not render when closed", () => {
|
||||
const { container } = render(
|
||||
<GenerationProgressModal {...baseProps} phase="setup" open={false} />,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -31,8 +31,6 @@ import "@/pages/editing-planner/components/ClipPropertiesPanel"
|
||||
import "@/pages/editing-planner/components/EditorClipList"
|
||||
import "@/pages/editing-planner/components/EditingDrawers"
|
||||
import "@/pages/editing-planner/components/FilterPanel"
|
||||
import "@/pages/editing-planner/components/GenerationHistoryModal"
|
||||
import "@/pages/editing-planner/components/GenerationProgressModal"
|
||||
import "@/pages/editing-planner/components/GreenScreenPanel"
|
||||
import "@/pages/editing-planner/components/IntroOutroPanel"
|
||||
import "@/pages/editing-planner/components/MediaPanel"
|
||||
|
||||
@@ -93,5 +93,5 @@ describe("useStep5Voice smoke test", () => {
|
||||
)
|
||||
expect(result.current).toBeDefined()
|
||||
expect(typeof result.current.handlePlayCloneSample).toBe("function")
|
||||
})
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
testTimeout: 15_000, // 全局 15 秒,防止 CI 高负载时偶发超时
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
|
||||
@@ -7,7 +7,7 @@ VideoProcessor 等)按需从子模块导入,避免 __init__ 阶段引入
|
||||
packages / DB 等重依赖。
|
||||
"""
|
||||
|
||||
# 共享工具模块(零外部依赖,供 editing_modes / generation / edit_plan_generation 等复用)
|
||||
# 共享工具模块(零外部依赖,供 editing_modes / generation 等复用)
|
||||
from . import dedup_helpers, ffmpeg_utils, oss_helpers, url_security
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""查重辅助函数 — 从 generation.py 提取的 GeneratedVideo 记录 + 查重逻辑.
|
||||
|
||||
供 render_edit_plan 和 generate_video 共同复用,
|
||||
供 generate_video 共同复用,
|
||||
创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重。
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""OSS 工具函数 — 从 generation.py / edit_plan_generation.py 提取的共享 OSS 操作.
|
||||
"""OSS 工具函数 — 从 generation.py 提取的共享 OSS 操作.
|
||||
|
||||
提供 OSS 配置读取、Bucket 创建、素材上传/下载、asset_id → 本地路径解析
|
||||
等能力,供 render_edit_plan 和 generate_video 共同复用。
|
||||
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# OSS 上传配置
|
||||
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒),防止网络慢时无限卡住
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT = 900 # 单文件上传总超时(秒),防止网络慢时无限卡住
|
||||
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片
|
||||
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
|
||||
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
|
||||
@@ -127,10 +127,10 @@ def download_asset(asset_storage_key: str, local_path: Path) -> bool:
|
||||
def _download_via_http(url: str, local_path: Path) -> bool:
|
||||
"""通过 HTTP 下载文件(支持预签名 URL)。
|
||||
|
||||
使用流式下载避免大文件内存溢出,超时 300s。
|
||||
使用流式下载避免大文件内存溢出,超时 900s。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(url, stream=True, timeout=300)
|
||||
resp = requests.get(url, stream=True, timeout=900)
|
||||
resp.raise_for_status()
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024):
|
||||
@@ -146,7 +146,7 @@ def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公开 URL。
|
||||
|
||||
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
|
||||
上传加总超时保护(默认 300s),防止网络异常时无限挂死。
|
||||
上传加总超时保护(默认 900s),防止网络异常时无限挂死。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径(Path 或 str 均可)
|
||||
|
||||
@@ -128,6 +128,7 @@ class RenderAdapter:
|
||||
job_id: str = "",
|
||||
work_dir: Path | None = None,
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
) -> RenderAdapterResult:
|
||||
"""渲染一个 EditPlan。
|
||||
|
||||
@@ -142,6 +143,7 @@ class RenderAdapter:
|
||||
job_id: 关联的 Job ID(用于结果存储路径)
|
||||
work_dir: 工作目录,不传则使用临时目录
|
||||
progress_cb: 进度回调函数
|
||||
voiceover_audio_path: 配音音频本地路径(一键生成场景使用)
|
||||
|
||||
Returns:
|
||||
RenderAdapterResult
|
||||
@@ -208,6 +210,7 @@ class RenderAdapter:
|
||||
progress_cb=progress_cb,
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
@@ -585,14 +588,11 @@ class RenderAdapter:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
|
||||
|
||||
# 从 plan config 提取标题文字,叠加到封面候选帧上
|
||||
_title_cfg = (plan_config or {}).get("title", {}) or {}
|
||||
if not isinstance(_title_cfg, dict):
|
||||
_title_cfg = {}
|
||||
_title_text = (_title_cfg.get("text", "") or "").strip() if _title_cfg.get("enabled", True) else ""
|
||||
|
||||
# 已渲染视频在统一渲染阶段已通过 ASS 字幕把标题烧录进画面,
|
||||
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
|
||||
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=_title_text
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=""
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
|
||||
@@ -67,6 +67,16 @@ def clip_has_audio(ctx: RenderContext, clip: ResolvedClip) -> bool:
|
||||
return ctx._audio_cache[key]
|
||||
|
||||
|
||||
def _clip_volume(clip: ResolvedClip) -> float:
|
||||
"""读取 clip 的音量配置(0.0~1.0,>1 放大)。缺省 1.0 原声。"""
|
||||
cfg = getattr(clip, "config", None) or {}
|
||||
try:
|
||||
vol = float(cfg.get("volume", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return max(0.0, vol)
|
||||
|
||||
|
||||
# ── 音频混音 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -82,12 +92,13 @@ def mix_audio(
|
||||
"""音频后处理混音.
|
||||
|
||||
处理逻辑:
|
||||
1. 丢弃主图层(main/broll/overlay/corner_voice)的原始音频,避免录入源视频杂音
|
||||
2. 仅使用独立音频轨(audio role,TTS/配音)作为主音频
|
||||
3. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
|
||||
4. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
|
||||
5. 输出时长截断到 video_duration
|
||||
6. 如果配置了降噪,最后应用降噪
|
||||
1. 保留主图层(main/broll/overlay/corner_voice)视频素材的原声,按顺序 concat 拼接
|
||||
2. 每个 clip 按 config.volume 应用音量(volume=0 静音,=1 原声)
|
||||
3. 独立音频轨(audio role,TTS/配音)通过 amix 混入
|
||||
4. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
|
||||
5. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
|
||||
6. 输出时长截断到 video_duration
|
||||
7. 如果配置了降噪,最后应用降噪
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
@@ -123,10 +134,12 @@ def mix_audio(
|
||||
if "audio" in layer_map:
|
||||
audio_clips = layer_map["audio"].clips
|
||||
|
||||
# ── 丢弃源视频的原始音频(避免录入杂音),成片仅保留 TTS 配音 + BGM ──
|
||||
main_clips = []
|
||||
# ── 保留源视频原声:过滤掉无音频流的 main clip(图片/无声素材) ──
|
||||
# 注意:volume=0 的 clip 不能移除——移除会导致后续 clip 音频时间轴前移、音画不同步。
|
||||
# volume=0 通过滤镜链生成静音流,保持时间轴对齐。
|
||||
main_clips = [c for c in main_clips if clip_has_audio(ctx, c)]
|
||||
|
||||
# ── 防御:过滤掉无音频流的 clip ──
|
||||
# ── 防御:过滤掉无音频流的独立音频轨 ──
|
||||
audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)]
|
||||
|
||||
if not main_clips and not audio_clips:
|
||||
@@ -144,7 +157,7 @@ def mix_audio(
|
||||
# 构建音频处理命令
|
||||
output_path = ctx.work_dir / f"audio_{ctx.plan_id}.aac"
|
||||
|
||||
# 源视频原始音频已被丢弃(main_clips = []),最终音频完全由独立音频轨 + BGM + 多轨配置组成。
|
||||
# 主音频为视频素材原声 concat;独立音频轨(TTS/配音)通过 amix 混入。
|
||||
# 当无 main_clips 时,将独立音频轨作为主音频走 concat 拼接;当二者均有则走 amix 混音。
|
||||
if main_clips:
|
||||
effective_main = main_clips
|
||||
@@ -266,28 +279,67 @@ def concat_main_audio(
|
||||
has_speed = abs(speed - 1.0) >= 1e-6
|
||||
|
||||
if not has_speed and not has_reverse:
|
||||
# 无调速无倒放:简单命令行,-ss 裁剪更高效
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(clip.local_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"2",
|
||||
]
|
||||
if trim_start > 0:
|
||||
command.extend(["-ss", f"{trim_start:.3f}"])
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
command.append(str(output_path))
|
||||
run_ffmpeg(command)
|
||||
# 无调速无倒放:根据是否需要裁剪/音量选择最高效的路径。
|
||||
vol = _clip_volume(clip)
|
||||
need_trim = trim_start > 0 or (effective_duration > 0 and final_duration < adjusted_duration)
|
||||
need_volume = abs(vol - 1.0) >= 1e-6
|
||||
|
||||
if need_trim:
|
||||
# 需要裁剪:用 atrim 滤镜在滤镜链中精确裁剪(采样点级精度,不浪费解码)。
|
||||
# 滤镜顺序:atrim → asetpts → volume(先裁剪再调音量,避免处理被丢弃的数据)。
|
||||
af_parts: list[str] = []
|
||||
if trim_start > 0 and effective_duration > 0:
|
||||
af_parts.append(f"atrim=start={trim_start:.3f}:duration={final_duration:.3f}")
|
||||
elif trim_start > 0:
|
||||
af_parts.append(f"atrim=start={trim_start:.3f}")
|
||||
elif final_duration > 0:
|
||||
af_parts.append(f"atrim=duration={final_duration:.3f}")
|
||||
af_parts.append("asetpts=PTS-STARTPTS")
|
||||
if need_volume:
|
||||
af_parts.append(f"volume={vol:.4f}")
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(clip.local_path),
|
||||
"-vn",
|
||||
"-af",
|
||||
",".join(af_parts),
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"2",
|
||||
]
|
||||
# atrim 已精确控制时长,无需额外 -t
|
||||
command.append(str(output_path))
|
||||
run_ffmpeg(command)
|
||||
else:
|
||||
# 无需裁剪:直接提取,最高效。音量用单个 -af(如有)。
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(clip.local_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"2",
|
||||
]
|
||||
if need_volume:
|
||||
command.extend(["-af", f"volume={vol:.4f}"])
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
command.append(str(output_path))
|
||||
run_ffmpeg(command)
|
||||
else:
|
||||
# 有调速或倒放:用 filter_complex
|
||||
speed_engine = SpeedEngine()
|
||||
@@ -312,6 +364,11 @@ def concat_main_audio(
|
||||
if reverse_filter:
|
||||
audio_filters.append(reverse_filter)
|
||||
|
||||
# 音量
|
||||
vol = _clip_volume(clip)
|
||||
if abs(vol - 1.0) >= 1e-6:
|
||||
audio_filters.append(f"volume={vol:.4f}")
|
||||
|
||||
# aformat 归一化:统一输出格式为 48000Hz + stereo + fltp
|
||||
audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
|
||||
|
||||
@@ -378,6 +435,11 @@ def concat_main_audio(
|
||||
if reverse_filter:
|
||||
audio_filters.append(reverse_filter)
|
||||
|
||||
# 音量(0=静音,1=原声)
|
||||
vol = _clip_volume(clip)
|
||||
if abs(vol - 1.0) >= 1e-6:
|
||||
audio_filters.append(f"volume={vol:.4f}")
|
||||
|
||||
# aformat 归一化:统一采样率48000Hz + 双声道stereo + fltp采样格式
|
||||
# concat filter 要求所有输入音频参数完全一致,否则 exit=234 失败
|
||||
audio_filters.append("aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。
|
||||
"""视频封面抽帧工具 — 从视频中抽取帧作为封面,支持标题文字叠加。
|
||||
|
||||
统一封面管道:视频渲染时标题已通过 ASS 字幕烧进视频,
|
||||
渲染完成后直接从此视频抽帧,封面天然带标题,无需额外叠加逻辑。
|
||||
统一封面管道:
|
||||
- 从已渲染视频抽帧:标题已通过 ASS 字幕烧进视频,帧天然带标题,无需再叠加。
|
||||
- 从源素材抽帧(API E2 兜底):源素材无标题,通过 Pillow 在帧上绘制标题文字。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,6 +13,40 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 标题叠加(Pillow)──────────────────────────────────────────────────────
|
||||
# 实现统一放在 packages/shared/title_overlay.py,API 和 Worker 共用。
|
||||
|
||||
|
||||
def apply_title_overlay(
|
||||
image_path: str,
|
||||
title_text: str,
|
||||
*,
|
||||
color: str = "#ffffff",
|
||||
position: str = "bottom",
|
||||
font_size: int | None = None,
|
||||
margin_ratio: float = 0.06,
|
||||
stroke_width_ratio: float = 0.04,
|
||||
) -> str:
|
||||
"""在图片上绘制标题文字(指定颜色 + 黑色描边/阴影)。
|
||||
|
||||
委托给 packages.shared.title_overlay.apply_title_to_image,
|
||||
保持 Worker 内调用方式不变。title_text 为空时直接返回原路径。
|
||||
"""
|
||||
from packages.shared.title_overlay import apply_title_to_image
|
||||
|
||||
if not title_text or not title_text.strip():
|
||||
return image_path
|
||||
result = apply_title_to_image(
|
||||
image_path,
|
||||
title_text,
|
||||
color=color,
|
||||
position=position,
|
||||
font_size=font_size,
|
||||
margin_ratio=margin_ratio,
|
||||
stroke_width_ratio=stroke_width_ratio,
|
||||
)
|
||||
return result or image_path
|
||||
|
||||
|
||||
def extract_first_frame(
|
||||
video_path: str,
|
||||
@@ -134,3 +169,104 @@ def _format_seek_time(seconds: float) -> str:
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = seconds % 60
|
||||
return f"{h:02d}:{m:02d}:{s:05.2f}"
|
||||
|
||||
|
||||
def generate_and_upload_thumbnail(
|
||||
video_path: str,
|
||||
storage_key: str,
|
||||
*,
|
||||
seek_ratio: float = 0.15,
|
||||
) -> str:
|
||||
"""从视频中提取一帧缩略图并上传到 OSS。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
storage_key: OSS 存储 key
|
||||
seek_ratio: 抽帧位置比例(默认 0.15)
|
||||
|
||||
Returns:
|
||||
上传后的 URL 字符串
|
||||
|
||||
Raises:
|
||||
RuntimeError: 抽帧或上传失败
|
||||
"""
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
try:
|
||||
frame_path = extract_first_frame(video_path, output_path=tmp.name, seek_ratio=seek_ratio)
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if not url:
|
||||
raise RuntimeError(f"上传缩略图到 OSS 失败: {storage_key}")
|
||||
return url
|
||||
finally:
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def extract_and_upload_cover_frames(
|
||||
video_path: str,
|
||||
plan_id: str,
|
||||
*,
|
||||
num_frames: int = 3,
|
||||
title_text: str = "",
|
||||
title_color: str = "#ffffff",
|
||||
title_position: str = "bottom",
|
||||
title_font_size: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""从视频中抽取多帧作为封面候选,上传到 OSS。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||||
num_frames: 抽取帧数(默认 3)
|
||||
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
|
||||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||||
title_color: 标题字体颜色(#RRGGBB)
|
||||
title_position: 标题位置 top/center/bottom
|
||||
title_font_size: 标题字号,None 时自动计算
|
||||
|
||||
Returns:
|
||||
封面候选列表,每项包含 {"url": str, "position": float}
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
except Exception:
|
||||
duration = 0.0
|
||||
|
||||
candidates: list[dict] = []
|
||||
# 均匀分布抽帧点:从 10% 到 90%
|
||||
for i in range(num_frames):
|
||||
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
try:
|
||||
frame_path = extract_first_frame(
|
||||
video_path,
|
||||
output_path=tmp.name,
|
||||
seek_ratio=ratio,
|
||||
min_seek_seconds=0.5,
|
||||
)
|
||||
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
|
||||
if title_text and title_text.strip():
|
||||
apply_title_overlay(
|
||||
frame_path,
|
||||
title_text,
|
||||
color=title_color,
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if url:
|
||||
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
|
||||
candidates.append({"url": url, "position": round(seek_time, 2)})
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
|
||||
finally:
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
return candidates
|
||||
|
||||
@@ -36,6 +36,7 @@ from video_processing.ffmpeg_utils import (
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
FFMPEG_BIN,
|
||||
probe_duration,
|
||||
probe_has_audio,
|
||||
probe_video_info,
|
||||
run_ffmpeg,
|
||||
)
|
||||
@@ -1000,6 +1001,11 @@ class UnifiedRenderService:
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
return False, f"有调速: speed={speed:.2f}x"
|
||||
|
||||
# 音量非默认(静音/放大)→ 需要音频滤镜重编码 → 不能 copy
|
||||
vol = UnifiedRenderService._clip_volume(clip)
|
||||
if abs(vol - 1.0) >= 1e-6:
|
||||
return False, f"音量非默认: volume={vol:.2f}"
|
||||
|
||||
# 有倒放 → 需要重编码 → 不能 copy
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
if reverse_config.enabled and (reverse_config.reverse_video or reverse_config.reverse_audio):
|
||||
@@ -1270,13 +1276,23 @@ class UnifiedRenderService:
|
||||
"+faststart",
|
||||
]
|
||||
|
||||
# 音频处理:background 通常是图片无音频,跳过;其他编码为 aac
|
||||
# background 以外的视频素材,默认带音频
|
||||
has_audio = role != "background"
|
||||
# 音频处理:background 通常是图片无音频,跳过;其他角色先探测是否真有音频流。
|
||||
# volume=0 不丢弃音频流,而是保留后通过 volume=0 滤镜静音,保持时间轴对齐。
|
||||
clip_volume = UnifiedRenderService._clip_volume(clip)
|
||||
if role != "background":
|
||||
try:
|
||||
has_audio = probe_has_audio(clip.local_path)
|
||||
except Exception as e:
|
||||
# probe_has_audio 内部已保守返回 True;只有极端错误才会到这里。
|
||||
# 此时不静默丢音频,记录 error 并向上抛出,让任务失败而不是产出无声视频。
|
||||
logger.error("[unified-render] 探测音频流发生致命错误,终止渲染: %s: %s", clip.local_path, e)
|
||||
raise
|
||||
else:
|
||||
has_audio = False
|
||||
if has_audio:
|
||||
af_parts: list[str] = []
|
||||
|
||||
# 音频降噪
|
||||
# 音频降噪(最先处理:在原始信号上降噪效果最好)
|
||||
try:
|
||||
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
|
||||
|
||||
@@ -1290,13 +1306,16 @@ class UnifiedRenderService:
|
||||
except Exception as e:
|
||||
logger.warning("[unified-render] 直通模式音频降噪应用失败,跳过: %s", e)
|
||||
|
||||
# 音频调速(与视频setpts对应,保持音画同步)
|
||||
# 音频调速(在降噪之后、音量之前,与 render_audio.py concat 路径保持一致)
|
||||
# SpeedEngine.build_audio_filter 内部已实现多级 atempo 串联,
|
||||
# 自动处理超出 [0.5, 2.0] 范围的速度(如 0.25x → atempo=0.5,atempo=0.5)。
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
try:
|
||||
from video_processing.speed_engine import SpeedConfig, SpeedEngine
|
||||
|
||||
speed_cfg = SpeedConfig(speed=speed)
|
||||
speed_cfg.clamp()
|
||||
speed_engine = SpeedEngine()
|
||||
af_parts.append(speed_engine.build_audio_filter(speed_cfg))
|
||||
except Exception as e:
|
||||
@@ -1309,6 +1328,10 @@ class UnifiedRenderService:
|
||||
if af_filter:
|
||||
af_parts.append(af_filter)
|
||||
|
||||
# 片段音量(最后应用:确保调速/倒放后的最终输出音量准确,与 concat 路径一致)
|
||||
if abs(clip_volume - 1.0) >= 1e-6:
|
||||
af_parts.append(f"volume={clip_volume:.4f}")
|
||||
|
||||
if af_parts:
|
||||
command.extend(["-af", ",".join(af_parts)])
|
||||
|
||||
@@ -1976,6 +1999,16 @@ class UnifiedRenderService:
|
||||
"""
|
||||
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
|
||||
|
||||
@staticmethod
|
||||
def _clip_volume(clip: ResolvedClip) -> float:
|
||||
"""获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。"""
|
||||
cfg = getattr(clip, "config", None) or {}
|
||||
try:
|
||||
vol = float(cfg.get("volume", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return max(0.0, vol)
|
||||
|
||||
@staticmethod
|
||||
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
|
||||
"""计算调速后的 clip 实际时长(用于拼接计算)。
|
||||
|
||||
@@ -14,9 +14,17 @@ celery_app.conf.imports = (
|
||||
"worker_app.tasks.voice_extraction",
|
||||
"worker_app.tasks.voice_clone",
|
||||
"worker_app.tasks.tts_synthesis",
|
||||
"worker_app.tasks.edit_plan_generation",
|
||||
"worker_app.tasks.compose_video",
|
||||
"worker_app.tasks.batch_download",
|
||||
"worker_app.tasks._startup",
|
||||
"apps.worker.video_processing.dedup",
|
||||
"worker_app.tasks.cleanup",
|
||||
)
|
||||
|
||||
# Celery Beat 定时任务调度
|
||||
celery_app.conf.beat_schedule = {
|
||||
"cleanup-stale-pending-tasks": {
|
||||
"task": "worker.cleanup_stale_pending_tasks",
|
||||
"schedule": 600.0, # 每 10 分钟(秒)
|
||||
"options": {"expires": 300}, # 5 分钟过期,避免堆积
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ def __getattr__(name: str):
|
||||
from .voice_extraction import extract_voice_task
|
||||
|
||||
return extract_voice_task
|
||||
elif name == "compose_video":
|
||||
from .compose_video import compose_video
|
||||
|
||||
return compose_video
|
||||
elif name == "extract_background_task":
|
||||
from .voice_extraction import extract_background_task
|
||||
|
||||
@@ -58,7 +54,6 @@ def __getattr__(name: str):
|
||||
|
||||
__all__ = [
|
||||
"classify_asset",
|
||||
"compose_video",
|
||||
"generate_video",
|
||||
"healthcheck",
|
||||
"ingest_asset",
|
||||
|
||||
@@ -10,6 +10,9 @@ logger = logging.getLogger(__name__)
|
||||
# 孤儿任务超时阈值:渲染任务超过此时间未更新则视为卡死
|
||||
ORPHAN_TASK_TIMEOUT_MINUTES = 10
|
||||
|
||||
# Pending 任务超时阈值:pending 任务在队列中等待超过此时间则自动清理
|
||||
PENDING_TASK_TIMEOUT_MINUTES = 30
|
||||
|
||||
|
||||
def cleanup_orphan_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> int: # pragma: no cover
|
||||
"""清理数据库中超时未更新的 running GenerationTask(孤儿任务)。
|
||||
@@ -83,6 +86,38 @@ def cleanup_stale_jobs(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> in
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_stale_pending_tasks(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINUTES) -> int: # pragma: no cover
|
||||
"""清理数据库中卡在 pending 状态超时的 GenerationTask。
|
||||
|
||||
全局任务队列有 pending 数量上限,长期卡在 pending 的任务会占满队列,
|
||||
导致新用户无法创建任务。通过 created_at 超时判断并标记为 failed。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 PENDING_TASK_TIMEOUT_MINUTES
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
count = repo.cleanup_stale_pending(timeout_minutes)
|
||||
if count > 0:
|
||||
logger.warning("清理了 %d 个超时的 pending GenerationTask(超过 %d 分钟未处理)", count, timeout_minutes)
|
||||
else:
|
||||
logger.info("无超时 pending GenerationTask 需要清理")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error("清理超时 pending GenerationTask 失败: %s", e, exc_info=True)
|
||||
return 0
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def cleanup_all_stale_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> dict: # pragma: no cover
|
||||
"""统一清理所有超时的孤儿任务。
|
||||
|
||||
@@ -93,15 +128,17 @@ def cleanup_all_stale_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES)
|
||||
"""
|
||||
gen_count = cleanup_orphan_tasks(timeout_minutes)
|
||||
job_count = cleanup_stale_jobs(timeout_minutes)
|
||||
total = gen_count + job_count
|
||||
pending_count = cleanup_stale_pending_tasks(PENDING_TASK_TIMEOUT_MINUTES)
|
||||
total = gen_count + job_count + pending_count
|
||||
if total > 0:
|
||||
logger.warning(
|
||||
"孤儿任务清理完成: GenerationTask=%d, Job=%d, 总计=%d",
|
||||
"任务清理完成: 孤儿 GenerationTask=%d, 孤儿 Job=%d, 超时 pending=%d, 总计=%d",
|
||||
gen_count,
|
||||
job_count,
|
||||
pending_count,
|
||||
total,
|
||||
)
|
||||
return {"generation_tasks": gen_count, "jobs": job_count}
|
||||
return {"generation_tasks": gen_count, "jobs": job_count, "pending": pending_count}
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""定期清理任务 — Celery Beat 调度。
|
||||
|
||||
包含:
|
||||
- cleanup_stale_pending_tasks: 定期清理卡在 pending 超时的 generation_tasks
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from worker_app.tasks._startup import (
|
||||
PENDING_TASK_TIMEOUT_MINUTES,
|
||||
cleanup_stale_pending_tasks,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="worker.cleanup_stale_pending_tasks")
|
||||
def scheduled_cleanup_stale_pending(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINUTES) -> dict:
|
||||
"""Celery Beat 调度的定期任务:清理超时的 pending 任务。
|
||||
|
||||
每 10 分钟执行一次(由 celery_app.py 的 beat_schedule 配置),
|
||||
查找所有 status='pending' 且 created_at < NOW() - timeout_minutes
|
||||
的 generation_tasks,批量更新为 failed。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 30 分钟
|
||||
|
||||
Returns:
|
||||
{"cleaned": int}
|
||||
"""
|
||||
count = cleanup_stale_pending_tasks(timeout_minutes)
|
||||
if count > 0:
|
||||
logger.info("[Beat] 清理了 %d 个超时 pending 任务(超时阈值 %d 分钟)", count, timeout_minutes)
|
||||
return {"cleaned": count}
|
||||
@@ -1,198 +0,0 @@
|
||||
"""视频合成 Celery 任务 — Phase 8 任务 2.10.
|
||||
|
||||
使用 JobService 管理任务生命周期,通过 RenderAdapter 调用 UnifiedRenderService 执行合成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # pragma: no cover
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded # pragma: no cover
|
||||
from celery.utils.log import get_task_logger
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
# 任务超时时间(秒):超过此时间 Celery 会抛出 SoftTimeLimitExceeded
|
||||
RENDER_TASK_SOFT_TIME_LIMIT = 600 # pragma: no cover # 10 分钟
|
||||
# 硬超时:超过此时间进程会被强制 kill
|
||||
RENDER_TASK_TIME_LIMIT = 660 # pragma: no cover # 10 分钟 + 1 分钟清理缓冲
|
||||
|
||||
|
||||
def _get_job_service():
|
||||
"""延迟导入 JobService,避免循环依赖。"""
|
||||
from apps.api.app.services.job_service import JobService
|
||||
from packages.adapters.sqlalchemy_impl.job_repository import SQLAlchemyJobRepository
|
||||
|
||||
db = SessionLocal()
|
||||
repo = SQLAlchemyJobRepository(db)
|
||||
return JobService(repo), db
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="worker.compose_video",
|
||||
bind=True,
|
||||
max_retries=3,
|
||||
default_retry_delay=60,
|
||||
soft_time_limit=RENDER_TASK_SOFT_TIME_LIMIT,
|
||||
time_limit=RENDER_TASK_TIME_LIMIT,
|
||||
)
|
||||
def compose_video(self, job_id: str, **kwargs): # pragma: no cover
|
||||
"""视频合成任务。
|
||||
|
||||
使用 UnifiedRenderService(图层架构)进行渲染。
|
||||
|
||||
Args:
|
||||
job_id: JobService 中的任务 ID
|
||||
**kwargs: 来自 Job.payload 的额外参数(plan_id, output_path 等)
|
||||
"""
|
||||
job_service, db = _get_job_service()
|
||||
|
||||
try:
|
||||
job = job_service.get_job(job_id)
|
||||
if job is None:
|
||||
logger.error("Job not found: %s", job_id)
|
||||
return {"status": "error", "message": f"Job {job_id} not found"}
|
||||
|
||||
plan_id = job.payload.get("plan_id", "")
|
||||
if not plan_id:
|
||||
job_service.fail_job(job_id, "Missing plan_id in job payload")
|
||||
return {"status": "error", "message": "Missing plan_id"}
|
||||
|
||||
# 使用 unified 渲染引擎
|
||||
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
|
||||
|
||||
except SoftTimeLimitExceeded:
|
||||
# Celery 软超时:任务执行超过 soft_time_limit
|
||||
error_msg = f"渲染任务超时(超过 {RENDER_TASK_SOFT_TIME_LIMIT // 60} 分钟)"
|
||||
logger.error("视频合成超时: job_id=%s", job_id)
|
||||
try:
|
||||
job_service.fail_job(job_id, error_msg)
|
||||
except Exception:
|
||||
logger.exception("更新 Job 超时失败状态时出错")
|
||||
# 超时不重试
|
||||
return {"status": "error", "message": error_msg, "error_type": "timeout"}
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# FFmpeg 子进程超时
|
||||
error_msg = f"FFmpeg 渲染超时({exc.timeout}s)"
|
||||
logger.error("视频合成 FFmpeg 超时: job_id=%s timeout=%s", job_id, exc.timeout)
|
||||
try:
|
||||
job_service.fail_job(job_id, error_msg)
|
||||
except Exception:
|
||||
logger.exception("更新 Job 超时失败状态时出错")
|
||||
# 超时不重试
|
||||
return {"status": "error", "message": error_msg, "error_type": "ffmpeg_timeout"}
|
||||
|
||||
except self.retry_exc as exc:
|
||||
logger.warning("视频合成重试中: job_id=%s, exc=%s", job_id, exc)
|
||||
raise
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# FFmpeg 执行失败,提取有意义的错误信息
|
||||
from video_processing.video_validation import get_exit_code_message
|
||||
|
||||
exit_msg = get_exit_code_message(exc.returncode)
|
||||
stderr_text = (exc.stderr or "").strip()
|
||||
stderr_tail = stderr_text[-300:] if len(stderr_text) > 300 else stderr_text
|
||||
error_msg = f"渲染失败: {exit_msg}"
|
||||
if stderr_tail:
|
||||
error_msg += f" | {stderr_tail[:200]}"
|
||||
|
||||
logger.error("视频合成 FFmpeg 失败: job_id=%s %s", job_id, exit_msg)
|
||||
try:
|
||||
job_service.fail_job(job_id, error_msg[:500])
|
||||
except Exception:
|
||||
logger.exception("更新 Job 失败状态时出错")
|
||||
# FFmpeg 错误不重试(通常是素材或配置问题)
|
||||
return {"status": "error", "message": error_msg, "error_type": "ffmpeg_error", "exit_code": exc.returncode}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("视频合成异常: job_id=%s", job_id)
|
||||
try:
|
||||
job_service.fail_job(job_id, str(exc)[:500])
|
||||
except Exception:
|
||||
logger.exception("更新 Job 失败状态时出错")
|
||||
raise self.retry(exc=exc, countdown=60) from exc
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> dict: # pragma: no cover
|
||||
"""新引擎渲染路径(UnifiedRenderService + RenderAdapter)。"""
|
||||
job_id = job.id
|
||||
|
||||
# 标记为 running
|
||||
job_service.update_progress(job_id, progress=10.0, current_stage="初始化统一渲染引擎")
|
||||
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
|
||||
adapter = RenderAdapter(db)
|
||||
|
||||
# 校验合成条件
|
||||
job_service.update_progress(job_id, progress=15.0, current_stage="校验合成条件")
|
||||
valid, errors, warnings, ready_count, total_count = adapter.validate_plan(plan_id)
|
||||
if not valid:
|
||||
error_msg = "; ".join(errors)
|
||||
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
# 进度回调
|
||||
def progress_cb(progress: float, stage: str) -> None:
|
||||
try:
|
||||
job_service.update_progress(job_id, progress=progress, current_stage=stage)
|
||||
except Exception:
|
||||
logger.exception("更新进度失败")
|
||||
|
||||
# 执行渲染
|
||||
job_service.update_progress(job_id, progress=20.0, current_stage="开始渲染")
|
||||
logger.info("统一渲染引擎开始: job_id=%s plan_id=%s", job_id, plan_id)
|
||||
|
||||
result = adapter.render_plan(
|
||||
plan_id=plan_id,
|
||||
job_id=job_id,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
error_msg = f"渲染失败: {result.error_message}"
|
||||
job_service.fail_job(job_id, error_msg[:500])
|
||||
raise RuntimeError(result.error_message)
|
||||
|
||||
# 更新 Job 状态为完成
|
||||
result_data = {
|
||||
"plan_id": plan_id,
|
||||
"output_path": str(result.output_path) if result.output_path else "",
|
||||
"storage_key": f"rendered/{plan_id}/{job_id}.mp4",
|
||||
"output_url": result.output_url,
|
||||
"estimated_duration": result.duration,
|
||||
"clip_count": result.clip_count,
|
||||
"engine": "unified",
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"file_size": result.file_size,
|
||||
}
|
||||
job_service.complete_job(job_id, result=result_data)
|
||||
|
||||
logger.info(
|
||||
"视频合成完成(unified): job_id=%s plan_id=%s duration=%.2fs",
|
||||
job_id,
|
||||
plan_id,
|
||||
result.duration,
|
||||
)
|
||||
return {"status": "completed", "job_id": job_id, "result": result_data}
|
||||
|
||||
|
||||
def _cleanup_output(job_id: str) -> None:
|
||||
"""清理临时输出文件。"""
|
||||
try:
|
||||
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
|
||||
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
|
||||
if Path(output_path).exists():
|
||||
Path(output_path).unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"清理输出文件失败: {e}", exc_info=True)
|
||||
@@ -1,452 +0,0 @@
|
||||
"""剪辑计划渲染任务 — 使用 UnifiedRenderService 统一渲染引擎.
|
||||
|
||||
Celery 任务 worker.render_edit_plan:
|
||||
1. 加载 EditPlan + EditPlanClips
|
||||
2. 通过 RenderAdapter 调用 UnifiedRenderService 渲染
|
||||
3. 下载各片段素材 + 渲染
|
||||
4. 上传渲染结果到 OSS
|
||||
5. 创建 GeneratedVideo 记录 + 查重
|
||||
6. 更新 EditPlan / EditPlanClip 状态
|
||||
7. 更新 GenerationTask 进度
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
OUTPUT_FPS = 25.0
|
||||
|
||||
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
# ── Repository imports (延迟导入避免循环依赖) ─────────────────────────────────
|
||||
|
||||
|
||||
def _get_repos():
|
||||
"""获取数据库仓储实例"""
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyEditPlanRepository
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
yield plan_repo, clip_repo, gen_task_repo, db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── Celery Task ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg: str):
|
||||
"""统一的计划失败标记工具。"""
|
||||
plan = plan_repo.get(plan_id)
|
||||
if plan and plan.status.value == "rendering":
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.status.value != "failed":
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = error_msg
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
gen_task.append_log(
|
||||
stage="render_failed",
|
||||
message=error_msg[:500],
|
||||
level="ERROR",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
|
||||
def _finalize_render_success(
|
||||
plan,
|
||||
plan_repo,
|
||||
clip_repo,
|
||||
gen_task_repo,
|
||||
db,
|
||||
plan_id: str,
|
||||
output_url: str,
|
||||
storage_key: str,
|
||||
duration: float,
|
||||
file_size: int,
|
||||
width: int,
|
||||
height: int,
|
||||
rendered_clip_ids: list[str],
|
||||
failed_clip_ids: list[str],
|
||||
generation_task_id: str,
|
||||
output_path: Path,
|
||||
engine: str,
|
||||
thumbnail_url: str = "",
|
||||
cover_candidates: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""渲染成功后的统一收尾:查重 + 更新状态 + 返回结果。"""
|
||||
# 创建 GeneratedVideo 记录 + 查重
|
||||
project_id = plan.project_id or ""
|
||||
batch_id = plan.config.get("batch_id", "")
|
||||
mode = plan.config.get("mode", "edit_plan")
|
||||
# 从 plan.config.title.text 读取视频名称
|
||||
plan_config = plan.config or {}
|
||||
title_cfg = plan_config.get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
video_name = (title_cfg.get("text") or "").strip() or f"generated-{generation_task_id[:8]}.mp4"
|
||||
if generation_task_id:
|
||||
try:
|
||||
create_video_record_and_dedup(
|
||||
generation_task_id=generation_task_id,
|
||||
project_id=project_id,
|
||||
user_id=plan.created_by_user_id or "",
|
||||
batch_id=batch_id,
|
||||
file_url=output_url or "",
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
video_path=str(output_path),
|
||||
mode=mode,
|
||||
session=db,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=OUTPUT_FPS,
|
||||
name=video_name,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
except Exception as dedup_err:
|
||||
logger.warning("查重失败(不影响渲染结果): %s", dedup_err)
|
||||
|
||||
# 更新片段状态为 rendered
|
||||
for clip_id in rendered_clip_ids:
|
||||
clip = clip_repo.get(clip_id)
|
||||
if clip and clip.status.value == "ready":
|
||||
clip.mark_rendered()
|
||||
clip_repo.update(clip)
|
||||
|
||||
# 更新 EditPlan 状态为 completed + 回写实际渲染时长 + 结果数
|
||||
plan.config["rendered_url"] = output_url or ""
|
||||
plan.config["rendered_storage_key"] = storage_key
|
||||
if hasattr(plan, "total_duration") and duration > 0:
|
||||
plan.total_duration = duration
|
||||
plan.mark_completed()
|
||||
plan_repo.update(plan)
|
||||
|
||||
# 更新 GenerationTask 状态为 completed
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
gen_task.status = "completed"
|
||||
gen_task.progress = 100.0
|
||||
# 剪辑计划是多片段合成 1 个成片,result_count = 1
|
||||
gen_task.result_count = 1
|
||||
gen_task.append_log(
|
||||
stage="render_complete",
|
||||
message=f"渲染完成,输出时长 {duration:.1f}s",
|
||||
level="INFO",
|
||||
engine=engine,
|
||||
clip_count=len(rendered_clip_ids),
|
||||
)
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
|
||||
# 回写封面 URL 到 GenerationTask,供封面生成接口读取
|
||||
if cover_candidates:
|
||||
first_cover = cover_candidates[0].get("image_url") or cover_candidates[0].get("url") or ""
|
||||
if first_cover:
|
||||
gen_task.cover_url = first_cover
|
||||
logger.info(
|
||||
"预览渲染完成,回写 cover_url: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
first_cover[:80],
|
||||
)
|
||||
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
logger.info(
|
||||
"剪辑计划渲染完成: plan_id=%s engine=%s rendered=%d failed=%d duration=%.1fs",
|
||||
plan_id,
|
||||
engine,
|
||||
len(rendered_clip_ids),
|
||||
len(failed_clip_ids),
|
||||
duration,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"plan_id": plan_id,
|
||||
"rendered_count": len(rendered_clip_ids),
|
||||
"failed_count": len(failed_clip_ids),
|
||||
"output_url": output_url,
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
|
||||
def _render_with_unified(
|
||||
plan,
|
||||
clips,
|
||||
plan_id: str,
|
||||
generation_task_id: str,
|
||||
plan_repo,
|
||||
clip_repo,
|
||||
gen_task_repo,
|
||||
db,
|
||||
) -> dict:
|
||||
"""统一渲染引擎路径(通过 RenderAdapter 调用 UnifiedRenderService)。
|
||||
|
||||
RenderAdapter 内部处理:素材下载、BGM 准备、ASR 自动字幕、渲染执行、OSS 上传。
|
||||
本函数只负责:业务状态更新、查重、收尾。
|
||||
"""
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
|
||||
adapter = RenderAdapter(db)
|
||||
|
||||
# 进度回调:更新 GenerationTask 进度
|
||||
def _progress_cb(progress: float, stage: str):
|
||||
if not generation_task_id:
|
||||
return
|
||||
try:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
# 映射到 30%~90% 区间(素材下载前已到 30%)
|
||||
mapped_progress = 30.0 + progress * 0.6
|
||||
gen_task.progress = min(mapped_progress, 95.0)
|
||||
gen_task.append_log(
|
||||
stage="render_progress",
|
||||
message=stage,
|
||||
level="INFO",
|
||||
progress=mapped_progress,
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = adapter.render_plan(
|
||||
plan_id=plan_id,
|
||||
job_id=generation_task_id or plan_id,
|
||||
progress_cb=_progress_cb,
|
||||
)
|
||||
except Exception as render_err:
|
||||
logger.error("渲染失败(unified): %s — %s", plan_id, render_err)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, f"渲染失败: {render_err}")
|
||||
return {"status": "error", "message": f"渲染失败: {render_err}"}
|
||||
|
||||
if not result.success:
|
||||
full_error = result.error_message or "渲染失败"
|
||||
if result.error_detail:
|
||||
full_error = f"{full_error}\n--- stderr ---\n{result.error_detail}"
|
||||
logger.error("渲染失败(unified): %s — %s", plan_id, result.error_message)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, full_error)
|
||||
return {"status": "error", "message": result.error_message or "渲染失败"}
|
||||
|
||||
output_path = result.output_path or Path("")
|
||||
output_url = result.output_url or ""
|
||||
thumbnail_url = result.thumbnail_url or ""
|
||||
# adapter 上传到 rendered/{plan_id}/{job_id}.mp4,从 URL 提取实际 key
|
||||
# 不能用 output.mp4 硬编码,否则 cover 等下游通过 key 构造的 URL 指向不存在的文件
|
||||
if output_url:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_key = get_shared_storage_service().normalize_storage_key(output_url)
|
||||
else:
|
||||
storage_key = f"rendered/{plan_id}/{generation_task_id or plan_id}.mp4"
|
||||
|
||||
# 用 adapter 返回的 clip 明细(以 adapter 的结果为准)
|
||||
rendered_clip_ids = result.rendered_clip_ids or []
|
||||
failed_clip_ids = result.failed_clip_ids or []
|
||||
|
||||
# 将封面候选帧写入 plan.config(供封面 API 直接使用,跳过 MediaKit 抽帧)
|
||||
if result.cover_candidates:
|
||||
plan_config = plan.config or {}
|
||||
plan_config["cover_candidates"] = result.cover_candidates
|
||||
plan.config = plan_config
|
||||
logger.info(
|
||||
"封面候选帧已写入 plan.config: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(result.cover_candidates),
|
||||
)
|
||||
|
||||
return _finalize_render_success(
|
||||
plan=plan,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
plan_id=plan_id,
|
||||
output_url=output_url or "",
|
||||
storage_key=storage_key,
|
||||
duration=result.duration,
|
||||
file_size=result.file_size,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
generation_task_id=generation_task_id,
|
||||
output_path=output_path,
|
||||
engine="unified",
|
||||
thumbnail_url=thumbnail_url,
|
||||
cover_candidates=result.cover_candidates,
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="worker.render_edit_plan",
|
||||
bind=True,
|
||||
max_retries=2,
|
||||
soft_time_limit=600, # 10 分钟软超时
|
||||
time_limit=660, # 11 分钟硬超时
|
||||
)
|
||||
def render_edit_plan(self, plan_id: str) -> dict: # pragma: no cover
|
||||
"""渲染剪辑计划
|
||||
|
||||
流程:
|
||||
1. 加载 EditPlan + EditPlanClips
|
||||
2. 通过 RenderAdapter 调用 UnifiedRenderService 渲染
|
||||
3. 下载素材 + 渲染
|
||||
4. 上传渲染结果到 OSS
|
||||
5. 创建 GeneratedVideo 记录 + 查重
|
||||
6. 更新 EditPlan → completed, EditPlanClips → rendered
|
||||
7. 更新 GenerationTask 进度
|
||||
"""
|
||||
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
|
||||
|
||||
generation_task_id = ""
|
||||
|
||||
for repos in _get_repos():
|
||||
plan_repo, clip_repo, gen_task_repo, db = repos
|
||||
|
||||
try:
|
||||
# 1. 加载 EditPlan
|
||||
plan = plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
logger.error("剪辑计划不存在: %s", plan_id)
|
||||
return {"status": "error", "message": f"计划不存在: {plan_id}"}
|
||||
|
||||
# 获取 generation_task_id(提前读取,确保 except 块可用)
|
||||
generation_task_id = plan.config.get("generation_task_id", "")
|
||||
|
||||
# 2. 准备渲染(使用 unified 渲染引擎)
|
||||
|
||||
# 3. 加载片段列表(按 order 排序)
|
||||
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||||
if not clips:
|
||||
logger.warning("剪辑计划没有片段: %s", plan_id)
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
return {"status": "error", "message": "没有可渲染的片段"}
|
||||
|
||||
# 更新 GenerationTask 状态为 running
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task:
|
||||
gen_task.status = "running"
|
||||
gen_task.started_at = datetime.now(timezone.utc)
|
||||
gen_task.append_log(
|
||||
stage="render_start",
|
||||
message=f"开始渲染,片段数 {len(clips)}",
|
||||
level="INFO",
|
||||
engine="unified",
|
||||
clip_count=len(clips),
|
||||
)
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
# 3. 渲染前取消检查
|
||||
if generation_task_id:
|
||||
current_task = gen_task_repo.get(generation_task_id)
|
||||
if current_task:
|
||||
task_status = (
|
||||
current_task.status.value if hasattr(current_task.status, "value") else str(current_task.status)
|
||||
)
|
||||
if task_status == "cancelled":
|
||||
logger.info("任务已被取消,中止渲染: plan_id=%s task_id=%s", plan_id, generation_task_id)
|
||||
|
||||
if plan.status.value == "rendering":
|
||||
try:
|
||||
plan.resume_editing()
|
||||
plan_repo.update(plan)
|
||||
except ValueError:
|
||||
pass
|
||||
return {"status": "cancelled", "plan_id": plan_id, "message": "任务已取消"}
|
||||
|
||||
# 4. 渲染(unified 引擎:RenderAdapter 统一处理下载 + BGM + ASR + 渲染 + 上传)
|
||||
result = _render_with_unified(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
plan_id=plan_id,
|
||||
generation_task_id=generation_task_id,
|
||||
plan_repo=plan_repo,
|
||||
clip_repo=clip_repo,
|
||||
gen_task_repo=gen_task_repo,
|
||||
db=db,
|
||||
)
|
||||
|
||||
result["engine"] = "unified"
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
# 超时异常不重试,直接标记失败
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
|
||||
is_timeout = isinstance(exc, SoftTimeLimitExceeded)
|
||||
if is_timeout:
|
||||
logger.error("渲染剪辑计划超时: plan_id=%s", plan_id)
|
||||
else:
|
||||
logger.exception("渲染剪辑计划异常: %s", plan_id)
|
||||
|
||||
# 尝试标记计划和 GenerationTask 为失败
|
||||
error_msg = "渲染任务超时(超过10分钟)" if is_timeout else f"渲染异常: {type(exc).__name__}: {exc}"
|
||||
try:
|
||||
plan = plan_repo.get(plan_id)
|
||||
if plan and plan.status.value == "rendering":
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
except Exception as e:
|
||||
logger.warning("标记计划失败时异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
|
||||
# 更新 GenerationTask 状态为 failed,前端轮询能看到失败状态
|
||||
try:
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.status.value != "failed":
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = error_msg
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
log_stage = "render_timeout" if is_timeout else "render_failed"
|
||||
gen_task.append_log(
|
||||
stage=log_stage,
|
||||
message=error_msg[:500],
|
||||
level="ERROR",
|
||||
exception_type=type(exc).__name__,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
gen_task_repo.update(gen_task)
|
||||
logger.info(
|
||||
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
|
||||
generation_task_id,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
|
||||
)
|
||||
raise self.retry(exc=exc, countdown=60) from exc
|
||||
|
||||
return {"status": "error", "message": "数据库连接失败"}
|
||||
@@ -223,6 +223,7 @@ def _load_template_segment_durations(template_id: str) -> list[float]:
|
||||
return []
|
||||
|
||||
|
||||
# DEPRECATED: 仅兼容无 source_edit_plan_id 的旧调用,后续移除
|
||||
def _build_plan_and_clips_from_task(
|
||||
task_id: str,
|
||||
downloaded_paths: list[Path],
|
||||
@@ -887,7 +888,9 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
"output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT,
|
||||
"cover_url": getattr(gen_task, "cover_url", "") or "",
|
||||
"custom_title": getattr(gen_task, "custom_title", "") or "",
|
||||
"title_config": dict(getattr(gen_task, "title_config", {}) or {}),
|
||||
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
|
||||
"source_edit_plan_id": getattr(gen_task, "source_edit_plan_id", "") or "",
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -967,14 +970,15 @@ def _render_video(
|
||||
bgm_config: dict | None = None,
|
||||
voice_ids: list[str] | None = None,
|
||||
custom_title: str = "",
|
||||
) -> tuple[Path, float]:
|
||||
title_config: dict | None = None,
|
||||
) -> tuple[Path, float, list[dict] | None]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/缩略图逻辑。
|
||||
|
||||
Args:
|
||||
Returns:
|
||||
(output_path, render_duration)
|
||||
(output_path, render_duration, cover_candidates, voiceover_path)
|
||||
"""
|
||||
if not downloaded_videos:
|
||||
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
|
||||
@@ -999,27 +1003,34 @@ def _render_video(
|
||||
list(template_config.keys()),
|
||||
)
|
||||
|
||||
# ── 用户自定义标题覆盖模板标题配置 ──────────────────────────────────
|
||||
if custom_title:
|
||||
# ── 用户自定义标题:title_config 优先,custom_title 兜底 ─────────────
|
||||
effective_title_cfg: dict | None = None
|
||||
if title_config and isinstance(title_config, dict) and title_config.get("text", "").strip():
|
||||
effective_title_cfg = dict(title_config)
|
||||
elif custom_title:
|
||||
try:
|
||||
user_title_cfg = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
|
||||
if isinstance(user_title_cfg, dict) and user_title_cfg.get("text", "").strip():
|
||||
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
|
||||
if "font_size" in user_title_cfg and "size" not in user_title_cfg:
|
||||
user_title_cfg["size"] = user_title_cfg["font_size"]
|
||||
if "font_color" in user_title_cfg and "color" not in user_title_cfg:
|
||||
user_title_cfg["color"] = user_title_cfg["font_color"]
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["title"] = user_title_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 用户自定义标题已注入: text=%s",
|
||||
task_id,
|
||||
user_title_cfg.get("text", "")[:30],
|
||||
)
|
||||
parsed = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
|
||||
if isinstance(parsed, dict) and parsed.get("text", "").strip():
|
||||
effective_title_cfg = parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("[task_id=%s] custom_title JSON解析失败: %s", task_id, custom_title[:100])
|
||||
|
||||
if effective_title_cfg:
|
||||
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
|
||||
if "font_size" in effective_title_cfg and "size" not in effective_title_cfg:
|
||||
effective_title_cfg["size"] = effective_title_cfg["font_size"]
|
||||
if "font_color" in effective_title_cfg and "color" not in effective_title_cfg:
|
||||
effective_title_cfg["color"] = effective_title_cfg["font_color"]
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["title"] = effective_title_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 标题配置已注入(source=%s): text=%s",
|
||||
task_id,
|
||||
"title_config" if title_config else "custom_title",
|
||||
effective_title_cfg.get("text", "")[:30],
|
||||
)
|
||||
|
||||
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
|
||||
if bgm_config:
|
||||
plan_cfg = virtual_plan.config or {}
|
||||
@@ -1113,8 +1124,10 @@ def _render_video(
|
||||
|
||||
# 配音素材库音频已在统一渲染引擎内部通过 audio 图层混音处理
|
||||
output_path = render_output_path
|
||||
# RenderAdapter 在渲染完成后用本地 ffmpeg 抽取的封面候选帧(已上传 OSS)
|
||||
cover_candidates = getattr(render_result, "cover_candidates", None)
|
||||
|
||||
return output_path, render_duration
|
||||
return output_path, render_duration, cover_candidates
|
||||
|
||||
|
||||
def _upload_and_record(
|
||||
@@ -1193,6 +1206,133 @@ def _upload_and_record(
|
||||
# ── Celery Task ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> str | None:
|
||||
"""将 GenerationTask 的配置同步到 EditPlan.config,返回配音本地路径(如果有)。
|
||||
|
||||
包括:title_config、BGM、输出分辨率。配音单独处理(需下载到本地)。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
|
||||
plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
plan = plan_repo.get(source_edit_plan_id)
|
||||
if plan is None:
|
||||
logger.error("[task] EditPlan not found: %s", source_edit_plan_id)
|
||||
return None
|
||||
|
||||
plan_config = dict(plan.config or {})
|
||||
changed = False
|
||||
|
||||
# 标题配置
|
||||
title_config = task_info.get("title_config") or {}
|
||||
if title_config and isinstance(title_config, dict) and title_config.get("text", "").strip():
|
||||
cfg = dict(title_config)
|
||||
# 字段名归一化
|
||||
if "font_size" in cfg and "size" not in cfg:
|
||||
cfg["size"] = cfg["font_size"]
|
||||
if "font_color" in cfg and "color" not in cfg:
|
||||
cfg["color"] = cfg["font_color"]
|
||||
plan_config["title"] = cfg
|
||||
changed = True
|
||||
logger.info("[task] title_config synced to plan: %s", cfg.get("text", "")[:30])
|
||||
|
||||
# BGM 配置
|
||||
bgm_config = task_info.get("bgm_config") or {}
|
||||
if bgm_config:
|
||||
from packages.domain.bgm_utils import merge_bgm_config
|
||||
|
||||
existing_bgm = plan_config.get("bgm", {}) or {}
|
||||
plan_config["bgm"] = merge_bgm_config(existing_bgm, bgm_config)
|
||||
changed = True
|
||||
|
||||
# 输出分辨率
|
||||
ow = task_info.get("output_width") or OUTPUT_WIDTH
|
||||
oh = task_info.get("output_height") or OUTPUT_HEIGHT
|
||||
if ow >= 100 and oh >= 100:
|
||||
export_cfg = dict(plan_config.get("export", {}) or {})
|
||||
export_cfg["resolution"] = f"{ow}x{oh}"
|
||||
plan_config["export"] = export_cfg
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
plan.config = plan_config
|
||||
plan_repo.update(plan)
|
||||
logger.info("[task] plan.config synced: plan_id=%s", source_edit_plan_id)
|
||||
|
||||
# 配音下载
|
||||
voiceover_path: str | None = None
|
||||
voice_library_id = task_info.get("voice_library_id", "")
|
||||
voice_ids = task_info.get("voice_ids", []) or []
|
||||
effective_voice_id = voice_library_id or (voice_ids[0] if voice_ids else "")
|
||||
|
||||
if effective_voice_id:
|
||||
import tempfile
|
||||
|
||||
voice_tmp = Path(tempfile.gettempdir()) / f"voice_{source_edit_plan_id}_{id(task_info)}.mp3"
|
||||
try:
|
||||
if _download_voice_asset(effective_voice_id, voice_tmp):
|
||||
voiceover_path = str(voice_tmp)
|
||||
logger.info("[task] voice downloaded: %s -> %s", effective_voice_id, voiceover_path)
|
||||
except Exception:
|
||||
logger.warning("[task] voice download failed: %s", effective_voice_id, exc_info=True)
|
||||
|
||||
return voiceover_path
|
||||
|
||||
|
||||
def _render_from_edit_plan(
|
||||
task_id: str,
|
||||
source_edit_plan_id: str,
|
||||
task_info: dict,
|
||||
) -> tuple[Path, float, list[dict] | None]:
|
||||
"""从 EditPlan 数据库记录直接渲染(不再内存重建clips)。
|
||||
|
||||
Returns:
|
||||
(output_path, render_duration, cover_candidates, voiceover_path)
|
||||
"""
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 同步配置到 plan.config + 下载配音
|
||||
voiceover_path = _sync_task_config_to_plan(source_edit_plan_id, task_info, db)
|
||||
|
||||
# 进度回调
|
||||
def _progress_cb(progress: float, stage: str):
|
||||
mapped = 40.0 + progress * 0.4
|
||||
_update_task_progress(task_id, min(mapped, 80.0), stage)
|
||||
|
||||
adapter = RenderAdapter(db)
|
||||
render_start = time.monotonic()
|
||||
logger.info("[task_id=%s] [渲染] RenderAdapter.render_plan 开始 (plan_id=%s)", task_id, source_edit_plan_id)
|
||||
|
||||
result = adapter.render_plan(
|
||||
plan_id=source_edit_plan_id,
|
||||
job_id=task_id,
|
||||
progress_cb=_progress_cb,
|
||||
voiceover_audio_path=voiceover_path,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise RuntimeError(f"渲染失败: {result.error_message}")
|
||||
|
||||
render_elapsed = time.monotonic() - render_start
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] RenderAdapter.render_plan 完成: 耗时=%.1fs, 时长=%.2fs",
|
||||
task_id,
|
||||
render_elapsed,
|
||||
result.duration,
|
||||
)
|
||||
|
||||
output_path = result.output_path
|
||||
cover_candidates = getattr(result, "cover_candidates", None)
|
||||
|
||||
return output_path, result.duration, cover_candidates, voiceover_path
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="worker.generate_video",
|
||||
@@ -1283,6 +1423,188 @@ def generate_video(self, task_id: str) -> dict:
|
||||
if template_id:
|
||||
_validate_template_exists(template_id)
|
||||
|
||||
# ── 新路径:有 source_edit_plan_id 时直接从数据库 EditPlan 渲染 ──
|
||||
source_edit_plan_id = task_info.get("source_edit_plan_id", "")
|
||||
if source_edit_plan_id:
|
||||
voiceover_tmp_path: str | None = None
|
||||
try:
|
||||
logger.info(
|
||||
"[task_id=%s] 使用 EditPlan 数据库路径渲染: plan_id=%s",
|
||||
task_id,
|
||||
source_edit_plan_id,
|
||||
)
|
||||
_update_task_progress(task_id, 30, "加载草稿数据")
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log("渲染模式", "从草稿数据渲染(与预览一致)")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
output_path, render_duration, cover_candidates, voiceover_tmp_path = _render_from_edit_plan(
|
||||
task_id=task_id,
|
||||
source_edit_plan_id=source_edit_plan_id,
|
||||
task_info=task_info,
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
_update_task_progress(task_id, 80, "渲染完成")
|
||||
|
||||
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────
|
||||
_update_task_progress(task_id, 85, "开始上传")
|
||||
file_url, duration, file_size, video_count = _upload_and_record(
|
||||
task_id=task_id,
|
||||
output_path=output_path,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
editing_mode=editing_mode,
|
||||
user_id=user_id,
|
||||
video_name=task_info.get("video_title", ""),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"OSS上传",
|
||||
f"上传成功, 大小={file_size}",
|
||||
file_size=file_size,
|
||||
file_url=file_url,
|
||||
)
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
|
||||
# ── 4.5 封面帧持久化 ────────────────────────────────────────────
|
||||
try:
|
||||
if cover_candidates:
|
||||
first = cover_candidates[0]
|
||||
cover_frame_url = first.get("image_url") or first.get("url") or ""
|
||||
if cover_frame_url:
|
||||
_cover_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
|
||||
_cover_model = (
|
||||
_cover_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _cover_model:
|
||||
_cover_model.cover_url = cover_frame_url
|
||||
meta = dict(_cover_model.extra_meta or {})
|
||||
meta["cover_candidates"] = cover_candidates
|
||||
_cover_model.extra_meta = meta
|
||||
_cover_session.commit()
|
||||
finally:
|
||||
_cover_session.close()
|
||||
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.1 更新标题使用次数
|
||||
try:
|
||||
_title_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.title_library_repository import (
|
||||
SQLAlchemyTitleLibraryRepository,
|
||||
)
|
||||
|
||||
_task_repo = SQLAlchemyGenerationTaskRepository(_title_session)
|
||||
_gen_task = _task_repo.get(task_id)
|
||||
if _gen_task and _gen_task.title_ids and _gen_task.created_by_user_id:
|
||||
_title_repo = SQLAlchemyTitleLibraryRepository(_title_session)
|
||||
for _tid in _gen_task.title_ids:
|
||||
try:
|
||||
_title_repo.increment_usage_count(_tid, _gen_task.created_by_user_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 更新标题使用次数失败: title_id=%s",
|
||||
task_id,
|
||||
_tid,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
_title_session.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 更新标题使用次数异常", task_id, exc_info=True)
|
||||
|
||||
# 5.2 更新素材使用次数
|
||||
try:
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation
|
||||
|
||||
_asset_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_task_repo = SQLAlchemyGenerationTaskRepository(_asset_session)
|
||||
_asset_repo = SQLAlchemyAssetRepository(_asset_session)
|
||||
_gen_task = _task_repo.get(task_id)
|
||||
if _gen_task and _gen_task.asset_ids:
|
||||
for _aid in _gen_task.asset_ids:
|
||||
try:
|
||||
_asset = _asset_repo.get(_aid)
|
||||
if _asset:
|
||||
mark_asset_used_for_generation(_asset)
|
||||
_asset_repo.update(_asset)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 更新素材使用次数失败: asset_id=%s",
|
||||
task_id,
|
||||
_aid,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
_asset_session.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 更新素材使用次数异常", task_id, exc_info=True)
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"任务完成",
|
||||
f"视频生成完成: 时长={duration:.2f}s, 大小={file_size}",
|
||||
duration=round(duration, 2),
|
||||
file_size=file_size,
|
||||
video_count=video_count,
|
||||
)
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
logger.info(
|
||||
"[task_id=%s] [任务完成] duration=%.2fs file_size=%d (edit_plan path)",
|
||||
task_id,
|
||||
duration,
|
||||
file_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"task_id": task_id,
|
||||
"output_path": str(output_path),
|
||||
"file_size": file_size,
|
||||
"duration": duration,
|
||||
"mode": editing_mode.value,
|
||||
}
|
||||
|
||||
finally:
|
||||
# 无论任务成功或失败,都清理临时配音文件,避免磁盘泄漏
|
||||
if voiceover_tmp_path:
|
||||
try:
|
||||
Path(voiceover_tmp_path).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("[task_id=%s] 清理临时配音文件失败: %s", task_id, voiceover_tmp_path)
|
||||
|
||||
# DEPRECATED: 以下为旧路径,仅兼容无 source_edit_plan_id 的旧调用,后续移除
|
||||
with tempfile.TemporaryDirectory(prefix="xiaoxia-generation-") as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
@@ -1358,10 +1680,27 @@ def generate_video(self, task_id: str) -> dict:
|
||||
logger.info("[task_id=%s] MediaKit 视频理解完成: %d 个素材", task_id, len(asset_urls))
|
||||
|
||||
# 保存分析结果到 extra_meta
|
||||
if asset_analyses and gen_task:
|
||||
gen_task.extra_meta = {**(gen_task.extra_meta or {}), "asset_analyses": asset_analyses}
|
||||
_repo.update(gen_task)
|
||||
_flush_logs(task_id, gen_task)
|
||||
if asset_analyses:
|
||||
_meta_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
|
||||
_m = (
|
||||
_meta_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _m:
|
||||
existing = dict(_m.extra_meta or {})
|
||||
existing["asset_analyses"] = asset_analyses
|
||||
_m.extra_meta = existing
|
||||
_meta_session.commit()
|
||||
finally:
|
||||
_meta_session.close()
|
||||
if gen_task:
|
||||
_flush_logs(task_id, gen_task)
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] MediaKit 视频理解失败,继续渲染", task_id, exc_info=True)
|
||||
|
||||
@@ -1385,7 +1724,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
else:
|
||||
_resolved_resolution = task_info.get("resolution", "")
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
output_path, render_duration, cover_candidates = _render_video(
|
||||
task_id=task_id,
|
||||
downloaded_videos=downloaded_videos,
|
||||
voice_path=audio_path,
|
||||
@@ -1399,6 +1738,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
custom_title=task_info.get("custom_title", ""),
|
||||
title_config=task_info.get("title_config", {}),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
@@ -1430,51 +1770,47 @@ def generate_video(self, task_id: str) -> dict:
|
||||
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
|
||||
# ── 4.5 封面抽帧 ────────────────────────────────────────────────
|
||||
# 预览视频上传完成后,提取封面帧写入 gen_task.cover_url
|
||||
# 这样封面路由(generation_cover.py 步骤A)可以通过 generation_task_id 直接找到
|
||||
# ── 4.5 封面帧持久化 ────────────────────────────────────────────
|
||||
# RenderAdapter 在渲染完成后已用本地 ffmpeg 从 output_path 抽帧
|
||||
# (标题通过 ASS 烧录,帧天然带标题),并上传 OSS 返回 cover_candidates。
|
||||
# 这里把第一帧写入 gen_task.cover_url,完整列表写入 metadata,
|
||||
# 封面路由(generation_cover.py)的 A/B/C/D 步骤即可直接命中。
|
||||
try:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
if cover_candidates:
|
||||
first = cover_candidates[0]
|
||||
# 候选帧字段兼容:RenderAdapter 用 image_url,thumbnail_generator 用 url
|
||||
cover_frame_url = first.get("image_url") or first.get("url") or ""
|
||||
if cover_frame_url:
|
||||
_cover_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
|
||||
mk_client = get_mediakit_client()
|
||||
if mk_client.is_available:
|
||||
_update_task_progress(task_id, 96, "提取封面帧")
|
||||
snapshots = mk_client.extract_frames(
|
||||
video_url=file_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=1,
|
||||
)
|
||||
if snapshots and len(snapshots) > 0:
|
||||
cover_frame_url = snapshots[0].get("image_url", "")
|
||||
if cover_frame_url and gen_task:
|
||||
# 通过独立 session 持久化 cover_url
|
||||
_cover_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
_cover_model = (
|
||||
_cover_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _cover_model:
|
||||
_cover_model.cover_url = cover_frame_url
|
||||
# 持久化完整候选列表到 extra_meta
|
||||
meta = dict(_cover_model.extra_meta or {})
|
||||
meta["cover_candidates"] = cover_candidates
|
||||
_cover_model.extra_meta = meta
|
||||
_cover_session.commit()
|
||||
logger.info(
|
||||
"[task_id=%s] 封面帧已持久化(ffmpeg本地抽帧): cover_url=%s candidates=%d",
|
||||
task_id,
|
||||
cover_frame_url[:80],
|
||||
len(cover_candidates),
|
||||
)
|
||||
|
||||
_cover_model = (
|
||||
_cover_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _cover_model:
|
||||
_cover_model.cover_url = cover_frame_url
|
||||
_cover_session.commit()
|
||||
logger.info(
|
||||
"[task_id=%s] 封面帧提取成功: %s",
|
||||
task_id,
|
||||
cover_frame_url[:80],
|
||||
)
|
||||
finally:
|
||||
_cover_session.close()
|
||||
else:
|
||||
logger.warning("[task_id=%s] 封面帧提取返回空结果", task_id)
|
||||
finally:
|
||||
_cover_session.close()
|
||||
else:
|
||||
logger.warning("[task_id=%s] MediaKit 未配置,跳过封面帧提取", task_id)
|
||||
logger.warning("[task_id=%s] 渲染未产出 cover_candidates,封面将依赖 API 兜底", task_id)
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 封面帧提取失败(不影响主流程)", task_id, exc_info=True)
|
||||
logger.warning("[task_id=%s] 封面帧持久化失败(不影响主流程)", task_id, exc_info=True)
|
||||
|
||||
# ── 5. 标记完成 ──────────────────────────────────────────────────
|
||||
_update_task_status(task_id, "mark_completed", result_count=video_count)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
@@ -233,6 +234,183 @@ def ingest_asset(job_id: str) -> dict:
|
||||
job_id,
|
||||
thumb_err,
|
||||
)
|
||||
|
||||
# ── HEVC 自动转码为 1080p H.264 ──────────────────────────────
|
||||
# 浏览器 WebCodecs 硬件解码 HEVC 输出黑帧,上传时自动转码
|
||||
# 失败时降级使用原始文件,不阻塞上传流程
|
||||
if media_type == "video" and local_file and local_file.exists():
|
||||
codec = (metadata.get("codec") or "").lower()
|
||||
if codec in ("hevc", "h265", "hvh1"):
|
||||
logger.info(
|
||||
"检测到 HEVC 编码 (codec=%s),启动转码: job_id=%s",
|
||||
codec,
|
||||
job_id,
|
||||
)
|
||||
_tc_tmp = None
|
||||
_needs_rotation = False
|
||||
|
||||
# ── Step 1: 磁盘空间检查(独立 try/except,失败仍尝试转码)──
|
||||
try:
|
||||
_disk_usage = shutil.disk_usage("/tmp")
|
||||
_free_gb = _disk_usage.free / (1024**3)
|
||||
if _free_gb < 2:
|
||||
raise RuntimeError(f"磁盘空间不足 ({_free_gb:.1f}GB < 2GB)")
|
||||
except Exception as _disk_err:
|
||||
logger.warning("磁盘检查失败,仍尝试转码: job_id=%s err=%s", job_id, _disk_err)
|
||||
|
||||
# ── Step 2: ffprobe 旋转检测(独立 try/except,失败不阻塞转码)──
|
||||
try:
|
||||
_probe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"side_data=rotation",
|
||||
"-show_entries",
|
||||
"stream_tags=rotate",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_file),
|
||||
]
|
||||
_probe_result = subprocess.run(
|
||||
_probe_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=60, # 大文件在容器 overlay 文件系统上解析可能较慢
|
||||
)
|
||||
_rotation_str = (_probe_result.stdout or "").strip().split("\n")[0]
|
||||
if _rotation_str in ("90", "270", "-90"):
|
||||
_needs_rotation = True
|
||||
logger.info(
|
||||
"检测到竖屏视频 (rotation=%s),将物理旋转画面: job_id=%s",
|
||||
_rotation_str,
|
||||
job_id,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"ffprobe 旋转检测超时(60s),跳过旋转继续转码: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
_needs_rotation = False
|
||||
except Exception as _probe_err:
|
||||
logger.warning(
|
||||
"ffprobe 旋转检测异常,跳过旋转继续转码: job_id=%s err=%s",
|
||||
job_id,
|
||||
_probe_err,
|
||||
)
|
||||
_needs_rotation = False
|
||||
|
||||
# ── Step 3: ffmpeg 转码(独立 try/except)──
|
||||
try:
|
||||
_tc_tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix="_h264.mp4")
|
||||
_tc_tmp = Path(_tc_tmp_file.name)
|
||||
_tc_tmp_file.close() # 关闭文件描述符,ffmpeg 会自己打开
|
||||
|
||||
# 构建 video filter:竖屏先旋转再缩放
|
||||
if _needs_rotation:
|
||||
_vf = "transpose=1,scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'"
|
||||
else:
|
||||
_vf = "scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'"
|
||||
|
||||
_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(local_file),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-vf",
|
||||
_vf + ",format=yuv420p",
|
||||
"-colorspace",
|
||||
"bt709",
|
||||
"-color_primaries",
|
||||
"bt709",
|
||||
"-color_trc",
|
||||
"bt709",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-level",
|
||||
"4.2",
|
||||
]
|
||||
# 竖屏视频:清除旋转元数据
|
||||
if _needs_rotation:
|
||||
_cmd.extend(["-metadata:s:v:0", "rotate=0"])
|
||||
_cmd.extend(
|
||||
[
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(_tc_tmp),
|
||||
]
|
||||
)
|
||||
_proc = subprocess.run(
|
||||
_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
if _proc.returncode == 0 and _tc_tmp.exists() and _tc_tmp.stat().st_size > 0:
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
_p = Path(job.storage_key)
|
||||
_new_key = str(_p.parent / (_p.stem + "_h264" + _p.suffix))
|
||||
_url = upload_to_oss(_tc_tmp, _new_key)
|
||||
if _url:
|
||||
# 先提取元数据,确认成功后再更新 storage_key(避免脏数据)
|
||||
_new_metadata, _new_extract_success = extract_media_metadata(
|
||||
str(_tc_tmp),
|
||||
media_type,
|
||||
)
|
||||
if _new_extract_success:
|
||||
job.storage_key = _new_key
|
||||
metadata = _new_metadata
|
||||
extract_success = _new_extract_success
|
||||
logger.info(
|
||||
"HEVC→H.264 转码完成: job_id=%s key=%s",
|
||||
job_id,
|
||||
_new_key[:80],
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"转码文件上传 OSS 失败,使用原始文件: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
else:
|
||||
_tail = _proc.stderr[-300:] if _proc.stderr else ""
|
||||
logger.warning(
|
||||
"FFmpeg 转码失败 rc=%s stderr=%s: job_id=%s",
|
||||
_proc.returncode,
|
||||
_tail,
|
||||
job_id,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"FFmpeg 转码超时(900s),降级原始文件: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.warning(
|
||||
"HEVC 转码异常(降级原始文件): job_id=%s err=%s",
|
||||
job_id,
|
||||
_e,
|
||||
)
|
||||
finally:
|
||||
if _tc_tmp and _tc_tmp.exists():
|
||||
try:
|
||||
_tc_tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if local_file and local_file.exists():
|
||||
try:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user