Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0709ea4cd0 | |||
| 0a59902957 | |||
| 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 |
@@ -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")
|
||||
@@ -31,8 +31,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Generation"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -65,6 +63,66 @@ class GenerateCoverResponse(BaseModel):
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _persist_cover_frame(frame_url: str, plan_id: str, title_text: str = "") -> str:
|
||||
"""下载 MediaKit 返回的临时帧图,可选叠加标题后转存到 OSS covers/ 路径。
|
||||
|
||||
Args:
|
||||
frame_url: MediaKit 返回的临时帧图 URL
|
||||
plan_id: 剪辑计划 ID(生成 OSS key)
|
||||
title_text: 非空时用 Pillow 在帧上叠加标题(用于 E2 从源素材抽帧,
|
||||
因为源素材本身没有烧录标题)
|
||||
"""
|
||||
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)
|
||||
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 +256,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 +355,118 @@ 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 ""
|
||||
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)
|
||||
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 +482,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,13 @@ def create_generation_task(
|
||||
log_task_status=True,
|
||||
):
|
||||
created_tasks.append(task)
|
||||
if request.source_edit_plan_id:
|
||||
_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:
|
||||
|
||||
@@ -56,6 +56,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
|
||||
@router.post("/generate", response_model=EditPlanGenerateResponse)
|
||||
def generate_editor_draft(
|
||||
template_id: str,
|
||||
@@ -285,6 +286,7 @@ def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
|
||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
||||
def get_editor_generation_status(
|
||||
template_id: str,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -231,10 +231,7 @@ test.describe("Core generation flow", () => {
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
path.endsWith("/generation/tasks")
|
||||
)
|
||||
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
@@ -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,32 +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(() => {
|
||||
if (!useWebCodecs || !canPlay) return
|
||||
const container = canvasContainerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
// 立即设置一次 canvas 像素分辨率,避免默认 300×150 导致首帧变形
|
||||
const initRect = container.getBoundingClientRect()
|
||||
if (initRect.width > 0 && initRect.height > 0) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = initRect.width * dpr
|
||||
canvas.height = initRect.height * dpr
|
||||
}
|
||||
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()
|
||||
}, [useWebCodecs, canPlay])
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
@@ -298,6 +297,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
|
||||
return (
|
||||
<div
|
||||
className="xx-preview-empty"
|
||||
@@ -316,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
|
||||
@@ -332,7 +345,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
return (
|
||||
<>
|
||||
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
|
||||
{useWebCodecs && (
|
||||
{effectiveUseWebCodecs && (
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
style={{
|
||||
@@ -353,8 +366,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Video 渲染层(fallback 路径) ── */}
|
||||
{!useWebCodecs &&
|
||||
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
|
||||
{!effectiveUseWebCodecs &&
|
||||
segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
@@ -362,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",
|
||||
@@ -423,7 +434,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{useWebCodecs ? "Canvas" : `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
||||
{`片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
||||
</div>
|
||||
|
||||
{/* 控制条 */}
|
||||
|
||||
@@ -182,6 +182,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>
|
||||
)
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -2309,6 +2309,8 @@
|
||||
.xx-cover-preview-box {
|
||||
position: relative;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-width: 180px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -9,8 +9,21 @@ import { createFile } from "mp4box"
|
||||
import type { Movie, Sample } from "mp4box"
|
||||
|
||||
// ── 常量 ──
|
||||
/** 初始化预解码最大帧数(约 2 秒 @30fps),后续帧通过 decodeAroundPosition 按需解码 */
|
||||
const MAX_INIT_FRAMES = 60
|
||||
|
||||
/**
|
||||
* 规范化 mp4box 提取的 codec 字符串为 WebCodecs 兼容格式
|
||||
* mp4box 返回的 codec 可能包含 mp4box 特有后缀(如 avc1.640028),
|
||||
* WebCodecs 要求标准 ISO BMFF codec string(如 avc1.640028)
|
||||
* 大部分情况下格式一致,但需要做防御性处理
|
||||
*/
|
||||
function normalizeCodecString(codec: string): string {
|
||||
// mp4box 有时返回带空格的 codec,去掉
|
||||
const trimmed = codec.trim()
|
||||
// HEVC: mp4box 可能返回 hev1.1.6.L93.B0 或 hvc1.1.6.L93.B0
|
||||
// WebCodecs 接受 hev1.x.x 或 hvc1.x.x,两者都可
|
||||
// H.264: mp4box 返回 avc1.640028,WebCodecs 也接受
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// ── MP4 Box 解析辅助函数 ──
|
||||
|
||||
@@ -96,7 +109,7 @@ class FrameQueue {
|
||||
private frames: FrameEntry[] = []
|
||||
private maxSize: number
|
||||
|
||||
constructor(maxSize = 5) {
|
||||
constructor(maxSize = 200) {
|
||||
this.maxSize = maxSize
|
||||
}
|
||||
|
||||
@@ -108,24 +121,36 @@ class FrameQueue {
|
||||
this.frames.push(entry)
|
||||
}
|
||||
|
||||
/** 获取当前时间戳应显示的帧 */
|
||||
/** 获取当前时间戳应显示的帧(二分查找,O(log n)) */
|
||||
getCurrentFrame(timestamp: number): VideoFrame | null {
|
||||
let best: FrameEntry | null = null
|
||||
let bestIdx = -1
|
||||
for (let i = 0; i < this.frames.length; i++) {
|
||||
const f = this.frames[i]
|
||||
if (f.pts <= timestamp + 0.01) {
|
||||
best = f
|
||||
bestIdx = i
|
||||
if (this.frames.length === 0) return null
|
||||
|
||||
const target = timestamp + 0.01
|
||||
|
||||
// 找到最后一个 pts <= target 的帧(右边界)
|
||||
let lo = 0,
|
||||
hi = this.frames.length - 1,
|
||||
bestIdx = -1
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (this.frames[mid].pts <= target) {
|
||||
bestIdx = mid
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx < 0) return null
|
||||
|
||||
// 关闭并移除 bestIdx 之前的所有已播放帧
|
||||
for (let i = 0; i < bestIdx; i++) {
|
||||
this.frames[i].frame.close()
|
||||
}
|
||||
if (bestIdx >= 0) {
|
||||
this.frames = this.frames.slice(bestIdx)
|
||||
}
|
||||
return best?.frame ?? null
|
||||
this.frames.splice(0, bestIdx)
|
||||
|
||||
// 此时 bestIdx 对应帧已在索引 0
|
||||
return this.frames[0]?.frame ?? null
|
||||
}
|
||||
|
||||
clear() {
|
||||
@@ -172,6 +197,10 @@ export interface CanvasPlayerState {
|
||||
duration: number
|
||||
isReady: boolean
|
||||
isBuffering: boolean
|
||||
/** WebCodecs 解码失败时为 true,调用方应 fallback 到原生 video 播放 */
|
||||
hasDecodeError: boolean
|
||||
/** 解码错误信息(用于 UI 展示) */
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export interface CanvasPlayerControls {
|
||||
@@ -209,21 +238,29 @@ export function useCanvasPlayer(
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
},
|
||||
onError?: (error: Error) => void,
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
const [state, setState] = useState<CanvasPlayerState>({
|
||||
hasSupport: isWebCodecsSupported(),
|
||||
hasSupport: enabled && isWebCodecsSupported(),
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
isReady: false,
|
||||
isBuffering: false,
|
||||
hasDecodeError: false,
|
||||
errorMessage: "",
|
||||
})
|
||||
|
||||
// ── 内部引用 ──
|
||||
const decoderRef = useRef<VideoDecoder | null>(null)
|
||||
const frameQueueRef = useRef(new FrameQueue(600))
|
||||
/** 已解码的片段索引集合,用于按需解码(先标记防重入,失败时移除允许重试) */
|
||||
const decodedSegmentsRef = useRef(new Set<number>())
|
||||
const frameQueueRef = useRef(new FrameQueue(200))
|
||||
/** 每个片段持久化解码器,避免每次新建导致关键帧错误 */
|
||||
const segmentDecodersRef = useRef(new Map<number, VideoDecoder>())
|
||||
/** 每个片段已送入解码器的 sample 游标(用于续解码) */
|
||||
const segmentSampleCursorRef = useRef(new Map<number, number>())
|
||||
/** 后台补充解码是否正在运行(防重入) */
|
||||
const isFeedingRef = useRef(false)
|
||||
/** 解码代数计数器,seek 时递增以作废正在进行的异步解码 */
|
||||
const decodeGenerationRef = useRef(0)
|
||||
const rafRef = useRef<number>(0)
|
||||
@@ -234,6 +271,8 @@ export function useCanvasPlayer(
|
||||
const videoDimRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||
const isDestroyedRef = useRef(false)
|
||||
const lastProgressUpdateRef = useRef<number>(0)
|
||||
const onErrorRef = useRef(onError)
|
||||
onErrorRef.current = onError
|
||||
|
||||
// 计算总时长
|
||||
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
|
||||
@@ -367,7 +406,7 @@ export function useCanvasPlayer(
|
||||
globalEndTime: globalStart + (segment.endTime - segment.startTime),
|
||||
trackId: videoTrack.id ?? 1,
|
||||
timescale: videoTrack.timescale ?? 90000,
|
||||
codec: videoTrack.codec ?? "avc1.42E01E",
|
||||
codec: normalizeCodecString(videoTrack.codec ?? "avc1.42E01E"),
|
||||
videoWidth: videoTrack.track_width || 1280,
|
||||
videoHeight: videoTrack.track_height || 720,
|
||||
description,
|
||||
@@ -451,87 +490,91 @@ export function useCanvasPlayer(
|
||||
[segments, extractCodecDescription],
|
||||
)
|
||||
|
||||
// ── 初始化 VideoDecoder 并解码指定片段 ──
|
||||
const decodeSegment = useCallback(
|
||||
async (_buffer: ArrayBuffer, meta: SegmentMeta, maxFrames?: number): Promise<void> => {
|
||||
if (isDestroyedRef.current) return
|
||||
// ── 解码片段的一批帧(使用持久化解码器,支持从断点续解码) ──
|
||||
/**
|
||||
* @param segIdx 片段索引
|
||||
* @param maxFrames 本次最多解码多少帧
|
||||
*/
|
||||
const decodeSegmentBatch = useCallback(
|
||||
async (segIdx: number, maxFrames: number = 60): Promise<number> => {
|
||||
if (isDestroyedRef.current) return 0
|
||||
const metas = segmentMetaRef.current
|
||||
const meta = metas[segIdx]
|
||||
if (!meta) return 0
|
||||
const buffer = segmentDataRef.current.get(meta.assetId)
|
||||
if (!buffer) return 0
|
||||
|
||||
let decoderReady = false
|
||||
const gen = decodeGenerationRef.current
|
||||
let decoder = segmentDecodersRef.current.get(segIdx)
|
||||
let cursor = segmentSampleCursorRef.current.get(segIdx) ?? 0
|
||||
const samples = meta.samples
|
||||
|
||||
// 配置解码器(每个片段可能需要不同的 codec/分辨率)
|
||||
const decoder = new VideoDecoder({
|
||||
output: (frame: VideoFrame) => {
|
||||
// 从第一帧获取实际尺寸
|
||||
if (videoDimRef.current.width === 0 || videoDimRef.current.height === 0) {
|
||||
videoDimRef.current = { width: frame.codedWidth, height: frame.codedHeight }
|
||||
console.log(
|
||||
`[useCanvasPlayer] Actual frame size: ${frame.codedWidth}x${frame.codedHeight}`,
|
||||
)
|
||||
}
|
||||
const localTime = frame.timestamp / 1_000_000
|
||||
const globalTime = localTime + meta.globalStartTime
|
||||
frameQueueRef.current.push({
|
||||
frame,
|
||||
pts: globalTime,
|
||||
duration: (frame.duration ?? 0) / 1_000_000,
|
||||
})
|
||||
},
|
||||
error: (e: DOMException) => {
|
||||
console.error("[useCanvasPlayer] Decoder error:", e)
|
||||
},
|
||||
})
|
||||
|
||||
console.log("[useCanvasPlayer] configure:", {
|
||||
codec: meta.codec,
|
||||
description: meta.description,
|
||||
descriptionByteLength: meta.description?.byteLength,
|
||||
videoWidth: meta.videoWidth,
|
||||
videoHeight: meta.videoHeight,
|
||||
})
|
||||
|
||||
try {
|
||||
await decoder.configure({
|
||||
codec: meta.codec,
|
||||
...(meta.description ? { description: meta.description } : {}),
|
||||
// 如果还没有解码器,新建一个(从关键帧开始,不会报 key frame 错误)
|
||||
if (!decoder || decoder.state === "closed") {
|
||||
decoder = new VideoDecoder({
|
||||
output: (frame: VideoFrame) => {
|
||||
if (videoDimRef.current.width === 0 || videoDimRef.current.height === 0) {
|
||||
videoDimRef.current = { width: frame.codedWidth, height: frame.codedHeight }
|
||||
}
|
||||
const localTime = frame.timestamp / 1_000_000
|
||||
const globalTime = localTime + meta.globalStartTime
|
||||
frameQueueRef.current.push({
|
||||
frame,
|
||||
pts: globalTime,
|
||||
duration: (frame.duration ?? 0) / 1_000_000,
|
||||
})
|
||||
},
|
||||
error: (e: DOMException) => {
|
||||
console.error(`[useCanvasPlayer] Segment ${segIdx} decoder error:`, e)
|
||||
// 重置该片段的解码器和游标,允许重试
|
||||
segmentDecodersRef.current.delete(segIdx)
|
||||
segmentSampleCursorRef.current.set(segIdx, 0)
|
||||
},
|
||||
})
|
||||
decoderRef.current = decoder
|
||||
decoderReady = true
|
||||
// 标记缓冲结束,让 UI 开始渲染
|
||||
|
||||
try {
|
||||
await decoder.configure({
|
||||
codec: meta.codec,
|
||||
...(meta.description ? { description: meta.description } : {}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[useCanvasPlayer] Segment ${segIdx} configure failed:`, err)
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setState((s) => ({
|
||||
...s,
|
||||
isBuffering: false,
|
||||
hasDecodeError: true,
|
||||
errorMessage: `视频解码失败: ${error.message || "不支持的编解码器"}`,
|
||||
}))
|
||||
onErrorRef.current?.(error)
|
||||
return 0
|
||||
}
|
||||
|
||||
segmentDecodersRef.current.set(segIdx, decoder)
|
||||
cursor = 0
|
||||
// 标记缓冲结束
|
||||
setState((s) => ({ ...s, isBuffering: false }))
|
||||
} catch (err) {
|
||||
console.error("[useCanvasPlayer] Decoder configure failed for segment:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if (!decoderReady) return
|
||||
if (decoder.state !== "configured") return 0
|
||||
|
||||
// 使用 demuxSegment 中已提取并过滤的 samples(前端切片)
|
||||
const samplesCollected = meta.samples
|
||||
console.log(
|
||||
`[useCanvasPlayer] Segment ${meta.assetId}: ${samplesCollected.length} samples to decode`,
|
||||
)
|
||||
if (samplesCollected.length === 0) {
|
||||
console.warn("[useCanvasPlayer] No samples to decode for segment", meta.assetId)
|
||||
return
|
||||
}
|
||||
|
||||
// 送入解码器
|
||||
let decodedCount = 0
|
||||
let skippedCount = 0
|
||||
let decodeErrors = 0
|
||||
for (const sample of samplesCollected) {
|
||||
if (!sample.data || isDestroyedRef.current) {
|
||||
skippedCount++
|
||||
// 从 cursor 继续喂 sample(流水线批量提交,不 await 单个 decode)
|
||||
let decoded = 0
|
||||
let si = cursor
|
||||
while (si < samples.length && decoded < maxFrames) {
|
||||
if (decodeGenerationRef.current !== gen || isDestroyedRef.current) break
|
||||
if ((decoder.state as string) === "closed") break
|
||||
// 帧队列快满时停止提交(这才是真正的背压)
|
||||
if (frameQueueRef.current.size >= 180) break
|
||||
// 解码器内部队列积压过多时短暂让出线程(阈值64,给硬件足够流水线深度)
|
||||
if (decoder.decodeQueueSize > 64) {
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
continue
|
||||
}
|
||||
if (decoder.state === "closed") break
|
||||
// 初始化阶段限制解码帧数,避免帧缓冲溢出
|
||||
if (maxFrames && decodedCount >= maxFrames) {
|
||||
console.log(
|
||||
`[useCanvasPlayer] Segment ${meta.assetId}: init decode limited to ${maxFrames} frames`,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
const sample = samples[si]
|
||||
si++
|
||||
if (!sample.data) continue
|
||||
|
||||
const chunk = new EncodedVideoChunk({
|
||||
type: sample.is_sync ? "key" : "delta",
|
||||
@@ -541,85 +584,73 @@ export function useCanvasPlayer(
|
||||
})
|
||||
|
||||
try {
|
||||
await decoder.decode(chunk) // 修复:await 捕获异步错误
|
||||
decodedCount++
|
||||
decoder.decode(chunk)
|
||||
decoded++
|
||||
} catch (e) {
|
||||
decodeErrors++
|
||||
console.warn(`[useCanvasPlayer] Decode chunk error (${decodeErrors}):`, e)
|
||||
// 连续 3 次解码失败,放弃当前片段
|
||||
if (decodeErrors >= 3) {
|
||||
console.error("[useCanvasPlayer] Too many decode errors, aborting segment")
|
||||
break
|
||||
}
|
||||
console.warn(`[useCanvasPlayer] Segment ${segIdx} decode error:`, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cursor = si
|
||||
segmentSampleCursorRef.current.set(segIdx, cursor)
|
||||
// 等待解码器输出帧(最多500ms)
|
||||
if (decoded > 0 && (decoder.state as string) === "configured") {
|
||||
let waited = 0
|
||||
while (frameQueueRef.current.size < Math.min(decoded, 10) && waited < 500) {
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
waited += 20
|
||||
if (isDestroyedRef.current) break
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[useCanvasPlayer] Segment ${meta.assetId}: decoded ${decodedCount}, skipped ${skippedCount}, errors ${decodeErrors}, decoder.state=${decoder.state}`,
|
||||
`[useCanvasPlayer] Segment ${segIdx} decoded ${decoded} frames, queue size: ${frameQueueRef.current.size}`,
|
||||
)
|
||||
|
||||
// flush 仅在解码器状态正常时执行
|
||||
if (decoder.state === "configured") {
|
||||
try {
|
||||
await decoder.flush()
|
||||
console.log(`[useCanvasPlayer] Segment ${meta.assetId}: flush complete`)
|
||||
} catch (e) {
|
||||
console.warn("[useCanvasPlayer] Decoder flush error:", e)
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── 后台持续补充帧 ──
|
||||
/**
|
||||
* 按需解码当前播放位置 ±1 个片段。
|
||||
* 在渲染循环中定期调用,避免一次性解码所有片段导致环形缓冲区溢出丢帧。
|
||||
* 使用"先标记再解码"模式防止并发重复解码,失败时移除标记允许重试。
|
||||
* 根据当前播放时间,确保队列中有足够缓冲
|
||||
* 播放循环每 200ms 调用一次
|
||||
*/
|
||||
const decodeAroundPosition = useCallback(
|
||||
const feedFrames = useCallback(
|
||||
async (currentTime: number) => {
|
||||
const metas = segmentMetaRef.current
|
||||
if (!metas || metas.length === 0) return
|
||||
if (isFeedingRef.current) return
|
||||
isFeedingRef.current = true
|
||||
try {
|
||||
const metas = segmentMetaRef.current
|
||||
if (!metas || metas.length === 0) return
|
||||
|
||||
// 记录当前代数,seek 后代数变化则中止
|
||||
const gen = decodeGenerationRef.current
|
||||
// 队列帧数充足时不解码(目标:保持 >= 80 帧缓冲)
|
||||
if (frameQueueRef.current.size >= 80) return
|
||||
|
||||
let targetIdx = -1
|
||||
let acc = 0
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
const dur = metas[i].globalEndTime - metas[i].globalStartTime
|
||||
if (currentTime < acc + dur) {
|
||||
targetIdx = i
|
||||
break
|
||||
// 找到当前播放的片段
|
||||
let targetIdx = 0
|
||||
let acc = 0
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
const dur = metas[i].globalEndTime - metas[i].globalStartTime
|
||||
if (currentTime < acc + dur) {
|
||||
targetIdx = i
|
||||
break
|
||||
}
|
||||
acc += dur
|
||||
}
|
||||
acc += dur
|
||||
}
|
||||
if (targetIdx === -1) targetIdx = metas.length - 1
|
||||
|
||||
for (
|
||||
let i = Math.max(0, targetIdx - 1);
|
||||
i <= Math.min(metas.length - 1, targetIdx + 1);
|
||||
i++
|
||||
) {
|
||||
// seek 已作废当前解码任务
|
||||
if (decodeGenerationRef.current !== gen) return
|
||||
if (decodedSegmentsRef.current.has(i)) continue
|
||||
const meta = metas[i]
|
||||
const buffer = segmentDataRef.current.get(meta.assetId)
|
||||
if (!buffer) continue
|
||||
// 先标记为解码中,防止下一帧渲染时重复发起解码
|
||||
decodedSegmentsRef.current.add(i)
|
||||
try {
|
||||
await decodeSegment(buffer, meta, 300)
|
||||
} catch (e) {
|
||||
// 解码失败则移除标记,允许后续重试
|
||||
decodedSegmentsRef.current.delete(i)
|
||||
console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e)
|
||||
// 依次补充:当前片段 → 下一个片段 → 再下一个
|
||||
for (let offset = 0; offset <= 2; offset++) {
|
||||
const idx = targetIdx + offset
|
||||
if (idx >= metas.length) break
|
||||
if (frameQueueRef.current.size >= 180) break
|
||||
await decodeSegmentBatch(idx, 60)
|
||||
}
|
||||
// await 后再次检查代数,seek 期间不更新标记
|
||||
if (decodeGenerationRef.current !== gen) return
|
||||
} finally {
|
||||
isFeedingRef.current = false
|
||||
}
|
||||
},
|
||||
[decodeSegment],
|
||||
[decodeSegmentBatch],
|
||||
)
|
||||
|
||||
// ── 标题绘制 ──
|
||||
@@ -636,7 +667,6 @@ export function useCanvasPlayer(
|
||||
|
||||
// 按 "/" 分割为多行("/" 作为手动换行符)
|
||||
const lines = title.text.split(/[//⁄∕]/)
|
||||
console.log("[drawTitle] 原始标题:", JSON.stringify(title.text), "分割后:", lines)
|
||||
const lineHeight = fontSize * 1.3
|
||||
const totalHeight = lines.length * lineHeight
|
||||
|
||||
@@ -748,8 +778,10 @@ export function useCanvasPlayer(
|
||||
}
|
||||
return s
|
||||
})
|
||||
// 按需解码当前 ±1 片段
|
||||
decodeAroundPosition(currentTime)
|
||||
// 后台补充帧:队列不足时自动续解码
|
||||
if (frameQueueRef.current.size < 80) {
|
||||
void feedFrames(currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTime >= totalDuration) {
|
||||
@@ -758,29 +790,46 @@ export function useCanvasPlayer(
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(renderFrame)
|
||||
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition])
|
||||
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, feedFrames])
|
||||
|
||||
// ── 播放控制 ──
|
||||
const play = useCallback(async () => {
|
||||
if (!state.hasSupport || isDestroyedRef.current) return
|
||||
|
||||
// 重播场景:currentTime 已回到起点但 decodedSegmentsRef 仍有旧标记
|
||||
// 此时 FrameQueue 中旧帧已被淘汰,需清空标记让 decodeAroundPosition 重新解码
|
||||
if (state.currentTime <= 0.1 && decodedSegmentsRef.current.size > 0) {
|
||||
// 重播:必须关闭旧解码器、清空队列、重置游标,从头重新解码
|
||||
if (state.currentTime >= totalDuration - 0.1 || state.currentTime <= 0.1) {
|
||||
decodeGenerationRef.current++
|
||||
decodedSegmentsRef.current.clear()
|
||||
// 同步清空帧缓冲,避免旧帧残留导致 getCurrentFrame 返回 null
|
||||
// 关闭所有持久化解码器
|
||||
for (const d of segmentDecodersRef.current.values()) {
|
||||
try {
|
||||
if (d.state !== "closed") d.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
segmentDecodersRef.current.clear()
|
||||
segmentSampleCursorRef.current.clear()
|
||||
frameQueueRef.current.clear()
|
||||
playStartOffsetRef.current = 0
|
||||
setState((s) => ({ ...s, currentTime: 0 }))
|
||||
// 重新初始化解码
|
||||
const metas = segmentMetaRef.current
|
||||
const initialDecodeCount = Math.min(metas.length, 2)
|
||||
for (let i = 0; i < initialDecodeCount; i++) {
|
||||
await decodeSegmentBatch(i, 60)
|
||||
}
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, isPlaying: true }))
|
||||
playStartRef.current = performance.now()
|
||||
playStartOffsetRef.current = state.currentTime
|
||||
if (state.currentTime < 0.1) {
|
||||
playStartOffsetRef.current = 0
|
||||
} else {
|
||||
playStartOffsetRef.current = state.currentTime
|
||||
}
|
||||
lastProgressUpdateRef.current = 0
|
||||
rafRef.current = requestAnimationFrame(renderFrame)
|
||||
// 立即触发一次按需解码,不等渲染循环 200ms 节流
|
||||
decodeAroundPosition(state.currentTime)
|
||||
}, [state.hasSupport, state.currentTime, renderFrame, decodeAroundPosition])
|
||||
}, [state.hasSupport, state.currentTime, totalDuration, renderFrame, decodeSegmentBatch])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setState((s) => ({ ...s, isPlaying: false }))
|
||||
@@ -790,33 +839,61 @@ export function useCanvasPlayer(
|
||||
const seek = useCallback(
|
||||
async (time: number) => {
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDuration))
|
||||
decodeGenerationRef.current++
|
||||
// 关闭所有解码器、清空队列、重置游标
|
||||
for (const d of segmentDecodersRef.current.values()) {
|
||||
try {
|
||||
if (d.state !== "closed") d.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
segmentDecodersRef.current.clear()
|
||||
segmentSampleCursorRef.current.clear()
|
||||
frameQueueRef.current.clear()
|
||||
setState((s) => ({ ...s, currentTime: clampedTime }))
|
||||
playStartOffsetRef.current = clampedTime
|
||||
playStartRef.current = performance.now()
|
||||
// seek 时递增解码代数,作废正在进行的异步解码
|
||||
decodeGenerationRef.current++
|
||||
// 清空帧队列(clear 内部会 close 所有帧)+ 清空已解码标记
|
||||
frameQueueRef.current.clear()
|
||||
decodedSegmentsRef.current.clear()
|
||||
await decodeAroundPosition(clampedTime)
|
||||
// 找到 seek 目标片段,从该片段开始解码
|
||||
const metas = segmentMetaRef.current
|
||||
let targetIdx = 0,
|
||||
acc = 0
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
const dur = metas[i].globalEndTime - metas[i].globalStartTime
|
||||
if (clampedTime < acc + dur) {
|
||||
targetIdx = i
|
||||
break
|
||||
}
|
||||
acc += dur
|
||||
}
|
||||
await decodeSegmentBatch(targetIdx, 60)
|
||||
await decodeSegmentBatch(Math.min(targetIdx + 1, metas.length - 1), 60)
|
||||
},
|
||||
[totalDuration, decodeAroundPosition],
|
||||
[totalDuration, decodeSegmentBatch],
|
||||
)
|
||||
|
||||
const destroy = useCallback(() => {
|
||||
isDestroyedRef.current = true
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
// 关闭所有持久化解码器
|
||||
for (const d of segmentDecodersRef.current.values()) {
|
||||
try {
|
||||
if (d.state !== "closed") d.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
segmentDecodersRef.current.clear()
|
||||
segmentSampleCursorRef.current.clear()
|
||||
|
||||
if (decoderRef.current && decoderRef.current.state !== "closed") {
|
||||
decoderRef.current.close()
|
||||
}
|
||||
|
||||
// 递增代数中止进行中的异步解码,清空帧队列(clear 内部 close 所有帧)
|
||||
decodeGenerationRef.current++
|
||||
frameQueueRef.current.clear()
|
||||
segmentDataRef.current.clear()
|
||||
segmentMetaRef.current = []
|
||||
decodedSegmentsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
// ── 预加载下一个片段的数据 ──
|
||||
@@ -833,6 +910,7 @@ export function useCanvasPlayer(
|
||||
|
||||
// ── 初始化:加载并解码所有片段 ──
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (!state.hasSupport || segments.length === 0) {
|
||||
console.log("[useCanvasPlayer] Skip init:", {
|
||||
hasSupport: state.hasSupport,
|
||||
@@ -842,10 +920,24 @@ export function useCanvasPlayer(
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
console.log("[useCanvasPlayer] Init start, segments:", segments.length)
|
||||
|
||||
const init = async () => {
|
||||
setState((s) => ({ ...s, isBuffering: true }))
|
||||
// ✅ 关键修复:重置销毁标记,允许新的 init 周期正常工作
|
||||
// destroy() 在 useEffect cleanup 中被调用,将 isDestroyedRef 设为 true
|
||||
// 如果不重置,后续的 loadSegment / decodeSegment 会立即 return
|
||||
isDestroyedRef.current = false
|
||||
// ✅ Strict Mode 修复:init 不再递增 generation
|
||||
// seek() 和 play() 仍保留 generation 递增用于中止异步解码
|
||||
// 重置错误状态,避免上一轮的解码错误影响新的 init 周期
|
||||
setState((s) => ({
|
||||
...s,
|
||||
isBuffering: true,
|
||||
hasDecodeError: false,
|
||||
errorMessage: "",
|
||||
isReady: false,
|
||||
}))
|
||||
|
||||
console.log("[useCanvasPlayer] Init start v2_DIAG, segments:", segments.length)
|
||||
|
||||
// 1. 加载所有片段数据
|
||||
for (const seg of segments) {
|
||||
@@ -887,32 +979,44 @@ export function useCanvasPlayer(
|
||||
|
||||
segmentMetaRef.current = metas
|
||||
|
||||
// 3. 按需解码:初始只解码前 3 个片段,后续通过 decodeAroundPosition 动态加载
|
||||
// 避免一次性全量解码导致 frameQueue 环形缓冲区旧帧被丢弃引发黑屏
|
||||
decodedSegmentsRef.current.clear()
|
||||
const initGen = decodeGenerationRef.current
|
||||
const initialDecodeCount = Math.min(metas.length, 3)
|
||||
for (let i = 0; i < initialDecodeCount; i++) {
|
||||
if (cancelled) break
|
||||
// seek 或 destroy 已作废当前初始化
|
||||
if (decodeGenerationRef.current !== initGen) break
|
||||
const meta = metas[i]
|
||||
const buffer = segmentDataRef.current.get(meta.assetId)
|
||||
if (!buffer) continue
|
||||
// 先标记为解码中,防止重复解码
|
||||
decodedSegmentsRef.current.add(i)
|
||||
// 3. 初始化解码:关闭旧解码器,前 2 个片段各解 60 帧
|
||||
// 后续由 feedFrames 后台补充
|
||||
for (const d of segmentDecodersRef.current.values()) {
|
||||
try {
|
||||
await decodeSegment(buffer, meta, MAX_INIT_FRAMES)
|
||||
} catch (e) {
|
||||
// 解码失败则移除标记,允许后续重试
|
||||
decodedSegmentsRef.current.delete(i)
|
||||
console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e)
|
||||
if (d.state !== "closed") d.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
segmentDecodersRef.current.clear()
|
||||
segmentSampleCursorRef.current.clear()
|
||||
frameQueueRef.current.clear()
|
||||
|
||||
const initGen = decodeGenerationRef.current
|
||||
const initialDecodeCount = Math.min(metas.length, 2)
|
||||
console.log(
|
||||
`[useCanvasPlayer] Starting init decode: ${initialDecodeCount} segments, metas: ${metas.length}`,
|
||||
)
|
||||
for (let i = 0; i < initialDecodeCount; i++) {
|
||||
if (cancelled) break
|
||||
if (decodeGenerationRef.current !== initGen) break
|
||||
console.log(`[DIAG_v2] Init decode segment ${i}...`)
|
||||
try {
|
||||
await decodeSegmentBatch(i, 60)
|
||||
console.log(`[DIAG_v2] Init decode segment ${i} done`)
|
||||
} catch (e) {
|
||||
console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e)
|
||||
}
|
||||
if (cancelled) break
|
||||
}
|
||||
|
||||
console.log(`[useCanvasPlayer] Init decode finished, cancelled:`, cancelled)
|
||||
|
||||
if (!cancelled) {
|
||||
console.log("[useCanvasPlayer] Init complete, isReady = true")
|
||||
console.log("[useCanvasPlayer] Init complete, isReady = true, duration:", totalDuration)
|
||||
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
|
||||
} else {
|
||||
console.warn("[useCanvasPlayer] Init was cancelled before completion")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -136,6 +141,20 @@ export function useStep6Cover({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
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) => {
|
||||
@@ -154,6 +173,21 @@ export function useStep6Cover({
|
||||
try {
|
||||
const status = await getPreviewStatus(previewResp.task_id)
|
||||
if (status.status === "completed") {
|
||||
// 保存预览视频地址到 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") {
|
||||
done(() => reject(new Error(status.error_message || "预览渲染失败")))
|
||||
@@ -212,7 +246,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) => {
|
||||
|
||||
@@ -585,14 +585,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(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。
|
||||
"""视频封面抽帧工具 — 从视频中抽取帧作为封面,支持标题文字叠加。
|
||||
|
||||
统一封面管道:视频渲染时标题已通过 ASS 字幕烧进视频,
|
||||
渲染完成后直接从此视频抽帧,封面天然带标题,无需额外叠加逻辑。
|
||||
统一封面管道:
|
||||
- 从已渲染视频抽帧:标题已通过 ASS 字幕烧进视频,帧天然带标题,无需再叠加。
|
||||
- 从源素材抽帧(API E2 兜底):源素材无标题,通过 Pillow 在帧上绘制标题文字。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,6 +13,38 @@ 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,
|
||||
*,
|
||||
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,
|
||||
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,
|
||||
@@ -182,7 +215,8 @@ def extract_and_upload_cover_frames(
|
||||
video_path: 视频文件路径
|
||||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||||
num_frames: 抽取帧数(默认 3)
|
||||
title_text: 标题文字(当前版本未叠加,预留参数)
|
||||
title_text: 标题文字;非空时用 Pillow 叠加到每帧(白色 + 黑色描边)。
|
||||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||||
|
||||
Returns:
|
||||
封面候选列表,每项包含 {"url": str, "position": float}
|
||||
@@ -208,6 +242,9 @@ def extract_and_upload_cover_frames(
|
||||
seek_ratio=ratio,
|
||||
min_seek_seconds=0.5,
|
||||
)
|
||||
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
|
||||
if title_text and title_text.strip():
|
||||
apply_title_overlay(frame_path, title_text)
|
||||
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if url:
|
||||
|
||||
@@ -19,4 +19,14 @@ celery_app.conf.imports = (
|
||||
"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 分钟过期,避免堆积
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -887,6 +887,7 @@ 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 []),
|
||||
}
|
||||
finally:
|
||||
@@ -967,14 +968,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)
|
||||
"""
|
||||
if not downloaded_videos:
|
||||
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
|
||||
@@ -999,27 +1001,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 +1122,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(
|
||||
@@ -1385,7 +1396,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 +1410,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 +1442,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
|
||||
# 持久化完整候选列表到 metadata
|
||||
meta = dict(_cover_model.metadata or {})
|
||||
meta["cover_candidates"] = cover_candidates
|
||||
_cover_model.metadata = 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)
|
||||
|
||||
@@ -17,6 +17,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
ffmpeg \
|
||||
fonts-noto-cjk \
|
||||
fontconfig \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
|
||||
@@ -6,8 +6,13 @@ set -e
|
||||
|
||||
CONCURRENCY="${WORKER_CONCURRENCY:-2}"
|
||||
|
||||
# ⚠️ 部署约束:此 Worker 必须且只能运行单实例(replicas=1)
|
||||
# -B 标志嵌入 celery beat,beat 负责定期触发 pending 超时清理等定时任务
|
||||
# 多实例部署会导致每个 Worker 独立运行 Beat,造成定时任务重复执行
|
||||
# 若需横向扩展 Worker,必须将 Beat 拆分为独立服务(celery beat -A worker_app.celery_app)
|
||||
exec celery \
|
||||
-A worker_app.celery_app \
|
||||
worker \
|
||||
--loglevel=info \
|
||||
"-B" \
|
||||
"--concurrency=${CONCURRENCY}"
|
||||
|
||||
@@ -42,6 +42,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
output_height=getattr(model, "output_height", 720) or 720,
|
||||
cover_url=getattr(model, "cover_url", "") or "",
|
||||
custom_title=getattr(model, "custom_title", "") or "",
|
||||
title_config=dict(getattr(model, "title_config", {}) or {}),
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
@@ -86,6 +87,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
output_height=task.output_height,
|
||||
cover_url=task.cover_url or "",
|
||||
custom_title=task.custom_title or "",
|
||||
title_config=dict(task.title_config) if task.title_config else {},
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
@@ -273,6 +275,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.output_height = task.output_height
|
||||
model.cover_url = task.cover_url or ""
|
||||
model.custom_title = task.custom_title or ""
|
||||
model.title_config = dict(task.title_config) if task.title_config else {}
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
@@ -310,3 +313,42 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.completed_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
return len(models)
|
||||
|
||||
def cleanup_stale_pending(self, timeout_minutes: int = 30) -> int:
|
||||
"""清理超时的 pending 任务(未被 Worker 拉取的任务)。
|
||||
|
||||
全局任务队列有 pending 数量上限,长期卡在 pending 的任务会占满队列,
|
||||
导致新用户无法创建任务。将超时的 pending 任务标记为 failed。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 30 分钟
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
error_info = {
|
||||
"error_type": "PendingTimeout",
|
||||
"message": f"任务在 pending 状态停留超过 {timeout_minutes} 分钟,自动清理",
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
count = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.status == GenerationTaskStatus.PENDING.value,
|
||||
GenerationTaskModel.created_at < cutoff,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
GenerationTaskModel.status: GenerationTaskStatus.FAILED.value,
|
||||
GenerationTaskModel.error_message: "pending timeout: auto cleanup",
|
||||
GenerationTaskModel.error_info: error_info,
|
||||
GenerationTaskModel.completed_at: datetime.now(timezone.utc),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
@@ -298,6 +298,7 @@ class GenerationTaskModel(Base):
|
||||
output_height = Column(Integer, nullable=False, default=720)
|
||||
cover_url = Column(String(1000), nullable=False, default="")
|
||||
custom_title = Column(String(500), nullable=False, default="")
|
||||
title_config = Column(JSON, nullable=False, default=dict)
|
||||
bgm_config = Column(JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
|
||||
@@ -69,6 +69,7 @@ class CreateGenerationTaskUseCase:
|
||||
output_height=command.output_height,
|
||||
cover_url=command.cover_url,
|
||||
custom_title=command.custom_title,
|
||||
title_config=command.title_config,
|
||||
)
|
||||
return self.generation_task_repository.create(task)
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ class GenerationTask:
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
title_config: dict = field(default_factory=dict)
|
||||
extra_meta: dict = field(default_factory=dict)
|
||||
logs: str = "[]"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -153,6 +154,7 @@ class GenerationTask:
|
||||
output_height: int = 720,
|
||||
cover_url: str = "",
|
||||
custom_title: str = "",
|
||||
title_config: dict | None = None,
|
||||
extra_meta: dict | None = None,
|
||||
) -> "GenerationTask":
|
||||
if not project_id.strip() and not template_id.strip():
|
||||
@@ -184,6 +186,7 @@ class GenerationTask:
|
||||
output_height=output_height,
|
||||
cover_url=cover_url,
|
||||
custom_title=custom_title,
|
||||
title_config=dict(title_config) if title_config else {},
|
||||
extra_meta=dict(extra_meta) if extra_meta else {},
|
||||
)
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ class MediaKitClient:
|
||||
data = response.json()
|
||||
|
||||
status = data.get("status")
|
||||
if status == "success":
|
||||
if status in ("completed", "success"):
|
||||
result = data.get("result", {})
|
||||
snapshots = result.get("snapshots", [])
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""封面标题文字叠加(Pillow)— API / Worker 共用。
|
||||
|
||||
在封面帧上绘制白色标题文字 + 黑色描边/阴影,支持 CJK 字体和自动换行。
|
||||
从已渲染视频抽帧时通常不需要调用(标题已烧录);
|
||||
从源素材抽帧(API E2 兜底)时调用,保证封面带标题。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 按优先级查找 CJK 字体(Debian/Ubuntu fonts-noto-cjk 安装路径)
|
||||
_FONT_CANDIDATES = (
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
)
|
||||
|
||||
|
||||
def find_title_font(size: int):
|
||||
"""查找可用的 CJK 字体并返回 PIL ImageFont,找不到返回 None。"""
|
||||
try:
|
||||
from PIL import ImageFont
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
for fp in _FONT_CANDIDATES:
|
||||
if Path(fp).exists():
|
||||
try:
|
||||
return ImageFont.truetype(fp, size=size)
|
||||
except Exception:
|
||||
continue
|
||||
logger.warning("未找到 CJK 字体,标题叠加将使用 PIL 默认字体(中文可能显示为方块)")
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def wrap_title_text(text: str, font, max_width: int) -> list[str]:
|
||||
"""按像素宽度对中英文混合文本自动换行,支持显式 \\n。"""
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for ch in text:
|
||||
if ch == "\n":
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = ""
|
||||
continue
|
||||
trial = current + ch
|
||||
try:
|
||||
bbox = font.getbbox(trial)
|
||||
width = bbox[2] - bbox[0]
|
||||
except Exception:
|
||||
width = len(trial) * (font.size // 2)
|
||||
if width <= max_width:
|
||||
current = trial
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = ch
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
def apply_title_to_image(
|
||||
image_path: str,
|
||||
title_text: str,
|
||||
*,
|
||||
position: str = "bottom",
|
||||
font_size: Optional[int] = None,
|
||||
margin_ratio: float = 0.06,
|
||||
stroke_width_ratio: float = 0.04,
|
||||
) -> Optional[str]:
|
||||
"""在图片上绘制标题文字并覆盖保存。
|
||||
|
||||
Args:
|
||||
image_path: 图片路径(处理结果覆盖写回)
|
||||
title_text: 标题文字;为空直接返回 None 表示跳过
|
||||
position: top / center / bottom
|
||||
font_size: 字号,None 时按图片宽度自动计算
|
||||
margin_ratio: 边缘留白占短边比例
|
||||
stroke_width_ratio: 描边宽度占字号比例
|
||||
|
||||
Returns:
|
||||
成功返回 image_path;标题为空或 PIL 不可用返回 None。
|
||||
"""
|
||||
if not title_text or not title_text.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
logger.warning("Pillow 未安装,跳过标题叠加: image=%s", image_path)
|
||||
return None
|
||||
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
img_w, img_h = img.size
|
||||
|
||||
if font_size is None:
|
||||
font_size = max(28, min(72, img_w // 16))
|
||||
|
||||
font = find_title_font(font_size)
|
||||
if font is None:
|
||||
return None
|
||||
|
||||
stroke_width = max(2, int(font_size * stroke_width_ratio))
|
||||
margin = int(min(img_w, img_h) * margin_ratio)
|
||||
max_text_width = img_w - 2 * margin
|
||||
|
||||
lines = wrap_title_text(title_text.strip(), font, max_text_width)
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
line_heights = []
|
||||
for ln in lines:
|
||||
bbox = font.getbbox(ln)
|
||||
line_heights.append(bbox[3] - bbox[1])
|
||||
line_height = max(line_heights) if line_heights else font_size
|
||||
line_gap = int(line_height * 0.3)
|
||||
total_height = len(lines) * line_height + (len(lines) - 1) * line_gap
|
||||
|
||||
if position == "top":
|
||||
y_start = margin
|
||||
elif position == "center":
|
||||
y_start = (img_h - total_height) // 2
|
||||
else:
|
||||
y_start = img_h - total_height - margin
|
||||
|
||||
for i, ln in enumerate(lines):
|
||||
bbox = font.getbbox(ln)
|
||||
line_w = bbox[2] - bbox[0]
|
||||
x = (img_w - line_w) // 2
|
||||
y = y_start + i * (line_height + line_gap)
|
||||
# 阴影
|
||||
draw.text((x + 2, y + 2), ln, font=font, fill=(0, 0, 0))
|
||||
# 白色文字 + 黑色描边
|
||||
draw.text(
|
||||
(x, y),
|
||||
ln,
|
||||
font=font,
|
||||
fill=(255, 255, 255),
|
||||
stroke_width=stroke_width,
|
||||
stroke_fill=(0, 0, 0),
|
||||
)
|
||||
|
||||
img.save(image_path, "JPEG", quality=92)
|
||||
return image_path
|
||||
@@ -29,3 +29,4 @@ httpx==0.27.2
|
||||
|
||||
# Prometheus monitoring
|
||||
prometheus-client==0.21.1
|
||||
Pillow==10.4.0
|
||||
|
||||
@@ -158,7 +158,7 @@ class TestRenderVideoVoiceInjection:
|
||||
|
||||
from packages.domain import EditingMode
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
output_path, render_duration, cover_candidates = _render_video(
|
||||
task_id="test_task_123",
|
||||
downloaded_videos=[Path("/tmp/video1.mp4")],
|
||||
voice_path=None,
|
||||
|
||||
@@ -182,6 +182,7 @@ class TestUnifiedCoverPipelineEndpoint:
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
@@ -194,6 +195,13 @@ class TestUnifiedCoverPipelineEndpoint:
|
||||
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/plan-2/video.mp4"
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
# MediaKit 抽帧也返回 None,模拟最终失败
|
||||
mock_mk = MagicMock()
|
||||
mock_mk.is_available = True
|
||||
mock_mk.extract_frames.return_value = None
|
||||
mock_mk_getter.return_value = mock_mk
|
||||
|
||||
# body 不传 asset_ids,步骤 E2 不会进入
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -207,7 +215,6 @@ class TestUnifiedCoverPipelineEndpoint:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "封面尚未生成" in exc_info.value.detail
|
||||
|
||||
def test_cover_url_found_via_source_edit_plan(self):
|
||||
"""步骤B:通过 source_edit_plan_id 找到预览任务的 cover_url。"""
|
||||
@@ -318,6 +325,157 @@ class TestUnifiedCoverPipelineEndpoint:
|
||||
template_id="template-y",
|
||||
)
|
||||
|
||||
def test_cover_url_found_via_cover_candidates_image_url(self):
|
||||
"""步骤D:plan.config.cover_candidates 有 image_url 时,直接使用第一个候选封面。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
# 步骤A/B/C 都找不到,进入步骤D
|
||||
mock_plan.config = {
|
||||
"rendered_storage_key": "rendered/plan-z/video.mp4", # 必须有预览视频才能通过前置检查
|
||||
"cover_candidates": [
|
||||
{"image_url": "https://oss.example.com/candidates/cover-1.jpg", "score": 0.95},
|
||||
{"image_url": "https://oss.example.com/candidates/cover-2.jpg", "score": 0.80},
|
||||
],
|
||||
}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/candidates/cover-1.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-z",
|
||||
plan_id="plan-z",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
# 步骤D从 cover_candidates 第一个元素的 image_url 提取封面
|
||||
assert result.cover["image_url"] == "https://oss.example.com/candidates/cover-1.jpg"
|
||||
# 验证 plan.config 被更新(至少调用一次:rendered_storage_key + cover)
|
||||
assert mock_plan_svc.update_plan_config.call_count >= 1
|
||||
|
||||
def test_cover_url_found_via_cover_candidates_url_key(self):
|
||||
"""步骤D:cover_candidates 用 url 键(非 image_url)时,也能正确提取。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {
|
||||
"rendered_storage_key": "rendered/plan-w/video.mp4", # 必须有预览视频才能通过前置检查
|
||||
"cover_candidates": [
|
||||
{"url": "https://oss.example.com/candidates/alt-cover.jpg"},
|
||||
],
|
||||
}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/candidates/alt-cover.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="template-w",
|
||||
plan_id="plan-w",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
# 步骤D fallback 到 url 键
|
||||
assert result.cover["image_url"] == "https://oss.example.com/candidates/alt-cover.jpg"
|
||||
|
||||
def test_cover_candidates_skips_non_dict_first_element(self):
|
||||
"""步骤D:cover_candidates 第一个元素不是 dict 时,安全跳过不崩溃。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {
|
||||
"rendered_storage_key": "rendered/plan-skip/video.mp4", # 必须有预览视频才能通过前置检查
|
||||
"cover_candidates": ["not-a-dict", 42, None],
|
||||
}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame")
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
# storage fallback 也找不到封面
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_url.return_value = ""
|
||||
mock_storage_getter.return_value = mock_storage_svc
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
# 所有步骤都失败,应返回 400
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="template-skip",
|
||||
plan_id="plan-skip",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
class TestSourceEditPlanFallback:
|
||||
"""测试步骤 2.5:通过 source_edit_plan_id 查找预览视频兜底逻辑。"""
|
||||
@@ -687,3 +845,205 @@ class TestUploadCoverType:
|
||||
assert result.cover["image_url"] == "https://oss.example.com/uploaded/my-cover.png"
|
||||
# 验证没有调用任何预览视频查找逻辑
|
||||
# (normalize_plan_config 是唯一被调用的外部函数)
|
||||
|
||||
def test_cover_extracted_from_source_asset_when_no_preview(self):
|
||||
"""步骤E2:无后端渲染产物时,直接从用户选择的视频素材抽帧。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {} # 无 rendered_storage_key,无 generation_task_id
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
|
||||
# 模拟视频素材
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.file_type = "video"
|
||||
mock_asset.storage_key = "uploads/source-clip.mp4"
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
mock_mk = MagicMock()
|
||||
mock_mk.is_available = True
|
||||
mock_mk.extract_frames.return_value = [{"image_url": "https://mediakit.internal/frame-abc.jpg"}]
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_url.return_value = "https://oss.example.com/uploads/source-clip.mp4"
|
||||
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="ai_frame",
|
||||
asset_ids=["asset-video-1"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
|
||||
return_value=mock_asset_repo,
|
||||
),
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
|
||||
patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage),
|
||||
patch(
|
||||
"app.api.routes.generation_cover._persist_cover_frame",
|
||||
return_value="https://oss.example.com/covers/final.jpg",
|
||||
),
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="tpl-source",
|
||||
plan_id="plan-source",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
assert result.cover["image_url"] == "https://oss.example.com/covers/final.jpg"
|
||||
mock_mk.extract_frames.assert_called_once()
|
||||
# 确保用的是源素材 URL
|
||||
call_kwargs = mock_mk.extract_frames.call_args.kwargs
|
||||
assert "source-clip.mp4" in call_kwargs["video_url"]
|
||||
|
||||
def test_e2_passes_plan_title_to_persist_for_overlay(self):
|
||||
"""步骤E2:plan.config.title.text 存在时,作为 title_text 传给 _persist_cover_frame 叠加标题。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {"title": {"enabled": True, "text": "我的视频标题"}}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.file_type = "video"
|
||||
mock_asset.storage_key = "uploads/src.mp4"
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
mock_mk = MagicMock()
|
||||
mock_mk.is_available = True
|
||||
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}]
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_url.return_value = "https://oss.example.com/uploads/src.mp4"
|
||||
|
||||
body = GenerateCoverRequest(cover_type="ai_frame", asset_ids=["a1"])
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
|
||||
return_value=mock_asset_repo,
|
||||
),
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
|
||||
patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage),
|
||||
patch(
|
||||
"app.api.routes.generation_cover._persist_cover_frame",
|
||||
return_value="https://oss.example.com/covers/final.jpg",
|
||||
) as mock_persist,
|
||||
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_normalize.return_value = {
|
||||
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final.jpg"}
|
||||
}
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
result = generate_cover(
|
||||
body=body,
|
||||
template_id="tpl",
|
||||
plan_id="plan-title",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
assert result.cover["image_url"] == "https://oss.example.com/covers/final.jpg"
|
||||
# 标题文字必须透传给持久化函数(用于源素材帧叠加标题)
|
||||
assert mock_persist.call_args.kwargs.get("title_text") == "我的视频标题"
|
||||
|
||||
def test_step_e_skips_non_video_assets(self):
|
||||
"""步骤E2:asset_ids 里只有图片素材时,不调用 MediaKit 并返回 400。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.api.routes.generation_cover import GenerateCoverRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.config = {}
|
||||
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
|
||||
mock_template_svc = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
|
||||
mock_image_asset = MagicMock()
|
||||
mock_image_asset.file_type = "image"
|
||||
mock_image_asset.storage_key = "uploads/photo.png"
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = mock_image_asset
|
||||
|
||||
mock_mk = MagicMock()
|
||||
mock_mk.is_available = True
|
||||
|
||||
body = GenerateCoverRequest(
|
||||
cover_type="ai_frame",
|
||||
asset_ids=["asset-img-1"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
|
||||
return_value=mock_asset_repo,
|
||||
),
|
||||
patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
|
||||
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo.list_by_source_edit_plan.return_value = []
|
||||
mock_repo.list_latest_completed_preview.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_storage_getter.return_value = MagicMock()
|
||||
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
generate_cover(
|
||||
body=body,
|
||||
template_id="tpl-img",
|
||||
plan_id="plan-img",
|
||||
services=(mock_template_svc, mock_plan_svc),
|
||||
db=mock_db,
|
||||
current_user=MagicMock(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_mk.extract_frames.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""GenerationTaskRepository - cleanup_stale_pending 超时 pending 清理单元测试。"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.domain import GenerationTask, GenerationTaskStatus
|
||||
|
||||
|
||||
def _repository():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
return SQLAlchemyGenerationTaskRepository(session), session, engine
|
||||
|
||||
|
||||
def _make_task(**kwargs) -> GenerationTask:
|
||||
defaults = dict(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return GenerationTask.create(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cleanup_stale_pending 基本测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cleanup_stale_pending_no_tasks_returns_zero():
|
||||
"""没有任务时返回 0。"""
|
||||
repo, _, _ = _repository()
|
||||
count = repo.cleanup_stale_pending(timeout_minutes=30)
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_cleanup_stale_pending_recent_pending_not_cleaned():
|
||||
"""30 分钟内的 pending 任务不被清理。"""
|
||||
repo, _, _ = _repository()
|
||||
task = _make_task()
|
||||
repo.create(task)
|
||||
# 刚创建的 pending 任务不应被清理
|
||||
count = repo.cleanup_stale_pending(timeout_minutes=30)
|
||||
assert count == 0
|
||||
assert repo.get(task.id).status == GenerationTaskStatus.PENDING
|
||||
|
||||
|
||||
def test_cleanup_stale_pending_old_pending_marked_failed():
|
||||
"""超过 30 分钟的 pending 任务被标记为 failed。"""
|
||||
repo, _, engine = _repository()
|
||||
task = _make_task()
|
||||
repo.create(task)
|
||||
|
||||
# 手动把 created_at 改到 1 小时前
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text("UPDATE generation_tasks SET created_at = :ts WHERE id = :id"),
|
||||
{"ts": datetime.now(timezone.utc) - timedelta(hours=1), "id": task.id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
count = repo.cleanup_stale_pending(timeout_minutes=30)
|
||||
assert count == 1
|
||||
|
||||
saved = repo.get(task.id)
|
||||
assert saved.status == GenerationTaskStatus.FAILED
|
||||
assert saved.error_message == "pending timeout: auto cleanup"
|
||||
assert saved.error_info.get("error_type") == "PendingTimeout"
|
||||
assert "30" in saved.error_info["message"]
|
||||
assert "failed_at" in saved.error_info
|
||||
assert saved.completed_at is not None
|
||||
|
||||
|
||||
def test_cleanup_stale_pending_running_not_touched():
|
||||
"""running 任务不受影响,只清理 pending。"""
|
||||
repo, _, engine = _repository()
|
||||
task = _make_task()
|
||||
repo.create(task)
|
||||
task.mark_processing()
|
||||
repo.update(task)
|
||||
|
||||
# 回写 created_at 到 1 小时前
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text("UPDATE generation_tasks SET created_at = :ts WHERE id = :id"),
|
||||
{"ts": datetime.now(timezone.utc) - timedelta(hours=1), "id": task.id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
count = repo.cleanup_stale_pending(timeout_minutes=30)
|
||||
assert count == 0
|
||||
assert repo.get(task.id).status == GenerationTaskStatus.RUNNING
|
||||
|
||||
|
||||
def test_cleanup_stale_pending_custom_timeout():
|
||||
"""自定义超时时间生效。"""
|
||||
repo, _, engine = _repository()
|
||||
task = _make_task()
|
||||
repo.create(task)
|
||||
|
||||
# 回写 created_at 到 20 分钟前
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text("UPDATE generation_tasks SET created_at = :ts WHERE id = :id"),
|
||||
{"ts": datetime.now(timezone.utc) - timedelta(minutes=20), "id": task.id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 30 分钟超时:不清理
|
||||
count_30 = repo.cleanup_stale_pending(timeout_minutes=30)
|
||||
assert count_30 == 0
|
||||
# 15 分钟超时:清理
|
||||
count_15 = repo.cleanup_stale_pending(timeout_minutes=15)
|
||||
assert count_15 == 1
|
||||
assert repo.get(task.id).status == GenerationTaskStatus.FAILED
|
||||
|
||||
|
||||
def test_cleanup_stale_pending_multiple():
|
||||
"""批量清理多个超时的 pending 任务。"""
|
||||
repo, _, engine = _repository()
|
||||
|
||||
tasks = []
|
||||
for i in range(5):
|
||||
t = _make_task(project_id=f"proj-{i}")
|
||||
repo.create(t)
|
||||
tasks.append(t)
|
||||
|
||||
# 全部回写 created_at 到 2 小时前
|
||||
with engine.connect() as conn:
|
||||
for t in tasks:
|
||||
conn.execute(
|
||||
text("UPDATE generation_tasks SET created_at = :ts WHERE id = :id"),
|
||||
{"ts": datetime.now(timezone.utc) - timedelta(hours=2), "id": t.id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
count = repo.cleanup_stale_pending(timeout_minutes=30)
|
||||
assert count == 5
|
||||
for t in tasks:
|
||||
assert repo.get(t.id).status == GenerationTaskStatus.FAILED
|
||||
@@ -13,6 +13,18 @@ os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
|
||||
def _patch_session_local(mock_session):
|
||||
"""Patch worker_app.db.SessionLocal robustly even when other tests
|
||||
have pre-registered a MagicMock for worker_app.db in sys.modules.
|
||||
Uses patch.dict to inject a clean module so that
|
||||
'from worker_app.db import SessionLocal' resolves correctly."""
|
||||
from types import ModuleType
|
||||
|
||||
_fresh_db = ModuleType("worker_app.db")
|
||||
_fresh_db.SessionLocal = lambda *a, **kw: mock_session
|
||||
return patch.dict(sys.modules, {"worker_app.db": _fresh_db})
|
||||
|
||||
|
||||
class TestLoadTemplateSegmentDurations:
|
||||
"""_load_template_segment_durations 单元测试 (covers lines 198-226)."""
|
||||
|
||||
@@ -40,8 +52,7 @@ class TestLoadTemplateSegmentDurations:
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
|
||||
# Patch at the source module since it's imported inside the function
|
||||
with patch("worker_app.db.SessionLocal", return_value=mock_session):
|
||||
with _patch_session_local(mock_session):
|
||||
result = _load_template_segment_durations("tpl_123")
|
||||
|
||||
assert result == [5.0, 8.0, 3.0]
|
||||
@@ -63,16 +74,24 @@ class TestLoadTemplateSegmentDurations:
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
|
||||
with patch("worker_app.db.SessionLocal", return_value=mock_session):
|
||||
with _patch_session_local(mock_session):
|
||||
result = _load_template_segment_durations("tpl_456")
|
||||
|
||||
assert result == [5.0]
|
||||
|
||||
def test_db_error_returns_empty(self):
|
||||
"""数据库异常返回空列表,不抛出。"""
|
||||
from types import ModuleType
|
||||
|
||||
from worker_app.tasks.generation import _load_template_segment_durations
|
||||
|
||||
with patch("worker_app.db.SessionLocal", side_effect=Exception("DB down")):
|
||||
_err_db = ModuleType("worker_app.db")
|
||||
|
||||
def _raise(*a, **kw):
|
||||
raise Exception("DB down")
|
||||
|
||||
_err_db.SessionLocal = _raise
|
||||
with patch.dict(sys.modules, {"worker_app.db": _err_db}):
|
||||
result = _load_template_segment_durations("tpl_789")
|
||||
|
||||
assert result == []
|
||||
@@ -86,7 +105,7 @@ class TestLoadTemplateSegmentDurations:
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
|
||||
with patch("worker_app.db.SessionLocal", return_value=mock_session):
|
||||
with _patch_session_local(mock_session):
|
||||
result = _load_template_segment_durations("tpl_empty")
|
||||
|
||||
assert result == []
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Unit tests for apps/api/app/api/routes/health.py
|
||||
|
||||
覆盖 _check_database() 和 _check_migrations() 中 psycopg3 连接逻辑。
|
||||
确保增量覆盖率 ≥ 60%(目标覆盖 lines 52, 127)。
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -9,63 +8,70 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_cursor(fetchone_result=None):
|
||||
"""Create a mock cursor with context manager support."""
|
||||
cur = MagicMock()
|
||||
cur.__enter__ = MagicMock(return_value=cur)
|
||||
cur.__exit__ = MagicMock(return_value=False)
|
||||
if fetchone_result is not None:
|
||||
cur.fetchone.return_value = fetchone_result
|
||||
return cur
|
||||
|
||||
|
||||
def _make_conn(cursor_result=None):
|
||||
conn = MagicMock()
|
||||
conn.cursor.return_value = cursor_result or _make_cursor()
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCheckDatabase:
|
||||
"""Tests for _check_database() health check function."""
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_database_success(self, mock_connect, mock_settings):
|
||||
"""PostgreSQL 连接成功时返回 healthy。"""
|
||||
async def test_check_database_success(self):
|
||||
mock_cur = _make_cursor(fetchone_result=(1,))
|
||||
mock_conn = _make_conn(mock_cur)
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
|
||||
# Mock connection and cursor
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchone.return_value = (1,)
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_connect.return_value = mock_conn
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
from apps.api.app.api.routes.health import _check_database
|
||||
|
||||
result = await _check_database()
|
||||
with patch.object(health, "psycopg") as mock_psycopg, patch.object(health, "settings", mock_settings):
|
||||
mock_psycopg.connect.return_value = mock_conn
|
||||
result = await health._check_database()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["type"] == "postgresql"
|
||||
assert result["message"] == "Database connection successful"
|
||||
mock_connect.assert_called_once_with(
|
||||
mock_psycopg.connect.assert_called_once_with(
|
||||
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
|
||||
)
|
||||
mock_cursor.execute.assert_called_once_with("SELECT 1")
|
||||
mock_cur.execute.assert_called_once_with("SELECT 1")
|
||||
mock_conn.close.assert_called_once()
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_database_connection_failure(self, mock_connect, mock_settings):
|
||||
"""PostgreSQL 连接失败时返回 unhealthy。"""
|
||||
async def test_check_database_connection_failure(self):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
mock_connect.side_effect = Exception("connection refused")
|
||||
|
||||
from apps.api.app.api.routes.health import _check_database
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
result = await _check_database()
|
||||
with patch.object(health, "psycopg") as mock_psycopg, patch.object(health, "settings", mock_settings):
|
||||
mock_psycopg.connect.side_effect = Exception("connection refused")
|
||||
result = await health._check_database()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert result["type"] == "postgresql"
|
||||
assert "connection refused" in result["message"]
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
async def test_check_database_in_memory(self, mock_settings):
|
||||
"""使用内存数据库时跳过 PostgreSQL 检查。"""
|
||||
async def test_check_database_in_memory(self):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.USE_IN_MEMORY_DB = True
|
||||
|
||||
from apps.api.app.api.routes.health import _check_database
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
result = await _check_database()
|
||||
with patch.object(health, "settings", mock_settings):
|
||||
result = await health._check_database()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["type"] == "in_memory"
|
||||
@@ -73,80 +79,66 @@ class TestCheckDatabase:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCheckMigrations:
|
||||
"""Tests for _check_migrations() health check function."""
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_migrations_success(self, mock_connect, mock_settings):
|
||||
"""所有迁移表存在时返回 healthy。"""
|
||||
async def test_check_migrations_success(self):
|
||||
mock_cur = _make_cursor(fetchone_result=(5,))
|
||||
mock_conn = _make_conn(mock_cur)
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchone.return_value = (5,) # 5 tables found
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_connect.return_value = mock_conn
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
from apps.api.app.api.routes.health import _check_migrations
|
||||
|
||||
result = await _check_migrations()
|
||||
with patch.object(health, "psycopg") as mock_psycopg, patch.object(health, "settings", mock_settings):
|
||||
mock_psycopg.connect.return_value = mock_conn
|
||||
result = await health._check_migrations()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["message"] == "Database migrations applied"
|
||||
mock_connect.assert_called_once_with(
|
||||
mock_psycopg.connect.assert_called_once_with(
|
||||
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
|
||||
)
|
||||
mock_conn.close.assert_called_once()
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_migrations_missing_tables(self, mock_connect, mock_settings):
|
||||
"""迁移表不完整时返回 unhealthy。"""
|
||||
async def test_check_migrations_missing_tables(self):
|
||||
mock_cur = _make_cursor(fetchone_result=(2,))
|
||||
mock_conn = _make_conn(mock_cur)
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchone.return_value = (2,) # Only 2 of 5 tables
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_connect.return_value = mock_conn
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
from apps.api.app.api.routes.health import _check_migrations
|
||||
|
||||
result = await _check_migrations()
|
||||
with patch.object(health, "psycopg") as mock_psycopg, patch.object(health, "settings", mock_settings):
|
||||
mock_psycopg.connect.return_value = mock_conn
|
||||
result = await health._check_migrations()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert "Missing tables" in result["message"]
|
||||
assert "2/5" in result["message"]
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_migrations_connection_failure(self, mock_connect, mock_settings):
|
||||
"""数据库连接失败时返回 unhealthy。"""
|
||||
async def test_check_migrations_connection_failure(self):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
mock_connect.side_effect = Exception("connection refused")
|
||||
|
||||
from apps.api.app.api.routes.health import _check_migrations
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
result = await _check_migrations()
|
||||
with patch.object(health, "psycopg") as mock_psycopg, patch.object(health, "settings", mock_settings):
|
||||
mock_psycopg.connect.side_effect = Exception("connection refused")
|
||||
result = await health._check_migrations()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert "Migration check failed" in result["message"]
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
async def test_check_migrations_in_memory(self, mock_settings):
|
||||
"""使用内存数据库时跳过迁移检查。"""
|
||||
async def test_check_migrations_in_memory(self):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.USE_IN_MEMORY_DB = True
|
||||
|
||||
from apps.api.app.api.routes.health import _check_migrations
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
result = await _check_migrations()
|
||||
with patch.object(health, "settings", mock_settings):
|
||||
result = await health._check_migrations()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert "no migrations needed" in result["message"]
|
||||
@@ -154,33 +146,27 @@ class TestCheckMigrations:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestStartupCheck:
|
||||
"""Tests for startup_check() endpoint."""
|
||||
|
||||
@patch("apps.api.app.api.routes.health._check_migrations")
|
||||
@patch("apps.api.app.api.routes.health._check_database")
|
||||
async def test_startup_all_healthy(self, mock_db, mock_mig):
|
||||
"""所有检查通过时返回 started。"""
|
||||
mock_db.return_value = {"status": "healthy"}
|
||||
mock_mig.return_value = {"status": "healthy"}
|
||||
async def test_startup_all_healthy(self):
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
from apps.api.app.api.routes.health import startup_check
|
||||
|
||||
result = await startup_check()
|
||||
with patch.object(health, "_check_migrations", new_callable=AsyncMock) as mock_mig, patch.object(health, "_check_database", new_callable=AsyncMock) as mock_db:
|
||||
mock_db.return_value = {"status": "healthy"}
|
||||
mock_mig.return_value = {"status": "healthy"}
|
||||
result = await health.startup_check()
|
||||
|
||||
assert result["status"] == "started"
|
||||
|
||||
@patch("apps.api.app.api.routes.health._check_migrations")
|
||||
@patch("apps.api.app.api.routes.health._check_database")
|
||||
async def test_startup_db_unhealthy(self, mock_db, mock_mig):
|
||||
"""数据库不健康时返回 starting + 503。"""
|
||||
mock_db.return_value = {"status": "unhealthy", "message": "fail"}
|
||||
mock_mig.return_value = {"status": "healthy"}
|
||||
async def test_startup_db_unhealthy(self):
|
||||
import json
|
||||
|
||||
from apps.api.app.api.routes.health import startup_check
|
||||
from apps.api.app.api.routes import health
|
||||
|
||||
result = await startup_check()
|
||||
with patch.object(health, "_check_migrations", new_callable=AsyncMock) as mock_mig, patch.object(health, "_check_database", new_callable=AsyncMock) as mock_db:
|
||||
mock_db.return_value = {"status": "unhealthy", "message": "fail"}
|
||||
mock_mig.return_value = {"status": "healthy"}
|
||||
result = await health.startup_check()
|
||||
|
||||
assert result.status_code == 503
|
||||
import json
|
||||
body = json.loads(result.body)
|
||||
assert body["status"] == "starting"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""packages.shared.title_overlay 单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.shared.title_overlay import apply_title_to_image, wrap_title_text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image():
|
||||
"""生成一张 640x360 的纯黑测试图片。"""
|
||||
from PIL import Image
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
img = Image.new("RGB", (640, 360), color=(0, 0, 0))
|
||||
img.save(tmp.name, "JPEG")
|
||||
yield tmp.name
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_apply_title_to_image_empty_text_returns_none(sample_image):
|
||||
assert apply_title_to_image(sample_image, "") is None
|
||||
assert apply_title_to_image(sample_image, " ") is None
|
||||
|
||||
|
||||
def test_apply_title_to_image_draws_title(sample_image):
|
||||
result = apply_title_to_image(sample_image, "测试标题")
|
||||
assert result == sample_image
|
||||
assert Path(sample_image).exists()
|
||||
assert Path(sample_image).stat().st_size > 0
|
||||
|
||||
|
||||
def test_apply_title_to_image_respects_position(sample_image):
|
||||
for pos in ("top", "center", "bottom"):
|
||||
result = apply_title_to_image(sample_image, "位置测试", position=pos)
|
||||
assert result == sample_image
|
||||
|
||||
|
||||
def test_wrap_title_text_supports_long_text():
|
||||
from PIL import ImageFont
|
||||
|
||||
font = ImageFont.load_default()
|
||||
lines = wrap_title_text("这是一个比较长的标题需要自动换行处理ABC", font, max_width=40)
|
||||
assert isinstance(lines, list)
|
||||
assert len(lines) >= 1
|
||||
|
||||
|
||||
def test_wrap_title_text_respects_explicit_newline():
|
||||
from PIL import ImageFont
|
||||
|
||||
font = ImageFont.load_default()
|
||||
lines = wrap_title_text("第一行\n第二行", font, max_width=10000)
|
||||
assert lines == ["第一行", "第二行"]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for _writeback_edit_plan_config in generation_tasks route.
|
||||
|
||||
覆盖 CI 增量覆盖率不足的代码:
|
||||
- generation_tasks.py 行 160-193 (_writeback_edit_plan_config 函数体)
|
||||
- generation_tasks.py 行 371-372 (路由中调用该函数)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.generation_tasks import _writeback_edit_plan_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Mock SQLAlchemy Session."""
|
||||
db = MagicMock()
|
||||
db.query.return_value = db
|
||||
db.filter.return_value = db
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plan():
|
||||
"""Mock EditPlanModel instance."""
|
||||
plan = MagicMock()
|
||||
plan.config = {"existing_key": "existing_value"}
|
||||
return plan
|
||||
|
||||
|
||||
class TestWritebackEditPlanConfig:
|
||||
"""_writeback_edit_plan_config 全分支覆盖"""
|
||||
|
||||
# ---- 行 160-161: plan_id 为空直接返回 ----
|
||||
def test_empty_plan_id_returns_immediately(self, mock_db):
|
||||
_writeback_edit_plan_config(plan_id="", task_id="task_1", title_config={"text": "hi"}, db=mock_db)
|
||||
mock_db.query.assert_not_called()
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
def test_none_plan_id_returns_immediately(self, mock_db):
|
||||
_writeback_edit_plan_config(plan_id=None, task_id="task_1", title_config=None, db=mock_db)
|
||||
mock_db.query.assert_not_called()
|
||||
|
||||
# ---- 行 165-168: plan 不存在 → warning + 不 commit ----
|
||||
def test_plan_not_found_no_commit(self, mock_db):
|
||||
mock_db.first.return_value = None
|
||||
|
||||
_writeback_edit_plan_config(plan_id="plan_999", task_id="task_1", title_config=None, db=mock_db)
|
||||
|
||||
mock_db.query.assert_called_once()
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
# ---- 行 170-182: 正常写入 + title_config ----
|
||||
def test_success_with_title_config(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
|
||||
_writeback_edit_plan_config(
|
||||
plan_id="plan_123",
|
||||
task_id="task_456",
|
||||
title_config={"text": "标题", "font_size": 36},
|
||||
db=mock_db,
|
||||
)
|
||||
|
||||
assert mock_plan.config["generation_task_id"] == "task_456"
|
||||
assert mock_plan.config["title_config"] == {"text": "标题", "font_size": 36}
|
||||
assert mock_plan.config["existing_key"] == "existing_value"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# ---- 行 170-175: 正常写入、无 title_config ----
|
||||
def test_success_without_title_config(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_789", title_config=None, db=mock_db)
|
||||
|
||||
assert mock_plan.config["generation_task_id"] == "task_789"
|
||||
assert "title_config" not in mock_plan.config
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# ---- 行 170: config 不是 dict → 兜底空 dict ----
|
||||
def test_config_not_dict_uses_empty_dict(self, mock_db):
|
||||
bad_plan = MagicMock()
|
||||
bad_plan.config = "not_a_dict"
|
||||
mock_db.first.return_value = bad_plan
|
||||
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
|
||||
|
||||
assert isinstance(bad_plan.config, dict)
|
||||
assert bad_plan.config["generation_task_id"] == "task_1"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# ---- 行 183-189: DB 异常 → warning + rollback ----
|
||||
def test_db_exception_triggers_rollback(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
mock_db.commit.side_effect = RuntimeError("DB connection lost")
|
||||
|
||||
# 不应抛异常
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
|
||||
|
||||
mock_db.rollback.assert_called_once()
|
||||
|
||||
# ---- 行 190-193: rollback 也失败 → 静默 ----
|
||||
def test_rollback_failure_silent(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
mock_db.commit.side_effect = RuntimeError("commit failed")
|
||||
mock_db.rollback.side_effect = RuntimeError("rollback also failed")
|
||||
|
||||
# 两个异常都不应抛出
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
|
||||
mock_db.rollback.assert_called_once()
|
||||
|
||||
# ---- 行 173: title_config 为空 dict → 不写入 title_config ----
|
||||
def test_empty_title_config_not_written(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config={}, db=mock_db)
|
||||
|
||||
# 空 dict 为 falsy,不写入
|
||||
assert "title_config" not in mock_plan.config
|
||||
assert mock_plan.config["generation_task_id"] == "task_1"
|
||||
Reference in New Issue
Block a user