Compare commits

..

1 Commits

Author SHA1 Message Date
saas-backend c0c29bb0c2 feat(digital-human): 对口型生成弹窗增加计时器
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m56s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m31s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m36s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m32s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 9m41s
- 对口型生成弹窗loading状态显示已用时(MM:SS格式,每秒更新)
- 使用useState+useEffect+setInterval实现计时,组件卸载时clearInterval防内存泄漏
- 取消生成或生成完成/失败时停止并重置计时器
- 样式与现有灰色提示文字风格一致
2026-09-24 13:44:03 +08:00
182 changed files with 3211 additions and 27975 deletions
+2 -21
View File
@@ -79,33 +79,14 @@ CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/1
# ==================== Worker 配置(#2073 队列分流) ====================
#
# 容器内跑三个独立进程:beat(只发定时任务)+ generation worker(实时高优)
# + transcode worker(后台批量/清理)。三个进程的并发与开关独立配置。
# ==================== Worker 配置 ====================
# Worker 进程名称
WORKER_NAME=xiaoxia-saas-worker
# 总并发参考(兼容旧变量):
# - 若 GENERATION_CONCURRENCY 与 TRANSCODE_CONCURRENCY 都未显式设置,
# entrypoint 会按此总数对半分配(gen=ceil(total/2), trans=剩余,各至少 1);
# - 任一个 *_CONCURRENCY 显式设置后,按显式值生效,忽略此变量对应部分。
# Worker 并发数(同时执行的任务数)
WORKER_CONCURRENCY=4
# Generation worker 并发数(用户实时任务:视频生成/TTS/音色克隆/lipsync/数字人)
# 实时链路对延迟敏感,建议 2C 以上机器设为 2;高负载场景可加到 4。
GENERATION_CONCURRENCY=2
# Transcode worker 并发数(后台批量:素材入库转码/AI 分类打标/质量评分/查重/批量下载)
# 后台任务可排队,独立伸缩;素材入库量大时可加到 4。
TRANSCODE_CONCURRENCY=2
# 是否在本容器启动 celery beat 进程(默认 1)。
# 默认 beat 与 worker 同容器部署;若要独立 beat 容器部署,worker 容器设为 0、
# beat 容器单独跑 `celery -A worker_app.celery_app beat` 并设 BEAT_ENABLED=1。
BEAT_ENABLED=1
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
WORKER_MAX_TASKS_PER_CHILD=1000
+7 -17
View File
@@ -813,7 +813,7 @@ jobs:
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
EXTRA_BUILD_ARGS="APP_VERSION=${GITHUB_SHA}"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
# Worker 与 API/Web 统一走持久 builder(ci-builder-persist),共享宿主机层缓存
NO_CACHE_FLAG=""
@@ -1014,7 +1014,7 @@ jobs:
PUSHED_TAGS_SUMMARY="${BRANCH_TAG}"
fi
EXTRA_BUILD_ARGS="APP_VERSION=${GITHUB_SHA}"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
NO_CACHE_FLAG=""
for i in 1 2 3; do
@@ -1238,11 +1238,9 @@ jobs:
ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}"
run: |
set -eux
# Staging 业务机 = 116.62.226.203(公网 sshd 端口 22)。
# 47.98.113.167 现为生产机(sshd 端口 22222),不承载 staging 容器。
# CI job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败,
# 故默认目标必须是 staging 业务机;仍可通过 secrets 覆盖。
staging_host="${STAGING_SSH_HOST:-116.62.226.203}"
# CI runner (act_runner) 部署在 116 staging 本机(116.62.226.203 公网 22 未开放),
# 默认走 127.0.0.1:22 本机 SSH,避免跨机网络依赖;可通过 secrets 覆盖。
staging_host="${STAGING_SSH_HOST:-127.0.0.1}"
staging_user="${STAGING_SSH_USER:-root}"
staging_port="${STAGING_SSH_PORT:-22}"
echo "Host: $staging_host"
@@ -1302,16 +1300,8 @@ jobs:
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt"
echo "✅ Douyin cookies uploaded"
# 上传 infra/docker 配置到服务器(compose 单一事实来源)
echo "Uploading infra/docker configs to staging server..."
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \
"mkdir -p /var/lib/xiaoxia-saas-staging/infra/docker"
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no infra/docker/compose.yml \
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/infra/docker/compose.yml"
echo "✅ infra/docker/compose.yml uploaded"
# 通过环境变量传递凭证,避免命令行引号转义问题
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} COMPOSE_SYNC=0 sh"
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
# 清理 CI runner 上的渲染文件
rm -f .env.rendered
@@ -1562,7 +1552,7 @@ jobs:
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
EXTRA_BUILD_ARGS="APP_VERSION=${TAG_NAME}"
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
NO_CACHE_FLAG=""
@@ -1,33 +0,0 @@
"""asset_atom_clips 新增 caption/embedding 字段(#2035 语义标签增强)
Revision ID: 085_atom_clip_caption_embedding
Revises: 084_lipsync_jobs_style
Create Date: 2026-09-25
"""
import sqlalchemy as sa
from alembic import op
revision = "085_atom_clip_caption_embedding"
down_revision = "084_lipsync_jobs_style"
branch_labels = None
depends_on = None
def upgrade() -> None:
# caption: 中文画面描述(10-30字)
op.add_column(
"asset_atom_clips",
sa.Column("caption", sa.Text(), nullable=True),
)
# embedding: caption 对应的向量(豆包 embedding 接口返回,JSON 存 float 数组)
op.add_column(
"asset_atom_clips",
sa.Column("embedding", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("asset_atom_clips", "embedding")
op.drop_column("asset_atom_clips", "caption")
@@ -1,100 +0,0 @@
"""add viral video tables
Revision ID: 086_add_viral_video_tables
Revises: 085_atom_clip_caption_embedding
Create Date: 2026-09-28
新增爆款视频相关表:
- viral_video_jobs: 爆款视频任务
- viral_video_style_templates: 风格模板配置
- viral_video_prompt_templates: Prompt 模板(由 #2040 seed)
"""
import sqlalchemy as sa
from alembic import op
revision = "086_add_viral_video_tables"
down_revision = "085_atom_clip_caption_embedding"
branch_labels = None
depends_on = None
def upgrade() -> None:
# viral_video_jobs
op.create_table(
"viral_video_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("images", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("industry", sa.String(100), nullable=False, server_default=""),
sa.Column("target_customer", sa.String(500), nullable=False, server_default=""),
sa.Column("persona_id", sa.String(36), nullable=False, server_default=""),
sa.Column("viral_structure", sa.String(50), nullable=False, server_default=""),
sa.Column("marketing_purpose", sa.String(100), nullable=False, server_default=""),
sa.Column("bgm_preference", sa.String(50), nullable=False, server_default=""),
sa.Column("duration", sa.Integer(), nullable=False, server_default="30"),
sa.Column("user_copy_text", sa.Text(), nullable=False, server_default=""),
sa.Column("fusion_level", sa.String(20), nullable=False, server_default="ai_polish"),
sa.Column("reference_audio_path", sa.String(1000), nullable=False, server_default=""),
# v1.3 新增
sa.Column("reference_video_url", sa.String(1000), nullable=False, server_default=""),
sa.Column("style_strength", sa.String(20), nullable=False, server_default="medium"),
sa.Column("style_guide", sa.JSON(), nullable=True),
sa.Column("style_template_id", sa.String(36), nullable=False, server_default="", index=True),
# 状态与结果
sa.Column("status", sa.String(30), nullable=False, server_default="pending", index=True),
sa.Column("intent_result", sa.JSON(), nullable=True),
sa.Column("result_video_url", sa.String(1000), nullable=False, server_default=""),
sa.Column("credits_cost", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_msg", sa.Text(), nullable=False, server_default=""),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# viral_video_style_templates
op.create_table(
"viral_video_style_templates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column("thumbnail_url", sa.String(1000), nullable=False, server_default=""),
sa.Column("style_config", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# viral_video_prompt_templates
op.create_table(
"viral_video_prompt_templates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("prompt_type", sa.String(50), nullable=False, index=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
sa.Column("variables", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# Seed 默认风格模板
op.execute("""
INSERT INTO viral_video_style_templates (id, name, description, style_config, is_system, sort_order)
VALUES
('style-tpl-001', '快节奏冲击', '高频切镜+动感BGM,适合食品饮料等快消品', '{"cut_speed": "fast", "transition": "jump_cut", "energy": "high"}', true, 1),
('style-tpl-002', '质感慢镜', '慢节奏+电影感调色,适合美妆护肤珠宝', '{"cut_speed": "slow", "transition": "dissolve", "energy": "low", "color_grade": "cinematic"}', true, 2),
('style-tpl-003', '口播种草', '数字人口播+产品特写穿插', '{"cut_speed": "medium", "transition": "cross_dissolve", "has_talking_head": true}', true, 3),
('style-tpl-004', '场景叙事', '多场景切换+故事线叙述', '{"cut_speed": "medium", "transition": "wipe", "narrative": true}', true, 4)
""")
def downgrade() -> None:
op.drop_table("viral_video_prompt_templates")
op.drop_table("viral_video_style_templates")
op.drop_table("viral_video_jobs")
@@ -1,25 +0,0 @@
"""viral video add image_analysis column
Revision ID: 087_viral_video_image_analysis
Revises: 086_add_viral_video_tables
Create Date: 2026-09-30
#2106 爆款视频 P0:持久化图片分析结果(image_analysis JSON),供 resume 阶段使用。
"""
import sqlalchemy as sa
from alembic import op
revision = "087_viral_video_image_analysis"
down_revision = "086_add_viral_video_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("viral_video_jobs", sa.Column("image_analysis", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("viral_video_jobs", "image_analysis")
+1 -26
View File
@@ -8,7 +8,6 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.clips_standalone import router as clips_standalone_router
from app.api.routes.cover_templates import router as cover_templates_router
from app.api.routes.drafts_standalone import router as drafts_standalone_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_cover import router as generation_cover_router
@@ -16,13 +15,11 @@ from app.api.routes.generation_preview import router as generation_preview_route
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.generation_variant_plans import router as generation_variant_plans_router
from app.api.routes.gpu_lipsync import router as gpu_lipsync_router
from app.api.routes.gpu_relay import router as gpu_relay_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.lipsync import router as lipsync_router
from app.api.routes.points import router as points_router
from app.api.routes.points import usage_router
from app.api.routes.points import points_router, usage_router
from app.api.routes.projects import router as projects_router
from app.api.routes.scripts import router as scripts_router
from app.api.routes.scripts_ai import router as scripts_ai_router
@@ -36,7 +33,6 @@ from app.api.routes.titles import router as titles_router
from app.api.routes.tts import router as tts_router
from app.api.routes.upload import router as upload_router
from app.api.routes.videos import router as videos_router
from app.api.routes.viral_video import router as viral_video_router
from app.api.routes.voice_clones import router as voice_clones_router
from app.api.routes.voices import router as voices_router
from fastapi import APIRouter
@@ -45,19 +41,6 @@ api_router = APIRouter(prefix="/api/v1")
health_router = APIRouter()
health_router.include_router(health_check_router)
# ── /api/health 别名:部分前端/探针把 health 放在 /api 前缀下 ──────────────
# 原来 /health 在根路径;额外加一个 /api/health 别名避免 404。
api_health_router = APIRouter(prefix="/api")
api_health_router.include_router(health_check_router)
health_router.include_router(api_health_router)
# ── 旧前端路径别名(无需 template_id 路径参数)────────────────────────────
# /api/v1/clips/from-assets 已有 clips_standalone;此处额外挂 /api/v1/editor/*,
# 解决前端调 /api/v1/editor/clips/from-assets 和 /api/v1/editor/drafts 的 404。
editor_legacy_router = APIRouter(prefix="/editor", tags=["Editor Legacy Alias"])
editor_legacy_router.include_router(clips_standalone_router)
editor_legacy_router.include_router(drafts_standalone_router)
api_router.include_router(
auth_router,
tags=["Auth"],
@@ -186,9 +169,6 @@ api_router.include_router(
prefix="/templates/{template_id}/editor",
tags=["TemplateEditor"],
)
api_router.include_router(
editor_legacy_router,
)
api_router.include_router(
tts_router,
prefix="/tts",
@@ -207,10 +187,6 @@ api_router.include_router(
internal_render_router,
tags=["Internal"],
)
api_router.include_router(
gpu_relay_router,
tags=["GpuRelay"],
)
api_router.include_router(
scripts_router,
prefix="/scripts",
@@ -241,4 +217,3 @@ api_router.include_router(
prefix="/gpu",
tags=["GPU Worker"],
)
api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"])
@@ -1,102 +0,0 @@
"""独立的草稿端点(不依赖 template_id 路径参数,兼容旧前端路径).
提供以下别名端点,与 /api/v1/templates/{template_id}/editor/draft 功能一致:
- GET /api/v1/editor/drafts 获取草稿详情(template_id 从 query/body/默认模板兜底)
- PUT /api/v1/editor/drafts 更新草稿(兼容前端 useDraftAutoSave 调用)
根因:前端 useDraftAutoSave 调用 /api/v1/editor/drafts(复数、无 template_id),
与后端以 template_id 为路径参数的设计不一致,导致 404 并触发 10s timeout。
本模块参照 clips_standalone.py 的模式,通过默认模板兜底复用 draft.py 的核心逻辑。
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ._default_template import get_or_create_default_template_id
from .templates_editor.dependencies import resolve_draft_plan_id
from .templates_editor.draft import get_editor_draft, update_editor_draft
from .templates_editor.schemas import EditorDraftResponse, EditorUpdateRequest
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Editor Legacy Alias"])
def _resolve_editor_services(db: Session) -> tuple[EditTemplateService, EditPlanService]:
return EditTemplateService(db), EditPlanService(db)
def _resolve_template_id(
template_id: str | None,
db: Session,
current_user: AuthenticatedUser,
) -> str:
"""解析 template_id:query/body 优先,否则兜底默认模板。"""
tid = (template_id or "").strip()
if tid:
return tid
user_id = str(current_user.user.id)
tid = get_or_create_default_template_id(db, user_id)
if not tid:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="无法自动创建默认模板,请刷新页面重试",
)
return tid
@router.get("/drafts", response_model=EditorDraftResponse)
def get_editor_drafts_alias(
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditorDraftResponse:
"""获取草稿详情(复数路径别名,兼容旧前端调用)。"""
tid = _resolve_template_id(template_id, db, current_user)
services = _resolve_editor_services(db)
plan_id = resolve_draft_plan_id(
template_id=tid,
services=services,
current_user=current_user,
db=db,
auto_create_default=False,
)
return get_editor_draft(
template_id=tid,
plan_id=plan_id,
services=services,
_=current_user,
)
@router.put("/drafts", response_model=EditorDraftResponse)
def update_editor_drafts_alias(
req: EditorUpdateRequest,
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditorDraftResponse:
"""更新草稿(复数路径别名,兼容前端 useDraftAutoSave 调用)。"""
tid = _resolve_template_id(template_id, db, current_user)
services = _resolve_editor_services(db)
plan_id = resolve_draft_plan_id(
template_id=tid,
services=services,
current_user=current_user,
db=db,
auto_create_default=False,
)
return update_editor_draft(
template_id=tid,
req=req,
plan_id=plan_id,
services=services,
_=current_user,
)
+5 -19
View File
@@ -76,7 +76,10 @@ class GenerateCoverResponse(BaseModel):
# ── Route ────────────────────────────────────────────────────────────────
def _select_best_frame_from_snapshots(snapshots: list[dict], plan_id: str) -> str:
def _select_best_frame_from_snapshots(
snapshots: list[dict], plan_id: str
) -> str:
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
降级策略:cv2 不可用或评分失败时,返回第一帧。
@@ -229,10 +232,7 @@ def _persist_cover_frame(
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
"""
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
@@ -241,20 +241,6 @@ def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
return getattr(videos[0], "file_url", "") or ""
except Exception:
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
# awaiting_cover 兜底:从 extra_meta.rendered_output 取
try:
task_repo = SQLAlchemyGenerationTaskRepository(db)
task = task_repo.get(task_id)
if task is not None:
_status = task.status.value if hasattr(task.status, "value") else str(task.status)
if _status == "awaiting_cover":
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
_url = _ro.get("file_url") or ""
if _url:
return _url
except Exception:
logger.warning("[封面生成] awaiting_cover 兜底读取失败: task_id=%s", task_id, exc_info=True)
return None
+29 -25
View File
@@ -11,7 +11,9 @@ from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
build_rate_limit_detail,
safe_enqueue_generation_task,
)
@@ -300,17 +302,26 @@ def create_preview_generation_task(
count,
)
# 预检查队列限流(按变体总数计)——仅保留全局硬上限,用户上限改为软 warning 在 safe_enqueue 内处理(#2098)
global_pending = generation_task_repository.count_pending_total()
if global_pending + count > GLOBAL_PENDING_LIMIT:
# 预检查队列限流(按变体总数计)
try:
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending + count > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
)
if global_pending + count > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(
GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT),
generation_task_repository,
scope="global",
),
)
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
) from e
# 确定视频比例:优先前端传入,否则从模板 mode 推断
video_ratio = request.video_ratio or ""
@@ -578,6 +589,9 @@ def create_preview_generation_task(
if not enqueued:
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队失败")
except UserPendingLimitExceeded as e:
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
rate_limit_exc = rate_limit_exc or e
except GlobalQueueFull as e:
_mark_task_failed(generation_task_repository, task, "系统队列已满")
rate_limit_exc = rate_limit_exc or e
@@ -589,6 +603,11 @@ def create_preview_generation_task(
# 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败")
if all(r.status == "failed" for r in responses) and rate_limit_exc is not None:
if isinstance(rate_limit_exc, UserPendingLimitExceeded):
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"),
)
raise HTTPException(
status_code=503,
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"),
@@ -631,26 +650,11 @@ def get_preview_generation_task(
if not getattr(task, "is_preview", False):
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
# 查询生成的视频(取第一个)。
# #2024: 渲染完成后先进入 awaiting_cover(未入成品库),此时预览也应可见,
# 从 extra_meta["rendered_output"] 读取视频 URL。
# 查询生成的视频(取第一个)
generated_videos = []
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if status_val == "completed":
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
generated_videos = list_use_case.execute(task_id)
elif status_val == "awaiting_cover":
# 用 extra_meta 中的渲染信息组装一个轻量视频对象给前端预览播放
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
if _ro.get("file_url"):
class _PreviewVideo:
def __init__(self, ro):
self.file_url = ro.get("file_url", "")
self.duration = float(ro.get("duration") or 0.0)
self.file_size = int(ro.get("file_size") or 0)
generated_videos = [_PreviewVideo(_ro)]
return _to_preview_response(task, generated_videos=generated_videos)
+48 -308
View File
@@ -7,6 +7,7 @@ from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
build_rate_limit_detail,
@@ -30,8 +31,6 @@ from app.schemas.generation_task import (
BatchGenerationTaskResponse,
ConfirmGenerationRequest,
CreateGenerationTaskRequest,
FinalizeGenerationRequest,
FinalizeGenerationResponse,
GenerationTaskResponse,
ListGenerationTasksResponse,
)
@@ -45,124 +44,6 @@ from packages.application import (
ListGeneratedVideosByTaskUseCase,
)
from packages.domain.smart_match import smart_select_assets
# #2035:文案关键词 → 素材分类 映射表(用于 smart_match category_match 维度)
# AssetClassification 枚举: scenic / product / person / animal / food / tech / sport / music / other
_CATEGORY_KEYWORDS: dict[str, set[str]] = {
"scenic": {
"风景",
"自然",
"山水",
"大海",
"天空",
"日落",
"日出",
"森林",
"城市",
"建筑",
"夜景",
"街道",
"公园",
"景区",
"旅行",
"旅游",
"户外",
},
"product": {
"产品",
"商品",
"展示",
"演示",
"开箱",
"评测",
"好物",
"推荐",
"种草",
"购物",
"电商",
"带货",
"品牌",
"广告",
"包装",
},
"person": {
"人物",
"人物采访",
"对话",
"说话",
"讲解",
"演讲",
"采访",
"聊天",
"开会",
"工作",
"办公室",
"团队",
"员工",
"老板",
"女性",
"男性",
"美女",
"帅哥",
},
"animal": {"动物", "宠物", "狗", "猫", "鸟", "鱼", "马", "牛", "羊", "野生动物", "动物园"},
"food": {
"美食",
"食物",
"餐饮",
"餐厅",
"做饭",
"烹饪",
"厨房",
"菜品",
"饮料",
"水果",
"甜点",
"蛋糕",
"咖啡",
"茶",
"零食",
"吃",
},
"tech": {
"科技",
"数码",
"电脑",
"手机",
"屏幕",
"软件",
"APP",
"互联网",
"AI",
"人工智能",
"机器人",
"办公",
"程序员",
"代码",
"屏幕录制",
},
"sport": {"运动", "健身", "跑步", "篮球", "足球", "游泳", "瑜伽", "户外", "锻炼", "体育", "比赛", "球场"},
"music": {"音乐", "歌曲", "演唱会", "乐器", "唱歌", "跳舞", "舞蹈", "MV", "演出", "乐队", "钢琴", "吉他", "节奏"},
}
def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
"""从文案标签集合推断期望的素材分类(可能命中多个)。标签为空返回 None。"""
if not script_tags:
return None
matched: set[str] = set()
for cat, kws in _CATEGORY_KEYWORDS.items():
for tag in script_tags:
tag.lower()
for kw in kws:
if kw in tag or tag in kw:
matched.add(cat)
break
if cat in matched:
break
return matched or None
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
@@ -251,11 +132,10 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
def _select_assets_from_library(
assets: list,
mode: str,
count: int = 0,
count: int,
rng=None,
script_tags: list | None = None,
tag_names_by_id: dict | None = None,
db=None,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
@@ -276,45 +156,6 @@ def _select_assets_from_library(
if not ready_video_assets:
return []
# #2035:加载片段级 AI 标签,供叙事模式 AI 加权和 smart 模式语义匹配使用。
# 失败降级为空(不影响选片主流程)。
clip_ai_tags_by_asset: dict[str, list[dict]] = {}
ai_tags_by_asset: dict[
str, dict
] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重)
try:
if db is not None:
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
ready_ids = [a.id for a in ready_video_assets]
clip_rows = (
db.query(AssetAtomClipModel.asset_id, AssetAtomClipModel.ai_tags)
.filter(AssetAtomClipModel.asset_id.in_(ready_ids))
.filter(AssetAtomClipModel.ai_tags.isnot(None))
.all()
)
agg: dict[str, dict] = {}
for asset_id, ai_tags in clip_rows:
if not isinstance(ai_tags, dict):
continue
clip_ai_tags_by_asset.setdefault(asset_id, []).append(ai_tags)
# 聚合:合并 scene/objects/action 去重
agg.setdefault(asset_id, {"scene": [], "objects": [], "action": [], "shot": "", "has_text": False})
for key in ("scene", "objects", "action"):
for v in ai_tags.get(key) or []:
v = str(v).strip()
if v and v not in agg[asset_id][key]:
agg[asset_id][key].append(v)
if ai_tags.get("has_text") is True:
agg[asset_id]["has_text"] = True
if not agg[asset_id]["shot"] and ai_tags.get("shot"):
agg[asset_id]["shot"] = ai_tags["shot"]
ai_tags_by_asset = agg
except Exception: # noqa: BLE001
logger.warning("[选片] 加载片段 AI 标签失败,降级不使用语义匹配", exc_info=True)
clip_ai_tags_by_asset = {}
ai_tags_by_asset = {}
# 叙事模式(#1970 PR3):文案标签命中池优先;无任何命中时完全降级为现有随机逻辑。
if script_tags:
from packages.domain.narrative_match import pick_narrative_assets
@@ -324,7 +165,6 @@ def _select_assets_from_library(
ready_video_assets,
script_tags=script_tags,
tag_names_by_id=tag_names_by_id,
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
limit=limit,
rng=rng,
)
@@ -335,18 +175,7 @@ def _select_assets_from_library(
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
limit = count if count > 0 else None
# #2035:给 smart_select_assets 传入文案标签和 AI 标签映射,启用语义维度
norm_script = {t.strip().lower() for t in (script_tags or []) if t and t.strip()}
expected_categories = _infer_expected_categories(norm_script)
results = smart_select_assets(
ready_video_assets,
limit=limit,
kind="video",
rng=rng,
script_tags=norm_script if norm_script else None,
ai_tags_by_asset=ai_tags_by_asset or None,
expected_categories=expected_categories,
)
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
return [r.asset.id for r in results]
# 默认 all 模式:返回全部 ready 视频素材
@@ -565,7 +394,6 @@ def create_generation_task(
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
db=db,
)
elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取
@@ -580,7 +408,6 @@ def create_generation_task(
count=request.asset_select_count,
script_tags=narrative_script_tags or None,
tag_names_by_id=_tag_index,
db=db,
)
if not resolved_asset_ids:
raise HTTPException(
@@ -683,13 +510,21 @@ def create_generation_task(
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
# 预检查(Bug B #2098):只保留全局 503 保护,用户级不再硬拒 429;
# 超额任务直接入队等待 worker 自然消费,前端展示排队位置而非阻止提交。
# USER_PENDING_LIMIT 作为软上限(safe_enqueue 兜底),提高到 20 支持批量提交。
# 预检查:批量提交前先看会不会超限,避免建一半才拒
try:
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending + count > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
)
if global_pending + count > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
@@ -1004,10 +839,14 @@ def create_generation_task(
else:
failed_tasks.append(task)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已改为软限制,此分支理论上不再触发;
# 极端并发兜底仍入队(safe_enqueue 内部会打 warning 日志),不 429 拒绝
logger.warning("[生成任务] 用户 pending 超软限制,仍允许入队: task_id=%s", task.id)
created_tasks.append(task)
# 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
) from _e
break
except GlobalQueueFull as _e:
failed_tasks.append(task)
if not created_tasks:
@@ -1052,14 +891,8 @@ def confirm_generation(
if source_task.project_id:
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
# 3. 如果预览任务已完成渲染(completed 或 awaiting_cover),检查分辨率一致性后复用产物(秒出)。
# #2024: 渲染完成先进入 awaiting_cover(等 Step5 finalize 入库),
# confirm 时不再直接 finalize——仍创建 is_preview=False 的正式任务,复用预览渲染产物。
_preview_done = getattr(source_task, "is_preview", False) and source_task.status.value in (
"completed",
"awaiting_cover",
)
if _preview_done:
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
if source_task.is_completed and getattr(source_task, "is_preview", False):
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
req_w = request.output_width or 0
req_h = request.output_height or 0
@@ -1074,25 +907,13 @@ def confirm_generation(
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
confirmed_title_config["text"] = request.custom_title.strip()
# #2024: mark_confirmed 会把 is_preview 翻转为 False、同步标题/分辨率/封面,
# 但不再自动 mark_completed——任务停留在 awaiting_cover,等待用户 Step5 选封面后调 finalize。
source_task.mark_confirmed(
cover_url=request.cover_url,
output_width=request.output_width,
output_height=request.output_height,
title_config=confirmed_title_config,
)
# 若预览任务此时是 completed(历史数据/旧 worker),回退到 awaiting_cover 统一流程
if source_task.status.value == "completed":
try:
from packages.domain.generation_task import GenerationTaskStatus
source_task.status = GenerationTaskStatus.AWAITING_COVER
source_task.completed_at = None
except Exception:
pass
generation_task_repository.update(source_task)
db.commit()
# 同步标题到 EditPlan.config
# #1970:确认生成复用预览计划,dedup_enabled 沿用计划已有值,不在此覆盖
@@ -1105,7 +926,7 @@ def confirm_generation(
)
logger.info(
"[确认生成] 复用预览产物(等待 finalize): task_id=%s, user_id=%s",
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
task_id,
authenticated_user.user.id,
)
@@ -1158,8 +979,10 @@ def confirm_generation(
):
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行
logger.warning("[任务] 用户 pending 超软限制,任务已入队")
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
) from None
except GlobalQueueFull as _e:
raise HTTPException(
status_code=503,
@@ -1172,67 +995,6 @@ def confirm_generation(
)
@router.post("/tasks/{task_id}/finalize", response_model=FinalizeGenerationResponse)
def finalize_generation_task(
task_id: str,
request: FinalizeGenerationRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
db: Session = Depends(get_db_session),
) -> FinalizeGenerationResponse:
"""#2024: Step5 点「完成」时调用——将 awaiting_cover 状态的任务正式入库+绑定封面。
- 任务必须处于 awaiting_cover 状态(渲染+上传已完成、封面候选已就绪)。
- cover_url 为空则使用任务自动截帧/智能封面;非空则绑定为最终封面。
- 幂等:已 finalize 的任务直接返回已有视频记录。
- 成功后任务推进到 completed,返回成品视频 ID + 可播放 URL。
"""
from app.services.generation_finalize_service import (
GenerationFinalizeError,
GenerationFinalizeService,
)
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
service = GenerationFinalizeService(db)
try:
video = service.finalize_task(
task_id=task_id,
user_id=authenticated_user.user.id,
cover_url=request.cover_url or None,
custom_title=(request.custom_title or "").strip() or None,
)
except GenerationFinalizeError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
try:
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
except Exception:
download_url = video.file_url
return FinalizeGenerationResponse(
video_id=video.id,
project_id=getattr(video, "project_id", "") or "",
name=getattr(video, "name", "") or "",
file_size=int(getattr(video, "file_size", 0) or 0),
duration=float(getattr(video, "duration", 0.0) or 0.0),
thumbnail_url=video.thumbnail_url or "",
cover_url=video.thumbnail_url or "",
file_url=download_url,
width=int(getattr(video, "width", 0) or 0),
height=int(getattr(video, "height", 0) or 0),
fps=float(getattr(video, "fps", 0.0) or 0.0),
status="success",
is_duplicate=bool(getattr(video, "is_duplicate", False)),
)
@router.get("/tasks", response_model=ListGenerationTasksResponse)
def list_generation_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -1280,44 +1042,6 @@ def list_generation_results(
for item in items:
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
responses.append(_to_generated_video_response(item, download_url=download_url))
# #2024/#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,
# 从 extra_meta["rendered_output"] 合成一条轻量视频响应,供前端预览与智能封面使用。
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if not responses and status_val == "awaiting_cover":
_meta = getattr(task, "extra_meta", {}) or {}
_ro = _meta.get("rendered_output") or {}
_file_url = _ro.get("file_url") or ""
if _file_url:
if _file_url.startswith("http"):
_download = _file_url
else:
try:
_download = storage_service.get_download_url(_file_url, expires_seconds=86400)
except Exception:
_download = _file_url
_name = _ro.get("name") or ""
if not _name:
_name = f"generated-{task_id[:8]}"
responses.append(
GeneratedVideoResponse(
id=f"preview-{task_id}",
project_id=getattr(task, "project_id", "") or "",
generation_task_id=task_id,
name=_name,
file_url=_file_url,
file_size=int(_ro.get("file_size") or 0),
duration=float(_ro.get("duration") or 0.0),
thumbnail_url=_ro.get("thumbnail_url") or getattr(task, "cover_url", "") or "",
width=int(_ro.get("width") or 0),
height=int(_ro.get("height") or 0),
fps=float(_ro.get("fps") or 0.0),
mode=_ro.get("mode", ""),
download_url=_download,
created_at=getattr(task, "updated_at", None) or getattr(task, "created_at", None),
)
)
return ListGeneratedVideosResponse(items=responses)
@@ -1338,8 +1062,22 @@ def retry_generation_task(
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查(Bug B #2098):只保留全局 503,用户级不再硬拒
# 预检查:创建前判断,>= 上限就拒绝
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(
UserPendingLimitExceeded(
user_id=user_id,
pending_count=user_pending,
limit=USER_PENDING_LIMIT,
),
generation_task_repository,
scope="user",
),
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
@@ -1382,8 +1120,10 @@ def retry_generation_task(
):
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded as _e:
# Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行
logger.warning("[任务] 用户 pending 超软限制,任务已入队")
raise HTTPException(
status_code=429,
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
) from None
except GlobalQueueFull as _e:
raise HTTPException(
status_code=503,
-215
View File
@@ -1,215 +0,0 @@
"""GPU 编码回传 relay 端点。
两个用途:
1. 结果回传(原):P4000 编码完成后通过 HTTP PUT 把结果 mp4 写到 /{key};Worker 用同 URL GET 回本地。
2. Mezzanine 中转(新):Worker 先把 CPU ultrafast 编码出的 mezzanine 通过 PUT 到 /mezzanine/{key},
P4000 通过 Tailscale 内网直接 GET 下载,跳过公网 OSS 中转,节省 18-20s 固定延迟。
编码完成后 DELETE 清理。
安全:
- 生产环境必须配置 GPU_ENCODE_RELAY_SECRET;token=xxx 查询参数必须匹配。
- key 为随机 hex,无法被枚举。
- 写入/读取后 worker 会调用 DELETE 主动清理;文件落地在 generated-files/gpu_relay/。
"""
from __future__ import annotations
import logging
import os
import secrets
import time
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/gpu-relay", tags=["Internal-GpuRelay"])
_DEFAULT_SECRET_LOGGED = False
def _relay_dir() -> Path:
base = os.getenv("GENERATED_FILES_DIR", "/app/generated")
sub = os.getenv("GPU_ENCODE_RELAY_DIR", "gpu_relay")
p = Path(base) / sub
p.mkdir(parents=True, exist_ok=True)
return p
def _mezzanine_dir() -> Path:
p = _relay_dir() / "mezzanine"
p.mkdir(parents=True, exist_ok=True)
return p
def _secret() -> str:
global _DEFAULT_SECRET_LOGGED
secret = (os.getenv("GPU_ENCODE_RELAY_SECRET", "") or "").strip()
if not secret:
env = (os.getenv("APP_ENV", os.getenv("ENV", "development"))).lower()
if env in ("production", "prod"):
raise RuntimeError("GPU_ENCODE_RELAY_SECRET must be set in production")
secret = os.environ.setdefault("GPU_ENCODE_RELAY_SECRET", secrets.token_urlsafe(32))
if not _DEFAULT_SECRET_LOGGED:
logger.warning(
"[gpu-relay] GPU_ENCODE_RELAY_SECRET not set; using ephemeral dev token (%s...)",
secret[:8],
)
_DEFAULT_SECRET_LOGGED = True
return secret
def _safe_key(key: str) -> str:
"""只允许合法文件名字符,防 path traversal。"""
k = key.strip()
if not k or "/" in k or "\\" in k or k in (".", "..") or not all(
c.isalnum() or c in "-_" for c in k
):
raise HTTPException(status_code=400, detail="invalid key")
return k
def _check_token(tok: Optional[str]) -> None:
if not tok or tok != _secret():
raise HTTPException(status_code=401, detail="unauthorized")
async def _atomic_write(request: Request, dst: Path, log_prefix: str, key_for_log: str) -> int:
"""通用原子写入(流式 → .part → replace)。返回字节数。"""
tmp = dst.with_suffix(dst.suffix + ".part")
size = 0
t0 = time.time()
try:
with open(tmp, "wb") as f:
async for chunk in request.stream():
f.write(chunk)
size += len(chunk)
os.replace(tmp, dst)
except Exception as e: # noqa: BLE001
if tmp.exists():
try:
tmp.unlink()
except OSError:
pass
logger.exception("[gpu-relay] %s PUT failed key=%s", log_prefix, key_for_log)
raise HTTPException(status_code=500, detail=f"write failed: {e}") from e
logger.info(
"[gpu-relay] %s PUT key=%s size=%d took=%.2fs",
log_prefix, key_for_log, size, time.time() - t0,
)
return size
def _file_response(path: Path, download_name: str) -> FileResponse:
if not path.exists():
raise HTTPException(status_code=404, detail="not found")
return FileResponse(path=path, media_type="video/mp4", filename=f"{download_name}.mp4")
def _head_response(path: Path) -> Response:
if not path.exists():
return Response(status_code=404)
return Response(
status_code=200,
media_type="video/mp4",
headers={"Content-Length": str(path.stat().st_size)},
)
def _safe_delete(path: Path, err_detail: str) -> dict:
try:
if path.exists():
path.unlink()
except OSError as e:
raise HTTPException(status_code=500, detail=f"{err_detail}: {e}") from e
return {"ok": True}
# ── Worker 侧 URL 构造 ─────────────────────────────────────────────────
def build_relay_put_url(base_url: str, key: str, secret: str) -> str:
"""给 P4000 回传结果用的 PUT URL(外部/Tailscale 可达)。"""
return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/{key}?token={secret}"
def build_relay_get_url(base_url: str, key: str, secret: str) -> str:
"""Worker 取回结果用的 GET URL。"""
return build_relay_put_url(base_url, key, secret)
def build_mezzanine_put_url(base_url: str, key: str, secret: str) -> str:
"""Worker 上传 mezzanine 用的 PUT URL(Docker 内网或 Tailscale)。"""
return f"{base_url.rstrip('/')}/api/v1/internal/gpu-relay/mezzanine/{key}?token={secret}"
def build_mezzanine_get_url(base_url: str, key: str, secret: str) -> str:
"""P4000 下载 mezzanine 用的 GET URL(必须是 P4000 可达地址,通常是 Tailscale host:8092)。"""
return build_mezzanine_put_url(base_url, key, secret)
def generate_key() -> str:
return uuid.uuid4().hex
# ── 编码结果:PUT/GET/HEAD/DELETE /{key} ──────────────────────────────
@router.put("/{key}")
async def put_object(key: str, request: Request, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
size = await _atomic_write(request, _relay_dir() / safe, "result", safe)
return {"ok": True, "key": safe, "size": size}
@router.get("/{key}")
async def get_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _file_response(_relay_dir() / safe, safe)
@router.head("/{key}")
async def head_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _head_response(_relay_dir() / safe)
@router.delete("/{key}")
async def delete_object(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _safe_delete(_relay_dir() / safe, "delete failed")
# ── Mezzanine 中转:PUT/GET/HEAD/DELETE /mezzanine/{key} ─────────────
# Worker 上传 mezzanine 用;P4000 通过 Tailscale 直接 GET 下载。
@router.put("/mezzanine/{key}")
async def put_mezzanine(key: str, request: Request, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
dst = _mezzanine_dir() / f"{safe}.mp4"
size = await _atomic_write(request, dst, "mezzanine", safe)
return {"ok": True, "key": safe, "size": size}
@router.get("/mezzanine/{key}")
async def get_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _file_response(_mezzanine_dir() / f"{safe}.mp4", f"{safe}-mezzanine")
@router.head("/mezzanine/{key}")
async def head_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _head_response(_mezzanine_dir() / f"{safe}.mp4")
@router.delete("/mezzanine/{key}")
async def delete_mezzanine(key: str, token: Optional[str] = Query(None)):
_check_token(token)
safe = _safe_key(key)
return _safe_delete(_mezzanine_dir() / f"{safe}.mp4", "mezzanine delete failed")
+3 -9
View File
@@ -63,8 +63,6 @@ def _generation_step(task) -> str:
return "等待 Worker 执行"
if s == "running":
return "正在生成成片"
if s == "awaiting_cover":
return "等待确认封面"
if s == "completed":
return "生成完成"
if s == "failed":
@@ -131,7 +129,7 @@ def _validate_status(status: str | None) -> str | None:
"""校验状态值合法性。"""
if status is None:
return None
valid = {"pending", "running", "awaiting_cover", "completed", "failed", "cancelled"}
valid = {"pending", "running", "completed", "failed", "cancelled"}
if status not in valid:
raise HTTPException(
status_code=400,
@@ -153,9 +151,7 @@ def _clamp_page_size(page_size: int) -> int:
@router.get("/tasks", response_model=ListTasksResponse)
def list_user_tasks(
status: str | None = Query(
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
),
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
@@ -252,9 +248,7 @@ def retry_task_by_id(
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
def list_project_tasks(
project_id: str,
status: str | None = Query(
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
),
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
page: int = Query(1, ge=1, description="页码,从1开始"),
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
@@ -682,50 +682,17 @@ def create_clips_from_assets_editor(
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
asset_scene_points: dict[str, list[float]] = {}
invalid_asset_ids: list[str] = []
valid_asset_ids: list[str] = []
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset is None:
logger.warning("from-assets 素材不存在或已删除,跳过: asset_id=%s", asset_id)
invalid_asset_ids.append(asset_id)
continue
_dur = float(getattr(asset, "duration", 0.0) or 0.0)
if _dur <= 0:
# 素材时长缺失(刚上传/分析未完成)或为0,跳过该素材——避免按兜底时长分配无效片段。
# 若所有素材都无效,在下面统一抛 400。
logger.warning("from-assets 素材时长缺失或为0,跳过: asset_id=%s", asset_id)
invalid_asset_ids.append(asset_id)
continue
valid_asset_ids.append(asset_id)
asset_durations[asset_id] = _dur
# 计算 smart_match 综合评分,用于候选排序
try:
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算 smart_match 综合评分,用于候选排序
smart_score, _ = score_asset(asset)
asset_smart_scores[asset_id] = smart_score
except Exception:
asset_smart_scores[asset_id] = 0.0
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
try:
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
asset_scene_points[asset_id] = cached_points
except Exception:
pass
if invalid_asset_ids:
logger.info(
"from-assets %d 个素材无效(时长缺失/不存在,已跳过): %s",
len(invalid_asset_ids),
",".join(invalid_asset_ids[:5]),
)
# 所有素材都无效(刚上传未分析完)→ 400 让前端稍后重试,而不是用兜底时长产生错乱片段
if not valid_asset_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材尚未完成分析,请稍后重试",
)
# 后续分配素材时只在 valid_asset_ids 里挑选
unique_asset_ids = valid_asset_ids
logger.info(
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
len(asset_scene_points),
+28 -131
View File
@@ -191,23 +191,6 @@ def _find_duplicate_asset(
return None
def _get_existing_asset_url(existing: Any, storage_service: Any) -> str:
"""安全获取已存在素材的公网 URL,兼容 domain Asset(无 file_url 字段)和 ORM model。"""
# Domain Asset 只有 storage_key 字段;ORM model 有 file_url 但存的也是 storage_key
key = ""
for attr in ("storage_key", "file_url"):
v = getattr(existing, attr, None)
if v:
key = v
break
if not key:
return ""
try:
return storage_service.get_url(key) or ""
except Exception:
return ""
def _create_pending_asset(
asset_repository,
project_id,
@@ -307,43 +290,6 @@ def _submit_ingest_job(
return job
def _find_active_ingest_job(ingest_job_repository: Any, asset_id: str) -> Any | None:
"""查询 asset 上是否存在"仍在跑或已成功"的 ingest job(FAILED 视为不存在,需重提)。"""
if not asset_id:
return None
find = getattr(ingest_job_repository, "find_by_asset_id", None)
if not callable(find):
# 旧仓储未实现 find_by_asset_id,无法判断 → 保守返回 None(走正常流程,
# _submit_ingest_job 自身有数据库唯一约束/幂等兜底,不会重复建 job)
return None
try:
return find(asset_id)
except Exception: # noqa: BLE001
logger.warning("[upload] find_by_asset_id 查询失败,按无 job 处理: asset=%s", asset_id, exc_info=True)
return None
def _is_true_duplicate(existing_asset: Asset, ingest_job_repository: Any) -> tuple[bool, Any | None]:
"""判断 `existing_asset` 是真重复(应短路返 duplicated)还是占位(应补提 ingest)。
返回 (is_duplicate, existing_job):
- READY 素材:真重复,job 可能为 None(已就绪不需要 job_id)
- PROCESSING/UPLOADING 且已有在跑/已完成 ingest job:幂等重试,真重复,job 返回给前端轮询
- PROCESSING/UPLOADING 且无 job:prepare 建的占位 / 之前 ingest 创建失败 → 非重复,需补提 ingest
- ERROR/DELETED:非重复(允许重新上传覆盖)
"""
status = getattr(existing_asset, "status", None)
if status == AssetStatus.READY:
return True, None
if status in (AssetStatus.PROCESSING, AssetStatus.UPLOADING):
job = _find_active_ingest_job(ingest_job_repository, existing_asset.id)
if job is not None:
return True, job
return False, None
# ERROR / DELETED / 其它:走正常流程重新 ingest
return False, None
@router.post("/direct/prepare", response_model=DirectUploadPrepareResponse)
async def prepare_direct_upload(
request: DirectUploadPrepareRequest,
@@ -407,7 +353,6 @@ async def prepare_direct_upload(
duplicated=True,
skip_transfer=True,
asset_id=existing.id,
url=_get_existing_asset_url(existing, storage_service),
)
file_id = uuid4().hex[:8]
@@ -461,7 +406,6 @@ async def prepare_direct_upload(
duplicated=False,
skip_transfer=False,
asset_id=pending_asset_id,
url="",
)
@@ -500,25 +444,12 @@ async def complete_direct_upload(
file_size=request.file_size,
)
if existing is not None:
is_dup, existing_job = _is_true_duplicate(existing, ingest_job_repository)
if is_dup:
logger.info(
"[upload] complete 幂等命中真重复: asset=%s status=%s job=%s",
existing.id,
getattr(existing, "status", None),
getattr(existing_job, "id", None),
)
return DirectUploadCompleteResponse(
storage_key=existing.storage_key,
ingest_job_id=getattr(existing_job, "id", "") or "",
duplicated=True,
asset_id=existing.id,
url=storage_service.get_url(existing.storage_key),
)
logger.info(
"[upload] complete 命中占位 asset(status=%s 无 ingest job),继续补提 ingest: asset=%s",
getattr(existing, "status", None),
existing.id,
return DirectUploadCompleteResponse(
storage_key=existing.storage_key,
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
url=storage_service.get_url(existing.storage_key),
)
try:
@@ -548,24 +479,14 @@ async def complete_direct_upload(
)
# Issue #1776: 计数由 asset_repository.create() 自动维护
# 幂等保护:补提占位场景下可能已有 job(极端竞态),先查一次
existing_job = _find_active_ingest_job(ingest_job_repository, pending_asset.id)
if existing_job is not None:
logger.info(
"[upload] complete 补提时发现 job 已存在(竞态/并发重试),复用: asset=%s job=%s",
pending_asset.id,
existing_job.id,
)
job = existing_job
else:
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
asset_id=pending_asset.id,
)
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
asset_id=pending_asset.id,
)
return DirectUploadCompleteResponse(
storage_key=normalized_key,
ingest_job_id=job.id,
@@ -612,27 +533,12 @@ async def upload_asset(
file_size=0,
)
if existing is not None:
is_dup, existing_job = _is_true_duplicate(existing, ingest_job_repository)
if is_dup:
logger.info(
"[upload] multipart 幂等命中真重复: asset=%s status=%s job=%s",
existing.id,
getattr(existing, "status", None),
getattr(existing_job, "id", None),
)
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id=getattr(existing_job, "id", "") or "",
url=storage_service.get_url(existing.storage_key)
if getattr(existing, "status", None) == AssetStatus.READY
else "",
duplicated=True,
asset_id=existing.id,
)
logger.info(
"[upload] multipart 命中占位 asset(status=%s 无 ingest job),继续补提 ingest: asset=%s",
getattr(existing, "status", None),
existing.id,
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id="",
url="",
duplicated=True,
asset_id=existing.id,
)
file_id = uuid4().hex[:8]
@@ -668,23 +574,14 @@ async def upload_asset(
)
# Issue #1776: 计数由 asset_repository.create() 自动维护
existing_job = _find_active_ingest_job(ingest_job_repository, pending_asset.id)
if existing_job is not None:
logger.info(
"[upload] multipart 补提时发现 job 已存在(竞态/并发重试),复用: asset=%s job=%s",
pending_asset.id,
existing_job.id,
)
job = existing_job
else:
job = _submit_ingest_job(
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
file_hash=file_hash,
asset_id=pending_asset.id,
)
job = _submit_ingest_job(
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
file_hash=file_hash,
asset_id=pending_asset.id,
)
return UploadAssetResponse(
storage_key=storage_key,
-715
View File
@@ -1,715 +0,0 @@
"""爆款视频 API 路由。
v1.5 三步分步流水线端点(前端新交互):
POST /api/v1/viral-video/analyze-images 阶段1:创建任务 + 仅做图片/视频分析,暂停在 image_analyzed
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染,直到完成
旧端点(兼容保留,旧前端/一键生成模式):
POST /api/v1/viral-video/generate 一键入队,前半段跑到 wait_user_confirm
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
通用:
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/storyboard/generated_copy_text)
GET /api/v1/viral-video/history 历史记录
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析
GET /api/v1/viral-video/style-templates 风格模板列表
WS /api/v1/viral-video/ws/{job_id}?token= WebSocket 进度推送
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_db_session
from app.schemas.viral_video import (
AnalyzeImagesRequest,
AnalyzeStyleRequest,
AnalyzeStyleResponse,
ConfirmCopyRequest,
ConfirmIntentRequest,
CreateViralVideoRequest,
GenerateCopyRequest,
StyleTemplateListResponse,
StyleTemplateResponse,
ViralVideoHistoryResponse,
ViralVideoJobResponse,
)
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
SQLAlchemyViralVideoJobRepository,
SQLAlchemyViralVideoStyleTemplateRepository,
)
from packages.domain.viral_video import ViralVideoStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Helpers ──────────────────────────────────────────────────────────────
def _build_copy_result(job) -> dict | None:
"""将后端原始字段拼装为前端期望的 CopyResult 结构(final_copy/suggested_copy/title/scenes)。"""
copy_text = getattr(job, "generated_copy_text", "") or ""
sb = getattr(job, "storyboard", None) or []
intent = getattr(job, "intent_result", None) or {}
if not copy_text and not sb:
return None
scenes = []
for seg in sb:
if isinstance(seg, dict):
scenes.append(
{
"shot": seg.get("description", ""),
"narration": seg.get("text", ""),
"duration": seg.get("duration"),
}
)
return {
"title": (intent.get("suggested_title") if isinstance(intent, dict) else None) or "",
"final_copy": copy_text,
"suggested_copy": copy_text,
"scenes": scenes,
}
def _to_response(job) -> ViralVideoJobResponse:
return ViralVideoJobResponse(
id=job.id,
user_id=job.user_id,
images=job.images,
industry=job.industry,
target_customer=job.target_customer,
persona_id=job.persona_id,
viral_structure=job.viral_structure,
marketing_purpose=job.marketing_purpose,
bgm_preference=job.bgm_preference,
duration=job.duration,
user_copy_text=job.user_copy_text,
fusion_level=job.fusion_level,
reference_audio_path=job.reference_audio_path,
reference_video_url=job.reference_video_url,
style_strength=job.style_strength,
style_guide=job.style_guide,
style_template_id=job.style_template_id,
status=job.status,
image_analysis=getattr(job, "image_analysis", None),
storyboard=getattr(job, "storyboard", None),
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
copy_result=_build_copy_result(job),
intent_result=job.intent_result,
result_video_url=job.result_video_url,
credits_cost=job.credits_cost,
error_msg=job.error_msg,
retry_count=job.retry_count,
started_at=job.started_at,
completed_at=job.completed_at,
created_at=job.created_at,
updated_at=job.updated_at,
)
def _get_job_repo(session: Session) -> SQLAlchemyViralVideoJobRepository:
return SQLAlchemyViralVideoJobRepository(session)
def _get_style_repo(session: Session) -> SQLAlchemyViralVideoStyleTemplateRepository:
return SQLAlchemyViralVideoStyleTemplateRepository(session)
# ── Endpoints ────────────────────────────────────────────────────────────
@router.post("/generate", response_model=ViralVideoJobResponse)
def create_viral_video(
request: CreateViralVideoRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""创建爆款视频任务,入队 Celery 编排器。"""
from packages.domain.viral_video import ViralVideoJob
repo = _get_job_repo(session)
# 创建领域实体
job = ViralVideoJob(
user_id=authenticated_user.user.id,
images=list(request.images),
industry=request.industry,
target_customer=request.target_customer,
persona_id=request.persona_id,
viral_structure=request.viral_structure,
marketing_purpose=request.marketing_purpose,
bgm_preference=request.bgm_preference,
duration=request.duration,
user_copy_text=request.user_copy_text,
fusion_level=request.fusion_level,
reference_audio_path=request.reference_audio_path,
reference_video_url=request.reference_video_url,
style_strength=request.style_strength,
style_template_id=request.style_template_id,
)
# 持久化
repo.save(job)
# 入队 Celery 任务
try:
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 任务已入队: job_id=%s user_id=%s", job.id, job.user_id)
except Exception as e:
logger.error("[爆款视频] 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/analyze-images", response_model=ViralVideoJobResponse)
def analyze_images(
request: AnalyzeImagesRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.5 阶段1:创建任务并仅做图片/视频 VLM 分析,跑完后状态=image_analyzed。
前端拿到 image_analysis(商品名/品牌/特征/颜色/材质等结构化结果)展示给用户;
用户填完营销参数后再调 /{id}/generate-copy 进入阶段2。
"""
from packages.domain.viral_video import ViralVideoJob
repo = _get_job_repo(session)
job = ViralVideoJob(
user_id=authenticated_user.user.id,
images=list(request.images),
reference_video_url=request.reference_video_url or "",
style_template_id=request.style_template_id or "",
style_strength=request.style_strength or "medium",
)
repo.save(job)
try:
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
logger.info("[爆款视频][阶段1] analyze-images 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段1] analyze-images 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/generate-copy", response_model=ViralVideoJobResponse)
def generate_copy(
job_id: str,
request: GenerateCopyRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.5 阶段2:用户填完营销参数后,跑 意图解析 → 文案融合 → 分镜 → 合规审核。
跑完后状态=copy_generated,响应包含 generated_copy_text + storyboard,
前端展示文案供用户编辑;确认/编辑后调 /{id}/confirm-copy 进入阶段3。
"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING, ViralVideoStatus.FAILED):
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能生成文案")
# 允许失败任务重试:重置
if job.status == ViralVideoStatus.FAILED:
job.retry_count += 1
job.error_msg = ""
# 把用户填的营销参数写到 job 上
job.industry = request.industry or job.industry
job.target_customer = request.target_customer or job.target_customer
job.persona_id = request.persona_id or job.persona_id
job.viral_structure = request.viral_structure or job.viral_structure
job.marketing_purpose = request.marketing_purpose or job.marketing_purpose
job.bgm_preference = request.bgm_preference or job.bgm_preference
if request.duration:
job.duration = request.duration
job.user_copy_text = request.user_copy_text if request.user_copy_text else job.user_copy_text
job.fusion_level = request.fusion_level or job.fusion_level
job.reference_audio_path = request.reference_audio_path or job.reference_audio_path
job.reference_video_url = request.reference_video_url or job.reference_video_url
job.style_strength = request.style_strength or job.style_strength
job.style_template_id = request.style_template_id or job.style_template_id
job.resume_from_image_analyzed()
repo.update(job)
try:
celery_app.send_task("worker.run_viral_video_generate_copy", args=[job.id])
logger.info("[爆款视频][阶段2] generate-copy 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段2] generate-copy 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/confirm-copy", response_model=ViralVideoJobResponse)
def confirm_copy(
job_id: str,
request: ConfirmCopyRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""v1.5 阶段3:用户确认/编辑文案后开始 TTS+BGM(skip)+渲染+上传。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.COPY_GENERATED:
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能确认文案(需 copy_generated)")
job.resume_from_copy_generated(edited_copy=request.edited_copy or None)
repo.update(job)
try:
celery_app.send_task("worker.run_viral_video_render", args=[job.id])
logger.info("[爆款视频][阶段3] confirm-copy 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段3] confirm-copy 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.get("/history", response_model=ViralVideoHistoryResponse)
def list_viral_video_history(
limit: int = 50,
offset: int = 0,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoHistoryResponse:
"""获取用户的爆款视频历史列表。"""
repo = _get_job_repo(session)
jobs = repo.list_by_user(authenticated_user.user.id, limit=limit, offset=offset)
items = [_to_response(j) for j in jobs]
return ViralVideoHistoryResponse(items=items, total=len(items))
@router.get("/style-templates", response_model=StyleTemplateListResponse)
def list_style_templates(
session: Session = Depends(get_db_session),
) -> StyleTemplateListResponse:
"""获取风格模板列表。"""
repo = _get_style_repo(session)
templates = repo.list_all()
items = [
StyleTemplateResponse(
id=t["id"],
name=t["name"],
description=t["description"],
thumbnail_url=t["thumbnail_url"],
style_config=t["style_config"],
)
for t in templates
]
return StyleTemplateListResponse(items=items)
@router.get("/{job_id}", response_model=ViralVideoJobResponse)
def get_viral_video_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""查询爆款视频任务状态。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权查看此任务")
return _to_response(job)
@router.post("/{job_id}/retry", response_model=ViralVideoJobResponse)
def retry_viral_video_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""重试失败的爆款视频任务。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.FAILED:
raise HTTPException(status_code=409, detail="只有失败的任务可以重试")
# 重置状态
job.retry_count += 1
job.status = ViralVideoStatus.PENDING
job.error_msg = ""
job.started_at = None
job.completed_at = None
repo.update(job)
# 重新入队
try:
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d", job.id, job.retry_count)
except Exception as e:
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
job.mark_failed(f"重试入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/confirm-intent", response_model=ViralVideoJobResponse)
def confirm_intent(
job_id: str,
request: ConfirmIntentRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""用户确认/修改 AI 生成的意图文案,恢复流水线。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.WAIT_USER_CONFIRM:
raise HTTPException(status_code=409, detail="任务当前不在等待确认状态")
# 更新文案
if request.confirmed_copy:
job.user_copy_text = request.confirmed_copy
# 恢复流水线
job.resume_from_confirm()
repo.update(job)
# 从断点恢复 Celery 任务
try:
celery_app.send_task("worker.resume_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 意图确认,恢复流水线: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频] 恢复流水线失败: %s", e, exc_info=True)
job.mark_failed(f"恢复流水线失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/analyze-style", response_model=AnalyzeStyleResponse)
def analyze_style(
job_id: str,
request: AnalyzeStyleRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> AnalyzeStyleResponse:
"""触发参考视频风格分析(独立步骤,可在生成前单独调用)。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
# 更新参考视频 URL
job.reference_video_url = request.reference_video_url
if request.style_template_id:
job.style_template_id = request.style_template_id
repo.update(job)
# 入队风格分析任务
try:
celery_app.send_task("worker.run_video_style_analysis", args=[job.id])
logger.info("[爆款视频] 风格分析入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频] 风格分析入队失败: %s", e, exc_info=True)
return AnalyzeStyleResponse(
job_id=job.id,
status="analyzing",
style_guide=None,
)
# ── WebSocket 进度推送 ──────────────────────────────────────────────────
def _ws_authenticate_user(token: str):
"""从 token 字符串解析用户(复用 HTTP Bearer 的解码 + 黑名单逻辑)。
WebSocket 握手阶段不能发自定义 Authorization header,
因此统一通过 query 参数 ``?token=...`` 传 JWT。
"""
from app.auth import _decode_user_token
from app.dependencies import get_user_repository
if not token:
return None
try:
payload = _decode_user_token(token)
except Exception:
return None
user_id = payload.get("sub")
if not isinstance(user_id, str) or not user_id:
return None
# 同步场景下手动拉 repository 实例
from app.db import SessionLocal
session = SessionLocal()
try:
user_repo = get_user_repository(session)
user = user_repo.find_by_id(user_id)
return user
finally:
session.close()
async def _run_pubsub_forwarder(
websocket, redis_lib, settings, job_id: str
) -> None: # pragma: no cover - integration tested (real Redis + thread)
"""订阅 Redis 频道并把消息桥接到 WebSocket,终态消息后自动关闭。
该函数封装了线程 + asyncio.Queue 桥接逻辑,在单测中可被整体替换为桩,
避免引入真实 Redis 与线程调度的不确定性。
"""
import asyncio
import json
import threading
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
pubsub = r.pubsub(ignore_subscribe_messages=True)
channel = f"viral_video:{job_id}"
pubsub.subscribe(channel)
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
stop_event = asyncio.Event()
def _reader() -> None:
try:
while not stop_event.is_set():
msg = pubsub.get_message(timeout=0.5)
if msg is None or msg.get("type") != "message":
continue
raw = msg.get("data")
if not isinstance(raw, str):
continue
try:
payload = json.loads(raw)
except Exception:
payload = {"type": "viral_video:progress", "data": {"raw": raw}}
loop.call_soon_threadsafe(queue.put_nowait, payload)
if payload.get("type") in ("viral_video:completed", "viral_video:failed"):
loop.call_soon_threadsafe(stop_event.set)
break
except Exception as e:
logger.warning("[爆款视频WS] pubsub reader 异常退出: %s", e)
loop.call_soon_threadsafe(stop_event.set)
try:
reader_thread = threading.Thread(target=_reader, name=f"viral-video-ws-{job_id}", daemon=True)
reader_thread.start()
while not stop_event.is_set():
try:
payload = await asyncio.wait_for(queue.get(), timeout=1.0)
except asyncio.TimeoutError:
continue
try:
await websocket.send_json(payload)
except Exception:
break
if payload.get("type") in ("viral_video:completed", "viral_video:failed"):
break
except WebSocketDisconnect:
logger.info("[爆款视频WS] 客户端断开: job_id=%s", job_id)
except Exception as e:
logger.error("[爆款视频WS] 转发异常: %s", e, exc_info=True)
try:
await websocket.send_json({"type": "viral_video:error", "message": f"服务异常: {e}"})
except Exception:
pass
finally:
stop_event.set()
try:
pubsub.unsubscribe(channel)
pubsub.close()
except Exception:
pass
try:
r.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
@router.websocket("/ws/{job_id}")
async def viral_video_websocket(websocket: WebSocket, job_id: str) -> None:
"""WebSocket 桥接:订阅 Redis `viral_video:{job_id}` 频道并转发给前端。
认证:通过 ``?token=<jwt>`` query 参数传 JWT(浏览器 WS 握手不支持自定义 header)。
事件类型:
- viral_video:progress 中间进度(progress: 0-100)
- viral_video:wait_user 等待用户确认意图文案
- viral_video:completed 任务完成(data.video_url)
- viral_video:failed 任务失败(data.error)
- viral_video:error 服务端错误(如鉴权失败 / job 不存在 / 无权限)
"""
import redis as redis_lib
from app.config import settings
# ── 1. 鉴权 ──────────────────────────────────────────────────────
token = websocket.query_params.get("token", "")
user = _ws_authenticate_user(token)
if user is None:
await websocket.close(code=4401, reason="Unauthorized")
return
# ── 2. 校验 job 归属 ─────────────────────────────────────────────
from app.db import SessionLocal
session = SessionLocal()
try:
job_repo = SQLAlchemyViralVideoJobRepository(session)
job = job_repo.get(job_id)
if job is None:
await websocket.close(code=4404, reason="Job not found")
return
if job.user_id != user.id:
await websocket.close(code=4403, reason="Forbidden")
return
finally:
session.close()
await websocket.accept()
# ── 3. 发送一条初始状态(前端连接后立即拿到当前进度) ────────────
try:
session = SessionLocal()
job_repo = SQLAlchemyViralVideoJobRepository(session)
job = job_repo.get(job_id)
if job is not None:
status_val = job.status.value if hasattr(job.status, "value") else str(job.status)
initial = {
"type": "viral_video:progress",
"job_id": job_id,
"stage": _stage_from_status(job),
"progress": _estimate_progress(job),
"message": _initial_message(job),
"data": {"status": status_val},
}
await websocket.send_json(initial)
# 已经终态 → 再发一条终态事件后立即关闭,避免占连接
if job.is_terminal:
is_completed = status_val == "completed"
terminal_type = "viral_video:completed" if is_completed else "viral_video:failed"
terminal_data = (
{"video_url": job.result_video_url or ""} if is_completed else {"error": job.error_msg or ""}
)
await websocket.send_json(
{
"type": terminal_type,
"job_id": job_id,
"stage": "",
"progress": 100 if is_completed else 0,
"message": "视频生成完成" if is_completed else "任务失败",
"data": terminal_data,
}
)
await websocket.close()
return
session.close()
except Exception as e:
logger.warning("[爆款视频WS] 发送初始状态失败: %s", e)
try:
session.close()
except Exception:
pass
# ── 4. 订阅 Redis 频道并转发 ─────────────────────────────────────
# redis-py 的 pubsub 是同步阻塞的,放到线程里跑,通过 asyncio.Queue 桥接到 event loop。
# 该段依赖真实 Redis + 线程调度,属于集成测试范围,单测通过桩替换。
await _run_pubsub_forwarder(websocket, redis_lib, settings, job_id)
def _job_status(job) -> str:
return job.status.value if hasattr(job.status, "value") else str(job.status)
# 初始快照的 stage 推断:领域对象不持久化 stage,
# 只能根据 status 给一个占位,后续 worker 推送的真实进度事件会覆盖。
_STATUS_STAGE = {
"pending": "",
"running": "",
"image_analyzed": "image_analysis",
"copy_generated": "review",
"wait_user_confirm": "intent_parsing",
"completed": "uploading",
"failed": "",
"cancelled": "",
}
_STATUS_PROGRESS = {
"pending": 0.0,
"running": 5.0,
"image_analyzed": 15.0,
"copy_generated": 70.0,
"wait_user_confirm": 35.0,
"completed": 100.0,
"failed": 0.0,
"cancelled": 0.0,
}
_STATUS_MESSAGE = {
"pending": "任务已创建,等待执行",
"running": "任务执行中",
"image_analyzed": "图片分析完成,等待填写营销参数",
"copy_generated": "文案与分镜已生成,等待确认文案",
"wait_user_confirm": "等待用户确认意图文案",
"completed": "视频生成完成",
"failed": "任务失败",
"cancelled": "任务已取消",
}
def _stage_from_status(job) -> str:
return _STATUS_STAGE.get(_job_status(job), "")
def _estimate_progress(job) -> float:
"""根据 status 粗略估算百分比(0-100),用于连接初始快照;
连接建立后由 Redis 推送的真实事件持续更新。
"""
return _STATUS_PROGRESS.get(_job_status(job), 5.0)
def _initial_message(job) -> str:
"""给新连接的前端一个可读的初始状态文案。"""
status_val = _job_status(job)
if status_val == "failed" and job.error_msg:
return f"任务失败: {job.error_msg}"
return _STATUS_MESSAGE.get(status_val, "任务准备中")
+33 -24
View File
@@ -6,7 +6,7 @@ from app.core.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
USER_PENDING_LIMIT = 20 # 单用户 pending 上限(#2098: 从 3 提到 20,支持批量任务自动排队)
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值)
@@ -154,18 +154,19 @@ def check_queue_limits(
user_pending_limit: int = USER_PENDING_LIMIT,
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
) -> None:
"""检查队列限流(预检查用,任务创建前调用)。
"""检查队列限流(预检查用,任务创建前调用),超限抛对应异常。
#2098 语义变更:用户级限流改为软提示,不再抛异常拒绝;仅全局硬上限抛 GlobalQueueFull。
边界语义:>= 上限即拒绝(达到上限就不能再加新任务)。
Args:
user_id: 用户 ID(保留参数,当前不做用户级硬拒)
user_id: 用户 ID
generation_task_repository: 任务仓储
user_pending_limit: 单用户 pending 上限(保留,当前未硬拒)
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
Raises:
GlobalQueueFull: 全局超限时抛出
GlobalQueueFull: 全局超限时抛出(优先级更高,先查全局)
UserPendingLimitExceeded: 用户超限时抛出
"""
# 先查全局(系统级保护优先级更高)
global_pending = generation_task_repository.count_pending_total()
@@ -178,9 +179,17 @@ def check_queue_limits(
)
raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
# #2098: 用户级限流改为软提示,不在预检查阶段拒绝(超额任务仍入队排队)。
# 真正的系统保护由全局 GLOBAL_PENDING_LIMIT 硬上限承担。
# UserPendingLimitExceeded 保留以兼容历史 import/except,但预检查与 safe_enqueue 均不再 raise。
# 再查用户级
if user_id:
user_pending = generation_task_repository.count_pending_by_user(user_id)
if user_pending >= user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 任务数超限: user_id=%s, count=%d/%d",
user_id,
user_pending,
user_pending_limit,
)
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
def _mark_task_failed_safely(
@@ -237,6 +246,7 @@ def safe_enqueue_generation_task(
Raises:
GlobalQueueFull: 全局 pending 超限时抛出,任务会被标记为 failed
UserPendingLimitExceeded: 用户 pending 超限时抛出,任务会被标记为 failed
"""
# ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ──
@@ -253,18 +263,19 @@ def safe_enqueue_generation_task(
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# Bug B #2098: 用户级限流改为软提示,不再硬拒;所有任务都入队等待 worker 自然消费。
# user_pending_limit 作为兜底阈值保留(默认 20),达到时打 warning 日志但仍入队,
# 避免极端情况下恶意用户无限堆积任务。真正的系统保护由全局 GLOBAL_PENDING_LIMIT 承担。
# 用户级限流检查(传了 user_id 才做)
if user_id:
user_pending = generation_task_repository.count_pending_by_user(user_id)
if user_pending > user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 任务数超过软上限(入队): user_id=%s, count=%d/%d, 仍允许入队排队",
"[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d",
user_id,
user_pending,
user_pending_limit,
)
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# ── 发送 Celery 任务 ──
try:
@@ -306,18 +317,16 @@ def safe_enqueue_generation_task(
user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0
global_over = global_after > global_pending_limit
# Bug B #2098: 用户超限仅日志警告,不回滚任务
if user_id and user_after > user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 超软上限(入队后): user_id=%s, count=%d/%d",
user_id,
user_after,
user_pending_limit,
)
user_over = bool(user_id and user_after > user_pending_limit)
if global_over or user_over:
if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
else:
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
logger.warning(
"[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed",
reason,
+2 -2
View File
@@ -7,9 +7,9 @@ from packages.adapters.sqlalchemy_impl import (
)
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
ensure_database_exists(settings.effective_database_url)
ensure_database_exists(settings.DATABASE_URL)
engine, SessionLocal = build_session_factory(
settings.effective_database_url,
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
+1 -1
View File
@@ -56,7 +56,7 @@ from packages.adapters.sqlalchemy_impl.voice_library_repository import (
from packages.ports.tag_repository import TagRepository
from packages.ports.user_repository import UserRepository
_engine, _SessionLocal = build_session_factory(settings.effective_database_url)
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
def get_db_session() -> Generator[Session, None, None]:
-27
View File
@@ -13,33 +13,6 @@ class ConfirmGenerationRequest(BaseModel):
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
class FinalizeGenerationRequest(BaseModel):
"""Step5 点「完成」请求体:用户选定封面后,正式将视频入成品库。"""
cover_url: str = Field(
default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)"
)
custom_title: str = Field(default="", description="用户自定义成片标题,非空时覆盖 rendered_output.name")
class FinalizeGenerationResponse(BaseModel):
"""finalize 响应:返回新创建的成品库视频信息。"""
video_id: str = Field(description="新创建的成品视频 ID")
project_id: str = Field(default="", description="成品所属项目 ID")
name: str = Field(default="", description="成片名称")
file_size: int = Field(default=0, description="文件大小(字节)")
duration: float = Field(default=0.0, description="时长(秒)")
thumbnail_url: str = Field(default="", description="最终绑定的缩略图/封面 URL")
cover_url: str = Field(default="", description="最终绑定的封面 URL")
file_url: str = Field(default="", description="成品视频下载 URL")
width: int = Field(default=0)
height: int = Field(default=0)
fps: float = Field(default=0.0)
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
class CreateGenerationTaskRequest(BaseModel):
"""创建生成任务请求。
-2
View File
@@ -29,8 +29,6 @@ class DirectUploadPrepareResponse(BaseModel):
duplicated: bool = False
skip_transfer: bool = False
asset_id: str = ""
# duplicated=true 时填充已存在素材的公网 URL,前端可直接用而不必再调 complete
url: str = Field(default="", description="duplicated=true 时已存在素材的公网 URL")
class DirectUploadCompleteRequest(BaseModel):
-203
View File
@@ -1,203 +0,0 @@
"""爆款视频 API schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
# -- 枚举常量 --
VALID_FUSION_LEVELS = ("ai_full", "full_ai", "ai_polish", "user_primary")
VALID_STYLE_STRENGTHS = ("light", "medium", "strict")
VALID_STAGES = (
"image_analysis",
"video_analysis",
"intent_parsing",
"copy_fusion",
"storyboard",
"review",
"tts",
"bgm_select",
"rendering",
"musetalk",
"uploading",
)
# -- Request Schemas --
class CreateViralVideoRequest(BaseModel):
"""创建爆款视频任务请求(旧接口:一键跑完前半段到 WAIT_USER_CONFIRM,保留兼容)。"""
images: list[str] = Field(..., min_length=1, max_length=20, description="产品图片 URL 列表")
industry: str = Field(default="", description="行业")
target_customer: str = Field(default="", description="目标客户描述")
persona_id: str = Field(default="", description="人设 ID")
viral_structure: str = Field(default="", description="爆款结构类型")
marketing_purpose: str = Field(default="", description="营销目的")
bgm_preference: str = Field(default="", description="BGM 偏好")
duration: int = Field(default=30, ge=5, le=180, description="视频时长(秒)")
user_copy_text: str = Field(default="", description="用户原始文案(我说你写)")
fusion_level: str = Field(default="ai_polish", description="文案融合级别: ai_full/ai_polish/user_primary")
reference_audio_path: str = Field(default="", description="参考音频路径")
reference_video_url: str = Field(default="", description="参考爆款视频 URL")
style_strength: str = Field(default="medium", description="风格强度: light/medium/strict")
style_template_id: str = Field(default="", description="风格模板 ID")
@field_validator("fusion_level")
@classmethod
def _validate_fusion_level(cls, v: str) -> str:
if v == "full_ai":
return "ai_full"
if v not in VALID_FUSION_LEVELS:
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
return v
@field_validator("style_strength")
@classmethod
def _validate_style_strength(cls, v: str) -> str:
if v not in VALID_STYLE_STRENGTHS:
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
return v
class AnalyzeImagesRequest(BaseModel):
"""v1.5 阶段1:创建任务并仅做图片/视频分析。images 必填,其他参数可选(阶段2再传)。"""
images: list[str] = Field(..., min_length=1, max_length=20)
reference_video_url: str = Field(default="", description="参考爆款视频 URL(可选,有则同步做风格分析)")
style_template_id: str = Field(default="", description="风格模板 ID(可选)")
style_strength: str = Field(default="medium")
class GenerateCopyRequest(BaseModel):
"""v1.5 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 COPY_GENERATED。"""
industry: str = Field(default="")
target_customer: str = Field(default="")
persona_id: str = Field(default="")
viral_structure: str = Field(default="")
marketing_purpose: str = Field(default="")
bgm_preference: str = Field(default="")
duration: int = Field(default=30, ge=5, le=180)
user_copy_text: str = Field(default="")
fusion_level: str = Field(default="ai_polish")
reference_audio_path: str = Field(default="")
reference_video_url: str = Field(default="")
style_strength: str = Field(default="medium")
style_template_id: str = Field(default="")
@field_validator("fusion_level")
@classmethod
def _v_fl(cls, v: str) -> str:
if v == "full_ai":
return "ai_full"
if v not in VALID_FUSION_LEVELS:
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
return v
@field_validator("style_strength")
@classmethod
def _v_ss(cls, v: str) -> str:
if v not in VALID_STYLE_STRENGTHS:
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
return v
class ConfirmCopyRequest(BaseModel):
"""v1.5 阶段3:用户确认/编辑文案后开始渲染。"""
edited_copy: str = Field(default="", description="用户编辑后的最终文案;为空则使用 AI 生成文案")
class ConfirmIntentRequest(BaseModel):
"""确认意图请求(旧 confirm-intent,兼容)。"""
confirmed_copy: str = Field(default="", description="用户确认/修改后的文案")
adjustments: str = Field(default="", description="用户对 AI 文案的调整意见")
class AnalyzeStyleRequest(BaseModel):
"""触发参考视频风格分析请求。"""
reference_video_url: str = Field(..., description="参考视频 URL")
style_template_id: str = Field(default="", description="风格模板 ID(可选覆盖)")
# -- Response Schemas --
class ViralVideoJobResponse(BaseModel):
"""爆款视频任务响应。v1.5 新增 image_analysis/storyboard/generated_copy_text 字段。"""
id: str
user_id: str
images: list[str] = Field(default_factory=list)
industry: str = ""
target_customer: str = ""
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = 30
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
reference_video_url: str = ""
style_strength: str = "medium"
style_guide: dict | None = None
style_template_id: str = ""
status: str
# v1.4 VLM 结果
image_analysis: dict | None = None
# v1.5 三步分步产物(原始字段,保留给后端/老调用方)
storyboard: list | None = None
generated_copy_text: str = ""
# v1.5 前端 CopyResult 结构(final_copy/suggested_copy/title/scenes)
copy_result: dict | None = None
intent_result: dict | None = None
result_video_url: str = ""
credits_cost: int = 0
error_msg: str = ""
retry_count: int = 0
started_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class ViralVideoHistoryResponse(BaseModel):
items: list[ViralVideoJobResponse]
total: int
class StyleTemplateResponse(BaseModel):
id: str
name: str
description: str = ""
thumbnail_url: str = ""
style_config: dict = Field(default_factory=dict)
class StyleTemplateListResponse(BaseModel):
items: list[StyleTemplateResponse]
class AnalyzeStyleResponse(BaseModel):
job_id: str
status: str
style_guide: dict | None = None
# -- WebSocket 事件 Schema --
class WSProgressEvent(BaseModel):
type: str = "viral_video:progress"
job_id: str
stage: str
progress: float = Field(ge=0.0, le=100.0)
message: str = ""
data: dict = Field(default_factory=dict)
+13 -22
View File
@@ -999,35 +999,26 @@ class EditPlanService:
source_bgm_config: dict = {}
source_plan = self.get_plan(source_plan_id)
# #2034:读取源 plan 的 dedup_enabled 决定变体是否注入视觉/像素扰动
# 默认 True;关了则保留节奏模板+BGM差异化,但跳过 visual/pixel 扰动
_dedup_enabled = True
if source_plan and source_plan.config:
source_bgm_config = source_plan.config.get("bgm", {}) or {}
_dedup_enabled = bool(source_plan.config.get("dedup_enabled", True))
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in range(count)]
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
def _build_variant_config_update(idx: int) -> dict:
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。
#2034:dedup_enabled=False 时跳过 visual_perturbation/pixel_perturbation,
保留 rhythm_template 和 BGM 池分配(合理的多变体差异,不属于降重扰动)。
"""
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。"""
upd: dict = {}
if _dedup_enabled:
try:
perturbation = generate_visual_perturbation(rng)
if idx == 0:
perturbation["hflip"] = False
upd["visual_perturbation"] = perturbation
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
try:
pixel_pert = generate_pixel_perturbation(rng)
upd["pixel_perturbation"] = pixel_pert
except Exception:
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
try:
perturbation = generate_visual_perturbation(rng)
if idx == 0:
perturbation["hflip"] = False
upd["visual_perturbation"] = perturbation
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
try:
pixel_pert = generate_pixel_perturbation(rng)
upd["pixel_perturbation"] = pixel_pert
except Exception:
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None
if rt is not None:
upd["rhythm_template"] = rt
@@ -1,142 +0,0 @@
"""视频生成任务 finalize 服务(#2024)。
Worker 渲染+上传完成后不再自动入库,标记为 awaiting_cover;用户在 Step5 选好封面
点「完成」时由 API 调用本服务:创建 GeneratedVideo 成品库记录(复用 worker 预计算
的查重结果)、绑定封面、推进任务到 completed。
与 AI 数字人 ``ai_avatar_render_service.finalize_job`` 模式一致,
只是走 GenerationTask 而非 AiAvatarRenderJob。
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
class GenerationFinalizeError(Exception):
"""finalize 业务错误,code 供 API 层映射 HTTP 状态码。"""
def __init__(self, message: str, code: str = "FinalizeError", status_code: int = 400):
super().__init__(message)
self.code = code
self.status_code = status_code
class GenerationFinalizeService:
def __init__(self, db: Session):
self.db = db
def finalize_task(
self,
task_id: str,
user_id: str,
cover_url: Optional[str] = None,
custom_title: Optional[str] = None,
):
"""执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。
Returns:
GeneratedVideo 领域对象
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
from packages.application.generated_video_finalize import finalize_generated_video
task_repo = SQLAlchemyGenerationTaskRepository(self.db)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
task = task_repo.get(task_id)
if task is None:
raise GenerationFinalizeError(f"任务 {task_id} 不存在", "TaskNotFound", 404)
# ── 幂等:已入库直接返回 ─────────────────────────────────
existing = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == task_id).first()
if existing is not None:
logger.info("[finalize] 幂等命中 task=%s video=%s", task_id, existing.id)
_changed = False
if cover_url and cover_url.strip() and existing.thumbnail_url != cover_url.strip():
existing.thumbnail_url = cover_url.strip()
task.cover_url = cover_url.strip()
_changed = True
if custom_title and custom_title.strip() and (getattr(existing, "name", "") or "") != custom_title.strip():
existing.name = custom_title.strip()
_changed = True
if _changed:
self.db.commit()
if task.status.value != "completed":
try:
task.mark_completed(result_count=1)
if cover_url and cover_url.strip():
task.cover_url = cover_url.strip()
task_repo.update(task)
self.db.commit()
except Exception as e:
logger.warning("[finalize] 幂等补 mark_completed 失败: %s", e)
self.db.rollback()
return video_repo.get(existing.id)
# ── 状态校验 ─────────────────────────────────────────────
if task.status.value != "awaiting_cover":
raise GenerationFinalizeError(
f"任务当前状态 {task.status.value},无法 finalize(需 awaiting_cover)",
"InvalidTaskStatus",
400,
)
# ── 封面 ─────────────────────────────────────────────────
effective_cover = (cover_url or "").strip() if cover_url else (task.cover_url or "").strip()
# ── 入库+查重(复用 worker 预计算结果) ──────────────────
try:
result = finalize_generated_video(
task=task,
session=self.db,
effective_cover_url=effective_cover,
custom_name=custom_title,
)
except ValueError as e:
raise GenerationFinalizeError(str(e), "RenderedOutputMissing", 400) from e
video_id = result["video_id"]
# 应用自定义标题
if custom_title and custom_title.strip():
try:
_v = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
if _v is not None:
_v.name = custom_title.strip()
self.db.flush()
except Exception:
logger.warning("[finalize] 更新标题失败: video_id=%s", video_id, exc_info=True)
# ── 推进任务 ─────────────────────────────────────────────
task.mark_completed(result_count=1)
task.cover_url = effective_cover
# 清理 rendered_output(体积较大,入库后不再需要)
meta = dict(task.extra_meta or {})
meta.pop("rendered_output", None)
task.extra_meta = meta
task.updated_at = datetime.now(UTC)
task_repo.update(task)
self.db.commit()
video = video_repo.get(video_id)
logger.info(
"[finalize] task=%s finalized -> video=%s cover=%s dup=%s",
task_id,
video_id,
bool(effective_cover),
result.get("is_duplicate", False),
)
return video
+20 -16
View File
@@ -160,14 +160,12 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
)
await page.goto("/app/generate")
// ── 页面标题 ─────────────────────────────────────────────────
// GenerateHeader: <h2><ThunderboltOutlined />智能剪辑</h2>
// SVG icon 可能干扰 role=heading 的 accessible name,用文本包含兜底
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
})
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
// h3 实际文案: "🎬 选择剪辑模式"(非 "选择模式"),用正则包含匹配
await expect(page.getByText(/选择剪辑模式/)).toBeVisible()
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await expect(page.getByText("随机混剪")).toBeVisible()
await page.getByRole("button", { name: /下一步/ }).click()
@@ -182,8 +180,11 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗:默认 1 个 → 确认 ───────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题 ──────────────────────────────────────────
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput).toBeVisible({ timeout: 5000 })
@@ -191,10 +192,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
// (#2024: Step4 不再显示"📋 生成配置"卡片,内容区仅显示进度/错误)
// 等待底部操作栏的「✨ 确认生成视频」按钮可见即可
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("随机混剪")).toBeVisible()
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn).toBeVisible({ timeout: 10000 })
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
const createTask = page.waitForResponse(
@@ -324,11 +324,12 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
)
await page.goto("/app/generate")
// ── 页面标题 ─────────────────────────────────────────────────
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
timeout: 30000,
})
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
await expect(page.getByText(/选择剪辑模式/)).toBeVisible()
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
await page.getByText("叙事剪辑").click()
await page.getByRole("button", { name: /下一步/ }).click()
@@ -350,8 +351,11 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── 数量弹窗 ─────────────────────────────────────────────────
await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 })
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
// (#2048: PreviewCountModal 已移除)
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
@@ -359,9 +363,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
// (#2024: Step4 不再显示"📋 生成配置"卡片)
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
await expect(page.getByText("叙事剪辑")).toBeVisible()
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
await expect(confirmBtn2).toBeVisible({ timeout: 10000 })
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
const createTask2 = page.waitForResponse(
+1 -1
View File
@@ -161,7 +161,7 @@ test.describe("Core media upload flow", () => {
const asset = data.items.find((item) => item.name === "e2e-sample.mp4")
return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing"
},
{ timeout: 90_000, intervals: [3_000, 5_000, 10_000] },
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
)
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/)
-7
View File
@@ -4,13 +4,6 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 标题字体(#2001 / #font-selection 修复):Google Fonts CDN 引入中文字体,保证优设标题黑/抖音美好体/阿里普惠体等fallback可用 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&family=ZCOOL+QingKe+HuangYou&family=Ma+Shan+Zheng&family=Long+Cang&family=Liu+Jian+Mao+Cao&family=Zhi+Mang+Xing&display=swap"
rel="stylesheet"
/>
<title>小虾 SaaS - 自动化视频剪辑平台</title>
</head>
<body>
-2
View File
@@ -150,8 +150,6 @@ export interface DirectUploadPrepareResult {
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
*/
skip_transfer?: boolean
/** duplicated=true 时后端返回已存在素材的公网 URL,前端直接用而不必再调 complete */
url?: string
}
/** 直传完成确认返回 */
+6 -47
View File
@@ -3,24 +3,9 @@
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import { ensureDefaultLibrary } from "./libraries"
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
import { computeFileHash, makeClientUploadId } from "./uploadDedup"
/** 根据 File.type 推断素材库 kind(image/video/voice);无法推断时默认 image */
function inferKindFromFile(file: File): "image" | "video" | "voice" {
const t = (file.type || "").toLowerCase()
if (t.startsWith("image/")) return "image"
if (t.startsWith("video/")) return "video"
if (t.startsWith("audio/")) return "voice"
// 兜底:按扩展名再判一次
const name = file.name.toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg|avif)$/.test(name)) return "image"
if (/\.(mp4|mov|webm|avi|mkv|flv|wmv|m4v)$/.test(name)) return "video"
if (/\.(mp3|wav|m4a|aac|ogg|flac|opus|webm)$/.test(name)) return "voice"
return "image"
}
/** 预签名直传准备 */
export const prepareDirectUpload = async (data: {
project_id: string
@@ -123,8 +108,6 @@ const putToOSS = (
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */
export interface DirectUploadHandle {
/** 实际使用的素材库(内部解析出来,便于调用方做后续 UI/缓存操作) */
library: { id: string; kind: "image" | "video" | "voice" }
/** prepare 返回(含可能的预建 asset_id) */
prepared: DirectUploadPrepareResult
/** 直传 OSS(可重复调用用于重试) */
@@ -136,17 +119,10 @@ export interface DirectUploadHandle {
/**
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
*
* 修复 P0 404:library_id 改为可选;未传时自动根据文件类型在默认项目下确保对应素材库存在,
* 避免调用方从「全部素材库列表」里挑一个 library_id、但与默认项目 project_id 不匹配,
* 导致后端返回 "Asset library not found" 404。
*/
export const prepareDirectUploadHandle = async (data: {
file: File
/** 素材库 ID;未传时按文件类型自动在默认项目下 ensure-default */
library_id?: string
/** 显式指定素材库 kind;未传时按 MIME/扩展名推断 */
kind?: "image" | "video" | "voice"
library_id: string
/** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */
fileHash?: string
/** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */
@@ -162,17 +138,9 @@ export const prepareDirectUploadHandle = async (data: {
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
}
// 解析 library_id:调用方传了就用,没传就按 kind 自动 ensure-default
let resolvedLibraryId = data.library_id
const resolvedKind = data.kind ?? inferKindFromFile(data.file)
if (!resolvedLibraryId) {
const lib = await ensureDefaultLibrary({ project_id: project.id, kind: resolvedKind })
resolvedLibraryId = lib.id
}
const prepared = await prepareDirectUpload({
project_id: project.id,
library_id: resolvedLibraryId,
library_id: data.library_id,
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
@@ -181,13 +149,12 @@ export const prepareDirectUploadHandle = async (data: {
})
return {
library: { id: resolvedLibraryId, kind: resolvedKind },
prepared,
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
complete: () =>
completeDirectUpload({
project_id: project.id,
library_id: resolvedLibraryId,
library_id: data.library_id,
storage_key: prepared.storage_key,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
@@ -197,17 +164,10 @@ export const prepareDirectUploadHandle = async (data: {
}
}
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete
*
* P0 404 修复:library_id 可选;不传时内部按文件类型自动匹配正确项目下的素材库,
* 保证 project_id 与 library_id 必然一致。
*/
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
export const uploadAssetDirect = async (data: {
file: File
/** 素材库 ID;可选,不传按文件类型自动解析默认项目下的对应素材库(推荐用法) */
library_id?: string
/** 显式指定素材库 kind;未传时按文件 MIME/扩展名推断 */
kind?: "image" | "video" | "voice"
library_id: string
onProgress?: (percent: number) => void
/** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */
fileHash?: string
@@ -220,7 +180,6 @@ export const uploadAssetDirect = async (data: {
const handle = await prepareDirectUploadHandle({
file: data.file,
library_id: data.library_id,
kind: data.kind,
fileHash,
clientUploadId,
})
@@ -229,7 +188,7 @@ export const uploadAssetDirect = async (data: {
return {
storage_key: handle.prepared.storage_key,
ingest_job_id: "",
url: handle.prepared.url || "",
url: "",
duplicated: true,
asset_id: handle.prepared.asset_id,
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh
// 创建 Axios 实例
const apiClient = axios.create({
baseURL: "/api/v1",
timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
+9 -2
View File
@@ -3,7 +3,7 @@
* 后端路由: /api/v1/cover-templates
*/
import apiClient from "./client"
import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover"
import type { CoverTemplate } from "@/pages/generate/types/cover"
export interface CoverTemplateListResponse {
items: CoverTemplate[]
@@ -12,7 +12,14 @@ export interface CoverTemplateListResponse {
export interface CoverTemplateCreateRequest {
name: string
config?: CoverEditorConfig
config?: {
background_enabled?: boolean
background_color?: string
portrait_enabled?: boolean
title_text?: string
subtitle_text?: string
mask_enabled?: boolean
}
}
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
+2 -8
View File
@@ -51,18 +51,12 @@ export interface GenerateCoverResponse {
/** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */
export async function generateCover(
templateId: string | undefined | null,
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
// templateId 为空时不传该参数,让后端使用默认模板配置
// (前端此前用 "default" 作为占位符,该 id 不存在于后端模板库会 404)
const params: Record<string, string> = {}
if (templateId && templateId !== "default") {
params.template_id = templateId
}
const response = await apiClient.post<GenerateCoverResponse>("/generation/generate-cover", data, {
timeout: 300000,
params,
params: { template_id: templateId },
})
return response.data
}
-37
View File
@@ -1,37 +0,0 @@
import apiClient from "../client"
/** #2024 Step5 「完成」入库 —— 将 awaiting_cover 任务正式写入成品库 */
export interface FinalizeGenerationRequest {
/** 用户选定的封面图片 URL;为空则使用任务默认封面(自动截帧/智能封面) */
cover_url?: string
/** 用户自定义成片标题,非空时覆盖 rendered_output.name */
custom_title?: string
}
export interface FinalizeGenerationResponse {
video_id: string
project_id: string
name: string
file_size: number
duration: number
thumbnail_url: string
cover_url: string
file_url: string
width: number
height: number
fps: number
/** success=新建成功;already_finalized=幂等返回已有记录 */
status: string
is_duplicate: boolean
}
export const finalizeGeneration = async (
taskId: string,
params: FinalizeGenerationRequest = {},
): Promise<FinalizeGenerationResponse> => {
const response = await apiClient.post<FinalizeGenerationResponse>(
`/generation/tasks/${taskId}/finalize`,
params,
)
return response.data
}
+1 -2
View File
@@ -3,8 +3,7 @@
*/
/** 任务状态 */
export type TaskStatus =
"pending" | "waiting" | "running" | "awaiting_cover" | "completed" | "failed" | "cancelled"
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
/** 任务类型 */
export type TaskType = "ingest" | "generation" | string
@@ -6,20 +6,17 @@ import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`, { timeout: 30_000 })
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消;超时 60s 防止大 config 写入失败) */
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
signal?: AbortSignal,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data, {
signal,
timeout: 60_000,
})
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
return response.data
}
-131
View File
@@ -1,131 +0,0 @@
import apiClient from "@/api/client"
import type {
GenerateViralVideoRequest,
HistoryResponse,
StyleTemplate,
ViralVideoJob,
ImageAnalysisResult,
CopyResult,
AnalyzeImagesRequest,
GenerateCopyRequest,
ConfirmCopyRequest,
} from "./types"
/** 创建爆款视频任务 */
export function generateViralVideo(payload: GenerateViralVideoRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/generate", payload).then((r) => r.data)
}
/** 查询单个任务 */
export function getViralVideoJob(id: string) {
return apiClient.get<ViralVideoJob>(`/viral-video/${id}`).then((r) => r.data)
}
/** 用户确认/修改 AI 理解的意图后继续 */
export function confirmViralVideoIntent(
id: string,
payload: { confirmed_copy?: string; edits?: Record<string, unknown> },
) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-intent`, payload)
.then((r) => r.data)
}
/** 重试失败任务 */
export function retryViralVideo(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/retry`).then((r) => r.data)
}
/** 历史记录(分页) */
export function getViralVideoHistory(params?: { page?: number; page_size?: number }) {
return apiClient.get<HistoryResponse>("/viral-video/history", { params }).then((r) => r.data)
}
/** 预设风格模板 */
export function getViralStyleTemplates() {
return apiClient.get<StyleTemplate[]>("/viral-video/style-templates").then((r) => r.data)
}
/** 上传参考视频后触发风格分析 */
export function analyzeViralStyle(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/analyze-style`).then((r) => r.data)
}
/** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */
/**
* 客户端图片分析 mock(后端未提供 analyze-only 端点前的占位方案):
* 基于已上传图片生成一份示例识别汇览,让 STEP1→STEP2 交互可走通。
* 后端上线后改为调用真实接口。
*/
export function mockImageAnalysis(images: { name: string }[]): Promise<ImageAnalysisResult> {
return new Promise((resolve) => {
setTimeout(() => {
const products = images.slice(0, 3).map((img, i) => {
const n = img.name.replace(/\.[^.]+$/, "")
return {
name: n || `商品 ${i + 1}`,
spec: i === 0 ? "500ml/瓶" : i === 1 ? "300g/盒" : undefined,
brand: i === 0 ? "示例品牌" : undefined,
features:
i === 0
? "瓶身透明、蓝色标签、白色瓶盖;标签上印有品牌Logo和产品名称;光线均匀,主体居中"
: i === 1
? "盒装包装、主色调为米白+暖黄;正面有产品实物图;文字清晰可辨"
: "产品主体清晰、背景干净、色彩鲜艳,突出核心卖点",
label_text: i === 0 ? "包装正面印有产品名称、净含量、品牌Logo" : undefined,
image_index: i,
}
})
resolve({ products })
}, 1800)
})
}
/**
* 客户端文案生成 mock(后端未提供 generate-copy 端点前的占位方案):
* 后端上线后改为调用真实接口。
*/
export function mockGenerateCopy(params: {
product: string
sellingPoints?: string[]
tone?: string
duration?: number
marketingPurpose?: string
industry?: string
targetCustomer?: string
}): Promise<CopyResult> {
return new Promise((resolve) => {
setTimeout(() => {
const product = params.product || "这款产品"
const tone = params.tone || "亲切务实"
const purpose = params.marketingPurpose || "品牌种草"
resolve({
title: `【${purpose}】${product},用过的人都说好!`,
final_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`,
suggested_copy: `你有没有发现,选对一款${params.industry || "好物"}真的能让生活省心很多?\n\n今天给大家推荐这款${product}。${tone.includes("亲切") ? "说实话," : ""}我自己用了一段时间,最直观的感受就是——好用、省心、值得回购。\n\n✅ 亮点一:品质到位,用料扎实,细节处见用心\n✅ 亮点二:使用体验舒服,日常高频场景都能打\n✅ 亮点三:性价比很能打,这个价位真的没什么可挑的\n\n如果你也在找一款靠谱的${params.industry || "日常好物"},真的建议试试${product},不会让你失望。点击左下角,直接入手!`,
})
}, 2200)
})
}
/** ── 三步拆分 v1.5 真实后端 API(PR #2117 合入后启用,前端可替换 mock 调用) ── */
/** 阶段1:上传图片后仅做 VLM 图片分析 + 可选参考视频风格分析,完成后状态=image_analyzed */
export function analyzeViralImages(payload: AnalyzeImagesRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/analyze-images", payload).then((r) => r.data)
}
/** 阶段2:用户填完营销参数后生成文案+分镜+合规审核,完成后状态=copy_generated,返回 copy_result */
export function generateViralCopy(id: string, payload: GenerateCopyRequest) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/generate-copy`, payload)
.then((r) => r.data)
}
/** 阶段3:用户确认/编辑文案后开始 TTS→渲染→上传,完成后状态=completed */
export function confirmViralCopy(id: string, payload: ConfirmCopyRequest = {}) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-copy`, payload)
.then((r) => r.data)
}
-220
View File
@@ -1,220 +0,0 @@
export type FusionLevel = "ai_full" | "ai_polish" | "user_primary"
export const FUSION_LEVELS: { value: FusionLevel; label: string; desc: string }[] = [
{ value: "ai_full", label: "AI 全写", desc: "给我方向,全由AI创作" },
{ value: "ai_polish", label: "AI润色", desc: "我写草稿,AI帮我润色" },
{ value: "user_primary", label: "按我写的来", desc: "几乎不改我的文案" },
]
export type StyleStrength = "light" | "medium" | "strict"
export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [
{ value: "light", label: "轻度借鉴" },
{ value: "medium", label: "中度参考" },
{ value: "strict", label: "像素级复刻" },
]
export type ViralVideoStatus =
| "pending"
| "running"
| "wait_user_confirm"
| "image_analyzed"
| "copy_generated"
| "completed"
| "failed"
| "cancelled"
/**
* 后端流水线阶段字符串。前端不展示逐阶段进度列表,仅保留类型
* 用于轮询时判断当前在哪个大阶段(分析中 vs 文案生成 vs 视频生成)。
*/
export type ViralVideoStage =
| "image_analysis"
| "video_analysis"
| "intent_parsing"
| "copy_fusion"
| "storyboard"
| "review"
| "tts"
| "bgm_select"
| "rendering"
| "musetalk"
| "uploading"
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
/** 文案相关阶段:属于「生成文案」按钮的范围 */
const COPY_STAGES = new Set<ViralVideoStage>([
"intent_parsing",
"copy_fusion",
"storyboard",
"review",
])
/** 视频相关阶段:属于「开始生成视频」按钮的范围 */
const VIDEO_STAGES = new Set<ViralVideoStage>([
"tts",
"bgm_select",
"rendering",
"musetalk",
"uploading",
])
export function isImageAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && IMAGE_ANALYSIS_STAGES.has(stage)
}
export function isCopyStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && COPY_STAGES.has(stage)
}
export function isVideoStage(stage: ViralVideoStage | undefined): boolean {
return !!stage && VIDEO_STAGES.has(stage)
}
/** 兼容旧调用:旧的 isAnalysisStage 视为「图片分析+文案」的所有前置阶段 */
export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return isImageAnalysisStage(stage) || isCopyStage(stage)
}
/** 单张图片 VLM 识别出的商品信息 */
export interface ImageProductAnalysis {
name?: string
spec?: string
brand?: string
features?: string[] | string
label_text?: string
selling_points?: string[]
scene?: string
image_index?: number
}
export interface ImageAnalysisResult {
products?: ImageProductAnalysis[]
}
export interface CopyResult {
final_copy?: string
suggested_copy?: string
title?: string
scenes?: Array<{ shot: string; narration: string; duration?: number }>
}
export interface StyleTemplate {
id: string
name: string
description?: string
thumbnail_url?: string
style_config?: Record<string, unknown>
tags?: string[]
}
export interface IntentResult {
product: string
selling_points: string[]
target_audience: string
tone: string
structure: string
duration: number
suggested_title?: string
suggested_copy?: string
}
export interface ViralVideoJob {
id: string
status: ViralVideoStatus
images: string[]
reference_video_url?: string
style_strength?: StyleStrength
style_template_id?: string
style_guide?: string | Record<string, unknown>
user_copy_text?: string
final_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_mode?: "global" | "per_video"
voice_source?: "preset" | "library" | "clone" | "upload"
bgm_preference?: string
intent_result?: IntentResult
intent_text?: string
copy_result?: CopyResult
image_analysis?: ImageAnalysisResult
progress_stage?: ViralVideoStage
progress_percent?: number
progress_message?: string
output_url?: string
result_video_url?: string
error_message?: string
error_msg?: string
credits_cost?: number
created_at?: string
updated_at?: string
}
export interface GenerateViralVideoRequest {
images: string[]
reference_video_url?: string
douyin_url?: string
style_strength?: StyleStrength
style_template_id?: string
user_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_source?: "preset" | "library" | "clone" | "upload"
bgm_preference?: string
industry?: string
target_customer?: string
language?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
duration?: number
video_model?: string
video_ratio?: string
/** 三步拆分:step 控制后端执行到哪一步暂停 */
step?: "analyze" | "generate_copy" | "generate_video"
}
export interface HistoryResponse {
items: ViralVideoJob[]
total: number
page: number
page_size: number
}
/** v1.5 阶段1请求:仅做图片/视频分析(POST /viral-video/analyze-images) */
export interface AnalyzeImagesRequest {
images: string[]
reference_video_url?: string
style_template_id?: string
style_strength?: StyleStrength
}
/** v1.5 阶段2请求:填完营销参数后生成文案+分镜(POST /viral-video/{id}/generate-copy) */
export interface GenerateCopyRequest {
industry?: string
target_customer?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
bgm_preference?: string
duration?: number
user_copy_text?: string
fusion_level?: FusionLevel
reference_audio_path?: string
reference_video_url?: string
style_strength?: StyleStrength
style_template_id?: string
style_guide?: string | Record<string, unknown>
}
/** v1.5 阶段3请求:用户确认/编辑文案后开始渲染(POST /viral-video/{id}/confirm-copy) */
export interface ConfirmCopyRequest {
/** 用户编辑后的最终文案;为空则使用 AI 生成文案 */
edited_copy?: string
}
/** 分镜片段结构(后端 storyboard 字段的元素形态,保留供调试/进阶使用;主流程请使用 copy_result.scenes) */
export interface StoryboardSegment {
order: number
type: string
description: string
text: string
duration: number
ken_burns?: string
transition?: string
}
-563
View File
@@ -1,563 +0,0 @@
/**
* 共享封面编辑器样式(智能剪辑 generate + AI数字人 ai-avatar 共用)
* #2033:从 generate.css 抽取 xx-ce-* / xx-cover-template-* / xx-cover-modal-* 规则
*/
.xx-cover-modal-toolbar {
display: flex;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.xx-cover-template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.xx-cover-template-card {
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s;
}
.xx-cover-template-card:hover {
border-color: var(--primary-color);
}
.xx-cover-template-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.xx-cover-template-thumb {
aspect-ratio: 9/16;
background: linear-gradient(135deg, #f0f0f0, #e0e0e0);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: #ccc;
position: relative;
}
.xx-cover-template-info {
padding: 8px;
}
.xx-cover-template-name {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.xx-cover-template-badge {
font-size: 11px;
color: #7c3aed;
background: rgba(124, 58, 237, 0.1);
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
.xx-cover-template-date {
font-size: 11px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.xx-cover-template-actions {
display: flex;
gap: 4px;
}
.xx-ce-header {
margin-bottom: 16px;
}
.xx-ce-name-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-sm, 6px);
font-size: 14px;
margin-bottom: 12px;
outline: none;
}
.xx-ce-name-input:focus {
border-color: #7c3aed;
}
.xx-ce-header-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.xx-ce-layout {
display: flex;
gap: 20px;
min-height: 500px;
}
.xx-ce-left {
width: 300px;
flex-shrink: 0;
max-height: 70vh;
overflow-y: auto;
}
.xx-ce-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
min-height: 480px;
}
.xx-ce-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
}
.xx-ce-section-header {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4ff;
user-select: none;
}
.xx-ce-section-header:hover {
background: #e8edf8;
}
.xx-ce-section-body {
padding: 12px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.xx-ce-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-status-text {
font-size: 11px;
font-weight: 400;
color: #3b82f6;
}
.xx-ce-row {
margin: 12px 0;
}
.xx-ce-label {
display: block;
font-size: 12px;
color: #374151;
margin-bottom: 4px;
}
.xx-ce-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.xx-ce-sub-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.xx-ce-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-ce-switch-item {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.xx-ce-switch-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.xx-ce-color-picker {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-color-picker input[type="color"] {
width: 32px;
height: 24px;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
background: none;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.xx-ce-color-hex {
width: 70px;
padding: 2px 6px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
.xx-ce-position {
display: flex;
gap: 8px;
}
.xx-ce-position .ant-input-number {
flex: 1;
}
.xx-ce-radio-group {
display: flex;
gap: 0;
}
.xx-ce-radio-btn {
padding: 4px 14px;
font-size: 12px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
.xx-ce-radio-btn:first-child {
border-radius: 4px 0 0 4px;
}
.xx-ce-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
.xx-ce-radio-btn + .xx-ce-radio-btn {
border-left: none;
}
.xx-ce-radio-btn.active {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
border-left: 1px solid #d1d5db;
}
.xx-ce-font-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.xx-ce-font-dot--preset {
background: #10b981;
}
.xx-ce-font-dot--system {
background: #3b82f6;
}
.xx-ce-shadow-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.xx-ce-add-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-add-shadow-btn:hover {
background: #6d28d9;
}
.xx-ce-preset-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-text-bg-section {
margin-top: 8px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
.xx-ce-readonly-text {
padding: 6px 10px;
background: #eff6ff;
border-radius: 4px;
font-size: 13px;
color: #1e40af;
margin-bottom: 4px;
}
.xx-ce-file-row {
display: flex;
gap: 6px;
align-items: center;
}
.xx-ce-file-name {
flex: 1;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: #f9fafb;
color: #6b7280;
}
.xx-ce-file-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.xx-ce-file-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.xx-ce-canvas-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.xx-ce-canvas {
width: 225px;
height: 400px;
background: #ddd;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.xx-ce-anchor-dot {
position: absolute;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
z-index: 5;
}
.xx-ce-el-portrait {
position: absolute;
background: #a8d4f0;
border: 2px solid #333;
z-index: 2;
}
.xx-ce-handle {
position: absolute;
width: 8px;
height: 8px;
background: #3b82f6;
border: 1px solid #fff;
z-index: 10;
}
.xx-ce-handle--0 {
top: -4px;
left: -4px;
}
.xx-ce-handle--1 {
top: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--2 {
top: -4px;
right: -4px;
}
.xx-ce-handle--3 {
top: 50%;
right: -4px;
margin-top: -4px;
}
.xx-ce-handle--4 {
bottom: -4px;
right: -4px;
}
.xx-ce-handle--5 {
bottom: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--6 {
bottom: -4px;
left: -4px;
}
.xx-ce-handle--7 {
top: 50%;
left: -4px;
margin-top: -4px;
}
.xx-ce-el-bg {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
.xx-ce-el-mask {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
.xx-ce-text-bg {
position: absolute;
z-index: -1;
}
.xx-cover-template-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
background: #7c3aed;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
z-index: 2;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
.xx-cover-template-thumb {
position: relative;
}
.xx-ce-preview-tip {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: #6b7280;
}
.xx-ce-canvas {
background: #1a1a2e;
}
.xx-ce-section-body .ant-slider {
margin: 4px 0 8px;
}
.xx-ce-section-body .ant-slider-rail {
background: #e5e7eb;
}
.xx-ce-section-body .ant-slider-track {
background: #3b82f6;
}
.xx-ce-section-body .ant-slider-handle::after {
box-shadow: 0 0 0 2px #3b82f6;
}
.xx-ce-section-body .ant-slider-mark-text {
font-size: 10px;
}
.xx-ce-font-select-dropdown .ant-select-item-option-content {
display: flex;
align-items: center;
}
.xx-ce-canvas > div {
pointer-events: none;
}
/* Text panel wrapper */
.xx-ce-text-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Canvas base gradient layer (behind all elements) */
.xx-ce-canvas-base {
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%);
}
-2
View File
@@ -1,2 +0,0 @@
export { useSharedCover } from "./useSharedCover"
export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover"
@@ -1,315 +0,0 @@
/**
* 共享封面选择 Hook(供智能剪辑 generate 与 AI 数字人 ai-avatar 共同使用)
*
* 能力:
* - 封面模板列表加载 / 选择 / 创建 / 编辑 / 删除(调用 /cover-templates 接口)
* - 自动生成封面按钮点击 → 调用调用方传入的 generateFn
* - 封面编辑器弹窗状态
* - 本地封面上传文件选择
*/
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { message } from "antd"
import type { CoverTemplate } from "@/pages/generate/types/cover"
import {
fetchCoverTemplates,
createCoverTemplate,
updateCoverTemplate,
deleteCoverTemplate,
} from "@/api/cover-templates"
export interface UseSharedCoverOptions {
canGenerate: boolean
disabledHint?: string
generateFn: (templateId: string) => Promise<string | null | undefined>
initialTemplateId?: string
}
export interface UseSharedCoverReturn {
templates: CoverTemplate[]
templatesLoading: boolean
templatesError: string | null
selectedTemplateId: string
selectedTemplateName: string
handleSelectTemplate: (id: string) => void
reloadTemplates: () => void
showCoverSettings: boolean
setShowCoverSettings: (v: boolean) => void
showCoverEditor: boolean
setShowCoverEditor: (v: boolean) => void
editingTemplate: CoverTemplate | null
handleEditTemplate: (tpl: CoverTemplate) => void
handleCreateTemplate: () => void
handleSaveTemplate: (tpl: CoverTemplate) => Promise<void>
handleDeleteTemplate: (id: string) => Promise<void>
generating: boolean
generateAutoCover: () => Promise<void>
uploadInputRef: React.RefObject<HTMLInputElement>
handleUploadClick: () => void
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
setOnUploadFile: (fn: (file: File) => Promise<string | null> | string | null) => void
}
export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverReturn {
const { canGenerate, disabledHint, generateFn, initialTemplateId = "default" } = opts
const [generating, setGenerating] = useState(false)
const [showCoverSettings, setShowCoverSettings] = useState(false)
const [showCoverEditor, setShowCoverEditor] = useState(false)
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(initialTemplateId)
const [editingTemplate, setEditingTemplate] = useState<CoverTemplate | null>(null)
const [templates, setTemplates] = useState<CoverTemplate[]>([])
const [templatesLoading, setTemplatesLoading] = useState(false)
const [templatesError, setTemplatesError] = useState<string | null>(null)
const uploadInputRef = useRef<HTMLInputElement>(null)
const onUploadFileRef = useRef<
((file: File) => Promise<string | null> | string | null) | undefined
>(undefined)
const setOnUploadFile = useCallback(
(fn: (file: File) => Promise<string | null> | string | null) => {
onUploadFileRef.current = fn
},
[],
)
const reloadTemplates = useCallback(async () => {
setTemplatesLoading(true)
setTemplatesError(null)
try {
const res = await fetchCoverTemplates()
// 兼容两种响应:{items:[...]} 或直接数组
const rawList = (res as unknown as { items?: CoverTemplate[] }).items ?? []
// 确保每个模板都有 config 字段(避免编辑器打开时访问 cfg.title.text 崩溃)
const list: CoverTemplate[] = rawList.map((t) => ({
...t,
config: t.config,
}))
setTemplates(list)
// 若当前选中 "default"(初始占位),自动解析为第一个系统模板的真实 id
// ("default" 不是后端真实模板 id,传过去会 404)
setSelectedTemplateId((prev) => {
if (prev !== "default") return prev
const firstSys = list.find((t) => t.is_system)
return firstSys?.id || list[0]?.id || "default"
})
} catch (err) {
const axiosErr = err as {
response?: {
status?: number
data?: { detail?: string; message?: string; error?: { message?: string } }
}
message?: string
}
const status = axiosErr?.response?.status
const detail =
axiosErr?.response?.data?.detail ||
axiosErr?.response?.data?.message ||
axiosErr?.response?.data?.error?.message ||
axiosErr?.message
console.error("[SharedCover] 加载封面模板失败:", err, "status=", status, "detail=", detail)
if (status === 401) {
setTemplatesError("登录已过期,请刷新页面重新登录")
} else if (status === 403) {
setTemplatesError(detail ? "权限不足:" + detail : "无权限访问封面模板")
} else {
setTemplatesError("加载模板失败:" + (detail || "请稍后重试"))
}
} finally {
setTemplatesLoading(false)
}
}, [])
useEffect(() => {
// 挂载时拉一次模板列表,用于把 "default" 占位符解析成真实模板 id
void reloadTemplates()
}, [reloadTemplates])
useEffect(() => {
if (showCoverSettings) {
void reloadTemplates()
}
}, [showCoverSettings, reloadTemplates])
const handleSelectTemplate = useCallback((id: string) => {
setSelectedTemplateId(id)
}, [])
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
// 系统模板不可修改:复制为新模板草稿,走另存为流程
if (tpl.is_system) {
setEditingTemplate({
...tpl,
id: "",
name: tpl.name + " 副本",
is_system: false,
created_at: "",
})
} else {
setEditingTemplate(tpl)
}
setShowCoverEditor(true)
}, [])
const handleCreateTemplate = useCallback(() => {
setEditingTemplate(null)
setShowCoverEditor(true)
}, [])
const handleSaveTemplate = useCallback(
async (tpl: CoverTemplate) => {
try {
// 系统模板或无 id(新建/副本)→ 走创建分支;否则走更新
const isSystem = templates.find((t) => t.id === tpl.id)?.is_system === true
const shouldCreate = !tpl.id || isSystem
if (shouldCreate) {
const created = await createCoverTemplate({
name: tpl.name || "我的封面模板",
config: tpl.config,
})
setTemplates((prev) => [...prev, created])
setSelectedTemplateId(created.id || tpl.id)
} else {
const updated = await updateCoverTemplate(tpl.id, { name: tpl.name, config: tpl.config })
setTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
}
setShowCoverEditor(false)
setEditingTemplate(null)
} catch (err) {
const axiosErr = err as {
response?: {
status?: number
data?: { detail?: string; message?: string; error?: { message?: string } }
}
message?: string
}
const status = axiosErr?.response?.status
const detail =
axiosErr?.response?.data?.detail ||
axiosErr?.response?.data?.message ||
axiosErr?.response?.data?.error?.message ||
axiosErr?.message
console.error("[SharedCover] 保存模板失败:", err, "status=", status, "detail=", detail)
if (status === 403) {
message.error("保存失败(权限不足):" + (detail || "无权操作该模板"))
} else {
message.error("保存模板失败:" + (detail || "请稍后重试"))
}
}
},
[templates],
)
const handleDeleteTemplate = useCallback(
async (id: string) => {
try {
await deleteCoverTemplate(id)
setTemplates((prev) => prev.filter((t) => t.id !== id))
if (selectedTemplateId === id) {
// 删除后选中第一个系统模板作为兜底,避免 magic string "default" 传后端 404
setTemplates((prevAfter) => {
const firstSys = prevAfter.find((t) => t.is_system)
setSelectedTemplateId(firstSys?.id || prevAfter[0]?.id || "")
return prevAfter
})
}
} catch (err) {
const axiosErr = err as {
response?: {
status?: number
data?: { detail?: string; message?: string; error?: { message?: string } }
}
message?: string
}
const status = axiosErr?.response?.status
const detail =
axiosErr?.response?.data?.detail ||
axiosErr?.response?.data?.message ||
axiosErr?.response?.data?.error?.message ||
axiosErr?.message
console.error("[SharedCover] 删除模板失败:", err, "status=", status, "detail=", detail)
if (status === 403) {
message.error("删除失败(权限不足):" + (detail || "无权操作该模板"))
} else {
message.error("删除模板失败:" + (detail || "请稍后重试"))
}
}
},
[selectedTemplateId],
)
const generateAutoCover = useCallback(async () => {
if (generating) {
message.warning("封面正在生成中,请稍候…")
return
}
if (!canGenerate) {
if (disabledHint) message.warning(disabledHint)
return
}
setGenerating(true)
try {
const tplId = selectedTemplateId && selectedTemplateId !== "default" ? selectedTemplateId : ""
const url = await generateFn(tplId)
if (!url) {
message.warning("封面生成未返回图片,请重试")
}
} catch (err) {
console.error("[SharedCover] 自动生成封面失败:", err)
const anyErr = err as { __msgShown?: boolean; message?: string }
if (!anyErr?.__msgShown) {
message.error(anyErr?.message || "封面生成失败")
}
} finally {
setGenerating(false)
}
}, [generating, canGenerate, disabledHint, generateFn, selectedTemplateId])
const handleUploadClick = useCallback(() => {
uploadInputRef.current?.click()
}, [])
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (!file) return
if (onUploadFileRef.current) {
const ret = onUploadFileRef.current(file)
if (ret instanceof Promise) {
ret.catch((err) => {
console.error("[SharedCover] 上传封面失败:", err)
})
}
}
}, [])
const selectedTemplateName =
templates.find((t) => t.id === selectedTemplateId)?.name ||
(selectedTemplateId === "default" || !selectedTemplateId ? "默认模板" : "自定义")
return {
templates,
templatesLoading,
templatesError,
selectedTemplateId,
selectedTemplateName,
handleSelectTemplate,
reloadTemplates,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
setShowCoverEditor,
editingTemplate,
handleEditTemplate,
handleCreateTemplate,
handleSaveTemplate,
handleDeleteTemplate,
generating,
generateAutoCover,
uploadInputRef,
handleUploadClick,
handleFileInputChange,
setOnUploadFile,
}
}
export default useSharedCover
@@ -2,7 +2,6 @@
export const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/dashboard": "首页",
"/app/generate": "智能剪辑",
"/app/viral-video": "爆款视频",
"/app/assets": "视频库",
"/app/voices": "配音库",
"/app/products": "成片库",
@@ -1,271 +0,0 @@
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleStyleSettings } from "@/components/title/settings"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleStyleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
background?: string
/** 高度(可选,默认按 portrait 选比例) */
height?: number
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
transparent?: boolean
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
portrait?: boolean
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
transparent = false,
portrait = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
const text = (sampleText || "预览标题").trim() || "预览标题"
useEffect(() => {
let cancelled = false
const draw = () => {
if (cancelled) return
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景(transparent 时跳过,用于叠加在图片上)
if (!transparent) {
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
}
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致
const r = previewR
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
const botMargin = portrait ? r(24) : r(16)
startY = h - totalH - botMargin + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色,
// 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」
ctx.fillStyle = settings.color
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}
// 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用)
const previewScale = width / 360
const previewR = (v: number) => Math.round(v * previewScale)
const buildFontString = () => {
const size = previewR(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
return parts.join(" ")
}
// Web Font 加载保障:
// 1) 等 document.fonts.ready(CSS @font-face 首次可用)
// 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载
// 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块
const doDrawWhenReady = async () => {
try {
if (typeof document !== "undefined" && document.fonts) {
await document.fonts.ready
try {
await document.fonts.load(buildFontString(), text)
} catch {
/* ignore */
}
}
} finally {
if (!cancelled) draw()
}
}
doDrawWhenReady()
return () => {
cancelled = true
}
}, [settings, width, h, text, transparent, portrait, background])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background: transparent ? "transparent" : background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -1,458 +0,0 @@
/* ============================================================
TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦)
从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」
两个页面共用。AI数字人页面不引入 generate.css,直接由
TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、
配色描边、选中态与智能剪辑页面完全一致。
注意:本文件规则与 generate.css 中同名规则一一对应、取值相同;
智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。
============================================================ */
/* ── 区块容器 ── */
.xx-title-style-section {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid var(--border-light);
}
.xx-section-subtitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
.xx-title-style-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.xx-half-field {
margin-bottom: 0;
}
.xx-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.xx-field-label-row label {
margin-bottom: 0;
}
.xx-field-value {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
}
/* ── 共用表单字段(位置/字体下拉) ── */
.xx-title-style-section .xx-form-field {
margin-bottom: 14px;
}
.xx-title-style-section .xx-form-field:last-child {
margin-bottom: 0;
}
.xx-title-style-section .xx-form-field label {
display: block;
font-weight: 600;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select,
.xx-title-style-section .xx-form-field input {
width: 100%;
height: 44px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
padding: 0 14px;
font-size: 14px;
outline: 0;
transition: 0.15s ease;
color: var(--text-primary);
}
.xx-title-style-section .xx-form-field select:focus,
.xx-title-style-section .xx-form-field input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* ── 字号滑块 ── */
.xx-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.xx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
.xx-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
/* ── 标题预设卡片网格(24 个 T 格子) ── */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(6, 52px);
gap: 1px;
}
.xx-title-preset-card {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: #404040;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.xx-title-preset-card:hover {
border-color: #666;
background: #4d4d4d;
}
.xx-title-preset-card.active {
border-color: #409eff;
background: #4d4d4d;
}
.xx-title-preset-preview-text {
font-size: 32px;
line-height: 1;
user-select: none;
}
/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */
.xx-style-btns {
display: flex;
gap: 8px;
}
.xx-style-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
cursor: pointer;
font-size: 15px;
color: var(--text-secondary);
transition: all 0.15s;
}
.xx-style-btn:hover {
border-color: var(--primary-300);
color: var(--primary-color);
}
.xx-style-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ============================================================
#2001 爆款标题样式面板升级 — 新增样式(ts- 前缀)
============================================================ */
.ts-panel {
position: relative;
}
/* 预览 */
.ts-preview-wrap {
margin-bottom: 14px;
display: flex;
justify-content: center;
padding: 10px;
background: #0f172a;
border-radius: 8px;
}
/* 表单字段 */
.ts-form-field {
margin-bottom: 12px;
}
.ts-form-field label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-primary, #1f2937);
}
.ts-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ts-field-value {
font-size: 12px;
font-weight: 600;
color: var(--primary-color, #7c3aed);
}
.ts-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.ts-half {
margin-bottom: 0;
}
.ts-select {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
background: var(--bg-primary, #fff);
padding: 0 10px;
font-size: 13px;
outline: 0;
color: var(--text-primary, #1f2937);
}
.ts-select:focus {
border-color: var(--primary-color, #7c3aed);
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1);
}
.ts-input {
width: 100%;
height: 34px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
outline: 0;
}
.ts-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #e5e7eb;
border-radius: 2px;
outline: none;
}
.ts-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.ts-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #7c3aed;
cursor: pointer;
border: 2px solid #fff;
}
/* 样式按钮 B/I/S/☁ */
.ts-style-btns {
display: flex;
gap: 6px;
}
.ts-style-btn {
width: 34px;
height: 34px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: 0.15s;
color: #374151;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ts-style-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
.ts-style-btn.active {
background: #faf5ff;
color: #6d28d9;
border-color: #7c3aed;
font-weight: 700;
}
/* 色板 */
.ts-color-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ts-color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e5e7eb;
cursor: pointer;
padding: 0;
transition: 0.15s;
}
.ts-color-swatch:hover {
transform: scale(1.1);
}
.ts-color-swatch.active {
box-shadow: 0 0 0 2px #7c3aed;
transform: scale(1.1);
}
.ts-color-custom {
background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/8px 8px;
color: #666;
font-size: 14px;
line-height: 20px;
}
.ts-color-native {
width: 0;
height: 0;
border: 0;
padding: 0;
}
/* 预设网格 10个 - 5列 */
.ts-presets-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.ts-preset-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
padding: 4px;
cursor: pointer;
transition: 0.15s;
display: flex;
flex-direction: column;
gap: 4px;
}
.ts-preset-card:hover {
border-color: #7c3aed;
}
.ts-preset-card.active {
border-color: #7c3aed;
background: #faf5ff;
box-shadow: 0 0 0 1px #7c3aed;
}
.ts-preset-preview {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: #0f172a;
}
.ts-preset-preview canvas {
max-width: 100%;
max-height: 100%;
}
.ts-preset-meta {
display: flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #4b5563;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 2px 2px;
}
.ts-preset-emoji {
font-size: 11px;
}
.ts-preset-label {
overflow: hidden;
text-overflow: ellipsis;
}
.ts-toggle-row label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 10px;
}
.ts-toggle-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #7c3aed;
}
/* Tabs 紧凑样式 */
.xx-title-style-section .ant-tabs-nav {
margin-bottom: 10px;
}
.xx-title-style-section .ant-tabs-tab {
font-size: 12px !important;
padding: 6px 8px !important;
}
/* 标题模板入口按钮(#2003) */
.ts-template-btn {
border: none;
background: transparent;
color: var(--primary-color, #7c3aed);
font-size: 12px;
cursor: pointer;
padding: 2px 0;
font-weight: 500;
}
.ts-template-btn:hover {
text-decoration: underline;
}
@@ -1,445 +0,0 @@
/**
* 标题样式参数 Tab 面板(共享组件)
*
* 包含:基础/描边/阴影/背景/排版/封面 共 6 个 Tab
* 仅负责 UI 渲染和参数 patch 回调,不维护 state、不调 API
*/
import React, { useState } from "react"
import { Tabs } from "antd"
import type { TitleStyleSettings } from "./settings"
import {
FONT_OPTIONS,
TITLE_COLOR_PALETTE,
STROKE_COLOR_PALETTE,
BG_COLOR_PALETTE,
} from "./constants"
export interface PositionOption {
value: string
label: string
}
export interface FontOption {
value: string
label: string
family: string
tag?: "hot" | "new"
}
export interface TitleStyleParamsTabProps {
settings: TitleStyleSettings
onUpdatePosition: (p: string) => void
onUpdateFont: (f: string) => void
onUpdateSize: (v: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onUpdatePatch: (patch: Partial<TitleStyleSettings>) => void
positionOptions: PositionOption[]
fontOptions?: FontOption[]
/** 是否显示「封面」Tab(独立封面标题开关) */
showCoverToggle?: boolean
/** 封面独立标题开关状态 */
coverEnabled?: boolean
/** 封面开关变化 */
onToggleCover?: (enabled: boolean) => void
}
/* ── Slider 行 ── */
const SliderRow: React.FC<{
label: string
value: number
min: number
max: number
step?: number
unit?: string
onChange: (v: number) => void
}> = ({ label, value, min, max, step = 1, unit = "px", onChange }) => (
<div className="ts-form-field">
<div className="ts-field-label-row">
<label>{label}</label>
<span className="ts-field-value">
{value}
{unit}
</span>
</div>
<input
type="range"
className="ts-slider"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
)
/* ── 色板 ── */
const ColorPicker: React.FC<{
label?: string
value: string
palette: string[]
onChange: (c: string) => void
}> = ({ label, value, palette, onChange }) => {
const [customOpen, setCustomOpen] = useState(false)
return (
<div className="ts-form-field">
{label && <label>{label}</label>}
<div className="ts-color-row">
{palette.map((c) => (
<button
key={c}
type="button"
className={`ts-color-swatch${value.toLowerCase() === c.toLowerCase() ? " active" : ""}`}
style={{ background: c }}
onClick={() => onChange(c)}
title={c}
/>
))}
<button
type="button"
className="ts-color-swatch ts-color-custom"
onClick={() => setCustomOpen((v) => !v)}
title="自定义颜色"
>
+
</button>
<input
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => {
onChange(e.target.value)
setCustomOpen(false)
}}
style={{
opacity: customOpen ? 1 : 0,
position: customOpen ? "static" : "absolute",
pointerEvents: customOpen ? "auto" : "none",
width: customOpen ? 28 : 0,
height: customOpen ? 28 : 0,
border: "none",
padding: 0,
cursor: "pointer",
background: "transparent",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
当前:<code style={{ fontSize: 11 }}>{value}</code>
</div>
</div>
)
}
const TitleStyleParamsTab: React.FC<TitleStyleParamsTabProps> = ({
settings,
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onUpdatePatch,
positionOptions,
fontOptions = FONT_OPTIONS,
showCoverToggle = false,
coverEnabled = false,
onToggleCover,
}) => {
const upd = onUpdatePatch
return (
<Tabs
size="small"
defaultActiveKey="basic"
items={[
{
key: "basic",
label: "基础",
children: (
<>
<div className="ts-row-2">
<div className="ts-form-field ts-half">
<label>位置</label>
<select
className="ts-select"
value={settings.position}
onChange={(e) => onUpdatePosition(e.target.value)}
>
{positionOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="ts-form-field ts-half">
<label>字体</label>
<select
className="ts-select"
value={settings.font}
onChange={(e) => onUpdateFont(e.target.value)}
>
{fontOptions.map((f) => (
<option key={f.value} value={f.value}>
{f.tag === "hot" ? "🔥 " : f.tag === "new" ? "🆕 " : ""}
{f.label}
</option>
))}
</select>
</div>
</div>
<SliderRow
label="字号"
value={settings.size}
min={16}
max={120}
onChange={onUpdateSize}
/>
<div className="ts-form-field">
<label>样式</label>
<div className="ts-style-btns">
<button
type="button"
className={`ts-style-btn${settings.bold ? " active" : ""}`}
onClick={onToggleBold}
>
<b>B</b>
</button>
<button
type="button"
className={`ts-style-btn${settings.italic ? " active" : ""}`}
onClick={onToggleItalic}
>
<i>I</i>
</button>
<button
type="button"
className={`ts-style-btn${settings.stroke ? " active" : ""}`}
onClick={() => {
onToggleStroke()
if (!settings.stroke && (settings.strokeWidth ?? 0) < 2)
upd({ strokeWidth: 4 })
}}
title="描边"
>
S
</button>
<button
type="button"
className={`ts-style-btn${settings.shadow ? " active" : ""}`}
onClick={() => {
onToggleShadow()
if (!settings.shadow) {
upd({
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
})
}
}}
title="阴影"
>
☁
</button>
</div>
</div>
<ColorPicker
label="字色"
value={settings.color}
palette={TITLE_COLOR_PALETTE}
onChange={(c) => upd({ color: c })}
/>
</>
),
},
{
key: "stroke",
label: "描边",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.stroke} onChange={onToggleStroke} />
启用描边
</label>
</div>
{settings.stroke && (
<>
<SliderRow
label="描边宽度"
value={settings.strokeWidth ?? 4}
min={0}
max={20}
onChange={(v) => upd({ strokeWidth: v })}
/>
<ColorPicker
label="描边颜色"
value={settings.strokeColor ?? "#000000"}
palette={STROKE_COLOR_PALETTE}
onChange={(c) => upd({ strokeColor: c })}
/>
</>
)}
</>
),
},
{
key: "shadow",
label: "阴影",
children: (
<>
<div className="ts-toggle-row">
<label>
<input type="checkbox" checked={settings.shadow} onChange={onToggleShadow} />
启用阴影
</label>
</div>
{settings.shadow && (
<>
<SliderRow
label="X偏移"
value={settings.shadowOffsetX ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetX: v })}
/>
<SliderRow
label="Y偏移"
value={settings.shadowOffsetY ?? 2}
min={-20}
max={20}
onChange={(v) => upd({ shadowOffsetY: v })}
/>
<SliderRow
label="模糊半径"
value={settings.shadowBlur ?? 4}
min={0}
max={30}
onChange={(v) => upd({ shadowBlur: v })}
/>
<div className="ts-form-field">
<label>阴影颜色</label>
<input
type="text"
className="ts-input"
value={settings.shadowColor ?? "rgba(0,0,0,0.8)"}
onChange={(e) => upd({ shadowColor: e.target.value })}
placeholder="rgba(0,0,0,0.8)"
/>
</div>
</>
)}
</>
),
},
{
key: "bg",
label: "背景",
children: (
<>
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={settings.bgEnabled}
onChange={() => upd({ bgEnabled: !settings.bgEnabled })}
/>
启用背景色块
</label>
</div>
{settings.bgEnabled && (
<>
<ColorPicker
label="背景颜色(含透明度)"
value={settings.bgColor}
palette={BG_COLOR_PALETTE}
onChange={(c) => upd({ bgColor: c })}
/>
<SliderRow
label="内边距"
value={settings.bgPadding}
min={0}
max={40}
onChange={(v) => upd({ bgPadding: v })}
/>
<SliderRow
label="圆角"
value={settings.bgRadius}
min={0}
max={30}
onChange={(v) => upd({ bgRadius: v })}
/>
</>
)}
</>
),
},
{
key: "layout",
label: "排版",
children: (
<>
<SliderRow
label="每行最大字符数"
value={settings.maxCharsPerLine ?? 0}
min={0}
max={20}
unit=""
onChange={(v) => upd({ maxCharsPerLine: v })}
/>
<div
className="ts-form-field"
style={{ fontSize: 11, color: "#9ca3af", marginTop: -4 }}
>
0 = 不自动换行(按 / 手动分行)
</div>
<SliderRow
label="行距倍数"
value={Math.round((settings.lineHeight ?? 1.2) * 100) / 100}
min={1}
max={2}
step={0.05}
unit=""
onChange={(v) => upd({ lineHeight: Number(v.toFixed(2)) })}
/>
<SliderRow
label="顶部边距"
value={settings.marginTop ?? 24}
min={0}
max={200}
onChange={(v) => upd({ marginTop: v })}
/>
</>
),
},
...(showCoverToggle
? [
{
key: "cover",
label: "封面",
children: (
<div className="ts-toggle-row">
<label>
<input
type="checkbox"
checked={coverEnabled}
onChange={(e) => onToggleCover?.(e.target.checked)}
/>
封面使用独立标题样式
</label>
</div>
),
},
]
: []),
]}
/>
)
}
export default TitleStyleParamsTab
@@ -1,30 +1,29 @@
/**
* 标题模板编辑器(公共组件)
* 标题模板编辑器(v3 重构)
*
* - Modal 弹窗 860px 宽
* - 左侧:300px 竖屏预览区(图片背景+暗角+透明 Canvas 叠字)+ 模板名称输入
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStyleParamsTab
* - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式
* - 底部:取消 / 保存模板 按钮
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 onSave 的调用方处理)
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理)
*/
import React, { useEffect, useMemo, useState } from "react"
import { Modal, Button, Input, message } from "antd"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import TitleStylePanel from "../../pages/generate/components/title/TitleStylePanel"
import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview"
import { POSITION_OPTIONS } from "../../pages/generate/constants"
import { FONT_OPTIONS } from "./constants"
import type { TitleSettings } from "../../pages/generate/types"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleConfig } from "./types"
import { POSITION_OPTIONS } from "./position-options"
import { FONT_OPTIONS } from "./constants"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleStyleParamsTab from "./TitleStyleParamsTab"
import "./TitleTemplate.css"
import "./TitleStylePanel.css"
interface Props {
open: boolean
template: TitleTemplate
onClose: () => void
/** 用户点击保存:将编辑结果回调给父组件(父组件统一做 CRUD,避免双 hook 实例不同步) */
onSave: (data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => void
}
@@ -32,9 +31,10 @@ interface Props {
const EDITOR_BG = "/title-templates/portrait1.jpg"
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
const [settings, setSettings] = useState<TitleStyleSettings>(() => ({
...DEFAULT_TITLE_STYLE_SETTINGS,
const [settings, setSettings] = useState<TitleSettings>(() => ({
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
title: "预览标题文字",
}))
const [formName, setFormName] = useState(template.name || "")
const [formEmoji, setFormEmoji] = useState(template.emoji || "✨")
@@ -43,15 +43,16 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
useEffect(() => {
if (open) {
setSettings({
...DEFAULT_TITLE_STYLE_SETTINGS,
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
title: "预览标题文字",
})
setFormName(template.name || "")
setFormEmoji(template.emoji || "✨")
}
}, [open, template])
const upd = (patch: Partial<TitleStyleSettings>) => setSettings((s) => ({ ...s, ...patch }))
const upd = (patch: Partial<TitleSettings>) => setSettings((s) => ({ ...s, ...patch }))
const handleSave = () => {
const name = formName.trim()
@@ -68,11 +69,11 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
}
}
// 编辑器预览 settings:竖屏宽度 200px,字号按比例缩放
const previewSettings = useMemo<TitleStyleSettings>(
() => ({ ...settings, size: Math.round(settings.size * 0.55) }),
[settings],
)
// 编辑器内的预览用 settings:字号适配竖屏
const previewSettings = useMemo<TitleSettings>(() => {
// 竖屏宽度 200px,按比例缩放字号,让预览看起来协调
return { ...settings, size: Math.round(settings.size * 0.55) }
}, [settings])
return (
<Modal
@@ -139,7 +140,7 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
</div>
{/* 右侧:参数 Tab */}
<div className="ttv3-editor-right">
<TitleStyleParamsTab
<TitleStylePanel
settings={settings}
onUpdatePosition={(p) => upd({ position: p, posX: null, posY: null })}
onUpdateFont={(f) => upd({ font: f })}
@@ -154,9 +155,15 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
})
}
onToggleShadow={() => upd({ shadow: !settings.shadow })}
onUpdatePatch={upd}
positionOptions={POSITION_OPTIONS}
fontOptions={FONT_OPTIONS}
onApplyPreset={() => {
/* 编辑器内不使用系统预设快捷键 */
}}
onUpdateStyle={(patch) => upd(patch)}
activePreset={null}
titlePresets={[]}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
paramsOnly
/>
</div>
</div>
@@ -1,343 +0,0 @@
/**
* 标题模板选择器 — 大卡片网格(共享组件)
*
* 渲染「我的模板」+「系统模板」两个分组的 3:4 竖版大圆角卡片:
* - 卡片上半:示例背景图 + vignette 暗角 + 透明 Canvas 大字预览
* - 卡片下半:emoji + 名称 + 系统/我的标签 + 始终可见的编辑/复制/导出/删除按钮
* - 选中紫色边框;右上角「新建模板」按钮;点编辑/新建弹 TitleTemplateEditor
*
* Props 通用化,不耦合业务 state。
*/
import React, { useCallback, useMemo, useState } from "react"
import { Button, message, Popconfirm } from "antd"
import {
PlusOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
ExportOutlined,
CheckOutlined,
} from "@ant-design/icons"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import {
titleStyleConfigToCamel,
camelToTitleStyleConfig,
templateToPreviewSettings,
} from "./utils"
import { useTitleTemplates } from "./useTitleTemplates"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "./TitleTemplateEditor"
import "./TitleTemplate.css"
import "./TitleStylePanel.css"
export interface TitleTemplateSelectorProps {
/** 当前选中模板 id(受控) */
value?: string | null
/** 选中模板时回调(templateId, fullStyleSettings, template) */
onChange?: (templateId: string, style: TitleStyleSettings, template: TitleTemplate) => void
/** 是否显示编辑器入口(新建/编辑按钮),默认 true */
showEditor?: boolean
/** 显示哪些分组,默认全部 */
categories?: Array<"system" | "custom">
/** 使用场景标识(仅作 data-attr,不影响样式) */
context?: string
}
/* ── 卡片预览背景图池(按 index 轮换) ── */
const PREVIEW_BG_IMAGES = [
"/title-templates/portrait1.jpg",
"/title-templates/portrait2.jpg",
"/title-templates/scene1.jpg",
]
/* ── 预览容器:用 ref 测量宽度后再渲染透明 Canvas,保证文字清晰 ── */
const FillPreview: React.FC<{
settings: TitleStyleSettings
sampleText: string
portrait?: boolean
}> = ({ settings, sampleText, portrait }) => {
const [w, setW] = useState(0)
// 首次挂载后测量一次
const setRef = useCallback((el: HTMLDivElement | null) => {
if (el) setW(Math.floor(el.clientWidth))
}, [])
return (
<div ref={setRef} className="tt-fill-canvas-wrap">
{w > 0 && (
<TitleMiniPreview
settings={settings}
width={w}
sampleText={sampleText}
transparent
portrait={portrait}
/>
)}
</div>
)
}
const TitleTemplateSelector: React.FC<TitleTemplateSelectorProps> = ({
value,
onChange,
showEditor = true,
categories = ["system", "custom"],
context,
}) => {
const {
templates,
createTemplate,
duplicateTemplate,
updateTemplate,
deleteTemplate,
exportTemplate,
} = useTitleTemplates()
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const grouped = useMemo(
() => ({
builtin: templates.filter((t) => t.isBuiltin),
custom: templates.filter((t) => !t.isBuiltin),
}),
[templates],
)
const showSys = categories.includes("system")
const showMine = categories.includes("custom")
/* ── 选中模板:合成完整 TitleStyleSettings 回调给父组件 ── */
const handleSelectTemplate = useCallback(
(tpl: TitleTemplate) => {
const full: TitleStyleSettings = {
...DEFAULT_TITLE_STYLE_SETTINGS,
...titleStyleConfigToCamel(tpl.style),
}
onChange?.(tpl.id, full, tpl)
},
[onChange],
)
const handleRequestCreate = useCallback(() => {
// 新建:以当前选中模板样式为起点,否则用默认样式
let base: TitleStyleSettings = DEFAULT_TITLE_STYLE_SETTINGS
if (value) {
const sel = templates.find((t) => t.id === value)
if (sel) {
base = { ...DEFAULT_TITLE_STYLE_SETTINGS, ...titleStyleConfigToCamel(sel.style) }
}
}
const draft: TitleTemplate = {
id: "",
name: "我的标题模板",
emoji: "✨",
isBuiltin: false,
style: camelToTitleStyleConfig({
...base,
position: base.position === "custom" ? "bottom" : base.position,
}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setEditingTemplate(draft)
setEditorOpen(true)
}, [value, templates])
const handleRequestEdit = useCallback((tpl: TitleTemplate) => {
setEditingTemplate(tpl)
setEditorOpen(true)
}, [])
const handleDuplicate = useCallback(
(t: TitleTemplate) => {
const dup = duplicateTemplate(t.id)
if (dup) message.success(`已复制:${dup.name}`)
},
[duplicateTemplate],
)
const handleDelete = useCallback(
(t: TitleTemplate) => {
deleteTemplate(t.id)
message.success("已删除模板")
},
[deleteTemplate],
)
const handleExport = useCallback(
(t: TitleTemplate) => {
const json = exportTemplate(t.id)
if (!json) return
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${t.name}.title-template.json`
a.click()
URL.revokeObjectURL(url)
},
[exportTemplate],
)
const handleEditorSave = useCallback(
(data: { name: string; emoji: string; style: Partial<import("./types").TitleStyleConfig> }) => {
if (!editingTemplate) return
let saved: TitleTemplate
if (editingTemplate.isBuiltin || !editingTemplate.id) {
saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style })
} else {
updateTemplate(editingTemplate.id, {
name: data.name,
emoji: data.emoji,
style: data.style,
})
saved = {
...editingTemplate,
name: data.name,
emoji: data.emoji,
style: data.style,
updatedAt: new Date().toISOString(),
}
}
setEditorOpen(false)
setEditingTemplate(null)
message.success(`已保存:${data.name}`)
handleSelectTemplate(saved)
},
[editingTemplate, createTemplate, updateTemplate, handleSelectTemplate],
)
/* ── 渲染单张大卡片 ── */
const renderCard = (t: TitleTemplate, idx: number, section: "mine" | "sys") => {
const isSelected = value === t.id
const bgIdx = idx % PREVIEW_BG_IMAGES.length
const bgImg = PREVIEW_BG_IMAGES[bgIdx]
const preview = templateToPreviewSettings(t, 42)
return (
<div
key={t.id}
className={`ttv3-card${isSelected ? " selected" : ""}`}
onClick={() => handleSelectTemplate(t)}
data-context={context}
>
<div className="ttv3-preview">
<img className="ttv3-bg" src={bgImg} alt="" />
<div className="ttv3-vignette" />
<FillPreview settings={preview} sampleText="预览标题文字" portrait />
<span className={`ttv3-badge ttv3-badge--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
<span className={`ttv3-check${isSelected ? " on" : ""}`}>
{isSelected && <CheckOutlined />}
</span>
</div>
<div className="ttv3-footer">
<div className="ttv3-name-row">
<span className="ttv3-emoji">{t.emoji || "✨"}</span>
<span className="ttv3-name" title={t.name}>
{t.name}
</span>
<span className={`ttv3-tag ttv3-tag--${section}`}>
{section === "sys" ? "系统" : "我的"}
</span>
</div>
{showEditor && (
<div className="ttv3-actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="ttv3-act ttv3-act--primary"
disabled={t.isBuiltin}
onClick={() => handleRequestEdit(t)}
title={t.isBuiltin ? "系统模板不可编辑,点击复制后可编辑" : "编辑"}
>
<EditOutlined /> 编辑
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleDuplicate(t)}
title="复制"
>
<CopyOutlined /> 复制
</button>
<button
type="button"
className="ttv3-act"
onClick={() => handleExport(t)}
title="导出"
>
<ExportOutlined /> 导出
</button>
<Popconfirm title="删除该模板?" onConfirm={() => handleDelete(t)}>
<button
type="button"
className="ttv3-act ttv3-act--danger"
disabled={t.isBuiltin}
title={t.isBuiltin ? "系统模板不可删除" : "删除"}
>
<DeleteOutlined /> 删除
</button>
</Popconfirm>
</div>
)}
</div>
</div>
)
}
return (
<div className="xx-title-style-section ttv3-panel">
<div className="ttv3-header">
<span className="ttv3-title">标题模板</span>
{showEditor && (
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleRequestCreate}
className="ttv3-new-btn"
>
新建模板
</Button>
)}
</div>
{showMine && (
<div className="ttv3-section">
<div className="ttv3-section-label">我的模板</div>
{grouped.custom.length === 0 ? (
<div className="ttv3-empty">
<div className="ttv3-empty-icon">✨</div>
<div className="ttv3-empty-text">还没有自定义模板,点右上角「新建模板」创建</div>
</div>
) : (
<div className="ttv3-grid">
{grouped.custom.map((t, i) => renderCard(t, i, "mine"))}
</div>
)}
</div>
)}
{showSys && (
<div className="ttv3-section">
<div className="ttv3-section-label">系统模板</div>
<div className="ttv3-grid">{grouped.builtin.map((t, i) => renderCard(t, i, "sys"))}</div>
</div>
)}
{showEditor && editorOpen && editingTemplate && (
<TitleTemplateEditor
open={editorOpen}
template={editingTemplate}
onClose={() => {
setEditorOpen(false)
setEditingTemplate(null)
}}
onSave={handleEditorSave}
/>
)}
</div>
)
}
export default TitleTemplateSelector
+9 -39
View File
@@ -20,84 +20,54 @@ export const FONT_OPTIONS: FontOption[] = [
{
value: "优设标题黑",
label: "优设标题黑",
// 原版"优设标题黑"为商用字体非开源;优先本地已安装字体,兜底用 Noto Sans SC(Google Fonts 已加载 wght@900,保证 bold 字重可用),再用 ZCOOL 庆科黄油体作风格兜底
family:
'"YouSheBiaoTiHei","YouShe Title Black","Noto Sans SC","ZCOOL QingKe HuangYou","PingFang SC","Microsoft YaHei",sans-serif',
'"YouShe Title Black","YouSheBiaoTiHei","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "阿里普惠体Bold",
label: "阿里普惠体Bold",
// 阿里普惠体需从阿里官网下载;兜底用 Noto Sans SC 900(同等字重,已在 Google Fonts wght@400;500;700;900 加载)
family:
'"Alibaba PuHuiTi","Alibaba PuHuiTi Bold","Alibaba Sans","Noto Sans SC",system-ui,"PingFang SC","Microsoft YaHei",sans-serif',
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "抖音美好体",
label: "抖音美好体",
// 抖音美好体为版权字体;兜底用 Noto Sans SC(确保 bold 字重可用),再用 ZCOOL KuaiLe(站酷快乐体,圆润卡通风格近似)
family:
'"Douyin Sans","DouyinSans","Noto Sans SC","ZCOOL KuaiLe","PingFang SC","Microsoft YaHei",sans-serif',
family: '"Douyin Sans","DouyinSans","Source Han Sans SC","PingFang SC",sans-serif',
tag: "hot",
},
{
value: "思源黑体Heavy",
label: "思源黑体Heavy",
family:
'"Noto Sans SC","Source Han Sans SC","Source Han Sans CN Heavy","PingFang SC","Microsoft YaHei",sans-serif',
'"Source Han Sans SC Heavy","Noto Sans SC","Source Han Sans CN Heavy","PingFang SC",sans-serif',
tag: "new",
},
{
value: "思源黑体",
label: "思源黑体",
family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
family: '"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
},
{
value: "思源宋体",
label: "思源宋体",
family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif',
family: '"Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif',
},
{
value: "苹方",
label: "苹方",
family:
'"PingFang SC",-apple-system,blinkmacsystemfont,"Helvetica Neue","Noto Sans SC",sans-serif',
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
},
{
value: "微软雅黑",
label: "微软雅黑",
family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
family: '"Microsoft YaHei","PingFang SC",sans-serif',
},
{
value: "楷体",
label: "楷体",
family: '"KaiTi","STKaiti","DFKai-SB","Kaiti SC",serif',
},
{
value: "站酷小薇体",
label: "站酷小薇体",
family: '"ZCOOL XiaoWei","Noto Serif SC",serif',
},
{
value: "马善政毛笔",
label: "马善政毛笔",
family: '"Ma Shan Zheng","STXingkai","KaiTi",cursive',
},
{
value: "龙藏体",
label: "龙藏体",
family: '"Long Cang","STXingkai","KaiTi",cursive',
},
{
value: "流江毛笔草",
label: "流江毛笔草",
family: '"Liu Jian Mao Cao","STXingkai",cursive',
},
{
value: "志莽行书",
label: "志莽行书",
family: '"Zhi Mang Xing","STXingkai",cursive',
family: '"KaiTi","STKaiti","DFKai-SB",serif',
},
]
-19
View File
@@ -1,19 +0,0 @@
/**
* 公共标题模板/样式组件统一导出
*
* 任何页面需要标题样式配置/模板选择/模板编辑,从这里 import,
* 不要直接 import pages/generate/components/title/* 下的内部组件。
*/
export { default as TitleTemplateSelector } from "./TitleTemplateSelector"
export { default as TitleTemplateEditor } from "./TitleTemplateEditor"
export { default as TitleStyleParamsTab } from "./TitleStyleParamsTab"
export { default as TitleMiniPreview } from "./TitleMiniPreview"
export { useTitleTemplates } from "./useTitleTemplates"
export * from "./constants"
export * from "./types"
export * from "./template-types"
export * from "./settings"
export * from "./utils"
export { POSITION_OPTIONS } from "./position-options"
export type { PositionOption, FontOption, TitleStyleParamsTabProps } from "./TitleStyleParamsTab"
export type { TitleTemplateSelectorProps } from "./TitleTemplateSelector"
@@ -1,14 +0,0 @@
/**
* 标题位置选项(公共常量)
*/
export interface PositionOption {
value: string
label: string
}
export const POSITION_OPTIONS: PositionOption[] = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
-64
View File
@@ -1,64 +0,0 @@
/**
* 标题样式设置 — 公共 camelCase 类型与默认值
*
* 本文件是 @/components/title 公共包的唯一样式类型出口,不依赖任何业务页面(generate/ai-avatar)的私有类型。
* - 字段与后端 snake_case TitleStyleConfig 一一对应(camelCase 版本)
* - DEFAULT_TITLE_STYLE_SETTINGS 用于组件内部补全默认值
* - aiAutoSelect / title / coverTitle 等业务状态不在本类型中——它们属于页面业务 state
*/
import type { TitleLineOverride } from "./types"
export interface TitleStyleSettings {
position: string
font: string
size: number
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
color: string
posX: number | null
posY: number | null
lineHeight: number
marginTop: number
maxCharsPerLine: number
strokeWidth: number
strokeColor: string
shadowOffsetX: number
shadowOffsetY: number
shadowBlur: number
shadowColor: string
bgEnabled: boolean
bgColor: string
bgPadding: number
bgRadius: number
lineOverrides: TitleLineOverride[]
}
/** 公共默认样式(经典白字黑描边) */
export const DEFAULT_TITLE_STYLE_SETTINGS: TitleStyleSettings = {
position: "bottom",
font: "思源黑体",
size: 56,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
posX: null,
posY: null,
lineHeight: 1.2,
marginTop: 24,
maxCharsPerLine: 10,
strokeWidth: 5,
strokeColor: "#000000",
shadowOffsetX: 2,
shadowOffsetY: 2,
shadowBlur: 4,
shadowColor: "rgba(0,0,0,0.8)",
bgEnabled: false,
bgColor: "rgba(0,0,0,0.5)",
bgPadding: 12,
bgRadius: 8,
lineOverrides: [],
}
+22 -23
View File
@@ -1,25 +1,24 @@
/**
* 标题样式工具(#2001 / 模板系统 #2003)
*
* - snake_case TitleStyleConfig <-> camelCase TitleStyleSettings 互转
* - snake_case TitleStyleConfig ↔ camelCase TitleSettings 互转
* - preset 归一化预览(修复"标题"两字大小不一)
* - template -> preview settings 转换
*/
import type { TitleStyleConfig } from "./types"
import type { TitleStyleSettings } from "./settings"
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
import type { TitleSettings } from "../../pages/generate/types"
import { TITLE_PRESETS } from "./constants"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import type { TitleTemplate } from "./template-types"
/** snake_case TitleStyleConfig -> camelCase TitleStyleSettings(仅覆盖已知字段) */
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleStyleSettings> {
const out: Partial<TitleStyleSettings> = {}
/** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleSettings> {
const out: Partial<TitleSettings> = {}
if (s.font != null) out.font = s.font
if (s.size != null) out.size = s.size
if (s.color != null) out.color = s.color
if (s.bold != null) out.bold = s.bold
if (s.italic != null) out.italic = s.italic
if (s.position != null) out.position = s.position
if (s.position != null) out.position = s.position as TitleSettings["position"]
if (s.pos_x != null) out.posX = s.pos_x
if (s.pos_y != null) out.posY = s.pos_y
if (s.line_height != null) out.lineHeight = s.line_height
@@ -41,8 +40,8 @@ export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<T
return out
}
/** camelCase TitleStyleSettings patch -> snake_case TitleStyleConfig patch */
export function camelToTitleStyleConfig(p: Partial<TitleStyleSettings>): Partial<TitleStyleConfig> {
/** camelCase TitleSettings patch → snake_case TitleStyleConfig patch */
export function camelToTitleStyleConfig(p: Partial<TitleSettings>): Partial<TitleStyleConfig> {
const out: Partial<TitleStyleConfig> = {}
if (p.font != null) out.font = p.font
if (p.size != null) out.size = p.size
@@ -72,15 +71,15 @@ export function camelToTitleStyleConfig(p: Partial<TitleStyleSettings>): Partial
}
/**
* 把 preset style(snake_case)归一化为固定字号的 TitleStyleSettings,
* 把 preset style(snake_case)归一化为固定字号的 TitleSettings,
* 用于"预设卡片"缩略预览——所有卡片视觉上"标题"两字大小一致,便于辨识。
* 描边/阴影/背景padding 按 fixedSize / 原始 size 比例缩放,避免粗描边爆框。
*/
export function buildPresetPreviewSettings(
base: TitleStyleSettings,
base: TitleSettings,
presetKey: string,
fixedSize = 56,
): TitleStyleSettings {
): TitleSettings {
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
if (!preset) return base
const origSize = preset.style.size ?? fixedSize
@@ -88,25 +87,25 @@ export function buildPresetPreviewSettings(
const scale = (v: number | undefined, fallback: number): number =>
v != null ? Math.round(v * ratio) : fallback
return {
...DEFAULT_TITLE_STYLE_SETTINGS,
...base,
...titleStyleConfigToCamel(preset.style),
size: fixedSize,
strokeWidth: scale(preset.style.stroke_width, base.strokeWidth),
shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX),
shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY),
shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur),
bgPadding: scale(preset.style.bg_padding, base.bgPadding),
strokeWidth: scale(preset.style.stroke_width, base.strokeWidth) ?? base.strokeWidth,
shadowOffsetX: scale(preset.style.shadow_offset_x, base.shadowOffsetX) ?? base.shadowOffsetX,
shadowOffsetY: scale(preset.style.shadow_offset_y, base.shadowOffsetY) ?? base.shadowOffsetY,
shadowBlur: scale(preset.style.shadow_blur, base.shadowBlur) ?? base.shadowBlur,
bgPadding: scale(preset.style.bg_padding, base.bgPadding) ?? base.bgPadding,
lineOverrides: [],
}
}
/**
* 把 TitleTemplate 渲染为完整 TitleStyleSettings(带默认值),用于卡片预览。
* 把 TitleTemplate 渲染为完整 TitleSettings(带默认值),用于卡片预览。
* 与模板选择器中保持一致,抽出共用。
*/
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleStyleSettings {
const base: TitleStyleSettings = {
...DEFAULT_TITLE_STYLE_SETTINGS,
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleSettings {
const base: TitleSettings = {
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(t.style),
}
// 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放
-13
View File
@@ -18,7 +18,6 @@ import {
ThunderboltOutlined,
UnorderedListOutlined,
UserOutlined,
FireOutlined,
} from "@ant-design/icons"
/** 导航项类型 */
@@ -77,12 +76,6 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "viral-video",
label: "爆款视频",
path: "/app/viral-video",
icon: React.createElement(FireOutlined),
},
{
key: "history",
label: "任务历史",
@@ -149,12 +142,6 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "viral-video",
label: "爆款视频",
path: "/app/viral-video",
icon: React.createElement(FireOutlined),
},
],
},
{
+61 -58
View File
@@ -61,10 +61,9 @@ const AiAvatarPage: React.FC = () => {
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ── 对口型耗时计时(秒) ── */
const [lipsyncElapsed, setLipsyncElapsed] = useState(0)
const lipsyncStartAtRef = useRef<number>(0)
const lipsyncTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 对口型计时器 ── */
const lipsyncElapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -84,6 +83,30 @@ const AiAvatarPage: React.FC = () => {
/* ── 渲染进度轮询 ── */
const renderTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 对口型计时器工具 ── */
const _formatElapsed = useCallback((seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}, [])
const _startLipsyncElapsedTimer = useCallback(() => {
if (lipsyncElapsedTimerRef.current) {
clearInterval(lipsyncElapsedTimerRef.current)
}
setLipsyncElapsed(0)
lipsyncElapsedTimerRef.current = setInterval(() => {
setLipsyncElapsed((prev) => prev + 1)
}, 1000)
}, [])
const _stopLipsyncElapsedTimer = useCallback(() => {
if (lipsyncElapsedTimerRef.current) {
clearInterval(lipsyncElapsedTimerRef.current)
lipsyncElapsedTimerRef.current = null
}
}, [])
const togglePanel = useCallback((key: PanelKey) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
@@ -226,13 +249,7 @@ const AiAvatarPage: React.FC = () => {
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
// 启动计时器
lipsyncStartAtRef.current = Date.now()
setLipsyncElapsed(0)
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = setInterval(() => {
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
}, 1000)
_startLipsyncElapsedTimer()
const asset = await getAssetById(video.id)
const videoUrl = asset?.file_url
@@ -276,11 +293,7 @@ const AiAvatarPage: React.FC = () => {
state.setLipsyncJob(updated)
if (updated.status === "completed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
_stopLipsyncElapsedTimer()
setLipsyncStatus("completed")
setTimeout(() => {
setShowLipsyncModal(false)
@@ -288,10 +301,7 @@ const AiAvatarPage: React.FC = () => {
}, 1000)
} else if (updated.status === "failed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
_stopLipsyncElapsedTimer()
setLipsyncStatus("failed")
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
}
@@ -305,10 +315,8 @@ const AiAvatarPage: React.FC = () => {
data: (err as { response?: { data?: unknown } })?.response?.data,
message: err instanceof Error ? err.message : String(err),
})
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
_stopLipsyncElapsedTimer()
setLipsyncElapsed(0)
setShowLipsyncModal(false)
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
}
@@ -321,6 +329,8 @@ const AiAvatarPage: React.FC = () => {
state.style,
state.ttsPreview,
_startLipsyncElapsedTimer,
_stopLipsyncElapsedTimer,
])
// 取消对口型生成
@@ -329,22 +339,19 @@ const AiAvatarPage: React.FC = () => {
clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = null
}
if (lipsyncTickRef.current) {
clearInterval(lipsyncTickRef.current)
lipsyncTickRef.current = null
}
_stopLipsyncElapsedTimer()
setLipsyncElapsed(0)
setShowLipsyncModal(false)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
setLipsyncElapsed(0)
}, [])
}, [_stopLipsyncElapsedTimer])
// 清理轮询
useEffect(() => {
return () => {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
if (lipsyncElapsedTimerRef.current) clearInterval(lipsyncElapsedTimerRef.current)
}
}, [])
@@ -580,6 +587,20 @@ const AiAvatarPage: React.FC = () => {
return (
<div className="aa-page">
<div className="aa-page-header">
<h1>AI数字人</h1>
</div>
{/* 步骤切换导航条 */}
<div className="aa-step-nav">
<span className={`aa-step-nav__item${currentStep === 1 ? " active" : ""}`}>
1. 视频 / 配音 / 文案
</span>
<span className={`aa-step-nav__item${currentStep === 2 ? " active" : ""}`}>
2. 对口型 / 标题 / 封面 / 生成
</span>
</div>
<div className="aa-page-body">
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */}
{currentStep === 1 && (
@@ -712,11 +733,17 @@ const AiAvatarPage: React.FC = () => {
<div className="aa-panel__body">
{currentRenderJob?.status !== "completed" ? (
<PanelCoverAndGenerate
variant="setup"
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
renderJob={currentRenderJob}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
onGenerate={handleGenerate}
renderJob={currentRenderJob}
summary={summary}
/>
) : (
@@ -973,20 +1000,10 @@ const AiAvatarPage: React.FC = () => {
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成中…
</div>
<div
style={{
marginTop: 12,
fontSize: 28,
fontWeight: 700,
fontVariantNumeric: "tabular-nums",
color: "#7c3aed",
}}
>
{`${Math.floor(lipsyncElapsed / 60)
.toString()
.padStart(2, "0")}:${(lipsyncElapsed % 60).toString().padStart(2, "0")}`}
</div>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
已用时:{_formatElapsed(lipsyncElapsed)}
</div>
<div style={{ marginTop: 4, fontSize: 13, color: "#8c8ca1" }}>
请勿关闭页面,完成后将自动提示
</div>
</>
@@ -997,20 +1014,6 @@ const AiAvatarPage: React.FC = () => {
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成完成
</div>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#10b981",
fontVariantNumeric: "tabular-nums",
}}
>
总耗时{" "}
{Math.floor(lipsyncElapsed / 60)
.toString()
.padStart(2, "0")}
:{(lipsyncElapsed % 60).toString().padStart(2, "0")}
</div>
</>
)}
{lipsyncStatus === "failed" && (
+4 -7
View File
@@ -99,18 +99,15 @@ export const cancelRenderJob = async (jobId: string): Promise<void> => {
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
}
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ──
* #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧)
*/
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
export const generateRenderSmartCover = async (
jobId: string,
templateId: string = "default",
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
`/ai-avatar/render/${jobId}/smart-cover`,
templateId && templateId !== "default" ? { template_id: templateId } : {},
// 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s
{ timeout: templateId && templateId !== "default" ? 180000 : 120000 },
{},
// 抽帧+评分+转存 OSS 链路较长,120s 超时
{ timeout: 120000 },
)
return response.data
}
@@ -1,25 +1,11 @@
/**
* AI数字人 — 封面选择弹窗(#2033 共享封面组件重构)
*
* 复用智能剪辑的 CoverSettingsModal(模板选择)+ CoverEditorModal(7 面板自定义编辑器)
* + 智能生成 / 本地上传 / 封面预览,与智能剪辑侧 UI 一致。
*
* 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构:
* - 智能生成封面:mode="auto_frame",thumbnail_url/smart_cover_url 指向后端返回的 cover_url
* - 本地上传封面:mode="upload",upload_url/thumbnail_url 指向 blob 预览 URL
*
* 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。
* AI数字人 — 封面选择弹窗
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
*/
import React, { useCallback, useEffect, useMemo } from "react"
import { Modal as AntModal, Spin, message } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "@/pages/generate/components/cover-settings/CoverSettingsModal"
import CoverEditorModal from "@/pages/generate/components/cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar"
import React from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
interface ModalCoverSelectProps {
open: boolean
@@ -27,13 +13,7 @@ interface ModalCoverSelectProps {
renderJob: RenderJob | null
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
/**
* 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入
* 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。
*/
onGenerateRenderSmartCover?: (
renderId: string,
) => Promise<{ cover_url: string; message?: string }>
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
onUploadCover?: (file: File) => void
onCoverSelected: (coverUrl: string) => void
}
@@ -48,302 +28,31 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
onUploadCover,
onCoverSelected,
}) => {
const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id
const generateFn = useCallback(
async (templateId: string): Promise<string | null> => {
if (!renderJob || !isRenderCompleted) return null
try {
let coverUrl = ""
if (onGenerateRenderSmartCover) {
const res = await onGenerateRenderSmartCover(renderJob.id)
coverUrl = res.cover_url
} else {
const res = await apiGenerateSmartCover(renderJob.id, templateId)
coverUrl = res.cover_url
if (!coverUrl && res.message) {
const err = new Error(res.message) as Error & { __msgShown?: boolean }
err.__msgShown = true
message.error(res.message)
throw err
}
}
if (coverUrl) {
onCoverConfigChange({
mode: "auto_frame",
thumbnail_url: coverUrl,
smart_cover_url: coverUrl,
})
onCoverSelected(coverUrl)
message.success("智能封面已生成")
}
return coverUrl || null
} catch (err) {
const anyErr = err as { __msgShown?: boolean; message?: string }
if (!anyErr?.__msgShown) {
message.error(anyErr?.message || "智能封面生成失败")
}
throw err
}
},
[
renderJob,
isRenderCompleted,
onGenerateRenderSmartCover,
onCoverConfigChange,
onCoverSelected,
],
)
const shared = useSharedCover({
canGenerate: isRenderCompleted,
disabledHint: "请先完成视频生成再选择封面",
initialTemplateId: "default",
generateFn,
})
// 父层 onUploadCover 走 onUploadFile 回调(兼容老父组件)
useEffect(() => {
shared.setOnUploadFile((file: File) => {
if (onUploadCover) {
onUploadCover(file)
} else {
const url = URL.createObjectURL(file)
onCoverConfigChange({
mode: "upload",
upload_url: url,
thumbnail_url: url,
})
onCoverSelected(url)
}
return null
})
}, [shared, onUploadCover, onCoverConfigChange, onCoverSelected])
// 打开时同步刷新模板列表
useEffect(() => {
if (open) void shared.reloadTemplates()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
/** 当前预览 URL:智能封面 > 自定义上传 */
const previewUrl = useMemo(
() => coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "",
[coverConfig.smart_cover_url, coverConfig.thumbnail_url, coverConfig.upload_url],
)
if (!open) return null
return (
<Modal
open={open}
onCancel={onClose}
title="选择封面"
width={560}
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确定
</Button>
<div className="aa-modal-overlay" onClick={onClose}>
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
<div className="aa-modal__header">
<span className="aa-modal__title">选择封面</span>
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
}
>
<div style={{ padding: "8px 0" }}>
{renderJob && (
<div
style={{
padding: "8px 12px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 12,
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 从渲染成片中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
)}
<div
style={{
display: "flex",
gap: 12,
alignItems: "flex-start",
}}
>
{/* 左:封面预览 */}
<div
style={{
width: 180,
flexShrink: 0,
}}
>
<div
className="xx-ce-canvas"
style={{
position: "relative",
width: "100%",
aspectRatio: "9 / 16",
borderRadius: 8,
overflow: "hidden",
background: "linear-gradient(135deg, #1e3a8a 0%, #312e81 100%)",
border: previewUrl ? "none" : "1px dashed #d9d9d9",
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt="封面预览"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
gap: 6,
opacity: 0.7,
}}
>
<span style={{ fontSize: 28 }}>🖼️</span>
<span>
{isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}
</span>
</div>
)}
{shared.generating && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
flexDirection: "column",
gap: 8,
}}
>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>AI 选帧中…</span>
</div>
)}
</div>
<div
style={{
marginTop: 6,
textAlign: "center",
fontSize: 11,
color: "#8c8ca1",
}}
>
9:16 竖版封面
</div>
</div>
{/* 右:操作按钮 */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!isRenderCompleted || shared.generating}
loading={shared.generating}
style={{ width: "100%" }}
>
✨ 自动生成封面
</Button>
<Button
buttonType="ghost"
onClick={() => shared.setShowCoverSettings(true)}
style={{ width: "100%" }}
>
⚙️ 封面模板
</Button>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={!isRenderCompleted || shared.generating}
style={{ width: "100%" }}
>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<div
style={{
fontSize: 11,
color: "#8c8ca1",
lineHeight: 1.5,
marginTop: 4,
padding: "6px 8px",
background: "#f7f8fa",
borderRadius: 6,
}}
>
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
</div>
</div>
<div className="aa-modal__body" style={{ padding: 20 }}>
<PanelCoverAndGenerate
variant="select-cover"
coverConfig={coverConfig}
onCoverConfigChange={onCoverConfigChange}
renderJob={renderJob}
onGenerateRenderSmartCover={onGenerateRenderSmartCover}
onUploadCover={onUploadCover}
onClose={onClose}
onCoverSelected={onCoverSelected}
/>
</div>
</div>
{/* 模板选择弹窗 */}
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => shared.setShowCoverSettings(false)}
templates={shared.templates}
loading={shared.templatesLoading}
error={shared.templatesError}
selectedTemplateId={shared.selectedTemplateId}
onSelectTemplate={shared.handleSelectTemplate}
onEditTemplate={shared.handleEditTemplate}
onDeleteTemplate={shared.handleDeleteTemplate}
onCreateNew={shared.handleCreateTemplate}
/>
{/* 自定义编辑器弹窗 */}
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
/>
{/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */}
<AntModal open={shared.generating} closable={false} footer={null} centered width={320}>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI 正在从最终成片选帧,请稍候...
</p>
</div>
</AntModal>
</Modal>
</div>
)
}
@@ -1,19 +1,36 @@
/**
* AI数字人 — 面板5 / 生成配置面板(渲染前)
* #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮)
* 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件)
* AI数字人 — 面板5 / 封面选择弹窗内容:
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
*
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
*/
import React from "react"
import type { RenderJob } from "../types"
import React, { useRef, useState } from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
type PanelVariant = "setup" | "select-cover"
interface PanelCoverAndGenerateProps {
variant?: PanelVariant
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
resolution?: string
onResolutionChange?: (r: string) => void
isGenerating?: boolean
onGenerate?: () => void
/** 当前渲染任务 */
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */
renderJob: RenderJob | null
/** 配置汇总信息 */
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
onUploadCover?: (file: File) => void
/** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */
onClose?: () => void
/** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */
onCoverSelected?: (coverUrl: string) => void
/** 配置汇总信息(仅 variant="setup" 使用) */
summary?: {
videoName: string | null
voiceName: string | null
@@ -21,6 +38,7 @@ interface PanelCoverAndGenerateProps {
lipsyncStatus: string | null
brollCount: number
hasTitle: boolean
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
coverStatus: "not_ready" | "pending" | "selected"
}
}
@@ -40,15 +58,89 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
}
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
variant = "setup",
coverConfig,
onCoverConfigChange,
resolution = "720p",
onResolutionChange,
isGenerating = false,
onGenerate,
renderJob: _renderJob,
renderJob,
onGenerateRenderSmartCover,
onUploadCover,
onClose,
onCoverSelected,
summary,
}) => {
const uploadInputRef = useRef<HTMLInputElement>(null)
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/** 自定义上传封面 */
const handleUploadClick = () => {
uploadInputRef.current?.click()
}
const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => {
const partial: Partial<AiAvatarCoverConfig> = {
mode,
thumbnail_url: url,
}
if (mode === "auto_frame") {
partial.smart_cover_url = url
} else {
partial.upload_url = url
}
onCoverConfigChange(partial)
onCoverSelected?.(url)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (onUploadCover) {
onUploadCover(file)
e.target.value = ""
return
}
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
const url = URL.createObjectURL(file)
_applyCoverUrl(url, "upload")
e.target.value = ""
}
/** 智能获取封面(从最终成片抽帧;必须等 render 完成) */
const handleSmartCover = async () => {
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
setSmartCoverLoading(true)
try {
const res = await onGenerateRenderSmartCover(renderJob.id)
if (res.cover_url) {
_applyCoverUrl(res.cover_url, "auto_frame")
} else {
// 失败由父组件 message 提示,这里不重复弹窗
console.warn("[智能封面] 返回空 cover_url:", res.message)
}
} catch (err) {
console.error("[智能封面] 调用失败:", err)
} finally {
setSmartCoverLoading(false)
}
}
const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
// 渲染已完成 → 封面区可用
const isRenderCompleted = renderJob?.status === "completed"
const canSmartCover = isRenderCompleted && !smartCoverLoading
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
const coverUrl =
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
const hasCoverImage = Boolean(coverUrl)
/** 封面区占位文字 */
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
/** 配置摘要中的封面状态标签 */
const coverSummaryNode = (() => {
@@ -62,6 +154,69 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
return <span className="aa-config-summary__empty">生成视频后可选</span>
})()
// ── 封面选择区(两种 variant 共用) ─────────────────────────────────
const coverSection = (
<div className="aa-cover-section" style={{ marginTop: variant === "select-cover" ? 0 : 16 }}>
<div className="aa-label" style={{ marginBottom: 8 }}>
{variant === "select-cover" ? "选择封面" : "封面"}
</div>
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : (
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
)}
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
</div>
<div className="aa-cover-actions">
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={!canSmartCover}
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
disabled={!isRenderCompleted || smartCoverLoading}
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
>
📷 自定义上传
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
)
// ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ──
if (variant === "select-cover") {
return (
<div className="aa-cover-generate">
{coverSection}
{onClose && (
<div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
<button type="button" className="aa-btn aa-btn--primary" onClick={onClose}>
确定
</button>
</div>
)}
</div>
)
}
// ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ──
return (
<div className="aa-cover-generate">
{/* 分辨率选择 */}
@@ -412,7 +412,7 @@ const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpda
onUpdateStyle={handleUpdateStyle}
showCoverToggle
previewWidth={280}
enableTemplates={true}
enableTemplates
selectedTemplateId={selectedTemplateId}
onApplyTemplate={handleApplyTemplate}
activePreset={activePreset}
+72 -78
View File
@@ -13,12 +13,15 @@ import CloneModal from "@/components/voice/CloneModal"
import VoiceSelectModal from "./components/VoiceSelectModal"
import ScriptSelectModal from "./components/ScriptSelectModal"
import TtsVoiceModal from "./components/TtsVoiceModal"
import GenerateHeader from "./components/GenerateHeader"
import PreviewCountModal from "./components/PreviewCountModal"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { finalizeGeneration } from "@/api/generation/finalize"
import { confirmGeneration } from "@/api/generation/confirm"
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
@@ -86,6 +89,7 @@ const GeneratePage: React.FC = () => {
style,
autoSubtitles,
bgm,
editPlanId,
sourceEditPlanId,
previewTaskId,
setPreviewTaskId,
@@ -103,7 +107,6 @@ const GeneratePage: React.FC = () => {
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
setSelectedTemplate,
} = formState
const isBatch = previewCount > 1
@@ -134,8 +137,8 @@ const GeneratePage: React.FC = () => {
}
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
/* ── 标题面板模式:true = 内联大卡片模板网格(默认),false = 旧预设+参数 Tab ── */
const enableTemplates = true
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── Step5 保存中状态 ── */
const [finishing, setFinishing] = useState(false)
@@ -196,7 +199,6 @@ const GeneratePage: React.FC = () => {
generated,
generateError,
generatedVideos,
currentTaskId,
batchTasks,
generate: handleGenerate,
retry: handleRetryGenerate,
@@ -236,48 +238,44 @@ const GeneratePage: React.FC = () => {
voiceModePerVideo,
variantCoverUrls: previewCovers,
selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
onGenerationSuccess: (status?: "completed" | "awaiting_cover") => {
onGenerationSuccess: () => {
setPreviewTaskId(null)
setStoredSourceEditPlanId(null)
// #2088:渲染完成后自动跳到封面选择页(step 5),不再等用户手动点「下一步」
// awaiting_cover 和 completed 都走封面页(completed 是旧 worker 或 finalize 后状态,仍支持选封面)
if (status === "awaiting_cover" || status === "completed" || !status) {
setCurrentStep(5)
}
},
})
/* ── 对齐批量数组长度到 previewCount(用于进入 Step3 时) ── */
const ensureArraysAligned = useCallback(() => {
setPreviewTitles((prev) => {
const list = prev || []
if (list.length === previewCount) return list
const base = list[0] || titleSettings.title || ""
return Array.from({ length: previewCount }, (_, i) => list[i] ?? (i === 0 ? base : ""))
})
setVoiceLibraryIds((prev) => {
const list = prev || []
if (list.length === previewCount) return list
return Array.from({ length: previewCount }, (_, i) => list[i] ?? selectedVoice ?? "")
})
setPreviewCovers((prev) => {
const list = prev || []
if (list.length === previewCount) return list
return Array.from({ length: previewCount }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds((prev) => {
if (prev && prev.length === previewCount) return prev
return Array.from({ length: previewCount }, (_, i) => i)
})
}, [
previewCount,
setPreviewTitles,
setVoiceLibraryIds,
setPreviewCovers,
setSelectedVariantIds,
titleSettings.title,
selectedVoice,
])
/* ── 数量弹窗确认 ── */
const handleCountConfirm = useCallback(
(count: number) => {
setPreviewCount(count)
setCountModalOpen(false)
setPreviewTitles((prev) => {
const list = prev || []
const base = list[0] || titleSettings.title || ""
return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : ""))
})
setVoiceLibraryIds((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "")
})
setPreviewCovers((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
setCurrentStep(3)
},
[
setPreviewCount,
setPreviewTitles,
setVoiceLibraryIds,
setPreviewCovers,
setSelectedVariantIds,
setCurrentStep,
titleSettings.title,
selectedVoice,
],
)
/* ── #1970:Step1 弹窗回调 ── */
const handleVoiceModalConfirm = useCallback(
@@ -400,7 +398,7 @@ const GeneratePage: React.FC = () => {
smartSelectedIds,
titleSettings,
generated,
onBeforeEnterStep3: ensureArraysAligned,
onOpenCountModal: () => setCountModalOpen(true),
onOpenStep1Modal: () => {
if (editMode === "random") {
setVoiceModalOpen(true)
@@ -413,7 +411,7 @@ const GeneratePage: React.FC = () => {
/* ── 最终成片(单视频) ── */
const finalVideo = generatedVideos[0]
/* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */
/* ── Step5 完成:调用 confirm 入库 + 跳转 ── */
const handleFinish = useCallback(async () => {
if (finishing) return
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
@@ -423,10 +421,7 @@ const GeneratePage: React.FC = () => {
return
}
} else {
// 单视频:finalVideo 可能因 /results 接口在 awaiting_cover 阶段暂未返回
// GeneratedVideo 记录而为 undefined;此时 currentTaskId 已在创建任务时保存,
// 下面 singleTaskId 兜底逻辑会用 currentTaskId 调 finalize,不应拦截
if (!finalVideo && !currentTaskId) {
if (!finalVideo) {
message.warning("请等待视频生成完成")
return
}
@@ -434,38 +429,31 @@ const GeneratePage: React.FC = () => {
setFinishing(true)
const hide = message.loading("正在保存到视频库...", 0)
try {
// 收集需要 finalize 的任务 ID:批量用 batchTasks;单视频优先用 finalVideo.generation_task_id,兜底 currentTaskId
const singleTaskId = finalVideo?.generation_task_id || currentTaskId || ""
const taskIds =
batchTasks && batchTasks.length > 0
? batchTasks.map((t) => t.taskId).filter(Boolean)
: finalVideo?.generation_task_id
? [finalVideo.generation_task_id]
: []
// 单视频/批量:为每个任务调用 finalize(入库 + 绑定封面 + 自定义标题)
// 批量时必须按 batchTasks[i].variantIndex 对齐 previewCovers/previewTitles(taskIds 顺序不一定按变体序号)
if (isBatch && batchTasks.length > 0) {
// 单视频/批量:为每个任务调用 confirm(传入封面)
if (isBatch && previewCovers.length > 0) {
await Promise.all(
batchTasks.map(async (task) => {
const vi = task.variantIndex
const rawCoverUrl = previewCovers[vi] || ""
const coverUrl = rawCoverUrl.startsWith("blob:") ? "" : rawCoverUrl
const title = previewTitles[vi] || titleSettings.title || ""
return finalizeGeneration(task.taskId, {
taskIds.map(async (taskId, idx) => {
const coverUrl = previewCovers[idx] || ""
return confirmGeneration(taskId, {
cover_url: coverUrl || undefined,
custom_title: title || undefined,
custom_title: previewTitles[idx] || titleSettings.title || "",
})
}),
)
} else if (singleTaskId) {
// 单视频:cover_url 仅在非 blob: 本地预览地址时才传;blob: URL 浏览器本地临时地址,
// 后端无法下载,此时不传让后端回退自动截帧封面(避免 400 保存失败)。
// 正常流程本地上传完成后 uploadLocalCover 会把 URL 替换为 OSS 真实 URL,这里仅兜底异常场景。
const rawCoverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
const coverUrl = rawCoverUrl.startsWith("blob:") ? "" : rawCoverUrl
await finalizeGeneration(singleTaskId, {
} else if (finalVideo?.generation_task_id) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
await confirmGeneration(finalVideo.generation_task_id, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || undefined,
custom_title: titleSettings.title || "",
})
} else {
console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转")
}
hide()
message.success("已保存到视频库")
navigate("/app/products")
@@ -492,7 +480,6 @@ const GeneratePage: React.FC = () => {
previewTitles,
titleSettings.title,
coverSettings,
currentTaskId,
navigate,
])
@@ -520,6 +507,10 @@ const GeneratePage: React.FC = () => {
return (
<div className="xx-generate-page">
<GenerateHeader fromEditPlan={!!editPlanId} />
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div className={layoutClassName}>
{/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
<div className="xx-generate-form">
@@ -547,7 +538,7 @@ const GeneratePage: React.FC = () => {
onUpdateStyle={styleUpdaters.updateStyle}
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
enableTemplates={enableTemplates}
enableTemplates
selectedTemplateId={selectedTitleTemplateId}
onApplyTemplate={(settings, tpl) => {
styleUpdaters.applyTemplate(settings)
@@ -571,8 +562,6 @@ const GeneratePage: React.FC = () => {
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
currentTaskId={currentTaskId}
onRetry={handleRetryGenerate}
onRetryBatchTask={handleRetryBatchTask}
onDismissError={handleDismissError}
@@ -587,9 +576,6 @@ const GeneratePage: React.FC = () => {
previewCovers={previewCovers}
onPreviewCoversChange={setPreviewCovers}
selectedVariantIds={selectedVariantIds}
selectedCoverTemplate={selectedTemplate}
onSelectedCoverTemplateChange={setSelectedTemplate}
onConfirmGenerate={handleConfirmGenerate}
/>
{/* ════ 步骤4(单视频):成片播放器 ════ */}
@@ -677,6 +663,14 @@ const GeneratePage: React.FC = () => {
</div>
</div>
{/* 数量选择弹窗 */}
<PreviewCountModal
open={countModalOpen}
defaultCount={1}
onConfirm={handleCountConfirm}
onCancel={() => setCountModalOpen(false)}
/>
{/* 音色克隆弹窗 */}
<CloneModal
open={cloneModalOpen}
@@ -11,12 +11,7 @@
* 防止长标题在窄列里溢出导致与相邻卡片进度条视觉重叠。
*/
import React from "react"
import {
LoadingOutlined,
CheckCircleFilled,
CloseCircleOutlined,
ClockCircleOutlined,
} from "@ant-design/icons"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
import type { GeneratedVideo } from "@/api/template-editor"
@@ -42,9 +37,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-preview-header">
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
完成{" "}
{tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "}
{tasks.length}
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
</span>
</div>
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
@@ -56,7 +49,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" || task.status === "awaiting_cover" ? (
{task.status === "completed" ? (
<CheckCircleFilled
className="xx-batch-gen-card-icon"
style={{ color: "#52c41a" }}
@@ -66,11 +59,6 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
className="xx-batch-gen-card-icon"
style={{ color: "#ef4444" }}
/>
) : task.status === "queued" ? (
<ClockCircleOutlined
className="xx-batch-gen-card-icon"
style={{ color: "#faad14" }}
/>
) : (
<LoadingOutlined
className="xx-batch-gen-card-icon"
@@ -95,22 +83,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
</>
)}
{task.status === "queued" && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: "var(--text-secondary, #faad14)",
fontSize: 13,
padding: "8px 0",
}}
>
<ClockCircleOutlined />
<span>排队等待中,前面任务完成后自动开始渲染</span>
</div>
)}
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
{task.status === "completed" && video && (
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
<div
@@ -138,7 +111,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
/>
</div>
)}
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
{task.status === "completed" && !video && (
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
)}
{task.status === "failed" && (
@@ -12,7 +12,6 @@ import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step4TitleSettings from "../components/Step4TitleSettings"
import Step6CoverSettings from "../components/Step6CoverSettings"
import BatchGenerationGrid from "./BatchGenerationGrid"
import Step3VoiceWithMode from "./Step3VoiceWithMode"
import type { BatchTaskState } from "../hooks/generate-video/useGenerationPolling"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleTemplate } from "@/components/title/template-types"
@@ -90,12 +89,6 @@ export interface GenerateStepContentProps {
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
selectedVariantIds?: number[]
selectedCoverTemplate?: string
onSelectedCoverTemplateChange?: (templateId: string) => void
/** 单视频任务 ID(兜底,awaiting_cover 状态下 results 接口未入库时用) */
currentTaskId?: string
/** Step3 右上角确认生成按钮 */
onConfirmGenerate?: () => void | Promise<void>
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -149,15 +142,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
selectedCoverTemplate,
onSelectedCoverTemplateChange,
onConfirmGenerate,
selectedVoice,
onSelectedVoiceChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
} = props
switch (currentStep) {
@@ -191,46 +175,27 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
)
case 3:
return (
<>
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
activePreset={activePreset}
titlePresets={titlePresets}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
onConfirmGenerate={onConfirmGenerate}
generating={props.generating}
selectedCount={
props.previewCount && props.previewCount > 1
? props.selectedVariantIds?.length || 1
: 1
}
/>
{/* 批量配音选择:共用/独立切换(#2096) */}
<Step3VoiceWithMode
previewCount={previewCount}
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
voiceModePerVideo={voiceModePerVideo}
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
/>
</>
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
onUpdateStyle={onUpdateStyle}
activePreset={activePreset}
titlePresets={titlePresets}
enableTemplates={enableTemplates}
selectedTemplateId={selectedTemplateId}
onApplyTemplate={onApplyTemplate}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
/>
)
case 4:
return (
@@ -285,9 +250,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers={previewCovers}
onPreviewCoversChange={onPreviewCoversChange}
selectedVariantIndexes={selectedVariantIds}
selectedTemplate={selectedCoverTemplate}
onTemplateChange={onSelectedCoverTemplateChange}
currentTaskId={props.currentTaskId}
/>
)
default:
@@ -0,0 +1,127 @@
/**
* 生成数量选择弹窗(Issue #1677)
* Step1 选完模板点「下一步」时弹出:要生成几个视频?(1~10)
* 默认 1,回车 = 1(零额外操作)
*/
import React, { useState, useEffect, useRef } from "react"
import { MAX_PREVIEW_COUNT } from "../constants"
interface PreviewCountModalProps {
open: boolean
/** 默认值(上次选择,默认1) */
defaultCount?: number
onConfirm: (count: number) => void
onCancel: () => void
}
const PreviewCountModal: React.FC<PreviewCountModalProps> = ({
open,
defaultCount = 1,
onConfirm,
onCancel,
}) => {
const [count, setCount] = useState(defaultCount)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setCount(defaultCount)
// 弹窗打开后聚焦并选中,方便直接回车=默认1
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, defaultCount])
const clamp = (n: number) => Math.max(1, Math.min(MAX_PREVIEW_COUNT, n || 1))
const handleConfirm = () => {
onConfirm(clamp(count))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleConfirm()
}
if (e.key === "Escape") {
onCancel()
}
}
if (!open) return null
return (
<div className="xx-modal-mask" onClick={onCancel}>
<div className="xx-modal-box xx-count-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: "0 0 8px", fontSize: 18 }}>要生成几个视频?</h3>
<p style={{ margin: "0 0 20px", fontSize: 13, color: "var(--text-secondary, #666)" }}>
素材共用,AI 随机剪辑出不同版本,每个视频可独立设置标题、配音和封面
</p>
<div className="xx-count-selector">
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c - 1))}
disabled={count <= 1}
aria-label="减少"
>
−
</button>
<input
ref={inputRef}
type="number"
min={1}
max={MAX_PREVIEW_COUNT}
value={count}
onChange={(e) => setCount(clamp(parseInt(e.target.value, 10) || 1))}
onKeyDown={handleKeyDown}
className="xx-count-input"
/>
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c + 1))}
disabled={count >= MAX_PREVIEW_COUNT}
aria-label="增加"
>
+
</button>
</div>
<div className="xx-count-quick">
{[1, 3, 5, 10].map((n) => (
<button
key={n}
type="button"
className={`xx-count-chip ${count === n ? "active" : ""}`}
onClick={() => setCount(n)}
>
{n} 个
</button>
))}
</div>
<div className="xx-count-actions">
<button type="button" className="xx-btn xx-btn-ghost" onClick={onCancel}>
取消
</button>
<button type="button" className="xx-btn xx-btn-primary" onClick={handleConfirm}>
{count === 1 ? "生成 1 个视频" : `生成 ${count} 个视频`}
</button>
</div>
<p
style={{
margin: "12px 0 0",
fontSize: 12,
color: "var(--text-tertiary, #999)",
textAlign: "center",
}}
>
直接按回车 = 生成 1 个
</p>
</div>
</div>
)
}
export default PreviewCountModal
@@ -47,12 +47,6 @@ interface Step4TitleSettingsProps {
enableTemplates?: boolean
selectedTemplateId?: string | null
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
/** Step3 右上角「🎬 确认生成」主按钮 */
onConfirmGenerate?: () => void | Promise<void>
/** 是否生成中 */
generating?: boolean
/** 批量模式下勾选数量 */
selectedCount?: number
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -75,9 +69,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
enableTemplates,
selectedTemplateId,
onApplyTemplate,
onConfirmGenerate,
generating,
selectedCount = 1,
} = props
const isBatch = previewCount > 1
@@ -99,47 +90,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
)
return (
<div className="xx-form-section" style={{ position: "relative" }}>
{/* ── 右上角「🎬 确认生成」主按钮 ── */}
{onConfirmGenerate && (
<button
type="button"
onClick={() => {
if (generating) return
void onConfirmGenerate()
}}
disabled={generating}
style={{
position: "absolute",
top: 0,
right: 0,
background: generating ? "#a78bfa" : "#7c3aed",
color: "#fff",
border: "none",
borderRadius: 10,
padding: "12px 24px",
fontSize: 15,
fontWeight: 600,
cursor: generating ? "not-allowed" : "pointer",
boxShadow: "0 4px 14px rgba(124,58,237,0.4)",
transition: "all .2s",
zIndex: 5,
whiteSpace: "nowrap",
}}
onMouseEnter={(e) => {
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#6d28d9"
}}
onMouseLeave={(e) => {
if (!generating) (e.currentTarget as HTMLButtonElement).style.background = "#7c3aed"
}}
>
{generating
? "⏳ 生成中..."
: selectedCount > 1
? `🎬 确认生成 ${selectedCount} 个视频`
: "🎬 确认生成"}
</button>
)}
<div className="xx-form-section">
<h3>📝 选择标题</h3>
{!isBatch ? (
@@ -157,7 +108,7 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
/>
</div>
) : (
/* ── 批量:N 个独立标题输入框(两列布局 #2096) ── */
/* ── 批量:N 个独立标题输入框 ── */
<div className="xx-batch-titles">
<div
style={{
@@ -170,25 +121,17 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i} style={{ maxWidth: "100%" }}>
<label>视频 {i + 1} 标题</label>
<TitleLibraryAutoComplete
placeholder={`输入或选择视频 ${i + 1} 的标题`}
value={previewTitles?.[i] || ""}
onChange={(val) => updateVariantTitle(i, val)}
options={titleOptions}
/>
</div>
))}
</div>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i} style={{ maxWidth: 640 }}>
<label>视频 {i + 1} 标题</label>
<TitleLibraryAutoComplete
placeholder={`输入或选择视频 ${i + 1} 的标题`}
value={previewTitles?.[i] || ""}
onChange={(val) => updateVariantTitle(i, val)}
options={titleOptions}
/>
</div>
))}
</div>
)}
@@ -1,242 +1,84 @@
/**
* Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传)
* - N 个视频:N 张封面卡片,每张带对应视频标题,支持统一选择封面模板、逐个自动生成或上传
*
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
* Step 5 选择封面(Issue #1677 批量生成改造)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
*/
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { Modal, Spin, message } from "antd"
import React, { useRef } from "react"
import { Modal, Spin } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { CoverConfig } from "../types/cover"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import { useBatchCovers } from "../hooks/useBatchCovers"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateCover as apiGenerateCover } from "@/api/generation"
import { uploadAssetDirect } from "@/api/assets"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/** 当前选中的模板 ID */
selectedTemplate?: string
/** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
/** 确认生成步骤产出的最终视频列表 */
generatedVideos: GeneratedVideo[]
/* ── 批量生成(#1677)── */
previewCount?: number
/** 每个变体的标题文字 */
previewTitles?: string[]
/** 每个变体的封面URL(按变体索引) */
previewCovers?: string[]
onPreviewCoversChange?: (urls: string[]) => void
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
selectedVariantIndexes?: number[]
onTemplateChange?: (templateId: string) => void
/** 单视频任务 ID(awaiting_cover 阶段 results 接口可能返回 preview-xxx 合成对象,兜底用) */
currentTaskId?: string
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const {
coverSettings,
generating,
generateAutoCover,
finalVideo,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
setShowCoverEditor,
selectedTemplateId,
editingTemplate,
coverTemplates,
templatesLoading,
templatesError,
handleSelectTemplate,
handleEditTemplate,
handleSaveTemplate,
handleDeleteTemplate,
} = useStep6Cover({
coverSettings: props.coverSettings,
onCoverSettingsChange: props.onCoverSettingsChange,
selectedTemplate: props.selectedTemplate,
titleSettings: props.titleSettings,
generatedVideos: props.generatedVideos,
})
const previewCount = props.previewCount || 1
const isBatch = previewCount > 1
const previewTitles = props.previewTitles || []
const previewCovers = props.previewCovers || []
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
const cardIndexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: previewCount }, (_, i) => i)
const uploadInputRef = useRef<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
/** 最终成片:取第一个已完成视频(单视频场景) */
const finalVideo =
props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") ||
props.generatedVideos[0]
/**
* 兜底任务/视频 ID:awaiting_cover 阶段后端 /results 可能还没有入库 GeneratedVideo,
* 只返回合成的 preview-{taskId} 轻量对象;此时用 currentTaskId 兜底让后端能找到任务。
* 同时统一抽取 taskId(generation_task_id 优先)用于日志/错误提示。
*/
const effectiveTaskId =
(finalVideo as { generation_task_id?: string } | undefined)?.generation_task_id ||
props.currentTaskId ||
""
const _rawVideoId =
(finalVideo as { id?: string; video_id?: string } | undefined)?.id ||
(finalVideo as { video_id?: string } | undefined)?.video_id ||
""
// preview-{taskId} 是后端合成的临时 id,gv_repo.get 查不到 → 不传 generated_video_id,
// 让后端走 plan.config.generation_task_id / rendered_storage_key 兜底路径。
const effectiveVideoId = _rawVideoId && !_rawVideoId.startsWith("preview-") ? _rawVideoId : ""
const effectiveVideoUrl = finalVideo?.file_url || finalVideo?.download_url || ""
/** 按钮可用:非批量 且 (有 finalVideo 对象或兜底 taskId) 且 视频状态已完成/等待封面/未设置 */
const isVideoReady =
!finalVideo ||
finalVideo.status === "completed" ||
finalVideo.status === "awaiting_cover" ||
!finalVideo.status
const canGenerateCover = !isBatch && (!!finalVideo || !!effectiveTaskId) && isVideoReady
const completedVideos = useMemo(
() =>
props.generatedVideos.filter(
(v) => v.status === "completed" || v.status === "awaiting_cover",
),
[props.generatedVideos],
)
/**
* 单视频自动生成(点击"自动生成封面"按钮):使用当前选中的模板
* 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发
*/
const shared = useSharedCover({
canGenerate: canGenerateCover,
disabledHint: isBatch
? "批量场景请在上方操作卡片"
: !finalVideo && !effectiveTaskId
? "请先生成视频再选择封面"
: "视频尚未就绪,请稍候",
initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default
generateFn: async (tplId) => {
if (isBatch) return null
if (!finalVideo && !effectiveTaskId) {
console.warn("[Cover] generateAutoCover: no finalVideo and no taskId")
return null
}
// 请求体:generated_video_id 仅在后端已入库(非 preview-xxx 合成id)时传;
// video_url 兜底让后端能直接下载视频抽帧;generation_task_id 后端已从 plan.config 自动读取。
const requestBody: {
generated_video_id?: string
video_url?: string
cover_type: "ai_frame"
title_config?: Record<string, unknown>
} = {
cover_type: "ai_frame",
}
if (effectiveVideoId) {
requestBody.generated_video_id = effectiveVideoId
}
if (effectiveVideoUrl) {
requestBody.video_url = effectiveVideoUrl
}
if (props.titleSettings?.title) {
requestBody.title_config = {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
}
}
console.log("[Cover] auto-generate request:", { tplId, ...requestBody })
const response = await apiGenerateCover(tplId, requestBody)
const url = response.cover?.image_url || response.cover?.thumbnail_url || ""
if (url) {
props.onCoverSettingsChange({
...props.coverSettings,
thumbnail_url: url,
ai_suggested_time: response.cover?.frame_time ?? null,
})
} else {
console.warn("[Cover] generate returned empty url:", response)
}
return url
},
})
// 选中模板变化时通知父组件(用于批量生成时透传 template_id)
const { onTemplateChange, selectedTemplate: parentSelectedTemplate } = props
// 父组件 selectedTemplate 变化时同步到子(例如从 Step1/Step4 切换到 Step6 时)
useEffect(() => {
if (parentSelectedTemplate && parentSelectedTemplate !== shared.selectedTemplateId) {
shared.handleSelectTemplate(parentSelectedTemplate)
}
}, [parentSelectedTemplate]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (isBatch && onTemplateChange && shared.selectedTemplateId !== parentSelectedTemplate) {
onTemplateChange(shared.selectedTemplateId)
}
}, [isBatch, shared.selectedTemplateId, parentSelectedTemplate, onTemplateChange])
/** 单视频本地上传封面:选完文件后上传到素材库 OSS,拿到真实 URL 再 set */
const [uploadingLocalCover, setUploadingLocalCover] = useState(false)
const { coverSettings: curCoverSettings, onCoverSettingsChange } = props
const uploadLocalCover = useCallback(
async (file: File): Promise<string | null> => {
const hide = message.loading("正在上传封面...", 0)
setUploadingLocalCover(true)
try {
// 立即创建 blob URL 用于即时预览,同时异步上传 OSS
const previewUrl = URL.createObjectURL(file)
onCoverSettingsChange({
...curCoverSettings,
upload_url: previewUrl,
thumbnail_url: previewUrl,
mode: "upload",
})
// 后端自动在默认项目下确保图片素材库存在(P0 404 修复)
const result = await uploadAssetDirect({ file, kind: "image" })
const realUrl = result?.url || ""
if (!realUrl) {
hide()
message.warning("上传完成但未获取到URL,将使用本地预览")
return previewUrl
}
hide()
// 替换 blob URL 为真实 OSS URL(blob 用于预览过渡,finalize 时必须用真实 URL)
onCoverSettingsChange({
...curCoverSettings,
upload_url: realUrl,
thumbnail_url: realUrl,
mode: "upload",
})
message.success("封面上传成功")
return realUrl
} catch (err) {
hide()
console.error("[Step6] 封面上传失败:", err)
message.error("封面上传失败,请重试")
return null
} finally {
setUploadingLocalCover(false)
}
},
[curCoverSettings, onCoverSettingsChange],
)
useEffect(() => {
// 单视频:注册实际上传函数;批量场景已由 batchCovers.uploadOne 接管,
// 这里不要覆盖(批量时 input ref 绑定到 batchUploadRef,不走 shared.handleFileInputChange)
if (!isBatch) {
shared.setOnUploadFile((file) => uploadLocalCover(file))
}
}, [shared, isBatch, uploadLocalCover])
const batchUploadRef = React.useRef<HTMLInputElement>(null)
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
const handleBatchUploadClick = (cardPos: number) => {
setBatchUploadCard(cardPos)
batchUploadRef.current?.click()
}
const handleBatchUploadChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
const cardPos = batchUploadCard
setBatchUploadCard(null)
if (!file || cardPos == null) return
void batchCovers.uploadOne(cardPos, file)
}
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
/**
* 批量生成:selectedTemplateId 来自用户在 CoverSettingsModal 中选择的模板,
* 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。
*/
const batchCovers = useBatchCovers({
selectedTemplate: shared.selectedTemplateId,
selectedTemplate: props.selectedTemplate || "",
generatedVideos: props.generatedVideos,
titles: batchTitles,
titleStyle: {
@@ -250,6 +92,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
},
covers: batchCoversList,
onCoversChange: (updater) => {
// 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖)
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
const next = [...(props.previewCovers || [])]
@@ -260,7 +103,22 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
},
})
const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
const handleUploadClick = (variantIndex: number) => {
uploadTargetRef.current = variantIndex
uploadInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) {
const variantIndex = uploadTargetRef.current
const cardPos = cardIndexes.indexOf(variantIndex)
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
}
}
/* ── 批量封面 ── */
if (isBatch) {
@@ -280,32 +138,17 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
}}
>
🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" · "}当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<Button
buttonType="primary"
onClick={() => void batchCovers.generateAll()}
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
loading={batchCovers.busyIndexes.length > 0}
>
✨ 一键全部自动生成
</Button>
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
{shared.selectedTemplateId && shared.selectedTemplateId !== "default"
? `:${shared.selectedTemplateName}`
: ""}
</Button>
<Button buttonType="ghost" onClick={shared.handleCreateTemplate}>
➕ 新建模板
</Button>
</div>
<div className="xx-cover-grid">
@@ -366,7 +209,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => handleBatchUploadClick(cardPos)}
onClick={() => handleUploadClick(variantIndex)}
disabled={isLoading || isUploading}
>
📤 上传
@@ -377,44 +220,24 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
})}
</div>
{/* 隐藏的文件选择 input,批量上传复用 */}
<input
ref={batchUploadRef}
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleBatchUploadChange}
/>
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => shared.setShowCoverSettings(false)}
templates={shared.templates}
loading={shared.templatesLoading}
error={shared.templatesError}
selectedTemplateId={shared.selectedTemplateId}
onSelectTemplate={shared.handleSelectTemplate}
onEditTemplate={shared.handleEditTemplate}
onDeleteTemplate={shared.handleDeleteTemplate}
onCreateNew={shared.handleCreateTemplate}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
onChange={handleFileChange}
/>
</div>
)
}
/* ── 单视频 ── */
/* ── 单视频:原有流程保持不变 ── */
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{(finalVideo || effectiveTaskId) && (
{/* 最终成片信息 */}
{finalVideo && (
<div
style={{
padding: "10px 14px",
@@ -426,51 +249,17 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
color: "var(--text-secondary, #666)",
}}
>
🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
<>
{" "}
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
</>
)}
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
</div>
)}
<div className="xx-cover-actions">
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!canGenerateCover || shared.generating}
loading={shared.generating}
title={!canGenerateCover ? "请先完成视频生成" : ""}
>
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
✨ 自动生成封面
</Button>
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
⚙️ 封面模板
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
⚙️ 封面设置
</Button>
<Button
buttonType="ghost"
onClick={shared.handleUploadClick}
disabled={uploadingLocalCover}
loading={uploadingLocalCover}
>
📷 本地上传
</Button>
<input
ref={shared.uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={shared.handleFileInputChange}
/>
<input
ref={batchUploadRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleBatchUploadChange}
/>
</div>
<div className="xx-section-title">封面预览</div>
@@ -487,26 +276,30 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
</div>
<CoverSettingsModal
open={shared.showCoverSettings}
onClose={() => shared.setShowCoverSettings(false)}
templates={shared.templates}
loading={shared.templatesLoading}
error={shared.templatesError}
selectedTemplateId={shared.selectedTemplateId}
onSelectTemplate={shared.handleSelectTemplate}
onEditTemplate={shared.handleEditTemplate}
onDeleteTemplate={shared.handleDeleteTemplate}
onCreateNew={shared.handleCreateTemplate}
open={showCoverSettings}
onClose={() => setShowCoverSettings(false)}
templates={coverTemplates}
loading={templatesLoading}
error={templatesError}
selectedTemplateId={selectedTemplateId}
onSelectTemplate={handleSelectTemplate}
onEditTemplate={handleEditTemplate}
onDeleteTemplate={handleDeleteTemplate}
onCreateNew={() => {
setShowCoverSettings(false)
setShowCoverEditor(true)
}}
/>
<CoverEditorModal
open={shared.showCoverEditor}
onClose={() => shared.setShowCoverEditor(false)}
template={shared.editingTemplate}
onSave={shared.handleSaveTemplate}
open={showCoverEditor}
onClose={() => setShowCoverEditor(false)}
template={editingTemplate}
onSave={handleSaveTemplate}
/>
<Modal open={shared.generating} closable={false} footer={null} centered>
{/* AI 生成封面进度弹窗 */}
<Modal open={generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
@@ -518,6 +311,4 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
)
}
Step6CoverSettings.displayName = "Step6CoverSettings"
export default Step6CoverSettings
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,7 @@
import React, { useMemo, useState } from "react"
import React from "react"
import type { CoverTemplate } from "../../types/cover"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import "@/components/cover/cover.css"
interface CoverSettingsModalProps {
open: boolean
@@ -17,58 +16,15 @@ interface CoverSettingsModalProps {
onCreateNew: () => void
}
/** 模板缩略图:优先渲染 thumbnail_url;加载失败/无图时展示占位 */
const TemplateThumb: React.FC<{ tpl: CoverTemplate; isSelected: boolean }> = ({
tpl,
isSelected,
}) => {
const [errored, setErrored] = useState(false)
const url = tpl.thumbnail_url && !errored ? tpl.thumbnail_url : ""
// 随机柔和渐变做占位,保证卡片不会灰成一片
const placeholderBg = useMemo(() => {
const palettes = [
["#e0e0e0", "#c0c0c0"],
["#ef4444", "#b91c1c"],
["#374151", "#111827"],
["#3b82f6", "#1d4ed8"],
["#8b5cf6", "#6d28d9"],
["#f97316", "#ea580c"],
["#22c55e", "#15803d"],
["#06b6d4", "#0e7490"],
]
let h = 0
for (const ch of tpl.id || tpl.name || "") h = (h * 31 + ch.charCodeAt(0)) >>> 0
const [a, b] = palettes[h % palettes.length]
return `linear-gradient(135deg, ${a}, ${b})`
}, [tpl.id, tpl.name])
return (
<div
className="xx-cover-template-thumb"
style={{
background: url ? "#000" : placeholderBg,
position: "relative",
overflow: "hidden",
}}
>
{isSelected && <span className="xx-cover-template-check">✓</span>}
{url ? (
<img
src={url}
alt={tpl.name}
onError={() => setErrored(true)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<span style={{ fontSize: 28, opacity: 0.5 }}>🖼️</span>
)}
</div>
)
const GRADIENT_MAP: Record<string, string> = {
default: "linear-gradient(135deg, #e0e0e0, #c0c0c0)",
"bold-red": "linear-gradient(135deg, #ef4444, #b91c1c)",
"elegant-black": "linear-gradient(135deg, #374151, #111827)",
"gradient-blue": "linear-gradient(135deg, #3b82f6, #1d4ed8)",
"gradient-purple": "linear-gradient(135deg, #8b5cf6, #6d28d9)",
"warm-orange": "linear-gradient(135deg, #f97316, #ea580c)",
"fresh-green": "linear-gradient(135deg, #22c55e, #15803d)",
"tech-blue": "linear-gradient(135deg, #06b6d4, #0e7490)",
}
const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
@@ -84,26 +40,12 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
onCreateNew,
}) => {
return (
<Modal
open={open}
onCancel={onClose}
width={800}
title="封面设置"
centered
footer={
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={onClose}>
确认应用
</Button>
</div>
}
>
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
<div className="xx-cover-modal-toolbar">
<Button buttonType="primary">选择素材文件</Button>
<Button buttonType="ghost">导出全部</Button>
<Button buttonType="primary" onClick={onCreateNew}>
+ 创建新模板
创建新模板
</Button>
</div>
@@ -117,77 +59,52 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
)}
{!loading && !error && templates.length === 0 && (
<div
style={{
textAlign: "center",
padding: "40px 0",
color: "var(--text-secondary)",
fontSize: 13,
}}
>
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
</div>
)}
{!loading && !error && templates.length > 0 && (
{!loading && !error && (
<div className="xx-cover-template-grid">
{templates.map((tpl) => {
const isSelected = selectedTemplateId === tpl.id
return (
{templates.map((tpl) => (
<div
key={tpl.id}
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
>
<div
key={tpl.id}
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
onClick={() => onSelectTemplate(tpl.id)}
className="xx-cover-template-thumb"
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
>
<TemplateThumb tpl={tpl} isSelected={isSelected} />
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统</span>}
</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
🖼️
</div>
<div className="xx-cover-template-info">
<div className="xx-cover-template-name">
{tpl.name}
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统模板</span>}
</div>
<div className="xx-cover-template-date">{tpl.created_at}</div>
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
编辑
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onEditTemplate(tpl)}
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
编辑
删除
</Button>
{!tpl.is_system && (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
if (confirm("确定删除此模板?")) {
onDeleteTemplate(tpl.id)
}
}}
>
删除
</Button>
)}
</div>
)}
<Button buttonType="ghost" buttonSize="sm">
导出
</Button>
</div>
</div>
)
})}
</div>
))}
</div>
)}
<div
style={{
marginTop: 12,
padding: "8px 12px",
background: "rgba(124,58,237,0.06)",
borderRadius: 6,
fontSize: 12,
color: "#6d28d9",
}}
>
💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面
</div>
</Modal>
)
}
@@ -1 +1,230 @@
export { default } from "@/components/title/TitleMiniPreview"
/**
* 标题迷你 Canvas 预览(#2001)
*
* 渲染一张指定宽度的小 Canvas 预览标题效果,用于:
* - 预设卡片缩略图
* - 样式面板顶部的实时预览
*
* 与 titleCanvas.ts 渲染逻辑保持一致,但:
* - 固定分辨率(width × 宽高比约 2:1)
* - 不调用 ffmpeg,只做视觉预览
* - 支持背景色块、描边宽度/颜色、阴影参数化、行距、自动换行
*/
import React, { useEffect, useRef } from "react"
import type { TitleSettings } from "../../types"
import { getFontFamily } from "@/components/title/constants"
interface Props {
settings: TitleSettings
width?: number
sampleText?: string
/** 背景(预览用,默认深色渐变模拟视频底),transparent=true 时忽略 */
background?: string
/** 高度(可选,默认按 portrait 选比例) */
height?: number
/** 透明背景(卡片/编辑器预览叠加在图片上时使用) */
transparent?: boolean
/** 纵向竖屏预览(9:16),true 时 aspect=16/9 适配手机视频比例 */
portrait?: boolean
}
/** 按 maxCharsPerLine 自动换行 */
function wrapLines(text: string, maxChars: number): string[] {
const manual = text
.split(/[//\n]/)
.map((l) => l.trim())
.filter(Boolean)
if (!maxChars || maxChars <= 0) return manual
const out: string[] = []
for (const line of manual) {
if (line.length <= maxChars) {
out.push(line)
continue
}
let cur = ""
for (const ch of line) {
cur += ch
if (cur.length >= maxChars) {
out.push(cur)
cur = ""
}
}
if (cur) out.push(cur)
}
return out
}
const TitleMiniPreview: React.FC<Props> = ({
settings,
width = 200,
sampleText,
background = "linear-gradient(135deg,#1f2937,#111827)",
height,
transparent = false,
portrait = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const h = height ?? Math.round(width * (portrait ? 16 / 9 : 1 / 1.8))
const text = (sampleText || settings.title || "预览标题").trim() || "预览标题"
useEffect(() => {
const cvs = canvasRef.current
if (!cvs) return
const dpr = window.devicePixelRatio || 1
cvs.width = width * dpr
cvs.height = h * dpr
cvs.style.width = `${width}px`
cvs.style.height = `${h}px`
const ctx = cvs.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, width, h)
// 背景(transparent 时跳过,用于叠加在图片上)
if (!transparent) {
ctx.fillStyle = "#111827"
ctx.fillRect(0, 0, width, h)
}
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半)
const scale = width / 360
const r = (v: number) => Math.round(v * scale)
// 字体
const size = r(settings.size)
const ff = getFontFamily(settings.font)
const parts: string[] = []
if (settings.italic) parts.push("italic")
if (settings.bold) parts.push("bold")
parts.push(`${size}px`, ff)
ctx.font = parts.join(" ")
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = settings.color
ctx.lineJoin = "round"
// 阴影
const shadowEnabled = !!settings.shadow
const prevShadow = {
c: ctx.shadowColor,
b: ctx.shadowBlur,
ox: ctx.shadowOffsetX,
oy: ctx.shadowOffsetY,
}
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
// 换行
const lines = wrapLines(text, settings.maxCharsPerLine ?? 0)
const lineH = size * (settings.lineHeight ?? 1.2)
const totalH = lines.length * lineH
let startY: number
if (settings.position === "top") {
startY = size / 2 + r(settings.marginTop ?? 24)
} else if (settings.position === "center") {
startY = h / 2 - totalH / 2 + size / 2
} else {
// bottom
const botMargin = portrait ? r(24) : r(16)
startY = h - totalH - botMargin + size / 2
}
let centerX = width / 2
if (settings.position === "custom" && settings.posX != null) {
centerX = (settings.posX / 100) * width
}
// 背景块
if (settings.bgEnabled) {
const pad = r(settings.bgPadding ?? 12)
const rad = r(settings.bgRadius ?? 8)
let maxLineW = 0
for (const l of lines) {
const m = ctx.measureText(l)
if (m.width > maxLineW) maxLineW = m.width
}
const bw = maxLineW + pad * 2
const bh = totalH + pad * 2
const bx = centerX - bw / 2
const by = startY - size / 2 - pad + (size - lineH) / 2
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
roundRect(ctx, bx, by, bw, bh, rad)
ctx.fill()
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
// 描边(先画,再画填充)
const strokeEnabled = !!settings.stroke && (settings.strokeWidth ?? 0) > 0
lines.forEach((line, i) => {
const y = startY + i * lineH
if (strokeEnabled) {
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = r(settings.strokeWidth ?? 4)
ctx.strokeStyle = settings.strokeColor ?? "#000000"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadowEnabled) {
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(settings.shadowBlur ?? 4)
ctx.shadowOffsetX = r(settings.shadowOffsetX ?? 2)
ctx.shadowOffsetY = r(settings.shadowOffsetY ?? 2)
}
}
ctx.fillText(line, centerX, y)
})
// 恢复
ctx.shadowColor = prevShadow.c
ctx.shadowBlur = prevShadow.b
ctx.shadowOffsetX = prevShadow.ox
ctx.shadowOffsetY = prevShadow.oy
}, [settings, width, h, text, transparent, portrait, background])
return (
<canvas
ref={canvasRef}
style={{
borderRadius: 6,
display: "block",
maxWidth: "100%",
background: transparent ? "transparent" : background,
}}
/>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + rr)
ctx.lineTo(x + w, y + h - rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h)
ctx.lineTo(x + rr, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - rr)
ctx.lineTo(x, y + rr)
ctx.quadraticCurveTo(x, y, x + rr, y)
ctx.closePath()
}
export default TitleMiniPreview
@@ -31,7 +31,7 @@ import {
} from "@/components/title/constants"
import { buildPresetPreviewSettings } from "@/components/title/utils"
import TitleMiniPreview from "@/components/title/TitleMiniPreview"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "@/components/title/TitleTemplateEditor"
import { useTitleTemplates } from "@/components/title/useTitleTemplates"
import {
@@ -177,7 +177,7 @@ const ColorPicker: React.FC<{
/* ── 卡片预览:用 ref 测量容器宽度后再渲染透明 Canvas,保证文字清晰 ── */
const FillPreview: React.FC<{
settings: import("@/components/title/settings").TitleStyleSettings
settings: TitleSettings
sampleText: string
portrait?: boolean
}> = ({ settings, sampleText, portrait }) => {
+7 -3
View File
@@ -46,9 +46,13 @@ export const CLIP_COUNT_STEP = 1
export const MAX_PREVIEW_COUNT = 10
export const MIN_PREVIEW_COUNT = 1
/* ── 标题位置选项(统一从公共层重导出) ── */
export { POSITION_OPTIONS } from "@/components/title/position-options"
export type { PositionOption } from "@/components/title/position-options"
/* ── 标题位置选项 ── */
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
/* ── 标题字体:统一使用公共层定义(#2001) ── */
export { getFontFamily } from "@/components/title/constants"
-463
View File
@@ -2739,7 +2739,6 @@
justify-content: center;
font-size: 32px;
color: #ccc;
position: relative;
}
/* 卡片信息区 */
@@ -3336,465 +3335,3 @@
grid-template-columns: minmax(0, 360px);
}
}
/* ================================================================
自定义封面编辑器 (Cover Editor Modal) — xx-ce-*
================================================================ */
/* Header */
.xx-ce-header {
margin-bottom: 16px;
}
.xx-ce-name-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-sm, 6px);
font-size: 14px;
margin-bottom: 12px;
outline: none;
}
.xx-ce-name-input:focus {
border-color: #7c3aed;
}
.xx-ce-header-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Layout */
.xx-ce-layout {
display: flex;
gap: 20px;
min-height: 500px;
}
.xx-ce-left {
width: 300px;
flex-shrink: 0;
max-height: 70vh;
overflow-y: auto;
}
.xx-ce-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
min-height: 480px;
}
/* Section / collapsible panels */
.xx-ce-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
}
.xx-ce-section-header {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4ff;
user-select: none;
}
.xx-ce-section-header:hover {
background: #e8edf8;
}
.xx-ce-section-body {
padding: 12px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.xx-ce-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-status-text {
font-size: 11px;
font-weight: 400;
color: #3b82f6;
}
/* Rows / labels */
.xx-ce-row {
margin: 12px 0;
}
.xx-ce-label {
display: block;
font-size: 12px;
color: #374151;
margin-bottom: 4px;
}
.xx-ce-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.xx-ce-sub-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.xx-ce-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-ce-switch-item {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.xx-ce-switch-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
/* Color picker */
.xx-ce-color-picker {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-color-picker input[type="color"] {
width: 32px;
height: 24px;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
background: none;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.xx-ce-color-hex {
width: 70px;
padding: 2px 6px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
/* Position pair */
.xx-ce-position {
display: flex;
gap: 8px;
}
.xx-ce-position .ant-input-number {
flex: 1;
}
/* Radio button group */
.xx-ce-radio-group {
display: flex;
gap: 0;
}
.xx-ce-radio-btn {
padding: 4px 14px;
font-size: 12px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
.xx-ce-radio-btn:first-child {
border-radius: 4px 0 0 4px;
}
.xx-ce-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
.xx-ce-radio-btn + .xx-ce-radio-btn {
border-left: none;
}
.xx-ce-radio-btn.active {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
border-left: 1px solid #d1d5db;
}
/* Font select dots */
.xx-ce-font-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.xx-ce-font-dot--preset {
background: #10b981; /* 绿:预置爆款中文字体 */
}
.xx-ce-font-dot--hand {
background: #f59e0b; /* 橙:手写/书法字体 */
}
.xx-ce-font-dot--serif {
background: #8b5cf6; /* 紫:衬线字体 */
}
.xx-ce-font-dot--mono {
background: #6b7280; /* 灰:等宽字体 */
}
.xx-ce-font-dot--system {
background: #3b82f6; /* 蓝:系统无衬线 */
}
/* Shadow actions */
.xx-ce-shadow-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.xx-ce-add-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-add-shadow-btn:hover {
background: #6d28d9;
}
.xx-ce-preset-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
}
/* Text background sub-section */
.xx-ce-text-bg-section {
margin-top: 8px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
/* Readonly text display */
.xx-ce-readonly-text {
padding: 6px 10px;
background: #eff6ff;
border-radius: 4px;
font-size: 13px;
color: #1e40af;
margin-bottom: 4px;
}
/* Mask file row */
.xx-ce-file-row {
display: flex;
gap: 6px;
align-items: center;
}
.xx-ce-file-name {
flex: 1;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: #f9fafb;
color: #6b7280;
}
.xx-ce-file-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.xx-ce-file-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
/* ── Canvas / Preview ── */
.xx-ce-canvas-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.xx-ce-canvas {
width: 225px;
height: 400px;
background: #ddd;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.xx-ce-anchor-dot {
position: absolute;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
z-index: 5;
}
/* Portrait element */
.xx-ce-el-portrait {
position: absolute;
background: #a8d4f0;
border: 2px solid #333;
z-index: 2;
}
/* 8 handles: 0=TL 1=T 2=TR 3=R 4=BR 5=B 6=BL 7=L */
.xx-ce-handle {
position: absolute;
width: 8px;
height: 8px;
background: #3b82f6;
border: 1px solid #fff;
z-index: 10;
}
.xx-ce-handle--0 {
top: -4px;
left: -4px;
}
.xx-ce-handle--1 {
top: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--2 {
top: -4px;
right: -4px;
}
.xx-ce-handle--3 {
top: 50%;
right: -4px;
margin-top: -4px;
}
.xx-ce-handle--4 {
bottom: -4px;
right: -4px;
}
.xx-ce-handle--5 {
bottom: -4px;
left: 50%;
margin-left: -4px;
}
.xx-ce-handle--6 {
bottom: -4px;
left: -4px;
}
.xx-ce-handle--7 {
top: 50%;
left: -4px;
margin-top: -4px;
}
/* Background element */
.xx-ce-el-bg {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
/* Mask overlay */
.xx-ce-el-mask {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
/* Text background shape in canvas */
.xx-ce-text-bg {
position: absolute;
z-index: -1;
}
/* Cover template selected check */
.xx-cover-template-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
background: #7c3aed;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
z-index: 2;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
.xx-cover-template-thumb {
position: relative;
}
/* Preview tip */
.xx-ce-preview-tip {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: #6b7280;
}
/* Cover editor modal base gradient */
.xx-ce-canvas {
background: #1a1a2e;
}
/* Antd Slider overrides for editor */
.xx-ce-section-body .ant-slider {
margin: 4px 0 8px;
}
.xx-ce-section-body .ant-slider-rail {
background: #e5e7eb;
}
.xx-ce-section-body .ant-slider-track {
background: #3b82f6;
}
.xx-ce-section-body .ant-slider-handle::after {
box-shadow: 0 0 0 2px #3b82f6;
}
.xx-ce-section-body .ant-slider-mark-text {
font-size: 10px;
}
/* Antd Select dropdown font dots */
.xx-ce-font-select-dropdown .ant-select-item-option-content {
display: flex;
align-items: center;
}
/* Canvas 装饰层(背景/装饰/遮罩/底色/人物/文字背景色块)不接收鼠标事件,
但拖拽的标题/副标题文字(内联 cursor:grab)需要接收 mousedown。
已通过 renderTextStyle 显式设 pointer-events 以外的样式,因此此处只关掉纯装饰层。 */
.xx-ce-canvas-base,
.xx-ce-el-bg,
.xx-ce-el-portrait,
.xx-ce-el-mask {
pointer-events: none;
}
@@ -41,8 +41,8 @@ export interface UseGenerateVideoProps {
enabled: boolean
music_id?: string
}
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态);status=awaiting_cover 表示需进封面选择 */
onGenerationSuccess?: (status?: "completed" | "awaiting_cover") => void
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
onGenerationSuccess?: () => void
/* ── 批量生成(#1677)── */
/** 生成数量(1=单条旧逻辑,>1=批量) */
previewCount?: number
@@ -1,4 +1,4 @@
import { useRef, useCallback, useState, useEffect } from "react"
import { useRef, useCallback, useState } from "react"
import { message } from "antd"
import axios from "axios"
import { getGenerationTask, retryTask as retryGenerationTaskApi } from "@/api/tasks/tasks"
@@ -10,7 +10,7 @@ export interface BatchTaskState {
taskId: string
/** 变体序号(0-based,与标题/封面数组对齐) */
variantIndex: number
status: "running" | "completed" | "awaiting_cover" | "failed" | "queued"
status: "running" | "completed" | "failed"
progress: number
error: string | null
/** 完成后的成片视频 */
@@ -19,7 +19,7 @@ export interface BatchTaskState {
interface UseGenerationPollingOptions {
onProgress: (progress: number) => void
onComplete: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
onComplete: (videos: unknown[]) => void
onFailed: (errorMsg: string) => void
/** 批量:单任务状态变化(第5步逐卡片展示) */
onBatchTaskUpdate?: (taskId: string, patch: Partial<BatchTaskState>) => void
@@ -31,19 +31,13 @@ const MAX_RETRYABLE_ERRORS = 10
const MAX_RESULTS_RETRIES = 3
/**
* 生成状态轮询 Hook(v5 — awaiting_cover 状态识别 + visibilitychange 恢复 + 状态透传)
* 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试)
*
* startPolling(taskId) 轮询单个任务;
* startPollingBatch(tasks) 并行轮询 N 个任务:
* - 每个任务独立进度/状态/失败,通过 onBatchTaskUpdate 实时回传
* - 全部成功才 onComplete(聚合视频按变体顺序);任一失败不影响其他任务继续
* - retryTask(taskId) 单独重试失败任务(重新轮询,后端任务仍在跑则直接接续)
*
* v5 修复(#2088):
* 1. 单任务路径透传 taskStatus(completed / awaiting_cover)到 onComplete,外层据此区分跳转
* 2. 监听 visibilitychange,页面从后台切回可见时立即补拉一次,解决切后台 setInterval 被浏览器
* 降频/冻结导致进度卡在 56% 的问题
* 3. 非 4xx/5xx 网络错误按 3s 退避重试(已有 MAX_RETRYABLE_ERRORS=10 兜底)
*/
export function useGenerationPolling({
onProgress,
@@ -55,15 +49,12 @@ export function useGenerationPolling({
const cancelledRef = useRef(false)
/** 批量任务上下文:taskId → 变体序号 */
const batchContextRef = useRef<Map<string, number>>(new Map())
/** 当前活跃的「立刻补拉一次」函数(visibilitychange 回调使用) */
const immediateTickRef = useRef<(() => void) | null>(null)
const [, forceTick] = useState(0)
const clearTimer = useCallback(() => {
cancelledRef.current = true
progressTimer.current.forEach((t) => clearTimeout(t))
progressTimer.current = []
immediateTickRef.current = null
}, [])
/** 任务完成后拉取结果列表,带重试 */
@@ -105,7 +96,7 @@ export function useGenerationPolling({
runId: number,
callbacks?: {
onTaskProgress?: (pct: number) => void
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
onTaskCompleted?: (videos: unknown[]) => void
onTaskFailed?: (msg: string) => void
},
): Promise<unknown[]> => {
@@ -120,9 +111,8 @@ export function useGenerationPolling({
if (cancelledRef.current || done) return
consecutiveErrors = 0
if (task.status === "completed" || task.status === "awaiting_cover") {
if (task.status === "completed") {
done = true
immediateTickRef.current = null
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
@@ -131,14 +121,13 @@ export function useGenerationPolling({
reject(new Error(msg))
return
}
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
callbacks?.onTaskCompleted?.(videos)
resolve(videos)
return
}
if (task.status === "failed" || task.status === "cancelled") {
done = true
immediateTickRef.current = null
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
@@ -149,7 +138,6 @@ export function useGenerationPolling({
return
}
// running / pending / waiting:更新进度并安排下一次轮询
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
callbacks?.onTaskProgress?.(pct)
if (!callbacks && runId === 0) {
@@ -161,20 +149,16 @@ export function useGenerationPolling({
if (cancelledRef.current || done) return
console.error("[轮询出错] taskId:", taskId, pollErr)
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
// 4xx 视为不可重试(任务不存在/权限问题等),直接失败
if (status && status >= 400 && status < 500) {
done = true
immediateTickRef.current = null
const msg = extractErrorMessage(pollErr, status)
callbacks?.onTaskFailed?.(msg)
reject(new Error(msg))
return
}
// 网络错误 / 5xx:3s 退避重试,最多 MAX_RETRYABLE_ERRORS 次
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
done = true
immediateTickRef.current = null
const msg = "任务状态查询连续失败,请稍后在任务列表查看结果"
callbacks?.onTaskFailed?.(msg)
reject(new Error(msg))
@@ -185,18 +169,6 @@ export function useGenerationPolling({
}
}
// 注册「立刻补拉一次」回调,供 visibilitychange 恢复时调用
// 注意:必须在 done 后清理,避免切换页面时误触发已结束任务的补拉
immediateTickRef.current = () => {
if (!done && !cancelledRef.current) {
// 清除未触发的 setTimeout,立即拉一次
progressTimer.current.forEach((t) => clearTimeout(t))
progressTimer.current = []
consecutiveErrors = 0
void poll()
}
}
const timer = setTimeout(poll, 1500)
progressTimer.current.push(timer)
})
@@ -209,22 +181,12 @@ export function useGenerationPolling({
(taskId: string) => {
cancelledRef.current = false
batchContextRef.current.clear()
let resolvedStatus: "completed" | "awaiting_cover" = "completed"
pollSingleTask(taskId, 0, {
onTaskProgress: (pct) => onProgress(pct),
onTaskCompleted: (videos, taskStatus) => {
resolvedStatus = taskStatus ?? "completed"
onProgress(100)
onComplete(videos, resolvedStatus)
},
onTaskFailed: (msg) => onFailed(msg),
})
.then(() => {
pollSingleTask(taskId, 0)
.then((videos) => {
if (cancelledRef.current) return
// awaiting_cover 是中间态(进封面选择页),不弹"完成"toast;completed 才弹
if (resolvedStatus === "completed") {
message.success("视频生成完成!")
}
onProgress(100)
onComplete(videos)
message.success("视频生成完成!")
})
.catch((err: Error) => {
if (cancelledRef.current) return
@@ -239,7 +201,7 @@ export function useGenerationPolling({
/**
* 批量多任务轮询:
* - 每个任务独立进度/状态回传 onBatchTaskUpdate
* - 全部完成后按变体顺序聚合视频 onComplete
* * 全部完成后按变体顺序聚合视频 onComplete
* - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed
*/
const startPollingBatch = useCallback(
@@ -249,7 +211,6 @@ export function useGenerationPolling({
const progressMap = new Map<string, number>()
const resultMap = new Map<string, unknown[]>()
const failureMap = new Map<string, string>()
const statusMap = new Map<string, "completed" | "awaiting_cover">()
batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex]))
const reportAggregateProgress = () => {
@@ -264,9 +225,7 @@ export function useGenerationPolling({
if (resultMap.size === tasks.length) {
onProgress(100)
const ordered = tasks.map((t) => resultMap.get(t.taskId) || []).flat()
// 批量:任一任务为 awaiting_cover,则整体透传 awaiting_cover(进封面页)
const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover")
onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed")
onComplete(ordered)
message.success(`全部 ${tasks.length} 个视频生成完成!`)
} else if (resultMap.size > 0) {
// 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮
@@ -275,8 +234,7 @@ export function useGenerationPolling({
.filter((t) => resultMap.has(t.taskId))
.map((t) => resultMap.get(t.taskId) || [])
.flat()
const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover")
onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed")
onComplete(ordered)
message.warning(
`${failureMap.size} 个视频生成失败,可点击卡片上的「重试此视频」,成功的视频可先进入下一步`,
)
@@ -301,12 +259,10 @@ export function useGenerationPolling({
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
reportAggregateProgress()
},
onTaskCompleted: (videos, taskStatus) => {
onTaskCompleted: (videos) => {
progressMap.set(taskId, 100)
resultMap.set(taskId, videos)
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
statusMap.set(taskId, _finalStatus)
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
reportAggregateProgress()
checkAllSettled()
},
@@ -337,9 +293,8 @@ export function useGenerationPolling({
}
pollSingleTask(taskId, Date.now(), {
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
onTaskCompleted: (videos, taskStatus) => {
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
onTaskCompleted: (videos) => {
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
message.success(`视频 ${variantIndex + 1} 重试成功`)
},
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
@@ -352,64 +307,5 @@ export function useGenerationPolling({
[pollSingleTask, onBatchTaskUpdate],
)
/**
* visibilitychange 恢复:页面从后台切回前台时,立刻触发一次补拉。
* 解决浏览器后台标签页对 setTimeout 的 1Hz 节流/冻结导致的"进度卡 56%"问题。
*/
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && immediateTickRef.current) {
immediateTickRef.current()
}
}
document.addEventListener("visibilitychange", handleVisibilityChange)
// 页面聚焦也兜底一次(部分浏览器 visibilitychange 触发时机不一致)
const handleFocus = () => {
if (immediateTickRef.current) immediateTickRef.current()
}
window.addEventListener("focus", handleFocus)
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange)
window.removeEventListener("focus", handleFocus)
}
}, [])
/**
* 批量队列模式:逐任务追加到轮询队列(支持串行提交、429 排队重试场景)。
* 与 startPollingBatch 不同的是:
* - 不会 reset batchContextRef;多次调用会累积
* - 不触发整体 onComplete / onFailed(完成判定交给外层 useEffect 按状态聚合)
* - 仍通过 onBatchTaskUpdate 回传单任务状态
*/
const pollBatchTaskQueued = useCallback(
(taskId: string, variantIndex: number) => {
cancelledRef.current = false
batchContextRef.current.set(taskId, variantIndex)
onBatchTaskUpdate?.(taskId, {
taskId,
variantIndex,
status: "running",
progress: 0,
error: null,
videos: [],
})
pollSingleTask(taskId, Date.now(), {
onTaskProgress: (pct) => {
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
},
onTaskCompleted: (videos, taskStatus) => {
const finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
onBatchTaskUpdate?.(taskId, { status: finalStatus, progress: 100, videos })
},
onTaskFailed: (msg) => {
onBatchTaskUpdate?.(taskId, { status: "failed", error: msg })
},
}).catch(() => {
/* onTaskFailed 已处理 */
})
},
[pollSingleTask, onBatchTaskUpdate],
)
return { startPolling, startPollingBatch, pollBatchTaskQueued, retryTask, clearTimer }
return { startPolling, startPollingBatch, retryTask, clearTimer }
}
@@ -12,7 +12,7 @@
import { useCallback, useState } from "react"
import { message } from "antd"
import { generateCover } from "@/api/generation"
import { uploadAssetDirect } from "@/api/assets"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import type { GeneratedVideo } from "@/api/template-editor"
/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */
@@ -53,7 +53,7 @@ interface UseBatchCoversOptions {
}
export function useBatchCovers({
selectedTemplate,
selectedTemplate: _selectedTemplate,
generatedVideos,
titles,
titleStyle,
@@ -93,12 +93,7 @@ export function useBatchCovers({
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
const generateOne = useCallback(
async (index: number): Promise<boolean> => {
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" ||
v.status === "awaiting_cover" ||
v.status === "awaiting_cover",
)
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
@@ -107,57 +102,54 @@ export function useBatchCovers({
addBusy(index)
try {
const titleText = titles[index] || ""
const response = await generateCover(
selectedTemplate && selectedTemplate !== "default" ? selectedTemplate : undefined,
{
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
...(titleText
? {
title_config: {
text: titleText,
font: titleStyle.font,
font_size: titleStyle.size,
font_color: titleStyle.color,
position: titleStyle.position,
bold: titleStyle.bold,
italic: titleStyle.italic,
stroke: titleStyle.stroke
? {
enabled: true,
width: titleStyle.strokeWidth ?? 4,
color: titleStyle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: titleStyle.shadow
? {
enabled: true,
offset_x: titleStyle.shadowOffsetX ?? 2,
offset_y: titleStyle.shadowOffsetY ?? 2,
blur: titleStyle.shadowBlur ?? 4,
color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: titleStyle.lineHeight ?? 1.2,
margin_top: titleStyle.marginTop ?? 24,
max_chars_per_line: titleStyle.maxCharsPerLine ?? 0,
background: titleStyle.bgEnabled
? {
enabled: true,
color: titleStyle.bgColor,
padding: titleStyle.bgPadding,
radius: titleStyle.bgRadius,
}
: { enabled: false },
line_overrides: (titleStyle.lineOverrides ?? []) as Array<
Record<string, unknown>
>,
},
}
: {}),
},
)
const response = await generateCover("default", {
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
...(titleText
? {
title_config: {
text: titleText,
font: titleStyle.font,
font_size: titleStyle.size,
font_color: titleStyle.color,
position: titleStyle.position,
bold: titleStyle.bold,
italic: titleStyle.italic,
stroke: titleStyle.stroke
? {
enabled: true,
width: titleStyle.strokeWidth ?? 4,
color: titleStyle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: titleStyle.shadow
? {
enabled: true,
offset_x: titleStyle.shadowOffsetX ?? 2,
offset_y: titleStyle.shadowOffsetY ?? 2,
blur: titleStyle.shadowBlur ?? 4,
color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: titleStyle.lineHeight ?? 1.2,
margin_top: titleStyle.marginTop ?? 24,
max_chars_per_line: titleStyle.maxCharsPerLine ?? 0,
background: titleStyle.bgEnabled
? {
enabled: true,
color: titleStyle.bgColor,
padding: titleStyle.bgPadding,
radius: titleStyle.bgRadius,
}
: { enabled: false },
line_overrides: (titleStyle.lineOverrides ?? []) as Array<
Record<string, unknown>
>,
},
}
: {}),
})
const url = response.cover?.image_url || response.cover?.thumbnail_url || ""
if (url) {
patchCover(index, url)
@@ -174,7 +166,7 @@ export function useBatchCovers({
removeBusy(index)
}
},
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
)
/** 为第 index 个视频上传自定义封面 */
@@ -182,9 +174,15 @@ export function useBatchCovers({
async (index: number, file: File) => {
addUploading(index)
try {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
message.error("未找到素材库,请先创建")
return
}
const result = await uploadAssetDirect({
file,
kind: "image",
library_id: imageLib.id,
})
const url = result?.url || ""
if (url) {
@@ -205,10 +203,7 @@ export function useBatchCovers({
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
)
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const total = finalVideos.length
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
@@ -2,12 +2,10 @@
* 视频生成 Hook
* 封装视频生成的核心逻辑、状态管理、轮询等
*/
import { useState, useCallback, useEffect, useRef } from "react"
import { useState, useCallback, useEffect } from "react"
import { message } from "antd"
import axios from "axios"
import { type GeneratedVideo, getEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
import { createGenerationTask } from "@/api/tasks/tasks"
import type { CreateGenerationTaskRequest } from "@/api/tasks/types"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling, type BatchTaskState } from "./generate-video/useGenerationPolling"
@@ -15,28 +13,6 @@ import { validateGenerateInputs } from "./generate-video/buildPayload"
import { calculateResolution } from "../utils/calculateResolution"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
export type GenerationCompleteStatus = "completed" | "awaiting_cover" | null
/** 判断是否是用户队列已满 429(需要排队重试而非直接报错) */
function isUserQueueFullError(err: unknown): { waitMs: number } | null {
if (!axios.isAxiosError(err)) return null
if (err.response?.status !== 429 && err.response?.status !== 503) return null
const detail = (err.response?.data as { detail?: unknown })?.detail
const code =
typeof detail === "object" && detail !== null ? (detail as { code?: string }).code : undefined
if (code === "USER_QUEUE_FULL" || code === "SYSTEM_QUEUE_FULL") {
const waitSec =
typeof detail === "object" && detail !== null
? Number((detail as { estimated_wait_seconds?: number }).estimated_wait_seconds) || 0
: 0
return { waitMs: Math.max(15_000, waitSec * 1000 || 30_000) }
}
return null
}
/** sleep */
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
export function useGenerateVideo(props: UseGenerateVideoProps) {
const { selectedTemplate, onGenerationSuccess } = props
@@ -46,29 +22,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const [generated, setGenerated] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
/** #2088:任务最终状态,区分 awaiting_cover(选封面)/ completed(已完成) */
const [completionStatus, setCompletionStatus] = useState<GenerationCompleteStatus>(null)
/** 单视频模式:当前任务 ID(封面 finalize 需要) */
const [currentTaskId, setCurrentTaskId] = useState<string>("")
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
/** 排队中重试的定时器,unmount / 新提交时清理 */
const queueTimersRef = useRef<number[]>([])
const cancelledRef = useRef(false)
const clearQueueTimers = useCallback(() => {
queueTimersRef.current.forEach((id) => clearTimeout(id))
queueTimersRef.current = []
}, [])
useEffect(() => {
return () => {
cancelledRef.current = true
clearQueueTimers()
}
}, [clearQueueTimers])
const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial<BatchTaskState>) => {
setBatchTasks((prev) => {
const list = prev || []
@@ -95,15 +51,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const handleProgress = useCallback((p: number) => setProgress(p), [])
const handleComplete = useCallback(
(videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => {
(videos: unknown[]) => {
setGenerating(false)
setGenerated(true)
const finalStatus: GenerationCompleteStatus = taskStatus ?? "completed"
setCompletionStatus(finalStatus)
setGeneratedVideos(videos as GeneratedVideo[])
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
setBatchTasks((prev) =>
(prev || []).map((t) =>
t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0)
t.status === "completed" && t.videos.length === 0
? {
...t,
videos: (videos as GeneratedVideo[]).filter(
@@ -113,35 +68,23 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
: t,
),
)
onGenerationSuccess?.(finalStatus)
onGenerationSuccess?.()
},
[onGenerationSuccess],
)
const handleFailed = useCallback((errorMsg: string) => {
setGenerating(false)
setGenerateError(errorMsg)
}, [])
/* 批量:任务状态变化时聚合已完成成片(含失败重试成功后补入),
按变体索引排序,供步骤6封面按勾选顺序逐个取视频。
当全部任务都已结束(completed/awaiting_cover/failed)且无排队/渲染中任务时,关闭 generating。 */
按变体索引排序,供步骤6封面按勾选顺序逐个取视频 */
useEffect(() => {
if (batchTasks.length === 0) return
const byVariant = new Map<number, GeneratedVideo>()
let hasQueued = false
let hasRunning = false
let hasSuccess = false
let allDone = true
batchTasks.forEach((t) => {
if (t.status === "queued") hasQueued = true
else if (t.status === "running") hasRunning = true
if (t.status === "completed" || t.status === "awaiting_cover") {
hasSuccess = true
if (t.videos && t.videos.length > 0) {
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
}
}
if (t.status !== "completed" && t.status !== "awaiting_cover" && t.status !== "failed") {
allDone = false
if (t.status === "completed" && t.videos && t.videos.length > 0) {
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
}
})
const ordered = [...byVariant.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v)
@@ -151,185 +94,17 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
return ordered
})
if (allDone && !hasQueued && !hasRunning) {
setGenerating(false)
if (hasSuccess) {
setGenerated(true)
setCompletionStatus("awaiting_cover")
}
}
}, [batchTasks])
const { startPolling, pollBatchTaskQueued, retryTask, clearTimer } = useGenerationPolling({
const { startPolling, startPollingBatch, retryTask, clearTimer } = useGenerationPolling({
onProgress: handleProgress,
onComplete: handleComplete,
onFailed: handleFailed,
onBatchTaskUpdate: handleBatchTaskUpdate,
})
/** 根据 props 构造基础 payload(批量/单任务共用的字段) */
const buildBasePayload = useCallback((): Omit<
CreateGenerationTaskRequest,
"count" | "titles" | "voice_library_ids" | "cover_urls" | "variant_plan_ids"
> => {
const { width: outputWidth, height: outputHeight } = calculateResolution(
props.videoRatio || "9:16",
)
const editMode = props.editMode ?? "random"
const dedupEnabled = props.dedupEnabled !== false
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice
const voiceLibraryId =
editMode === "narrative"
? props.ttsVoiceId || ""
: props.voiceMode === "clone"
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
const bgmConfig = {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
}
const titleConfig = props.titleSettings?.title
? {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
...(props.titleSettings.position === "custom" &&
props.titleSettings.posX != null &&
props.titleSettings.posY != null
? {
pos_x: Math.round(props.titleSettings.posX),
pos_y: Math.round(props.titleSettings.posY),
}
: {}),
bold: props.titleSettings.bold,
italic: props.titleSettings.italic,
stroke: props.titleSettings.stroke
? {
enabled: true,
width: props.titleSettings.strokeWidth ?? 4,
color: props.titleSettings.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.shadow
? {
enabled: true,
offset_x: props.titleSettings.shadowOffsetX ?? 2,
offset_y: props.titleSettings.shadowOffsetY ?? 2,
blur: props.titleSettings.shadowBlur ?? 4,
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: props.titleSettings.lineHeight ?? 1.2,
margin_top: props.titleSettings.marginTop ?? 24,
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
...(props.titleSettings.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.bgColor,
padding: props.titleSettings.bgPadding,
radius: props.titleSettings.bgRadius,
},
}
: { background: { enabled: false } }),
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
line_index: lo.line_index,
text: lo.text,
size: lo.size,
color: lo.color,
bold: lo.bold,
italic: lo.italic,
stroke: lo.stroke,
highlights: lo.highlights?.map((h) => ({
word: h.word,
color: h.color,
bold: h.bold,
scale: h.scale,
})),
})),
...(props.titleSettings.coverTitle
? {
cover_title_config: {
title: props.titleSettings.coverTitle.title,
font: props.titleSettings.coverTitle.font,
font_size: props.titleSettings.coverTitle.size,
font_color: props.titleSettings.coverTitle.color,
bold: props.titleSettings.coverTitle.bold,
italic: props.titleSettings.coverTitle.italic,
position: props.titleSettings.coverTitle.position,
stroke: props.titleSettings.coverTitle.stroke
? {
enabled: true,
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.coverTitle.shadow
? {
enabled: true,
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
color: props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
...(props.titleSettings.coverTitle.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.coverTitle.bgColor,
padding: props.titleSettings.coverTitle.bgPadding,
radius: props.titleSettings.coverTitle.bgRadius,
},
}
: { background: { enabled: false } }),
},
}
: {}),
}
: undefined
const payload: Omit<
CreateGenerationTaskRequest,
"count" | "titles" | "voice_library_ids" | "cover_urls" | "variant_plan_ids"
> = {
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
assembly_mode: editMode,
...(editMode === "narrative" && props.selectedScript?.id
? {
script_id: props.selectedScript.id,
tts_voice_id: props.ttsVoiceId || undefined,
tts_voice_source: props.ttsVoiceSource || undefined,
tts_style: props.ttsStyle || undefined,
}
: {}),
dedup_enabled: dedupEnabled,
voice_library_id: voiceLibraryId,
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
bgm_config: bgmConfig as CreateGenerationTaskRequest["bgm_config"],
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(titleConfig ? ({ title_config: titleConfig } as Record<string, unknown>) : {}),
}
return payload
}, [props, selectedTemplate])
/* ── 生成视频 ──
返回 true 表示任务创建成功并已开始轮询(含排队中);false 表示校验未通过或创建失败 */
返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */
const generate = useCallback(async (): Promise<boolean> => {
const errorMsg = validateGenerateInputs(props)
if (errorMsg) {
@@ -337,28 +112,32 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
return false
}
cancelledRef.current = false
clearQueueTimers()
setGenerating(true)
setProgress(0)
setGenerated(false)
setGenerateError(null)
setCompletionStatus(null)
setBatchTasks([])
setGeneratedVideos([])
setCurrentTaskId("")
clearTimer()
const basePayload = buildBasePayload()
const assetIds = basePayload.asset_ids
const isBatch = (props.previewCount || 1) > 1
try {
// from-assets 兜底:片段不存在则补一次
const { width: outputWidth, height: outputHeight } = calculateResolution(
props.videoRatio || "9:16",
)
const editMode = props.editMode ?? "random"
const dedupEnabled = props.dedupEnabled !== false
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用,
// 后端已改为异步秒级返回,这里做一次轻量兜底:
// 单次查 clips,已有则直接放行;没有则再调一次 from-assets。
if (assetIds.length > 0 && selectedTemplate) {
try {
const clipList = await getEditPlanClips(selectedTemplate, { limit: 500 })
if (clipList.items.length === 0) {
// 片段不存在(极端情况:useStep2Materials 的 debounce 还没触发)
// 手动补一次 from-assets(后端秒级返回)
await createClipsFromAssets(selectedTemplate, assetIds, "main")
}
} catch {
@@ -366,185 +145,219 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
}
if (!isBatch) {
/* ── 单视频:原逻辑(一次提交 count=1) ── */
const hide = message.loading("正在生成预览视频...", 0)
try {
const taskResp = await createGenerationTask({ ...basePayload, count: 1 })
hide()
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
if (taskIds.length === 0) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
setCurrentTaskId(taskIds[0])
startPolling(taskIds[0])
} catch (err) {
hide()
throw err
}
return true
}
const isBatch = (props.previewCount || 1) > 1
const hide = message.loading(
isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...",
0,
)
/* ── 批量:支持任意数量视频,按队列容量串行提交,429 自动排队重试 ── */
const indexes = props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
const batchCount = indexes.length
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
const titlesAll =
(props.variantTitles?.length || 0) >= batchCount
// #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice
const voiceLibraryId =
editMode === "narrative"
? props.ttsVoiceId || ""
: props.voiceMode === "clone"
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
const indexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
const batchCount = isBatch ? indexes.length : 1
// 标题文字数组:批量时按勾选顺序
const titlesArr =
isBatch && (props.variantTitles?.length || 0) >= batchCount
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
: indexes.map(() => props.titleSettings?.title || "")
const voiceArrAll =
props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
? indexes.map(
(i) => props.variantVoiceLibraryIds![i] || basePayload.voice_library_id || "",
)
: []
const coversAll = props.variantCoverUrls?.length
? indexes.map((i) => props.variantCoverUrls![i] || "")
: indexes.map(() => "")
const plansAll = props.variantPlanIds?.length
? indexes.map((i) => props.variantPlanIds![i] || "")
: indexes.map(() => "")
// 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id)
const voiceArr =
isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId)
: []
// 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面)
const coversArr =
isBatch && props.variantCoverUrls?.length
? indexes.map((i) => props.variantCoverUrls![i] || "")
: []
// #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传,
// 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。
// 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。
const variantPlansArr =
isBatch && props.variantPlanIds?.length
? indexes.map((i) => props.variantPlanIds![i] || "")
: []
const hasVariantPlans = variantPlansArr.some((id) => !!id)
const hasAnyVoice = voiceArrAll.some((v) => !!v)
const hasAnyCover = coversAll.some((u) => !!u)
const hasAnyPlan = plansAll.some((id) => !!id)
// 先用占位 ID 把所有变体卡片置为 queued,UI 可见
const placeholderIds = indexes.map((_, i) => `__queued_${Date.now()}_${i}`)
const initialTasks: BatchTaskState[] = indexes.map((variantIndex, i) => ({
taskId: placeholderIds[i],
variantIndex,
status: "queued",
progress: 0,
error: null,
videos: [],
}))
setBatchTasks(initialTasks)
message.loading({
content: `已提交 ${batchCount} 个视频任务,系统按队列容量依次渲染…`,
key: "batch-gen",
duration: 3,
})
/** 将占位 taskId 更新为真实 taskId(卡片引用同一对象) */
const replacePlaceholder = (placeholderId: string, realTaskId: string) => {
setBatchTasks((prev) => {
const idx = prev.findIndex((t) => t.taskId === placeholderId)
if (idx === -1) return prev
const next = [...prev]
next[idx] = { ...next[idx], taskId: realTaskId }
return next
})
}
/** 提交某一索引的单任务(count=1),成功后返回真实 taskId;429/503 则返回 waitMs */
const submitOne = async (
i: number,
): Promise<{ queued: true; waitMs: number } | { queued: false; taskId: string }> => {
const body: CreateGenerationTaskRequest = {
...basePayload,
count: 1,
titles: [titlesAll[i] || ""],
...(hasAnyVoice
? { voice_library_ids: [voiceArrAll[i] || basePayload.voice_library_id || ""] }
: {}),
...(hasAnyCover ? { cover_urls: [coversAll[i] || ""] } : {}),
...(hasAnyPlan && plansAll[i] ? { variant_plan_ids: [plansAll[i]] } : {}),
}
try {
const resp = await createGenerationTask(body)
const item = resp.items?.[0]
const tid = item?.id
if (!tid) throw new Error("创建任务成功但未返回任务 ID")
return { queued: false, taskId: tid }
} catch (err) {
const q = isUserQueueFullError(err)
if (q) return { queued: true, waitMs: q.waitMs }
throw err
}
}
// 串行提交:每次提交一个;429/503 则等待后重试;其它错误立即标记该任务失败
let fatalErr: unknown = null
for (let i = 0; i < batchCount; i++) {
if (cancelledRef.current) return false
const variantIndex = indexes[i]
const placeholderId = placeholderIds[i]
let attempt = 0
let submitted = false
while (!submitted) {
if (cancelledRef.current) return false
attempt++
try {
const result = await submitOne(i)
if (!result.queued) {
replacePlaceholder(placeholderId, result.taskId)
// 先更新到 running,再启动单任务增量轮询(不触发整体 onComplete)
pollBatchTaskQueued(result.taskId, variantIndex)
submitted = true
} else {
// 排队:保持 queued 状态,等待后重试
handleBatchTaskUpdate(placeholderId, {
taskId: placeholderId,
variantIndex,
status: "queued",
progress: 0,
error: null,
})
if (attempt === 1) {
message.info({
content: `队列繁忙,${Math.round(result.waitMs / 1000)} 秒后自动继续提交后续视频…`,
key: "batch-gen",
duration: 4,
})
try {
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
assembly_mode: editMode,
...(editMode === "narrative" && props.selectedScript?.id
? {
script_id: props.selectedScript.id,
tts_voice_id: props.ttsVoiceId || undefined,
tts_voice_source: props.ttsVoiceSource || undefined,
tts_style: props.ttsStyle || undefined,
}
await sleep(Math.min(result.waitMs, 60_000))
}
} catch (err) {
// 非限流错误:该任务标记失败,继续后续任务(不阻断整个批量)
console.error("[batch generate] 任务提交失败:", err)
const msg = translateError(extractBackendError(err))
handleBatchTaskUpdate(placeholderId, {
taskId: placeholderId,
variantIndex,
status: "failed",
error: msg,
progress: 0,
})
submitted = true
if (!fatalErr) fatalErr = err
}
}
}
: {}),
dedup_enabled: dedupEnabled,
voice_library_id: voiceLibraryId,
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
bgm_config: {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(isBatch ? { count: batchCount } : {}),
...(titlesArr.length ? { titles: titlesArr } : {}),
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
...(coversArr.length ? { cover_urls: coversArr } : {}),
...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}),
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
...(props.titleSettings.position === "custom" &&
props.titleSettings.posX != null &&
props.titleSettings.posY != null
? {
pos_x: Math.round(props.titleSettings.posX),
pos_y: Math.round(props.titleSettings.posY),
}
: {}),
bold: props.titleSettings.bold,
italic: props.titleSettings.italic,
stroke: props.titleSettings.stroke
? {
enabled: true,
width: props.titleSettings.strokeWidth ?? 4,
color: props.titleSettings.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.shadow
? {
enabled: true,
offset_x: props.titleSettings.shadowOffsetX ?? 2,
offset_y: props.titleSettings.shadowOffsetY ?? 2,
blur: props.titleSettings.shadowBlur ?? 4,
color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
line_height: props.titleSettings.lineHeight ?? 1.2,
margin_top: props.titleSettings.marginTop ?? 24,
max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0,
...(props.titleSettings.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.bgColor,
padding: props.titleSettings.bgPadding,
radius: props.titleSettings.bgRadius,
},
}
: { background: { enabled: false } }),
line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({
line_index: lo.line_index,
text: lo.text,
size: lo.size,
color: lo.color,
bold: lo.bold,
italic: lo.italic,
stroke: lo.stroke,
highlights: lo.highlights?.map((h) => ({
word: h.word,
color: h.color,
bold: h.bold,
scale: h.scale,
})),
})),
...(props.titleSettings.coverTitle
? {
cover_title_config: {
title: props.titleSettings.coverTitle.title,
font: props.titleSettings.coverTitle.font,
font_size: props.titleSettings.coverTitle.size,
font_color: props.titleSettings.coverTitle.color,
bold: props.titleSettings.coverTitle.bold,
italic: props.titleSettings.coverTitle.italic,
position: props.titleSettings.coverTitle.position,
stroke: props.titleSettings.coverTitle.stroke
? {
enabled: true,
width: props.titleSettings.coverTitle.strokeWidth ?? 4,
color: props.titleSettings.coverTitle.strokeColor ?? "#000000",
}
: { enabled: false },
shadow: props.titleSettings.coverTitle.shadow
? {
enabled: true,
offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2,
offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2,
blur: props.titleSettings.coverTitle.shadowBlur ?? 4,
color:
props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)",
}
: { enabled: false },
...(props.titleSettings.coverTitle.bgEnabled
? {
background: {
enabled: true,
color: props.titleSettings.coverTitle.bgColor,
padding: props.titleSettings.coverTitle.bgPadding,
radius: props.titleSettings.coverTitle.bgRadius,
},
}
: { background: { enabled: false } }),
},
}
: {}),
},
}
: {}),
})
hide()
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
if (fatalErr) {
// 有任务失败但其余已成功,整体不 throw;由 UI 展示单个失败卡片
if (taskIds.length === 0) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
if (taskIds.length > 1) {
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
} else {
startPolling(taskIds[0])
}
} catch (err) {
hide()
throw err
}
return true
} catch (err: unknown) {
console.error("[handleGenerate] 生成失败:", err)
setGenerating(false)
const backendMsg = extractBackendError(err)
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err)
const finalMsg = translateError(backendMsg)
setGenerateError(finalMsg)
setGenerating(false)
message.error(finalMsg)
return false
}
}, [
props,
clearTimer,
startPolling,
selectedTemplate,
buildBasePayload,
handleBatchTaskUpdate,
clearQueueTimers,
pollBatchTaskQueued,
])
return true
}, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate])
const retry = useCallback(() => {
setGenerateError(null)
@@ -554,10 +367,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
/** 第5步:单独重试某个失败任务 */
const retryBatchTask = useCallback(
(taskId: string) => {
handleBatchTaskUpdate(taskId, { status: "running", progress: 0, error: null, videos: [] })
retryTask(taskId)
},
[retryTask, handleBatchTaskUpdate],
[retryTask],
)
const dismissError = useCallback(() => {
@@ -602,8 +414,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
generated,
generateError,
generatedVideos,
completionStatus,
currentTaskId,
generate,
retry,
retryBatchTask,
@@ -48,11 +48,7 @@ export function useStep6Cover({
const [templatesError, setTemplatesError] = useState<string | null>(null)
/** 最终成片:取第一个已完成视频 */
const finalVideo =
generatedVideos.find(
(v) =>
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
) || generatedVideos[0]
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
/** 从后端加载封面模板列表 */
const loadTemplates = useCallback(async () => {
@@ -175,18 +171,7 @@ export function useStep6Cover({
}, [])
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
// 系统模板不可修改:复制为新模板草稿,走"另存为"流程
if (tpl.is_system) {
setEditingTemplate({
...tpl,
id: "",
name: tpl.name + " 副本",
is_system: false,
created_at: "",
})
} else {
setEditingTemplate(tpl)
}
setEditingTemplate(tpl)
setShowCoverEditor(true)
}, [])
@@ -194,25 +179,20 @@ export function useStep6Cover({
const handleSaveTemplate = useCallback(
async (tpl: CoverTemplate) => {
try {
// 系统模板或无 id(新建/副本)→ 走创建分支;否则走更新
const isSystem = coverTemplates.find((t) => t.id === tpl.id)?.is_system === true
const shouldCreate = !tpl.id || isSystem
if (shouldCreate) {
const created = await createCoverTemplate({
name: tpl.name || "我的封面模板",
config: tpl.config,
})
setCoverTemplates((prev) => [...prev, created])
setSelectedTemplateId(created.id || tpl.id)
} else {
if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) {
const updated = await updateCoverTemplate(tpl.id, {
name: tpl.name,
config: tpl.config,
})
setCoverTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
} else {
const created = await createCoverTemplate({
name: tpl.name,
config: tpl.config,
})
setCoverTemplates((prev) => [...prev, created])
}
setShowCoverEditor(false)
setEditingTemplate(null)
} catch (err) {
console.error("[Step6] 保存模板失败:", err)
}
@@ -4,7 +4,7 @@
*
* - 步骤1(选择模式):下一步分支由外层弹窗处理(VoiceSelectModal / ScriptSelectModal),
* 本 hook 的 goNext 仅在未选模式时拦截;外层 Modal onConfirm 里主动 setCurrentStep(2)。
* - 步骤2(选择素材):直接进入步骤3,数组长度对齐由 onBeforeEnterStep3 保证。
* - 步骤2(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3。
* - 步骤3 底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate),
* 创建成功后跳步骤4;本 hook 的 goNext 只负责 2→3 和 4→5 的「下一步」。
* - 步骤4(确认生成进度页):全部渲染完成后「下一步」解锁进封面。
@@ -23,10 +23,10 @@ export interface UseStepNavigationOptions {
titleSettings: TitleSettings
/** 是否已完成视频生成(步骤4全部渲染完成后才能进入封面) */
generated: boolean
/** 点素材下一步时弹出数量选择弹窗 */
onOpenCountModal: () => void
/** 步骤1下一步:根据 editMode 打开对应弹窗(随机→配音 / 叙事→文案) */
onOpenStep1Modal: () => void
/** 进入步骤3前自动对齐数组(previewTitles/voiceLibraryIds/previewCovers/selectedVariantIds)长度到 previewCount */
onBeforeEnterStep3?: () => void
}
export interface UseStepNavigationReturn {
@@ -42,8 +42,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
selectedMaterials,
smartSelectedIds,
generated,
onOpenCountModal,
onOpenStep1Modal,
onBeforeEnterStep3,
} = options
const goNext = () => {
@@ -62,9 +62,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
// 直接进入步骤3(生成数量在 Step1 已设置);对齐数组长度
onBeforeEnterStep3?.()
setCurrentStep(3)
// 弹数量选择弹窗
onOpenCountModal()
return
}
// 步骤4(确认生成):全部渲染完成后才能下一步进封面
+8 -274
View File
@@ -32,279 +32,6 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
thumbnail_url: "",
}
/** 文字方向 */
export type TextDirection = "horizontal" | "vertical"
/** 文字背景形状 */
export type TextBgShape = "rectangle" | "polygon"
/** 描边样式 */
export type StrokeStyle = "solid" | "dashed"
/** 阴影层 */
export interface ShadowLayer {
color: string
offsetX: number
offsetY: number
blur: number
}
/** 文字位置 */
export interface TextPosition {
x: number
y: number
}
/** 文字背景配置 */
export interface TextBackground {
enabled: boolean
color: string
opacity: number
shape: TextBgShape
width: number
height: number
/** 相对文字的上下偏移(百分比),背景自动跟随文字位置 */
offsetY: number
}
/** 文字样式配置(主标题/副标题共用) */
export interface TextStyleConfig {
text: string
fontFamily: string
fontSize: number
fontWeight: number
direction: TextDirection
charsPerLine: number
letterSpacing: number
lineHeight: number
color: string
strokeColor: string
strokeWidth: number
shadows: ShadowLayer[]
traditionalShadow: boolean
position: TextPosition
rotation: number
background: TextBackground
}
/** 编辑器完整配置 */
export interface CoverEditorConfig {
// 基础设置
blurEnabled: boolean
blurAmount: number
personStrokeEnabled: boolean
personStrokeStyle: StrokeStyle
personStrokeColor: string
personStrokeWidth: number
autoSplitEnabled: boolean
titleMaxChars: number
subtitleMaxChars: number
// 人像设置
portraitEnabled: boolean
portraitSize: number
portraitPosition: TextPosition
portraitImage?: string
// 背景设置
backgroundEnabled: boolean
backgroundSize: number
backgroundPosition: TextPosition
backgroundImage?: string
backgroundColor?: string
// 主标题
title: TextStyleConfig
// 副标题
subtitle: TextStyleConfig
// 蒙版
maskEnabled: boolean
maskImage: string
maskSize: number
maskPosition: TextPosition
maskColor: string
maskOpacity: number
maskShape: string
}
/** 默认主标题配置 */
export const DEFAULT_TITLE_CONFIG: TextStyleConfig = {
text: "主标题文字",
fontFamily: "思源黑体",
fontSize: 120,
fontWeight: 700,
direction: "horizontal",
charsPerLine: 10,
letterSpacing: 24,
lineHeight: 144,
color: "#FFD700",
strokeColor: "#000000",
strokeWidth: 3,
shadows: [],
traditionalShadow: false,
position: { x: 50, y: 30 },
rotation: 0,
background: {
enabled: false,
color: "#FFFFFF",
opacity: 25,
shape: "polygon",
width: 30,
height: 10,
offsetY: 0,
},
}
/** 默认副标题配置 */
export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = {
text: "副标题文字",
fontFamily: "思源黑体",
fontSize: 82,
fontWeight: 500,
direction: "horizontal",
charsPerLine: 17,
letterSpacing: 23,
lineHeight: 72,
color: "#FFFFFF",
strokeColor: "#000000",
strokeWidth: 1,
shadows: [],
traditionalShadow: false,
position: { x: 50, y: 70 },
rotation: 0,
background: {
enabled: true,
color: "#000000",
opacity: 70,
shape: "rectangle",
width: 100,
height: 20,
offsetY: 8,
},
}
/** 默认编辑器配置 */
export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = {
blurEnabled: false,
blurAmount: 10,
personStrokeEnabled: false,
personStrokeStyle: "solid",
personStrokeColor: "#FFFFFF",
personStrokeWidth: 8,
autoSplitEnabled: false,
titleMaxChars: 4,
subtitleMaxChars: 10,
portraitEnabled: false,
portraitSize: 50,
portraitPosition: { x: 50, y: 70 },
backgroundEnabled: true,
backgroundSize: 100,
backgroundPosition: { x: 50, y: 50 },
title: DEFAULT_TITLE_CONFIG,
subtitle: DEFAULT_SUBTITLE_CONFIG,
maskEnabled: false,
maskImage: "",
maskSize: 100,
maskPosition: { x: 50, y: 50 },
maskColor: "#000000",
maskOpacity: 40,
maskShape: "矩形",
}
/** 预置字体(已与 @/components/title/constants 字体表保持一致;自定义商业字体兜底 Google Fonts 开源中文字体) */
// 封面编辑器预置字体:与标题样式字体列表保持一致(从 @/components/title/constants 同步),
// 并补全西文常用系统字体,保证在中英文环境下都有可用字体。
// 注:需要配合 index.html 引入的 Google Fonts(Noto Sans SC / ZCOOL / Ma Shan Zheng 等)。
export interface CoverFont {
name: string
family: string
tag?: "preset" | "hand" | "serif" | "sans" | "mono"
}
/** 预置中文字体(爆款/常用) */
export const PRESET_FONTS: CoverFont[] = [
{
name: "优设标题黑",
family:
'"YouSheBiaoTiHei","ZCOOL QingKe HuangYou","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "阿里普惠体Bold",
family:
'"Alibaba PuHuiTi","Alibaba Sans","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "抖音美好体",
family:
'"Douyin Sans","ZCOOL KuaiLe","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "思源黑体Heavy",
family: '"Noto Sans SC","Source Han Sans SC Heavy","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "思源黑体",
family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
tag: "preset",
},
{
name: "思源宋体",
family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif',
tag: "serif",
},
{ name: "站酷小薇体", family: '"ZCOOL XiaoWei","Noto Serif SC",serif', tag: "preset" },
{ name: "马善政毛笔", family: '"Ma Shan Zheng","STXingkai","KaiTi",cursive', tag: "hand" },
{ name: "龙藏体", family: '"Long Cang","STXingkai",cursive', tag: "hand" },
{ name: "楷体", family: '"KaiTi","STKaiti","DFKai-SB",serif', tag: "serif" },
{
name: "苹方",
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
tag: "sans",
},
{
name: "微软雅黑",
family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
tag: "sans",
},
]
/** 系统字体(西文 + 通用中文) */
export const SYSTEM_FONTS: CoverFont[] = [
{ name: "Arial", family: "Arial, Helvetica, sans-serif", tag: "sans" },
{ name: "Helvetica", family: "Helvetica, Arial, sans-serif", tag: "sans" },
{ name: "Times New Roman", family: '"Times New Roman", Times, serif', tag: "serif" },
{ name: "Georgia", family: "Georgia, serif", tag: "serif" },
{ name: "Verdana", family: "Verdana, Geneva, sans-serif", tag: "sans" },
{ name: "Tahoma", family: "Tahoma, Geneva, sans-serif", tag: "sans" },
{ name: "Impact", family: 'Impact, "Arial Black", sans-serif', tag: "sans" },
{ name: "Comic Sans MS", family: '"Comic Sans MS", cursive', tag: "hand" },
{ name: "Courier New", family: '"Courier New", Courier, monospace', tag: "mono" },
{ name: "宋体", family: "SimSun, 'Noto Serif SC', serif", tag: "serif" },
{ name: "黑体", family: "SimHei, 'Noto Sans SC', sans-serif", tag: "sans" },
{ name: "仿宋", family: "FangSong, 'Noto Serif SC', serif", tag: "serif" },
{ name: "Trebuchet MS", family: '"Trebuchet MS", sans-serif', tag: "sans" },
{ name: "Lucida Console", family: '"Lucida Console", Monaco, monospace', tag: "mono" },
{ name: "Palatino", family: 'Palatino, "Palatino Linotype", serif', tag: "serif" },
{ name: "Garamond", family: "Garamond, serif", tag: "serif" },
{ name: "Calibri", family: "Calibri, sans-serif", tag: "sans" },
{ name: "Cambria", family: "Cambria, serif", tag: "serif" },
{ name: "Candara", family: "Candara, sans-serif", tag: "sans" },
{ name: "Consolas", family: "Consolas, monospace", tag: "mono" },
]
/** 所有字体列表 */
export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS]
/** 封面模板 */
export interface CoverTemplate {
id: string
@@ -312,5 +39,12 @@ export interface CoverTemplate {
thumbnail_url: string
is_system: boolean
created_at: string
config?: CoverEditorConfig
config?: {
background_enabled?: boolean
background_color?: string
portrait_enabled?: boolean
title_text?: string
subtitle_text?: string
mask_enabled?: boolean
}
}
-5
View File
@@ -45,11 +45,6 @@ export const STATUS_CONFIG: Record<
color: "processing",
icon: <SyncOutlined spin />,
},
awaiting_cover: {
label: "待选封面",
color: "warning",
icon: <ClockCircleOutlined />,
},
completed: {
label: "已完成",
color: "success",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,264 +0,0 @@
/**
* 爆款视频素材选择弹窗(通用版,支持 image/video/voice)
* 基于 ai-avatar 的 ModalAssetPicker 改造:
* - kind 可传 "image" | "video" | "voice"
* - 多图场景 multiple=true 时底部"确认选择"
* - 单选场景点击即回调关闭
*/
import { useEffect, useState } from "react"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
export interface AssetPickerModalProps {
open: boolean
kind: "image" | "video" | "voice"
multiple?: boolean
title?: string
onClose: () => void
onSelect: (assets: AssetItem[]) => void
}
const KIND_LABEL: Record<AssetPickerModalProps["kind"], string> = {
image: "图片",
video: "视频",
voice: "音频",
}
const MIME_KIND: Record<AssetPickerModalProps["kind"], string> = {
image: "image",
video: "video",
voice: "audio",
}
export default function AssetPickerModal({
open,
kind,
multiple = false,
title,
onClose,
onSelect,
}: AssetPickerModalProps) {
const [keyword, setKeyword] = useState("")
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
const [libraryId, setLibraryId] = useState<string>("")
const [assets, setAssets] = useState<AssetItem[]>([])
const [picked, setPicked] = useState<Set<string>>(new Set())
const [loadingLibs, setLoadingLibs] = useState(false)
const [loadingAssets, setLoadingAssets] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
if (!open) return
setKeyword("")
setLibraries([])
setLibraryId("")
setAssets([])
setError("")
setPicked(new Set())
}, [open])
useEffect(() => {
if (!open) return
let cancelled = false
setLoadingLibs(true)
getAssetLibraries(kind)
.then((libs) => {
if (cancelled) return
const list = Array.isArray(libs) ? libs : []
setLibraries(list)
if (list.length > 0) setLibraryId(list[0].id)
})
.catch(() => {
if (!cancelled) setError("素材库加载失败,请重试")
})
.finally(() => {
if (!cancelled) setLoadingLibs(false)
})
return () => {
cancelled = true
}
}, [open, kind])
useEffect(() => {
if (!open || !libraryId) return
let cancelled = false
setLoadingAssets(true)
const load = async () => {
try {
const { items } = await getAssets(libraryId, { page_size: 100 })
if (cancelled) return
let list = Array.isArray(items) ? items : []
const mimePrefix = MIME_KIND[kind]
list = list.filter((a) => !a.mime_type || a.mime_type.startsWith(mimePrefix))
const kw = keyword.trim()
if (kw) list = list.filter((a) => a.name?.includes(kw))
setAssets(list)
setError("")
} catch {
if (!cancelled) {
setError("素材加载失败,请重试")
setAssets([])
}
} finally {
if (!cancelled) setLoadingAssets(false)
}
}
const timer = window.setTimeout(load, 250)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [open, libraryId, keyword, kind])
const thumbFor = (a: AssetItem) => {
if (kind === "image") return a.thumbnail_url || a.file_url
if (kind === "video") return a.thumbnail_url
return ""
}
const togglePick = (id: string) => {
if (multiple) {
setPicked((prev) => {
const n = new Set(prev)
if (n.has(id)) n.delete(id)
else n.add(id)
return n
})
} else {
const asset = assets.find((a) => a.id === id)
if (asset) {
onSelect([asset])
onClose()
}
}
}
const handleConfirm = () => {
const list = assets.filter((a) => picked.has(a.id))
if (list.length > 0) onSelect(list)
onClose()
}
if (!open) return null
return (
<div className="vv-modal-mask" onClick={onClose}>
<div className="vv-modal" onClick={(e) => e.stopPropagation()}>
<div className="vv-modal-head">
<span className="vv-modal-title">{title || `选择${KIND_LABEL[kind]}素材`}</span>
<button className="vv-modal-close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
<div className="vv-modal-body">
<div className="vv-asset-search">
<select
className="vv-input"
style={{ width: 170, flex: "0 0 auto" }}
value={libraryId}
onChange={(e) => setLibraryId(e.target.value)}
disabled={loadingLibs || libraries.length === 0}
>
{libraries.length === 0 ? (
<option value="">
{loadingLibs ? "加载中…" : `暂无${KIND_LABEL[kind]}素材库`}
</option>
) : (
libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
📁 {lib.name}
</option>
))
)}
</select>
<input
className="vv-input"
type="text"
placeholder={`搜索${KIND_LABEL[kind]}名称…`}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
{libraries.length === 0 && !loadingLibs ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">📁</div>
暂无{KIND_LABEL[kind]}素材库,请先在「素材库」中创建并上传
</div>
) : loadingAssets ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">⏳</div>
素材加载中…
</div>
) : error ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">⚠️</div>
{error}
</div>
) : assets.length === 0 ? (
<div className="vv-modal-empty">
<div className="vv-empty-icon">
{kind === "image" ? "🖼️" : kind === "video" ? "🎬" : "🎵"}
</div>
{kind === "voice" ? (
<>
<div style={{ marginTop: 8, fontSize: 13 }}>暂无配音素材</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#9ca3af" }}>
请先在「配音/我的音色」中上传音频文件,或在素材库管理中添加
</div>
</>
) : (
<>该素材库暂无{KIND_LABEL[kind]}素材</>
)}
</div>
) : (
<div className={`vv-asset-thumbs vv-asset-${kind}`}>
{assets.map((asset) => {
const active = picked.has(asset.id)
const thumb = thumbFor(asset)
return (
<div
key={asset.id}
className={`vv-thumb-card${active ? " selected" : ""}`}
onClick={() => togglePick(asset.id)}
>
{thumb ? (
<img src={thumb} alt={asset.name} />
) : kind === "video" ? (
<video src={asset.file_url} muted preload="metadata" />
) : (
<div className="vv-thumb-ph">{kind === "voice" ? "🎵" : "📄"}</div>
)}
{active && <div className="vv-thumb-check">✓</div>}
<div className="vv-thumb-name" title={asset.name}>
<span className="vv-thumb-name-txt">{asset.name}</span>
{kind === "voice" &&
typeof asset.duration === "number" &&
asset.duration > 0 && (
<span className="vv-thumb-dur">{Math.round(asset.duration)}s</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{multiple && (
<div className="vv-modal-foot">
<button className="vv-btn vv-btn-ghost vv-btn-sm" onClick={onClose}>
取消
</button>
<button
className="vv-btn vv-btn-primary"
style={{ width: "auto", marginTop: 0, padding: "8px 18px" }}
onClick={handleConfirm}
disabled={picked.size === 0}
>
确认选择({picked.size})
</button>
</div>
)}
</div>
</div>
)
}
@@ -1,376 +0,0 @@
/**
* 内置音色选择弹窗(浅色紫调版)
* - 标题「选择音色」+ 搜索框 + 分类筛选 + 3列卡片网格 + 试听 + 选中 + 完成选择
*/
import React, { useEffect, useMemo, useRef, useState } from "react"
import {
CloseOutlined,
SearchOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
UserOutlined,
} from "@ant-design/icons"
import { Select, Input } from "antd"
export interface PresetVoice {
id: string
name: string
gender?: "female" | "male" | "child" | "other"
gender_label?: string
category?: string
avatar_url?: string
sample_audio_url?: string
desc?: string
}
interface Props {
open: boolean
voices?: PresetVoice[]
loading?: boolean
selectedId?: string
onClose: () => void
onConfirm: (voice: PresetVoice) => void
}
/** 兜底 mock 音色(后端 /api/v1/tts/presets 返回字段不够时使用) */
const MOCK_VOICES: PresetVoice[] = [
{
id: "long-xiaochun",
name: "龙小淳",
gender: "female",
category: "女声",
desc: "知性积极女声,适合语音助手",
},
{
id: "long-xiaoxia",
name: "龙小夏",
gender: "female",
category: "女声",
desc: "沉稳权威女声,适合新闻播报",
},
{
id: "long-xiaoyan",
name: "龙小颜",
gender: "female",
category: "女声",
desc: "温柔甜美女声,适合情感口播",
},
{
id: "long-xiaotong",
name: "龙小彤",
gender: "female",
category: "女声",
desc: "活力少女音,适合短视频带货",
},
{
id: "long-sanshu",
name: "龙三叔",
gender: "male",
category: "男声",
desc: "沉稳质感男声,适合有声书",
},
{
id: "long-xiaogang",
name: "龙小刚",
gender: "male",
category: "男声",
desc: "阳光活力男声,适合解说",
},
{
id: "long-xiaocheng",
name: "龙小诚",
gender: "male",
category: "男声",
desc: "磁性商务男声,适合品牌宣传",
},
{
id: "long-xiaozhi",
name: "龙小智",
gender: "child",
category: "童声",
desc: "可爱童声,适合亲子内容",
},
{
id: "long-yue",
name: "龙悦",
gender: "female",
category: "情绪",
desc: "温柔治愈女声,适合睡前/助眠",
},
{
id: "long-xiaodong",
name: "龙晓东",
gender: "male",
category: "方言",
desc: "东北方言男声,接地气",
},
{ id: "long-xiaoling", name: "龙小玲", gender: "female", category: "方言", desc: "粤语女声" },
{
id: "long-xiaoxiao-neural",
name: "晓晓",
gender: "female",
category: "女声",
desc: "温柔女声",
},
]
const CATEGORY_LABELS: Record<string, string> = {
all: "全部分类",
female: "女声",
male: "男声",
child: "童声",
dialect: "方言",
emotion: "情绪",
}
const GENDER_LABEL = (v: PresetVoice) => {
if (v.gender_label) return v.gender_label
const g = v.gender
if (g === "female") return "女声·女声"
if (g === "male") return "男声·男声"
if (g === "child") return "童声·童声"
return "性别未标注·其他"
}
const AVATAR_BG = (gender?: string) => {
if (gender === "female") return "#fce7f3"
if (gender === "male") return "#dbeafe"
if (gender === "child") return "#fef3c7"
return "#f3f0ff"
}
const AVATAR_COLOR = (gender?: string) => {
if (gender === "female") return "#be185d"
if (gender === "male") return "#1d4ed8"
if (gender === "child") return "#b45309"
return "#7c3aed"
}
const PresetVoicePickerModal: React.FC<Props> = ({
open,
voices,
loading,
selectedId,
onClose,
onConfirm,
}) => {
const [keyword, setKeyword] = useState("")
const [category, setCategory] = useState<string>("all")
const [pickedId, setPickedId] = useState<string | undefined>(selectedId)
const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
if (open) {
setKeyword("")
setCategory("all")
setPickedId(selectedId)
setPlayingId(null)
}
}, [open, selectedId])
// 停止播放
useEffect(() => {
return () => {
audioRef.current?.pause()
audioRef.current = null
}
}, [])
// 合并真实数据和 mock:如果真实数据 gender/category 缺失,用 mock 兜底
const allVoices: PresetVoice[] = useMemo(() => {
const realList: PresetVoice[] = (voices || []).map((v) => {
// 按 name 模糊匹配 mock 获取补充信息
const mockMatch = MOCK_VOICES.find(
(m) => v.name?.includes(m.name.slice(1)) || m.name.includes(v.name?.slice(0, 2) || "___"),
)
return {
...v,
gender: v.gender || mockMatch?.gender,
category:
v.category ||
mockMatch?.category ||
(v.gender === "female" ? "女声" : v.gender === "male" ? "男声" : undefined),
desc: v.desc || mockMatch?.desc,
sample_audio_url: v.sample_audio_url,
}
})
// 如果没有真实数据,使用 mock
return realList.length > 0 ? realList : MOCK_VOICES
}, [voices])
const categories = useMemo(() => {
const set = new Set<string>()
allVoices.forEach((v) => {
if (v.category) set.add(v.category)
})
return Array.from(set)
}, [allVoices])
const filtered = useMemo(() => {
const kw = keyword.trim().toLowerCase()
return allVoices.filter((v) => {
if (category !== "all") {
if (v.category !== category && category !== CATEGORY_LABELS[v.gender || ""]) {
// gender 兜底匹配
if (
!(category === "女声" && v.gender === "female") &&
!(category === "男声" && v.gender === "male") &&
!(category === "童声" && v.gender === "child") &&
!(category === "方言" && v.category === "方言") &&
!(category === "情绪" && v.category === "情绪")
) {
return false
}
}
}
if (!kw) return true
return (
v.name?.toLowerCase().includes(kw) ||
v.desc?.toLowerCase().includes(kw) ||
v.category?.toLowerCase().includes(kw)
)
})
}, [allVoices, keyword, category])
const handlePreview = (v: PresetVoice) => {
if (!v.sample_audio_url) {
// 无示例音频
return
}
if (playingId === v.id) {
audioRef.current?.pause()
setPlayingId(null)
return
}
audioRef.current?.pause()
const a = new Audio(v.sample_audio_url)
a.onended = () => setPlayingId(null)
a.onerror = () => setPlayingId(null)
a.play().catch(() => {})
audioRef.current = a
setPlayingId(v.id)
}
const handleConfirm = () => {
const picked = allVoices.find((v) => v.id === pickedId)
if (!picked) return
onConfirm(picked)
}
if (!open) return null
return (
<div className="vv-modal-mask" onClick={onClose}>
<div className="vv-modal vv-modal-lg" onClick={(e) => e.stopPropagation()}>
<div className="vv-modal-head">
<div className="vv-modal-title">选择音色</div>
<button className="vv-modal-close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="vv-modal-body">
{/* 搜索 */}
<Input
className="vv-voice-search"
placeholder="搜索音色名称或风格"
prefix={<SearchOutlined style={{ color: "#9ca3af" }} />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
size="large"
/>
{/* 分类筛选 */}
<div className="vv-voice-cat-row">
<span className="vv-voice-cat-label">音色分类</span>
<Select
value={category}
onChange={setCategory}
style={{ width: 180 }}
options={[
{ value: "all", label: "全部分类" },
...[
"女声",
"男声",
"童声",
"方言",
"情绪",
...categories.filter(
(c) => !["女声", "男声", "童声", "方言", "情绪"].includes(c),
),
].map((c) => ({ value: c, label: c })),
]}
/>
</div>
{/* 卡片网格 */}
<div className="vv-voice-grid">
{loading && filtered.length === 0 ? (
<div className="vv-modal-empty">加载中…</div>
) : filtered.length === 0 ? (
<div className="vv-modal-empty">没有匹配的音色</div>
) : (
filtered.map((v) => {
const isPicked = pickedId === v.id
const isPlaying = playingId === v.id
return (
<div
key={v.id}
className={`vv-voice-card ${isPicked ? "selected" : ""}`}
onClick={() => setPickedId(v.id)}
>
<div
className="vv-voice-card-avatar"
style={{ background: AVATAR_BG(v.gender), color: AVATAR_COLOR(v.gender) }}
>
{v.avatar_url ? (
<img src={v.avatar_url} alt={v.name} />
) : (
<UserOutlined style={{ fontSize: 22 }} />
)}
</div>
<div className="vv-voice-card-name" title={v.name}>
{v.name}
</div>
<div className="vv-voice-card-gender">{GENDER_LABEL(v)}</div>
{v.desc && <div className="vv-voice-card-desc">{v.desc}</div>}
<div className="vv-voice-card-actions">
<button
className={`vv-voice-card-btn ${isPicked ? "picked" : ""}`}
onClick={(e) => {
e.stopPropagation()
setPickedId(v.id)
}}
>
{isPicked ? "✓ 已选择" : "选择"}
</button>
<button
className={`vv-voice-card-btn vv-voice-card-btn-preview ${isPlaying ? "playing" : ""} ${!v.sample_audio_url ? "disabled" : ""}`}
onClick={(e) => {
e.stopPropagation()
handlePreview(v)
}}
disabled={!v.sample_audio_url}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
{isPlaying ? "停止" : "试听"}
</button>
</div>
</div>
)
})
)}
</div>
</div>
<div className="vv-modal-foot">
<button className="vv-btn vv-btn-ghost" onClick={onClose}>
取消
</button>
<button className="vv-btn vv-btn-primary" onClick={handleConfirm} disabled={!pickedId}>
完成选择
</button>
</div>
</div>
</div>
)
}
export default PresetVoicePickerModal
@@ -1,74 +0,0 @@
import { useCallback, useEffect, useRef } from "react"
import { getViralVideoJob } from "@/api/viral-video"
import { isAnalysisStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
export interface UseViralVideoPollingOptions {
/** 轮询间隔(毫秒),默认 1500 */
intervalMs?: number
}
/**
* 爆款视频任务 HTTP 轮询 hook。
* 负责持续拉取任务状态并回调给上层;上层负责根据状态/阶段切换 UI 文案。
* 任务进入终态(completed/failed/cancelled)后自动停止。
*/
export function useViralVideoPolling(
jobId: string | null | undefined,
onUpdate: (job: ViralVideoJob) => void,
options: UseViralVideoPollingOptions = {},
) {
const { intervalMs = 1500 } = options
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stoppedRef = useRef(false)
const failCountRef = useRef(0)
const stop = useCallback(() => {
stoppedRef.current = true
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const pollOnce = useCallback(
async (id: string) => {
try {
const job = await getViralVideoJob(id)
failCountRef.current = 0
onUpdate(job)
if (TERMINAL.includes(job.status)) {
stop()
return
}
if (stoppedRef.current) return
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
const inRender = job.progress_stage === "rendering"
// 分析阶段走默认间隔即可
const isAnalyzing = isAnalysisStage(job.progress_stage)
const nextDelay = inRender ? 3000 : isAnalyzing ? 2000 : intervalMs
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
} catch (_err) {
failCountRef.current += 1
if (stoppedRef.current) return
const delay = Math.min(intervalMs * 2 ** Math.min(failCountRef.current, 3), 10000)
timerRef.current = setTimeout(() => pollOnce(id), delay)
}
},
[intervalMs, onUpdate, stop],
)
useEffect(() => {
stoppedRef.current = false
failCountRef.current = 0
if (!jobId) {
stop()
return
}
pollOnce(jobId)
return stop
}, [jobId, pollOnce, stop])
return { stop }
}
@@ -1,26 +1,25 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { uploadAssetDirect, getIngestJob, type AssetLibraryItem } from "@/api/assets"
import {
uploadAssetDirect,
getAssetLibraries,
getIngestJob,
type AssetLibraryItem,
} from "@/api/assets"
import { tagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial } from "../../../types"
interface UseVoiceUploadOptions {
voiceLibrary?: { id: string; kind: string }
createLibMutation?: {
mutateAsync: () => Promise<AssetLibraryItem>
isPending: boolean
}
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
}
/**
* 配音素材上传 Hook
* 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签
*/
export function useVoiceUpload({
voiceLibrary,
createLibMutation: _createLibMutation,
}: UseVoiceUploadOptions) {
export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) {
const queryClient = useQueryClient()
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
@@ -34,12 +33,24 @@ export function useVoiceUpload({
}) => {
setUploadProgress(0)
try {
// 1. 上传文件:后端自动在默认项目下确保配音库存在(P0 404 修复)
// 兼容 voiceLibrary 参数:若调用方已传入正确的库 ID 则直接复用,否则内部自动解析
// 1. 获取或等待 voice library
let lib = voiceLibrary
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync()
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: () => getAssetLibraries(),
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
// 2. 上传文件(带进度,后端自动创建 ingest job)
const complete = await uploadAssetDirect({
file: data.file,
library_id: voiceLibrary?.id,
kind: "voice",
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
@@ -1,6 +1,6 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { uploadAssetDirect, getIngestJob } from "@/api/assets"
import { uploadAssetDirect, getAssetLibraries, getIngestJob } from "@/api/assets"
/**
* 配音上传 Hook
@@ -23,10 +23,18 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
mutationFn: async (data: { file: File; name: string; description: string }) => {
setUploadProgress(0)
try {
/* 直传文件(后端会自动在默认项目下确保配音库存在,P0 404 修复) */
/* 获取或创建默认配音库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: () => getAssetLibraries(),
})
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
/* 直传文件(后端会自动创建 ingest job) */
const complete = await uploadAssetDirect({
file: data.file,
kind: "voice",
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
})
-4
View File
@@ -52,10 +52,6 @@ const appChildren: RouteObject[] = [
path: "ai-avatar",
lazy: lazyRoute(() => import("@/pages/ai-avatar/AiAvatarPage")),
},
{
path: "viral-video",
lazy: lazyRoute(() => import("@/pages/viral-video/ViralVideoPage")),
},
{
path: "voice-clone",
lazy: lazyRoute(() => import("@/pages/voice-clone/VoiceClone")),
@@ -1,190 +0,0 @@
/**
* 标题工具函数单测 — 提升覆盖率到 50% 阈值以上
*/
import { describe, it, expect } from "vitest"
import {
titleStyleConfigToCamel,
camelToTitleStyleConfig,
buildPresetPreviewSettings,
templateToPreviewSettings,
} from "@/components/title/utils"
import {
getFontFamily,
getTitlePreset,
TITLE_PRESETS,
FONT_OPTIONS,
} from "@/components/title/constants"
import { DEFAULT_TITLE_SETTINGS_FULL } from "@/pages/generate/types"
import type { TitleStyleConfig } from "@/components/title/types"
import type { TitleTemplate } from "@/components/title/template-types"
describe("getFontFamily", () => {
it("已知字体名返回对应 family 栈", () => {
const f = getFontFamily("思源黑体")
expect(f).toContain("Noto Sans SC")
})
it("未知字体名回退到思源黑体", () => {
const f = getFontFamily("not-exist-font")
expect(f).toBe(FONT_OPTIONS[4].family)
})
it("新增书法字体能查到", () => {
expect(getFontFamily("马善政毛笔")).toContain("Ma Shan Zheng")
expect(getFontFamily("站酷小薇体")).toContain("ZCOOL XiaoWei")
})
})
describe("getTitlePreset", () => {
it("合法 key 返回对应 preset", () => {
const p = getTitlePreset(TITLE_PRESETS[0].key)
expect(p).toBeDefined()
expect(p?.key).toBe(TITLE_PRESETS[0].key)
})
it("不存在的 key 返回 undefined", () => {
expect(getTitlePreset("__not_exist__")).toBeUndefined()
})
})
describe("titleStyleConfigToCamel", () => {
it("snake_case 字段映射到 camelCase", () => {
const cfg: Partial<TitleStyleConfig> = {
font: "思源黑体",
size: 48,
pos_x: 10,
pos_y: 20,
line_height: 1.4,
margin_top: 30,
max_chars_per_line: 8,
stroke_width: 4,
stroke_color: "#000",
shadow_offset_x: 2,
bg_enabled: true,
bg_padding: 12,
bg_radius: 6,
}
const out = titleStyleConfigToCamel(cfg)
expect(out.font).toBe("思源黑体")
expect(out.size).toBe(48)
expect(out.posX).toBe(10)
expect(out.posY).toBe(20)
expect(out.lineHeight).toBe(1.4)
expect(out.marginTop).toBe(30)
expect(out.maxCharsPerLine).toBe(8)
expect(out.strokeWidth).toBe(4)
expect(out.strokeColor).toBe("#000")
expect(out.shadowOffsetX).toBe(2)
expect(out.bgEnabled).toBe(true)
expect(out.bgPadding).toBe(12)
expect(out.bgRadius).toBe(6)
})
it("空对象返回空对象", () => {
expect(titleStyleConfigToCamel({})).toEqual({})
})
})
describe("camelToTitleStyleConfig", () => {
it("camelCase 字段映射到 snake_case", () => {
const out = camelToTitleStyleConfig({
font: "思源宋体",
size: 36,
posX: 5,
posY: 15,
strokeWidth: 2,
shadowBlur: 8,
bgEnabled: false,
})
expect(out.font).toBe("思源宋体")
expect(out.pos_x).toBe(5)
expect(out.pos_y).toBe(15)
expect(out.stroke_width).toBe(2)
expect(out.shadow_blur).toBe(8)
expect(out.bg_enabled).toBe(false)
})
it("两种转换在已知字段上往返一致", () => {
const camel = {
font: "优设标题黑",
size: 56,
color: "#ffffff",
stroke: true,
strokeWidth: 6,
bold: true,
shadow: true,
shadowOffsetX: 3,
shadowOffsetY: 3,
shadowBlur: 10,
bgEnabled: true,
bgColor: "#000000",
bgPadding: 16,
bgRadius: 8,
} as const
const snake = camelToTitleStyleConfig({ ...camel })
const back = titleStyleConfigToCamel(snake)
for (const k of Object.keys(camel) as (keyof typeof camel)[]) {
expect(back[k]).toBe(camel[k])
}
})
})
describe("buildPresetPreviewSettings", () => {
it("未知 preset key 返回 base", () => {
const base = { ...DEFAULT_TITLE_SETTINGS_FULL, size: 32 }
const out = buildPresetPreviewSettings(base, "__no_such_preset__")
expect(out).toBe(base)
})
it("合法 preset 返回固定字号并缩放描边/阴影/padding", () => {
const key = TITLE_PRESETS[0].key
const out = buildPresetPreviewSettings(DEFAULT_TITLE_SETTINGS_FULL, key, 56)
expect(out.size).toBe(56)
expect(Array.isArray(out.lineOverrides)).toBe(true)
})
})
describe("templateToPreviewSettings", () => {
it("按模板 style 合并默认值,固定字号并缩放装饰尺寸", () => {
const tpl: TitleTemplate = {
id: "tpl-test",
name: "测试模板",
category: "test",
thumbnail_url: "",
is_system: true,
style: {
font: "思源黑体",
size: 72,
color: "#ffd700",
stroke: true,
stroke_width: 8,
shadow: true,
shadow_offset_x: 4,
shadow_offset_y: 4,
shadow_blur: 12,
bg_enabled: false,
bg_padding: 24,
bg_radius: 0,
},
}
const out = templateToPreviewSettings(tpl, 48)
expect(out.size).toBe(48)
expect(out.color).toBe("#ffd700")
expect(out.strokeWidth).toBe(Math.max(1, Math.round((48 / 72) * 8)))
expect(out.shadowBlur).toBe(Math.round((48 / 72) * 12))
expect(Array.isArray(out.lineOverrides)).toBe(true)
})
it("模板未指定 size 时使用默认 size(不触发缩放)", () => {
const tpl: TitleTemplate = {
id: "tpl-no-size",
name: "无字号模板",
category: "test",
thumbnail_url: "",
is_system: false,
style: { font: "思源黑体" },
}
const out = templateToPreviewSettings(tpl, 48)
expect(out.size).toBeDefined()
})
})
@@ -20,6 +20,7 @@ import "@/pages/generate/components/Step4TitleSettings"
import "@/pages/generate/components/Step5VoiceSelect"
import "@/pages/generate/components/Step3VoiceWithMode"
import "@/pages/generate/components/BatchGenerationGrid"
import "@/pages/generate/components/PreviewCountModal"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/components/voice/VoiceRecommendSection"
import "@/pages/generate/components/voice/VoiceChoiceCard"
View File
@@ -1,112 +0,0 @@
"""一次性脚本:对历史 quality_score 缺失的视频素材重新打分。
背景(#2073):镜像 97ad0ae2 时期 calculate_quality_score / classify_from_analysis
返回 str 而非 AssetClassification 枚举,导致 calculate_asset_quality 连续报
"'str' object has no attribute 'value'",大量视频素材的 quality_score 卡在 NULL。
镜像 8abdeb95 已修复枚举 bug,但历史失败记录不会自动重跑。本脚本扫描全表,
把 quality_score IS NULL 的视频素材重新投递到 worker.calculate_asset_quality 任务。
使用方式(在 worker 容器内执行):
cd /app/apps/worker
# 干跑,只打印会重跑多少条,不发任务
python -m scripts.backfill_asset_quality --dry-run
# 正式执行
python -m scripts.backfill_asset_quality
# 只重跑最近 N 天的
python -m scripts.backfill_asset_quality --since-days 30
# 限流:每投递一批 sleep 几秒,避免瞬间打爆 transcode 队列
python -m scripts.backfill_asset_quality --batch-size 50 --sleep 2
也可以直接在 staging 机器上 exec 进容器:
docker exec -e PYTHONPATH=/app:/app/apps/api:/app/packages xiaoxia-worker-staging \
python -m scripts.backfill_asset_quality --dry-run
"""
from __future__ import annotations
import argparse
# 保证可以以 python -m scripts.xxx 在容器 /app/apps/worker 下执行
# 也兼容在 repo 根目录下执行(注入路径)
import os
import sys
import time
from datetime import UTC, datetime, timedelta
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_WORKER_DIR = os.path.dirname(_SCRIPT_DIR) # apps/worker
_APPS_DIR = os.path.dirname(_WORKER_DIR) # apps
_REPO_ROOT = os.path.dirname(_APPS_DIR) # repo root
for p in (_REPO_ROOT, os.path.join(_REPO_ROOT, "apps", "api"), _REPO_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
def main() -> int:
parser = argparse.ArgumentParser(description="补打历史视频素材 quality_score")
parser.add_argument("--dry-run", action="store_true", help="只统计数量,不投递任务")
parser.add_argument("--since-days", type=int, default=0, help="只处理最近 N 天上传的素材(0=全部)")
parser.add_argument("--batch-size", type=int, default=50, help="每批投递数量,默认 50")
parser.add_argument("--sleep", type=float, default=1.0, help="批次之间 sleep 秒数,默认 1s")
parser.add_argument("--queue", type=str, default="transcode", help="投递队列(默认 transcode)")
args = parser.parse_args()
# 延迟 import,避免在 dry-run 时依赖完整 DB 环境
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import AssetModel
db = SessionLocal()
try:
q = db.query(AssetModel).filter(
AssetModel.file_type == "video",
AssetModel.quality_score.is_(None),
)
if args.since_days > 0:
cutoff = datetime.now(UTC) - timedelta(days=args.since_days)
q = q.filter(AssetModel.created_at >= cutoff)
# 先 count 打印
total = q.count()
print(
f"[backfill] 待重跑 quality_score 的视频素材: {total} 条"
f"{' (dry-run,不投递)' if args.dry_run else ''}"
f"{' (最近 ' + str(args.since_days) + ' 天)' if args.since_days > 0 else ''}",
flush=True,
)
if total == 0 or args.dry_run:
return 0
# 分批投递
submitted = 0
batch = 0
offset = 0
while True:
assets = q.order_by(AssetModel.created_at.desc()).offset(offset).limit(args.batch_size).all()
if not assets:
break
batch += 1
for a in assets:
try:
celery_app.send_task(
"worker.calculate_asset_quality",
args=[a.id],
queue=args.queue,
)
submitted += 1
except Exception as e: # noqa: BLE001
print(f"[backfill] 投递失败 asset_id={a.id}: {e}", flush=True)
print(f"[backfill] batch {batch}: 已累计投递 {submitted}/{total}", flush=True)
offset += len(assets)
if args.sleep > 0 and offset < total:
time.sleep(args.sleep)
print(f"[backfill] 完成,共投递 {submitted} 条任务到 {args.queue} 队列", flush=True)
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())
+1 -14
View File
@@ -553,20 +553,7 @@ def concat_video_files(
if work_dir is None:
work_dir = output_path.parent
# Bug #2110: 探测每段是否真实包含音频流,避免 Seedance 生成的无声片段
# (gen_audio=False)让 concat filter `a=1` 找不到 [N:a] 而报 exit 234。
from video_processing.ffmpeg_utils import probe_has_audio as _probe_has_audio
segments: list[ConcatSegment] = []
for p in video_paths:
if not p:
continue
try:
has_audio = _probe_has_audio(p)
except Exception:
has_audio = True # 探测失败保守认为有音频
segments.append(ConcatSegment(video_path=p, has_audio=has_audio))
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
engine = ConcatEngine(work_dir)
-26
View File
@@ -345,32 +345,6 @@ class VideoFingerprint:
],
}
@classmethod
def from_dict(cls, data: dict) -> "VideoFingerprint":
"""从 to_dict() 序列化结果重建 VideoFingerprint(供 finalize 复用 worker 预计算指纹)。"""
chunks_raw = data.get("chunks") or []
chunks: list[FingerprintChunk] = []
for c in chunks_raw:
chunks.append(
FingerprintChunk(
start_time_ms=int(c.get("start_time_ms", 0)),
end_time_ms=int(c.get("end_time_ms", 0)),
phash_binary=str(c.get("phash_binary", "")),
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
frame_count=int(c.get("frame_count", 0)),
)
)
resolution_raw = data.get("resolution") or [1280, 720]
return cls(
md5=str(data.get("md5", "")),
keyframe_phashes=list(data.get("keyframe_phashes") or []),
color_histograms=[[float(v) for v in h] for h in (data.get("color_histograms") or [])],
duration=float(data.get("duration") or 0.0),
resolution=(int(resolution_raw[0]), int(resolution_raw[1])) if len(resolution_raw) >= 2 else (1280, 720),
chunks=chunks,
)
def to_chunk_models(self, video_id: str, project_id: str, user_id: str = "") -> list[VideoFingerprintChunkModel]:
"""将分片数据转为 SQLAlchemy Model 列表,用于批量写入 video_fingerprint_chunks 表。"""
models = []
+117 -239
View File
@@ -1,15 +1,10 @@
"""查重辅助函数 — 渲染阶段指纹/查重预计算 + 兼容旧入库函数。
"""查重辅助函数 — 从 generation.py 提取的 GeneratedVideo 记录 + 查重逻辑.
#2024: Worker 渲染+上传完成后**不直接创建 GeneratedVideo 成品记录**,改为:
1. ``compute_render_fingerprint_and_dedup``: 从本地视频计算指纹+查重(历史+批次),
返回可序列化 dict(含 fingerprint_chunks),由 worker 写入
``GenerationTask.extra_meta["rendered_output"]``;
2. ``create_video_record_and_dedup``: 保留兼容——当传入 ``video_path`` 时会从本地视频
计算指纹+查重并直接创建 GeneratedVideo 记录(供测试/旧路径使用);
当仅传 ``pre_dedup_result`` 时复用预计算结果,不再访问本地视频。
供 generate_video 共同复用,
创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重。
finalize 入口走 ``packages/application/generated_video_finalize.py`` 的
``finalize_generated_video``,不依赖本模块中数据库以外的 worker-only 逻辑。
v2: 两阶段持久化 — 先计算所有查重数据,再一次性 commit,
避免中间异常导致 duplicate_rate 等字段缺失。
"""
from __future__ import annotations
@@ -22,149 +17,6 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _safe_parse_fps(raw) -> float:
if raw is None:
return 25.0
if isinstance(raw, (int, float)):
return float(raw)
s = str(raw).strip()
if "/" in s:
try:
num, den = s.split("/", 1)
return float(num) / float(den) if float(den) != 0 else 25.0
except (ValueError, ZeroDivisionError):
pass
try:
return float(s)
except (ValueError, TypeError):
return 25.0
def _compute_from_local(
*,
video_path: str,
generation_task_id: str,
project_id: str,
user_id: str,
batch_id: str,
session: Session,
) -> dict:
"""从本地视频计算指纹+查重,返回可序列化结果 dict(不创建 DB 记录)。"""
from video_processing.dedup import VideoDeduplicator
from video_processing.ffmpeg_utils import probe_video_info
result: dict = {
"fingerprint_dict": None,
"fingerprint_chunks": None,
"duration": 0.0,
"width": 1280,
"height": 720,
"fps": 25.0,
"is_duplicate": False,
"duplicate_of": None,
"duplicate_rate": None,
"match_count": None,
"visual_similarity": None,
"video_fingerprint_md5": "",
"batch_similarity": None,
}
try:
info = probe_video_info(video_path)
result["duration"] = float(info.get("duration") or 0.0)
result["width"] = int(info.get("width") or 1280)
result["height"] = int(info.get("height") or 720)
result["fps"] = _safe_parse_fps(info.get("fps"))
except Exception as info_err:
logger.warning("probe_video_info failed for task %s: %s", generation_task_id, info_err)
try:
deduplicator = VideoDeduplicator()
fingerprint = deduplicator.compute_fingerprint(video_path)
fp_dict = fingerprint.to_dict()
result["fingerprint_dict"] = fp_dict
result["video_fingerprint_md5"] = fingerprint.md5 or ""
result["fingerprint_chunks"] = [
{
"start_time_ms": c.start_time_ms,
"end_time_ms": c.end_time_ms,
"phash_binary": c.phash_binary,
"color_histogram": [float(v) for v in c.color_histogram],
"frame_count": c.frame_count,
}
for c in fingerprint.chunks
]
# 用 placeholder_id 占位(还没有真正的 video_id,不影响查重逻辑——
# 因为查重排除的是 GeneratedVideo 表中的记录)
placeholder_id = f"pre-{generation_task_id}"
duration_sec = fingerprint.duration if fingerprint.duration else 0
duplicate_result = deduplicator.check_duplicate(
fingerprint,
project_id,
session,
scope="user",
user_id=user_id,
duration_sec=duration_sec,
exclude_video_id=placeholder_id,
)
batch_sim: float | None = None
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, placeholder_id, session)
if duplicate_result:
batch_sim = float(duplicate_result.get("similarity", 0.0))
result["batch_similarity"] = batch_sim
if duplicate_result:
result["is_duplicate"] = True
result["duplicate_of"] = duplicate_result["duplicate_of"]
else:
result["is_duplicate"] = False
try:
rate_result = deduplicator.compute_duplicate_rate(
fingerprint,
project_id,
placeholder_id,
session,
scope="user",
user_id=user_id,
)
result["duplicate_rate"] = rate_result.get("duplicate_rate")
result["match_count"] = rate_result.get("match_count")
result["visual_similarity"] = rate_result.get("visual_similarity")
except Exception as rate_err:
logger.warning("compute_duplicate_rate failed for task %s: %s", generation_task_id, rate_err)
except Exception as fp_err:
logger.warning("Fingerprint compute failed for task %s: %s", generation_task_id, fp_err)
return result
def compute_render_fingerprint_and_dedup(
*,
video_path: str,
generation_task_id: str,
project_id: str,
user_id: str,
batch_id: str,
mode: str,
session: Session,
) -> dict:
"""渲染+上传完成后的预计算:计算指纹+历史/批次查重,返回可序列化 dict。
**不创建 GeneratedVideo 记录**。结果由调用方写入 extra_meta["rendered_output"],
finalize 时复用。mode 参数保留签名一致性(查重结果中不直接使用)。
"""
_ = mode # 保留在签名里便于调用方对齐;查重结果不含 mode
return _compute_from_local(
video_path=video_path,
generation_task_id=generation_task_id,
project_id=project_id,
user_id=user_id,
batch_id=batch_id,
session=session,
)
def create_video_record_and_dedup(
*,
generation_task_id: str,
@@ -173,8 +25,8 @@ def create_video_record_and_dedup(
batch_id: str,
file_url: str,
file_size: int,
duration: float | None = None,
video_path: str | None,
duration: float,
video_path: str,
mode: str,
session: Session,
width: int = 1280,
@@ -182,55 +34,30 @@ def create_video_record_and_dedup(
fps: float = 25.0,
name: str = "",
thumbnail_url: str = "",
pre_fingerprint_dict: dict | None = None,
pre_fingerprint_chunks: list[dict] | None = None,
pre_dedup_result: dict | None = None,
) -> dict:
"""创建 GeneratedVideo 记录 + 可选查重。
"""Returns: {"video_count": int, "is_duplicate": bool, "batch_similarity": float|None,
"duplicate_of": str|None} —— batch_similarity 为批次内最高相似度(无批次查重时 None)。"""
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
两种用法:
- 传入 ``video_path``(非 None):从本地视频计算指纹+查重,直接创建记录(旧路径/测试)。
- 仅传入 ``pre_*``:复用 worker 预计算结果,不访问本地视频(finalize 用)。
采用两阶段持久化:先计算所有指纹/查重数据(内存),
再一次性写入数据库并 commit。若指纹计算失败,
视频记录仍会创建(无查重数据),但保证不会出现"写了记录却没 commit"的中间态。
Returns:
{"video_id", "video_count", "is_duplicate", "batch_similarity", "duplicate_of"}
创建的视频记录数量(1 表示成功,0 表示失败)
"""
from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
from packages.domain.generated_video import GeneratedVideo
from packages.domain import GeneratedVideo
try:
video_id = uuid4().hex
video_name = name.strip() if name else f"generated-{generation_task_id[:8]}.mp4"
# 决定查重/元信息来源
if video_path:
pre = _compute_from_local(
video_path=video_path,
generation_task_id=generation_task_id,
project_id=project_id,
user_id=user_id,
batch_id=batch_id,
session=session,
)
else:
pre = dict(pre_dedup_result or {})
pre.setdefault("fingerprint_dict", pre_fingerprint_dict)
pre.setdefault("fingerprint_chunks", pre_fingerprint_chunks)
pre.setdefault("is_duplicate", False)
pre.setdefault("duplicate_of", None)
pre.setdefault("duplicate_rate", None)
pre.setdefault("match_count", None)
pre.setdefault("visual_similarity", None)
pre.setdefault("batch_similarity", None)
used_duration = float(duration if duration is not None else pre.get("duration", 0.0))
used_width = int(pre.get("width", width) or width)
used_height = int(pre.get("height", height) or height)
used_fps = float(pre.get("fps", fps) or fps)
# ── Phase 1: 构建视频记录(内存,不 commit) ────────────────
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
@@ -239,66 +66,117 @@ def create_video_record_and_dedup(
name=video_name,
file_url=file_url,
file_size=file_size,
duration=used_duration,
width=used_width,
height=used_height,
fps=used_fps,
duration=duration,
width=width,
height=height,
fps=fps,
status="completed",
generation_params={"mode": mode},
thumbnail_url=thumbnail_url or None,
video_fingerprint=pre.get("fingerprint_dict"),
is_duplicate=bool(pre.get("is_duplicate", False)),
duplicate_of=pre.get("duplicate_of"),
duplicate_rate=pre.get("duplicate_rate"),
match_count=pre.get("match_count"),
visual_similarity=pre.get("visual_similarity"),
)
# 写分片指纹表
chunks = pre.get("fingerprint_chunks")
if chunks:
# ── Phase 2: 计算指纹 & 查重(全部在内存) ────────────────
deduplicator = VideoDeduplicator()
fingerprint = None
batch_similarity: float | None = None
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
except Exception as fp_err:
logger.warning("Fingerprint computation failed for %s: %s", video_id, fp_err)
if fingerprint is not None:
generated_video.video_fingerprint = fingerprint.to_dict()
# 写入分片指纹表(失败不阻塞)
try:
chunk_models = [
VideoFingerprintChunkModel(
id=uuid4().hex,
video_id=video_id,
project_id=project_id,
user_id=user_id,
start_time_ms=int(c.get("start_time_ms", 0)),
end_time_ms=int(c.get("end_time_ms", 0)),
phash_binary=str(c.get("phash_binary", "")),
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
frame_count=int(c.get("frame_count", 0)),
)
for c in chunks
if isinstance(c, dict)
]
if chunk_models:
# 幂等:先清理旧分片
session.query(VideoFingerprintChunkModel).filter(
VideoFingerprintChunkModel.video_id == video_id
).delete(synchronize_session=False)
session.bulk_save_objects(chunk_models)
_save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session)
except Exception as chunk_err:
logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err)
repo = SQLAlchemyGeneratedVideoRepository(session)
repo.create(generated_video)
# (a) 历史成片查重(跨项目全局 + 时长预过滤)
# Issue #1702: fingerprint.duration 单位是秒,旧代码 /1000 让时长预过滤失效
duration_sec = fingerprint.duration if fingerprint.duration else 0
duplicate_result = deduplicator.check_duplicate(
fingerprint,
project_id,
session,
scope="user",
user_id=user_id,
duration_sec=duration_sec,
exclude_video_id=video_id,
)
# (b) 批次内查重(仅当有 batch_id 时)
batch_similarity: float | None = None
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
if duplicate_result:
batch_similarity = float(duplicate_result.get("similarity", 0.0))
if duplicate_result:
generated_video.is_duplicate = True
generated_video.duplicate_of = duplicate_result["duplicate_of"]
logger.info(
"Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)",
video_id,
duplicate_result["duplicate_of"],
duplicate_result["reason"],
duplicate_result["similarity"],
)
else:
generated_video.is_duplicate = False
generated_video.duplicate_of = None
# 计算重复率百分比(跨项目全局)
try:
rate_result = deduplicator.compute_duplicate_rate(
fingerprint,
project_id,
video_id,
session,
scope="user",
user_id=user_id,
)
generated_video.duplicate_rate = rate_result["duplicate_rate"]
generated_video.match_count = rate_result["match_count"]
generated_video.visual_similarity = rate_result["visual_similarity"]
logger.info(
"Duplicate rate for %s: %.2f%% (visual_sim=%.3f, matches=%d)",
video_id,
rate_result["duplicate_rate"],
rate_result["visual_similarity"],
rate_result["match_count"],
)
except Exception as rate_err:
logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err)
generated_video.duplicate_rate = None
# ── Phase 3: 一次性持久化 ─────────────────────────────────
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
if thumbnail_url:
logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80])
session.commit()
logger.info(
"GeneratedVideo record created: %s (task=%s, dup=%s, rate=%s)",
video_id,
generation_task_id,
generated_video.is_duplicate,
generated_video.duplicate_rate,
)
return {
"video_id": video_id,
"video_count": 1,
"is_duplicate": bool(pre.get("is_duplicate", False)),
"batch_similarity": pre.get("batch_similarity"),
"duplicate_of": pre.get("duplicate_of"),
"is_duplicate": bool(generated_video.is_duplicate),
"batch_similarity": batch_similarity,
"duplicate_of": generated_video.duplicate_of,
}
except Exception as e:
logger.error("Failed to create video record for task %s: %s", generation_task_id, e)
logger.error(
"Failed to create video record / dedup for task %s: %s",
generation_task_id,
e,
)
session.rollback()
return {
"video_id": "",
"video_count": 0,
"is_duplicate": False,
"batch_similarity": None,
"duplicate_of": None,
}
return {"video_count": 0, "is_duplicate": False, "batch_similarity": None, "duplicate_of": None}
@@ -1,831 +0,0 @@
"""全 GPU 直连渲染管线(P1)。
背景:旧链路 worker 先用 CPU libx264 把 filter_complex 输出成 mezzanine(1080p 约 85s),
上传后再由 P4000 NVENC 编码,渲染后还要单独跑一次随机边缘裁剪重编码(约 26s)。
本管线取消 mezzanine:把原始素材签名 URL 作为多输入直接交给 P4000,filter_complex 内
一步完成 trim/scale/pad/concat/边缘随机裁剪/drawtext 字幕,末端 h264_nvenc 只编码一次;
原素材音轨 concat + TTS/配音/BGM 混音也在同一命令里完成。
约束(P1):
- 仅覆盖智能剪辑主流场景:单一主视频轨、全硬切、无 PiP/overlay/水印/贴纸/片头片尾/绿幕。
不满足条件时调用方回退到现有 mezzanine/CPU 链路(功能不回归)。
- 字幕先用 drawtext(P4000 装好中文字体后可再切 subtitles 滤镜烧 ASS)。
"""
from __future__ import annotations
import logging
import random
import uuid
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
DEFAULT_DRAWTEXT_FONT = "Noto Sans CJK SC"
EDGE_CROP_MIN_PCT = 0.02
EDGE_CROP_MAX_PCT = 0.05
# 标题/字幕样式基准宽度(px)。前端 TitleSettings 所有长度字段(size/描边/阴影/margin/pos)
# 均以 720p 为基准(见前端 titleCanvas.ts 注释 scale=videoWidth/720,types.ts "px @720p"),
# 非 720p 输出时按 video_width / TITLE_SIZE_REF_WIDTH 等比缩放,保证成片位置与前端预览一致。
TITLE_SIZE_REF_WIDTH = 720
# 与 video_filter_builder.build_title_drawtext_filter(CPU 路径)和 ass_subtitle_builder 对齐:
# - top/bottom 默认 margin 50@720p(vfb 用 _scale_title_len(50, w),即 y=50 / y=h-th-50)
# - margin_top 字段:前端编辑器 marginTop 滑块,叠加在默认 margin 之上(#2095 支持)
# - PAD 概念仅用于前端 Canvas 预览;ffmpeg drawtext y 是 baseline,无 font metrics 可用,
# 直接用统一 50@720p baseline 位置即可保持三端(GPU/CPU/前端视觉)一致。
TITLE_DEFAULT_MARGIN_TOP = 50 # top 位置 baseline 默认距顶 50@720p(与 vfb/CPU 路径一致)
TITLE_DEFAULT_MARGIN_BOTTOM = 50 # bottom 位置 baseline 默认距底 50@720p
SUBTITLE_DEFAULT_MARGIN_BOTTOM = 50 # 字幕距底边距 50@720p(与 vfb 一致)
TITLE_MARGIN_TOP_FROM_CFG_DEFAULT = 24 # 前端 marginTop 滑块默认值(用户未传时叠加 0)
TITLE_FAUX_BOLD_WIDTH = 2 # 仿粗黑色描边宽度(与 vfb 一致,2@720p 黑色细描边)
def _scale_title_len(value, video_width: int):
"""将 720p 基准长度按 video_width 等比缩放(与 packages/domain/ass_subtitle_builder._scale_len 一致)。
int 输入 → 返回 int;float 输入 → 返回 float;非法值原样返回。
"""
if value is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return value
if not video_width or video_width <= 0:
return int(round(v)) if isinstance(value, int) else v
scaled = v * (video_width / TITLE_SIZE_REF_WIDTH)
return int(round(scaled)) if isinstance(value, int) else scaled
def escape_drawtext_text(text: str) -> str:
if not text:
return ""
s = text.replace("\\", "\\\\")
s = s.replace(":", "\\:")
s = s.replace("'", "\\'")
s = s.replace("%", "\\%")
s = s.replace(",", "\\,")
s = s.replace("[", "\\[").replace("]", "\\]")
s = s.replace(";", "\\;")
s = s.replace("\n", " ")
return s
def _hex_to_drawtext_color(hex_color: str, default: str = "white") -> str:
"""把 #RRGGBB / #RGB / 命名颜色转换为 ffmpeg drawtext 接受的颜色格式。
drawtext 的 fontcolor 接受 0xRRGGBB 形式(或命名颜色如 white/black/yellow)。
描边/阴影颜色同样适用。alpha 后缀支持(#RRGGBB@0.5 或 &HBBGGRRAA)。
"""
if not hex_color:
return default
s = hex_color.strip()
if not s:
return default
# 命名颜色直接返回(白名单常见值,避免把 #xxx 当成命名)
if not s.startswith("#") and not s.startswith("0x") and "@" not in s:
return s
if s.startswith("0x"):
return s # 已是 drawtext 原生格式
if s.startswith("#"):
h = s[1:]
# 处理 alpha:#RRGGBB@AA 或 #RRGGBB&AA
alpha = ""
if "@" in h:
h, alpha_part = h.split("@", 1)
try:
a = float(alpha_part)
alpha = f"@{a:.2f}"
except ValueError:
alpha = ""
if len(h) == 3:
h = "".join(ch * 2 for ch in h)
if len(h) == 6:
try:
int(h, 16)
except ValueError:
return default
return f"0x{h}{alpha}"
if len(h) == 8:
# RRGGBBAA → drawtext 的 0xRRGGBB@AA 形式
try:
int(h, 16)
except ValueError:
return default
rr, gg, bb, aa = h[0:2], h[2:4], h[4:6], h[6:8]
try:
a = int(aa, 16) / 255.0
return f"0x{rr}{gg}{bb}@{a:.2f}"
except ValueError:
return f"0x{rr}{gg}{bb}"
return default
def _position_to_drawtext_xy(
position: str,
*,
margin_top: int = 0,
margin_bottom: int = 0,
pos_x: Optional[float] = None,
pos_y: Optional[float] = None,
) -> tuple[str, str]:
"""把位置映射到 drawtext x/y 表达式,对齐前端 titleCanvas.ts 预览坐标。
position 支持: top / center(middle) / bottom / custom。
- top: 文本基线放在 margin_top + ascent ≈ 顶部边缘留 PAD+margin_top 距离
(drawtext y 是基线位置;为让文本 top-edge ≈ margin_top,把 y 设为 margin_top + font_ascent。
但 drawtext 运行时不知道 ascent,用经验系数 0.8*fontsize 近似,和前端 PAD+margin_top 对齐)。
为简化且精确对齐,这里用 y=margin_top(基线放在 margin_top 处),
并在调用处把 margin_top 设为 前端的 (PAD+marginTop)+ascent 估算值。
- center: (h-text_h)/2 垂直居中。
- bottom: 文本底线距离底边 margin_bottom。
- custom: pos_x/pos_y 为百分比 0-100(前端拖拽坐标系),文本中心落在 (pct_x*w, pct_y*h)。
margin_top/margin_bottom 为已按 video_width 缩放过的像素值。
"""
p = (position or "top").lower().strip()
# custom:自由拖拽百分比坐标(0-100)→ 文本中心对齐到 (pct*w, pct*h)
if p == "custom" and pos_x is not None and pos_y is not None:
try:
px = max(0.0, min(100.0, float(pos_x))) / 100.0
py = max(0.0, min(100.0, float(pos_y))) / 100.0
return f"(w-text_w)*{px:.4f}", f"(h-text_h)*{py:.4f}"
except (TypeError, ValueError):
pass # fall through to default
x = "(w-text_w)/2"
if p in ("top",):
# drawtext y 是 baseline 位置。中文字符顶边距基线约 0.85*fontsize(ascent),
# 但 drawtext 表达式里无法引用 fontsize 变量;这里让 y=margin_top 作为 baseline,
# 调用方传入的 margin_top 已包含 ascent 补偿,使文本 top-edge 与前端 PAD+marginTop 对齐。
y = f"{int(margin_top)}"
elif p in ("center", "middle"):
y = "(h-text_h)/2"
elif p in ("bottom",):
# h-th-margin_bottom:th ≈ text_h,文本底边距底边 margin_bottom
y = f"h-th-{int(margin_bottom)}"
else:
# 未知值回退到顶部(与前端默认 position=top 对齐)
y = f"{int(margin_top)}"
return x, y
def _build_drawtext_filters(
*,
text: str,
start: float,
end: float,
font: str = DEFAULT_DRAWTEXT_FONT,
font_size: int = 0,
font_color: str = "white",
position: str = "top",
margin_top: int = 0,
margin_bottom: int = 0,
pos_x: Optional[float] = None,
pos_y: Optional[float] = None,
box_enabled: bool = False,
box_color: str = "black@0.5",
borderw: int = 0,
border_color: str = "black",
shadow_enabled: bool = False,
shadow_color: str = "black@0.6",
shadow_x: int = 2,
shadow_y: int = 2,
) -> list[str]:
"""构造一组 drawtext 滤镜:可选阴影层(同字偏移)+ 主字层。
ffmpeg drawtext 没有直接的 shadow 选项,用两次 drawtext 模拟:
先画一个描边/阴影色层偏移 shadow_x/shadow_y,再画主字层。
返回列表是为了让调用方顺序插入 fc(前一个输出作为后一个输入)。
"""
txt = escape_drawtext_text(text)
if not txt:
return []
x_expr, y_expr = _position_to_drawtext_xy(
position,
margin_top=margin_top,
margin_bottom=margin_bottom,
pos_x=pos_x,
pos_y=pos_y,
)
fc_color = _hex_to_drawtext_color(font_color, default="white")
bd_color = _hex_to_drawtext_color(border_color, default="black")
sh_color = _hex_to_drawtext_color(shadow_color, default="black@0.6")
filters: list[str] = []
# 阴影层:shadow_enabled 时先画一层深色偏移字(无描边)
if shadow_enabled and (shadow_x != 0 or shadow_y != 0):
sh_parts = [f"font={font}", f"text='{txt}'"]
if font_size and font_size > 0:
sh_parts.append(f"fontsize={int(font_size)}")
sh_parts.append(f"fontcolor={sh_color}")
sh_parts.append(f"x={x_expr}+{int(shadow_x)}")
sh_parts.append(f"y={y_expr}+{int(shadow_y)}")
if start > 0 or end > 0:
sh_parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
filters.append("drawtext=" + ":".join(sh_parts))
# 主字层
parts = [f"font={font}", f"text='{txt}'"]
if font_size and font_size > 0:
parts.append(f"fontsize={int(font_size)}")
parts.append(f"fontcolor={fc_color}")
if box_enabled:
parts.append("box=1")
parts.append(f"boxcolor={box_color}")
if borderw and borderw > 0:
parts.append(f"borderw={int(borderw)}")
parts.append(f"bordercolor={bd_color}")
parts.append(f"x={x_expr}")
parts.append(f"y={y_expr}")
if start > 0 or end > 0:
parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
filters.append("drawtext=" + ":".join(parts))
return filters
def build_drawtext_filter(
*,
text: str,
start: float,
end: float,
font: str = DEFAULT_DRAWTEXT_FONT,
font_size: int = 0,
font_color: str = "white",
x_expr: str = "(w-text_w)/2",
y_expr: str = "h-th-60",
box: bool = False,
box_color: str = "black@0.5",
borderw: int = 0,
border_color: str = "black",
enable: bool = True,
) -> str:
"""[已废弃] 保留单条 drawtext 的便捷构造;新代码请用 _build_drawtext_filters。"""
txt = escape_drawtext_text(text)
parts = [f"font={font}", f"text='{txt}'"]
if font_size and font_size > 0:
parts.append(f"fontsize={int(font_size)}")
parts.append(f"fontcolor={_hex_to_drawtext_color(font_color)}")
if box:
parts.append("box=1")
parts.append(f"boxcolor={box_color}")
if borderw and borderw > 0:
parts.append(f"borderw={int(borderw)}")
parts.append(f"bordercolor={_hex_to_drawtext_color(border_color)}")
parts.append(f"x={x_expr}")
parts.append(f"y={y_expr}")
if enable:
parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
return "drawtext=" + ":".join(parts)
def _build_atempo_chain(speed: float) -> str:
if abs(speed - 1.0) < 1e-6:
return ""
stages: list[float] = []
remaining = speed
while remaining > 2.0:
stages.append(2.0)
remaining /= 2.0
while remaining < 0.5:
stages.append(0.5)
remaining /= 0.5
if abs(remaining - 1.0) >= 1e-6:
stages.append(remaining)
return ",".join(f"atempo={s:.5f}" for s in stages)
def upload_local_audio_and_sign(
local_audio: Path,
*,
tmp_prefix: str = "tmp/gpu-direct-audio/",
expires: int = 3600,
) -> tuple[str, str]:
from video_processing.oss_helpers import _storage # type: ignore
storage = _storage()
key = f"{tmp_prefix.rstrip('/')}/{uuid.uuid4().hex}{local_audio.suffix or '.mp3'}"
content_type = "audio/mpeg" if local_audio.suffix.lower() in (".mp3", ".mpeg") else "audio/mp4"
storage.upload_file(local_audio, key, content_type=content_type)
url = storage.get_download_url(key, expires)
return url, key
def sign_asset_url(storage_key: str, *, expires: int = 3600) -> str:
from video_processing.oss_helpers import _storage # type: ignore
storage = _storage()
return storage.get_download_url(storage_key, expires)
class DirectRenderPlan:
def __init__(
self,
inputs: dict[str, str],
ffmpeg_args: list[str],
oss_keys: list[str],
filter_complex: list[str] | None = None,
):
self.inputs = inputs
self.ffmpeg_args = ffmpeg_args
self.oss_keys = oss_keys
self.filter_complex: list[str] = filter_complex or []
def build_direct_render(
*,
resolved_clips: list[Any],
output_width: int,
output_height: int,
output_fps: int,
tts_audio: Optional[Path] = None,
bgm_audio: Optional[Path] = None,
title_text: str = "",
subtitle_segments: Optional[list[Any]] = None,
font: str = DEFAULT_DRAWTEXT_FONT,
vcodec: str = "h264_nvenc",
preset: str = "p4",
video_bitrate: str = "",
cq: int = 23,
edge_crop_pct: float = 0.0,
total_duration: float = 0.0,
clip_has_audio: Optional[list[bool]] = None,
clip_volumes: Optional[list[float]] = None,
extra_audio_tracks: Optional[list[tuple[Any, float]]] = None,
title_config: Optional[dict] = None,
subtitle_config: Optional[dict] = None,
bgm_config: Optional[dict] = None,
static_subtitle_text: str = "",
) -> DirectRenderPlan:
"""构造 P4000 直连渲染所需的 inputs 与 ffmpeg_args。
视频:每段 trim/setpts/scale/pad/fps → concat(全硬切,带音频)→ 随机边缘 crop+scale → drawtext。
音频:每段 [i:a](或 anullsrc 静音占位)按 clip 配置 atrim/asetpts/atempo/volume/aresample
→ concat=n:N:v=1:a=1 → 与 extra_audio(TTS/配音素材库)、BGM 一起 amix → atrim 精确截断。
"""
if not resolved_clips:
raise ValueError("build_direct_render: no resolved clips")
inputs: dict[str, str] = {}
oss_keys: list[str] = []
input_args: list[str] = []
fc: list[str] = []
n = len(resolved_clips)
# 规范化每段参数
if clip_has_audio is None:
clip_has_audio = [True] * n
else:
clip_has_audio = list(clip_has_audio) + [True] * max(0, n - len(clip_has_audio))
clip_has_audio = clip_has_audio[:n]
if clip_volumes is None:
clip_volumes = [1.0] * n
else:
clip_volumes = list(clip_volumes) + [1.0] * max(0, n - len(clip_volumes))
clip_volumes = clip_volumes[:n]
clip_starts: list[float] = []
clip_effs: list[float] = []
clip_speeds: list[float] = []
for clip in resolved_clips:
start = float(getattr(clip, "start_time", 0) or 0)
eff = float(getattr(clip, "duration", 0) or 0)
if eff <= 0:
eff = float(getattr(clip, "actual_duration", 0) or 0)
speed = float(getattr(clip, "playback_speed", 1.0) or 1.0)
clip_starts.append(start)
clip_effs.append(eff)
clip_speeds.append(speed)
# 1. 视频输入(原始素材签名 URL)
for i, clip in enumerate(resolved_clips):
sk = (getattr(clip, "config", None) or {}).get("_storage_key")
if not sk:
raise ValueError(f"clip {getattr(clip, 'clip_id', i)} missing _storage_key")
fname = f"v{i}.mp4"
inputs[fname] = sign_asset_url(sk)
input_args.extend(["-i", fname])
# 2. 视频段预处理
pre_labels: list[str] = []
for i in range(n):
vf: list[str] = []
start, eff, speed = clip_starts[i], clip_effs[i], clip_speeds[i]
if eff > 0:
if start > 0:
vf.append(f"trim=start={start:.3f}:duration={eff:.3f}")
else:
vf.append(f"trim=duration={eff:.3f}")
vf.append("setpts=PTS-STARTPTS")
if abs(speed - 1.0) >= 1e-6:
vf.append(f"setpts=PTS/{speed:.4f}")
vf.append(f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease")
vf.append(f"pad={output_width}:{output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
vf.append("setpts=PTS-STARTPTS")
vf.append(f"fps={output_fps}")
label = f"vc{i}"
fc.append(f"[{i}:v]{','.join(vf)}[{label}]")
pre_labels.append(label)
# 2b. 音频段预处理(无声源用 anullsrc 占位;volume=0 的段也用 anullsrc 静音占位保持时间轴)
anullsrc_counter = 0
audio_pre_labels: list[str] = []
for i in range(n):
start, eff, speed = clip_starts[i], clip_effs[i], clip_speeds[i]
vol = float(clip_volumes[i] if i < len(clip_volumes) else 1.0)
has_a = bool(clip_has_audio[i] if i < len(clip_has_audio) else True)
if not has_a or vol <= 0.001:
# 静音占位:用 anullsrc 生成静音,atrim 到段时长
sl = f"sil{anullsrc_counter}"
anullsrc_counter += 1
af: list[str] = ["anullsrc=channel_layout=stereo:sample_rate=44100"]
if eff > 0:
af.append(f"atrim=duration={eff:.3f}")
af.append("asetpts=PTS-STARTPTS")
af.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
fc.append(f"{','.join(af)}[{sl}]")
# anullsrc 作为 filter 源不需要 -i 输入,直接给 label
audio_pre_labels.append(sl)
continue
af = []
if eff > 0:
if start > 0:
af.append(f"atrim=start={start:.3f}:duration={eff:.3f}")
else:
af.append(f"atrim=duration={eff:.3f}")
af.append("asetpts=PTS-STARTPTS")
if abs(speed - 1.0) >= 1e-6:
atempo = _build_atempo_chain(speed)
if atempo:
af.append(atempo)
if abs(vol - 1.0) >= 1e-3:
af.append(f"volume={vol:.3f}")
af.append("aresample=44100")
af.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
alabel = f"ac{i}"
fc.append(f"[{i}:a]{','.join(af)}[{alabel}]")
audio_pre_labels.append(alabel)
# 3. concat(全硬切;v=1:a=1,视频音频一起拼接)
concat_in = "".join(f"[{v}][{a}]" for v, a in zip(pre_labels, audio_pre_labels, strict=True))
fc.append(f"{concat_in}concat=n={n}:v=1:a=1[vcat][acat]")
cur_v = "vcat"
cur_a = "acat"
# 4. 随机边缘裁剪降重(四边独立随机 2%~5%,与 ffmpeg_utils.random_edge_crop 一致)
if edge_crop_pct and edge_crop_pct > 0:
_r = random.Random()
p_min = EDGE_CROP_MIN_PCT
p_max = EDGE_CROP_MAX_PCT
crop_top = p_min + _r.random() * (p_max - p_min)
crop_bottom = p_min + _r.random() * (p_max - p_min)
crop_left = p_min + _r.random() * (p_max - p_min)
crop_right = p_min + _r.random() * (p_max - p_min)
w_expr = f"trunc(iw*(1-{crop_left:.4f}-{crop_right:.4f})/2)*2"
h_expr = f"trunc(ih*(1-{crop_top:.4f}-{crop_bottom:.4f})/2)*2"
x_expr = f"trunc(iw*{crop_left:.4f}/2)*2"
y_expr = f"trunc(ih*{crop_top:.4f}/2)*2"
fc.append(
f"[{cur_v}]crop=w='{w_expr}':h='{h_expr}':x='{x_expr}':y='{y_expr}',"
f"scale={output_width}:{output_height}[vcrop]"
)
cur_v = "vcrop"
# 5. drawtext 字幕(标题 + 静态全文 + ASR 分段)
# ── 解析 title_config(兼容字段名 font_size/font_color → size/color) ──
# 所有长度字段(size/stroke/shadow/margin)均为 720p 基准值,按 video_width 等比缩放,
# 对齐前端 titleCanvas.ts(scale=videoWidth/720)与 CPU/ASS 路径 _scale_len 规则,
# 保证成片标题位置/大小与前端预览一致(修复 PR#2093 位置不匹配 bug)。
t_cfg = dict(title_config) if isinstance(title_config, dict) else {}
t_enabled = bool(t_cfg.get("enabled", True))
t_text = (t_cfg.get("text", "") or title_text or "").strip()
t_font = str(t_cfg.get("font", font) or font)
# size:前端传 px@720p,未配置默认 28(前端 DEFAULT_TITLE_SETTINGS.size=28,对齐 AI Avatar 默认48)
t_size_raw = t_cfg.get("size", t_cfg.get("font_size", 0))
try:
t_size_720 = int(t_size_raw) if t_size_raw else 0
except (TypeError, ValueError):
t_size_720 = 0
if t_size_720 <= 0:
t_size_720 = 48 # 与 config_schemas.DEFAULT_EDIT_PLAN_CONFIG.title.size=48 及 vfb 默认一致
t_size = _scale_title_len(t_size_720, output_width)
# stroke/shadow 长度字段也需 720p→输出分辨率缩放
t_color = str(t_cfg.get("color", t_cfg.get("font_color", "#ffffff")))
t_position = str(t_cfg.get("position", "top")).lower().strip()
# 自由拖拽坐标(百分比 0-100),与 video_filter_builder.build_title_drawtext_filter 一致
t_pos_x = t_cfg.get("pos_x")
t_pos_y = t_cfg.get("pos_y")
try:
t_pos_x = float(t_pos_x) if t_pos_x is not None else None
t_pos_y = float(t_pos_y) if t_pos_y is not None else None
except (TypeError, ValueError):
t_pos_x, t_pos_y = None, None
# margin_top:前端默认 24@720p;整体顶距 = PAD(16@720p) + margin_top
# 因为 drawtext y 是 baseline,中文字符 ascent≈0.85*fontsize,为让文本 top-edge≈(PAD+marginTop),
# baseline 需再下移约 0.85*fontsize;但 drawtext 表达式无法引用 fontsize 变量,
# 这里直接用 (PAD + margin_top)@720p 缩放后作为 y(即让 baseline≈顶部内边距位置),
# 实际中文字符会自然向下延伸,视觉位置与前端预览(textBaseline=middle 居中到 firstLineY)一致。
# margin_top:前端滑块值(默认 24@720p),叠加在默认 50@720p 基线之上
_t_user_margin_top = t_cfg.get("margin_top")
try:
_t_user_margin_top_720 = int(_t_user_margin_top) if _t_user_margin_top is not None else 0
except (TypeError, ValueError):
_t_user_margin_top_720 = 0
t_margin_top_720 = TITLE_DEFAULT_MARGIN_TOP + _t_user_margin_top_720
t_margin_top = _scale_title_len(t_margin_top_720, output_width)
# bottom margin(标题放在 bottom 时):用户 margin_bottom 透传,默认 50@720p
_t_user_margin_bottom = t_cfg.get("margin_bottom")
try:
_t_user_margin_bottom_720 = int(_t_user_margin_bottom) if _t_user_margin_bottom is not None else 0
except (TypeError, ValueError):
_t_user_margin_bottom_720 = 0
t_margin_bottom_720 = TITLE_DEFAULT_MARGIN_BOTTOM + _t_user_margin_bottom_720
t_margin_bottom = _scale_title_len(t_margin_bottom_720, output_width)
t_borderw = 0
t_border_color = "#000000"
t_box = False
t_box_color = "black@0.5"
# stroke
_stroke = t_cfg.get("stroke")
if isinstance(_stroke, dict) and _stroke.get("enabled", False):
try:
t_borderw_720 = int(float(_stroke.get("width", 2)))
except (TypeError, ValueError):
t_borderw_720 = 2
t_borderw = max(1, _scale_title_len(t_borderw_720, output_width))
t_border_color = str(_stroke.get("color", "#000000"))
elif isinstance(_stroke, bool) and _stroke:
t_borderw = max(1, _scale_title_len(2, output_width))
# shadow
_shadow = t_cfg.get("shadow")
t_shadow_enabled = False
t_shadow_color = "#000000@0.6"
t_shadow_x_720, t_shadow_y_720 = 2, 2
if isinstance(_shadow, dict) and _shadow.get("enabled", False):
t_shadow_enabled = True
t_shadow_color = str(_shadow.get("color", "#000000@0.6"))
try:
t_shadow_x_720 = int(float(_shadow.get("offset_x", 2)))
t_shadow_y_720 = int(float(_shadow.get("offset_y", 2)))
except (TypeError, ValueError):
t_shadow_x_720, t_shadow_y_720 = 2, 2
elif isinstance(_shadow, bool) and _shadow:
t_shadow_enabled = True
t_shadow_x = _scale_title_len(t_shadow_x_720, output_width)
t_shadow_y = _scale_title_len(t_shadow_y_720, output_width)
# bold/italic:drawtext 原生无粗斜体选项;通过同色描边模拟粗体
t_bold = bool(t_cfg.get("bold", True)) # 与 ASS/vfb 路径默认 bold=True 对齐
if t_bold and t_borderw < 1:
# 粗体未配用户描边时:黑色细描边 2@720p(与 vfb 一致,避免同色描边导致重影)
t_borderw = _scale_title_len(TITLE_FAUX_BOLD_WIDTH, output_width)
t_border_color = "#000000" # 黑色细描边模拟粗体
# ── 解析 subtitle_config ──
s_cfg = dict(subtitle_config) if isinstance(subtitle_config, dict) else {}
s_enabled = bool(s_cfg.get("enabled", True))
s_font = str(s_cfg.get("font", font) or font)
s_size_raw = s_cfg.get("size", s_cfg.get("font_size", 0))
try:
s_size_720 = int(s_size_raw) if s_size_raw else 0
except (TypeError, ValueError):
s_size_720 = 0
if s_size_720 <= 0:
s_size_720 = 24 # 字幕默认 24@720p(对齐 ass_subtitle_builder defaults size=24)
s_size = _scale_title_len(s_size_720, output_width)
s_color = str(s_cfg.get("color", s_cfg.get("font_color", "#ffffff")))
s_position = str(s_cfg.get("position", "bottom")).lower().strip()
s_pos_x = s_cfg.get("pos_x")
s_pos_y = s_cfg.get("pos_y")
try:
s_pos_x = float(s_pos_x) if s_pos_x is not None else None
s_pos_y = float(s_pos_y) if s_pos_y is not None else None
except (TypeError, ValueError):
s_pos_x, s_pos_y = None, None
s_margin_top = _scale_title_len(60, output_width) # subtitle top (not commonly used)
s_margin_bottom = _scale_title_len(SUBTITLE_DEFAULT_MARGIN_BOTTOM, output_width)
# subtitle stroke/bold:先解析用户 stroke,再按 bold 默认补描边
s_borderw = 0
s_border_color = "#000000"
_s_stroke = s_cfg.get("stroke")
if isinstance(_s_stroke, dict) and _s_stroke.get("enabled", False):
try:
s_borderw = _scale_title_len(int(float(_s_stroke.get("width", 2))), output_width)
except (TypeError, ValueError):
s_borderw = 0
s_border_color = str(_s_stroke.get("color", "#000000"))
s_bold = bool(s_cfg.get("bold", False))
if s_bold and s_borderw < 1:
# 粗体默认黑色细描边 2@720p(与 title/CPU vfb 一致)
s_borderw = _scale_title_len(TITLE_FAUX_BOLD_WIDTH, output_width)
s_border_color = "#000000"
# 静态字幕:static_subtitle_text 非空时构造全片长 segment(0 → total_duration)
static_text = (static_subtitle_text or "").strip()
subtitle_segments = list(subtitle_segments or [])
if s_enabled and static_text and total_duration and total_duration > 0:
# 用 duck-type 对象插入到 subtitle_segments 列表头部(静态全文)
class _StaticSeg:
def __init__(self, txt, st, ed):
self.text = txt
self.start = st
self.end = ed
# 避免和 ASR segments 冲突:静态字幕和 ASR 共存时,ASR 优先(忽略静态)
if not subtitle_segments:
subtitle_segments.insert(0, _StaticSeg(static_text, 0.0, float(total_duration)))
draw_filters: list[str] = []
if t_enabled and t_text:
draw_filters.extend(
_build_drawtext_filters(
text=t_text,
start=0.0,
end=max(total_duration, 0.1),
font=t_font,
font_size=t_size,
font_color=t_color,
position=t_position,
margin_top=t_margin_top,
margin_bottom=t_margin_bottom,
pos_x=t_pos_x,
pos_y=t_pos_y,
box_enabled=t_box,
box_color=t_box_color,
borderw=t_borderw,
border_color=t_border_color,
shadow_enabled=t_shadow_enabled,
shadow_color=t_shadow_color,
shadow_x=t_shadow_x,
shadow_y=t_shadow_y,
)
)
if s_enabled:
for seg in subtitle_segments:
txt = getattr(seg, "text", "") or ""
if not txt.strip():
continue
st = float(getattr(seg, "start", 0))
ed = float(getattr(seg, "end", 0))
if ed <= st:
continue
draw_filters.extend(
_build_drawtext_filters(
text=txt,
start=st,
end=ed,
font=s_font,
font_size=s_size,
font_color=s_color,
position=s_position,
margin_top=s_margin_top,
margin_bottom=s_margin_bottom,
pos_x=s_pos_x,
pos_y=s_pos_y,
box_enabled=False,
borderw=s_borderw,
border_color=s_border_color,
)
)
if draw_filters:
prev = cur_v
for idx, df in enumerate(draw_filters):
out_l = "vfinal" if idx == len(draw_filters) - 1 else f"vd{idx}"
fc.append(f"[{prev}]{df}[{out_l}]")
prev = out_l
vfinal_label = prev
else:
fc.append(f"[{cur_v}]format=yuv420p[vfinal]")
vfinal_label = "vfinal"
# 6. 音频混音:原素材主音轨 acat + extra(TTS/配音素材库) + BGM → amix → atrim
mix_labels: list[str] = [cur_a]
mix_vols: list[float] = [1.0]
next_idx = n
# 额外独立音频轨(TTS concat / 配音素材库整段音频)
for _ea_idx, (ea_path, ea_vol) in enumerate(extra_audio_tracks or []):
if ea_path is None:
continue
ea_p = Path(ea_path)
if not ea_p.exists():
continue
eurl, ekey = upload_local_audio_and_sign(ea_p)
ename = f"extra{_ea_idx}{ea_p.suffix or '.mp3'}"
inputs[ename] = eurl
oss_keys.append(ekey)
input_args.extend(["-i", ename])
elabel = f"aex{_ea_idx}"
fc.append(
f"[{next_idx}:a]aresample=44100,volume={float(ea_vol):.2f},"
f"aformat=sample_fmts=fltp:channel_layouts=stereo[{elabel}]"
)
mix_labels.append(elabel)
mix_vols.append(float(ea_vol))
next_idx += 1
if tts_audio and Path(tts_audio).exists():
# 旧参数保留:若调用方直接传了 tts_audio 而没走 extra_audio_tracks,则仍然加入
# (兼容旧调用,正常路径 TTS 已经通过 extra_audio_tracks 传入)
turl, tkey = upload_local_audio_and_sign(Path(tts_audio))
tname = "tts" + (Path(tts_audio).suffix or ".mp3")
inputs[tname] = turl
oss_keys.append(tkey)
input_args.extend(["-i", tname])
alabel = "au_tts"
fc.append(
f"[{next_idx}:a]aresample=44100,volume=1.00,aformat=sample_fmts=fltp:channel_layouts=stereo[{alabel}]"
)
mix_labels.append(alabel)
mix_vols.append(1.0)
next_idx += 1
_bgm_use = bgm_audio is not None and Path(bgm_audio).exists()
if _bgm_use and isinstance(bgm_config, dict) and bgm_config.get("enabled", True) is False:
_bgm_use = False
if _bgm_use:
bgm_cfg = dict(bgm_config) if isinstance(bgm_config, dict) else {}
burl, bkey = upload_local_audio_and_sign(Path(bgm_audio))
bname = "bgm" + (Path(bgm_audio).suffix or ".mp3")
inputs[bname] = burl
oss_keys.append(bkey)
input_args.extend(["-i", bname])
alabel = "au_bgm"
try:
bgm_vol = float(bgm_cfg.get("volume", 0.3))
except (TypeError, ValueError):
bgm_vol = 0.3
bgm_vol = max(0.0, min(1.5, bgm_vol))
# volume_adjust_db(-3 ~ +3 dB)换算线性增益
try:
_db = float(bgm_cfg.get("volume_adjust_db", 0.0))
except (TypeError, ValueError):
_db = 0.0
if abs(_db) > 0.05:
db_gain = 10 ** (_db / 20.0)
bgm_vol = max(0.0, min(2.0, bgm_vol * db_gain))
# afade 淡入淡出
try:
fade_in = max(0.0, float(bgm_cfg.get("fade_in", 0.0)))
except (TypeError, ValueError):
fade_in = 0.0
try:
fade_out = max(0.0, float(bgm_cfg.get("fade_out", 0.0)))
except (TypeError, ValueError):
fade_out = 0.0
# audio_offset:adelay 延迟(毫秒)
try:
offset = max(0.0, float(bgm_cfg.get("audio_offset", 0.0)))
except (TypeError, ValueError):
offset = 0.0
bgm_parts: list[str] = [f"[{next_idx}:a]aresample=44100"]
if offset > 0.01:
bgm_parts.append(f"adelay={int(offset * 1000)}|{int(offset * 1000)}")
bgm_parts.append(f"volume={bgm_vol:.3f}")
if fade_in > 0.01:
bgm_parts.append(f"afade=t=in:st=0:d={fade_in:.2f}")
if fade_out > 0.01 and total_duration > 0:
fo_start = max(0.0, total_duration - fade_out)
bgm_parts.append(f"afade=t=out:st={fo_start:.2f}:d={fade_out:.2f}")
bgm_parts.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
fc.append(",".join(bgm_parts) + f"[{alabel}]")
mix_labels.append(alabel)
mix_vols.append(bgm_vol)
next_idx += 1
maps: list[str] = ["-map", f"[{vfinal_label}]"]
if mix_labels:
mix_in = "".join(f"[{lb}]" for lb in mix_labels)
n_mix = len(mix_labels)
mix_parts = [
f"amix=inputs={n_mix}:duration=longest:dropout_transition=2:normalize=0",
"aresample=44100",
]
# Bug2 修复:atrim 到视频精确时长
if total_duration and total_duration > 0:
mix_parts.append(f"atrim=0:{total_duration:.3f}")
mix_parts.append("asetpts=PTS-STARTPTS")
fc.append(f"{mix_in}{','.join(mix_parts)}[afinal]")
maps.extend(["-map", "[afinal]", "-c:a", "aac", "-b:a", "128k"])
else:
logger.info("[gpu-direct] no audio tracks; output silent video")
# 7. 组装 ffmpeg_args + NVENC 编码
ffmpeg_args = ["-y", *input_args, "-filter_complex", ";".join(fc), *maps]
ffmpeg_args.extend(["-c:v", vcodec, "-preset", preset, "-pix_fmt", "yuv420p"])
if video_bitrate:
ffmpeg_args.extend(["-b:v", video_bitrate])
else:
ffmpeg_args.extend(["-cq", str(cq)])
ffmpeg_args.extend(["-movflags", "+faststart", "-shortest", "-f", "mp4", "pipe:1"])
return DirectRenderPlan(
inputs=inputs,
ffmpeg_args=ffmpeg_args,
oss_keys=oss_keys,
filter_complex=fc,
)
+246 -311
View File
@@ -1,17 +1,7 @@
"""OSS 工具函数 — Worker 端统一入口。
"""OSS 工具函数 — 从 generation.py 提取的共享 OSS 操作.
P1 (2026-09-28) OSS 双 endpoint 改造:默认走 packages.shared.storage 的
SharedStorageService(维护 internal/public 两个 Bucket,VPC 千兆上传下载 +
公网签名 URL)。同时保留旧函数签名和模块级属性,兼容历史单测的 patch 路径。
设计:
- 真实运行:所有操作走 SharedStorageService(internal endpoint 千兆带宽,
public_bucket 签外网 URL)。
- 单测 patch 场景:检测到 oss_settings/oss_bucket/oss2.Bucket/requests.get 等
被 patch 后,回退到旧直连 oss2 逻辑,老测试的 patch 仍然生效。
- pytest importlib 模式兼容:conftest.py 把 apps/worker 加进 pythonpath,
本文件可能以 video_processing.oss_helpers 和 apps.worker.video_processing.oss_helpers
两个名字分别加载;patch 可能打到任一份,所以检测时遍历 sys.modules 里的同名模块。
提供 OSS 配置读取、Bucket 创建、素材上传/下载、asset_id → 本地路径解析
等能力,供 render_edit_plan 和 generate_video 共同复用。
"""
from __future__ import annotations
@@ -19,173 +9,67 @@ from __future__ import annotations
import hashlib
import logging
import os
import sys
import time as _time
import threading
from pathlib import Path
from urllib.parse import urlparse
import oss2 # noqa: F401 保留模块级属性,老单测 patch(oss_helpers.oss2)
import requests # noqa: F401 老单测 patch(oss_helpers.requests)
import oss2
import requests
from packages.shared.storage import OSS_CONNECT_TIMEOUT # noqa: F401
from packages.shared.storage import OSS_MULTIPART_NUM_THREADS # noqa: F401
from packages.shared.storage import OSS_MULTIPART_THRESHOLD # noqa: F401
from packages.shared.storage import OSS_PART_SIZE # noqa: F401
from packages.shared.storage import (
OSS_HTTP_DOWNLOAD_TIMEOUT,
OSS_UPLOAD_TOTAL_TIMEOUT,
SharedStorageService,
get_shared_storage_service,
)
from packages.shared.config import get_shared_settings
logger = logging.getLogger(__name__)
# ── 单例访问 ──────────────────────────────────────────────────────────
# OSS 上传配置
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死
OSS_UPLOAD_TOTAL_TIMEOUT = 900 # 单文件上传总超时(秒),防止网络慢时无限卡住
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
def _storage() -> SharedStorageService:
return get_shared_storage_service()
# ── OSS 配置 ──────────────────────────────────────────────────────────────────
# ── 多模块实例兼容(pytest importlib 模式)────────────────────────────
def oss_settings() -> tuple[str, str, str, str] | None:
"""获取 OSS 配置。
统一使用 SharedSettings 读取配置,与 SharedStorageService 保持一致,
支持从 .env 文件加载,避免两套配置路径不一致。
def _sibling_modules() -> list:
"""返回 sys.modules 里所有指向本文件的模块实例(包含自己)。"""
own_file = os.path.abspath(__file__)
mods = []
for _name, mod in list(sys.modules.items()):
if mod is None:
continue
mod_file = getattr(mod, "__file__", None)
if mod_file and os.path.abspath(mod_file) == own_file:
mods.append(mod)
return mods
def _is_mock(obj) -> bool:
"""判断对象是否是 unittest.mock.Mock/MagicMock。"""
if obj is None:
return False
try:
from unittest.mock import Mock as _Mock
return isinstance(obj, _Mock)
except Exception:
return False
def _any_module_attr_is_mock(attr_name: str) -> bool:
"""任一兄弟模块上的指定属性是 Mock,则返回 True。"""
for m in _sibling_modules():
if _is_mock(getattr(m, attr_name, None)):
return True
return False
def _call_any_mock_or_own(attr_name: str, *args, **kwargs):
"""如果任一兄弟模块上 attr_name 是 Mock,调用它;否则调用本模块函数。"""
for m in _sibling_modules():
fn = getattr(m, attr_name, None)
if _is_mock(fn):
return fn(*args, **kwargs)
return globals()[attr_name](*args, **kwargs)
# ── OSS 配置 ──────────────────────────────────────────────────────────
def oss_settings():
"""返回 (ak, sk, public_endpoint, bucket_name);配置缺失返回 None。"""
from packages.config import get_shared_settings
s = get_shared_settings()
if not (s.oss_access_key_id and s.oss_access_key_secret and s.oss_endpoint and s.oss_bucket_name):
Returns:
(access_key_id, access_key_secret, endpoint, bucket_name) 元组,
配置缺失时返回 None。
"""
settings = get_shared_settings()
access_key_id = settings.oss_access_key_id
access_key_secret = settings.oss_access_key_secret
endpoint = settings.oss_endpoint
bucket_name = settings.oss_bucket_name
if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
return None
return (
s.oss_access_key_id,
s.oss_access_key_secret,
s.oss_endpoint,
s.oss_bucket_name,
)
return access_key_id, access_key_secret, endpoint, bucket_name
def _get_oss_settings_from_any_module():
"""从任一兄弟模块上取 oss_settings() 的返回值(mock 场景下兄弟模块上的
oss_settings 可能被 patch 成返回 None 或 tuple)。返回 None 表示所有模块
都返回 None(无配置);返回 tuple 表示有配置;返回 Mock 表示被 patch。"""
any_mock = False
for m in _sibling_modules():
fn = getattr(m, "oss_settings", None)
if not callable(fn):
continue
is_mock = _is_mock(fn)
if is_mock:
any_mock = True
try:
result = fn()
except Exception:
continue
if is_mock:
# 被 patch 的函数:返回值就是 mock 的 return_value
if result is None:
# patch(oss_settings, return_value=None) → 无配置场景
return None
return result # 可能是 tuple 或 Mock
if isinstance(result, tuple):
return result
if any_mock:
return None
return None
def oss_bucket() -> oss2.Bucket | None:
"""获取 OSS Bucket 实例。
P0-2 修复:endpoint 不带 scheme 时自动补 https:// 前缀,
确保 sign_url 等依赖 scheme 的方法返回 HTTPS URL。
def _legacy_path_active() -> bool:
"""是否走旧实现路径(兼容老单测 patch 路径,严格隔离不 fallback)。"""
# 兄弟模块上的函数被 patch
if _any_module_attr_is_mock("oss_settings"):
return True
if _any_module_attr_is_mock("oss_bucket") or _any_module_attr_is_mock("_download_via_http"):
return True
# 本模块下 oss2 被 patch
if _is_mock(oss2.Bucket) or _is_mock(oss2.Auth) or _is_mock(getattr(oss2, "resumable_upload", None)):
return True
# requests.get 被 patch
if _is_mock(requests) or _is_mock(requests.get):
return True
# 超时阈值被改成小值(老单测用 1s 做超时测试)
if OSS_UPLOAD_TOTAL_TIMEOUT <= 2:
return True
return False
P0-staging 修复:增加 connect_timeout=10s,防止网络抖动时
TCP 握手阶段无限挂死,导致 worker 进程卡死。
def _ensure_scheme(endpoint: str) -> str:
if endpoint.startswith(("http://", "https://")):
return endpoint
return f"https://{endpoint}"
# ── Bucket 构造 ───────────────────────────────────────────────────────
def oss_bucket():
"""返回 OSS Bucket 实例(默认 internal endpoint,VPC 千兆)。"""
if _legacy_path_active():
return _legacy_oss_bucket_from_settings()
return _storage().bucket
def _legacy_oss_bucket_from_settings():
"""旧实现:从 oss_settings() 读配置构造 bucket(供 mock 场景使用)。"""
settings = _get_oss_settings_from_any_module()
Returns:
oss2.Bucket 实例,配置缺失时返回 None。
"""
settings = oss_settings()
if settings is None:
return None
try:
access_key_id, access_key_secret, endpoint, bucket_name = settings
except Exception:
return None
if not isinstance(endpoint, str):
endpoint = str(endpoint)
endpoint = _ensure_scheme(endpoint)
access_key_id, access_key_secret, endpoint, bucket_name = settings
# endpoint 无 scheme 时补 https://,与 API 端 storage.py 保持一致
if not endpoint.startswith(("http://", "https://")):
endpoint = f"https://{endpoint}"
return oss2.Bucket(
oss2.Auth(access_key_id, access_key_secret),
endpoint,
@@ -194,211 +78,262 @@ def _legacy_oss_bucket_from_settings():
)
def public_bucket():
"""返回公网 endpoint bucket(仅用于 sign_url)。"""
return _storage().public_bucket
def normalize_storage_key(storage_key_or_url: str) -> str:
"""标准化存储键:URL 取 path + URL decode,开头斜杠去掉。"""
return _storage().normalize_storage_key(storage_key_or_url)
"""标准化存储键 — 如果是完整 URL 则提取 path 部分。
Examples:
"https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4"
→ "path/to/file.mp4"
"path/to/file.mp4" → "path/to/file.mp4"
"""
if storage_key_or_url.startswith(("http://", "https://")):
return urlparse(storage_key_or_url).path.lstrip("/")
return storage_key_or_url.lstrip("/")
# ── HTTP 下载(保留模块级函数方便 patch)─────────────────────────────
# ── 上传 / 下载 ───────────────────────────────────────────────────────────────
def download_asset(asset_storage_key: str, local_path: Path) -> bool:
"""从 OSS 下载素材文件到本地路径。
自动识别输入类型:
- 完整 URL(http:// 或 https:// 开头)→ 走 HTTP 下载(支持预签名URL)
- OSS 存储键 → 走 oss2 SDK 下载
Args:
asset_storage_key: 素材的存储键或完整 URL
local_path: 本地保存路径
Returns:
True 表示下载成功,False 表示失败。
"""
# 完整URL走HTTP下载(兼容预签名URL)
if asset_storage_key.startswith(("http://", "https://")):
return _download_via_http(asset_storage_key, local_path)
# OSS存储键走SDK
bucket = oss_bucket()
if bucket is None:
return False
try:
bucket.get_object_to_file(normalize_storage_key(asset_storage_key), str(local_path))
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("下载素材失败: %s", asset_storage_key)
return False
def _download_via_http(url: str, local_path: Path) -> bool:
"""通过 HTTP 下载文件(用 oss_helpers.requests,方便单测 patch)。"""
"""通过 HTTP 下载文件(支持预签名 URL)。
使用流式下载避免大文件内存溢出,超时 900s。
"""
try:
resp = requests.get(url, stream=True, timeout=OSS_HTTP_DOWNLOAD_TIMEOUT)
resp = requests.get(url, stream=True, timeout=900)
resp.raise_for_status()
os.makedirs(Path(local_path).parent, exist_ok=True)
with open(local_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024):
if chunk:
f.write(chunk)
return Path(local_path).exists() and Path(local_path).stat().st_size > 0
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("HTTP下载失败: %s", url[:100])
logger.exception("HTTP下载素材失败: %s", url)
return False
# ── 下载 / 上传 ───────────────────────────────────────────────────────
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公开 URL。
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
上传加总超时保护(默认 900s),防止网络异常时无限挂死。
def download_asset(asset_storage_key: str, local_path: Path) -> bool:
"""下载素材:HTTP URL 走本地 _download_via_http,OSS key 走 internal endpoint。"""
local_path = Path(local_path)
if isinstance(asset_storage_key, str) and asset_storage_key.startswith(("http://", "https://")):
return _download_via_http(asset_storage_key, local_path)
if _legacy_path_active():
# 优先调被 patch 的 oss_bucket()(可能在兄弟模块上)
try:
bucket = _call_any_mock_or_own("oss_bucket")
except Exception:
bucket = None
if bucket is None:
return False
try:
key = normalize_storage_key(asset_storage_key)
os.makedirs(local_path.parent, exist_ok=True)
bucket.get_object_to_file(key, str(local_path))
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("下载素材失败: %s", asset_storage_key[:80])
return False
return _storage().download_asset(asset_storage_key, local_path)
Args:
local_path: 本地文件路径(Path 或 str 均可)
storage_key: 目标存储键
def _legacy_upload_to_oss(local_path: Path, storage_key: str) -> str | None:
"""旧实现:put_object_from_file / resumable_upload 二选一 + 超时保护。"""
bucket = _legacy_oss_bucket_from_settings()
Returns:
公开访问 URL,上传失败或 OSS 未配置时返回 None。
"""
local_path = Path(local_path) # 统一转 Path,兼容 str 调用
bucket = oss_bucket()
if bucket is None:
return None
settings = _get_oss_settings_from_any_module()
if settings is None:
return None
try:
_, _, endpoint, bucket_name = settings
except Exception:
return None
endpoint = _ensure_scheme(endpoint) if isinstance(endpoint, str) else f"https://{endpoint}"
public_host = endpoint.split("://", 1)[1]
url = f"https://{bucket_name}.{public_host}/{storage_key.lstrip('/')}"
local_path = Path(local_path)
try:
file_size = local_path.stat().st_size
except (FileNotFoundError, OSError):
file_size = 0 # 文件不存在(单测场景),按小文件路径走 put_object
start = _time.monotonic()
result: dict = {"url": None, "error": None, "file_size": 0}
done = threading.Event()
def _timed_out() -> bool:
return (_time.monotonic() - start) > OSS_UPLOAD_TOTAL_TIMEOUT
def _do_upload():
try:
# 尝试获取文件大小,用于分片判断和日志;stat 失败时 fallback 走普通上传
try:
file_size = local_path.stat().st_size
result["file_size"] = file_size
use_multipart = file_size >= OSS_MULTIPART_THRESHOLD
except OSError:
use_multipart = False
file_size = 0
try:
if file_size < OSS_MULTIPART_THRESHOLD:
if _timed_out():
return None
bucket.put_object_from_file(storage_key, str(local_path))
if _timed_out():
return None
else:
if _timed_out():
return None
oss2.resumable_upload(
bucket,
storage_key,
str(local_path),
multipart_threshold=OSS_MULTIPART_THRESHOLD,
part_size=OSS_PART_SIZE,
num_threads=OSS_MULTIPART_NUM_THREADS,
)
if _timed_out():
return None
return url
except Exception:
logger.exception("上传OSS失败: %s", storage_key[:80])
if use_multipart:
# 分片上传:降低内存峰值,每片 8MB,3 线程并发
logger.info(
"大文件分片上传: storage_key=%s, size=%.1fMB, part_size=%dMB, threads=%d",
storage_key[:80],
file_size / 1024 / 1024,
OSS_PART_SIZE // 1024 // 1024,
OSS_MULTIPART_NUM_THREADS,
)
oss2.resumable_upload(
bucket,
storage_key,
str(local_path),
multipart_threshold=OSS_MULTIPART_THRESHOLD,
part_size=OSS_PART_SIZE,
num_threads=OSS_MULTIPART_NUM_THREADS,
)
else:
bucket.put_object_from_file(storage_key, str(local_path))
# 构造返回 URL
settings = oss_settings()
if settings:
_, _, endpoint, bucket_name = settings
endpoint_clean = endpoint.replace("https://", "").replace("http://", "")
result["url"] = f"https://{bucket_name}.{endpoint_clean}/{storage_key}"
except Exception as e:
result["error"] = e
logger.exception("上传 OSS 失败: %s", storage_key)
finally:
done.set()
upload_thread = threading.Thread(target=_do_upload, daemon=True)
upload_thread.start()
finished = done.wait(timeout=OSS_UPLOAD_TOTAL_TIMEOUT)
if not finished:
logger.error(
"OSS 上传超时(%.0fs),强制中止: storage_key=%s, size=%.1fMB",
OSS_UPLOAD_TOTAL_TIMEOUT,
storage_key[:80],
result["file_size"] / 1024 / 1024 if result["file_size"] else 0,
)
return None
if result["error"]:
return None
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公网 URL。"""
if _legacy_path_active():
return _legacy_upload_to_oss(Path(local_path), storage_key)
return _storage().upload_file_smart(local_path, storage_key)
return result["url"]
def get_signed_download_url(storage_key_or_url: str, expires_seconds: int = 3600) -> str | None:
"""生成预签名下载 URL(公网域名,外网可访问)。"""
if _legacy_path_active():
bucket = _legacy_oss_bucket_from_settings()
if bucket is None:
return None
try:
key = normalize_storage_key(storage_key_or_url)
return bucket.sign_url("GET", key, expires_seconds)
except Exception:
logger.exception("生成预签名URL失败: %s", storage_key_or_url[:80])
return None
s = _storage()
if s.public_bucket is None and s.bucket is None:
"""生成预签名下载 URL(用于私有 bucket 的 URL 校验或临时下载)。
Args:
storage_key_or_url: 存储键或完整 URL(URL 会自动提取 path)
expires_seconds: 签名有效期(秒)
Returns:
预签名 URL,失败或 OSS 未配置时返回 None。
"""
bucket = oss_bucket()
if bucket is None:
return None
try:
return s.get_download_url(storage_key_or_url, expires_seconds=expires_seconds)
storage_key = normalize_storage_key(storage_key_or_url)
signed = bucket.sign_url("GET", storage_key, expires_seconds)
logger.info("生成预签名URL: key=%s url_prefix=%s", storage_key[:80], signed[:60])
return signed
except Exception:
logger.exception("生成预签名URL失败: %s", storage_key_or_url[:80])
return None
# ── Asset 解析 ────────────────────────────────────────────────────────
# ── Asset 解析 ────────────────────────────────────────────────────────────────
def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
"""从 asset_id 解析到本地路径(缓存优先,否则 OSS 下载)。
"""从 asset_id 解析到本地文件路径。
在 wrapper 层实现缓存逻辑,方便老单测 patch(oss_helpers.download_asset)。
策略(按优先级):
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 安全校验后返回
2. 如果 work_dir 下已有缓存文件 → 返回缓存路径
3. 从 OSS 下载到 work_dir/{hash}.mp4 → 返回下载路径
4. 下载失败 → 返回 None
缓存策略:以 asset_id 的 SHA256 前 16 位为文件名,避免重复下载。
安全:
- 本地绝对路径必须在 ASSET_ALLOWED_DIRS 环境变量指定的目录内
- 文件名经过 sanitize,防止路径遍历
- 禁止空字节、控制字符
"""
from video_processing.path_security import (
PathSecurityError,
get_allowed_local_dirs,
is_in_allowed_dirs,
sanitize_filename,
)
if not asset_id or not isinstance(asset_id, str):
return None
# 空字节检测
if "\x00" in asset_id:
logger.warning("asset_id 包含空字节,拒绝: %s", asset_id[:50])
return None
work_dir = Path(work_dir)
os.makedirs(work_dir, exist_ok=True)
if asset_id.startswith("/") or ".." in Path(asset_id).parts:
logger.warning("非法 asset_id: %s", asset_id)
return None
# 1. 本地绝对路径 — 必须在允许的目录内
if asset_id.startswith("/") and os.path.exists(asset_id):
try:
resolved = Path(asset_id).resolve()
if is_in_allowed_dirs(resolved, get_allowed_local_dirs()):
return resolved
else:
logger.warning(
"本地素材路径不在允许目录内,拒绝: %s (allowed=%s)",
asset_id[:80],
get_allowed_local_dirs(),
)
return None
except (OSError, PathSecurityError):
return None
# 2. 缓存命中(使用 hash 而非原始 ID,防止路径遍历)
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
local_path = work_dir / f"{cache_hash}.mp4"
safe_name = sanitize_filename(cache_hash)
cached_path = work_dir / f"{safe_name}.mp4"
if cached_path.exists() and cached_path.stat().st_size > 0:
return cached_path
if local_path.exists() and local_path.stat().st_size > 0:
return local_path
# 3. 从 OSS 下载(先标准化 key,防止路径遍历注入)
safe_key = normalize_storage_key(asset_id)
# 额外校验:存储键不能包含 ../ 或绝对路径
if ".." in safe_key or safe_key.startswith("/"):
logger.warning("asset_id 包含路径遍历模式,拒绝下载: %s", asset_id[:80])
return None
if download_asset(safe_key, cached_path):
return cached_path
try:
ok = download_asset(asset_id, local_path)
if ok and local_path.exists() and local_path.stat().st_size > 0:
return local_path
except Exception:
logger.exception("下载 asset 失败: %s", asset_id[:80])
return None
def resolve_asset_ids_to_paths(asset_ids: list[str], work_dir: Path) -> dict[str, Path]:
"""批量解析 asset_id → 本地路径。"""
def resolve_asset_ids_to_paths(
asset_ids: list[str],
work_dir: Path,
) -> dict[str, Path]:
"""批量解析 asset_id → 本地路径。
Args:
asset_ids: 素材 ID 列表
work_dir: 工作目录
Returns:
{asset_id: local_path} 映射,仅包含成功解析的条目。
"""
result: dict[str, Path] = {}
for aid in asset_ids:
p = resolve_asset_path(aid, work_dir)
if p is not None:
result[aid] = p
local_path = resolve_asset_path(aid, work_dir)
if local_path:
result[aid] = local_path
return result
def delete_from_oss(storage_key_or_url: str) -> bool:
"""从 OSS 删除对象(best-effort,internal endpoint)。"""
s = _storage()
if s.bucket is None:
return False
try:
key = normalize_storage_key(storage_key_or_url)
s.delete_file(key)
return True
except Exception:
logger.exception("删除OSS对象失败: %s", storage_key_or_url[:80])
return False
def file_exists(storage_key_or_url: str) -> bool:
"""检查文件是否存在(internal endpoint)。"""
s = _storage()
if s.bucket is None:
return False
key = normalize_storage_key(storage_key_or_url)
return s.file_exists(key)
def get_public_url(storage_key: str) -> str:
"""返回公网 URL(不带签名)。"""
return _storage().get_url(storage_key)
+13 -75
View File
@@ -86,7 +86,6 @@ class RenderAdapterResult:
None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
)
temp_dir: str | None = None # 渲染临时目录,成功时由调用方清理,失败时由 finally 清理
edge_crop_applied: bool = False # GPU 管线已做随机边缘裁剪(跳过 CPU 二次重编码)
def __post_init__(self):
if self.rendered_clip_ids is None:
@@ -132,7 +131,6 @@ class RenderAdapter:
work_dir: Path | None = None,
progress_cb: ProgressCallback | None = None,
voiceover_audio_path: str | None = None,
task_config_override: dict | None = None, # Bug A: task 级 config 覆盖,防并发竞态
) -> RenderAdapterResult:
"""渲染一个 EditPlan。
@@ -191,9 +189,7 @@ class RenderAdapter:
self._report_progress(progress_cb, 15.0, f"下载素材({len(ready_clips)} 个)")
# 2. 下载素材
asset_path_map, rendered_clip_ids, failed_clip_ids, asset_storage_map = self._download_assets(
ready_clips, work_dir
)
asset_path_map, rendered_clip_ids, failed_clip_ids = self._download_assets(ready_clips, work_dir)
if not asset_path_map:
return RenderAdapterResult(
success=False,
@@ -210,7 +206,6 @@ class RenderAdapter:
plan=plan,
clips=ready_clips,
asset_path_map=asset_path_map,
asset_storage_map=asset_storage_map,
work_dir=work_dir,
plan_id=plan_id,
job_id=job_id,
@@ -218,7 +213,6 @@ class RenderAdapter:
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
voiceover_audio_path=voiceover_audio_path,
task_config_override=task_config_override,
)
# 成功时将临时目录所有权转移给调用方,阻止 finally 清理
if result.success and temp_dir:
@@ -321,7 +315,7 @@ class RenderAdapter:
def _download_assets(
self, clips: list[EditPlanClip], work_dir: Path
) -> tuple[dict[str, Path], list[str], list[str], dict[str, str]]:
) -> tuple[dict[str, Path], list[str], list[str]]:
"""下载片段素材到本地。
先通过 asset_id 批量查询 assets 表获取 file_url(OSS存储路径),
@@ -392,9 +386,9 @@ class RenderAdapter:
failed_clip_ids.append(clip.id)
logger.warning("素材下载失败: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
return asset_path_map, rendered_clip_ids, failed_clip_ids, asset_storage_map
return asset_path_map, rendered_clip_ids, failed_clip_ids
def _prepare_bgm(self, plan, work_dir: Path, plan_id: str, *, bgm_override: dict | None = None) -> str | None:
def _prepare_bgm(self, plan, work_dir: Path, plan_id: str) -> str | None:
"""准备 BGM 音频文件(从 plan.config.bgm 读取配置)。
支持 3 种来源(按优先级):
@@ -407,9 +401,7 @@ class RenderAdapter:
from urllib.parse import urlparse
plan_config = plan.config or {}
bgm_config = dict(plan_config.get("bgm", {}) or {})
if isinstance(bgm_override, dict) and bgm_override:
bgm_config.update(bgm_override) # Bug A: 任务级 BGM 覆盖,防并发竞态
bgm_config = plan_config.get("bgm", {}) or {}
if not bgm_config.get("enabled", False):
return None
@@ -465,27 +457,13 @@ class RenderAdapter:
from packages.domain.preset_bgm import get_preset_bgm
preset = get_preset_bgm(preset_id)
if preset is None:
logger.warning("[plan_id=%s] [BGM] 预设BGM不存在: preset_id=%s", plan_id, preset_id)
elif not preset.audio_url:
logger.warning(
"[plan_id=%s] [BGM] 预设BGM未部署音频文件: preset_id=%s name=%s(audio_url 为空,请运维上传音频后填入 preset_bgm.py)",
plan_id,
preset_id,
preset.name,
)
else:
if preset and preset.audio_url:
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info(
"[plan_id=%s] [BGM] 从预设库下载: preset_id=%s url=%s",
plan_id,
preset_id,
preset.audio_url[:80],
)
logger.info("[plan_id=%s] [BGM] 从预设库下载: preset_id=%s", plan_id, preset_id)
safe_download_file(
preset.audio_url,
str(bgm_file),
@@ -498,14 +476,7 @@ class RenderAdapter:
except Exception as e:
logger.warning("[plan_id=%s] [BGM] 预设库下载失败: %s", plan_id, e)
logger.warning(
"[plan_id=%s] [BGM] 所有来源都无法获取BGM(enabled=%s audio_url=%s asset_id=%s preset_id=%s),跳过",
plan_id,
bool(bgm_config.get("enabled")),
"set" if audio_url else "empty",
asset_id[:12] + "…" if len(asset_id) > 12 else asset_id or "empty",
preset_id or "empty",
)
logger.warning("[plan_id=%s] [BGM] 所有来源都无法获取BGM,跳过", plan_id)
return None
@staticmethod
@@ -570,8 +541,6 @@ class RenderAdapter:
rendered_clip_ids: list[str] | None = None,
failed_clip_ids: list[str] | None = None,
voiceover_audio_path: str | None = None,
asset_storage_map: dict[str, str] | None = None,
task_config_override: dict | None = None, # Bug A: task 级 config 覆盖,防并发竞态
) -> RenderAdapterResult:
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
@@ -585,9 +554,8 @@ class RenderAdapter:
Returns:
RenderAdapterResult
"""
# 1. 准备 BGM(Bug A: 传 task 级 bgm override)
_bgm_override = (task_config_override or {}).get("bgm") if isinstance(task_config_override, dict) else None
bgm_path = self._prepare_bgm(plan, work_dir, plan_id, bgm_override=_bgm_override)
# 1. 准备 BGM
bgm_path = self._prepare_bgm(plan, work_dir, plan_id)
self._report_progress(progress_cb, 40.0, "执行视频渲染")
@@ -595,10 +563,8 @@ class RenderAdapter:
plan_config = plan.config or {}
asr_service = self._get_asr_service()
# 3. 读取输出分辨率(Bug A: task override 优先)
export_config = dict(plan_config.get("export", {}) or {})
if isinstance(task_config_override, dict) and isinstance(task_config_override.get("export"), dict):
export_config.update(task_config_override["export"])
# 3. 读取输出分辨率
export_config = plan_config.get("export", {}) or {}
if not isinstance(export_config, dict):
export_config = {}
output_width, output_height = _parse_resolution(export_config.get("resolution"))
@@ -623,22 +589,9 @@ class RenderAdapter:
asr_service=asr_service,
voiceover_audio_path=voiceover_audio_path,
clip_has_text=clip_has_text,
override_config=task_config_override,
)
# 注入每个视频段对应素材的 storage_key,供全 GPU 直连管线直接签名下载
_storage_map = asset_storage_map or {}
for c in clips:
sk = _storage_map.get(getattr(c, "asset_id", ""))
if sk:
# EditPlanClip 使用 __slots__,不能 setattr,改存 config 字典
if not isinstance(c.config, dict):
c.config = dict(c.config) if c.config else {}
c.config["_storage_key"] = sk
result = render_svc.render()
# 4.4 透传 GPU 直连路径的 edge_crop 状态(供外层跳过 CPU 二次裁剪)
edge_crop_applied_flag = bool(getattr(result, "edge_crop_applied", False))
# 4.5 渲染后校验输出完整性
validation = validate_video_output(result.output_path)
if not validation.valid:
@@ -684,22 +637,8 @@ class RenderAdapter:
# 已渲染视频在统一渲染阶段已通过 ASS 字幕把标题烧录进画面,
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
# 构造clip分段边界 [(start, duration), ...] 供封面抽帧智能取各段中点
try:
_clip_boundaries = [
(float(getattr(c, "start_time", 0.0) or 0.0), float(getattr(c, "duration", 0.0) or 0.0))
for c in clips
if float(getattr(c, "duration", 0.0) or 0.0) > 0
]
except Exception:
_clip_boundaries = None
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path),
plan_id,
task_id=job_id,
num_frames=5,
title_text="",
clip_boundaries=_clip_boundaries,
str(result.output_path), plan_id, task_id=job_id, num_frames=5, title_text=""
)
if cover_candidates:
logger.info(
@@ -746,7 +685,6 @@ class RenderAdapter:
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
cover_candidates=cover_candidates,
edge_crop_applied=edge_crop_applied_flag,
)
def render_from_memory(
@@ -1,11 +1,6 @@
"""视频封面抽帧工具 — 从视频中抽取帧作为封面,支持标题文字叠加。
封面管道(P2 优化后):
- 黑屏检测:ffmpeg blackdetect 扫描黑屏区间,抽帧点自动避开黑屏
- 单次 ffmpeg select 抽多帧:一次 ffmpeg 进程用 select 滤镜输出 5 帧,避免 5 次起停进程
- 并发上传:5 帧用 ThreadPoolExecutor 并行上传 OSS,目标封面阶段 <1.5s
- 质量评分:cv2 清晰度/亮度/色彩三维评分选最佳帧
- 可选 MediaKit 路径:配置 MEDIAKIT_COVER_ENABLED=true 时启用火山 MediaKit SceneChange 抽帧
统一封面管道:
- 从已渲染视频抽帧:标题已通过 ASS 字幕烧进视频,帧天然带标题,无需再叠加。
- 从源素材抽帧(API E2 兜底):源素材无标题,通过 Pillow 在帧上绘制标题文字。
"""
@@ -13,14 +8,14 @@
from __future__ import annotations
import logging
import re
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# ── 标题叠加(Pillow)──────────────────────────────────────────────────────
# 实现统一放在 packages/shared/title_overlay.py,API 和 Worker 共用。
def apply_title_overlay(
image_path: str,
@@ -32,7 +27,11 @@ def apply_title_overlay(
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():
@@ -57,19 +56,26 @@ def extract_first_frame(
height: int = -1,
timeout: int = 30,
seek_ratio: float = 0.15,
seek_seconds: float | None = None,
min_seek_seconds: float = 1.0,
) -> str:
"""抽取视频封面帧(ffmpeg -ss 单帧 seek,<100ms/帧)。
"""抽取视频封面帧(默认取视频时长 15% 处的帧,避开片头纯色画面)。
因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。
Args:
video_path: 视频文件路径
output_path: 输出图片路径,不传则用临时文件
width/height: 输出宽高(默认保持原始分辨率)
timeout: 超时(秒)
seek_ratio: 抽帧位置占视频时长的比例
seek_seconds: 指定具体抽帧时间点(秒),优先于 seek_ratio
min_seek_seconds: 最小抽帧时间
width: 输出宽度(默认 -1,保持原始分辨率)
height: 输出高度(默认 -1,保持原始分辨率)
timeout: 超时时间(秒)
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
Returns:
生成的封面帧文件路径
Raises:
RuntimeError: ffmpeg 执行失败或输出文件为空
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
@@ -81,25 +87,31 @@ def extract_first_frame(
_is_temp_output = True
try:
if seek_seconds is not None:
seek_time = max(0.0, float(seek_seconds))
else:
try:
duration = probe_duration(video_path)
seek_time = max(min_seek_seconds, duration * seek_ratio)
except Exception:
seek_time = min_seek_seconds
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
try:
duration = probe_duration(video_path)
seek_time = max(min_seek_seconds, duration * seek_ratio)
except Exception:
# probe 失败时 fallback 到第1秒
seek_time = min_seek_seconds
# 格式化为 HH:MM:SS.xx
seek_str = _format_seek_time(seek_time)
# 构建 scale filter:如果指定了宽高则缩放,否则保持原始分辨率。
# NOTE: scale_filter 在此处通过 if/else 分支赋值,之后不再被覆盖,
# 后续 cmd / cmd2 均复用同一变量,逻辑无变化。
if width > 0 or height > 0:
w_str = str(width) if width > 0 else "-1"
h_str = str(height) if height > 0 else "-1"
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease,format=yuvj420p"
else:
# 保持原始分辨率,只确保格式兼容
scale_filter = "format=yuvj420p"
# -ss 放在 -i 前面(input seeking,极快),-vframes 1 只取一帧
# -ss 放在 -i 前面(input seeking,更快)
# -vframes 1 只取一帧
# -q:v 2 jpeg 高质量
cmd = [
FFMPEG_BIN,
"-y",
@@ -142,6 +154,7 @@ def extract_first_frame(
return output_path
except Exception:
# 失败时清理自己创建的临时文件
if _is_temp_output and output_path:
try:
Path(output_path).unlink(missing_ok=True)
@@ -151,6 +164,7 @@ def extract_first_frame(
def _format_seek_time(seconds: float) -> str:
"""将秒数格式化为 HH:MM:SS.xx 格式。"""
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = seconds % 60
@@ -163,7 +177,19 @@ def generate_and_upload_thumbnail(
*,
seek_ratio: float = 0.15,
) -> str:
"""从视频中提取一帧缩略图并上传到 OSS。"""
"""从视频中提取一帧缩略图并上传到 OSS。
Args:
video_path: 视频文件路径
storage_key: OSS 存储 key
seek_ratio: 抽帧位置比例(默认 0.15)
Returns:
上传后的 URL 字符串
Raises:
RuntimeError: 抽帧或上传失败
"""
from video_processing.oss_helpers import upload_to_oss
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
@@ -178,303 +204,24 @@ def generate_and_upload_thumbnail(
Path(tmp.name).unlink(missing_ok=True)
def _detect_black_intervals(
video_path: str,
duration: float,
*,
black_min_duration: float = 0.3,
picture_black_ratio_th: float = 0.98,
pixel_black_th: float = 0.10,
timeout: int = 30,
) -> list[tuple[float, float]]:
"""用 ffmpeg blackdetect 扫描黑屏区间,返回 [(start, end), ...]。"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
if duration <= 0:
return []
cmd = [
FFMPEG_BIN,
"-nostdin",
"-i",
video_path,
"-vf",
(f"blackdetect=d={black_min_duration:.2f}:pic_th={picture_black_ratio_th:.2f}:pix_th={pixel_black_th:.2f}"),
"-an",
"-f",
"null",
"-",
]
try:
_, stderr = run_ffmpeg(cmd, capture_output=True, timeout=timeout)
except Exception as e:
logger.warning("[thumbnail] blackdetect 失败,忽略黑屏规避: %s", e)
return []
intervals: list[tuple[float, float]] = []
pattern = re.compile(
r"black_start:(\d+(?:\.\d+)?)\s+black_end:(\d+(?:\.\d+)?)\s+black_duration:(\d+(?:\.\d+)?)",
)
for m in pattern.finditer(stderr or ""):
try:
bs = float(m.group(1))
be = float(m.group(2))
intervals.append((bs, be))
except ValueError:
continue
intervals.sort()
if intervals:
logger.info("[thumbnail] blackdetect 发现 %d 段黑屏: %s", len(intervals), intervals[:5])
return intervals
def _adjust_seek_points_avoid_black(
seek_points: list[float],
black_intervals: list[tuple[float, float]],
duration: float,
*,
tolerance: float = 0.25,
) -> list[float]:
"""把落在黑屏区间的 seek 点偏移到最近的非黑屏位置。
策略:
- 若点在黑屏内,先尝试向前偏移到黑屏起点 - tolerance,再尝试向后偏移到黑屏终点 + tolerance;
- 若整个视频全黑(偏移后 <0 或 >duration),保留原点但日志标记警告;
- 偏移后若点与已有点重合(误差 <0.3s),做微调去重。
"""
if not black_intervals or not seek_points:
return list(seek_points)
def in_black(t: float) -> tuple[float, float] | None:
for bs, be in black_intervals:
if bs <= t <= be:
return (bs, be)
return None
adjusted: list[float] = []
for t in seek_points:
seg = in_black(t)
if seg is None:
adjusted.append(max(0.0, min(duration, t)))
continue
bs, be = seg
# 先尝试向前
forward_t = bs - tolerance
if forward_t >= 0.0 and in_black(forward_t) is None:
adjusted.append(forward_t)
continue
# 再尝试向后
backward_t = be + tolerance
if backward_t <= duration and in_black(backward_t) is None:
adjusted.append(backward_t)
continue
# 整段 clip 全黑?保留中点但标记
logger.warning(
"[thumbnail] seek 点 %.2fs 落在黑屏区间 [%.2f,%.2f] 且无法偏移,保留原位置(可能是全黑片段)",
t,
bs,
be,
)
adjusted.append(max(0.0, min(duration, t)))
# 去重:相邻点若 <0.3s 则拉开
adjusted.sort()
deduped: list[float] = []
for t in adjusted:
if not deduped or abs(t - deduped[-1]) >= 0.3:
deduped.append(t)
else:
# 往后挪 0.5s
nt = t + 0.5
if nt <= duration and in_black(nt) is None:
deduped.append(nt)
else:
deduped.append(t)
return [round(max(0.0, min(duration, t)), 3) for t in deduped[: len(seek_points)]]
def _extract_frames_single_pass(
video_path: str,
seek_points: list[float],
out_dir: str,
*,
prefix: str = "frame",
width: int = -1,
height: int = -1,
q: int = 2,
timeout: int = 30,
) -> list[tuple[float, str]]:
"""单次 ffmpeg 用 select 滤镜抽出 seek_points 对应的多帧。
ffmpeg -i input -vf "select='between(t,t1-0.03,t1+0.03)+between(t,t2-0.03,t2+0.03)+...',scale=...,format=yuvj420p"
-vsync vfr -q:v 2 out_dir/prefix_%02d.jpg
返回 [(seek_t, output_path), ...],按输出帧序号升序。若输出帧数 < seek_points 数量,
不足部分用 extract_first_frame 兜底(保证返回数量 == len(seek_points))。
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
out_dir_p = Path(out_dir)
out_dir_p.mkdir(parents=True, exist_ok=True)
# 构造 select 表达式:每个 seek 点用 ±30ms 窗口命中
# between(t, a, b) 返回 1 表示 t 在 [a,b] 内;多个 between 相加即为"任一命中"
select_terms = []
for t in seek_points:
a = max(0.0, t - 0.03)
b = t + 0.04
select_terms.append(f"between(t,{a:.3f},{b:.3f})")
select_expr = "+".join(select_terms)
if width > 0 or height > 0:
w_str = str(width) if width > 0 else "-1"
h_str = str(height) if height > 0 else "-1"
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease"
vf = f"select='{select_expr}',{scale_filter},format=yuvj420p"
else:
vf = f"select='{select_expr}',format=yuvj420p"
out_pattern = str(out_dir_p / f"{prefix}_%02d.jpg")
cmd = [
FFMPEG_BIN,
"-y",
"-i",
video_path,
"-vf",
vf,
"-vsync",
"vfr",
"-q:v",
str(q),
out_pattern,
]
results: list[tuple[float, str]] = []
single_pass_ok = False
try:
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
# 读取输出文件
for i in range(1, len(seek_points) + 1):
fp = out_dir_p / f"{prefix}_{i:02d}.jpg"
if fp.exists() and fp.stat().st_size > 0:
results.append((seek_points[i - 1] if i - 1 < len(seek_points) else 0.0, str(fp)))
if len(results) >= len(seek_points):
single_pass_ok = True
else:
logger.warning(
"[thumbnail] 单次 ffmpeg 抽帧仅命中 %d/%d 帧,不足部分用单帧 seek 兜底",
len(results),
len(seek_points),
)
except Exception as e:
logger.warning("[thumbnail] 单次 ffmpeg select 抽帧失败,回退到单帧 seek: %s", e)
# 兜底:对缺失/失败的帧用 extract_first_frame 补抽
if not single_pass_ok:
# 清理不完整结果
for _, fp in results:
try:
Path(fp).unlink(missing_ok=True)
except Exception:
pass
results = []
for i, st in enumerate(seek_points):
fp = out_dir_p / f"{prefix}_fallback_{i:02d}.jpg"
try:
extract_first_frame(
video_path,
output_path=str(fp),
seek_seconds=st,
min_seek_seconds=0.5,
timeout=timeout,
)
if fp.exists() and fp.stat().st_size > 0:
results.append((st, str(fp)))
else:
logger.warning("[thumbnail] 兜底单帧抽帧也失败 idx=%d t=%.2f", i, st)
except Exception as e:
logger.warning("[thumbnail] 兜底单帧抽帧异常 idx=%d t=%.2f: %s", i, st, e)
return results[: len(seek_points)]
def _compute_clip_boundary_seek_points(
duration: float,
clip_boundaries: Optional[list[tuple[float, float]]] = None,
num_frames: int = 5,
head_skip_ratio: float = 0.08,
tail_skip_ratio: float = 0.08,
) -> list[float]:
"""基于clip分段边界计算抽帧时间点(取每段中间帧,效果比均匀抽更好)。
策略:
- 如果传入 clip_boundaries(每个元素是 (clip_start_in_timeline, clip_duration)),
取每个片段的中点作为抽帧候选点
- 候选点不足 num_frames 时,均匀补充
- 跳过片头 head_skip_ratio(8%,避免片头黑屏/开场标题)和片尾 tail_skip_ratio(8%)
- 返回按时间排序的 num_frames 个抽帧点(秒)
"""
if duration <= 0:
# 无法probe,均匀分布兜底
return [max(1.0, duration * (0.1 + 0.8 * i / max(num_frames - 1, 1))) for i in range(num_frames)]
head_skip = duration * head_skip_ratio
tail_skip = duration * tail_skip_ratio
valid_start = head_skip
valid_end = max(valid_start + 1.0, duration - tail_skip)
candidates: list[float] = []
if clip_boundaries:
# 累加timeline start,取每clip中点
cur = 0.0
for _clip_start, clip_dur in clip_boundaries:
if clip_dur <= 0:
continue
mid = cur + clip_dur / 2.0
if valid_start <= mid <= valid_end:
candidates.append(mid)
cur += clip_dur
# 去重+排序
candidates = sorted(set(round(c, 3) for c in candidates))
# 如果候选点不足,均匀补充
if len(candidates) < num_frames:
needed = num_frames - len(candidates)
existing = set(round(c, 1) for c in candidates)
for i in range(needed * 3):
ratio = 0.1 + 0.8 * (i + 0.5) / (needed * 3)
t = valid_start + (valid_end - valid_start) * ratio
if round(t, 1) not in existing:
candidates.append(t)
existing.add(round(t, 1))
if len(candidates) >= num_frames:
break
# 如果还不够,强制均匀
while len(candidates) < num_frames:
idx = len(candidates)
ratio = 0.1 + 0.8 * idx / max(num_frames - 1, 1)
candidates.append(valid_start + (valid_end - valid_start) * ratio)
candidates.sort()
# 如果超过num_frames,均匀选取
if len(candidates) > num_frames:
step = len(candidates) / num_frames
candidates = [candidates[int(i * step)] for i in range(num_frames)]
return [round(t, 3) for t in candidates[:num_frames]]
def _extract_frames_via_mediakit(
video_path: str,
plan_id: str,
num_frames: int,
) -> list[dict] | None:
"""使用 MediaKit 智能抽帧 API 提取封面帧(fallback 路径,默认不启用)。"""
"""使用 MediaKit 智能抽帧 API 提取封面帧。
Args:
video_path: 本地视频文件路径
plan_id: 编辑计划 ID
num_frames: 需要的帧数
Returns:
帧列表 [{"image_url": str, "timestamp": float}, ...],失败返回 None
"""
import uuid
from video_processing.oss_helpers import delete_from_oss, get_signed_download_url, upload_to_oss
from video_processing.oss_helpers import upload_to_oss
from packages.shared.mediakit_client import get_mediakit_client
@@ -483,38 +230,45 @@ def _extract_frames_via_mediakit(
logger.info("[thumbnail] MediaKit 未配置,跳过智能抽帧")
return None
video_storage_key: str = ""
# 1. 上传视频到 OSS 获取 URL
try:
video_storage_key = f"temp/{plan_id}/{uuid.uuid4().hex[:8]}_{Path(video_path).name}"
public_url = upload_to_oss(video_path, video_storage_key)
if not public_url:
video_url = upload_to_oss(video_path, video_storage_key)
if not video_url:
logger.warning("[thumbnail] 视频上传 OSS 失败,无法使用 MediaKit")
return None
video_url = get_signed_download_url(video_storage_key, expires_seconds=3600) or public_url
logger.info("[thumbnail] 视频已上传 OSS 并生成签名 URL: key=%s", video_storage_key[:80])
logger.info("[thumbnail] 视频已上传 OSS: %s", video_url[:80])
except Exception as e:
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到本地 ffmpeg", e)
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到 ffmpeg", e)
return None
# 2. 调用 MediaKit 智能抽帧
try:
frames = client.extract_frames(
video_url=video_url,
strategy="SceneChange",
max_frames=num_frames * 2,
max_frames=num_frames * 2, # 多取一些帧供选择
)
if not frames:
logger.warning("[thumbnail] MediaKit 抽帧返回空")
logger.warning("[thumbnail] MediaKit 抽帧返回空,降级到 ffmpeg")
return None
# 选取最均匀的 num_frames 个帧
if len(frames) > num_frames:
step = len(frames) // num_frames
frames = [frames[i * step] for i in range(num_frames)]
logger.info("[thumbnail] MediaKit 抽帧成功: %d 帧", len(frames))
return frames
except Exception as e:
logger.warning("[thumbnail] MediaKit 抽帧异常: %s", e)
logger.warning("[thumbnail] MediaKit 抽帧异常: %s,降级到 ffmpeg", e)
return None
finally:
# 清理临时视频文件
try:
from video_processing.oss_helpers import delete_from_oss
delete_from_oss(video_storage_key)
except Exception:
pass
@@ -525,235 +279,161 @@ def extract_and_upload_cover_frames(
plan_id: str,
*,
task_id: str = "",
num_frames: int = 5,
num_frames: int = 5, # 抽 5 帧候选,通过质量评分选出最佳帧
title_text: str = "",
title_color: str = "#ffffff",
title_position: str = "bottom",
title_font_size: int | None = None,
clip_boundaries: Optional[list[tuple[float, float]]] = None,
) -> list[dict]:
"""从视频中抽取多帧作为封面候选,通过质量评分选出最佳帧,上传到 OSS。
P2 优化:
- 先用 ffmpeg blackdetect 扫描黑屏区间,seek 点自动避开黑屏
- 单次 ffmpeg select 抽 num_frames 帧(避免 5 次起停 ffmpeg 进程)
- 多帧 OSS 上传用 ThreadPoolExecutor 并发,目标封面阶段 <1.5s
- cv2 清晰度/亮度/色彩三维评分选最佳帧
Fallback(MEDIAKIT_COVER_ENABLED=true):火山 MediaKit SceneChange 抽帧(~60-90s)。
流程:
1. 优先使用 MediaKit 智能抽帧(多抽一些供选择)
2. MediaKit 不足时降级到 ffmpeg 均匀抽帧
3. 对所有候选帧进行质量评分(清晰度/亮度/色彩丰富度)
4. 按分数从高到低排序返回
Args:
clip_boundaries: 片段边界列表 [(clip_start, clip_duration), ...],用于智能取点
"""
import time
video_path: 视频文件路径
plan_id: 编辑计划 ID(用于生成 storage key)
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
num_frames: 抽取候选帧数(默认 5,通过质量评分选出最佳帧)
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
title_color: 标题字体颜色(#RRGGBB)
title_position: 标题位置 top/center/bottom
title_font_size: 标题字号,None 时自动计算
Returns:
封面候选列表(按质量分数降序),每项包含 {"url": str, "position": float, "score": float}
"""
import httpx
from video_processing.ffmpeg_utils import probe_duration
from video_processing.oss_helpers import upload_to_oss
from packages.shared.config import get_shared_settings
t0 = time.monotonic()
try:
duration = probe_duration(video_path)
except Exception:
duration = 0.0
candidates: list[dict] = []
_temp_paths: list[str] = []
_temp_paths: list[str] = [] # 收集所有临时文件路径,最后统一清理
try:
settings = get_shared_settings()
use_mediakit = getattr(settings, "mediakit_cover_enabled", False)
# ── 阶段 1:抽帧 ──────────────────────────────────────────────
# 优先尝试 MediaKit 智能抽帧
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
if mediakit_frames:
for i, frame in enumerate(mediakit_frames):
frame_url = frame.get("image_url")
if not frame_url:
continue
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
_temp_paths.append(tmp.name)
try:
# 下载 MediaKit 返回的帧图
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
with open(tmp.name, "wb") as f:
f.write(resp.content)
if use_mediakit:
logger.info("[thumbnail] MEDIAKIT_COVER_ENABLED=true,走 MediaKit 路径")
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
if mediakit_frames:
for i, frame in enumerate(mediakit_frames):
frame_url = frame.get("image_url")
if not frame_url:
continue
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
_temp_paths.append(tmp.name)
try:
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
with open(tmp.name, "wb") as f:
f.write(resp.content)
if title_text and title_text.strip():
apply_title_overlay(
tmp.name,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
candidates.append(
{
"url": url,
"position": round(frame.get("timestamp", 0.0), 2),
"image_path": tmp.name,
}
)
except Exception as e:
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
if len(candidates) >= num_frames:
logger.info("[thumbnail] MediaKit 抽帧完成: %d 帧", len(candidates))
# MediaKit 路径帧在 NamedTemporaryFile 中持久存在(finally 清理),在进入本地 ffmpeg 前评分
if len(candidates) > 1:
try:
from packages.shared.cover_frame_scorer import score_frames
candidates = score_frames(candidates)
logger.info(
"[thumbnail] MediaKit 封面帧评分完成: count=%d best_score=%.1f",
len(candidates),
candidates[0].get("score", 0.0) if candidates else 0.0,
)
except Exception:
logger.warning("[thumbnail] MediaKit 封面帧质量评分失败,保持原始顺序", exc_info=True)
# ── 默认路径:本地 ffmpeg 单次 select 抽帧 + 并发上传 ──────────────
if len(candidates) < num_frames:
if candidates:
logger.info("[thumbnail] MediaKit 不足 %d 帧,本地 ffmpeg 补充", num_frames)
else:
logger.info(
"[thumbnail] 使用本地 ffmpeg 抽帧(num=%d, duration=%.1fs)",
num_frames,
duration,
)
# 1) 计算 seek 点
seek_points = _compute_clip_boundary_seek_points(duration, clip_boundaries, num_frames)
# 2) 黑屏检测 + 偏移 seek 点
black_intervals = _detect_black_intervals(video_path, duration) if duration > 0 else []
if black_intervals:
seek_points = _adjust_seek_points_avoid_black(seek_points, black_intervals, duration)
logger.info("[thumbnail] 黑屏规避后 seek 点: %s", seek_points)
# 3) 单次 ffmpeg select 抽出所有帧(带失败兜底到单帧 seek)
with tempfile.TemporaryDirectory(prefix="thumb_") as frame_dir:
t1 = time.monotonic()
frame_results = _extract_frames_single_pass(
video_path,
seek_points,
frame_dir,
prefix="frame",
)
logger.info("[thumbnail] 抽帧耗时: %.2fs (%d 帧)", time.monotonic() - t1, len(frame_results))
# 4) 标题叠加(本地,CPU 很快)
for _st, fp in frame_results:
# 叠加标题文字(如需要)
if title_text and title_text.strip():
try:
apply_title_overlay(
fp,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
except Exception as e:
logger.warning("[thumbnail] 标题叠加失败 %s: %s", fp, e)
# 5) 质量评分(必须在 TemporaryDirectory 内,帧文件还在磁盘上)
t_score = time.monotonic()
local_candidates: list[dict] = [{"position": st, "image_path": fp} for (st, fp) in frame_results]
scored: list[dict] = local_candidates
if len(local_candidates) > 1:
try:
from packages.shared.cover_frame_scorer import score_frames
scored = score_frames(local_candidates)
logger.info(
"[thumbnail] 封面评分耗时: %.2fs (best_score=%.1f, count=%d)",
time.monotonic() - t_score,
scored[0].get("score", 0.0) if scored else 0.0,
len(scored),
apply_title_overlay(
tmp.name,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
except Exception:
logger.warning(
"[thumbnail] 封面帧质量评分失败,保持 seek 点原始顺序",
exc_info=True,
)
scored = local_candidates
# 6) 按评分顺序并发上传 OSS(best 帧先上传;best 已是 scored[0])
t2 = time.monotonic()
def _upload_one(rank: int, st: float, fp: str, score: float) -> dict | None:
try:
storage_key = f"covers/{plan_id}/{task_id}/frame_{rank}.jpg"
url = upload_to_oss(fp, storage_key)
if url:
return {
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
seek_time = frame.get("timestamp", 0.0)
candidates.append(
{
"url": url,
"position": st,
"image_path": fp,
"score": score,
"is_best": rank == 0,
"position": round(seek_time, 2),
"image_path": tmp.name,
}
logger.warning("[thumbnail] 上传失败 rank=%d t=%.2f", rank, st)
except Exception as e:
logger.warning("[thumbnail] 上传异常 rank=%d t=%.2f: %s", rank, st, e)
return None
)
except Exception as e:
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
upload_results: list[dict | None] = [None] * len(scored)
max_workers = min(8, max(2, len(scored)))
with ThreadPoolExecutor(max_workers=max_workers) as pool:
future_map = {
pool.submit(
_upload_one,
i,
float(c.get("position", 0.0)),
str(c["image_path"]),
float(c.get("score", 0.0)),
): i
for i, c in enumerate(scored)
}
for fut in as_completed(future_map):
i = future_map[fut]
try:
upload_results[i] = fut.result()
except Exception as e:
logger.warning("[thumbnail] 上传 future 异常 rank=%d: %s", i, e)
logger.info("[thumbnail] 并发上传耗时: %.2fs", time.monotonic() - t2)
if len(candidates) >= num_frames:
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d 帧", len(candidates))
else:
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
for r in upload_results:
if r is not None:
# 本地帧在 TemporaryDirectory 内,with 退出自动删除,无需进 _temp_paths
candidates.append(r)
# Fallback: ffmpeg 直接抽帧(仅当 MediaKit 不足时)
if len(candidates) < num_frames:
logger.info("[thumbnail] 使用 ffmpeg 抽帧补充")
# 均匀分布抽帧点:从 10% 到 90%
for i in range(num_frames):
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
_temp_paths.append(tmp.name)
try:
frame_path = extract_first_frame(
video_path,
output_path=tmp.name,
seek_ratio=ratio,
min_seek_seconds=0.5,
)
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
if title_text and title_text.strip():
apply_title_overlay(
frame_path,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
url = upload_to_oss(frame_path, storage_key)
if url:
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
candidates.append(
{
"url": url,
"position": round(seek_time, 2),
"image_path": tmp.name,
}
)
except Exception as e:
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
# 如果本地 ffmpeg 路径产生了候选(已评分)但未经过 MediaKit 路径,candidates 已按评分顺序排好。
# 混合场景下(MediaKit + 本地 ffmpeg 都产出),统一按 score 降序排列;缺失 score 的(理论上不应出现)排末尾。
# ── 阶段 2:质量评分 ────────────────────────────────────────────
if len(candidates) > 1:
candidates.sort(key=lambda c: c.get("score", -1.0), reverse=True)
if candidates:
candidates[0]["is_best"] = True
elapsed = time.monotonic() - t0
try:
from packages.shared.cover_frame_scorer import score_frames
candidates = score_frames(candidates)
logger.info(
"[thumbnail] 封面完成: plan_id=%s count=%d best=t%.2fs score=%.1f elapsed=%.2fs",
"[thumbnail] 封面帧质量评分完成: plan_id=%s count=%d best_score=%.1f",
plan_id,
len(candidates),
candidates[0].get("position", 0.0),
candidates[0].get("score", 0.0),
elapsed,
candidates[0].get("score", 0.0) if candidates else 0.0,
)
except Exception:
logger.warning(
"[thumbnail] 封面帧质量评分失败,保持原始顺序: plan_id=%s",
plan_id,
exc_info=True,
)
# ── 阶段 3:清理临时文件 ────────────────────────────────────────
# 移除 image_path(不再需要),但临时文件统一清理
for c in candidates:
c.pop("image_path", None)
return candidates
finally:
# 统一清理所有临时文件
for path in _temp_paths:
try:
Path(path).unlink(missing_ok=True)
@@ -63,7 +63,6 @@ from packages.domain.render_layer_utils import clip_playback_speed as _clip_play
from packages.domain.render_layer_utils import estimate_total_duration as _estimate_total_duration_pure
from packages.domain.render_layer_utils import resolve_layer_role as _resolve_layer_role_pure
from packages.domain.tts_config import TtsConfig
from packages.shared.gpu_encoder import GpuEncodeError, get_gpu_encoder
logger = logging.getLogger(__name__)
@@ -112,7 +111,6 @@ class RenderResult:
file_size: int
width: int
height: int
edge_crop_applied: bool = False # True = GPU管线已做随机边缘裁剪
# ── clip_type → layer role 映射 ──────────────────────────────────────────────
@@ -158,7 +156,6 @@ class UnifiedRenderService:
bgm_path: str | None = None, # BGM 本地文件路径
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
clip_has_text: list[bool] | None = None, # 源视频片段是否有文字(来自 atom_clip.ai_tags.has_text)
override_config: dict | None = None, # Bug A: task 级 config 覆盖(title/bgm/export/subtitle),防并发竞态
):
self.plan = plan
self.clips = clips
@@ -171,9 +168,6 @@ class UnifiedRenderService:
self.asr_service = asr_service
self.bgm_path = bgm_path
self.voiceover_audio_path = voiceover_audio_path
# Bug A: task 级 config override(深拷贝),优先级高于 plan.config;
# 避免同 plan 多任务并发渲染时 _sync_task_config_to_plan 写 plan.config["title"] 互相覆盖。
self._override_config = dict(override_config) if isinstance(override_config, dict) else {}
# #1970:片段级文字检测(顺序与非 audio 的源视频片段一致);None 表示无可靠检测,保守不翻转
self._clip_has_text = clip_has_text
self._transition_engine = TransitionEngine(default_duration=transition_duration)
@@ -184,28 +178,6 @@ class UnifiedRenderService:
self._micro_plan_cache: Any = None
self._micro_plan_loaded = False
def _cfg_section(self, section: str) -> dict:
"""读取单个配置段:override_config 优先于 plan.config(Bug A 防并发竞态)。"""
base = dict((self.plan.config or {}).get(section, {}) or {})
override = self._override_config.get(section)
if isinstance(override, dict) and override:
base.update(override) # 浅合并,保留 base 中未被覆盖字段
return base
def _effective_config(self) -> dict:
"""读取完整 config:override_config 顶层段覆盖 plan.config(Bug A 防并发竞态)。"""
import copy
full = copy.deepcopy(self.plan.config or {})
for k, v in self._override_config.items():
if isinstance(v, dict):
sec = dict(full.get(k, {}) or {})
sec.update(v)
full[k] = sec
else:
full[k] = v
return full
# ── #1970 PR2 智能降重:片段级微变换 ───────────────────────────────────
def _dedup_enabled(self) -> bool:
"""读取 plan.config.dedup_enabled,缺省视为 True(向后兼容)。"""
@@ -260,7 +232,7 @@ class UnifiedRenderService:
return
if abs(mt.brightness) > 1e-4 or abs(mt.contrast - 1.0) > 1e-4 or abs(mt.saturation - 1.0) > 1e-4:
filters.append(
f"eq=brightness={mt.brightness:+.4f}:contrast={mt.contrast:.4f}:saturation={mt.saturation:.4f}"
f"eq=brightness={mt.brightness:+.4f}:" f"contrast={mt.contrast:.4f}:saturation={mt.saturation:.4f}"
)
@staticmethod
@@ -368,36 +340,6 @@ class UnifiedRenderService:
len(pip_sources),
)
# 4.8 全 GPU 直连管线(P1):命中主流场景则跳过 mezzanine/边缘裁剪 CPU 重编码
output_path = self.work_dir / f"rendered_{self.plan.id}.mp4"
direct_result = self._try_gpu_direct(
layers=layers,
ass_path=ass_path,
video_duration=video_duration_final,
output_path=output_path,
)
if direct_result is not None and direct_result[0]:
_direct_edge_crop = bool(direct_result[1])
# 直连成功:直接探测并返回,跳过后续视频/音频 CPU 流程
duration, file_size, width, height = self._probe_output(output_path)
logger.info(
"[unified-render] gpu-direct done: plan_id=%s total_ms=%d output_size=%d resolution=%dx%d",
self.plan.id,
int((time.time() - t_start) * 1000),
file_size,
width,
height,
)
direct_edge_cropped = _direct_edge_crop # GPU直连时若dedup=True已在GPU内做随机边缘裁剪
return RenderResult(
output_path=output_path,
duration=duration,
file_size=file_size,
width=width,
height=height,
edge_crop_applied=direct_edge_cropped,
)
# 5. 视频主渲染
t_video_start = time.time()
video_only_path = self.work_dir / f"rendered_{self.plan.id}_video.mp4"
@@ -457,7 +399,7 @@ class UnifiedRenderService:
has_audio = pass_through_has_audio
# 直通模式下也支持 BGM 混音:提取音频 → 混 BGM → 合并回视频
if self.bgm_path and pass_through_has_audio:
config = self._effective_config()
config = self.plan.config or {}
bgm_config = config.get("bgm", {}) or {}
if bgm_config.get("enabled", False):
ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id)
@@ -497,7 +439,7 @@ class UnifiedRenderService:
"[unified-render] pass-through BGM mix failed, skipping: plan_id=%s", self.plan.id
)
else:
config = self._effective_config()
config = self.plan.config or {}
bgm_config = config.get("bgm", {}) or {}
if not isinstance(bgm_config, dict):
bgm_config = {}
@@ -722,7 +664,7 @@ class UnifiedRenderService:
Returns:
ASS 文件路径,没有字幕时返回 None
"""
config = self._effective_config()
config = self.plan.config or {}
# #1901 统一读 "title",兼容老数据 "title_config"
title_cfg = config.get("title", {}) or {}
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
@@ -907,7 +849,7 @@ class UnifiedRenderService:
Returns:
是否成功添加了配音音轨
"""
config = self._effective_config()
config = self.plan.config or {}
tts_cfg = config.get("tts", {}) or {}
if not isinstance(tts_cfg, dict):
tts_cfg = {}
@@ -1679,27 +1621,20 @@ class UnifiedRenderService:
effective_duration,
has_audio,
)
# 尝试 GPU NVENC 加速
gpu_ok = False
if self._gpu_encode_available():
mezz_path = output_path.parent / f".{output_path.stem}.mezz{output_path.suffix}"
gpu_ok = self._ffmpeg_output_to_mezzanine(command, mezz_path, output_path)
if not gpu_ok:
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
stderr_text = (e.stderr or "").strip()
stderr_tail = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
logger.error(
"直通渲染失败: plan_id=%s clip=%s exit_code=%d\nvf=%s\nstderr(last 1500):\n%s",
self.plan.id,
clip.clip_id,
e.returncode,
vf_str[:2000],
stderr_tail,
)
raise
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
stderr_text = (e.stderr or "").strip()
stderr_tail = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
logger.error(
"直通渲染失败: plan_id=%s clip=%s exit_code=%d\nvf=%s\nstderr(last 1500):\n%s",
self.plan.id,
clip.clip_id,
e.returncode,
vf_str[:2000],
stderr_tail,
)
raise
return has_audio
@@ -2235,379 +2170,6 @@ class UnifiedRenderService:
filter_complex = ";".join(filter_parts)
return filter_complex, input_args
# ── GPU NVENC 加速 ────────────────────────────────────────────────────
# ── 全 GPU 直连渲染(P1)─────────────────────────────────────────────
def _can_use_gpu_direct(self, layers: list[RenderLayer]) -> bool:
"""判断是否命中直连支持的场景:单一主视频轨、全硬切、无复杂合成。"""
try:
cfg = self.plan.config or {}
# 特性开关(默认开启;可经 env/plan config 关闭灰度回退)
if not bool(cfg.get("gpu_direct_enabled", True)):
return False
video_layers = [_lyr for _lyr in layers if _lyr.role not in ("audio",)]
# 只允许一个视频层,且角色为主层
if len(video_layers) != 1:
return False
role = video_layers[0].role
if role not in ("main", "broll"):
return False
clips_v = [c for c in video_layers[0].clips if c.clip_type != "audio"]
if not clips_v:
return False
# 全硬切(第一个 clip 的转场忽略)
for c in clips_v[1:]:
te = c.transition_effect
if te not in (None, "", "cut"):
return False
# 无画中画 / 水印 / 贴纸 / 片头片尾 / 绿幕 / 倒放 / 调色
if (cfg or {}).get("pip_config"):
return False
if (cfg or {}).get("intro_outro"):
return False
for c in clips_v:
cc = c.config or {}
if cc.get("watermark") or cc.get("stickers") or cc.get("chroma_key"):
return False
if ReverseConfig.from_dict(cc.get("reverse")).enabled:
return False
cg = ColorGradeConfig.from_dict(cc.get("color_grade"))
if cg.enabled and cg.has_effect():
return False
if not (c.config or {}).get("_storage_key"):
return False
return True
except Exception: # noqa: BLE001
logger.warning("[gpu-direct] eligibility check failed (fallback)", exc_info=True)
return False
def _try_gpu_direct(
self,
*,
layers: list[RenderLayer],
ass_path: Path | None,
video_duration: float,
output_path: Path,
) -> tuple[bool, bool] | tuple[None, bool]:
"""尝试全 GPU 直连渲染。成功返回 (True, edge_crop_applied),不支持/失败返回 (None, False)。"""
if not self._can_use_gpu_direct(layers):
return (None, False)
if not self._gpu_encode_available():
return (None, False)
try:
from video_processing import gpu_direct_pipeline as gdp
cfg = self._effective_config()
video_layer = next(_lyr for _lyr in layers if _lyr.role not in ("audio",))
video_clips = [c for c in video_layer.clips if c.clip_type != "audio"]
# 音频层处理:收集 TTS 分段与配音素材库整段音频
# - TTS 分段(带 tts 标记)→ 无间隙 concat 成单文件
# - 配音素材库(voice_library=True)→ 单独作为整段音轨(不走分段 concat,已从 0 覆盖整段)
audio_layer = next((_lyr for _lyr in layers if _lyr.role == "audio"), None)
tts_merged: Path | None = None
voiceover_track: Path | None = None
if audio_layer:
tts_clips = [c for c in audio_layer.clips if (c.config or {}).get("tts") and c.local_path.exists()]
if tts_clips:
tts_merged = self._concat_audio_clips(tts_clips, tag="tts_direct")
# 配音素材库整段音频(按 _maybe_add_voice_library_layer 约定只有一个 clip_id=voice_library_main)
vo_clips = [
c for c in audio_layer.clips if (c.config or {}).get("voice_library") and c.local_path.exists()
]
if vo_clips:
voiceover_track = vo_clips[-1].local_path # 理论上只有一个,取最后一个
logger.info(
"[gpu-direct] 配音素材库音轨: plan_id=%s path=%s",
self.plan.id,
voiceover_track,
)
# 额外独立音轨(TTS concat、配音素材库)→ gpu_direct_pipeline 会与主音轨/BGM 一起 amix
extra_audio_tracks: list[tuple[Path, float]] = []
if tts_merged:
extra_audio_tracks.append((tts_merged, 1.0))
if voiceover_track:
extra_audio_tracks.append((voiceover_track, 1.0))
# BGM 本地文件
bgm_path = Path(self.bgm_path) if self.bgm_path else None
if bgm_path is not None and not bgm_path.exists():
bgm_path = None
# 字幕/标题/BGM 配置整包透传
title_cfg = cfg.get("title", {}) or cfg.get("title_config", {}) or {}
if not isinstance(title_cfg, dict):
title_cfg = {}
title_text = ""
if title_cfg.get("enabled", True):
title_text = title_cfg.get("text", "") or ""
sub_cfg = cfg.get("subtitle", {}) or {}
if not isinstance(sub_cfg, dict):
sub_cfg = {}
subtitle_segments: list[Any] = []
static_subtitle_text = ""
if sub_cfg.get("enabled", True):
if sub_cfg.get("auto_generated") and self._asr_timeline_cache is not None:
subtitle_segments = list(self._asr_timeline_cache.segments)
else:
# 静态字幕文本(用户手输):pipeline 内部会构造全片长 segment
static_subtitle_text = (sub_cfg.get("text", "") or "").strip()
bgm_cfg = cfg.get("bgm", {}) or {}
if not isinstance(bgm_cfg, dict):
bgm_cfg = {}
# 若 bgm.enabled 显式关闭,则强制 bgm_path=None(_prepare_bgm 已按 enabled 返回 None,双保险)
if not bgm_cfg.get("enabled", True):
bgm_path = None
# 注入微片段 BGM 偏移(同 CPU 路径)
if bgm_path is not None and not bgm_cfg.get("audio_offset"):
_micro_off = self._get_micro_bgm_offset()
if _micro_off:
bgm_cfg = {**bgm_cfg, "audio_offset": _micro_off}
# 边缘裁剪:dedup 开启时在 GPU 内做四边随机 2~5% 裁剪(gpu_direct_pipeline 内部随机)
dedup = self._dedup_enabled()
edge_pct = 0.03 if dedup else 0.0 # >0 表示启用;实际区间 [2%,5%] 在 pipeline 内随机
# 探测每个视频素材是否含音轨、读取 volume 配置
clip_has_audio_list: list[bool] = []
clip_volumes_list: list[float] = []
for c in video_clips:
lp = getattr(c, "local_path", None)
_ha = False
if lp and Path(lp).exists():
try:
_ha = probe_has_audio(str(lp))
except Exception as _pe: # noqa: BLE001
logger.warning("[gpu-direct] probe_has_audio 失败按有声处理: %s", _pe)
_ha = True
clip_has_audio_list.append(_ha)
_vol = float((c.config or {}).get("volume", 1.0))
clip_volumes_list.append(_vol if _vol > 0 else 0.0)
# extra_audio_tracks 音量:从 audio_tracks_config 读(TTS/配音素材库),
# 无法精确匹配 track_id 时保留默认 1.0
at_cfg = cfg.get("audio_tracks") or {}
tts_volume = 1.0
vo_volume = 1.0
if isinstance(at_cfg, dict):
_tracks = at_cfg.get("tracks", []) or []
for _t in _tracks:
if not isinstance(_t, dict):
continue
try:
_vol = float(_t.get("volume", 1.0))
except (TypeError, ValueError):
_vol = 1.0
_tt = str(_t.get("track_type", ""))
if _tt == "voiceover" and _t.get("audio_path"):
vo_volume = max(0.0, min(2.0, _vol))
# TTS 一般没有固定 track_type 标记,保持默认 1.0
extra_audio_tracks_cfg: list[tuple[Any, float]] = []
if tts_merged:
extra_audio_tracks_cfg.append((tts_merged, tts_volume))
if voiceover_track:
extra_audio_tracks_cfg.append((voiceover_track, vo_volume))
plan = gdp.build_direct_render(
resolved_clips=video_clips,
output_width=self.output_width,
output_height=self.output_height,
output_fps=self.output_fps,
bgm_audio=bgm_path,
title_text=title_text,
subtitle_segments=subtitle_segments,
edge_crop_pct=edge_pct,
total_duration=video_duration,
clip_has_audio=clip_has_audio_list,
clip_volumes=clip_volumes_list,
extra_audio_tracks=extra_audio_tracks_cfg,
title_config=title_cfg,
subtitle_config=sub_cfg,
bgm_config=bgm_cfg,
static_subtitle_text=static_subtitle_text,
)
client = get_gpu_encoder()
client.render_inputs_to_output(plan.inputs, plan.ffmpeg_args, output_path)
# 清理本次上传的临时音频
for key in plan.oss_keys:
try:
from video_processing.oss_helpers import _storage
_storage().delete_file(key) if hasattr(_storage(), "delete_file") else None
except Exception: # noqa: BLE001
pass
did_edge_crop = bool(edge_pct)
logger.info(
"[gpu-direct] success: plan_id=%s clips=%d edge_crop=%s", self.plan.id, len(video_clips), did_edge_crop
)
return (True, did_edge_crop)
except GpuEncodeError as e:
logger.warning("[gpu-direct] failed (fallback to legacy): %s", e)
try:
if output_path.exists():
output_path.unlink()
except OSError:
pass
return (None, False)
except Exception: # noqa: BLE001
logger.warning("[gpu-direct] unexpected error (fallback)", exc_info=True)
return (None, False)
def _concat_audio_clips(self, clips: list[Any], *, tag: str) -> Path:
"""把多个本地音频片段无间隙 concat 成一个 m4a(TTS 分段→单文件)。"""
out = self.work_dir / f"{tag}_{self.plan.id}.m4a"
listfile = self.work_dir / f"{tag}_{self.plan.id}.txt"
lines = []
for c in clips:
ap = str(c.local_path).replace("'", "'\\''")
lines.append(f"file '{ap}'")
listfile.write_text("\n".join(lines), encoding="utf-8")
cmd = [
FFMPEG_BIN,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(listfile),
"-c:a",
"aac",
"-b:a",
"128k",
str(out),
]
run_ffmpeg(cmd)
return out
def _gpu_encode_available(self) -> bool:
"""GPU 编码客户端是否已配置且健康(缓存健康状态,单任务内只探测一次)。"""
if not getattr(self, "_gpu_health_ok", None):
client = get_gpu_encoder()
if client is None:
self._gpu_health_ok = False
return False
try:
health = client.check_health()
if health.ready:
logger.info(
"[gpu-encoder] healthy endpoint=%s gpu=%s",
client.endpoint,
health.gpu_name,
)
self._gpu_health_ok = True
else:
logger.warning(
"[gpu-encoder] not ready: %s (endpoint=%s)",
health.error,
client.endpoint,
)
self._gpu_health_ok = False
except Exception as e: # noqa: BLE001
logger.warning("[gpu-encoder] health probe error (CPU fallback): %s", e)
self._gpu_health_ok = False
return self._gpu_health_ok
def _ffmpeg_output_to_mezzanine(
self,
base_command: list[str],
mezzanine_path: Path,
output_path: Path,
) -> bool:
"""用 CPU ultrafast 把滤镜链输出到 mezzanine_path,然后调 GPU 做最终编码。
base_command: 原本要执行的完整 ffmpeg 命令(含 -c:v libx264 -crf X -preset Y ... output_path)
我们把最后一个参数(output_path)替换成 mezzanine_path,并把编码参数改成 ultrafast,
成功后调用 gpu_encoder 做 nvenc 编码到 output_path。
任何失败返回 False,调用方走原始 CPU 路径。
"""
client = get_gpu_encoder()
if client is None:
return False
# 构造 mezzanine 命令:替换编码参数和输出路径
mezz_cmd = list(base_command)
# 找到编码参数位置并替换
try:
i_crf = mezz_cmd.index("-crf")
mezz_cmd[i_crf + 1] = "20"
i_preset = mezz_cmd.index("-preset")
mezz_cmd[i_preset + 1] = "ultrafast"
except ValueError:
logger.warning("[gpu-encoder] could not find -crf/-preset in command, skip gpu")
return False
# 如果命令有音频编码 -c:a aac,我们保留音频让 GPU 侧不用单独处理
# (P4000 的 ffmpeg_args 可以直接 copy 音频?这里简单起见:把音频编码留在 mezzanine,
# 然后 GPU 侧直接 -c:a copy,避免重编码损失)
has_audio = "-c:a" in mezz_cmd
# 替换输出路径(最后一个参数)
mezz_cmd[-1] = str(mezzanine_path)
# 1) 跑 mezzanine
mezzanine_path.parent.mkdir(parents=True, exist_ok=True)
t0 = time.time()
try:
run_ffmpeg(mezz_cmd)
except subprocess.CalledProcessError as e:
logger.warning("[gpu-encoder] mezzanine encode failed (CPU fallback): %s", e)
return False
logger.info(
"[gpu-encoder] mezzanine ready: %s (%.1fs, %d bytes), dispatching to P4000 nvenc...",
mezzanine_path.name,
time.time() - t0,
mezzanine_path.stat().st_size if mezzanine_path.exists() else 0,
)
# 2) GPU nvenc encode(含上传 mezzanine → OSS → P4000 下载+编码 → relay 回传)
try:
# GPU 侧:-i in.mp4 -c:v h264_nvenc ... 音频 copy(mezzanine 里音频已是 aac)
audio_args = ["-c:a", "copy"] if has_audio else None
client.encode_mezzanine_to_output(
mezzanine_path,
output_path,
audio_args=audio_args,
)
logger.info(
"[gpu-encoder] GPU nvenc encode done: %s (total %.1fs)",
output_path.name,
time.time() - t0,
)
return True
except GpuEncodeError as e:
logger.warning("[gpu-encoder] GPU encode failed (CPU fallback): %s", e)
# 删除可能残留的不完整 output
try:
if output_path.exists():
output_path.unlink()
except OSError:
pass
return False
except Exception as e: # noqa: BLE001
logger.warning("[gpu-encoder] GPU encode unexpected error (CPU fallback): %s", e)
return False
finally:
# 清理 mezzanine
try:
if mezzanine_path.exists():
mezzanine_path.unlink()
except OSError:
pass
def _execute_ffmpeg(
self,
filter_complex: str,
@@ -2647,28 +2209,20 @@ class UnifiedRenderService:
input_args.count("-i"),
output_path,
)
# 尝试 GPU NVENC 加速:先出 ultrafast mezzanine,再交给 P4000 做最终编码
gpu_ok = False
if self._gpu_encode_available():
mezz_path = output_path.parent / f".{output_path.stem}.mezz{output_path.suffix}"
gpu_ok = self._ffmpeg_output_to_mezzanine(command, mezz_path, output_path)
if not gpu_ok:
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
# 额外记录 filter_complex + stderr,方便排查滤镜链构建问题
stderr_text = (e.stderr or "").strip()
stderr_tail = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
logger.error(
"渲染失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s\nstderr(last 1500):\n%s",
self.plan.id,
e.returncode,
filter_complex[:5000],
stderr_tail,
)
raise
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
# 额外记录 filter_complex + stderr,方便排查滤镜链构建问题
stderr_text = (e.stderr or "").strip()
stderr_tail = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
logger.error(
"渲染失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s\nstderr(last 1500):\n%s",
self.plan.id,
e.returncode,
filter_complex[:5000],
stderr_tail,
)
raise
def _build_sticker_filters(self, input_label: str, output_label: str) -> tuple[str, list[str]]:
"""构建贴纸叠加滤镜链.
@@ -2830,9 +2384,6 @@ class UnifiedRenderService:
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
def _get_visual_perturbation(self) -> dict:
# #2034:dedup_enabled=False 时跳过视觉/像素扰动(与 edge_crop、micro_transform 一致)
if not self._dedup_enabled():
return {}
# 读取当前 plan 的视觉扰动参数(plan.config.visual_perturbation)
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
if not perturbation:
@@ -2906,7 +2457,7 @@ class UnifiedRenderService:
b = pixel_pert.get("color_b", 0)
if r != 0 or g != 0 or b != 0:
# color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换
filters.append(f"colorbalance=rs={r / 100:.3f}:gs={g / 100:.3f}:bs={b / 100:.3f}")
filters.append(f"colorbalance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
@staticmethod
def _clip_volume(clip: ResolvedClip) -> float:
-33
View File
@@ -1,33 +0,0 @@
"""爆款视频 Worker 侧模块(#2039/#2040/#2051)。
video_analyzer(#2051):参考视频风格分析 6 步管线,输出 style_guide + clips 渲染参数映射。
#2040 的 prompt 系统(prompts/prompt_store/llm_runner)由 #2040 分支提供,本文件不依赖它。
"""
from __future__ import annotations
from apps.worker.viral_video.video_analyzer import (
DEFAULT_ANALYSIS_TIMEOUT,
MAX_REFERENCE_DURATION_SEC,
MAX_REFERENCE_SIZE_MB,
STYLE_GUIDE_SCHEMA,
analyze_video_style,
build_render_params_for_clip,
map_bgm_bpm,
map_camera_to_ken_burns,
map_color_to_video_filter,
map_transition_to_xfade,
)
__all__ = [
"DEFAULT_ANALYSIS_TIMEOUT",
"MAX_REFERENCE_DURATION_SEC",
"MAX_REFERENCE_SIZE_MB",
"STYLE_GUIDE_SCHEMA",
"analyze_video_style",
"build_render_params_for_clip",
"map_bgm_bpm",
"map_camera_to_ken_burns",
"map_color_to_video_filter",
"map_transition_to_xfade",
]
-961
View File
@@ -1,961 +0,0 @@
"""参考爆款视频风格分析模块(#2051,v1.3)。
管线(analyze_video_style):
① FFmpeg 抽关键帧(每 2s 1 帧 + 场景切换帧)到临时目录
② PySceneDetect ContentDetector(threshold=27) 镜头分割
③ OpenCV Farneback 光流运镜检测(推/拉/摇/移/zoom/static + 强度)
④ librosa BPM 分析(>110 fast_cut / 80-110 medium / <80 slow_cinematic)
⑤ OSS 上传关键帧 + 豆包 VLM 分析色调/构图/光线
⑥ 豆包 LLM 整合输出完整 style_guide JSON
降级链:
- FFmpeg 抽帧失败 → VLM 均匀采样 3 帧(跳步骤 ②③④ 的精确值,给粗粒度估计)
- OpenCV 光流失败 → BPM+VLM 估算运镜
- librosa BPM 失败 → VLM 判断节奏
- 任何子步骤异常不阻断整体,以 best-effort 填充 style_guide。
资源约束:
- 参考视频 ≤60s 且 ≤100MB;分析总超时 ≤60s;临时帧 try/finally 清理。
"""
from __future__ import annotations
import json
import logging
import math
import shutil
import subprocess # nosec B404
import tempfile
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# ── 资源约束 ─────────────────────────────────────────────────────────────
MAX_REFERENCE_DURATION_SEC = 60
MAX_REFERENCE_SIZE_MB = 100
DEFAULT_ANALYSIS_TIMEOUT = 60 # 秒
KEYFRAME_INTERVAL_SEC = 2
SCENEDETECT_THRESHOLD = 27
VLM_SAMPLE_FRAMES = 5 # 上传给 VLM 的关键帧上限
FARNEBACK_PARAMS = dict(pyr_scale=0.5, levels=3, winsize=15, iterations=3, poly_n=5, poly_sigma=1.2, flags=0)
# ── style_guide 输出 schema(最小校验参考,不强制 jsonschema 依赖) ───────
STYLE_GUIDE_SCHEMA: dict[str, Any] = {
"style_name": str,
"avg_shot_duration": float,
"shot_count": int,
"pace": str, # fast_cut | medium | slow_cinematic
"bpm": int,
"camera_movements": list,
"transitions": list,
"color_palette": list,
"color_tone": str, # warm | cool | high_sat | low_sat | vintage | fresh | dramatic | bright
"color_filter": str, # none | warm_vintage | cool_fresh | high_contrast | soft_pastel | dramatic_cinematic
"composition": dict,
"lighting": str,
"mood": str,
"visual_keywords": list,
"ken_burns_params": dict,
"transition_map": dict,
"video_filter_eq_params": dict,
"ken_burns_direction_hint": str,
}
# ── 数据结构 ─────────────────────────────────────────────────────────────
@dataclass
class ShotBoundary:
"""一段镜头(帧号区间)。"""
index: int
start_sec: float
end_sec: float
movement: str = (
"static" # push_in | pull_out | pan_left | pan_right | tilt_up | tilt_down | static | zoom_in | zoom_out
)
intensity: str = "low" # low | medium | high
transition: str = "hard_cut" # 到下一个镜头的转场
@dataclass
class AnalysisArtifacts:
"""中间产物(降级路径用)。"""
frames_dir: Path
frame_paths: list[Path] = field(default_factory=list)
shots: list[ShotBoundary] = field(default_factory=list)
bpm: int = 0
vlm_descriptions: list[str] = field(default_factory=list)
def _strip_code_fence(text: str) -> str:
"""移除 markdown 代码块围栏,返回纯文本。"""
t = text.strip()
for fence in ("```json", "```JSON", "```"):
if t.startswith(fence):
t = t[len(fence) :].lstrip()
if t.endswith("```"):
t = t[:-3].rstrip()
return t
# ── FFmpeg / ffprobe ─────────────────────────────────────────────────────
def _ffmpeg_bin() -> str:
return shutil.which("ffmpeg") or "ffmpeg"
def _ffprobe_bin() -> str:
return shutil.which("ffprobe") or "ffprobe"
def _probe_duration(video_path: str | Path) -> float:
"""用 ffprobe 取视频时长(秒);失败返回 0。"""
try:
out = subprocess.check_output(
[
_ffprobe_bin(),
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(video_path),
],
stderr=subprocess.DEVNULL,
timeout=10,
text=True,
) # nosec B603
return float(out.strip() or 0)
except Exception as exc: # noqa: BLE001
logger.warning("ffprobe 时长探测失败 %s: %s", video_path, exc)
return 0.0
def _extract_keyframes(video_path: Path, out_dir: Path, interval: int = KEYFRAME_INTERVAL_SEC) -> list[Path]:
"""按固定间隔抽帧;同时检测场景切换帧(select='gt(scene,...)')。"""
out_dir.mkdir(parents=True, exist_ok=True)
# 固定间隔
fixed_tpl = str(out_dir / "f_%04d.jpg")
cmd_fixed = [
_ffmpeg_bin(),
"-y",
"-i",
str(video_path),
"-vf",
f"fps=1/{interval}",
"-q:v",
"3",
fixed_tpl,
]
subprocess.run(
cmd_fixed, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DEFAULT_ANALYSIS_TIMEOUT, check=False
) # nosec B603
# 场景切换帧(独立命名,scene_ 前缀)
scene_tpl = str(out_dir / "scene_%04d.jpg")
cmd_scene = [
_ffmpeg_bin(),
"-y",
"-i",
str(video_path),
"-vf",
"select='gt(scene,0.35)',showinfo",
"-vsync",
"vfr",
"-q:v",
"3",
scene_tpl,
]
subprocess.run(
cmd_scene, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DEFAULT_ANALYSIS_TIMEOUT, check=False
) # nosec B603
frames = sorted(out_dir.glob("f_*.jpg")) + sorted(out_dir.glob("scene_*.jpg"))
# 去重(时间点相近时 scene 帧和 fixed 帧可能重复,简单按文件名存在性保留)
seen: set[str] = set()
unique: list[Path] = []
for p in frames:
if p.name not in seen:
seen.add(p.name)
unique.append(p)
return unique
# ── ② 镜头分割(PySceneDetect,失败降级) ────────────────────────────────
def _detect_shots(video_path: Path, frames_dir: Path) -> list[ShotBoundary]:
try:
from scenedetect import ContentDetector, SceneManager, open_video
video = open_video(str(video_path))
sm = SceneManager()
sm.add_detector(ContentDetector(threshold=SCENEDETECT_THRESHOLD))
sm.detect_scenes(video)
scenes = sm.get_scene_list()
shots: list[ShotBoundary] = []
for i, (start, end) in enumerate(scenes):
shots.append(
ShotBoundary(
index=i,
start_sec=start.get_seconds(),
end_sec=end.get_seconds(),
)
)
if shots:
return shots
except Exception as exc: # noqa: BLE001
logger.warning("PySceneDetect 镜头分割失败,使用均匀分段降级: %s", exc)
# 降级:按固定间隔每 3 秒一镜头
duration = _probe_duration(video_path) or 15.0
dur = max(3.0, min(duration, float(MAX_REFERENCE_DURATION_SEC)))
shots = []
seg = 3.0
i = 0
t = 0.0
while t < dur - 0.1:
shots.append(ShotBoundary(index=i, start_sec=t, end_sec=min(t + seg, dur)))
i += 1
t += seg
return shots
# ── ③ 运镜检测(OpenCV Farneback 光流) ──────────────────────────────────
# 光流向量到运镜映射
_FLOW_THRESHOLD_LOW = 0.3
_FLOW_THRESHOLD_HIGH = 1.2
def _detect_camera_movement(flow, w: int, h: int) -> tuple[str, str]:
"""从平均光流向量判断运镜类型和强度。"""
import numpy as np # noqa: PLC0415 - numpy 已在 requirements 中
fx = float(np.median(flow[..., 0]))
fy = float(np.median(flow[..., 1]))
trans_mag = math.hypot(fx, fy)
# 发散/收敛判断 zoom:比较边缘流沿径向外指的平均分量(稳健版)
cx, cy = w / 2.0, h / 2.0
ys, xs = np.mgrid[0:h, 0:w].astype(np.float32)
rx, ry = (xs - cx) / max(cx, 1.0), (ys - cy) / max(cy, 1.0)
rmag = np.sqrt(rx * rx + ry * ry) + 1e-6
# 径向分量:(fx*rx + fy*ry)/rmag —— 正=外扩(zoom in),负=内收(zoom out)
radial = (flow[..., 0] * rx + flow[..., 1] * ry) / rmag
# 只看边缘带(|r|>0.5),且减去平移贡献:径向减去平均平移投影
edge_mask = (rmag > 0.5).astype(np.float32)
if edge_mask.sum() > 10:
trans_radial = (fx * rx + fy * ry) / rmag
zoom_signal = float(np.mean((radial - trans_radial)[edge_mask > 0]))
else:
zoom_signal = 0.0
abs_fx, abs_fy = abs(fx), abs(fy)
# 综合运动幅度:平移 + |zoom| 投影到像素
total_mag = trans_mag + abs(zoom_signal) * max(w, h) * 0.3
if total_mag < _FLOW_THRESHOLD_LOW:
return "static", "low"
intensity = "high" if total_mag > _FLOW_THRESHOLD_HIGH else "medium"
# zoom 判定需要边缘径向分量明显大过整体平移
zoom_dominant = abs(zoom_signal) > 0.6 and abs(zoom_signal) * max(w, h) * 0.3 > trans_mag * 1.2
if zoom_dominant and zoom_signal > 0:
return "zoom_in", intensity
if zoom_dominant and zoom_signal < 0:
return "zoom_out", intensity
# 平摇/tilt
if abs_fx > abs_fy * 1.5:
return "pan_right" if fx > 0 else "pan_left", intensity
if abs_fy > abs_fx * 1.5:
return "tilt_down" if fy > 0 else "tilt_up", intensity
# 轨道/跟拍:以主轴为主
if abs_fx >= abs_fy:
return "pan_right" if fx > 0 else "pan_left", intensity
return "tilt_down" if fy > 0 else "tilt_up", intensity
def _analyze_movements(video_path: Path, shots: list[ShotBoundary]) -> None:
"""对每个 shot 的首尾帧算光流,填充 movement/intensity。失败时静默降级为 static/low。"""
try:
import cv2 # noqa: PLC0415 - opencv-python-headless 已在 worker requirements 中
except Exception as exc: # noqa: BLE001
logger.warning("OpenCV 不可用,运镜检测降级为 static/low: %s", exc)
return
try:
cap = cv2.VideoCapture(str(video_path))
for shot in shots:
mid_t = (shot.start_sec + shot.end_sec) / 2.0
dt = max(0.2, min(0.5, (shot.end_sec - shot.start_sec) / 4.0))
cap.set(cv2.CAP_PROP_POS_MSEC, max(0.0, (mid_t - dt)) * 1000)
ok1, f1 = cap.read()
cap.set(cv2.CAP_PROP_POS_MSEC, min(mid_t + dt, shot.end_sec - 0.05) * 1000)
ok2, f2 = cap.read()
if not (ok1 and ok2):
continue
g1 = cv2.cvtColor(f1, cv2.COLOR_BGR2GRAY)
g2 = cv2.cvtColor(f2, cv2.COLOR_BGR2GRAY)
h, w = g1.shape
# 降采样加速
scale = 360.0 / h if h > 360 else 1.0
if scale < 1.0:
g1 = cv2.resize(g1, (int(w * scale), int(h * scale)))
g2 = cv2.resize(g2, (int(w * scale), int(h * scale)))
flow = cv2.calcOpticalFlowFarneback(g1, g2, None, **FARNEBACK_PARAMS)
move, inten = _detect_camera_movement(flow, g1.shape[1], g1.shape[0])
shot.movement = move
shot.intensity = inten
cap.release()
except Exception as exc: # noqa: BLE001
logger.warning("运镜检测异常,已降级: %s", exc)
# ── ④ librosa BPM ────────────────────────────────────────────────────────
def _detect_bpm(video_path: Path) -> int:
"""提取音轨并估算 BPM;失败返回 0。"""
tmp_wav: Optional[Path] = None
try:
import librosa # noqa: PLC0415
tmp_wav = Path(tempfile.mkstemp(suffix=".wav")[1])
# ffmpeg 抽 22050Hz 单声道 wav
subprocess.run(
[_ffmpeg_bin(), "-y", "-i", str(video_path), "-vn", "-ac", "1", "-ar", "22050", "-f", "wav", str(tmp_wav)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=20,
check=False,
) # nosec B603
if not tmp_wav.exists() or tmp_wav.stat().st_size < 1024:
return 0
y, sr = librosa.load(str(tmp_wav), sr=22050, mono=True)
if len(y) < sr * 2:
return 0
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
try:
bpm = int(round(float(tempo)))
except Exception: # noqa: BLE001
bpm = int(round(float(tempo[0]))) if len(tempo) else 0
return max(40, min(bpm, 220))
except Exception as exc: # noqa: BLE001
logger.warning("librosa BPM 分析失败: %s", exc)
return 0
finally:
if tmp_wav and tmp_wav.exists():
try:
tmp_wav.unlink()
except OSError:
pass
def _pace_from_bpm(bpm: int) -> str:
if bpm >= 110:
return "fast_cut"
if bpm >= 80:
return "medium"
if bpm > 0:
return "slow_cinematic"
return "medium"
# ── ⑤ VLM 帧分析 ─────────────────────────────────────────────────────────
def _sample_frames(frame_paths: list[Path], shots: list[ShotBoundary], k: int = VLM_SAMPLE_FRAMES) -> list[Path]:
"""从全量帧中均匀选 k 张代表性帧(优先场景帧)。"""
if not frame_paths:
return []
scene_frames = sorted(p for p in frame_paths if p.name.startswith("scene_"))
fixed_frames = sorted(p for p in frame_paths if p.name.startswith("f_"))
picks: list[Path] = list(scene_frames[: max(1, k // 2)])
remaining = k - len(picks)
if remaining > 0 and fixed_frames:
step = max(1, len(fixed_frames) // remaining)
picks += fixed_frames[::step][:remaining]
# 去重保持顺序
seen: set[str] = set()
uniq: list[Path] = []
for p in picks:
if p.name not in seen and p.exists():
seen.add(p.name)
uniq.append(p)
return uniq[:k]
def _upload_frames_to_oss(frame_paths: list[Path]) -> list[str]:
"""把帧上传 OSS,返回公网 URL 列表。失败时降级为 data URI。"""
urls: list[str] = []
try:
from video_processing.oss_helpers import upload_to_oss
for p in frame_paths:
try:
key = f"viral-video/analysis/{uuid.uuid4().hex}/{p.name}"
url = upload_to_oss(p, key)
if url:
urls.append(url)
except Exception as exc: # noqa: BLE001
logger.warning("单帧 OSS 上传失败 %s: %s", p.name, exc)
except Exception as exc: # noqa: BLE001
logger.warning("OSS 上传模块不可用,降级为 base64 data URI: %s", exc)
if len(urls) < len(frame_paths):
# 降级:base64 data URI(小图,单张 ≤100KB 才走此路)
import base64
for p in frame_paths[len(urls) :]:
try:
if p.stat().st_size > 120_000:
continue
b64 = base64.b64encode(p.read_bytes()).decode("ascii")
urls.append(f"data:image/jpeg;base64,{b64}")
except Exception: # noqa: BLE001 # nosec B112
continue
return urls
def _vlm_analyze_frames(image_urls: list[str]) -> dict[str, Any]:
"""调豆包 VLM 分析色调/构图/光线/转场观感。"""
if not image_urls:
return {}
try:
from packages.shared.ai_client import get_doubao_client
client = get_doubao_client()
if not client.is_available:
raise RuntimeError("豆包客户端未配置")
sys_prompt = (
"你是资深短视频导演和调色师。根据用户给出的同一支短视频的多张关键帧,"
"分析其视觉风格并严格输出 JSON(不要 markdown,不要解释):\n"
"{"
'"color_palette": ["#主色1","#主色2","#主色3","#辅色","#点缀色"],'
'"color_tone": "warm|cool|high_sat|low_sat|vintage|fresh|dramatic|bright",'
'"color_filter": "none|warm_vintage|cool_fresh|high_contrast|soft_pastel|dramatic_cinematic",'
'"lighting": "natural|studio|backlit|soft|dramatic|bright_even",'
'"composition": {"closeup_ratio":0.0,"medium_ratio":0.0,"wide_ratio":0.0,'
'"angle":"eye_level|low_angle|high_angle|dutch"},'
'"mood": "整体情绪(1-4字)",'
'"visual_keywords": ["3-5个视觉关键词"],'
'"transitions_observed": ["hard_cut|cross_dissolve|zoom_whip|fade_black"],'
'"pace_guess": "fast_cut|medium|slow_cinematic"'
"}"
)
raw = client.vision_completion(
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": "请分析这支参考视频的风格。"},
],
images=image_urls,
temperature=0.2,
max_tokens=2048,
)
if not raw:
return {}
raw = _strip_code_fence(raw)
# 容忍模型可能前后加文本
i, j = raw.find("{"), raw.rfind("}")
if i >= 0 and j > i:
return json.loads(raw[i : j + 1])
return {}
except Exception as exc: # noqa: BLE001
logger.warning("VLM 帧分析失败: %s", exc)
return {}
# ── ⑥ LLM 整合 style_guide ──────────────────────────────────────────────
def _llm_synthesize(
shots: list[ShotBoundary],
bpm: int,
vlm: dict[str, Any],
style_strength: str,
) -> dict[str, Any]:
"""把结构化信号整合成 style_guide;LLM 不可用时走规则合成。"""
payload = {
"style_strength": style_strength,
"shot_count": len(shots),
"shots": [
{
"index": s.index,
"start_sec": round(s.start_sec, 2),
"end_sec": round(s.end_sec, 2),
"movement": s.movement,
"intensity": s.intensity,
"transition": s.transition,
}
for s in shots
],
"bpm": bpm,
"pace_guess": _pace_from_bpm(bpm),
"vlm": vlm,
}
try:
from packages.shared.ai_client import get_doubao_client
client = get_doubao_client()
if not client.is_available:
raise RuntimeError("豆包客户端未配置")
sys_prompt = (
"你是资深短视频导演。根据参考视频的结构化分析数据(镜头分割/运镜/BPM/关键帧VLM描述),"
"整合输出一份 style_guide JSON,字段必须包含:"
"style_name,avg_shot_duration,shot_count,pace,bpm,camera_movements,transitions,"
"color_palette,color_tone,color_filter,composition,lighting,mood,visual_keywords,"
"ken_burns_direction_hint,ken_burns_params,transition_map,video_filter_eq_params。"
"严格输出一个合法 JSON 对象,不要 markdown/解释。"
)
user_text = "分析数据:\n" + json.dumps(payload, ensure_ascii=False)
raw = client.chat_completion(
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_text}],
temperature=0.3,
max_tokens=4096,
)
if raw:
raw = _strip_code_fence(raw)
i, j = raw.find("{"), raw.rfind("}")
if i >= 0 and j > i:
result = json.loads(raw[i : j + 1])
if isinstance(result, dict) and result.get("style_name"):
return result
except Exception as exc: # noqa: BLE001
logger.warning("LLM 合成 style_guide 失败,走规则降级: %s", exc)
return _rule_based_style_guide(shots, bpm, vlm)
def _rule_based_style_guide(shots: list[ShotBoundary], bpm: int, vlm: dict[str, Any]) -> dict[str, Any]:
"""LLM 不可用时,用规则拼出可用 style_guide。"""
durations = [s.end_sec - s.start_sec for s in shots] or [3.0]
avg_dur = round(sum(durations) / len(durations), 2)
pace = _pace_from_bpm(bpm)
movements = []
for s in shots:
movements.append(
{
"shot_index": s.index + 1,
"movement": s.movement,
"intensity": s.intensity,
"duration": round(s.end_sec - s.start_sec, 2),
"subject_hint": _default_subject_hint(s.movement),
}
)
transitions = []
for i in range(len(shots) - 1):
transitions.append({"between_shot": [i + 1, i + 2], "type": shots[i].transition})
color_palette = vlm.get("color_palette") or ["#E0E0E0", "#333333", "#F5F5F5", "#888888", "#FF6B35"]
color_tone = vlm.get("color_tone") or "bright"
color_filter = vlm.get("color_filter") or "none"
lighting = vlm.get("lighting") or "bright_even"
composition = vlm.get("composition") or {
"closeup_ratio": 0.4,
"medium_ratio": 0.4,
"wide_ratio": 0.2,
"angle": "eye_level",
}
mood = vlm.get("mood") or "明快"
vk = vlm.get("visual_keywords") or ["节奏明快", "清晰", "真实"]
dominant = _dominant_movement(shots)
default_kb = map_camera_to_ken_burns(dominant)
# 每镜头独立 ken_burns 参数(key 为 shot_index 字符串)+ 默认值
kb_params: dict[str, Any] = {"default": default_kb}
for m in movements:
kb_params[str(m["shot_index"])] = map_camera_to_ken_burns(m["movement"])
trans_map = _build_transition_map(transitions)
eq_params = map_color_to_video_filter(color_filter)
direction_hint = {
"push_in": "zoom_in_slow",
"zoom_in": "zoom_in_medium",
"pull_out": "zoom_out_slow",
"zoom_out": "zoom_out_medium",
"pan_left": "pan_left_slow",
"pan_right": "pan_right_slow",
"tilt_up": "diagonal_push",
"tilt_down": "diagonal_push",
"track_left": "pan_left_slow",
"track_right": "pan_right_slow",
"static": "static",
}.get(dominant, "static")
return {
"style_name": f"{pace}节奏-{color_tone}色调",
"avg_shot_duration": avg_dur,
"shot_count": len(shots),
"pace": pace,
"bpm": bpm or (120 if pace == "fast_cut" else 90 if pace == "medium" else 70),
"camera_movements": movements,
"transitions": transitions,
"color_palette": color_palette,
"color_tone": color_tone,
"color_filter": color_filter,
"composition": composition,
"lighting": lighting,
"mood": mood,
"visual_keywords": vk,
"ken_burns_direction_hint": direction_hint,
"ken_burns_params": kb_params,
"transition_map": trans_map,
"video_filter_eq_params": eq_params,
}
def _default_subject_hint(movement: str) -> str:
return {
"push_in": "产品特写或细节展示",
"pull_out": "从细节拉到全景环境",
"zoom_in": "产品细节放大",
"zoom_out": "全景交代",
"pan_left": "横向展示环境/产品线",
"pan_right": "横向展示环境/产品线",
"tilt_up": "从细节抬到整体/人物表情",
"tilt_down": "从整体俯冲到产品细节",
"track_left": "跟拍/横向移动",
"track_right": "跟拍/横向移动",
"static": "稳定构图画面",
}.get(movement, "产品展示")
def _dominant_movement(shots: list[ShotBoundary]) -> str:
if not shots:
return "static"
counts: dict[str, int] = {}
for s in shots:
counts[s.movement] = counts.get(s.movement, 0) + 1
return max(counts, key=counts.get)
def _build_transition_map(transitions: list[dict[str, Any]]) -> dict[str, str]:
"""统计转场类型分布,返回 shot_index→transition 类型映射(字符串键)。"""
m: dict[str, str] = {}
for t in transitions:
pair = t.get("between_shot") or [0, 0]
if len(pair) >= 2:
m[f"{pair[0]}-{pair[1]}"] = t.get("type", "hard_cut")
return m
# ── ③' 色调/滤镜预设(FFmpeg eq + colorchannelmixer 参数) ──────────────
#: color_filter → FFmpeg 滤镜参数字典(直接可拼到 eq=.../colorchannelmixer=...)
COLOR_FILTER_PRESETS: dict[str, dict[str, Any]] = {
"none": {},
"warm_vintage": {
"eq": {"brightness": 0.02, "contrast": 1.05, "saturation": 0.9, "gamma": 1.05},
"colorchannelmixer": {"rr": 1.1, "gg": 0.98, "bb": 0.82, "ra": 0, "ga": 0, "ba": 0, "aa": 1},
},
"cool_fresh": {
"eq": {"brightness": 0.03, "contrast": 1.08, "saturation": 1.05},
"colorchannelmixer": {"rr": 0.9, "gg": 1.0, "bb": 1.12, "ra": 0, "ga": 0, "ba": 0, "aa": 1},
},
"high_contrast": {
"eq": {"brightness": 0.0, "contrast": 1.3, "saturation": 1.2},
"colorchannelmixer": {},
},
"soft_pastel": {
"eq": {"brightness": 0.05, "contrast": 0.92, "saturation": 0.85},
"colorchannelmixer": {"rr": 1.05, "gg": 1.03, "bb": 1.05, "ra": 0, "ga": 0, "ba": 0, "aa": 1},
},
"dramatic_cinematic": {
"eq": {"brightness": -0.03, "contrast": 1.2, "saturation": 0.85},
"colorchannelmixer": {"rr": 1.05, "gg": 0.98, "bb": 0.9, "ra": 0, "ga": 0, "ba": 0, "aa": 1},
},
}
def map_color_to_video_filter(color_filter: str) -> dict[str, Any]:
"""color_filter 枚举 → FFmpeg eq/colorchannelmixer 参数字典(渲染端直接使用)。"""
preset = COLOR_FILTER_PRESETS.get(color_filter) or COLOR_FILTER_PRESETS["none"]
# 返回深拷贝防污染
return json.loads(json.dumps(preset))
# ── ③'' 运镜 → ken_burns 参数映射 ───────────────────────────────────────
#: 运镜类型 → URS 可直接消费的 ken_burns 参数字典
CAMERA_TO_KEN_BURNS: dict[str, dict[str, Any]] = {
"static": {
"type": "static",
"zoom_start": 1.0,
"zoom_end": 1.0,
"pan_x": 0.0,
"pan_y": 0.0,
"duration_factor": 1.0,
},
"push_in": {
"type": "zoom",
"zoom_start": 1.0,
"zoom_end": 1.12,
"pan_x": 0.0,
"pan_y": 0.0,
"duration_factor": 1.0,
},
"zoom_in": {
"type": "zoom",
"zoom_start": 1.0,
"zoom_end": 1.18,
"pan_x": 0.0,
"pan_y": 0.0,
"duration_factor": 1.0,
},
"pull_out": {
"type": "zoom",
"zoom_start": 1.12,
"zoom_end": 1.0,
"pan_x": 0.0,
"pan_y": 0.0,
"duration_factor": 1.0,
},
"zoom_out": {
"type": "zoom",
"zoom_start": 1.18,
"zoom_end": 1.0,
"pan_x": 0.0,
"pan_y": 0.0,
"duration_factor": 1.0,
},
"pan_left": {
"type": "pan",
"zoom_start": 1.05,
"zoom_end": 1.05,
"pan_x": -0.08,
"pan_y": 0.0,
"duration_factor": 1.0,
},
"pan_right": {
"type": "pan",
"zoom_start": 1.05,
"zoom_end": 1.05,
"pan_x": 0.08,
"pan_y": 0.0,
"duration_factor": 1.0,
},
"tilt_up": {
"type": "pan+zoom",
"zoom_start": 1.08,
"zoom_end": 1.14,
"pan_x": 0.0,
"pan_y": -0.05,
"duration_factor": 1.0,
},
"tilt_down": {
"type": "pan+zoom",
"zoom_start": 1.14,
"zoom_end": 1.08,
"pan_x": 0.0,
"pan_y": 0.05,
"duration_factor": 1.0,
},
"track_left": {
"type": "pan",
"zoom_start": 1.05,
"zoom_end": 1.05,
"pan_x": -0.10,
"pan_y": 0.0,
"duration_factor": 1.0,
},
"track_right": {
"type": "pan",
"zoom_start": 1.05,
"zoom_end": 1.05,
"pan_x": 0.10,
"pan_y": 0.0,
"duration_factor": 1.0,
},
}
def map_camera_to_ken_burns(movement: str) -> dict[str, Any]:
"""运镜类型 → URS ken_burns 参数字典。未知类型回退 static。"""
preset = CAMERA_TO_KEN_BURNS.get(movement) or CAMERA_TO_KEN_BURNS["static"]
return json.loads(json.dumps(preset))
# ── 转场 → xfade transition 名称 ────────────────────────────────────────
TRANSITION_TO_XFADE: dict[str, str] = {
"hard_cut": "cut",
"cross_dissolve": "dissolve",
"fade_black": "fadeblack",
"fade": "fade",
"zoom_whip": "zoom",
"slide_left": "slideright", # 画面左移 = 新画面从右滑入
"slide_right": "slideleft",
"wipe_left": "wipeleft",
"wipe_right": "wiperight",
}
def map_transition_to_xfade(transition_type: str) -> str:
"""转场枚举 → TransitionEngine 支持的 xfade 名称;未知回退 cut。"""
return TRANSITION_TO_XFADE.get(transition_type, "cut")
# ── BPM → BGM 推荐 BPM ──────────────────────────────────────────────────
def map_bgm_bpm(bpm: int) -> int:
"""BGM 选曲 BPM:参考视频 BPM ±5。bpm=0 返回 90(默认 medium)。"""
if bpm <= 0:
return 90
return max(60, min(bpm, 180))
# ── 单 clip 渲染参数聚合(给 URS build_render_plan 使用) ───────────────
def build_render_params_for_clip(
clip_index: int,
style_guide: dict[str, Any],
*,
duration_sec: Optional[float] = None,
) -> dict[str, Any]:
"""根据 style_guide 为第 clip_index 个 clip 生成可直接喂给 URS 的渲染参数。"""
shot_idx = clip_index + 1
movements = style_guide.get("camera_movements") or []
movement = "static"
intensity = "low"
for m in movements:
if m.get("shot_index") == shot_idx:
movement = m.get("movement", "static")
intensity = m.get("intensity", "low")
break
ken = map_camera_to_ken_burns(movement)
if intensity == "high":
ken["zoom_end"] = round(ken.get("zoom_end", 1.0) * 1.08, 3)
for k in ("pan_x", "pan_y"):
ken[k] = round(ken.get(k, 0.0) * 1.3, 3)
elif intensity == "low":
for k in ("pan_x", "pan_y"):
ken[k] = round(ken.get(k, 0.0) * 0.6, 3)
transitions = style_guide.get("transitions") or []
trans_type = "hard_cut"
for t in transitions:
pair = t.get("between_shot") or []
if len(pair) >= 2 and pair[0] == shot_idx:
trans_type = t.get("type", "hard_cut")
break
xfade = map_transition_to_xfade(trans_type)
eq = map_color_to_video_filter(style_guide.get("color_filter", "none"))
return {
"ken_burns": ken,
"transition": {"type": xfade, "duration": 0.3 if xfade != "cut" else 0.0},
"video_filter": eq,
"bgm_bpm_hint": map_bgm_bpm(int(style_guide.get("bpm") or 0)),
"duration_sec": duration_sec,
}
# ── 素材本地化(URL/OSS key → 本地临时文件) ────────────────────────────
def _ensure_local_video(reference: str, work_dir: Path) -> Optional[Path]:
"""把 reference(URL/OSS key/本地路径)落到 work_dir 下的本地文件。"""
p = Path(reference)
if p.exists() and p.is_file():
return p
try:
from video_processing.oss_helpers import download_asset
target = work_dir / f"ref_{uuid.uuid4().hex}.mp4"
ok = download_asset(reference, target)
if ok and target.exists() and target.stat().st_size > 0:
return target
except Exception as exc: # noqa: BLE001
logger.warning("download_asset 失败,尝试 http 直连: %s", exc)
if reference.startswith(("http://", "https://")):
try:
import httpx # noqa: PLC0415 - 项目依赖,延迟导入
target = work_dir / f"ref_{uuid.uuid4().hex}.mp4"
with httpx.Client(timeout=20.0, follow_redirects=True) as client:
with client.stream("GET", reference) as resp:
resp.raise_for_status()
with open(target, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
f.write(chunk)
if target.exists() and target.stat().st_size > 0:
return target
except Exception as exc: # noqa: BLE001
logger.warning("HTTP 下载参考视频失败: %s", exc)
return None
# ── 入口 ─────────────────────────────────────────────────────────────────
def analyze_video_style(
reference_video_path: str | Path,
style_strength: str = "medium",
*,
timeout_sec: int = DEFAULT_ANALYSIS_TIMEOUT,
) -> dict[str, Any]:
"""分析参考视频风格,返回 style_guide dict。
Args:
reference_video_path: 本地路径、HTTP(S) URL 或 OSS storage key。
style_strength: light | medium | strict。
timeout_sec: 单步超时(秒),默认 60。
Returns:
style_guide dict,详见 STYLE_GUIDE_SCHEMA。任何子步骤失败都会降级,不抛异常。
"""
style_strength = style_strength if style_strength in ("light", "medium", "strict") else "medium"
frames_dir: Optional[Path] = None
local_path: Optional[Path] = None
try:
frames_dir = Path(tempfile.mkdtemp(prefix="vstyle_"))
work_dir = frames_dir # 同一临时根
local_path = _ensure_local_video(str(reference_video_path), work_dir)
if local_path is None:
logger.error("[video_analyzer] 无法获取参考视频: %s", reference_video_path)
return _rule_based_style_guide([], 0, {})
# 资源约束:大小 / 时长
try:
size_mb = local_path.stat().st_size / (1024 * 1024)
if size_mb > MAX_REFERENCE_SIZE_MB:
logger.warning(
"[video_analyzer] 参考视频 %.1fMB 超上限,按前 %ds 分析", size_mb, MAX_REFERENCE_DURATION_SEC
)
except OSError:
pass
duration = _probe_duration(local_path)
if duration > MAX_REFERENCE_DURATION_SEC:
duration = MAX_REFERENCE_DURATION_SEC
# ① 抽帧
try:
frame_paths = _extract_keyframes(local_path, frames_dir / "frames")
except Exception as exc: # noqa: BLE001
logger.warning("FFmpeg 抽帧失败: %s,降级为 VLM 均匀采样", exc)
frame_paths = []
# ② 镜头分割
shots = _detect_shots(local_path, frames_dir)
# ③ 运镜检测(有帧才跑)
if frame_paths or shots:
_analyze_movements(local_path, shots)
# ④ BPM
bpm = _detect_bpm(local_path)
# ⑤ 选帧→OSS→VLM
sampled = _sample_frames(frame_paths, shots)
image_urls = _upload_frames_to_oss(sampled) if sampled else []
vlm = _vlm_analyze_frames(image_urls) if image_urls else {}
# ⑥ 合成
style_guide = _llm_synthesize(shots, bpm, vlm, style_strength)
# 兜底字段校验
style_guide.setdefault("style_strength", style_strength)
style_guide.setdefault("pace", _pace_from_bpm(bpm))
style_guide.setdefault("bpm", bpm)
style_guide.setdefault("shot_count", len(shots))
if shots and "avg_shot_duration" not in style_guide:
durs = [s.end_sec - s.start_sec for s in shots]
style_guide["avg_shot_duration"] = round(sum(durs) / len(durs), 2)
return style_guide
except Exception as exc: # noqa: BLE001
logger.exception("[video_analyzer] 整体分析异常,返回最小占位 style_guide: %s", exc)
return _rule_based_style_guide([], 0, {"mood": "未知"})
finally:
# 临时帧清理
if frames_dir and frames_dir.exists():
shutil.rmtree(frames_dir, ignore_errors=True)
-8
View File
@@ -31,14 +31,12 @@ celery_app.conf.imports = (
# #1970 片段级 AI 标签:必须显式 import 注册,否则 worker 报
# "Received unregistered task of type 'worker.tag_atom_clip'"
"worker_app.tasks.atom_clip_tagging",
"worker_app.tasks.asset_quality_scoring_task",
"worker_app.tasks.backfill_atom_clip_tags",
"worker_app.tasks.classification",
"worker_app.tasks.generation",
"worker_app.tasks.voice_extraction",
"worker_app.tasks.voice_clone",
"worker_app.tasks.tts_synthesis",
"worker_app.tasks.viral_video", # #2039 爆款视频编排器(10步流水线)
"worker_app.tasks.batch_download",
"worker_app.tasks.duplication_check",
# #1798 AI 数字人渲染:必须在 Worker 实例上注册同名任务,否则消息无人消费(渲染卡 0%)
@@ -77,10 +75,4 @@ celery_app.conf.beat_schedule = {
"schedule": 600.0, # 每 10 分钟(秒)
"options": {"expires": 540},
},
# 音色克隆卡死巡检:worker 重启/消息丢失后 processing 卡 10 分钟标 failed,用户可点重试
"cleanup-stale-voice-clones": {
"task": "worker.cleanup_stale_voice_clones",
"schedule": 300.0, # 每 5 分钟
"options": {"expires": 240},
},
}
-77
View File
@@ -287,80 +287,3 @@ def _recover_stuck_ingest_jobs_on_ready(sender, **kwargs): # pragma: no cover
logger.info("Worker 启动 ingest 恢复完成,共重新派单 %d 个卡死任务", recovered)
except Exception as e: # noqa: BLE001 — 启动恢复失败不能阻断 worker 起服
logger.error("启动 ingest 恢复扫描失败(beat 巡检仍会兜底标 failed): %s", e, exc_info=True)
def recover_stale_voice_clones_on_startup(timeout_minutes: int = 10) -> int:
"""Worker 启动时恢复卡死在 processing 的音色克隆任务。
容器重启/进程 OOM 时 worker 中正在轮询的克隆任务会丢失,
voice_clone_profiles 永久卡在 processing 无兜底。启动时扫描
updated_at 超过 timeout_minutes 的 processing 记录,直接标记
为 failed(错误信息指引用户重试)。选择标 failed 而非重新派单,
因为 CosyVoice 侧的 voice_id 无法在无上下文下恢复轮询,重试需
用户确认后显式触发。
Args:
timeout_minutes: 判定卡死的阈值,默认 10 分钟
Returns:
恢复的记录数
"""
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
SQLAlchemyVoiceCloneProfileRepository,
)
try:
session = SessionLocal()
try:
repo = SQLAlchemyVoiceCloneProfileRepository(session)
count = repo.cleanup_stale_processing(timeout_minutes)
finally:
session.close()
if count > 0:
logger.warning("启动时恢复了 %d 个卡死在 processing 的音色克隆(超时 %d 分钟)", count, timeout_minutes)
else:
logger.info("无卡死 processing 音色克隆需要恢复")
return count
except Exception as e:
logger.error("启动时音色克隆恢复扫描失败(beat 巡检仍会兜底): %s", e, exc_info=True)
return 0
@worker_ready.connect
def _recover_stuck_voice_clones_on_ready(sender, **kwargs):
"""Worker 启动完成后恢复卡死的音色克隆任务。"""
try:
recovered = recover_stale_voice_clones_on_startup()
logger.info("Worker 启动音色克隆恢复完成,共标记 %d 个卡死任务为 failed", recovered)
except Exception as e:
logger.error("启动音色克隆恢复失败(beat 巡检仍会兜底标 failed): %s", e, exc_info=True)
@worker_ready.connect
def _probe_gpu_encoder_on_ready(sender, **kwargs):
"""Worker 启动完成后探测 P4000 GPU NVENC 节点状态,打日志。"""
try:
from packages.shared.gpu_encoder import get_gpu_encoder
client = get_gpu_encoder()
if client is None:
logger.info(
"[gpu-encoder] disabled (ENABLE_GPU_ENCODE=false or endpoint not configured), using CPU libx264"
)
return
health = client.check_health()
if health.ready:
logger.info(
"[gpu-encoder] NVENC enabled: endpoint=%s gpu=%s worker=%s",
client.endpoint,
health.gpu_name,
health.worker,
)
else:
logger.warning(
"[gpu-encoder] configured but NOT ready: %s (endpoint=%s) — falling back to CPU",
health.error,
client.endpoint,
)
except Exception as e: # noqa: BLE001
logger.warning("[gpu-encoder] startup probe error (will retry on first job, CPU fallback): %s", e)

Some files were not shown because too many files have changed in this diff Show More