Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba8f786b60 | |||
| b04a803655 | |||
| e496f127a3 | |||
| 423be1446f | |||
| 6d9d2e8179 | |||
| b233529eee | |||
| 6de6971e7b | |||
| 548be6aced | |||
| 588a4b7320 | |||
| 1a93a9c00e | |||
| 01991f14d7 | |||
| 8abdeb9551 | |||
| 97ad0ae2e5 | |||
| 59c05148ab | |||
| a00031e100 | |||
| 87ec400a1b | |||
| 2d3cb13707 | |||
| 28cbe2a207 | |||
| e4723bfb1b | |||
| 9c0535da19 | |||
| c7288cd46b | |||
| 447aad166b | |||
| 1463cf0834 | |||
| 3a8d5e50d0 | |||
| 6778f4be32 | |||
| 778b17a723 | |||
| 706fed9c08 | |||
| 7b0db03875 | |||
| 66dc73c8d3 | |||
| 30f16c4771 | |||
| 39c064ba1c | |||
| 105ab54059 | |||
| a5075624f8 | |||
| 95fdc97b65 | |||
| 8a396303b8 | |||
| 534e4fcc36 | |||
| ac5353b335 | |||
| 44a98b8fcb | |||
| 755b3a8eb4 | |||
| 63e1889bc4 | |||
| c7dffb858b | |||
| 407516e78b | |||
| 76928d2dfc | |||
| d6e63349cb | |||
| 751f8ad84e | |||
| 1f3b04cde5 | |||
| 1be658f72d | |||
| 9039fcaea9 | |||
| 54368c24ff | |||
| a998ccd527 | |||
| dd16f8f783 | |||
| 1528d6b59c | |||
| 445375e1cb | |||
| 23fe5f9822 | |||
| c32065207a |
+21
-2
@@ -79,14 +79,33 @@ CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
||||
|
||||
|
||||
# ==================== Worker 配置 ====================
|
||||
# ==================== Worker 配置(#2073 队列分流) ====================
|
||||
#
|
||||
# 容器内跑三个独立进程:beat(只发定时任务)+ generation worker(实时高优)
|
||||
# + transcode worker(后台批量/清理)。三个进程的并发与开关独立配置。
|
||||
|
||||
# Worker 进程名称
|
||||
WORKER_NAME=xiaoxia-saas-worker
|
||||
|
||||
# Worker 并发数(同时执行的任务数)
|
||||
# 总并发参考(兼容旧变量):
|
||||
# - 若 GENERATION_CONCURRENCY 与 TRANSCODE_CONCURRENCY 都未显式设置,
|
||||
# entrypoint 会按此总数对半分配(gen=ceil(total/2), trans=剩余,各至少 1);
|
||||
# - 任一个 *_CONCURRENCY 显式设置后,按显式值生效,忽略此变量对应部分。
|
||||
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
|
||||
|
||||
|
||||
@@ -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,9 +1238,11 @@ jobs:
|
||||
ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}"
|
||||
run: |
|
||||
set -eux
|
||||
# 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 业务机 = 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}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
staging_port="${STAGING_SSH_PORT:-22}"
|
||||
echo "Host: $staging_host"
|
||||
@@ -1300,8 +1302,16 @@ 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} 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} COMPOSE_SYNC=0 sh"
|
||||
|
||||
# 清理 CI runner 上的渲染文件
|
||||
rm -f .env.rendered
|
||||
@@ -1552,7 +1562,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=""
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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")
|
||||
@@ -8,6 +8,7 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.clips_standalone import router as clips_standalone_router
|
||||
from app.api.routes.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.drafts_standalone import router as drafts_standalone_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_cover import router as generation_cover_router
|
||||
@@ -15,11 +16,13 @@ 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 points_router, usage_router
|
||||
from app.api.routes.points import router as points_router
|
||||
from app.api.routes.points import usage_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.scripts import router as scripts_router
|
||||
from app.api.routes.scripts_ai import router as scripts_ai_router
|
||||
@@ -41,6 +44,19 @@ api_router = APIRouter(prefix="/api/v1")
|
||||
health_router = APIRouter()
|
||||
health_router.include_router(health_check_router)
|
||||
|
||||
# ── /api/health 别名:部分前端/探针把 health 放在 /api 前缀下 ──────────────
|
||||
# 原来 /health 在根路径;额外加一个 /api/health 别名避免 404。
|
||||
api_health_router = APIRouter(prefix="/api")
|
||||
api_health_router.include_router(health_check_router)
|
||||
health_router.include_router(api_health_router)
|
||||
|
||||
# ── 旧前端路径别名(无需 template_id 路径参数)────────────────────────────
|
||||
# /api/v1/clips/from-assets 已有 clips_standalone;此处额外挂 /api/v1/editor/*,
|
||||
# 解决前端调 /api/v1/editor/clips/from-assets 和 /api/v1/editor/drafts 的 404。
|
||||
editor_legacy_router = APIRouter(prefix="/editor", tags=["Editor Legacy Alias"])
|
||||
editor_legacy_router.include_router(clips_standalone_router)
|
||||
editor_legacy_router.include_router(drafts_standalone_router)
|
||||
|
||||
api_router.include_router(
|
||||
auth_router,
|
||||
tags=["Auth"],
|
||||
@@ -169,6 +185,9 @@ api_router.include_router(
|
||||
prefix="/templates/{template_id}/editor",
|
||||
tags=["TemplateEditor"],
|
||||
)
|
||||
api_router.include_router(
|
||||
editor_legacy_router,
|
||||
)
|
||||
api_router.include_router(
|
||||
tts_router,
|
||||
prefix="/tts",
|
||||
@@ -187,6 +206,10 @@ 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",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""独立的草稿端点(不依赖 template_id 路径参数,兼容旧前端路径).
|
||||
|
||||
提供以下别名端点,与 /api/v1/templates/{template_id}/editor/draft 功能一致:
|
||||
- GET /api/v1/editor/drafts 获取草稿详情(template_id 从 query/body/默认模板兜底)
|
||||
- PUT /api/v1/editor/drafts 更新草稿(兼容前端 useDraftAutoSave 调用)
|
||||
|
||||
根因:前端 useDraftAutoSave 调用 /api/v1/editor/drafts(复数、无 template_id),
|
||||
与后端以 template_id 为路径参数的设计不一致,导致 404 并触发 10s timeout。
|
||||
本模块参照 clips_standalone.py 的模式,通过默认模板兜底复用 draft.py 的核心逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._default_template import get_or_create_default_template_id
|
||||
from .templates_editor.dependencies import resolve_draft_plan_id
|
||||
from .templates_editor.draft import get_editor_draft, update_editor_draft
|
||||
from .templates_editor.schemas import EditorDraftResponse, EditorUpdateRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Editor Legacy Alias"])
|
||||
|
||||
|
||||
def _resolve_editor_services(db: Session) -> tuple[EditTemplateService, EditPlanService]:
|
||||
return EditTemplateService(db), EditPlanService(db)
|
||||
|
||||
|
||||
def _resolve_template_id(
|
||||
template_id: str | None,
|
||||
db: Session,
|
||||
current_user: AuthenticatedUser,
|
||||
) -> str:
|
||||
"""解析 template_id:query/body 优先,否则兜底默认模板。"""
|
||||
tid = (template_id or "").strip()
|
||||
if tid:
|
||||
return tid
|
||||
user_id = str(current_user.user.id)
|
||||
tid = get_or_create_default_template_id(db, user_id)
|
||||
if not tid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
return tid
|
||||
|
||||
|
||||
@router.get("/drafts", response_model=EditorDraftResponse)
|
||||
def get_editor_drafts_alias(
|
||||
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditorDraftResponse:
|
||||
"""获取草稿详情(复数路径别名,兼容旧前端调用)。"""
|
||||
tid = _resolve_template_id(template_id, db, current_user)
|
||||
services = _resolve_editor_services(db)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id=tid,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
return get_editor_draft(
|
||||
template_id=tid,
|
||||
plan_id=plan_id,
|
||||
services=services,
|
||||
_=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/drafts", response_model=EditorDraftResponse)
|
||||
def update_editor_drafts_alias(
|
||||
req: EditorUpdateRequest,
|
||||
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditorDraftResponse:
|
||||
"""更新草稿(复数路径别名,兼容前端 useDraftAutoSave 调用)。"""
|
||||
tid = _resolve_template_id(template_id, db, current_user)
|
||||
services = _resolve_editor_services(db)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id=tid,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
return update_editor_draft(
|
||||
template_id=tid,
|
||||
req=req,
|
||||
plan_id=plan_id,
|
||||
services=services,
|
||||
_=current_user,
|
||||
)
|
||||
@@ -76,10 +76,7 @@ class GenerateCoverResponse(BaseModel):
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _select_best_frame_from_snapshots(
|
||||
snapshots: list[dict], plan_id: str
|
||||
) -> str:
|
||||
def _select_best_frame_from_snapshots(snapshots: list[dict], plan_id: str) -> str:
|
||||
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
|
||||
|
||||
降级策略:cv2 不可用或评分失败时,返回第一帧。
|
||||
@@ -232,7 +229,10 @@ def _persist_cover_frame(
|
||||
|
||||
|
||||
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
|
||||
|
||||
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
|
||||
"""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
@@ -241,6 +241,20 @@ def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
except Exception:
|
||||
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
|
||||
# awaiting_cover 兜底:从 extra_meta.rendered_output 取
|
||||
try:
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = task_repo.get(task_id)
|
||||
if task is not None:
|
||||
_status = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if _status == "awaiting_cover":
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
_url = _ro.get("file_url") or ""
|
||||
if _url:
|
||||
return _url
|
||||
except Exception:
|
||||
logger.warning("[封面生成] awaiting_cover 兜底读取失败: task_id=%s", task_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -650,11 +650,26 @@ def get_preview_generation_task(
|
||||
if not getattr(task, "is_preview", False):
|
||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||||
|
||||
# 查询生成的视频(取第一个)
|
||||
# 查询生成的视频(取第一个)。
|
||||
# #2024: 渲染完成后先进入 awaiting_cover(未入成品库),此时预览也应可见,
|
||||
# 从 extra_meta["rendered_output"] 读取视频 URL。
|
||||
generated_videos = []
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if status_val == "completed":
|
||||
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
generated_videos = list_use_case.execute(task_id)
|
||||
elif status_val == "awaiting_cover":
|
||||
# 用 extra_meta 中的渲染信息组装一个轻量视频对象给前端预览播放
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
if _ro.get("file_url"):
|
||||
|
||||
class _PreviewVideo:
|
||||
def __init__(self, ro):
|
||||
self.file_url = ro.get("file_url", "")
|
||||
self.duration = float(ro.get("duration") or 0.0)
|
||||
self.file_size = int(ro.get("file_size") or 0)
|
||||
|
||||
generated_videos = [_PreviewVideo(_ro)]
|
||||
|
||||
return _to_preview_response(task, generated_videos=generated_videos)
|
||||
|
||||
@@ -31,6 +31,8 @@ from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
FinalizeGenerationRequest,
|
||||
FinalizeGenerationResponse,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
@@ -44,6 +46,37 @@ 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__)
|
||||
@@ -132,10 +165,11 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
count: int = 0,
|
||||
rng=None,
|
||||
script_tags: list | None = None,
|
||||
tag_names_by_id: dict | None = None,
|
||||
db=None,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
@@ -156,6 +190,42 @@ 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
|
||||
@@ -165,6 +235,7 @@ 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,
|
||||
)
|
||||
@@ -175,7 +246,18 @@ def _select_assets_from_library(
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
|
||||
limit = count if count > 0 else None
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
|
||||
# #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,
|
||||
)
|
||||
return [r.asset.id for r in results]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
@@ -394,6 +476,7 @@ 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 模式(或叙事模式按标签匹配)时自动选取
|
||||
@@ -408,6 +491,7 @@ 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(
|
||||
@@ -891,8 +975,14 @@ def confirm_generation(
|
||||
if source_task.project_id:
|
||||
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
|
||||
if source_task.is_completed and getattr(source_task, "is_preview", False):
|
||||
# 3. 如果预览任务已完成渲染(completed 或 awaiting_cover),检查分辨率一致性后复用产物(秒出)。
|
||||
# #2024: 渲染完成先进入 awaiting_cover(等 Step5 finalize 入库),
|
||||
# confirm 时不再直接 finalize——仍创建 is_preview=False 的正式任务,复用预览渲染产物。
|
||||
_preview_done = getattr(source_task, "is_preview", False) and source_task.status.value in (
|
||||
"completed",
|
||||
"awaiting_cover",
|
||||
)
|
||||
if _preview_done:
|
||||
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
|
||||
req_w = request.output_width or 0
|
||||
req_h = request.output_height or 0
|
||||
@@ -907,13 +997,25 @@ def confirm_generation(
|
||||
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
|
||||
confirmed_title_config["text"] = request.custom_title.strip()
|
||||
|
||||
# #2024: mark_confirmed 会把 is_preview 翻转为 False、同步标题/分辨率/封面,
|
||||
# 但不再自动 mark_completed——任务停留在 awaiting_cover,等待用户 Step5 选封面后调 finalize。
|
||||
source_task.mark_confirmed(
|
||||
cover_url=request.cover_url,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
title_config=confirmed_title_config,
|
||||
)
|
||||
# 若预览任务此时是 completed(历史数据/旧 worker),回退到 awaiting_cover 统一流程
|
||||
if source_task.status.value == "completed":
|
||||
try:
|
||||
from packages.domain.generation_task import GenerationTaskStatus
|
||||
|
||||
source_task.status = GenerationTaskStatus.AWAITING_COVER
|
||||
source_task.completed_at = None
|
||||
except Exception:
|
||||
pass
|
||||
generation_task_repository.update(source_task)
|
||||
db.commit()
|
||||
|
||||
# 同步标题到 EditPlan.config
|
||||
# #1970:确认生成复用预览计划,dedup_enabled 沿用计划已有值,不在此覆盖
|
||||
@@ -926,7 +1028,7 @@ def confirm_generation(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
|
||||
"[确认生成] 复用预览产物(等待 finalize): task_id=%s, user_id=%s",
|
||||
task_id,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
@@ -995,6 +1097,67 @@ def confirm_generation(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/finalize", response_model=FinalizeGenerationResponse)
|
||||
def finalize_generation_task(
|
||||
task_id: str,
|
||||
request: FinalizeGenerationRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> FinalizeGenerationResponse:
|
||||
"""#2024: Step5 点「完成」时调用——将 awaiting_cover 状态的任务正式入库+绑定封面。
|
||||
|
||||
- 任务必须处于 awaiting_cover 状态(渲染+上传已完成、封面候选已就绪)。
|
||||
- cover_url 为空则使用任务自动截帧/智能封面;非空则绑定为最终封面。
|
||||
- 幂等:已 finalize 的任务直接返回已有视频记录。
|
||||
- 成功后任务推进到 completed,返回成品视频 ID + 可播放 URL。
|
||||
"""
|
||||
from app.services.generation_finalize_service import (
|
||||
GenerationFinalizeError,
|
||||
GenerationFinalizeService,
|
||||
)
|
||||
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
if task.project_id:
|
||||
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
service = GenerationFinalizeService(db)
|
||||
try:
|
||||
video = service.finalize_task(
|
||||
task_id=task_id,
|
||||
user_id=authenticated_user.user.id,
|
||||
cover_url=request.cover_url or None,
|
||||
custom_title=(request.custom_title or "").strip() or None,
|
||||
)
|
||||
except GenerationFinalizeError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
|
||||
try:
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
download_url = video.file_url
|
||||
return FinalizeGenerationResponse(
|
||||
video_id=video.id,
|
||||
project_id=getattr(video, "project_id", "") or "",
|
||||
name=getattr(video, "name", "") or "",
|
||||
file_size=int(getattr(video, "file_size", 0) or 0),
|
||||
duration=float(getattr(video, "duration", 0.0) or 0.0),
|
||||
thumbnail_url=video.thumbnail_url or "",
|
||||
cover_url=video.thumbnail_url or "",
|
||||
file_url=download_url,
|
||||
width=int(getattr(video, "width", 0) or 0),
|
||||
height=int(getattr(video, "height", 0) or 0),
|
||||
fps=float(getattr(video, "fps", 0.0) or 0.0),
|
||||
status="success",
|
||||
is_duplicate=bool(getattr(video, "is_duplicate", False)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
def list_generation_tasks(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -1042,6 +1205,44 @@ def list_generation_results(
|
||||
for item in items:
|
||||
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
|
||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
||||
|
||||
# #2024/#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,
|
||||
# 从 extra_meta["rendered_output"] 合成一条轻量视频响应,供前端预览与智能封面使用。
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if not responses and status_val == "awaiting_cover":
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
_file_url = _ro.get("file_url") or ""
|
||||
if _file_url:
|
||||
if _file_url.startswith("http"):
|
||||
_download = _file_url
|
||||
else:
|
||||
try:
|
||||
_download = storage_service.get_download_url(_file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
_download = _file_url
|
||||
_name = _ro.get("name") or ""
|
||||
if not _name:
|
||||
_name = f"generated-{task_id[:8]}"
|
||||
responses.append(
|
||||
GeneratedVideoResponse(
|
||||
id=f"preview-{task_id}",
|
||||
project_id=getattr(task, "project_id", "") or "",
|
||||
generation_task_id=task_id,
|
||||
name=_name,
|
||||
file_url=_file_url,
|
||||
file_size=int(_ro.get("file_size") or 0),
|
||||
duration=float(_ro.get("duration") or 0.0),
|
||||
thumbnail_url=_ro.get("thumbnail_url") or getattr(task, "cover_url", "") or "",
|
||||
width=int(_ro.get("width") or 0),
|
||||
height=int(_ro.get("height") or 0),
|
||||
fps=float(_ro.get("fps") or 0.0),
|
||||
mode=_ro.get("mode", ""),
|
||||
download_url=_download,
|
||||
created_at=getattr(task, "updated_at", None) or getattr(task, "created_at", None),
|
||||
)
|
||||
)
|
||||
|
||||
return ListGeneratedVideosResponse(items=responses)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""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")
|
||||
@@ -63,6 +63,8 @@ def _generation_step(task) -> str:
|
||||
return "等待 Worker 执行"
|
||||
if s == "running":
|
||||
return "正在生成成片"
|
||||
if s == "awaiting_cover":
|
||||
return "等待确认封面"
|
||||
if s == "completed":
|
||||
return "生成完成"
|
||||
if s == "failed":
|
||||
@@ -129,7 +131,7 @@ def _validate_status(status: str | None) -> str | None:
|
||||
"""校验状态值合法性。"""
|
||||
if status is None:
|
||||
return None
|
||||
valid = {"pending", "running", "completed", "failed", "cancelled"}
|
||||
valid = {"pending", "running", "awaiting_cover", "completed", "failed", "cancelled"}
|
||||
if status not in valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -151,7 +153,9 @@ def _clamp_page_size(page_size: int) -> int:
|
||||
|
||||
@router.get("/tasks", response_model=ListTasksResponse)
|
||||
def list_user_tasks(
|
||||
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
|
||||
status: str | None = Query(
|
||||
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
|
||||
),
|
||||
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
|
||||
page: int = Query(1, ge=1, description="页码,从1开始"),
|
||||
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
|
||||
@@ -248,7 +252,9 @@ def retry_task_by_id(
|
||||
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
|
||||
def list_project_tasks(
|
||||
project_id: str,
|
||||
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
|
||||
status: str | None = Query(
|
||||
None, description="按状态筛选:pending/running/awaiting_cover/completed/failed/cancelled"
|
||||
),
|
||||
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
|
||||
page: int = Query(1, ge=1, description="页码,从1开始"),
|
||||
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
|
||||
|
||||
@@ -682,17 +682,50 @@ def create_clips_from_assets_editor(
|
||||
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
|
||||
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
|
||||
asset_scene_points: dict[str, list[float]] = {}
|
||||
invalid_asset_ids: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
for asset_id in unique_asset_ids:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if asset and hasattr(asset, "duration"):
|
||||
asset_durations[asset_id] = float(asset.duration or 0.0)
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
if asset is None:
|
||||
logger.warning("from-assets 素材不存在或已删除,跳过: asset_id=%s", asset_id)
|
||||
invalid_asset_ids.append(asset_id)
|
||||
continue
|
||||
_dur = float(getattr(asset, "duration", 0.0) or 0.0)
|
||||
if _dur <= 0:
|
||||
# 素材时长缺失(刚上传/分析未完成)或为0,跳过该素材——避免按兜底时长分配无效片段。
|
||||
# 若所有素材都无效,在下面统一抛 400。
|
||||
logger.warning("from-assets 素材时长缺失或为0,跳过: asset_id=%s", asset_id)
|
||||
invalid_asset_ids.append(asset_id)
|
||||
continue
|
||||
valid_asset_ids.append(asset_id)
|
||||
asset_durations[asset_id] = _dur
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
try:
|
||||
smart_score, _ = score_asset(asset)
|
||||
asset_smart_scores[asset_id] = smart_score
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
except Exception:
|
||||
asset_smart_scores[asset_id] = 0.0
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
try:
|
||||
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
|
||||
if cached_points:
|
||||
asset_scene_points[asset_id] = cached_points
|
||||
except Exception:
|
||||
pass
|
||||
if invalid_asset_ids:
|
||||
logger.info(
|
||||
"from-assets %d 个素材无效(时长缺失/不存在,已跳过): %s",
|
||||
len(invalid_asset_ids),
|
||||
",".join(invalid_asset_ids[:5]),
|
||||
)
|
||||
# 所有素材都无效(刚上传未分析完)→ 400 让前端稍后重试,而不是用兜底时长产生错乱片段
|
||||
if not valid_asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材尚未完成分析,请稍后重试",
|
||||
)
|
||||
# 后续分配素材时只在 valid_asset_ids 里挑选
|
||||
unique_asset_ids = valid_asset_ids
|
||||
logger.info(
|
||||
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
|
||||
len(asset_scene_points),
|
||||
|
||||
@@ -13,6 +13,33 @@ class ConfirmGenerationRequest(BaseModel):
|
||||
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
|
||||
|
||||
|
||||
class FinalizeGenerationRequest(BaseModel):
|
||||
"""Step5 点「完成」请求体:用户选定封面后,正式将视频入成品库。"""
|
||||
|
||||
cover_url: str = Field(
|
||||
default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)"
|
||||
)
|
||||
custom_title: str = Field(default="", description="用户自定义成片标题,非空时覆盖 rendered_output.name")
|
||||
|
||||
|
||||
class FinalizeGenerationResponse(BaseModel):
|
||||
"""finalize 响应:返回新创建的成品库视频信息。"""
|
||||
|
||||
video_id: str = Field(description="新创建的成品视频 ID")
|
||||
project_id: str = Field(default="", description="成品所属项目 ID")
|
||||
name: str = Field(default="", description="成片名称")
|
||||
file_size: int = Field(default=0, description="文件大小(字节)")
|
||||
duration: float = Field(default=0.0, description="时长(秒)")
|
||||
thumbnail_url: str = Field(default="", description="最终绑定的缩略图/封面 URL")
|
||||
cover_url: str = Field(default="", description="最终绑定的封面 URL")
|
||||
file_url: str = Field(default="", description="成品视频下载 URL")
|
||||
width: int = Field(default=0)
|
||||
height: int = Field(default=0)
|
||||
fps: float = Field(default=0.0)
|
||||
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
|
||||
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
"""创建生成任务请求。
|
||||
|
||||
|
||||
@@ -999,26 +999,35 @@ 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/视觉/像素扰动)。"""
|
||||
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。
|
||||
|
||||
#2034:dedup_enabled=False 时跳过 visual_perturbation/pixel_perturbation,
|
||||
保留 rhythm_template 和 BGM 池分配(合理的多变体差异,不属于降重扰动)。
|
||||
"""
|
||||
upd: dict = {}
|
||||
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)
|
||||
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)
|
||||
rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None
|
||||
if rt is not None:
|
||||
upd["rhythm_template"] = rt
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""视频生成任务 finalize 服务(#2024)。
|
||||
|
||||
Worker 渲染+上传完成后不再自动入库,标记为 awaiting_cover;用户在 Step5 选好封面
|
||||
点「完成」时由 API 调用本服务:创建 GeneratedVideo 成品库记录(复用 worker 预计算
|
||||
的查重结果)、绑定封面、推进任务到 completed。
|
||||
|
||||
与 AI 数字人 ``ai_avatar_render_service.finalize_job`` 模式一致,
|
||||
只是走 GenerationTask 而非 AiAvatarRenderJob。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenerationFinalizeError(Exception):
|
||||
"""finalize 业务错误,code 供 API 层映射 HTTP 状态码。"""
|
||||
|
||||
def __init__(self, message: str, code: str = "FinalizeError", status_code: int = 400):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class GenerationFinalizeService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def finalize_task(
|
||||
self,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
cover_url: Optional[str] = None,
|
||||
custom_title: Optional[str] = None,
|
||||
):
|
||||
"""执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。
|
||||
|
||||
Returns:
|
||||
GeneratedVideo 领域对象
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(self.db)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
|
||||
|
||||
task = task_repo.get(task_id)
|
||||
if task is None:
|
||||
raise GenerationFinalizeError(f"任务 {task_id} 不存在", "TaskNotFound", 404)
|
||||
|
||||
# ── 幂等:已入库直接返回 ─────────────────────────────────
|
||||
existing = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == task_id).first()
|
||||
if existing is not None:
|
||||
logger.info("[finalize] 幂等命中 task=%s video=%s", task_id, existing.id)
|
||||
_changed = False
|
||||
if cover_url and cover_url.strip() and existing.thumbnail_url != cover_url.strip():
|
||||
existing.thumbnail_url = cover_url.strip()
|
||||
task.cover_url = cover_url.strip()
|
||||
_changed = True
|
||||
if custom_title and custom_title.strip() and (getattr(existing, "name", "") or "") != custom_title.strip():
|
||||
existing.name = custom_title.strip()
|
||||
_changed = True
|
||||
if _changed:
|
||||
self.db.commit()
|
||||
if task.status.value != "completed":
|
||||
try:
|
||||
task.mark_completed(result_count=1)
|
||||
if cover_url and cover_url.strip():
|
||||
task.cover_url = cover_url.strip()
|
||||
task_repo.update(task)
|
||||
self.db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("[finalize] 幂等补 mark_completed 失败: %s", e)
|
||||
self.db.rollback()
|
||||
return video_repo.get(existing.id)
|
||||
|
||||
# ── 状态校验 ─────────────────────────────────────────────
|
||||
if task.status.value != "awaiting_cover":
|
||||
raise GenerationFinalizeError(
|
||||
f"任务当前状态 {task.status.value},无法 finalize(需 awaiting_cover)",
|
||||
"InvalidTaskStatus",
|
||||
400,
|
||||
)
|
||||
|
||||
# ── 封面 ─────────────────────────────────────────────────
|
||||
effective_cover = (cover_url or "").strip() if cover_url else (task.cover_url or "").strip()
|
||||
|
||||
# ── 入库+查重(复用 worker 预计算结果) ──────────────────
|
||||
try:
|
||||
result = finalize_generated_video(
|
||||
task=task,
|
||||
session=self.db,
|
||||
effective_cover_url=effective_cover,
|
||||
custom_name=custom_title,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise GenerationFinalizeError(str(e), "RenderedOutputMissing", 400) from e
|
||||
|
||||
video_id = result["video_id"]
|
||||
|
||||
# 应用自定义标题
|
||||
if custom_title and custom_title.strip():
|
||||
try:
|
||||
_v = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
|
||||
if _v is not None:
|
||||
_v.name = custom_title.strip()
|
||||
self.db.flush()
|
||||
except Exception:
|
||||
logger.warning("[finalize] 更新标题失败: video_id=%s", video_id, exc_info=True)
|
||||
|
||||
# ── 推进任务 ─────────────────────────────────────────────
|
||||
task.mark_completed(result_count=1)
|
||||
task.cover_url = effective_cover
|
||||
# 清理 rendered_output(体积较大,入库后不再需要)
|
||||
meta = dict(task.extra_meta or {})
|
||||
meta.pop("rendered_output", None)
|
||||
task.extra_meta = meta
|
||||
task.updated_at = datetime.now(UTC)
|
||||
task_repo.update(task)
|
||||
self.db.commit()
|
||||
|
||||
video = video_repo.get(video_id)
|
||||
logger.info(
|
||||
"[finalize] task=%s finalized -> video=%s cover=%s dup=%s",
|
||||
task_id,
|
||||
video_id,
|
||||
bool(effective_cover),
|
||||
result.get("is_duplicate", False),
|
||||
)
|
||||
return video
|
||||
@@ -160,12 +160,14 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
)
|
||||
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
// ── 页面标题 ─────────────────────────────────────────────────
|
||||
// GenerateHeader: <h2><ThunderboltOutlined />智能剪辑</h2>
|
||||
// SVG icon 可能干扰 role=heading 的 accessible name,用文本包含兜底
|
||||
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
// h3 实际文案: "🎬 选择剪辑模式"(非 "选择模式"),用正则包含匹配
|
||||
await expect(page.getByText(/选择剪辑模式/)).toBeVisible()
|
||||
await expect(page.getByText("随机混剪")).toBeVisible()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
@@ -180,11 +182,8 @@ 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 })
|
||||
@@ -192,9 +191,10 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText("随机混剪")).toBeVisible()
|
||||
// (#2024: Step4 不再显示"📋 生成配置"卡片,内容区仅显示进度/错误)
|
||||
// 等待底部操作栏的「✨ 确认生成视频」按钮可见即可
|
||||
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 10000 })
|
||||
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
const createTask = page.waitForResponse(
|
||||
@@ -324,12 +324,11 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
)
|
||||
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
// ── 页面标题 ─────────────────────────────────────────────────
|
||||
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(/选择剪辑模式/)).toBeVisible()
|
||||
await page.getByText("叙事剪辑").click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
@@ -351,11 +350,8 @@ 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 })
|
||||
@@ -363,9 +359,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText("叙事剪辑")).toBeVisible()
|
||||
// (#2024: Step4 不再显示"📋 生成配置"卡片)
|
||||
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
|
||||
await expect(confirmBtn2).toBeVisible({ timeout: 10000 })
|
||||
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
const createTask2 = page.waitForResponse(
|
||||
|
||||
@@ -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: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
{ timeout: 90_000, intervals: [3_000, 5_000, 10_000] },
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/)
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
<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>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
baseURL: "/api/v1",
|
||||
timeout: 10000,
|
||||
timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 后端路由: /api/v1/cover-templates
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
||||
import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
export interface CoverTemplateListResponse {
|
||||
items: CoverTemplate[]
|
||||
@@ -12,14 +12,7 @@ export interface CoverTemplateListResponse {
|
||||
|
||||
export interface CoverTemplateCreateRequest {
|
||||
name: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
config?: CoverEditorConfig
|
||||
}
|
||||
|
||||
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
|
||||
|
||||
@@ -51,12 +51,18 @@ export interface GenerateCoverResponse {
|
||||
|
||||
/** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */
|
||||
export async function generateCover(
|
||||
templateId: string,
|
||||
templateId: string | undefined | null,
|
||||
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: { template_id: templateId },
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/** #2024 Step5 「完成」入库 —— 将 awaiting_cover 任务正式写入成品库 */
|
||||
export interface FinalizeGenerationRequest {
|
||||
/** 用户选定的封面图片 URL;为空则使用任务默认封面(自动截帧/智能封面) */
|
||||
cover_url?: string
|
||||
/** 用户自定义成片标题,非空时覆盖 rendered_output.name */
|
||||
custom_title?: string
|
||||
}
|
||||
|
||||
export interface FinalizeGenerationResponse {
|
||||
video_id: string
|
||||
project_id: string
|
||||
name: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string
|
||||
cover_url: string
|
||||
file_url: string
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
/** success=新建成功;already_finalized=幂等返回已有记录 */
|
||||
status: string
|
||||
is_duplicate: boolean
|
||||
}
|
||||
|
||||
export const finalizeGeneration = async (
|
||||
taskId: string,
|
||||
params: FinalizeGenerationRequest = {},
|
||||
): Promise<FinalizeGenerationResponse> => {
|
||||
const response = await apiClient.post<FinalizeGenerationResponse>(
|
||||
`/generation/tasks/${taskId}/finalize`,
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -6,17 +6,20 @@ import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`)
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`, { timeout: 30_000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消;超时 60s 防止大 config 写入失败) */
|
||||
export async function updateEditPlan(
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, {
|
||||
signal,
|
||||
timeout: 60_000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* 共享封面编辑器样式(智能剪辑 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%);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useSharedCover } from "./useSharedCover"
|
||||
export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover"
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 共享封面选择 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
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 标题迷你 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
|
||||
@@ -0,0 +1,458 @@
|
||||
/* ============================================================
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 标题样式参数 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,29 +1,30 @@
|
||||
/**
|
||||
* 标题模板编辑器(v3 重构)
|
||||
* 标题模板编辑器(公共组件)
|
||||
*
|
||||
* - Modal 弹窗 860px 宽
|
||||
* - 左侧:300px 竖屏预览区(图片背景+暗色渐变遮罩+透明 Canvas 叠字)+ 模板名称输入框
|
||||
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStylePanel 的 paramsOnly 模式
|
||||
* - 左侧:300px 竖屏预览区(图片背景+暗角+透明 Canvas 叠字)+ 模板名称输入
|
||||
* - 右侧:参数 Tab 面板(基础/描边/阴影/背景/排版),复用 TitleStyleParamsTab
|
||||
* - 底部:取消 / 保存模板 按钮
|
||||
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 handleSave 处理)
|
||||
* - 内置模板编辑时保存会创建副本(带"副本"逻辑由 onSave 的调用方处理)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Modal, Button, Input, message } from "antd"
|
||||
import TitleStylePanel from "../../pages/generate/components/title/TitleStylePanel"
|
||||
import TitleMiniPreview from "../../pages/generate/components/title/TitleMiniPreview"
|
||||
import { POSITION_OPTIONS } from "../../pages/generate/constants"
|
||||
import { FONT_OPTIONS } from "./constants"
|
||||
import type { TitleSettings } from "../../pages/generate/types"
|
||||
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
|
||||
import type { TitleStyleSettings } from "./settings"
|
||||
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -31,10 +32,9 @@ interface Props {
|
||||
const EDITOR_BG = "/title-templates/portrait1.jpg"
|
||||
|
||||
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
|
||||
const [settings, setSettings] = useState<TitleSettings>(() => ({
|
||||
...DEFAULT_TITLE_SETTINGS_FULL,
|
||||
const [settings, setSettings] = useState<TitleStyleSettings>(() => ({
|
||||
...DEFAULT_TITLE_STYLE_SETTINGS,
|
||||
...titleStyleConfigToCamel(template.style || {}),
|
||||
title: "预览标题文字",
|
||||
}))
|
||||
const [formName, setFormName] = useState(template.name || "")
|
||||
const [formEmoji, setFormEmoji] = useState(template.emoji || "✨")
|
||||
@@ -43,16 +43,15 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSettings({
|
||||
...DEFAULT_TITLE_SETTINGS_FULL,
|
||||
...DEFAULT_TITLE_STYLE_SETTINGS,
|
||||
...titleStyleConfigToCamel(template.style || {}),
|
||||
title: "预览标题文字",
|
||||
})
|
||||
setFormName(template.name || "")
|
||||
setFormEmoji(template.emoji || "✨")
|
||||
}
|
||||
}, [open, template])
|
||||
|
||||
const upd = (patch: Partial<TitleSettings>) => setSettings((s) => ({ ...s, ...patch }))
|
||||
const upd = (patch: Partial<TitleStyleSettings>) => setSettings((s) => ({ ...s, ...patch }))
|
||||
|
||||
const handleSave = () => {
|
||||
const name = formName.trim()
|
||||
@@ -69,11 +68,11 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器内的预览用 settings:字号适配竖屏
|
||||
const previewSettings = useMemo<TitleSettings>(() => {
|
||||
// 竖屏宽度 200px,按比例缩放字号,让预览看起来协调
|
||||
return { ...settings, size: Math.round(settings.size * 0.55) }
|
||||
}, [settings])
|
||||
// 编辑器预览 settings:竖屏宽度 200px,字号按比例缩放
|
||||
const previewSettings = useMemo<TitleStyleSettings>(
|
||||
() => ({ ...settings, size: Math.round(settings.size * 0.55) }),
|
||||
[settings],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -140,7 +139,7 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
|
||||
</div>
|
||||
{/* 右侧:参数 Tab */}
|
||||
<div className="ttv3-editor-right">
|
||||
<TitleStylePanel
|
||||
<TitleStyleParamsTab
|
||||
settings={settings}
|
||||
onUpdatePosition={(p) => upd({ position: p, posX: null, posY: null })}
|
||||
onUpdateFont={(f) => upd({ font: f })}
|
||||
@@ -155,15 +154,9 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave
|
||||
})
|
||||
}
|
||||
onToggleShadow={() => upd({ shadow: !settings.shadow })}
|
||||
onApplyPreset={() => {
|
||||
/* 编辑器内不使用系统预设快捷键 */
|
||||
}}
|
||||
onUpdateStyle={(patch) => upd(patch)}
|
||||
activePreset={null}
|
||||
titlePresets={[]}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
paramsOnly
|
||||
onUpdatePatch={upd}
|
||||
positionOptions={POSITION_OPTIONS}
|
||||
fontOptions={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 标题模板选择器 — 大卡片网格(共享组件)
|
||||
*
|
||||
* 渲染「我的模板」+「系统模板」两个分组的 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
|
||||
@@ -20,54 +20,84 @@ export const FONT_OPTIONS: FontOption[] = [
|
||||
{
|
||||
value: "优设标题黑",
|
||||
label: "优设标题黑",
|
||||
// 原版"优设标题黑"为商用字体非开源;优先本地已安装字体,兜底用 Noto Sans SC(Google Fonts 已加载 wght@900,保证 bold 字重可用),再用 ZCOOL 庆科黄油体作风格兜底
|
||||
family:
|
||||
'"YouShe Title Black","YouSheBiaoTiHei","Source Han Sans SC Heavy","Noto Sans SC","PingFang SC",sans-serif',
|
||||
'"YouSheBiaoTiHei","YouShe Title Black","Noto Sans SC","ZCOOL QingKe HuangYou","PingFang SC","Microsoft YaHei",sans-serif',
|
||||
tag: "hot",
|
||||
},
|
||||
{
|
||||
value: "阿里普惠体Bold",
|
||||
label: "阿里普惠体Bold",
|
||||
// 阿里普惠体需从阿里官网下载;兜底用 Noto Sans SC 900(同等字重,已在 Google Fonts wght@400;500;700;900 加载)
|
||||
family:
|
||||
'"Alibaba PuHuiTi Bold","Alibaba PuHuiTi","Source Han Sans SC","PingFang SC",sans-serif',
|
||||
'"Alibaba PuHuiTi","Alibaba PuHuiTi Bold","Alibaba Sans","Noto Sans SC",system-ui,"PingFang SC","Microsoft YaHei",sans-serif',
|
||||
tag: "hot",
|
||||
},
|
||||
{
|
||||
value: "抖音美好体",
|
||||
label: "抖音美好体",
|
||||
family: '"Douyin Sans","DouyinSans","Source Han Sans SC","PingFang SC",sans-serif',
|
||||
// 抖音美好体为版权字体;兜底用 Noto Sans SC(确保 bold 字重可用),再用 ZCOOL KuaiLe(站酷快乐体,圆润卡通风格近似)
|
||||
family:
|
||||
'"Douyin Sans","DouyinSans","Noto Sans SC","ZCOOL KuaiLe","PingFang SC","Microsoft YaHei",sans-serif',
|
||||
tag: "hot",
|
||||
},
|
||||
{
|
||||
value: "思源黑体Heavy",
|
||||
label: "思源黑体Heavy",
|
||||
family:
|
||||
'"Source Han Sans SC Heavy","Noto Sans SC","Source Han Sans CN Heavy","PingFang SC",sans-serif',
|
||||
'"Noto Sans SC","Source Han Sans SC","Source Han Sans CN Heavy","PingFang SC","Microsoft YaHei",sans-serif',
|
||||
tag: "new",
|
||||
},
|
||||
{
|
||||
value: "思源黑体",
|
||||
label: "思源黑体",
|
||||
family: '"Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
|
||||
family: '"Noto Sans SC","Source Han Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
|
||||
},
|
||||
{
|
||||
value: "思源宋体",
|
||||
label: "思源宋体",
|
||||
family: '"Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif',
|
||||
family: '"Noto Serif SC","Source Han Serif SC","Songti SC","SimSun",serif',
|
||||
},
|
||||
{
|
||||
value: "苹方",
|
||||
label: "苹方",
|
||||
family: '"PingFang SC",-apple-system,"Helvetica Neue",sans-serif',
|
||||
family:
|
||||
'"PingFang SC",-apple-system,blinkmacsystemfont,"Helvetica Neue","Noto Sans SC",sans-serif',
|
||||
},
|
||||
{
|
||||
value: "微软雅黑",
|
||||
label: "微软雅黑",
|
||||
family: '"Microsoft YaHei","PingFang SC",sans-serif',
|
||||
family: '"Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
|
||||
},
|
||||
{
|
||||
value: "楷体",
|
||||
label: "楷体",
|
||||
family: '"KaiTi","STKaiti","DFKai-SB",serif',
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 公共标题模板/样式组件统一导出
|
||||
*
|
||||
* 任何页面需要标题样式配置/模板选择/模板编辑,从这里 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"
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 标题位置选项(公共常量)
|
||||
*/
|
||||
export interface PositionOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const POSITION_OPTIONS: PositionOption[] = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 标题样式设置 — 公共 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: [],
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
/**
|
||||
* 标题样式工具(#2001 / 模板系统 #2003)
|
||||
*
|
||||
* - snake_case TitleStyleConfig ↔ camelCase TitleSettings 互转
|
||||
* - snake_case TitleStyleConfig <-> camelCase TitleStyleSettings 互转
|
||||
* - preset 归一化预览(修复"标题"两字大小不一)
|
||||
* - template -> preview settings 转换
|
||||
*/
|
||||
import type { TitleStyleConfig } from "./types"
|
||||
import type { TitleSettings } from "../../pages/generate/types"
|
||||
import type { TitleStyleSettings } from "./settings"
|
||||
import { DEFAULT_TITLE_STYLE_SETTINGS } from "./settings"
|
||||
import { TITLE_PRESETS } from "./constants"
|
||||
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
|
||||
import type { TitleTemplate } from "./template-types"
|
||||
|
||||
/** snake_case TitleStyleConfig → camelCase TitleSettings(仅覆盖已知字段) */
|
||||
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleSettings> {
|
||||
const out: Partial<TitleSettings> = {}
|
||||
/** snake_case TitleStyleConfig -> camelCase TitleStyleSettings(仅覆盖已知字段) */
|
||||
export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<TitleStyleSettings> {
|
||||
const out: Partial<TitleStyleSettings> = {}
|
||||
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 as TitleSettings["position"]
|
||||
if (s.position != null) out.position = s.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
|
||||
@@ -40,8 +41,8 @@ export function titleStyleConfigToCamel(s: Partial<TitleStyleConfig>): Partial<T
|
||||
return out
|
||||
}
|
||||
|
||||
/** camelCase TitleSettings patch → snake_case TitleStyleConfig patch */
|
||||
export function camelToTitleStyleConfig(p: Partial<TitleSettings>): Partial<TitleStyleConfig> {
|
||||
/** camelCase TitleStyleSettings patch -> snake_case TitleStyleConfig patch */
|
||||
export function camelToTitleStyleConfig(p: Partial<TitleStyleSettings>): Partial<TitleStyleConfig> {
|
||||
const out: Partial<TitleStyleConfig> = {}
|
||||
if (p.font != null) out.font = p.font
|
||||
if (p.size != null) out.size = p.size
|
||||
@@ -71,15 +72,15 @@ export function camelToTitleStyleConfig(p: Partial<TitleSettings>): Partial<Titl
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 preset style(snake_case)归一化为固定字号的 TitleSettings,
|
||||
* 把 preset style(snake_case)归一化为固定字号的 TitleStyleSettings,
|
||||
* 用于"预设卡片"缩略预览——所有卡片视觉上"标题"两字大小一致,便于辨识。
|
||||
* 描边/阴影/背景padding 按 fixedSize / 原始 size 比例缩放,避免粗描边爆框。
|
||||
*/
|
||||
export function buildPresetPreviewSettings(
|
||||
base: TitleSettings,
|
||||
base: TitleStyleSettings,
|
||||
presetKey: string,
|
||||
fixedSize = 56,
|
||||
): TitleSettings {
|
||||
): TitleStyleSettings {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return base
|
||||
const origSize = preset.style.size ?? fixedSize
|
||||
@@ -87,25 +88,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) ?? 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,
|
||||
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),
|
||||
lineOverrides: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 TitleTemplate 渲染为完整 TitleSettings(带默认值),用于卡片预览。
|
||||
* 与模板选择器中保持一致,抽出共用。
|
||||
* 把 TitleTemplate 渲染为完整 TitleStyleSettings(带默认值),用于卡片预览。
|
||||
*/
|
||||
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleSettings {
|
||||
const base: TitleSettings = {
|
||||
...DEFAULT_TITLE_SETTINGS_FULL,
|
||||
export function templateToPreviewSettings(t: TitleTemplate, fixedSize = 48): TitleStyleSettings {
|
||||
const base: TitleStyleSettings = {
|
||||
...DEFAULT_TITLE_STYLE_SETTINGS,
|
||||
...titleStyleConfigToCamel(t.style),
|
||||
}
|
||||
// 预览时用固定字号保证所有卡片字大小一致;描边/阴影/padding按比例缩放
|
||||
|
||||
@@ -61,6 +61,10 @@ const AiAvatarPage: React.FC = () => {
|
||||
"generating",
|
||||
)
|
||||
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
|
||||
/* ── 对口型耗时计时(秒) ── */
|
||||
const [lipsyncElapsed, setLipsyncElapsed] = useState(0)
|
||||
const lipsyncStartAtRef = useRef<number>(0)
|
||||
const lipsyncTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
/* ── 渲染进度弹窗 ── */
|
||||
const [showRenderModal, setShowRenderModal] = useState(false)
|
||||
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
|
||||
@@ -222,6 +226,13 @@ const AiAvatarPage: React.FC = () => {
|
||||
setShowLipsyncModal(true)
|
||||
setLipsyncStatus("generating")
|
||||
setLipsyncErrorMessage("")
|
||||
// 启动计时器
|
||||
lipsyncStartAtRef.current = Date.now()
|
||||
setLipsyncElapsed(0)
|
||||
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = setInterval(() => {
|
||||
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
|
||||
}, 1000)
|
||||
|
||||
const asset = await getAssetById(video.id)
|
||||
const videoUrl = asset?.file_url
|
||||
@@ -265,6 +276,11 @@ const AiAvatarPage: React.FC = () => {
|
||||
state.setLipsyncJob(updated)
|
||||
if (updated.status === "completed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setLipsyncElapsed(Math.floor((Date.now() - lipsyncStartAtRef.current) / 1000))
|
||||
setLipsyncStatus("completed")
|
||||
setTimeout(() => {
|
||||
setShowLipsyncModal(false)
|
||||
@@ -272,6 +288,10 @@ const AiAvatarPage: React.FC = () => {
|
||||
}, 1000)
|
||||
} else if (updated.status === "failed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setLipsyncStatus("failed")
|
||||
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
|
||||
}
|
||||
@@ -285,6 +305,10 @@ const AiAvatarPage: React.FC = () => {
|
||||
data: (err as { response?: { data?: unknown } })?.response?.data,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setShowLipsyncModal(false)
|
||||
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
|
||||
}
|
||||
@@ -305,15 +329,21 @@ const AiAvatarPage: React.FC = () => {
|
||||
clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = null
|
||||
}
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setShowLipsyncModal(false)
|
||||
setLipsyncStatus("generating")
|
||||
setLipsyncErrorMessage("")
|
||||
setLipsyncElapsed(0)
|
||||
}, [])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (lipsyncTickRef.current) clearInterval(lipsyncTickRef.current)
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
@@ -696,17 +726,11 @@ 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}
|
||||
/>
|
||||
) : (
|
||||
@@ -963,6 +987,19 @@ const AiAvatarPage: React.FC = () => {
|
||||
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
|
||||
对口型视频生成中…
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
color: "#7c3aed",
|
||||
}}
|
||||
>
|
||||
{`${Math.floor(lipsyncElapsed / 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}:${(lipsyncElapsed % 60).toString().padStart(2, "0")}`}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
|
||||
请勿关闭页面,完成后将自动提示
|
||||
</div>
|
||||
@@ -974,6 +1011,20 @@ const AiAvatarPage: React.FC = () => {
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
|
||||
对口型视频生成完成
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
color: "#10b981",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
总耗时{" "}
|
||||
{Math.floor(lipsyncElapsed / 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}
|
||||
:{(lipsyncElapsed % 60).toString().padStart(2, "0")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{lipsyncStatus === "failed" && (
|
||||
|
||||
@@ -99,15 +99,18 @@ export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
}
|
||||
|
||||
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
|
||||
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ──
|
||||
* #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧)
|
||||
*/
|
||||
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`,
|
||||
{},
|
||||
// 抽帧+评分+转存 OSS 链路较长,120s 超时
|
||||
{ timeout: 120000 },
|
||||
templateId && templateId !== "default" ? { template_id: templateId } : {},
|
||||
// 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s
|
||||
{ timeout: templateId && templateId !== "default" ? 180000 : 120000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
/**
|
||||
* AI数字人 — 封面选择弹窗
|
||||
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供
|
||||
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
|
||||
* 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数字人共享同一套模板库)。
|
||||
*/
|
||||
import React from "react"
|
||||
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 type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
|
||||
|
||||
interface ModalCoverSelectProps {
|
||||
open: boolean
|
||||
@@ -13,7 +27,13 @@ interface ModalCoverSelectProps {
|
||||
renderJob: RenderJob | null
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
/**
|
||||
* 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入
|
||||
* 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。
|
||||
*/
|
||||
onGenerateRenderSmartCover?: (
|
||||
renderId: string,
|
||||
) => Promise<{ cover_url: string; message?: string }>
|
||||
onUploadCover?: (file: File) => void
|
||||
onCoverSelected: (coverUrl: string) => void
|
||||
}
|
||||
@@ -28,31 +48,302 @@ 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 (
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
/**
|
||||
* AI数字人 — 面板5 / 封面选择弹窗内容:
|
||||
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
|
||||
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
|
||||
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
|
||||
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
|
||||
*
|
||||
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
|
||||
* AI数字人 — 面板5 / 生成配置面板(渲染前)
|
||||
* #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮)
|
||||
* 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件)
|
||||
*/
|
||||
import React, { useRef, useState } from "react"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
|
||||
type PanelVariant = "setup" | "select-cover"
|
||||
import React from "react"
|
||||
import type { RenderJob } from "../types"
|
||||
|
||||
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
|
||||
@@ -38,7 +21,6 @@ interface PanelCoverAndGenerateProps {
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
|
||||
coverStatus: "not_ready" | "pending" | "selected"
|
||||
}
|
||||
}
|
||||
@@ -58,89 +40,15 @@ 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,
|
||||
onGenerateRenderSmartCover,
|
||||
onUploadCover,
|
||||
onClose,
|
||||
onCoverSelected,
|
||||
renderJob: _renderJob,
|
||||
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 = (() => {
|
||||
@@ -154,69 +62,6 @@ 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
|
||||
enableTemplates={true}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onApplyTemplate={handleApplyTemplate}
|
||||
activePreset={activePreset}
|
||||
|
||||
@@ -14,14 +14,13 @@ 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 { confirmGeneration } from "@/api/generation/confirm"
|
||||
import { finalizeGeneration } from "@/api/generation/finalize"
|
||||
|
||||
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
@@ -107,6 +106,7 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewCovers,
|
||||
selectedVariantIds,
|
||||
setSelectedVariantIds,
|
||||
setSelectedTemplate,
|
||||
} = formState
|
||||
|
||||
const isBatch = previewCount > 1
|
||||
@@ -137,8 +137,8 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
|
||||
|
||||
/* ── 数量选择弹窗 ── */
|
||||
const [countModalOpen, setCountModalOpen] = useState(false)
|
||||
/* ── 标题面板模式:true = 内联大卡片模板网格(默认),false = 旧预设+参数 Tab ── */
|
||||
const enableTemplates = true
|
||||
|
||||
/* ── Step5 保存中状态 ── */
|
||||
const [finishing, setFinishing] = useState(false)
|
||||
@@ -199,6 +199,7 @@ const GeneratePage: React.FC = () => {
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
currentTaskId,
|
||||
batchTasks,
|
||||
generate: handleGenerate,
|
||||
retry: handleRetryGenerate,
|
||||
@@ -244,38 +245,37 @@ const GeneratePage: React.FC = () => {
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 数量弹窗确认 ── */
|
||||
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,
|
||||
],
|
||||
)
|
||||
/* ── 对齐批量数组长度到 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,
|
||||
])
|
||||
|
||||
/* ── #1970:Step1 弹窗回调 ── */
|
||||
const handleVoiceModalConfirm = useCallback(
|
||||
@@ -398,7 +398,7 @@ const GeneratePage: React.FC = () => {
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
generated,
|
||||
onOpenCountModal: () => setCountModalOpen(true),
|
||||
onBeforeEnterStep3: ensureArraysAligned,
|
||||
onOpenStep1Modal: () => {
|
||||
if (editMode === "random") {
|
||||
setVoiceModalOpen(true)
|
||||
@@ -411,7 +411,7 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 最终成片(单视频) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ── Step5 完成:调用 confirm 入库 + 跳转 ── */
|
||||
/* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */
|
||||
const handleFinish = useCallback(async () => {
|
||||
if (finishing) return
|
||||
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
|
||||
@@ -421,7 +421,10 @@ const GeneratePage: React.FC = () => {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!finalVideo) {
|
||||
// 单视频:finalVideo 可能因 /results 接口在 awaiting_cover 阶段暂未返回
|
||||
// GeneratedVideo 记录而为 undefined;此时 currentTaskId 已在创建任务时保存,
|
||||
// 下面 singleTaskId 兜底逻辑会用 currentTaskId 调 finalize,不应拦截
|
||||
if (!finalVideo && !currentTaskId) {
|
||||
message.warning("请等待视频生成完成")
|
||||
return
|
||||
}
|
||||
@@ -429,31 +432,38 @@ const GeneratePage: React.FC = () => {
|
||||
setFinishing(true)
|
||||
const hide = message.loading("正在保存到视频库...", 0)
|
||||
try {
|
||||
const taskIds =
|
||||
batchTasks && batchTasks.length > 0
|
||||
? batchTasks.map((t) => t.taskId).filter(Boolean)
|
||||
: finalVideo?.generation_task_id
|
||||
? [finalVideo.generation_task_id]
|
||||
: []
|
||||
// 收集需要 finalize 的任务 ID:批量用 batchTasks;单视频优先用 finalVideo.generation_task_id,兜底 currentTaskId
|
||||
const singleTaskId = finalVideo?.generation_task_id || currentTaskId || ""
|
||||
|
||||
// 单视频/批量:为每个任务调用 confirm(传入封面)
|
||||
if (isBatch && previewCovers.length > 0) {
|
||||
// 单视频/批量:为每个任务调用 finalize(入库 + 绑定封面 + 自定义标题)
|
||||
// 批量时必须按 batchTasks[i].variantIndex 对齐 previewCovers/previewTitles(taskIds 顺序不一定按变体序号)
|
||||
if (isBatch && batchTasks.length > 0) {
|
||||
await Promise.all(
|
||||
taskIds.map(async (taskId, idx) => {
|
||||
const coverUrl = previewCovers[idx] || ""
|
||||
return confirmGeneration(taskId, {
|
||||
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, {
|
||||
cover_url: coverUrl || undefined,
|
||||
custom_title: previewTitles[idx] || titleSettings.title || "",
|
||||
custom_title: title || undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
} else if (finalVideo?.generation_task_id) {
|
||||
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
|
||||
await confirmGeneration(finalVideo.generation_task_id, {
|
||||
} 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, {
|
||||
cover_url: coverUrl || undefined,
|
||||
custom_title: titleSettings.title || "",
|
||||
custom_title: titleSettings.title || undefined,
|
||||
})
|
||||
} else {
|
||||
console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转")
|
||||
}
|
||||
|
||||
hide()
|
||||
message.success("已保存到视频库")
|
||||
navigate("/app/products")
|
||||
@@ -480,6 +490,7 @@ const GeneratePage: React.FC = () => {
|
||||
previewTitles,
|
||||
titleSettings.title,
|
||||
coverSettings,
|
||||
currentTaskId,
|
||||
navigate,
|
||||
])
|
||||
|
||||
@@ -538,7 +549,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)
|
||||
@@ -562,6 +573,8 @@ const GeneratePage: React.FC = () => {
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
|
||||
currentTaskId={currentTaskId}
|
||||
onRetry={handleRetryGenerate}
|
||||
onRetryBatchTask={handleRetryBatchTask}
|
||||
onDismissError={handleDismissError}
|
||||
@@ -576,6 +589,9 @@ const GeneratePage: React.FC = () => {
|
||||
previewCovers={previewCovers}
|
||||
onPreviewCoversChange={setPreviewCovers}
|
||||
selectedVariantIds={selectedVariantIds}
|
||||
selectedCoverTemplate={selectedTemplate}
|
||||
onSelectedCoverTemplateChange={setSelectedTemplate}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
/>
|
||||
|
||||
{/* ════ 步骤4(单视频):成片播放器 ════ */}
|
||||
@@ -663,14 +679,6 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数量选择弹窗 */}
|
||||
<PreviewCountModal
|
||||
open={countModalOpen}
|
||||
defaultCount={1}
|
||||
onConfirm={handleCountConfirm}
|
||||
onCancel={() => setCountModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 音色克隆弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
|
||||
@@ -37,7 +37,9 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
|
||||
完成{" "}
|
||||
{tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "}
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
|
||||
@@ -49,7 +51,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
|
||||
<div className="xx-batch-gen-card-head">
|
||||
<span className="xx-batch-gen-card-title" title={title}>
|
||||
{task.status === "completed" ? (
|
||||
{task.status === "completed" || task.status === "awaiting_cover" ? (
|
||||
<CheckCircleFilled
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#52c41a" }}
|
||||
@@ -83,7 +85,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
|
||||
</>
|
||||
)}
|
||||
{task.status === "completed" && video && (
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
|
||||
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
|
||||
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
|
||||
<div
|
||||
@@ -111,7 +113,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && !video && (
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
|
||||
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
|
||||
@@ -89,6 +89,12 @@ 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) => {
|
||||
@@ -142,6 +148,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
previewCovers,
|
||||
onPreviewCoversChange,
|
||||
selectedVariantIds,
|
||||
selectedCoverTemplate,
|
||||
onSelectedCoverTemplateChange,
|
||||
onConfirmGenerate,
|
||||
} = props
|
||||
|
||||
switch (currentStep) {
|
||||
@@ -195,6 +204,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
previewCount={previewCount}
|
||||
previewTitles={previewTitles}
|
||||
onPreviewTitlesChange={onPreviewTitlesChange}
|
||||
onConfirmGenerate={onConfirmGenerate}
|
||||
generating={props.generating}
|
||||
selectedCount={
|
||||
props.previewCount && props.previewCount > 1 ? props.selectedVariantIds?.length || 1 : 1
|
||||
}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
@@ -250,6 +264,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
previewCovers={previewCovers}
|
||||
onPreviewCoversChange={onPreviewCoversChange}
|
||||
selectedVariantIndexes={selectedVariantIds}
|
||||
selectedTemplate={selectedCoverTemplate}
|
||||
onTemplateChange={onSelectedCoverTemplateChange}
|
||||
currentTaskId={props.currentTaskId}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* 生成数量选择弹窗(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,6 +47,12 @@ 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) => {
|
||||
@@ -69,6 +75,9 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
enableTemplates,
|
||||
selectedTemplateId,
|
||||
onApplyTemplate,
|
||||
onConfirmGenerate,
|
||||
generating,
|
||||
selectedCount = 1,
|
||||
} = props
|
||||
|
||||
const isBatch = previewCount > 1
|
||||
@@ -90,7 +99,47 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<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>
|
||||
)}
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{!isBatch ? (
|
||||
|
||||
@@ -1,84 +1,249 @@
|
||||
/**
|
||||
* Step 5 选择封面(Issue #1677 批量生成改造)
|
||||
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
|
||||
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
|
||||
* Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择)
|
||||
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传)
|
||||
* - N 个视频:N 张封面卡片,每张带对应视频标题,支持统一选择封面模板、逐个自动生成或上传
|
||||
*
|
||||
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import { Modal, Spin } from "antd"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Modal, Spin, message } 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, getAssetLibraries } 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 completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
|
||||
/** 最终成片:取第一个已完成视频(单视频场景) */
|
||||
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",
|
||||
})
|
||||
// 查找图片素材库(复用批量封面的逻辑)
|
||||
const libs = await getAssetLibraries()
|
||||
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
|
||||
if (!imageLib) {
|
||||
hide()
|
||||
message.error("未找到素材库,请先创建图片素材库")
|
||||
return previewUrl
|
||||
}
|
||||
const result = await uploadAssetDirect({ file, library_id: imageLib.id })
|
||||
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 batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
|
||||
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
|
||||
|
||||
/**
|
||||
* 批量生成:selectedTemplateId 来自用户在 CoverSettingsModal 中选择的模板,
|
||||
* 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。
|
||||
*/
|
||||
const batchCovers = useBatchCovers({
|
||||
selectedTemplate: props.selectedTemplate || "",
|
||||
selectedTemplate: shared.selectedTemplateId,
|
||||
generatedVideos: props.generatedVideos,
|
||||
titles: batchTitles,
|
||||
titleStyle: {
|
||||
@@ -92,7 +257,6 @@ 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 || [])]
|
||||
@@ -103,22 +267,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url
|
||||
|
||||
/* ── 批量封面 ── */
|
||||
if (isBatch) {
|
||||
@@ -138,17 +287,32 @@ 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 }}>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
|
||||
<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">
|
||||
@@ -209,7 +373,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={() => handleUploadClick(variantIndex)}
|
||||
onClick={() => handleBatchUploadClick(cardPos)}
|
||||
disabled={isLoading || isUploading}
|
||||
>
|
||||
📤 上传
|
||||
@@ -220,24 +384,44 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 隐藏的文件选择 input,批量上传复用 */}
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
ref={batchUploadRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 单视频:原有流程保持不变 ── */
|
||||
/* ── 单视频 ── */
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 最终成片信息 */}
|
||||
{finalVideo && (
|
||||
{(finalVideo || effectiveTaskId) && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
@@ -249,17 +433,51 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
|
||||
🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧
|
||||
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
|
||||
<>
|
||||
{" "}
|
||||
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-cover-actions">
|
||||
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void shared.generateAutoCover()}
|
||||
disabled={!canGenerateCover || shared.generating}
|
||||
loading={shared.generating}
|
||||
title={!canGenerateCover ? "请先完成视频生成" : ""}
|
||||
>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
⚙️ 封面设置
|
||||
<Button buttonType="ghost" onClick={() => shared.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>
|
||||
@@ -276,30 +494,26 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<CoverSettingsModal
|
||||
open={showCoverSettings}
|
||||
onClose={() => setShowCoverSettings(false)}
|
||||
templates={coverTemplates}
|
||||
loading={templatesLoading}
|
||||
error={templatesError}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onEditTemplate={handleEditTemplate}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
onCreateNew={() => {
|
||||
setShowCoverSettings(false)
|
||||
setShowCoverEditor(true)
|
||||
}}
|
||||
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={showCoverEditor}
|
||||
onClose={() => setShowCoverEditor(false)}
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
open={shared.showCoverEditor}
|
||||
onClose={() => shared.setShowCoverEditor(false)}
|
||||
template={shared.editingTemplate}
|
||||
onSave={shared.handleSaveTemplate}
|
||||
/>
|
||||
|
||||
{/* AI 生成封面进度弹窗 */}
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<Modal open={shared.generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
|
||||
@@ -311,4 +525,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
Step6CoverSettings.displayName = "Step6CoverSettings"
|
||||
|
||||
export default Step6CoverSettings
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import React from "react"
|
||||
import React, { useMemo, useState } 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
|
||||
@@ -16,15 +17,58 @@ interface CoverSettingsModalProps {
|
||||
onCreateNew: () => void
|
||||
}
|
||||
|
||||
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)",
|
||||
/** 模板缩略图:优先渲染 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 CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
@@ -40,12 +84,26 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
onCreateNew,
|
||||
}) => {
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
title="封面设置"
|
||||
centered
|
||||
footer={
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={onClose}>
|
||||
确认应用
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="xx-cover-modal-toolbar">
|
||||
<Button buttonType="primary">选择素材文件</Button>
|
||||
<Button buttonType="ghost">导出全部</Button>
|
||||
<Button buttonType="primary" onClick={onCreateNew}>
|
||||
创建新模板
|
||||
+ 创建新模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -59,52 +117,77 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
{!loading && !error && templates.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "40px 0",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && templates.length > 0 && (
|
||||
<div className="xx-cover-template-grid">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onSelectTemplate(tpl.id)}
|
||||
>
|
||||
{templates.map((tpl) => {
|
||||
const isSelected = selectedTemplateId === tpl.id
|
||||
return (
|
||||
<div
|
||||
className="xx-cover-template-thumb"
|
||||
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
|
||||
key={tpl.id}
|
||||
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelectTemplate(tpl.id)}
|
||||
>
|
||||
🖼️
|
||||
</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 && (
|
||||
<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()}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
if (confirm("确定删除此模板?")) {
|
||||
onDeleteTemplate(tpl.id)
|
||||
}
|
||||
}}
|
||||
onClick={() => onEditTemplate(tpl)}
|
||||
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
|
||||
>
|
||||
删除
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="ghost" buttonSize="sm">
|
||||
导出
|
||||
</Button>
|
||||
{!tpl.is_system && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
if (confirm("确定删除此模板?")) {
|
||||
onDeleteTemplate(tpl.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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,230 +1 @@
|
||||
/**
|
||||
* 标题迷你 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
|
||||
export { default } from "@/components/title/TitleMiniPreview"
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "@/components/title/constants"
|
||||
import { buildPresetPreviewSettings } from "@/components/title/utils"
|
||||
|
||||
import TitleMiniPreview from "./TitleMiniPreview"
|
||||
import TitleMiniPreview from "@/components/title/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: TitleSettings
|
||||
settings: import("@/components/title/settings").TitleStyleSettings
|
||||
sampleText: string
|
||||
portrait?: boolean
|
||||
}> = ({ settings, sampleText, portrait }) => {
|
||||
|
||||
@@ -46,13 +46,9 @@ export const CLIP_COUNT_STEP = 1
|
||||
export const MAX_PREVIEW_COUNT = 10
|
||||
export const MIN_PREVIEW_COUNT = 1
|
||||
|
||||
/* ── 标题位置选项 ── */
|
||||
export const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
/* ── 标题位置选项(统一从公共层重导出) ── */
|
||||
export { POSITION_OPTIONS } from "@/components/title/position-options"
|
||||
export type { PositionOption } from "@/components/title/position-options"
|
||||
|
||||
/* ── 标题字体:统一使用公共层定义(#2001) ── */
|
||||
export { getFontFamily } from "@/components/title/constants"
|
||||
|
||||
@@ -2739,6 +2739,7 @@
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #ccc;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 卡片信息区 */
|
||||
@@ -3335,3 +3336,465 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BatchTaskState {
|
||||
taskId: string
|
||||
/** 变体序号(0-based,与标题/封面数组对齐) */
|
||||
variantIndex: number
|
||||
status: "running" | "completed" | "failed"
|
||||
status: "running" | "completed" | "awaiting_cover" | "failed"
|
||||
progress: number
|
||||
error: string | null
|
||||
/** 完成后的成片视频 */
|
||||
@@ -96,7 +96,7 @@ export function useGenerationPolling({
|
||||
runId: number,
|
||||
callbacks?: {
|
||||
onTaskProgress?: (pct: number) => void
|
||||
onTaskCompleted?: (videos: unknown[]) => void
|
||||
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
|
||||
onTaskFailed?: (msg: string) => void
|
||||
},
|
||||
): Promise<unknown[]> => {
|
||||
@@ -111,7 +111,7 @@ export function useGenerationPolling({
|
||||
if (cancelledRef.current || done) return
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed") {
|
||||
if (task.status === "completed" || task.status === "awaiting_cover") {
|
||||
done = true
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
@@ -121,7 +121,7 @@ export function useGenerationPolling({
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
callbacks?.onTaskCompleted?.(videos)
|
||||
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
|
||||
resolve(videos)
|
||||
return
|
||||
}
|
||||
@@ -259,10 +259,11 @@ export function useGenerationPolling({
|
||||
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
|
||||
reportAggregateProgress()
|
||||
},
|
||||
onTaskCompleted: (videos) => {
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
progressMap.set(taskId, 100)
|
||||
resultMap.set(taskId, videos)
|
||||
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
|
||||
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
reportAggregateProgress()
|
||||
checkAllSettled()
|
||||
},
|
||||
@@ -293,8 +294,9 @@ export function useGenerationPolling({
|
||||
}
|
||||
pollSingleTask(taskId, Date.now(), {
|
||||
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
|
||||
onTaskCompleted: (videos) => {
|
||||
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
message.success(`视频 ${variantIndex + 1} 重试成功`)
|
||||
},
|
||||
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
|
||||
|
||||
@@ -53,7 +53,7 @@ interface UseBatchCoversOptions {
|
||||
}
|
||||
|
||||
export function useBatchCovers({
|
||||
selectedTemplate: _selectedTemplate,
|
||||
selectedTemplate,
|
||||
generatedVideos,
|
||||
titles,
|
||||
titleStyle,
|
||||
@@ -93,7 +93,12 @@ export function useBatchCovers({
|
||||
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
|
||||
const generateOne = useCallback(
|
||||
async (index: number): Promise<boolean> => {
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const finalVideos = generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" ||
|
||||
v.status === "awaiting_cover" ||
|
||||
v.status === "awaiting_cover",
|
||||
)
|
||||
const target = finalVideos[index] || generatedVideos[index]
|
||||
if (!target) {
|
||||
message.warning("该视频尚未生成完成")
|
||||
@@ -102,54 +107,57 @@ export function useBatchCovers({
|
||||
addBusy(index)
|
||||
try {
|
||||
const titleText = titles[index] || ""
|
||||
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 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 url = response.cover?.image_url || response.cover?.thumbnail_url || ""
|
||||
if (url) {
|
||||
patchCover(index, url)
|
||||
@@ -166,7 +174,7 @@ export function useBatchCovers({
|
||||
removeBusy(index)
|
||||
}
|
||||
},
|
||||
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
|
||||
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
|
||||
)
|
||||
|
||||
/** 为第 index 个视频上传自定义封面 */
|
||||
@@ -203,7 +211,10 @@ export function useBatchCovers({
|
||||
|
||||
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
|
||||
const generateAll = useCallback(async () => {
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const finalVideos = generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
)
|
||||
const total = finalVideos.length
|
||||
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
|
||||
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
|
||||
|
||||
@@ -22,6 +22,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const [generated, setGenerated] = useState(false)
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
/** 单视频模式:当前任务 ID(封面 finalize 需要) */
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string>("")
|
||||
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
|
||||
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
|
||||
|
||||
@@ -58,7 +60,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
|
||||
setBatchTasks((prev) =>
|
||||
(prev || []).map((t) =>
|
||||
t.status === "completed" && t.videos.length === 0
|
||||
t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0)
|
||||
? {
|
||||
...t,
|
||||
videos: (videos as GeneratedVideo[]).filter(
|
||||
@@ -83,7 +85,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
if (batchTasks.length === 0) return
|
||||
const byVariant = new Map<number, GeneratedVideo>()
|
||||
batchTasks.forEach((t) => {
|
||||
if (t.status === "completed" && t.videos && t.videos.length > 0) {
|
||||
if (
|
||||
t.status === "completed" ||
|
||||
(t.status === "awaiting_cover" && t.videos && t.videos.length > 0)
|
||||
) {
|
||||
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
|
||||
}
|
||||
})
|
||||
@@ -117,6 +122,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerated(false)
|
||||
setGenerateError(null)
|
||||
setBatchTasks([])
|
||||
setGeneratedVideos([])
|
||||
setCurrentTaskId("")
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
@@ -338,8 +345,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
if (taskIds.length > 1) {
|
||||
// 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建)
|
||||
setCurrentTaskId("")
|
||||
startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i })))
|
||||
} else {
|
||||
setCurrentTaskId(taskIds[0])
|
||||
startPolling(taskIds[0])
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -414,6 +423,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
currentTaskId,
|
||||
generate,
|
||||
retry,
|
||||
retryBatchTask,
|
||||
|
||||
@@ -48,7 +48,11 @@ export function useStep6Cover({
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
|
||||
/** 最终成片:取第一个已完成视频 */
|
||||
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
|
||||
const finalVideo =
|
||||
generatedVideos.find(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
) || generatedVideos[0]
|
||||
|
||||
/** 从后端加载封面模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
@@ -171,7 +175,18 @@ export function useStep6Cover({
|
||||
}, [])
|
||||
|
||||
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
|
||||
setEditingTemplate(tpl)
|
||||
// 系统模板不可修改:复制为新模板草稿,走"另存为"流程
|
||||
if (tpl.is_system) {
|
||||
setEditingTemplate({
|
||||
...tpl,
|
||||
id: "",
|
||||
name: tpl.name + " 副本",
|
||||
is_system: false,
|
||||
created_at: "",
|
||||
})
|
||||
} else {
|
||||
setEditingTemplate(tpl)
|
||||
}
|
||||
setShowCoverEditor(true)
|
||||
}, [])
|
||||
|
||||
@@ -179,20 +194,25 @@ export function useStep6Cover({
|
||||
const handleSaveTemplate = useCallback(
|
||||
async (tpl: CoverTemplate) => {
|
||||
try {
|
||||
if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) {
|
||||
// 系统模板或无 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 {
|
||||
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(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3。
|
||||
* - 步骤2(选择素材):直接进入步骤3,数组长度对齐由 onBeforeEnterStep3 保证。
|
||||
* - 步骤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,8 +62,9 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
// 弹数量选择弹窗
|
||||
onOpenCountModal()
|
||||
// 直接进入步骤3(生成数量在 Step1 已设置);对齐数组长度
|
||||
onBeforeEnterStep3?.()
|
||||
setCurrentStep(3)
|
||||
return
|
||||
}
|
||||
// 步骤4(确认生成):全部渲染完成后才能下一步进封面
|
||||
|
||||
@@ -32,6 +32,279 @@ 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
|
||||
@@ -39,12 +312,5 @@ export interface CoverTemplate {
|
||||
thumbnail_url: string
|
||||
is_system: boolean
|
||||
created_at: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
config?: CoverEditorConfig
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 标题工具函数单测 — 提升覆盖率到 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,7 +20,6 @@ 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"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""一次性脚本:对历史 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())
|
||||
@@ -345,6 +345,32 @@ class VideoFingerprint:
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "VideoFingerprint":
|
||||
"""从 to_dict() 序列化结果重建 VideoFingerprint(供 finalize 复用 worker 预计算指纹)。"""
|
||||
|
||||
chunks_raw = data.get("chunks") or []
|
||||
chunks: list[FingerprintChunk] = []
|
||||
for c in chunks_raw:
|
||||
chunks.append(
|
||||
FingerprintChunk(
|
||||
start_time_ms=int(c.get("start_time_ms", 0)),
|
||||
end_time_ms=int(c.get("end_time_ms", 0)),
|
||||
phash_binary=str(c.get("phash_binary", "")),
|
||||
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
|
||||
frame_count=int(c.get("frame_count", 0)),
|
||||
)
|
||||
)
|
||||
resolution_raw = data.get("resolution") or [1280, 720]
|
||||
return cls(
|
||||
md5=str(data.get("md5", "")),
|
||||
keyframe_phashes=list(data.get("keyframe_phashes") or []),
|
||||
color_histograms=[[float(v) for v in h] for h in (data.get("color_histograms") or [])],
|
||||
duration=float(data.get("duration") or 0.0),
|
||||
resolution=(int(resolution_raw[0]), int(resolution_raw[1])) if len(resolution_raw) >= 2 else (1280, 720),
|
||||
chunks=chunks,
|
||||
)
|
||||
|
||||
def to_chunk_models(self, video_id: str, project_id: str, user_id: str = "") -> list[VideoFingerprintChunkModel]:
|
||||
"""将分片数据转为 SQLAlchemy Model 列表,用于批量写入 video_fingerprint_chunks 表。"""
|
||||
models = []
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""查重辅助函数 — 从 generation.py 提取的 GeneratedVideo 记录 + 查重逻辑.
|
||||
"""查重辅助函数 — 渲染阶段指纹/查重预计算 + 兼容旧入库函数。
|
||||
|
||||
供 generate_video 共同复用,
|
||||
创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重。
|
||||
#2024: Worker 渲染+上传完成后**不直接创建 GeneratedVideo 成品记录**,改为:
|
||||
1. ``compute_render_fingerprint_and_dedup``: 从本地视频计算指纹+查重(历史+批次),
|
||||
返回可序列化 dict(含 fingerprint_chunks),由 worker 写入
|
||||
``GenerationTask.extra_meta["rendered_output"]``;
|
||||
2. ``create_video_record_and_dedup``: 保留兼容——当传入 ``video_path`` 时会从本地视频
|
||||
计算指纹+查重并直接创建 GeneratedVideo 记录(供测试/旧路径使用);
|
||||
当仅传 ``pre_dedup_result`` 时复用预计算结果,不再访问本地视频。
|
||||
|
||||
v2: 两阶段持久化 — 先计算所有查重数据,再一次性 commit,
|
||||
避免中间异常导致 duplicate_rate 等字段缺失。
|
||||
finalize 入口走 ``packages/application/generated_video_finalize.py`` 的
|
||||
``finalize_generated_video``,不依赖本模块中数据库以外的 worker-only 逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +22,149 @@ from sqlalchemy.orm import Session
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_parse_fps(raw) -> float:
|
||||
if raw is None:
|
||||
return 25.0
|
||||
if isinstance(raw, (int, float)):
|
||||
return float(raw)
|
||||
s = str(raw).strip()
|
||||
if "/" in s:
|
||||
try:
|
||||
num, den = s.split("/", 1)
|
||||
return float(num) / float(den) if float(den) != 0 else 25.0
|
||||
except (ValueError, ZeroDivisionError):
|
||||
pass
|
||||
try:
|
||||
return float(s)
|
||||
except (ValueError, TypeError):
|
||||
return 25.0
|
||||
|
||||
|
||||
def _compute_from_local(
|
||||
*,
|
||||
video_path: str,
|
||||
generation_task_id: str,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
batch_id: str,
|
||||
session: Session,
|
||||
) -> dict:
|
||||
"""从本地视频计算指纹+查重,返回可序列化结果 dict(不创建 DB 记录)。"""
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
from video_processing.ffmpeg_utils import probe_video_info
|
||||
|
||||
result: dict = {
|
||||
"fingerprint_dict": None,
|
||||
"fingerprint_chunks": None,
|
||||
"duration": 0.0,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"is_duplicate": False,
|
||||
"duplicate_of": None,
|
||||
"duplicate_rate": None,
|
||||
"match_count": None,
|
||||
"visual_similarity": None,
|
||||
"video_fingerprint_md5": "",
|
||||
"batch_similarity": None,
|
||||
}
|
||||
try:
|
||||
info = probe_video_info(video_path)
|
||||
result["duration"] = float(info.get("duration") or 0.0)
|
||||
result["width"] = int(info.get("width") or 1280)
|
||||
result["height"] = int(info.get("height") or 720)
|
||||
result["fps"] = _safe_parse_fps(info.get("fps"))
|
||||
except Exception as info_err:
|
||||
logger.warning("probe_video_info failed for task %s: %s", generation_task_id, info_err)
|
||||
|
||||
try:
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||||
fp_dict = fingerprint.to_dict()
|
||||
result["fingerprint_dict"] = fp_dict
|
||||
result["video_fingerprint_md5"] = fingerprint.md5 or ""
|
||||
result["fingerprint_chunks"] = [
|
||||
{
|
||||
"start_time_ms": c.start_time_ms,
|
||||
"end_time_ms": c.end_time_ms,
|
||||
"phash_binary": c.phash_binary,
|
||||
"color_histogram": [float(v) for v in c.color_histogram],
|
||||
"frame_count": c.frame_count,
|
||||
}
|
||||
for c in fingerprint.chunks
|
||||
]
|
||||
|
||||
# 用 placeholder_id 占位(还没有真正的 video_id,不影响查重逻辑——
|
||||
# 因为查重排除的是 GeneratedVideo 表中的记录)
|
||||
placeholder_id = f"pre-{generation_task_id}"
|
||||
duration_sec = fingerprint.duration if fingerprint.duration else 0
|
||||
duplicate_result = deduplicator.check_duplicate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
session,
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
duration_sec=duration_sec,
|
||||
exclude_video_id=placeholder_id,
|
||||
)
|
||||
batch_sim: float | None = None
|
||||
if not duplicate_result and batch_id:
|
||||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, placeholder_id, session)
|
||||
if duplicate_result:
|
||||
batch_sim = float(duplicate_result.get("similarity", 0.0))
|
||||
result["batch_similarity"] = batch_sim
|
||||
if duplicate_result:
|
||||
result["is_duplicate"] = True
|
||||
result["duplicate_of"] = duplicate_result["duplicate_of"]
|
||||
else:
|
||||
result["is_duplicate"] = False
|
||||
|
||||
try:
|
||||
rate_result = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
placeholder_id,
|
||||
session,
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
)
|
||||
result["duplicate_rate"] = rate_result.get("duplicate_rate")
|
||||
result["match_count"] = rate_result.get("match_count")
|
||||
result["visual_similarity"] = rate_result.get("visual_similarity")
|
||||
except Exception as rate_err:
|
||||
logger.warning("compute_duplicate_rate failed for task %s: %s", generation_task_id, rate_err)
|
||||
except Exception as fp_err:
|
||||
logger.warning("Fingerprint compute failed for task %s: %s", generation_task_id, fp_err)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def compute_render_fingerprint_and_dedup(
|
||||
*,
|
||||
video_path: str,
|
||||
generation_task_id: str,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
batch_id: str,
|
||||
mode: str,
|
||||
session: Session,
|
||||
) -> dict:
|
||||
"""渲染+上传完成后的预计算:计算指纹+历史/批次查重,返回可序列化 dict。
|
||||
|
||||
**不创建 GeneratedVideo 记录**。结果由调用方写入 extra_meta["rendered_output"],
|
||||
finalize 时复用。mode 参数保留签名一致性(查重结果中不直接使用)。
|
||||
"""
|
||||
_ = mode # 保留在签名里便于调用方对齐;查重结果不含 mode
|
||||
return _compute_from_local(
|
||||
video_path=video_path,
|
||||
generation_task_id=generation_task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
batch_id=batch_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
def create_video_record_and_dedup(
|
||||
*,
|
||||
generation_task_id: str,
|
||||
@@ -25,8 +173,8 @@ def create_video_record_and_dedup(
|
||||
batch_id: str,
|
||||
file_url: str,
|
||||
file_size: int,
|
||||
duration: float,
|
||||
video_path: str,
|
||||
duration: float | None = None,
|
||||
video_path: str | None,
|
||||
mode: str,
|
||||
session: Session,
|
||||
width: int = 1280,
|
||||
@@ -34,30 +182,55 @@ def create_video_record_and_dedup(
|
||||
fps: float = 25.0,
|
||||
name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
pre_fingerprint_dict: dict | None = None,
|
||||
pre_fingerprint_chunks: list[dict] | None = None,
|
||||
pre_dedup_result: dict | None = None,
|
||||
) -> dict:
|
||||
"""Returns: {"video_count": int, "is_duplicate": bool, "batch_similarity": float|None,
|
||||
"duplicate_of": str|None} —— batch_similarity 为批次内最高相似度(无批次查重时 None)。"""
|
||||
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
|
||||
"""创建 GeneratedVideo 记录 + 可选查重。
|
||||
|
||||
采用两阶段持久化:先计算所有指纹/查重数据(内存),
|
||||
再一次性写入数据库并 commit。若指纹计算失败,
|
||||
视频记录仍会创建(无查重数据),但保证不会出现"写了记录却没 commit"的中间态。
|
||||
两种用法:
|
||||
- 传入 ``video_path``(非 None):从本地视频计算指纹+查重,直接创建记录(旧路径/测试)。
|
||||
- 仅传入 ``pre_*``:复用 worker 预计算结果,不访问本地视频(finalize 用)。
|
||||
|
||||
Returns:
|
||||
创建的视频记录数量(1 表示成功,0 表示失败)
|
||||
{"video_id", "video_count", "is_duplicate", "batch_similarity", "duplicate_of"}
|
||||
"""
|
||||
from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.domain import GeneratedVideo
|
||||
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
try:
|
||||
video_id = uuid4().hex
|
||||
video_name = name.strip() if name else f"generated-{generation_task_id[:8]}.mp4"
|
||||
|
||||
# ── Phase 1: 构建视频记录(内存,不 commit) ────────────────
|
||||
# 决定查重/元信息来源
|
||||
if video_path:
|
||||
pre = _compute_from_local(
|
||||
video_path=video_path,
|
||||
generation_task_id=generation_task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
batch_id=batch_id,
|
||||
session=session,
|
||||
)
|
||||
else:
|
||||
pre = dict(pre_dedup_result or {})
|
||||
pre.setdefault("fingerprint_dict", pre_fingerprint_dict)
|
||||
pre.setdefault("fingerprint_chunks", pre_fingerprint_chunks)
|
||||
pre.setdefault("is_duplicate", False)
|
||||
pre.setdefault("duplicate_of", None)
|
||||
pre.setdefault("duplicate_rate", None)
|
||||
pre.setdefault("match_count", None)
|
||||
pre.setdefault("visual_similarity", None)
|
||||
pre.setdefault("batch_similarity", None)
|
||||
|
||||
used_duration = float(duration if duration is not None else pre.get("duration", 0.0))
|
||||
used_width = int(pre.get("width", width) or width)
|
||||
used_height = int(pre.get("height", height) or height)
|
||||
used_fps = float(pre.get("fps", fps) or fps)
|
||||
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=project_id,
|
||||
@@ -66,117 +239,66 @@ def create_video_record_and_dedup(
|
||||
name=video_name,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
duration=used_duration,
|
||||
width=used_width,
|
||||
height=used_height,
|
||||
fps=used_fps,
|
||||
status="completed",
|
||||
generation_params={"mode": mode},
|
||||
thumbnail_url=thumbnail_url or None,
|
||||
video_fingerprint=pre.get("fingerprint_dict"),
|
||||
is_duplicate=bool(pre.get("is_duplicate", False)),
|
||||
duplicate_of=pre.get("duplicate_of"),
|
||||
duplicate_rate=pre.get("duplicate_rate"),
|
||||
match_count=pre.get("match_count"),
|
||||
visual_similarity=pre.get("visual_similarity"),
|
||||
)
|
||||
|
||||
# ── Phase 2: 计算指纹 & 查重(全部在内存) ────────────────
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = None
|
||||
batch_similarity: float | None = None
|
||||
|
||||
try:
|
||||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||||
except Exception as fp_err:
|
||||
logger.warning("Fingerprint computation failed for %s: %s", video_id, fp_err)
|
||||
|
||||
if fingerprint is not None:
|
||||
generated_video.video_fingerprint = fingerprint.to_dict()
|
||||
|
||||
# 写入分片指纹表(失败不阻塞)
|
||||
# 写分片指纹表
|
||||
chunks = pre.get("fingerprint_chunks")
|
||||
if chunks:
|
||||
try:
|
||||
_save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session)
|
||||
chunk_models = [
|
||||
VideoFingerprintChunkModel(
|
||||
id=uuid4().hex,
|
||||
video_id=video_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
start_time_ms=int(c.get("start_time_ms", 0)),
|
||||
end_time_ms=int(c.get("end_time_ms", 0)),
|
||||
phash_binary=str(c.get("phash_binary", "")),
|
||||
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
|
||||
frame_count=int(c.get("frame_count", 0)),
|
||||
)
|
||||
for c in chunks
|
||||
if isinstance(c, dict)
|
||||
]
|
||||
if chunk_models:
|
||||
# 幂等:先清理旧分片
|
||||
session.query(VideoFingerprintChunkModel).filter(
|
||||
VideoFingerprintChunkModel.video_id == video_id
|
||||
).delete(synchronize_session=False)
|
||||
session.bulk_save_objects(chunk_models)
|
||||
except Exception as chunk_err:
|
||||
logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err)
|
||||
|
||||
# (a) 历史成片查重(跨项目全局 + 时长预过滤)
|
||||
# Issue #1702: fingerprint.duration 单位是秒,旧代码 /1000 让时长预过滤失效
|
||||
duration_sec = fingerprint.duration if fingerprint.duration else 0
|
||||
duplicate_result = deduplicator.check_duplicate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
session,
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
duration_sec=duration_sec,
|
||||
exclude_video_id=video_id,
|
||||
)
|
||||
|
||||
# (b) 批次内查重(仅当有 batch_id 时)
|
||||
batch_similarity: float | None = None
|
||||
if not duplicate_result and batch_id:
|
||||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
|
||||
if duplicate_result:
|
||||
batch_similarity = float(duplicate_result.get("similarity", 0.0))
|
||||
if duplicate_result:
|
||||
generated_video.is_duplicate = True
|
||||
generated_video.duplicate_of = duplicate_result["duplicate_of"]
|
||||
logger.info(
|
||||
"Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)",
|
||||
video_id,
|
||||
duplicate_result["duplicate_of"],
|
||||
duplicate_result["reason"],
|
||||
duplicate_result["similarity"],
|
||||
)
|
||||
else:
|
||||
generated_video.is_duplicate = False
|
||||
generated_video.duplicate_of = None
|
||||
|
||||
# 计算重复率百分比(跨项目全局)
|
||||
try:
|
||||
rate_result = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
video_id,
|
||||
session,
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
)
|
||||
generated_video.duplicate_rate = rate_result["duplicate_rate"]
|
||||
generated_video.match_count = rate_result["match_count"]
|
||||
generated_video.visual_similarity = rate_result["visual_similarity"]
|
||||
logger.info(
|
||||
"Duplicate rate for %s: %.2f%% (visual_sim=%.3f, matches=%d)",
|
||||
video_id,
|
||||
rate_result["duplicate_rate"],
|
||||
rate_result["visual_similarity"],
|
||||
rate_result["match_count"],
|
||||
)
|
||||
except Exception as rate_err:
|
||||
logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err)
|
||||
generated_video.duplicate_rate = None
|
||||
|
||||
# ── Phase 3: 一次性持久化 ─────────────────────────────────
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
video_repo.create(generated_video)
|
||||
|
||||
if thumbnail_url:
|
||||
logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80])
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
repo.create(generated_video)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"GeneratedVideo record created: %s (task=%s, dup=%s, rate=%s)",
|
||||
video_id,
|
||||
generation_task_id,
|
||||
generated_video.is_duplicate,
|
||||
generated_video.duplicate_rate,
|
||||
)
|
||||
return {
|
||||
"video_id": video_id,
|
||||
"video_count": 1,
|
||||
"is_duplicate": bool(generated_video.is_duplicate),
|
||||
"batch_similarity": batch_similarity,
|
||||
"duplicate_of": generated_video.duplicate_of,
|
||||
"is_duplicate": bool(pre.get("is_duplicate", False)),
|
||||
"batch_similarity": pre.get("batch_similarity"),
|
||||
"duplicate_of": pre.get("duplicate_of"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create video record / dedup for task %s: %s",
|
||||
generation_task_id,
|
||||
e,
|
||||
)
|
||||
logger.error("Failed to create video record for task %s: %s", generation_task_id, e)
|
||||
session.rollback()
|
||||
return {"video_count": 0, "is_duplicate": False, "batch_similarity": None, "duplicate_of": None}
|
||||
return {
|
||||
"video_id": "",
|
||||
"video_count": 0,
|
||||
"is_duplicate": False,
|
||||
"batch_similarity": None,
|
||||
"duplicate_of": None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""全 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 只编码一次;
|
||||
TTS/BGM 音频也在同一命令里 amix 合成。
|
||||
|
||||
约束(P1):
|
||||
- 仅覆盖智能剪辑主流场景:单一主视频轨、全硬切、无 PiP/overlay/水印/贴纸/片头片尾/绿幕。
|
||||
不满足条件时调用方回退到现有 mezzanine/CPU 链路(功能不回归)。
|
||||
- 字幕先用 drawtext(P4000 装好中文字体后可再切 subtitles 滤镜烧 ASS)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# drawtext 默认字体名(fontconfig 解析);P4000 装好中文字体后可在 config 指定
|
||||
DEFAULT_DRAWTEXT_FONT = "sans"
|
||||
|
||||
|
||||
def escape_drawtext_text(text: str) -> str:
|
||||
"""转义 drawtext text= 中的特殊字符(ffmpeg 过滤器语法)。"""
|
||||
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(";", "\\;")
|
||||
# 换行保留(drawtext 支持 %{...};真实换行需转成字面)
|
||||
s = s.replace("\n", " ")
|
||||
return s
|
||||
|
||||
|
||||
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 滤镜字符串(不含输入/输出标签)。
|
||||
|
||||
[start, end] 秒的显示窗口通过 enable='between(t,...)' 控制。
|
||||
"""
|
||||
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={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={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 upload_local_audio_and_sign(
|
||||
local_audio: Path,
|
||||
*,
|
||||
tmp_prefix: str = "tmp/gpu-direct-audio/",
|
||||
expires: int = 3600,
|
||||
) -> tuple[str, str]:
|
||||
"""把本地音频(TTS/BGM)上传 OSS tmp 目录并签公网 GET URL。
|
||||
|
||||
Returns:
|
||||
(signed_get_url, oss_key)
|
||||
"""
|
||||
from video_processing.oss_helpers import _storage # 类型: 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:
|
||||
"""给原始素材 storage_key 签公网 GET URL(供 P4000 直接下载)。"""
|
||||
from video_processing.oss_helpers import _storage # 类型: ignore
|
||||
|
||||
storage = _storage()
|
||||
return storage.get_download_url(storage_key, expires)
|
||||
|
||||
|
||||
# ── 直连渲染编排器 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DirectRenderPlan:
|
||||
"""一次直连渲染的产物:inputs(裸文件名→URL)与完整 ffmpeg_args。"""
|
||||
|
||||
def __init__(self, inputs: dict[str, str], ffmpeg_args: list[str], oss_keys: list[str]):
|
||||
self.inputs = inputs
|
||||
self.ffmpeg_args = ffmpeg_args
|
||||
self.oss_keys = oss_keys # 本次上传的临时音频 key(供事后清理)
|
||||
|
||||
|
||||
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,
|
||||
) -> DirectRenderPlan:
|
||||
"""构造 P4000 直连渲染所需的 inputs 与 ffmpeg_args。
|
||||
|
||||
视频:每段 trim/setpts/scale/pad/fps → concat(全硬切)→ 可选边缘 crop+scale → drawtext。
|
||||
音频:concat 时丢弃原生音轨(只映射 [vfinal]),TTS/BGM 上传签名后 amix 混音。
|
||||
"""
|
||||
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] = [] # filter_complex 各段
|
||||
|
||||
n = len(resolved_clips)
|
||||
|
||||
# 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, clip in enumerate(resolved_clips):
|
||||
filters: list[str] = []
|
||||
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)
|
||||
if eff > 0:
|
||||
if start > 0:
|
||||
filters.append(f"trim=start={start:.3f}:duration={eff:.3f}")
|
||||
else:
|
||||
filters.append(f"trim=duration={eff:.3f}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
speed = float(getattr(clip, "playback_speed", 1.0) or 1.0)
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
filters.append(f"setpts=PTS/{speed:.4f}")
|
||||
|
||||
filters.append(f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease")
|
||||
filters.append(f"pad={output_width}:{output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
filters.append(f"fps={output_fps}")
|
||||
|
||||
label = f"vc{i}"
|
||||
fc.append(f"[{i}:v]{','.join(filters)}[{label}]")
|
||||
pre_labels.append(label)
|
||||
|
||||
# 3. concat(全硬切;原生音频丢弃,v=1:a=0)
|
||||
concat_in = "".join(f"[{l}]" for l in pre_labels)
|
||||
fc.append(f"{concat_in}concat=n={n}:v=1:a=0[vcat]")
|
||||
cur = "vcat"
|
||||
|
||||
# 4. 边缘裁剪降重(合并进同一条,不再单独重编码)
|
||||
if edge_crop_pct and edge_crop_pct > 0:
|
||||
p = float(edge_crop_pct)
|
||||
keep = 1.0 - 2.0 * p
|
||||
cw_expr = f"trunc(iw*{keep:.4f}/2)*2"
|
||||
ch_expr = f"trunc(ih*{keep:.4f}/2)*2"
|
||||
fc.append(
|
||||
f"[{cur}]crop=w='{cw_expr}':h='{ch_expr}':x='(iw-{cw_expr})/2':y='(ih-{ch_expr})/2',"
|
||||
f"scale={output_width}:{output_height}[vcrop]"
|
||||
)
|
||||
cur = "vcrop"
|
||||
|
||||
# 5. drawtext 字幕(标题整段 + ASR 逐句)
|
||||
draw_filters: list[str] = []
|
||||
if title_text.strip():
|
||||
title_size = max(int(output_height * 0.05), 24)
|
||||
draw_filters.append(
|
||||
build_drawtext_filter(
|
||||
text=title_text,
|
||||
start=0.0,
|
||||
end=max(total_duration, 0.1),
|
||||
font=font,
|
||||
font_size=title_size,
|
||||
y_expr="h-th-40",
|
||||
box=True,
|
||||
)
|
||||
)
|
||||
sub_size = max(int(output_height * 0.045), 20)
|
||||
for seg in subtitle_segments or []:
|
||||
txt = getattr(seg, "text", "") or ""
|
||||
if not txt.strip():
|
||||
continue
|
||||
draw_filters.append(
|
||||
build_drawtext_filter(
|
||||
text=txt,
|
||||
start=float(getattr(seg, "start", 0)),
|
||||
end=float(getattr(seg, "end", 0)),
|
||||
font=font,
|
||||
font_size=sub_size,
|
||||
y_expr="h-th-60",
|
||||
borderw=2,
|
||||
)
|
||||
)
|
||||
|
||||
if draw_filters:
|
||||
prev = cur
|
||||
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}]format=yuv420p[vfinal]")
|
||||
vfinal_label = "vfinal"
|
||||
|
||||
# 6. 音频输入与混音
|
||||
audio_items: list[tuple[int, float]] = [] # (input_index, volume)
|
||||
next_idx = n
|
||||
if tts_audio and Path(tts_audio).exists():
|
||||
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])
|
||||
audio_items.append((next_idx, 1.0))
|
||||
next_idx += 1
|
||||
if bgm_audio and Path(bgm_audio).exists():
|
||||
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])
|
||||
audio_items.append((next_idx, 0.35))
|
||||
next_idx += 1
|
||||
|
||||
maps: list[str] = ["-map", f"[{vfinal_label}]"]
|
||||
if audio_items:
|
||||
mix_labels: list[str] = []
|
||||
for k, (idx, vol) in enumerate(audio_items):
|
||||
alabel = f"au{k}"
|
||||
fc.append(
|
||||
f"[{idx}:a]aresample=44100,volume={vol:.2f},"
|
||||
f"aformat=sample_fmts=fltp:channel_layouts=stereo[{alabel}]"
|
||||
)
|
||||
mix_labels.append(alabel)
|
||||
mix_in = "".join(f"[{l}]" for l in mix_labels)
|
||||
fc.append(
|
||||
f"{mix_in}amix=inputs={len(mix_labels)}:duration=first:dropout_transition=2,"
|
||||
f"aresample=44100[afinal]"
|
||||
)
|
||||
maps.extend(["-map", "[afinal]", "-c:a", "aac", "-b:a", "128k"])
|
||||
|
||||
# 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", "-f", "mp4", "pipe:1"])
|
||||
|
||||
return DirectRenderPlan(inputs=inputs, ffmpeg_args=ffmpeg_args, oss_keys=oss_keys)
|
||||
@@ -1,7 +1,17 @@
|
||||
"""OSS 工具函数 — 从 generation.py 提取的共享 OSS 操作.
|
||||
"""OSS 工具函数 — Worker 端统一入口。
|
||||
|
||||
提供 OSS 配置读取、Bucket 创建、素材上传/下载、asset_id → 本地路径解析
|
||||
等能力,供 render_edit_plan 和 generate_video 共同复用。
|
||||
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 里的同名模块。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -9,67 +19,173 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import sys
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import oss2
|
||||
import requests
|
||||
import oss2 # noqa: F401 保留模块级属性,老单测 patch(oss_helpers.oss2)
|
||||
import requests # noqa: F401 老单测 patch(oss_helpers.requests)
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
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,
|
||||
)
|
||||
|
||||
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 # 分片上传并发数
|
||||
|
||||
# ── 单例访问 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ── OSS 配置 ──────────────────────────────────────────────────────────────────
|
||||
def _storage() -> SharedStorageService:
|
||||
return get_shared_storage_service()
|
||||
|
||||
|
||||
def oss_settings() -> tuple[str, str, str, str] | None:
|
||||
"""获取 OSS 配置。
|
||||
# ── 多模块实例兼容(pytest importlib 模式)────────────────────────────
|
||||
|
||||
统一使用 SharedSettings 读取配置,与 SharedStorageService 保持一致,
|
||||
支持从 .env 文件加载,避免两套配置路径不一致。
|
||||
|
||||
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]):
|
||||
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):
|
||||
return None
|
||||
return access_key_id, access_key_secret, endpoint, bucket_name
|
||||
return (
|
||||
s.oss_access_key_id,
|
||||
s.oss_access_key_secret,
|
||||
s.oss_endpoint,
|
||||
s.oss_bucket_name,
|
||||
)
|
||||
|
||||
|
||||
def oss_bucket() -> oss2.Bucket | None:
|
||||
"""获取 OSS Bucket 实例。
|
||||
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
|
||||
|
||||
P0-2 修复:endpoint 不带 scheme 时自动补 https:// 前缀,
|
||||
确保 sign_url 等依赖 scheme 的方法返回 HTTPS URL。
|
||||
|
||||
P0-staging 修复:增加 connect_timeout=10s,防止网络抖动时
|
||||
TCP 握手阶段无限挂死,导致 worker 进程卡死。
|
||||
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
|
||||
|
||||
Returns:
|
||||
oss2.Bucket 实例,配置缺失时返回 None。
|
||||
"""
|
||||
settings = oss_settings()
|
||||
|
||||
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()
|
||||
if settings is None:
|
||||
return None
|
||||
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}"
|
||||
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)
|
||||
return oss2.Bucket(
|
||||
oss2.Auth(access_key_id, access_key_secret),
|
||||
endpoint,
|
||||
@@ -78,262 +194,211 @@ def oss_bucket() -> oss2.Bucket | None:
|
||||
)
|
||||
|
||||
|
||||
def public_bucket():
|
||||
"""返回公网 endpoint bucket(仅用于 sign_url)。"""
|
||||
return _storage().public_bucket
|
||||
|
||||
|
||||
def normalize_storage_key(storage_key_or_url: str) -> str:
|
||||
"""标准化存储键 — 如果是完整 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("/")
|
||||
"""标准化存储键:URL 取 path + URL decode,开头斜杠去掉。"""
|
||||
return _storage().normalize_storage_key(storage_key_or_url)
|
||||
|
||||
|
||||
# ── 上传 / 下载 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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
|
||||
# ── HTTP 下载(保留模块级函数方便 patch)─────────────────────────────
|
||||
|
||||
|
||||
def _download_via_http(url: str, local_path: Path) -> bool:
|
||||
"""通过 HTTP 下载文件(支持预签名 URL)。
|
||||
|
||||
使用流式下载避免大文件内存溢出,超时 900s。
|
||||
"""
|
||||
"""通过 HTTP 下载文件(用 oss_helpers.requests,方便单测 patch)。"""
|
||||
try:
|
||||
resp = requests.get(url, stream=True, timeout=900)
|
||||
resp = requests.get(url, stream=True, timeout=OSS_HTTP_DOWNLOAD_TIMEOUT)
|
||||
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 local_path.exists() and local_path.stat().st_size > 0
|
||||
return Path(local_path).exists() and Path(local_path).stat().st_size > 0
|
||||
except Exception:
|
||||
logger.exception("HTTP下载素材失败: %s", url)
|
||||
logger.exception("HTTP下载失败: %s", url[:100])
|
||||
return False
|
||||
|
||||
|
||||
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公开 URL。
|
||||
# ── 下载 / 上传 ───────────────────────────────────────────────────────
|
||||
|
||||
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
|
||||
上传加总超时保护(默认 900s),防止网络异常时无限挂死。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径(Path 或 str 均可)
|
||||
storage_key: 目标存储键
|
||||
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)
|
||||
|
||||
Returns:
|
||||
公开访问 URL,上传失败或 OSS 未配置时返回 None。
|
||||
"""
|
||||
local_path = Path(local_path) # 统一转 Path,兼容 str 调用
|
||||
bucket = oss_bucket()
|
||||
|
||||
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()
|
||||
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('/')}"
|
||||
|
||||
result: dict = {"url": None, "error": None, "file_size": 0}
|
||||
done = threading.Event()
|
||||
local_path = Path(local_path)
|
||||
try:
|
||||
file_size = local_path.stat().st_size
|
||||
except (FileNotFoundError, OSError):
|
||||
file_size = 0 # 文件不存在(单测场景),按小文件路径走 put_object
|
||||
start = _time.monotonic()
|
||||
|
||||
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
|
||||
def _timed_out() -> bool:
|
||||
return (_time.monotonic() - start) > OSS_UPLOAD_TOTAL_TIMEOUT
|
||||
|
||||
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,
|
||||
)
|
||||
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])
|
||||
return None
|
||||
|
||||
if result["error"]:
|
||||
return None
|
||||
|
||||
return result["url"]
|
||||
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)
|
||||
|
||||
|
||||
def get_signed_download_url(storage_key_or_url: str, expires_seconds: int = 3600) -> str | 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:
|
||||
"""生成预签名下载 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:
|
||||
return None
|
||||
try:
|
||||
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
|
||||
return s.get_download_url(storage_key_or_url, expires_seconds=expires_seconds)
|
||||
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 解析到本地文件路径。
|
||||
"""从 asset_id 解析到本地路径(缓存优先,否则 OSS 下载)。
|
||||
|
||||
策略(按优先级):
|
||||
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 安全校验后返回
|
||||
2. 如果 work_dir 下已有缓存文件 → 返回缓存路径
|
||||
3. 从 OSS 下载到 work_dir/{hash}.mp4 → 返回下载路径
|
||||
4. 下载失败 → 返回 None
|
||||
|
||||
缓存策略:以 asset_id 的 SHA256 前 16 位为文件名,避免重复下载。
|
||||
|
||||
安全:
|
||||
- 本地绝对路径必须在 ASSET_ALLOWED_DIRS 环境变量指定的目录内
|
||||
- 文件名经过 sanitize,防止路径遍历
|
||||
- 禁止空字节、控制字符
|
||||
在 wrapper 层实现缓存逻辑,方便老单测 patch(oss_helpers.download_asset)。
|
||||
"""
|
||||
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
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# 2. 缓存命中(使用 hash 而非原始 ID,防止路径遍历)
|
||||
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
|
||||
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
|
||||
local_path = work_dir / f"{cache_hash}.mp4"
|
||||
|
||||
# 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
|
||||
if local_path.exists() and local_path.stat().st_size > 0:
|
||||
return local_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 → 本地路径。
|
||||
|
||||
Args:
|
||||
asset_ids: 素材 ID 列表
|
||||
work_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
{asset_id: local_path} 映射,仅包含成功解析的条目。
|
||||
"""
|
||||
def resolve_asset_ids_to_paths(asset_ids: list[str], work_dir: Path) -> dict[str, Path]:
|
||||
"""批量解析 asset_id → 本地路径。"""
|
||||
result: dict[str, Path] = {}
|
||||
for aid in asset_ids:
|
||||
local_path = resolve_asset_path(aid, work_dir)
|
||||
if local_path:
|
||||
result[aid] = local_path
|
||||
p = resolve_asset_path(aid, work_dir)
|
||||
if p is not None:
|
||||
result[aid] = p
|
||||
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)
|
||||
|
||||
@@ -189,7 +189,9 @@ class RenderAdapter:
|
||||
self._report_progress(progress_cb, 15.0, f"下载素材({len(ready_clips)} 个)")
|
||||
|
||||
# 2. 下载素材
|
||||
asset_path_map, rendered_clip_ids, failed_clip_ids = self._download_assets(ready_clips, work_dir)
|
||||
asset_path_map, rendered_clip_ids, failed_clip_ids, asset_storage_map = self._download_assets(
|
||||
ready_clips, work_dir
|
||||
)
|
||||
if not asset_path_map:
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
@@ -206,6 +208,7 @@ 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,
|
||||
@@ -315,7 +318,7 @@ class RenderAdapter:
|
||||
|
||||
def _download_assets(
|
||||
self, clips: list[EditPlanClip], work_dir: Path
|
||||
) -> tuple[dict[str, Path], list[str], list[str]]:
|
||||
) -> tuple[dict[str, Path], list[str], list[str], dict[str, str]]:
|
||||
"""下载片段素材到本地。
|
||||
|
||||
先通过 asset_id 批量查询 assets 表获取 file_url(OSS存储路径),
|
||||
@@ -386,7 +389,7 @@ 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
|
||||
return asset_path_map, rendered_clip_ids, failed_clip_ids, asset_storage_map
|
||||
|
||||
def _prepare_bgm(self, plan, work_dir: Path, plan_id: str) -> str | None:
|
||||
"""准备 BGM 音频文件(从 plan.config.bgm 读取配置)。
|
||||
@@ -541,6 +544,7 @@ 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,
|
||||
) -> RenderAdapterResult:
|
||||
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
|
||||
|
||||
@@ -590,6 +594,15 @@ class RenderAdapter:
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
clip_has_text=clip_has_text,
|
||||
)
|
||||
# 注入每个视频段对应素材的 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.5 渲染后校验输出完整性
|
||||
|
||||
@@ -221,7 +221,7 @@ def _extract_frames_via_mediakit(
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
from video_processing.oss_helpers import get_signed_download_url, upload_to_oss
|
||||
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
@@ -230,14 +230,17 @@ def _extract_frames_via_mediakit(
|
||||
logger.info("[thumbnail] MediaKit 未配置,跳过智能抽帧")
|
||||
return None
|
||||
|
||||
# 1. 上传视频到 OSS 获取 URL
|
||||
video_storage_key: str = ""
|
||||
# 1. 上传视频到 OSS,并生成预签名下载 URL(bucket 私有读,公网 URL 会 403)
|
||||
try:
|
||||
video_storage_key = f"temp/{plan_id}/{uuid.uuid4().hex[:8]}_{Path(video_path).name}"
|
||||
video_url = upload_to_oss(video_path, video_storage_key)
|
||||
if not video_url:
|
||||
public_url = upload_to_oss(video_path, video_storage_key)
|
||||
if not public_url:
|
||||
logger.warning("[thumbnail] 视频上传 OSS 失败,无法使用 MediaKit")
|
||||
return None
|
||||
logger.info("[thumbnail] 视频已上传 OSS: %s", video_url[:80])
|
||||
# MediaKit 从公网拉取视频,必须使用预签名 URL;签名 1h 足够完成抽帧
|
||||
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])
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到 ffmpeg", e)
|
||||
return None
|
||||
|
||||
@@ -63,6 +63,7 @@ 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__)
|
||||
|
||||
@@ -340,6 +341,33 @@ 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:
|
||||
# 直连成功:直接探测并返回,跳过后续视频/音频 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,
|
||||
)
|
||||
return RenderResult(
|
||||
output_path=output_path,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
# 5. 视频主渲染
|
||||
t_video_start = time.time()
|
||||
video_only_path = self.work_dir / f"rendered_{self.plan.id}_video.mp4"
|
||||
@@ -1621,20 +1649,27 @@ class UnifiedRenderService:
|
||||
effective_duration,
|
||||
has_audio,
|
||||
)
|
||||
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
|
||||
# 尝试 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
|
||||
|
||||
return has_audio
|
||||
|
||||
@@ -1717,6 +1752,11 @@ class UnifiedRenderService:
|
||||
trim_config=seg.trim,
|
||||
)
|
||||
resolved.append(rc)
|
||||
# 传递存储键(GPU直连管线需要,从 EditPlanClip.config 读取)
|
||||
_sk = (clip.config or {}).get("_storage_key")
|
||||
if _sk:
|
||||
for seg_rc in resolved[-len(resolved_segments):]:
|
||||
seg_rc.config["_storage_key"] = _sk
|
||||
continue
|
||||
|
||||
# 单段裁剪(或无裁剪)
|
||||
@@ -1790,6 +1830,10 @@ class UnifiedRenderService:
|
||||
actual_duration=actual_duration,
|
||||
trim_config=effective_trim,
|
||||
)
|
||||
# 传递存储键(GPU直连管线需要,从 EditPlanClip.config 读取)
|
||||
_sk = (clip.config or {}).get("_storage_key")
|
||||
if _sk:
|
||||
rc.config["_storage_key"] = _sk
|
||||
resolved.append(rc)
|
||||
|
||||
# Debug日志:记录每个clip的时长信息
|
||||
@@ -2170,6 +2214,290 @@ 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 = [l for l in layers if l.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,
|
||||
) -> bool | None:
|
||||
"""尝试全 GPU 直连渲染。成功返回 True,不支持/失败返回 None(调用方走旧链路)。"""
|
||||
if not self._can_use_gpu_direct(layers):
|
||||
return None
|
||||
if not self._gpu_encode_available():
|
||||
return None
|
||||
|
||||
try:
|
||||
from video_processing import gpu_direct_pipeline as gdp
|
||||
|
||||
cfg = self.plan.config or {}
|
||||
video_layer = next(l for l in layers if l.role not in ("audio",))
|
||||
video_clips = [c for c in video_layer.clips if c.clip_type != "audio"]
|
||||
|
||||
# TTS:把 audio 层的 TTS 片段合并成一个文件给 P4000
|
||||
tts_merged: Path | None = None
|
||||
audio_layer = next((l for l in layers if l.role == "audio"), 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")
|
||||
|
||||
# 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
|
||||
|
||||
# 字幕:标题 + ASR 时间轴
|
||||
title_cfg = cfg.get("title", {}) or cfg.get("title_config", {}) or {}
|
||||
title_text = ""
|
||||
if isinstance(title_cfg, dict) and title_cfg.get("enabled", True):
|
||||
title_text = title_cfg.get("text", "") or ""
|
||||
|
||||
subtitle_segments: list[Any] = []
|
||||
sub_cfg = cfg.get("subtitle", {}) or {}
|
||||
if isinstance(sub_cfg, dict) and 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)
|
||||
|
||||
# 边缘裁剪比例(与 random_edge_crop 默认 2~5% 同口径,取固定 3%)
|
||||
dedup = self._dedup_enabled()
|
||||
edge_pct = 0.03 if dedup else 0.0
|
||||
|
||||
plan = gdp.build_direct_render(
|
||||
resolved_clips=video_clips,
|
||||
output_width=self.output_width,
|
||||
output_height=self.output_height,
|
||||
output_fps=self.output_fps,
|
||||
tts_audio=tts_merged,
|
||||
bgm_audio=bgm_path,
|
||||
title_text=title_text,
|
||||
subtitle_segments=subtitle_segments,
|
||||
edge_crop_pct=edge_pct,
|
||||
total_duration=video_duration,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
logger.info("[gpu-direct] success: plan_id=%s clips=%d", self.plan.id, len(video_clips))
|
||||
return True
|
||||
|
||||
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
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[gpu-direct] unexpected error (fallback)", exc_info=True)
|
||||
return None
|
||||
|
||||
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,
|
||||
@@ -2209,20 +2537,28 @@ class UnifiedRenderService:
|
||||
input_args.count("-i"),
|
||||
output_path,
|
||||
)
|
||||
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
|
||||
|
||||
# 尝试 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
|
||||
|
||||
def _build_sticker_filters(self, input_label: str, output_label: str) -> tuple[str, list[str]]:
|
||||
"""构建贴纸叠加滤镜链.
|
||||
@@ -2384,6 +2720,9 @@ 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:
|
||||
|
||||
@@ -31,6 +31,7 @@ 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",
|
||||
@@ -75,4 +76,10 @@ 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},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -287,3 +287,80 @@ 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)
|
||||
|
||||
@@ -445,22 +445,3 @@ def classify_asset_real(video_path: str) -> tuple[str, float]:
|
||||
except Exception as e:
|
||||
logger.warning(f"Classification failed, using fallback: {e}")
|
||||
return AssetClassification.OTHER.value, 0.3
|
||||
|
||||
|
||||
def calculate_quality_score_real(video_path: str) -> float:
|
||||
"""
|
||||
质量评分入口函数
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
|
||||
Returns:
|
||||
质量评分 (0-100)
|
||||
"""
|
||||
try:
|
||||
analyzer = AssetAnalyzer(video_path)
|
||||
result = analyzer.calculate_quality_score()
|
||||
return result.total
|
||||
except Exception as e:
|
||||
logger.warning(f"Quality scoring failed, using fallback: {e}")
|
||||
return 50.0
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""素材质量评分 Celery 任务 — #2035.
|
||||
|
||||
视频素材 READY 入库后异步触发:下载视频到临时文件,运行 FFmpeg+NumPy 质量分析,
|
||||
将 0-100 总分写入 assets.quality_score 字段。同时复用已下载的视频,调用 AssetAnalyzer
|
||||
完成 9 类素材分类(写入 asset.metadata.classification / classification_confidence),
|
||||
供 smart_match 选片打分使用。任一环节失败均不阻断主流程(质量分兜底 50,分类降级 "other")。
|
||||
|
||||
任务名:worker.calculate_asset_quality
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.domain.classification import ClassificationStatus
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.calculate_asset_quality", bind=True, max_retries=1, default_retry_delay=15)
|
||||
def calculate_asset_quality_task(self, asset_id: str) -> dict:
|
||||
"""为单个视频素材计算质量评分并写回 assets.quality_score。
|
||||
|
||||
流程:
|
||||
1. 下载视频到临时文件;
|
||||
2. 用 AssetAnalyzer(FFmpeg+NumPy) 提取分辨率/帧率/码率/清晰度/稳定性 5 维分数;
|
||||
3. 写回 assets.quality_score;
|
||||
4. 复用同一临时文件,调用 AssetAnalyzer.classify() 做 9 类素材分类,
|
||||
结果写入 asset.metadata.classification / classification_confidence;
|
||||
如已有分类结果则幂等跳过(避免重复计算)。
|
||||
|
||||
失败/非视频/无文件等情况均静默降级,返回 status=skipped/failed 不抛异常。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
tmp_dir = tempfile.mkdtemp(prefix="quality_score_")
|
||||
try:
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
asset = asset_repo.find_by_id(asset_id)
|
||||
if asset is None:
|
||||
return {"status": "skipped", "reason": "asset not found", "asset_id": asset_id}
|
||||
if not (getattr(asset, "mime_type", "") or "").startswith("video/"):
|
||||
return {"status": "skipped", "reason": "not a video", "asset_id": asset_id}
|
||||
# 已有质量分则幂等跳过(重新计算需显式置空)
|
||||
if getattr(asset, "quality_score", None) is not None:
|
||||
return {"status": "skipped", "reason": "already scored", "asset_id": asset_id}
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = getattr(asset, "storage_key", "") or ""
|
||||
if not storage_key:
|
||||
return {"status": "skipped", "reason": "no storage_key", "asset_id": asset_id}
|
||||
|
||||
# 下载到临时文件
|
||||
safe_suffix = ".mp4"
|
||||
local_path = Path(tmp_dir) / f"asset_{asset_id[:8]}{safe_suffix}"
|
||||
ok = storage.download_asset(storage_key, local_path)
|
||||
if not ok or not local_path.exists() or local_path.stat().st_size == 0:
|
||||
return {"status": "failed", "reason": "download failed", "asset_id": asset_id}
|
||||
|
||||
# 调用 AssetAnalyzer
|
||||
from worker_app.tasks.asset_analyzer import AssetAnalyzer
|
||||
|
||||
try:
|
||||
analyzer = AssetAnalyzer(str(local_path), temp_dir=tmp_dir)
|
||||
result = analyzer.calculate_quality_score()
|
||||
total = float(result.total) if result and 0 <= result.total <= 100 else 50.0
|
||||
except Exception as analyze_err: # noqa: BLE001
|
||||
logger.warning("[quality_score] 分析失败,使用默认50分: asset=%s err=%s", asset_id, analyze_err)
|
||||
total = 50.0
|
||||
|
||||
# 写回数据库(质量分)
|
||||
asset.quality_score = total
|
||||
|
||||
# #2035:自动触发 9 类分类(复用已下载的临时文件,避免重复下载)
|
||||
existing_meta = dict(asset.metadata or {})
|
||||
existing_classification = existing_meta.get("classification")
|
||||
classification = None
|
||||
confidence = None
|
||||
if not existing_classification or existing_classification == "other":
|
||||
try:
|
||||
from worker_app.tasks.asset_analyzer import AssetAnalyzer as _AA
|
||||
|
||||
# 重新构造analyzer可能会重复抽帧,但classify()会复用临时帧
|
||||
_analyzer = _AA(str(local_path), temp_dir=tmp_dir)
|
||||
_cls_result = _analyzer.classify()
|
||||
classification = getattr(_cls_result, "category", None) or "other"
|
||||
confidence = float(getattr(_cls_result, "confidence", 0.0) or 0.0)
|
||||
if confidence < 0:
|
||||
confidence = 0.0
|
||||
if confidence > 1:
|
||||
confidence = 1.0
|
||||
existing_meta["classification"] = classification
|
||||
existing_meta["classification_confidence"] = confidence
|
||||
asset.classification_status = ClassificationStatus.COMPLETED
|
||||
asset.metadata = existing_meta
|
||||
logger.info(
|
||||
"[quality_score] asset=%s 自动分类完成: category=%s confidence=%.2f",
|
||||
asset_id,
|
||||
classification,
|
||||
confidence,
|
||||
)
|
||||
except Exception as cls_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[quality_score] asset=%s 自动分类失败(不影响质量分): %s",
|
||||
asset_id,
|
||||
cls_err,
|
||||
)
|
||||
# 分类失败显式标记 FAILED,避免停留在 PENDING 被反复重试
|
||||
asset.classification_status = ClassificationStatus.FAILED
|
||||
|
||||
asset_repo.update(asset)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"[quality_score] asset=%s score=%.1f classification=%s",
|
||||
asset_id,
|
||||
total,
|
||||
classification or existing_classification,
|
||||
)
|
||||
return {
|
||||
"status": "completed",
|
||||
"asset_id": asset_id,
|
||||
"quality_score": total,
|
||||
"classification": classification or existing_classification or "other",
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
db.rollback()
|
||||
logger.exception("[quality_score] asset=%s 失败: %s", asset_id, exc)
|
||||
if self.request.retries < self.max_retries:
|
||||
raise self.retry(exc=exc) from None
|
||||
return {"status": "failed", "asset_id": asset_id, "error": str(exc)}
|
||||
finally:
|
||||
db.close()
|
||||
# 清理临时文件
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,7 +1,8 @@
|
||||
"""片段级 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
|
||||
|
||||
为单个 atom_clip 调用视觉 AI 生成结构化标签,并更新到 ai_tags 字段。
|
||||
失败不阻断流程(降级为仅继承素材标签)。
|
||||
为单个 atom_clip 调用视觉 AI 生成结构化标签(含 caption),再调用
|
||||
豆包 embedding 接口为 caption 生成向量,一并写入数据库。
|
||||
失败不阻断流程(降级为仅继承素材标签 / caption 留空 / embedding 留空)。
|
||||
|
||||
任务名:worker.tag_atom_clip
|
||||
"""
|
||||
@@ -26,16 +27,16 @@ logger = get_task_logger(__name__)
|
||||
|
||||
@celery_app.task(name="worker.tag_atom_clip", bind=True, max_retries=2, default_retry_delay=10)
|
||||
def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
"""为单个原子片段生成 AI 标签.
|
||||
"""为单个原子片段生成 AI 标签 + caption + embedding.
|
||||
|
||||
Args:
|
||||
atom_clip_id: 原子片段 ID。
|
||||
force: True 时允许覆盖只有 inherited_tags 的降级记录
|
||||
(视觉 API 曾失败写入的占位标签,#1970)。
|
||||
已有完整标签(含 has_text)始终跳过,保证幂等。
|
||||
已有完整标签且有 caption 始终跳过,保证幂等。
|
||||
|
||||
Returns:
|
||||
任务结果 dict:status / clip_id / ai_tags(部分字段)。
|
||||
任务结果 dict:status / clip_id / has_ai_tags / caption / embedding_dim。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -46,11 +47,27 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
if clip is None:
|
||||
return {"status": "skipped", "reason": "clip not found", "clip_id": atom_clip_id}
|
||||
|
||||
# 已有完整标签则跳过(幂等);force 仅放行缺失 has_text 的降级记录
|
||||
if clip.ai_tags is not None:
|
||||
has_real_tags = isinstance(clip.ai_tags, dict) and "has_text" in clip.ai_tags
|
||||
if has_real_tags or not force:
|
||||
return {"status": "skipped", "reason": "already tagged", "clip_id": atom_clip_id}
|
||||
# 幂等:已有任意 ai_tags(含降级占位)则按 force 策略跳过;person_count/text_content 为附加字段不单独触发重跑
|
||||
# - 无 force:只要 ai_tags 非 None 就跳过(与旧逻辑一致)
|
||||
# - force=True 且 ai_tags 是完整标签(含 has_text)且 caption 已存在才跳过
|
||||
existing_tags = clip.ai_tags
|
||||
has_real_tags = isinstance(existing_tags, dict) and "has_text" in existing_tags
|
||||
bool(getattr(clip, "caption", None))
|
||||
if existing_tags is not None:
|
||||
if not force:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "already tagged",
|
||||
"clip_id": atom_clip_id,
|
||||
}
|
||||
# force=True:有完整标签(has_text)就跳过;caption 是 #2035 新增的
|
||||
# 字段,对已有完整标签的历史数据不强制重跑
|
||||
if has_real_tags:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "already tagged",
|
||||
"clip_id": atom_clip_id,
|
||||
}
|
||||
|
||||
# 获取素材信息
|
||||
asset = asset_repo.find_by_id(clip.asset_id)
|
||||
@@ -65,7 +82,7 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
doubao_client = get_doubao_client()
|
||||
mediakit_client = get_mediakit_client()
|
||||
|
||||
# 调用 tagger
|
||||
# 调用 tagger(视觉 API → ai_tags + caption)
|
||||
ai_tags = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url=video_url,
|
||||
@@ -74,18 +91,54 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
# 更新数据库
|
||||
# 先写入 AI 标签(含 caption 字段在 ai_tags 字典里)
|
||||
atom_repo.update_ai_tags(atom_clip_id, ai_tags)
|
||||
|
||||
# 提取 caption 并生成 embedding(失败降级,不阻断主流程)
|
||||
caption = (ai_tags or {}).get("caption", "") or ""
|
||||
embedding = None
|
||||
try:
|
||||
if caption.strip() and doubao_client.is_available:
|
||||
embedding = doubao_client.embed_text(caption)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[atom_clip_tagging] clip_id=%s embedding 生成失败,降级为空: %s",
|
||||
atom_clip_id,
|
||||
exc,
|
||||
)
|
||||
embedding = None
|
||||
|
||||
# 写入 caption + embedding(caption 冗余写一次到独立列,便于查询)
|
||||
try:
|
||||
atom_repo.update_caption_embedding(atom_clip_id, caption, embedding)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[atom_clip_tagging] clip_id=%s caption/embedding 写入失败: %s",
|
||||
atom_clip_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
person_count = (ai_tags or {}).get("person_count", 0)
|
||||
(ai_tags or {}).get("text_content", "") or ""
|
||||
logger.info(
|
||||
"[atom_clip_tagging] clip_id=%s ai_tags=%s",
|
||||
"[atom_clip_tagging] clip_id=%s ai_tags=%s caption=%r person_count=%s has_text=%s embedding_dim=%s",
|
||||
atom_clip_id,
|
||||
{k: v for k, v in ai_tags.items() if k != "inherited_tags"},
|
||||
{k: v for k, v in ai_tags.items() if k not in ("inherited_tags", "caption", "text_content")},
|
||||
caption,
|
||||
person_count,
|
||||
bool((ai_tags or {}).get("has_text")),
|
||||
len(embedding) if embedding else 0,
|
||||
)
|
||||
return {
|
||||
"status": "completed",
|
||||
"clip_id": atom_clip_id,
|
||||
"has_ai_tags": any(v for k, v in ai_tags.items() if k != "inherited_tags" and v),
|
||||
"has_ai_tags": any(
|
||||
v for k, v in ai_tags.items() if k not in ("inherited_tags", "caption", "text_content") and v
|
||||
),
|
||||
"caption": caption,
|
||||
"person_count": (ai_tags or {}).get("person_count", 0),
|
||||
"text_content": (ai_tags or {}).get("text_content", "") or "",
|
||||
"embedding_dim": len(embedding) if embedding else 0,
|
||||
}
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
|
||||
@@ -19,6 +19,7 @@ from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.domain.atom_clip_service import compute_atom_clips
|
||||
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -56,6 +57,35 @@ def generate_atom_clips(asset_id: str) -> dict:
|
||||
}
|
||||
|
||||
scene_points = extract_scene_points_from_metadata(asset.metadata)
|
||||
# #2035:metadata 中没有 scene_change_points 时,按需调用 MediaKit 检测
|
||||
# (templates_editor 路由会主动写 metadata,ingest 流程此前未触发检测导致切点无法对齐)
|
||||
if not scene_points:
|
||||
try:
|
||||
mk = get_mediakit_client()
|
||||
video_url = getattr(asset, "file_url", "") or ""
|
||||
if mk.is_available and video_url:
|
||||
timestamps = mk.detect_scene_changes(video_url)
|
||||
if timestamps:
|
||||
scene_points = timestamps
|
||||
# 持久化到 metadata,避免下次重复检测
|
||||
new_meta = dict(asset.metadata or {})
|
||||
new_meta["scene_change_points"] = list(timestamps)
|
||||
asset.metadata = new_meta
|
||||
asset_repo.update(asset)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[atom_clips] asset_id=%s 自动检测到 %d 个场景切换点并写回metadata",
|
||||
asset_id,
|
||||
len(timestamps),
|
||||
)
|
||||
except Exception as detect_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[atom_clips] asset_id=%s scene_change自动检测失败,降级为均匀切片: %s",
|
||||
asset_id,
|
||||
detect_err,
|
||||
)
|
||||
db.rollback() # 回滚metadata写失败,不影响后续切片
|
||||
|
||||
# P1 阶段继承素材的标签 ID;片段级语义标签是 P2 功能
|
||||
tags = list(getattr(asset, "tag_ids", []) or [])
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ from packages.application.ingest_orphan_cleanup import (
|
||||
INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
)
|
||||
|
||||
# 音色克隆 processing 超时:正常克隆轮询最多 5 分钟,10 分钟无更新视为卡死
|
||||
VOICE_CLONE_PROCESSING_TIMEOUT_MINUTES = 10
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -125,3 +129,40 @@ def scheduled_cleanup_stale_ingest_jobs(
|
||||
purged,
|
||||
)
|
||||
return {"stale_jobs": total_jobs, "assets_to_error": total_assets, "purged_messages": purged}
|
||||
|
||||
|
||||
@shared_task(name="worker.cleanup_stale_voice_clones")
|
||||
def scheduled_cleanup_stale_voice_clones(
|
||||
processing_timeout_minutes: int = VOICE_CLONE_PROCESSING_TIMEOUT_MINUTES,
|
||||
) -> dict:
|
||||
"""Celery Beat: 清理卡死在 processing 的音色克隆档案。
|
||||
|
||||
每 5 分钟执行一次。worker 重启/Celery 消息丢失/进程 OOM 时,
|
||||
已 prefetch 的克隆任务消息丢失,voice_clone_profile 永久卡在 processing。
|
||||
超过 processing_timeout_minutes 未更新的记录标记为 failed,
|
||||
错误信息指引用户点击重试。
|
||||
"""
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
||||
SQLAlchemyVoiceCloneProfileRepository,
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
session = SessionLocal()
|
||||
repo = SQLAlchemyVoiceCloneProfileRepository(session)
|
||||
count = repo.cleanup_stale_processing(processing_timeout_minutes)
|
||||
if count > 0:
|
||||
logger.warning(
|
||||
"[Beat] 清理了 %d 个卡死 processing 的音色克隆(超时 %d 分钟)",
|
||||
count,
|
||||
processing_timeout_minutes,
|
||||
)
|
||||
return {"cleaned": count}
|
||||
except Exception as e:
|
||||
logger.error("[Beat] 清理卡死音色克隆失败: %s", e, exc_info=True)
|
||||
return {"cleaned": 0, "error": str(e)}
|
||||
finally:
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
@@ -17,7 +17,6 @@ import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
from worker_app.tasks.generation_plan_builder import build_error_info as _build_error_info
|
||||
@@ -142,7 +141,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
|
||||
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.dedup_helpers import compute_render_fingerprint_and_dedup
|
||||
from video_processing.oss_helpers import (
|
||||
download_asset,
|
||||
get_signed_download_url,
|
||||
@@ -555,7 +554,7 @@ def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict)
|
||||
return None
|
||||
|
||||
|
||||
def _record_video_and_dedup(
|
||||
def _precompute_render_metadata(
|
||||
*,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
@@ -568,28 +567,48 @@ def _record_video_and_dedup(
|
||||
video_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> dict:
|
||||
"""成片落库 + 指纹查重(含批次内)。返回查重信息 dict。"""
|
||||
duration = probe_duration(Path(video_path))
|
||||
dedup_session = SessionLocal()
|
||||
"""渲染+上传完成后的预处理:计算指纹/查重(不落 GeneratedVideo 库)。
|
||||
|
||||
#2024: 视频生成后不再自动入成品库。本函数计算视频元信息、指纹、历史+批次查重,
|
||||
结果以 dict 返回,由调用方写入 GenerationTask.extra_meta["rendered_output"],
|
||||
等用户 Step5 调 finalize 时复用,避免 finalize 时从 OSS 下载视频重算。
|
||||
"""
|
||||
pre_session = SessionLocal()
|
||||
try:
|
||||
result = create_video_record_and_dedup(
|
||||
fp_result = compute_render_fingerprint_and_dedup(
|
||||
video_path=video_path,
|
||||
generation_task_id=task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
batch_id=batch_id,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
video_path=video_path,
|
||||
mode=editing_mode.value,
|
||||
session=dedup_session,
|
||||
name=video_name,
|
||||
thumbnail_url=thumbnail_url,
|
||||
session=pre_session,
|
||||
)
|
||||
finally:
|
||||
dedup_session.close()
|
||||
result["duration"] = duration
|
||||
return result
|
||||
pre_session.close()
|
||||
return {
|
||||
"file_url": file_url,
|
||||
"file_size": file_size,
|
||||
"duration": fp_result.get("duration", 0.0),
|
||||
"width": fp_result.get("width", 1280),
|
||||
"height": fp_result.get("height", 720),
|
||||
"fps": fp_result.get("fps", 25.0),
|
||||
"name": video_name,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"mode": editing_mode.value,
|
||||
"batch_id": batch_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
# 查重结果(finalize 时直接写入 GeneratedVideo 字段,无需重算)
|
||||
"fingerprint_dict": fp_result.get("fingerprint_dict"),
|
||||
"fingerprint_chunks": fp_result.get("fingerprint_chunks"),
|
||||
"is_duplicate": bool(fp_result.get("is_duplicate", False)),
|
||||
"duplicate_of": fp_result.get("duplicate_of"),
|
||||
"duplicate_rate": fp_result.get("duplicate_rate"),
|
||||
"match_count": fp_result.get("match_count"),
|
||||
"visual_similarity": fp_result.get("visual_similarity"),
|
||||
"video_fingerprint_md5": fp_result.get("video_fingerprint_md5", ""),
|
||||
}
|
||||
|
||||
|
||||
# ── Celery Task ──────────────────────────────────────────────────────────────
|
||||
@@ -945,8 +964,8 @@ def generate_video(self, task_id: str) -> dict:
|
||||
)
|
||||
file_size = output_path.stat().st_size
|
||||
|
||||
# ── 4.5 落库 + 查重(批次任务检查批次内相似度) ───────────
|
||||
dedup_info = _record_video_and_dedup(
|
||||
# ── 4.5 预计算指纹/元信息(#2024: 不自动入成品库,finalize 时再落库+查重) ──
|
||||
rendered_output = _precompute_render_metadata(
|
||||
task_id=task_id,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
@@ -958,68 +977,23 @@ def generate_video(self, task_id: str) -> dict:
|
||||
video_name=task_info.get("video_title", ""),
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
duration = dedup_info.get("duration", render_duration)
|
||||
video_count = dedup_info.get("video_count", 1)
|
||||
batch_sim = dedup_info.get("batch_similarity")
|
||||
duration = rendered_output.get("duration", render_duration)
|
||||
# #2024: 批次内重渲依赖已 finalize 的同批次视频。渲染阶段暂不做批次查重决策,
|
||||
# 统一在 finalize 阶段查重;首版即视为最终渲染结果。
|
||||
file_size_final = file_size
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"OSS上传",
|
||||
f"第{render_attempt + 1}版上传成功, 大小={file_size}"
|
||||
+ (f", 批次相似度={batch_sim:.0%}" if batch_sim is not None else ""),
|
||||
f"第{render_attempt + 1}版上传成功, 大小={file_size},等待用户确认封面",
|
||||
file_size=file_size,
|
||||
file_url=file_url,
|
||||
)
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
# 非批次 / 相似度达标 / 已是最后一次 → 结束循环
|
||||
if not should_rerender_for_batch_dedup(
|
||||
batch_id=batch_id,
|
||||
render_attempt=render_attempt,
|
||||
batch_similarity=batch_sim,
|
||||
):
|
||||
file_size_final = file_size
|
||||
break
|
||||
|
||||
# 批次内相似度过高:重选独立 plan 后重渲一次
|
||||
logger.warning(
|
||||
"[task_id=%s] 批次内查重相似度 %.2f 超阈值 %.2f,重选 plan 重渲",
|
||||
task_id,
|
||||
batch_sim,
|
||||
BATCH_RENDER_SIMILARITY_LIMIT,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log("批次查重", f"与批次内成片相似度过高({batch_sim:.0%}),重新选片渲染")
|
||||
_flush_logs(task_id, gen_task)
|
||||
new_plan_id = _reselect_plan_for_batch_retry(task_id, current_plan_id, task_info)
|
||||
if not new_plan_id:
|
||||
logger.warning("[task_id=%s] 重选 plan 失败,保留首版", task_id)
|
||||
file_size_final = file_size
|
||||
break
|
||||
# 回写任务关联的 plan(重渲版以新 plan 渲染)
|
||||
try:
|
||||
_ps = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_pr = SQLAlchemyGenerationTaskRepository(_ps)
|
||||
_gt = _pr.get(task_id)
|
||||
if _gt:
|
||||
_gt.source_edit_plan_id = new_plan_id
|
||||
_pr.update(_gt)
|
||||
finally:
|
||||
_ps.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 回写重渲 plan_id 失败", task_id, exc_info=True)
|
||||
current_plan_id = new_plan_id
|
||||
# 清理本轮临时目录,下一轮重新渲染
|
||||
if render_temp_dir:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(render_temp_dir, ignore_errors=True)
|
||||
render_temp_dir = None
|
||||
# #2024: 不再因批次内相似度过高而重渲(finalize 阶段统一查重),
|
||||
# 首版即视为最终渲染结果,直接结束循环。
|
||||
break
|
||||
|
||||
file_size = file_size_final or file_size
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
@@ -1062,8 +1036,42 @@ def generate_video(self, task_id: str) -> dict:
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 封面帧持久化失败", task_id, exc_info=True)
|
||||
|
||||
# ── 5. 标记完成 ──────────────────────────────────────────────────
|
||||
_update_task_status(task_id, "mark_completed", result_count=video_count)
|
||||
# ── 5. 保存渲染产物到 extra_meta 并标记为等待封面确认(#2024: 不自动入成品库) ──
|
||||
try:
|
||||
_finalize_meta_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
|
||||
_meta_model = (
|
||||
_finalize_meta_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _meta_model:
|
||||
meta = dict(_meta_model.extra_meta or {})
|
||||
# #2024/P0 finalize-400: 直接展开 _precompute_render_metadata 返回的
|
||||
# 完整 dict(含 file_url/fingerprint_dict/fingerprint_chunks/is_duplicate
|
||||
# /duplicate_of/...),避免手写字段白名单漏传字段导致 finalize 读不到数据。
|
||||
meta["rendered_output"] = {
|
||||
**dict(rendered_output or {}),
|
||||
# file_url/duration 由外层调用方拿到的实际上传结果,优先覆盖预计算值
|
||||
"file_url": file_url,
|
||||
"file_size": file_size,
|
||||
"duration": duration,
|
||||
}
|
||||
_meta_model.extra_meta = meta
|
||||
_finalize_meta_session.commit()
|
||||
finally:
|
||||
_finalize_meta_session.close()
|
||||
except Exception as meta_err:
|
||||
logger.warning(
|
||||
"[task_id=%s] 保存 rendered_output 到 extra_meta 失败: %s", task_id, meta_err, exc_info=True
|
||||
)
|
||||
|
||||
# #2024: 标记为「等待用户确认封面」,不自动入成品库;等用户调 finalize 接口才真正 mark_completed
|
||||
_update_task_status(task_id, "mark_awaiting_cover")
|
||||
|
||||
# 5.1 更新标题使用次数
|
||||
try:
|
||||
|
||||
@@ -808,17 +808,23 @@ def ingest_asset(job_id: str) -> dict:
|
||||
|
||||
db.commit()
|
||||
|
||||
# ── #1970 素材原子切片:视频 READY 后异步触发,失败不阻断入库 ──
|
||||
# ── #1970 素材原子切片 + #2035 质量评分:视频 READY 后异步触发,失败不阻断入库 ──
|
||||
# atom_clips 未就绪时选片逻辑有内存兜底(compute_fallback_clips)。
|
||||
# quality_score 未计算时选片按 50 分兜底。
|
||||
try:
|
||||
if media_type == "video" and float(asset.duration or 0) > 0:
|
||||
celery_app.send_task(
|
||||
"worker.generate_atom_clips",
|
||||
args=[asset.id],
|
||||
)
|
||||
# #2035: 异步质量评分(不与 atom_clips 链式耦合,独立任务)
|
||||
celery_app.send_task(
|
||||
"worker.calculate_asset_quality",
|
||||
args=[asset.id],
|
||||
)
|
||||
except Exception as atom_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"触发原子切片任务失败(不影响入库): asset_id=%s err=%s",
|
||||
"触发原子切片/质量评分任务失败(不影响入库): asset_id=%s err=%s",
|
||||
asset.id,
|
||||
atom_err,
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
|
||||
# P2-2 修复:session 初始化为 None,避免 SessionLocal() 抛异常时
|
||||
# finally 块中 session.close() 触发 UnboundLocalError
|
||||
session = None
|
||||
logger.info(f"Voice clone task started: profile_id={profile_id}")
|
||||
try:
|
||||
session = SessionLocal()
|
||||
repo = SQLAlchemyVoiceCloneProfileRepository(session)
|
||||
|
||||
@@ -280,3 +280,18 @@ USE_GPU_LIPSYNC=true
|
||||
GPU_LIPSYNC_POLL_INTERVAL=5
|
||||
GPU_LIPSYNC_WAIT_TIMEOUT=1200
|
||||
GPU_WORKER_STALE_SECONDS=300
|
||||
|
||||
# ==================== P4000 NVENC 硬件编码(GPU mezzanine relay)====================
|
||||
# 注意:这些值必须写死在模板里(不是 CI Secret),否则每次 CI 重新渲染 .env 都会被丢弃,
|
||||
# 导致 staging 发版后 GPU 编码静默降级到 CPU(P0 防复发)。
|
||||
ENABLE_GPU_ENCODE=true
|
||||
GPU_ENCODE_ENDPOINT=http://100.105.75.67:8900
|
||||
GPU_ENCODE_RELAY_BASE_URL=http://100.125.116.43:8092
|
||||
GPU_ENCODE_RELAY_INTERNAL_BASE_URL=http://xiaoxia-api-staging:8000
|
||||
GPU_ENCODE_RELAY_SECRET=0e1a8f0626438564a8b3fa92f3f2aac29e3c69bc02f2f85c
|
||||
GPU_ENCODE_VCODEC=h264_nvenc
|
||||
GPU_ENCODE_PRESET=p4
|
||||
GPU_ENCODE_CRF=23
|
||||
GPU_ENCODE_FALLBACK_CPU=true
|
||||
GPU_ENCODE_MEZZANINE_TRANSPORT=oss
|
||||
GPU_ENCODE_OSS_TMP_PREFIX=tmp/gpu-mezzanine/
|
||||
|
||||
@@ -12,6 +12,12 @@ server {
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
|
||||
+44
-74
@@ -10,20 +10,25 @@
|
||||
# API_IMAGE - API 镜像名称 (默认: xiaoxia-saas-api:dev)
|
||||
# WORKER_IMAGE - Worker 镜像名称 (默认: xiaoxia-saas-worker:dev)
|
||||
# WEB_IMAGE - Web 镜像名称 (默认: xiaoxia-saas-web:dev)
|
||||
# WEB_DOCKERFILE - Web Dockerfile 路径
|
||||
# WEB_NGINX_CONF - Nginx 配置文件路径
|
||||
# API_PORT - API 端口映射 (staging: 8000, production: 8001)
|
||||
# WEB_PORT - Web 端口映射 (staging: 3001, production: 3002)
|
||||
# GENERATED_FILES_HOST_DIR - 生成文件的主机目录
|
||||
# WORKER_CONCURRENCY - Worker 并发数 (默认: 4)
|
||||
# GENERATION_CONCURRENCY - Generation worker 并发(用户实时任务,默认 2)
|
||||
# TRANSCODE_CONCURRENCY - Transcode worker 并发(后台/转码/AI,默认 2)
|
||||
# WORKER_MAX_TASKS_PER_CHILD - Worker 每个子进程最大任务数 (默认: 100)
|
||||
# BEAT_ENABLED - 容器内启动 celery beat(默认 1;独立 beat 容器部署设为 0)
|
||||
# WORKER_CONCURRENCY - 兼容旧变量:未显式设置上面两个并发时按此总数分配
|
||||
#
|
||||
# 重要:
|
||||
# 重要:
|
||||
# - 生产环境不要挂载 web-dist volume,否则会导致 403
|
||||
# - 确保环境隔离网络已创建: docker network create xiaoxia-net-${ENV}
|
||||
# - ENV=staging → xiaoxia-net-staging
|
||||
# - ENV=production → xiaoxia-net-production
|
||||
# - ENV=staging -> xiaoxia-net-staging
|
||||
# - ENV=production -> xiaoxia-net-production
|
||||
#
|
||||
# #2073 队列分流:worker 容器内跑三个独立进程——beat(只发定时任务)、
|
||||
# generation worker(只消费 generation 队列,实时高优)、transcode worker(消费
|
||||
# transcode + celery 队列,后台任务)。beat 不再嵌入 generation worker,
|
||||
# 不占实时任务槽位;TRANSCODE_CONCURRENCY 独立伸缩,不再依赖 WORKER_CONCURRENCY 差值。
|
||||
|
||||
# ===========================================
|
||||
# 日志轮转配置(所有服务共享)
|
||||
@@ -40,54 +45,39 @@ services:
|
||||
# =========================================
|
||||
api:
|
||||
image: ${API_IMAGE:-xiaoxia-saas-api:dev}
|
||||
# 不在生产环境构建镜像,使用预构建的镜像
|
||||
# build:
|
||||
# context: ../..
|
||||
# dockerfile: infra/docker/api.Dockerfile
|
||||
|
||||
container_name: xiaoxia-api-${ENV:-staging}
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
|
||||
# 环境变量文件(包含数据库密码等敏感信息)
|
||||
|
||||
env_file:
|
||||
- ../../.env
|
||||
|
||||
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-staging}
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
GENERATED_FILES_DIR: /app/generated
|
||||
GENERATED_FILES_URL_PREFIX: /generated-files
|
||||
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://api.xiaoxiajianji.com}
|
||||
|
||||
# 端口映射
|
||||
# Staging: 8000 -> 8000
|
||||
# Production: 8001 -> 8000
|
||||
|
||||
ports:
|
||||
- "127.0.0.1:${API_PORT:-8000}:8000"
|
||||
|
||||
# 共享生成文件目录 + 抖音 cookies 等运行时配置
|
||||
|
||||
volumes:
|
||||
- generated-files:/app/generated
|
||||
- ../../deploy/configs:/app/configs:ro
|
||||
|
||||
|
||||
networks:
|
||||
- xiaoxia-net
|
||||
|
||||
# 健康检查配置
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
|
||||
logging: *default-logging
|
||||
|
||||
# =========================================
|
||||
# 资源限制建议(生产环境建议启用)
|
||||
# =========================================
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -98,40 +88,48 @@ services:
|
||||
memory: 512M
|
||||
|
||||
# =========================================
|
||||
# Worker 服务(Celery 任务队列)
|
||||
# Worker 服务(#2073 队列分流:beat + generation + transcode 同容器三进程)
|
||||
# =========================================
|
||||
# 三个进程独立启动,任一退出则容器整体退出由 docker restart 拉起;
|
||||
# 各自的并发与资源占用通过环境变量控制:
|
||||
# - generation:GENERATION_CONCURRENCY(默认 2),消费 generation 队列
|
||||
# - transcode: TRANSCODE_CONCURRENCY(默认 2),消费 transcode,celery 队列
|
||||
# - beat: 不消费任务,只发定时任务到 celery 默认队列
|
||||
worker:
|
||||
image: ${WORKER_IMAGE:-xiaoxia-saas-worker:dev}
|
||||
|
||||
|
||||
container_name: xiaoxia-worker-${ENV:-staging}
|
||||
restart: unless-stopped
|
||||
# 长任务(ingest HEVC 转码最长 30min、生成硬超时 11min)给足优雅关闭窗口
|
||||
stop_grace_period: 300s
|
||||
stop_signal: SIGTERM
|
||||
|
||||
|
||||
env_file:
|
||||
- ../../.env
|
||||
|
||||
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-staging}
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
# 兼容旧变量:若两个 *_CONCURRENCY 均未显式设置,entrypoint 会按此总数分配
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
|
||||
WORKER_MAX_TASKS_PER_CHILD: ${WORKER_MAX_TASKS_PER_CHILD:-100}
|
||||
# #1714 队列隔离:generation 队列独占 worker(默认并发 2),其余并发给转码
|
||||
# #2073 队列独立伸缩:generation 默认 2,transcode 默认 2(不再差值计算)
|
||||
GENERATION_CONCURRENCY: ${GENERATION_CONCURRENCY:-2}
|
||||
TRANSCODE_CONCURRENCY: ${TRANSCODE_CONCURRENCY:-2}
|
||||
# beat 默认在本容器启动;独立 beat 容器部署时设为 0
|
||||
BEAT_ENABLED: ${BEAT_ENABLED:-1}
|
||||
GENERATED_FILES_DIR: /app/generated
|
||||
GENERATED_FILES_URL_PREFIX: /generated-files
|
||||
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://api.xiaoxiajianji.com}
|
||||
|
||||
|
||||
volumes:
|
||||
- generated-files:/app/generated
|
||||
|
||||
|
||||
networks:
|
||||
- xiaoxia-net
|
||||
|
||||
# 健康检查配置
|
||||
# 注:容器内无 pgrep/ps,扫描 /proc 所有进程的 cmdline 查找 celery 进程
|
||||
# 健康检查:至少有一个 celery worker 进程在跑(beat 本身不作为存活依据)
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1"]
|
||||
test: ["CMD-SHELL", "grep -q 'celery.*worker' /proc/[0-9]*/cmdline 2>/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -139,12 +137,8 @@ services:
|
||||
|
||||
logging: *default-logging
|
||||
|
||||
# =========================================
|
||||
# 资源限制建议(生产环境建议启用)
|
||||
# =========================================
|
||||
# 注意: Worker 需要处理视频,建议分配更多资源
|
||||
# #1714 队列隔离后容器内运行 generation + transcode 两个 worker 进程,
|
||||
# 总并发 = WORKER_CONCURRENCY(默认 4),4C8G 以上确保视频渲染不 OOM
|
||||
# 资源限制:容器总资源 = gen + trans + beat,按 2+2 并发场景建议 4C8G;
|
||||
# 后续如需独立扩容/重启,可拆为 worker-generation / worker-transcode / worker-beat 三个 service。
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -159,35 +153,21 @@ services:
|
||||
# =========================================
|
||||
web:
|
||||
image: ${WEB_IMAGE:-xiaoxia-saas-web:dev}
|
||||
# 不在生产环境构建镜像,使用 web-artifact.Dockerfile
|
||||
# build:
|
||||
# context: ../..
|
||||
# dockerfile: ${WEB_DOCKERFILE:-infra/docker/web.Dockerfile}
|
||||
# args:
|
||||
# (NGINX_CONF no longer needed - all configs baked into image)
|
||||
|
||||
|
||||
container_name: xiaoxia-web-${ENV:-staging}
|
||||
restart: unless-stopped
|
||||
|
||||
# 端口映射
|
||||
# Staging: 3001 -> 80
|
||||
# Production: 3002 -> 80 (通过 Nginx 反向代理)
|
||||
|
||||
ports:
|
||||
- "127.0.0.1:${WEB_PORT:-3001}:80"
|
||||
|
||||
|
||||
networks:
|
||||
- xiaoxia-net
|
||||
|
||||
# =========================================
|
||||
# Nginx 配置运行时覆盖(双保险:entrypoint 也按 APP_ENV 选择配置)
|
||||
# 确保容器使用正确环境的 nginx 配置,即使镜像构建时使用了默认配置
|
||||
# 注意: 只覆盖 /etc/nginx/conf.d/default.conf,不挂载 /usr/share/nginx/html
|
||||
# =========================================
|
||||
|
||||
environment:
|
||||
- APP_ENV=${ENV:-staging}
|
||||
volumes:
|
||||
- ./nginx-${ENV:-staging}.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80"]
|
||||
interval: 30s
|
||||
@@ -196,9 +176,6 @@ services:
|
||||
|
||||
logging: *default-logging
|
||||
|
||||
# =========================================
|
||||
# 资源限制建议
|
||||
# =========================================
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -214,9 +191,6 @@ volumes:
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
# 重要: 确保主机目录存在且有正确权限
|
||||
# Staging: /var/lib/xiaoxia-saas-staging/generated
|
||||
# Production: /var/lib/xiaoxia-saas-production/generated
|
||||
device: ${GENERATED_FILES_HOST_DIR:?GENERATED_FILES_HOST_DIR must be set in .env}
|
||||
|
||||
# ===========================================
|
||||
@@ -225,8 +199,4 @@ volumes:
|
||||
networks:
|
||||
xiaoxia-net:
|
||||
external: true
|
||||
# 网络名根据 ENV 变量区分,实现 staging/production 环境隔离
|
||||
# staging: xiaoxia-net-staging
|
||||
# production: xiaoxia-net-production
|
||||
name: xiaoxia-net-${ENV:-staging}
|
||||
|
||||
|
||||
@@ -1,48 +1,77 @@
|
||||
#!/bin/bash
|
||||
# Worker 启动脚本 — #1714 队列隔离
|
||||
# Worker 启动脚本 — #1714 + #2073 队列分流
|
||||
#
|
||||
# 部署约束:worker 容器单实例(replicas=1),容器内启动两个 celery 进程:
|
||||
# 1. generation-worker:独占消费 generation 队列(用户视频生成,高优先级),
|
||||
# 内嵌 celery beat(-B),定时清理任务只在一个进程里跑,避免重复执行;
|
||||
# 2. transcode-worker:消费 transcode + celery 默认队列(素材转码/分类/查重/
|
||||
# 配音/下载等后台任务)。
|
||||
# 转码队列积压时,generation 队列仍有独立 worker 立即领取视频生成任务。
|
||||
# 容器内启动三个独立进程(任一退出则整体退出由 docker restart 拉起):
|
||||
# 1. beat:celery beat 调度器,不消费任何任务,只发定时任务到 celery 默认队列
|
||||
# 2. generation-worker:独占消费 generation 队列(用户实时任务,高优先级)
|
||||
# 3. transcode-worker:消费 transcode + celery 默认队列(后台/清理任务)
|
||||
#
|
||||
# 环境变量:
|
||||
# WORKER_CONCURRENCY 总并发槽参考(默认 4);生成 worker 并发默认 2,
|
||||
# 可用 GENERATION_CONCURRENCY 覆盖
|
||||
# GENERATION_CONCURRENCY generation worker 并发(默认 2)
|
||||
# TRANSCODE_CONCURRENCY transcode worker 并发(默认 = WORKER_CONCURRENCY - 2,最小 1)
|
||||
# TRANSCODE_CONCURRENCY transcode worker 并发(默认 2)
|
||||
# WORKER_MAX_TASKS_PER_CHILD 每个子进程最大任务数(默认 100)
|
||||
# WORKER_CONCURRENCY 兼容旧变量:若未显式设置 GENERATION_CONCURRENCY /
|
||||
# TRANSCODE_CONCURRENCY,则按比例分配(gen=ceil(total*1/2),
|
||||
# trans=剩余,各至少 1);已显式设置时忽略此变量。
|
||||
# BEAT_ENABLED 是否在本容器内启动 beat 进程(默认 1);
|
||||
# 若独立 beat 容器部署设为 0。
|
||||
|
||||
set -e
|
||||
|
||||
CONCURRENCY="${WORKER_CONCURRENCY:-4}"
|
||||
MAX_TASKS="${WORKER_MAX_TASKS_PER_CHILD:-100}"
|
||||
|
||||
GEN_CONCURRENCY="${GENERATION_CONCURRENCY:-2}"
|
||||
if [ -z "$TRANSCODE_CONCURRENCY" ]; then
|
||||
TRANS_CONCURRENCY=$((CONCURRENCY - GEN_CONCURRENCY))
|
||||
if [ "$TRANS_CONCURRENCY" -lt 1 ]; then
|
||||
TRANS_CONCURRENCY=1
|
||||
fi
|
||||
# ── 并发计算:显式 env 优先;否则从 WORKER_CONCURRENCY 按比例推导 ──
|
||||
if [ -n "$GENERATION_CONCURRENCY" ]; then
|
||||
GEN_CONCURRENCY="$GENERATION_CONCURRENCY"
|
||||
else
|
||||
TRANS_CONCURRENCY="$TRANSCODE_CONCURRENCY"
|
||||
TOTAL="${WORKER_CONCURRENCY:-4}"
|
||||
GEN_CONCURRENCY=$(( (TOTAL + 1) / 2 ))
|
||||
if [ "$GEN_CONCURRENCY" -lt 1 ]; then GEN_CONCURRENCY=1; fi
|
||||
fi
|
||||
|
||||
echo "Starting generation worker (queue=generation, concurrency=$GEN_CONCURRENCY, beat embedded)"
|
||||
if [ -n "$TRANSCODE_CONCURRENCY" ]; then
|
||||
TRANS_CONCURRENCY="$TRANSCODE_CONCURRENCY"
|
||||
else
|
||||
if [ -n "$WORKER_CONCURRENCY" ] && [ -z "$GENERATION_CONCURRENCY" ]; then
|
||||
# 两个都没显式设置,按 WORKER_CONCURRENCY 分配剩余
|
||||
TOTAL="$WORKER_CONCURRENCY"
|
||||
TRANS_CONCURRENCY=$(( TOTAL - GEN_CONCURRENCY ))
|
||||
if [ "$TRANS_CONCURRENCY" -lt 1 ]; then TRANS_CONCURRENCY=1; fi
|
||||
else
|
||||
# 默认 2(#2073:独立伸缩,不再依赖 WORKER_CONCURRENCY 差值)
|
||||
TRANS_CONCURRENCY=2
|
||||
fi
|
||||
fi
|
||||
|
||||
BEAT_ENABLED="${BEAT_ENABLED:-1}"
|
||||
|
||||
PIDS=()
|
||||
|
||||
# ── 1. Beat 调度器(独立进程,不消费任务)──
|
||||
if [ "$BEAT_ENABLED" = "1" ] || [ "$BEAT_ENABLED" = "true" ]; then
|
||||
echo "Starting beat scheduler (schedule file=/tmp/celerybeat-schedule)"
|
||||
celery \
|
||||
-A worker_app.celery_app \
|
||||
beat \
|
||||
--loglevel=info \
|
||||
-s /tmp/celerybeat-schedule &
|
||||
PIDS+=($!)
|
||||
fi
|
||||
|
||||
# ── 2. Generation worker(实时高优队列)──
|
||||
echo "Starting generation worker (queue=generation, concurrency=$GEN_CONCURRENCY)"
|
||||
celery \
|
||||
-A worker_app.celery_app \
|
||||
worker \
|
||||
--loglevel=info \
|
||||
"-B" \
|
||||
-s /tmp/celerybeat-schedule \
|
||||
-Q generation \
|
||||
"--concurrency=${GEN_CONCURRENCY}" \
|
||||
"--max-tasks-per-child=${MAX_TASKS}" \
|
||||
-n generation@%h &
|
||||
GEN_PID=$!
|
||||
PIDS+=($!)
|
||||
GEN_PID=${PIDS[1]:-${PIDS[0]}}
|
||||
|
||||
# ── 3. Transcode worker(后台 + 清理队列)──
|
||||
echo "Starting transcode worker (queues=transcode,celery, concurrency=$TRANS_CONCURRENCY)"
|
||||
celery \
|
||||
-A worker_app.celery_app \
|
||||
@@ -52,13 +81,22 @@ celery \
|
||||
"--concurrency=${TRANS_CONCURRENCY}" \
|
||||
"--max-tasks-per-child=${MAX_TASKS}" \
|
||||
-n transcode@%h &
|
||||
TRANS_PID=$!
|
||||
PIDS+=($!)
|
||||
TRANS_PID=${PIDS[2]:-${PIDS[1]}}
|
||||
|
||||
# 任一进程退出则终止另一个,让容器整体重启(restart: unless-stopped)
|
||||
trap 'echo "Shutting down workers..."; kill -TERM $GEN_PID $TRANS_PID 2>/dev/null || true' TERM INT
|
||||
# 任一进程退出则终止其他进程,让容器整体重启
|
||||
cleanup() {
|
||||
echo "Shutting down all celery processes..."
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait -n $GEN_PID $TRANS_PID
|
||||
# wait -n 等待任意一个子进程退出(bash 4.3+)
|
||||
# 容器镜像基础为 python:3.11-slim,bash 版本满足
|
||||
wait -n "${PIDS[@]}"
|
||||
EXIT_CODE=$?
|
||||
echo "One worker exited (code=$EXIT_CODE), stopping the other..."
|
||||
kill -TERM $GEN_PID $TRANS_PID 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
echo "One celery process exited (code=$EXIT_CODE), stopping the rest..."
|
||||
cleanup
|
||||
exit "$EXIT_CODE"
|
||||
|
||||
@@ -12,6 +12,13 @@ server {
|
||||
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# SPA routing - all routes to index.html
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Staging GPU relay plain-HTTP vhost (P4000 NVENC 编码回传入口)
|
||||
# - 监听 8092 端口纯 HTTP(绕开 HTTPS 证书与 P4000 httpx SSL 问题)
|
||||
# - 代理到本机 staging API 的 /api/ 路径(127.0.0.1:8000 是 docker 映射端口)
|
||||
# - P4000 通过 Tailscale 直连宿主机 100.69.73.60:8092 PUT 编码结果
|
||||
# - Worker 通过 Docker DNS (xiaoxia-api-staging:8000) 直接 GET/DELETE,
|
||||
# 不经宿主机 nginx,避免 UFW FORWARD DROP 阻断
|
||||
#
|
||||
# 部署:cp infra/nginx/gpu-relay-staging.conf /etc/nginx/conf.d/ && nginx -t && systemctl reload nginx
|
||||
|
||||
server {
|
||||
listen 8092;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 2048m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://127.0.0.1:8000/health;
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,19 @@ class SQLAlchemyAssetAtomClipRepository:
|
||||
models = query.all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def update_caption_embedding(self, clip_id: str, caption: str | None, embedding: list[float] | None = None) -> bool:
|
||||
"""更新片段的 caption 和 embedding 字段。"""
|
||||
upd: dict = {}
|
||||
if caption is not None:
|
||||
upd["caption"] = caption
|
||||
if embedding is not None:
|
||||
upd["embedding"] = embedding
|
||||
if not upd:
|
||||
return False
|
||||
count = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).update(upd)
|
||||
self.session.commit()
|
||||
return count > 0
|
||||
|
||||
def update_ai_tags(self, clip_id: str, ai_tags: dict) -> bool:
|
||||
"""更新指定片段的 ai_tags 字段."""
|
||||
count = (
|
||||
@@ -118,6 +131,8 @@ class SQLAlchemyAssetAtomClipRepository:
|
||||
clip_index=clip.clip_index,
|
||||
tags=clip.tags,
|
||||
ai_tags=clip.ai_tags,
|
||||
caption=clip.caption,
|
||||
embedding=clip.embedding,
|
||||
scene_change_at=clip.scene_change_at,
|
||||
is_fallback=clip.is_fallback,
|
||||
created_at=clip.created_at or datetime.now(UTC),
|
||||
@@ -132,6 +147,9 @@ class SQLAlchemyAssetAtomClipRepository:
|
||||
duration=model.duration,
|
||||
clip_index=model.clip_index,
|
||||
tags=model.tags or [],
|
||||
ai_tags=getattr(model, "ai_tags", None),
|
||||
caption=getattr(model, "caption", None),
|
||||
embedding=getattr(model, "embedding", None),
|
||||
scene_change_at=model.scene_change_at,
|
||||
is_fallback=model.is_fallback,
|
||||
created_at=model.created_at,
|
||||
|
||||
@@ -128,8 +128,12 @@ class SQLAlchemyAssetRepository:
|
||||
height=asset.height,
|
||||
fps=asset.fps,
|
||||
codec=asset.codec,
|
||||
status=asset.status.value,
|
||||
classification_status=asset.classification_status.value,
|
||||
status=(asset.status.value if hasattr(asset.status, "value") else str(asset.status)),
|
||||
classification_status=(
|
||||
asset.classification_status.value
|
||||
if hasattr(asset.classification_status, "value")
|
||||
else str(asset.classification_status)
|
||||
),
|
||||
classification_result=(json.dumps(asset.metadata) if asset.metadata else None),
|
||||
quality_score=asset.quality_score,
|
||||
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
|
||||
@@ -142,7 +146,7 @@ class SQLAlchemyAssetRepository:
|
||||
self.session.flush()
|
||||
self._sync_asset_tags(asset.id, asset.tag_ids)
|
||||
# Issue #1776: 自动维护素材库计数(同事务内原子更新)
|
||||
if asset.library_id and asset.status.value != "deleted":
|
||||
if asset.library_id and (getattr(asset.status, "value", str(asset.status)) != "deleted"):
|
||||
from sqlalchemy import func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == asset.library_id).update(
|
||||
@@ -168,8 +172,12 @@ class SQLAlchemyAssetRepository:
|
||||
model.height = asset.height
|
||||
model.fps = asset.fps
|
||||
model.codec = asset.codec
|
||||
model.status = asset.status.value
|
||||
model.classification_status = asset.classification_status.value
|
||||
model.status = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
|
||||
model.classification_status = (
|
||||
asset.classification_status.value
|
||||
if hasattr(asset.classification_status, "value")
|
||||
else str(asset.classification_status)
|
||||
)
|
||||
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
|
||||
model.quality_score = asset.quality_score
|
||||
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
|
||||
|
||||
@@ -43,6 +43,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
output_height=getattr(model, "output_height", 720) or 720,
|
||||
cover_url=getattr(model, "cover_url", "") or "",
|
||||
title_config=dict(getattr(model, "title_config", {}) or {}),
|
||||
extra_meta=dict(getattr(model, "extra_meta", {}) or {}),
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
@@ -88,6 +89,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
output_height=task.output_height,
|
||||
cover_url=task.cover_url or "",
|
||||
title_config=dict(task.title_config) if task.title_config else {},
|
||||
extra_meta=dict(task.extra_meta) if task.extra_meta else {},
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
@@ -322,6 +324,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.output_height = task.output_height
|
||||
model.cover_url = task.cover_url or ""
|
||||
model.title_config = dict(task.title_config) if task.title_config else {}
|
||||
model.extra_meta = dict(task.extra_meta) if task.extra_meta else {}
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -841,6 +841,8 @@ class AssetAtomClipModel(Base):
|
||||
clip_index = Column(Integer, nullable=False)
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
ai_tags = Column(JSON, nullable=True, default=None)
|
||||
caption = Column(Text, nullable=True, default=None)
|
||||
embedding = Column(JSON, nullable=True, default=None)
|
||||
scene_change_at = Column(Float, nullable=True)
|
||||
is_fallback = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
@@ -136,6 +136,39 @@ class SQLAlchemyVoiceCloneProfileRepository:
|
||||
)
|
||||
return {voice_id: profile_id for voice_id, profile_id in rows}
|
||||
|
||||
def cleanup_stale_processing(self, timeout_minutes: int = 10) -> int:
|
||||
"""清理超时卡在 processing 的克隆档案。
|
||||
|
||||
worker 重启、Celery 任务丢失或 OOM 被杀时,processing 档案会永久卡住。
|
||||
updated_at < NOW() - timeout_minutes 的 processing 记录,标记为 failed
|
||||
并附带明确错误信息,用户可在前端点击「重试」。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时分钟数,默认 10 分钟(正常克隆 < 5 分钟)
|
||||
|
||||
Returns:
|
||||
清理的记录数
|
||||
"""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=timeout_minutes)
|
||||
models = (
|
||||
self.session.query(VoiceCloneProfileModel)
|
||||
.filter(
|
||||
VoiceCloneProfileModel.status == "processing",
|
||||
VoiceCloneProfileModel.updated_at < cutoff,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
count = 0
|
||||
for model in models:
|
||||
model.status = "failed"
|
||||
model.error_message = f"克隆任务执行超时(超过 {timeout_minutes} 分钟未更新,可能因服务重启中断),请重试"
|
||||
count += 1
|
||||
if count > 0:
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def _model_to_entity(model: VoiceCloneProfileModel) -> VoiceCloneProfile:
|
||||
return VoiceCloneProfile(
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""#2024: 视频生成 finalize 入库用例。
|
||||
|
||||
Worker 渲染+上传完成后不自动入库,只把渲染产物与查重结果保存到
|
||||
GenerationTask.extra_meta["rendered_output"],并标记为 awaiting_cover。
|
||||
用户点「完成」时由 API 调用本用例:创建 GeneratedVideo 记录(复用预计算查重结果)、
|
||||
推进任务到 completed,返回新记录 id。
|
||||
|
||||
设计原则:finalize 必须快速(仅 DB 写入,不下载视频、不重算指纹)——
|
||||
所有耗时操作(指纹计算、历史/批次查重)都在 worker 渲染阶段预完成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RenderedOutput:
|
||||
"""Worker 预计算并写入 extra_meta 的渲染产物+查重结果。"""
|
||||
|
||||
file_url: str
|
||||
file_size: int = 0
|
||||
duration: float = 0.0
|
||||
width: int = 1280
|
||||
height: int = 720
|
||||
fps: float = 25.0
|
||||
name: str = ""
|
||||
thumbnail_url: str = ""
|
||||
mode: str = "narrative"
|
||||
batch_id: str = ""
|
||||
project_id: str = ""
|
||||
user_id: str = ""
|
||||
# 查重结果(worker 预计算)
|
||||
fingerprint_dict: dict[str, Any] | None = None
|
||||
fingerprint_chunks: list[dict[str, Any]] | None = None
|
||||
is_duplicate: bool = False
|
||||
duplicate_of: str | None = None
|
||||
duplicate_rate: float | None = None
|
||||
match_count: int | None = None
|
||||
visual_similarity: float | None = None
|
||||
video_fingerprint_md5: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "RenderedOutput":
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("rendered_output must be a dict")
|
||||
# fingerprint_chunks 历史上有两种位置:
|
||||
# 1) 顶层 ``fingerprint_chunks``(由 compute_render_fingerprint_and_dedup 直接返回)
|
||||
# 2) 嵌套在 ``fingerprint_dict["chunks"]``(VideoFingerprint.to_dict() 序列化的结构)
|
||||
# 顶层优先;顶层为空时回退到嵌套位置,兼容旧数据。
|
||||
fp_dict = data.get("fingerprint_dict") or {}
|
||||
chunks_raw = data.get("fingerprint_chunks")
|
||||
if not chunks_raw and isinstance(fp_dict, dict):
|
||||
chunks_raw = fp_dict.get("chunks")
|
||||
# md5 同样可能在顶层或嵌套在 fingerprint_dict 内(历史数据兼容)
|
||||
md5_value = data.get("video_fingerprint_md5")
|
||||
if not md5_value and isinstance(fp_dict, dict):
|
||||
md5_value = fp_dict.get("md5")
|
||||
return cls(
|
||||
file_url=str(data.get("file_url") or ""),
|
||||
file_size=int(data.get("file_size") or 0),
|
||||
duration=float(data.get("duration") or 0.0),
|
||||
width=int(data.get("width") or 1280),
|
||||
height=int(data.get("height") or 720),
|
||||
fps=float(data.get("fps") or 25.0),
|
||||
name=str(data.get("name") or ""),
|
||||
thumbnail_url=str(data.get("thumbnail_url") or ""),
|
||||
mode=str(data.get("mode") or "narrative"),
|
||||
batch_id=str(data.get("batch_id") or ""),
|
||||
project_id=str(data.get("project_id") or ""),
|
||||
user_id=str(data.get("user_id") or ""),
|
||||
fingerprint_dict=fp_dict or None,
|
||||
fingerprint_chunks=chunks_raw if isinstance(chunks_raw, list) else None,
|
||||
is_duplicate=bool(data.get("is_duplicate", False)),
|
||||
duplicate_of=data.get("duplicate_of"),
|
||||
duplicate_rate=_safe_float(data.get("duplicate_rate")),
|
||||
match_count=_safe_int(data.get("match_count")),
|
||||
visual_similarity=_safe_float(data.get("visual_similarity")),
|
||||
video_fingerprint_md5=str(md5_value or ""),
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(v) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(v) -> int | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def finalize_generated_video(
|
||||
*,
|
||||
task,
|
||||
session: Session,
|
||||
effective_cover_url: str = "",
|
||||
custom_name: str | None = None,
|
||||
) -> dict:
|
||||
"""将 awaiting_cover 的任务正式入库。
|
||||
|
||||
从 ``task.extra_meta["rendered_output"]`` 读取 worker 预存的渲染结果与查重数据,
|
||||
创建 GeneratedVideo 记录并 commit;调用方负责将 task 推进到 completed 并 update。
|
||||
|
||||
Returns:
|
||||
{"video_id": str, "is_duplicate": bool, "duplicate_of": str|None}
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
meta = dict(task.extra_meta or {})
|
||||
rendered_dict = meta.get("rendered_output") or {}
|
||||
rendered = RenderedOutput.from_dict(rendered_dict)
|
||||
|
||||
if not rendered.file_url.strip():
|
||||
raise ValueError(f"task {task.id} rendered_output.file_url 为空,无法 finalize")
|
||||
|
||||
video_id = uuid4().hex
|
||||
_custom = (custom_name or "").strip() if custom_name else ""
|
||||
video_name = _custom or (rendered.name.strip() or f"generated-{task.id[:8]}.mp4")
|
||||
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=(rendered.project_id or task.project_id or "").strip(),
|
||||
user_id=(rendered.user_id or task.created_by_user_id or "").strip(),
|
||||
generation_task_id=task.id,
|
||||
name=video_name,
|
||||
file_url=rendered.file_url.strip(),
|
||||
file_size=rendered.file_size,
|
||||
duration=rendered.duration,
|
||||
width=rendered.width,
|
||||
height=rendered.height,
|
||||
fps=rendered.fps,
|
||||
status="completed",
|
||||
generation_params={"mode": rendered.mode},
|
||||
thumbnail_url=effective_cover_url or rendered.thumbnail_url or None,
|
||||
video_fingerprint=rendered.fingerprint_dict,
|
||||
is_duplicate=rendered.is_duplicate,
|
||||
duplicate_of=rendered.duplicate_of,
|
||||
duplicate_rate=rendered.duplicate_rate,
|
||||
match_count=rendered.match_count,
|
||||
visual_similarity=rendered.visual_similarity,
|
||||
created_at=datetime.now(UTC),
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# 写入分片指纹(worker 预序列化的 chunk 列表)
|
||||
if rendered.fingerprint_chunks:
|
||||
try:
|
||||
chunk_models = []
|
||||
for c in rendered.fingerprint_chunks:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
chunk_models.append(
|
||||
VideoFingerprintChunkModel(
|
||||
id=uuid4().hex,
|
||||
video_id=video_id,
|
||||
project_id=generated_video.project_id,
|
||||
user_id=generated_video.user_id,
|
||||
start_time_ms=int(c.get("start_time_ms", 0)),
|
||||
end_time_ms=int(c.get("end_time_ms", 0)),
|
||||
phash_binary=str(c.get("phash_binary", "")),
|
||||
color_histogram=[float(v) for v in (c.get("color_histogram") or [])],
|
||||
frame_count=int(c.get("frame_count", 0)),
|
||||
)
|
||||
)
|
||||
if chunk_models:
|
||||
session.bulk_save_objects(chunk_models)
|
||||
except Exception as chunk_err:
|
||||
logger.warning("Failed to persist fingerprint chunks for video %s: %s", video_id, chunk_err)
|
||||
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
video_repo.create(generated_video)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"[finalize] GeneratedVideo created: %s (task=%s, dup=%s, cover=%s)",
|
||||
video_id,
|
||||
task.id,
|
||||
rendered.is_duplicate,
|
||||
bool(effective_cover_url),
|
||||
)
|
||||
return {
|
||||
"video_id": video_id,
|
||||
"is_duplicate": rendered.is_duplicate,
|
||||
"duplicate_of": rendered.duplicate_of,
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class APISettings(SharedSettings):
|
||||
def validate_jwt_secret_key(cls, v):
|
||||
if v is None or v == "":
|
||||
raise ValueError(
|
||||
"JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!"
|
||||
"JWT_SECRET_KEY must be set via environment variable. Do not use default value in production!"
|
||||
)
|
||||
# Block known insecure default values
|
||||
insecure_defaults = [
|
||||
@@ -63,7 +63,7 @@ class APISettings(SharedSettings):
|
||||
]
|
||||
if v.lower() in [d.lower() for d in insecure_defaults]:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable."
|
||||
f"JWT_SECRET_KEY '{v}' is insecure. Please set a strong random secret via environment variable."
|
||||
)
|
||||
return v
|
||||
|
||||
@@ -248,6 +248,10 @@ class APISettings(SharedSettings):
|
||||
def OSS_ENDPOINT(self) -> str:
|
||||
return self.oss_endpoint
|
||||
|
||||
@property
|
||||
def OSS_INTERNAL_ENDPOINT(self) -> str:
|
||||
return self.effective_oss_internal_endpoint
|
||||
|
||||
@property
|
||||
def OSS_ACCESS_KEY_ID(self) -> str:
|
||||
return self.oss_access_key_id
|
||||
|
||||
@@ -47,12 +47,37 @@ class SharedSettings(BaseSettings):
|
||||
|
||||
# ── OSS 阿里云 ──────────────────────────────────────────────────────
|
||||
oss_endpoint: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||
# 内网 endpoint:ECS VPC 内访问 OSS 用(千兆带宽、免公网流量费)。
|
||||
# 为空时自动从 oss_endpoint 推导:若 oss_endpoint 是阿里云公网域名(形如
|
||||
# oss-cn-<region>.aliyuncs.com),自动加 -internal 得到内网域名;其他情况
|
||||
# (自定义域名/本地 MinIO/非阿里云)回退使用 oss_endpoint。
|
||||
# 显式填同值可以覆盖自动推导、强制所有流量都走公网。
|
||||
oss_internal_endpoint: str = ""
|
||||
oss_access_key_id: str = ""
|
||||
oss_access_key_secret: str = ""
|
||||
oss_bucket_name: str = "xiaoxia-autocut"
|
||||
oss_direct_upload_max_mb: int = 2000
|
||||
oss_direct_upload_expire_seconds: int = 900
|
||||
|
||||
@property
|
||||
def effective_oss_internal_endpoint(self) -> str:
|
||||
"""实际用于 SDK 内网访问的 endpoint(带 -internal 自动推导)。"""
|
||||
if self.oss_internal_endpoint:
|
||||
return self.oss_internal_endpoint
|
||||
ep = self.oss_endpoint.strip()
|
||||
scheme = ""
|
||||
host = ep
|
||||
if ep.startswith("https://"):
|
||||
scheme = "https://"
|
||||
host = ep[len("https://") :]
|
||||
elif ep.startswith("http://"):
|
||||
scheme = "http://"
|
||||
host = ep[len("http://") :]
|
||||
# 阿里云公网域名自动推导:oss-cn-<region>.aliyuncs.com → oss-cn-<region>-internal.aliyuncs.com
|
||||
if host.endswith(".aliyuncs.com") and "-internal" not in host and host.startswith("oss-cn-"):
|
||||
host = host[: -len(".aliyuncs.com")] + "-internal.aliyuncs.com"
|
||||
return f"{scheme}{host}" if scheme else host
|
||||
|
||||
# ── CosyVoice (阿里云百炼语音合成) ───────────────────────────────────
|
||||
cosyvoice_api_key: str = ""
|
||||
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
|
||||
@@ -70,6 +95,7 @@ class SharedSettings(BaseSettings):
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
doubao_vision_model: str = "doubao-1-5-vision-pro-250915"
|
||||
doubao_embedding_model: str = "doubao-embedding-large-text-240915"
|
||||
|
||||
# ── MediaKit (火山引擎 AI 媒体工具) ──────────────────────────────────
|
||||
mediakit_api_key: str = ""
|
||||
@@ -128,6 +154,72 @@ class SharedSettings(BaseSettings):
|
||||
# 判断 Worker 可用的心跳新鲜度窗口(秒)—— last_heartbeat_at 在窗口内视为在线
|
||||
gpu_worker_stale_seconds: int = 300
|
||||
|
||||
# ── P4000 NVENC 硬件编码 ────────────────────────────────────────────
|
||||
# GPU 编码总开关;关闭或 endpoint 为空时始终走本机 CPU libx264
|
||||
enable_gpu_encode: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("ENABLE_GPU_ENCODE", "enable_gpu_encode"),
|
||||
)
|
||||
# P4000 编码节点地址(Tailscale 内网),例如 http://100.105.75.67:8900
|
||||
gpu_encode_endpoint: str = Field(
|
||||
default="",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_ENDPOINT", "gpu_encode_endpoint"),
|
||||
)
|
||||
# GPU 回传临时文件走公网/内网 nginx(/gpu-relay/ 已加 location);
|
||||
# 形如 http://100.69.73.60/gpu-relay (不带尾斜杠)
|
||||
gpu_encode_relay_base_url: str = Field(
|
||||
default="",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_RELAY_BASE_URL", "gpu_encode_relay_base_url"),
|
||||
description="P4000 回传结果用的外部 URL(worker 通过该 URL 提供给 P4000 PUT),如 http://100.69.73.60:8092",
|
||||
)
|
||||
# Worker→API 内网直连 URL(Docker DNS),用于 worker 自己下载/清理 relay 文件。
|
||||
# 未配置时回退到 relay_base_url(本地开发/单节点)。
|
||||
gpu_encode_relay_internal_base_url: str = Field(
|
||||
default="",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_RELAY_INTERNAL_BASE_URL", "gpu_encode_relay_internal_base_url"),
|
||||
)
|
||||
# 同步调用超时(秒):含编码+上传回传,5 分钟足够短视频
|
||||
gpu_encode_sync_timeout: int = 300
|
||||
# 异步轮询总超时(秒):长视频走 async + 轮询
|
||||
gpu_encode_async_timeout: int = 1800
|
||||
# 轮询间隔(秒)
|
||||
gpu_encode_poll_interval: float = 3.0
|
||||
# 启动探测超时(秒)
|
||||
gpu_encode_health_timeout: float = 3.0
|
||||
# NVENC 默认编码参数(可被调用方覆盖)
|
||||
gpu_encode_vcodec: str = "h264_nvenc"
|
||||
gpu_encode_preset: str = "p4" # NVENC preset: p1(最快)~p7(最好),p4 为均衡
|
||||
gpu_encode_crf: int = 23
|
||||
gpu_encode_bitrate: str = "" # 空则用 crf;非空则用 -b:v 模式
|
||||
# GPU 编码失败时是否自动降级到 CPU(默认 True);设为 False 可在 CI/测试中暴露错误
|
||||
gpu_encode_fallback_cpu: bool = Field(
|
||||
default=True,
|
||||
validation_alias=AliasChoices("GPU_ENCODE_FALLBACK_CPU", "gpu_encode_fallback_cpu"),
|
||||
)
|
||||
# P4000 → relay 回传鉴权 token(query 参数 token=xxx)。
|
||||
# 生产环境必须设置;未设置且非 production 时自动生成随机值(写日志方便排查)。
|
||||
gpu_encode_relay_secret: str = Field(
|
||||
default="",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_RELAY_SECRET", "gpu_encode_relay_secret"),
|
||||
)
|
||||
# Mezzanine 传输方式:relay=走Tailscale/Docker内网relay PUT(推荐,省公网OSS往返18-20s);oss=走旧公网OSS路径
|
||||
gpu_encode_mezzanine_transport: str = Field(
|
||||
default="relay",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_MEZZANINE_TRANSPORT", "gpu_encode_mezzanine_transport"),
|
||||
)
|
||||
# GPU 中间片在 OSS 的临时前缀(mezzanine_transport=oss 时或 relay 失败 fallback 时使用)
|
||||
gpu_encode_oss_tmp_prefix: str = Field(
|
||||
default="tmp/gpu-mezzanine/",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_OSS_TMP_PREFIX", "gpu_encode_oss_tmp_prefix"),
|
||||
)
|
||||
# relay 写入目录(相对于 generated-files 根目录)
|
||||
gpu_encode_relay_dir: str = Field(
|
||||
default="gpu_relay",
|
||||
validation_alias=AliasChoices("GPU_ENCODE_RELAY_DIR", "gpu_encode_relay_dir"),
|
||||
)
|
||||
# relay 文件保留时间(秒),worker 下载完成后会主动删除,此为兜底清理 TTL
|
||||
gpu_encode_relay_ttl: int = 3600
|
||||
|
||||
@property
|
||||
def effective_database_url(self) -> str:
|
||||
"""返回实际使用的数据库 URL。
|
||||
|
||||
@@ -37,6 +37,8 @@ class AssetAtomClip:
|
||||
clip_index: int
|
||||
tags: list[str] = field(default_factory=list)
|
||||
ai_tags: dict | None = None
|
||||
caption: str | None = None
|
||||
embedding: list[float] | None = None
|
||||
scene_change_at: float | None = None
|
||||
is_fallback: bool = False
|
||||
created_at: datetime | None = None
|
||||
@@ -67,6 +69,8 @@ class AssetAtomClip:
|
||||
tags: list[str] | None = None,
|
||||
scene_change_at: float | None = None,
|
||||
is_fallback: bool = False,
|
||||
caption: str | None = None,
|
||||
embedding: list[float] | None = None,
|
||||
) -> AssetAtomClip:
|
||||
"""工厂方法:创建一个新的原子片段。"""
|
||||
return cls(
|
||||
@@ -79,4 +83,6 @@ class AssetAtomClip:
|
||||
tags=tags or [],
|
||||
scene_change_at=scene_change_at,
|
||||
is_fallback=is_fallback,
|
||||
caption=caption,
|
||||
embedding=embedding,
|
||||
)
|
||||
|
||||
@@ -23,36 +23,53 @@ from typing import Any, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# AI 标签结构的键
|
||||
AI_TAG_KEYS = ("scene", "objects", "action", "shot", "has_text")
|
||||
AI_TAG_KEYS = ("scene", "objects", "action", "shot", "has_text", "person_count", "text_content", "caption")
|
||||
|
||||
|
||||
def build_vision_prompt() -> str:
|
||||
"""返回结构化标签提取 prompt.
|
||||
|
||||
要求 AI 以 JSON 格式返回片段内容标签,包含:
|
||||
- scene: 场景类型列表(如 "工厂", "办公室", "户外")
|
||||
- objects: 出现的物体列表(如 "产品", "手机", "电脑")
|
||||
- action: 动作类型列表(如 "演示", "说话", "操作")
|
||||
- scene: 场景类型列表(如 "工厂", "办公室", "户外", "家庭", "商店")
|
||||
- objects: 画面中出现的主要物体/人物/动物类别,详细列出,常见类别包括:
|
||||
人物类:"人物"/"男性"/"女性"/"儿童"
|
||||
食物类:"食物"/"水果"/"饮料"/"菜肴"
|
||||
电子设备类:"手机"/"电脑"/"笔记本"/"平板"/"电视"/"相机"
|
||||
交通类:"汽车"/"自行车"/"公交车"/"飞机"
|
||||
建筑类:"建筑"/"房屋"/"桥梁"/"道路"
|
||||
动物类:"狗"/"猫"/"鸟"/"马"
|
||||
其他常见:"桌子"/"椅子"/"书本"/"花草"/"产品"等
|
||||
尽可能列出所有可识别的主要物体,3-8个
|
||||
- action: 动作类型列表(如 "演示", "说话", "操作", "行走", "奔跑", "进食")
|
||||
- shot: 景别("特写" / "中景" / "远景" 之一)
|
||||
- has_text: 画面中是否有显著文字(true/false)
|
||||
- has_text: 画面中是否有显著文字(标题/字幕/标语/海报文字)
|
||||
- person_count: 画面中可见的人数,0/1/2/3(3代表3人及以上)
|
||||
- text_content: 若 has_text=true,提取画面中最显著的文字内容(不超过30字,概括即可);否则为空字符串
|
||||
- caption: 一句中文画面描述(15-30字),简洁概括这段视频的人物、动作、场景和主体内容
|
||||
"""
|
||||
return """请分析这段视频片段的关键帧,识别内容并返回 JSON 格式标签。
|
||||
|
||||
要求返回以下 JSON 结构(严格 JSON,不要添加其他文字):
|
||||
{
|
||||
"scene": ["场景1", "场景2"],
|
||||
"objects": ["物体1", "物体2"],
|
||||
"objects": ["物体1", "物体2", "物体3"],
|
||||
"action": ["动作1"],
|
||||
"shot": "特写|中景|远景",
|
||||
"has_text": true/false
|
||||
"has_text": true/false,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "一句中文描述"
|
||||
}
|
||||
|
||||
规则:
|
||||
- scene: 场景类型,如"工厂"、"办公室"、"户外"、"商店"、"家庭"等,1-3个
|
||||
- objects: 画面中可见的主要物体,如"产品"、"手机"、"电脑"、"食品"等,1-5个
|
||||
- action: 人物或物体正在进行的动作,如"演示"、"说话"、"操作"、"展示"等,1-3个
|
||||
- scene: 场景类型,如"工厂"、"办公室"、"户外"、"商店"、"家庭"、"街道"等,1-3个
|
||||
- objects: 画面中可见的所有主要物体/人物/动物/食物/设备等,详细列出(3-8个)。人物算作"人物",不要写具体人名。
|
||||
- action: 人物或物体正在进行的动作,如"演示"、"说话"、"操作"、"展示"、"行走"等,1-3个
|
||||
- shot: 景别判断,只能是"特写"、"中景"或"远景"之一
|
||||
- has_text: 画面中是否有显著可读文字(标题、字幕、标语等)
|
||||
- has_text: 画面中是否有显著可读文字(标题、字幕、标语、海报文字等)
|
||||
- person_count: 画面中可见的清晰人物数量,0=无人/远景人物不计数,1=1人,2=2人,3=3人及以上
|
||||
- text_content: 仅当 has_text=true 时填写,提取画面中最显眼的文字内容(不要超过30字);has_text=false 时填空字符串
|
||||
- caption: 一句简洁的中文画面描述(15-30字),概括主体人物、动作、场景和物体,例如"一名女性在办公室中讲解产品展示,桌上放有笔记本电脑"
|
||||
|
||||
请只返回 JSON,不要有其他说明文字。"""
|
||||
|
||||
@@ -65,7 +82,7 @@ def parse_vision_response(text: str) -> dict:
|
||||
|
||||
Returns:
|
||||
结构化标签 dict,格式如:
|
||||
{"scene": [...], "objects": [...], "action": [...], "shot": "...", "has_text": bool}
|
||||
{"scene": [...], "objects": [...], "action": [...], "shot": "...", "has_text": bool, "person_count": int, "text_content": str, "caption": "..."}
|
||||
|
||||
解析失败时返回空 dict。
|
||||
"""
|
||||
@@ -127,6 +144,33 @@ def parse_vision_response(text: str) -> dict:
|
||||
else:
|
||||
result["has_text"] = False
|
||||
|
||||
cap_val = data.get("caption", "")
|
||||
if isinstance(cap_val, str):
|
||||
cap_val = cap_val.strip()
|
||||
if len(cap_val) > 80:
|
||||
cap_val = cap_val[:80]
|
||||
else:
|
||||
cap_val = ""
|
||||
result["caption"] = cap_val
|
||||
|
||||
# person_count: 0/1/2/3
|
||||
pc_val = data.get("person_count", 0)
|
||||
try:
|
||||
pc = int(pc_val)
|
||||
result["person_count"] = max(0, min(3, pc))
|
||||
except (TypeError, ValueError):
|
||||
result["person_count"] = 0
|
||||
|
||||
# text_content: OCR 文字
|
||||
tc_val = data.get("text_content", "")
|
||||
if isinstance(tc_val, str):
|
||||
tc_val = tc_val.strip()
|
||||
if len(tc_val) > 100:
|
||||
tc_val = tc_val[:100]
|
||||
else:
|
||||
tc_val = ""
|
||||
result["text_content"] = tc_val
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -237,14 +281,24 @@ def tag_atom_clip(
|
||||
Returns:
|
||||
结构化标签 dict,格式如:
|
||||
{"scene": [...], "objects": [...], "action": [...], "shot": "...",
|
||||
"has_text": bool, "inherited_tags": [...]}
|
||||
"has_text": bool, "caption": "...", "inherited_tags": [...]}
|
||||
"""
|
||||
inherited = list(getattr(clip, "tags", []) or [])
|
||||
|
||||
# 检查 DoubaoClient 是否可用
|
||||
if not getattr(doubao_client, "is_available", False):
|
||||
logger.info("DoubaoClient 不可用,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
|
||||
return {"inherited_tags": inherited}
|
||||
return {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
|
||||
# 提取帧图片
|
||||
frame_urls: Optional[list[str]] = None
|
||||
@@ -261,7 +315,17 @@ def tag_atom_clip(
|
||||
|
||||
if not frame_urls:
|
||||
logger.warning("帧提取失败,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
|
||||
return {"inherited_tags": inherited}
|
||||
return {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
|
||||
# 调用视觉 API
|
||||
prompt = build_vision_prompt()
|
||||
@@ -275,17 +339,47 @@ def tag_atom_clip(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("视觉 API 调用异常: clip_id=%s error=%s", getattr(clip, "id", ""), e)
|
||||
return {"inherited_tags": inherited}
|
||||
return {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
|
||||
if not response_text:
|
||||
logger.warning("视觉 API 返回空: clip_id=%s", getattr(clip, "id", ""))
|
||||
return {"inherited_tags": inherited}
|
||||
return {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
|
||||
# 解析标签
|
||||
ai_tags = parse_vision_response(response_text)
|
||||
if not ai_tags:
|
||||
logger.warning("标签解析失败: clip_id=%s response=%s", getattr(clip, "id", ""), response_text[:200])
|
||||
return {"inherited_tags": inherited}
|
||||
return {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
|
||||
# 合并 inherited_tags
|
||||
ai_tags["inherited_tags"] = inherited
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""GenerationTask 领域模型 — 视频生成任务.
|
||||
|
||||
状态机:
|
||||
pending → running → completed
|
||||
pending → running → awaiting_cover → completed
|
||||
↘ failed → pending (重试)
|
||||
↘ cancelled
|
||||
|
||||
``awaiting_cover`` 表示渲染已完成、视频文件已上传、封面候选已就绪,
|
||||
但用户尚未在 Step5 确认封面并点击「完成」,此时不创建 GeneratedVideo 成品记录。
|
||||
用户调用 finalize 接口后才进入 ``completed`` 并正式入库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,8 +38,11 @@ class GenerationTaskStatus(StrEnum):
|
||||
RUNNING = "running"
|
||||
"""运行中(正在生成视频)"""
|
||||
|
||||
AWAITING_COVER = "awaiting_cover"
|
||||
"""视频已渲染上传、封面候选已就绪,等待用户在 Step5 确认封面(finalize 前的中间态)"""
|
||||
|
||||
COMPLETED = "completed"
|
||||
"""已完成(视频生成成功)"""
|
||||
"""已完成(用户已确认封面,视频已正式入库)"""
|
||||
|
||||
FAILED = "failed"
|
||||
"""失败(生成失败)"""
|
||||
@@ -61,6 +68,8 @@ class GenerationTaskStatus(StrEnum):
|
||||
return cls.FAILED
|
||||
if normalized in ("process", "processing", "run", "running", "in_progress"):
|
||||
return cls.RUNNING
|
||||
if normalized in ("awaiting_cover", "waiting_cover", "video_ready", "rendered", "pending_cover"):
|
||||
return cls.AWAITING_COVER
|
||||
if normalized in ("cancel", "cancelled", "canceled"):
|
||||
return cls.CANCELLED
|
||||
return cls.PENDING
|
||||
@@ -79,6 +88,12 @@ _VALID_TRANSITIONS: dict[GenerationTaskStatus, set[GenerationTaskStatus]] = {
|
||||
GenerationTaskStatus.CANCELLED,
|
||||
},
|
||||
GenerationTaskStatus.RUNNING: {
|
||||
GenerationTaskStatus.AWAITING_COVER,
|
||||
GenerationTaskStatus.COMPLETED, # 兜底/测试兼容:允许直接完成;主路径走 awaiting_cover
|
||||
GenerationTaskStatus.FAILED,
|
||||
GenerationTaskStatus.CANCELLED,
|
||||
},
|
||||
GenerationTaskStatus.AWAITING_COVER: {
|
||||
GenerationTaskStatus.COMPLETED,
|
||||
GenerationTaskStatus.FAILED,
|
||||
GenerationTaskStatus.CANCELLED,
|
||||
@@ -210,6 +225,11 @@ class GenerationTask:
|
||||
"""是否运行中。"""
|
||||
return self.status == GenerationTaskStatus.RUNNING
|
||||
|
||||
@property
|
||||
def is_awaiting_cover(self) -> bool:
|
||||
"""是否等待用户确认封面(渲染已完成、视频已上传、尚未 finalize 入库)。"""
|
||||
return self.status == GenerationTaskStatus.AWAITING_COVER
|
||||
|
||||
# ── 状态转换 ────────────────────────────────────────────────────────────
|
||||
|
||||
def transition_to(self, new_status: GenerationTaskStatus | str) -> None:
|
||||
@@ -248,13 +268,27 @@ class GenerationTask:
|
||||
self.started_at = datetime.now(UTC)
|
||||
self.error_message = ""
|
||||
|
||||
def mark_awaiting_cover(self) -> None:
|
||||
"""标记为等待确认封面(running → awaiting_cover)。
|
||||
|
||||
渲染与上传已完成、封面候选已就绪,等待用户在 Step5 选封面并点「完成」。
|
||||
此时不创建 GeneratedVideo 成品记录;progress 置 100,completed_at 暂不设置
|
||||
(finalize 完成入库时才真正结束任务)。
|
||||
|
||||
Raises:
|
||||
ValueError: 当前状态不允许转换到 awaiting_cover
|
||||
"""
|
||||
self.transition_to(GenerationTaskStatus.AWAITING_COVER)
|
||||
self.progress = 100.0
|
||||
self.error_message = ""
|
||||
|
||||
def mark_completed(self, result_count: int = 1) -> None:
|
||||
"""标记为已完成(running → completed)。
|
||||
"""标记为已完成(awaiting_cover → completed,由 finalize 调用)。
|
||||
|
||||
设置 completed_at、progress=100.0、result_count,清除 error_message。
|
||||
|
||||
Args:
|
||||
result_count: 生成的视频数量,默认为 1
|
||||
result_count: 入库的视频数量,默认为 1
|
||||
|
||||
Raises:
|
||||
ValueError: 当前状态不允许转换到 completed
|
||||
|
||||
@@ -245,16 +245,39 @@ def pick_narrative_assets(
|
||||
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
|
||||
)
|
||||
|
||||
# #2035:把文案标签与聚合的素材级 ai_tags 透传给 smart_select_assets,
|
||||
# 让 smart 评分维度(ai_semantic)在叙事模式内部兜底/补位时同样生效。
|
||||
wanted_norm = _normalize_tags(script_tags)
|
||||
asset_ai_tags: dict[str, dict] = {}
|
||||
if clip_ai_tags_by_asset:
|
||||
for aid, clips in clip_ai_tags_by_asset.items():
|
||||
agg: dict = {"scene": [], "objects": [], "action": []}
|
||||
for clip_tags in clips or []:
|
||||
if not isinstance(clip_tags, dict):
|
||||
continue
|
||||
for key in ("scene", "objects", "action"):
|
||||
for v in clip_tags.get(key) or []:
|
||||
v = str(v).strip()
|
||||
if v and v not in agg[key]:
|
||||
agg[key].append(v)
|
||||
asset_ai_tags[aid] = agg
|
||||
|
||||
smart_kwargs = dict(
|
||||
kind="video",
|
||||
rng=rng,
|
||||
script_tags=wanted_norm if wanted_norm else None,
|
||||
ai_tags_by_asset=asset_ai_tags if asset_ai_tags else None,
|
||||
)
|
||||
|
||||
need = limit if (limit is not None and limit > 0) else None
|
||||
|
||||
if not matched:
|
||||
# 完全降级:与改造前随机混剪同一逻辑
|
||||
return [r.asset for r in smart_select_assets(assets, kind="video", limit=need, rng=rng)]
|
||||
return [r.asset for r in smart_select_assets(assets, limit=need, **smart_kwargs)]
|
||||
|
||||
picked = [r.asset for r in smart_select_assets(matched, kind="video", limit=need, rng=rng)]
|
||||
picked = [r.asset for r in smart_select_assets(matched, limit=need, **smart_kwargs)]
|
||||
if need is not None and len(picked) < need and unmatched:
|
||||
rest_need = need - len(picked)
|
||||
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", limit=rest_need, rng=rng))
|
||||
picked.extend(r.asset for r in smart_select_assets(unmatched, limit=rest_need, **smart_kwargs))
|
||||
elif need is None:
|
||||
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", rng=rng))
|
||||
picked.extend(r.asset for r in smart_select_assets(unmatched, **smart_kwargs))
|
||||
return picked
|
||||
|
||||
@@ -40,6 +40,14 @@ def _get_enum_value(obj: Any, attr: str) -> str:
|
||||
return val.value if hasattr(val, "value") else str(val)
|
||||
|
||||
|
||||
def normalize_tag(tag) -> str:
|
||||
"""标准化标签:去两端空白、小写;非 str 转 str。返回空串表示应丢弃。"""
|
||||
if tag is None:
|
||||
return ""
|
||||
s = str(tag).strip().lower()
|
||||
return s
|
||||
|
||||
|
||||
def _duration_bucket(duration: float | None) -> str:
|
||||
"""将素材时长分为 3 档:short(<10s) / medium(10-30s) / long(>30s)。"""
|
||||
if duration is None or duration <= 0:
|
||||
@@ -54,14 +62,24 @@ def _duration_bucket(duration: float | None) -> str:
|
||||
def score_asset(
|
||||
asset: Any,
|
||||
now: datetime | None = None,
|
||||
script_tags: set | None = None,
|
||||
ai_tags_by_asset: dict | None = None,
|
||||
expected_categories: set[str] | None = None,
|
||||
) -> tuple[float, dict[str, float]]:
|
||||
"""为单个素材计算综合得分(0-100)。
|
||||
|
||||
维度权重:
|
||||
- quality_score (40%):素材质量分(0-100),无质量分按 50 计
|
||||
- duration_fitness (30%):时长适配度,5-30s 为最优区间
|
||||
- recency (20%):新鲜度,30 天内衰减
|
||||
- unused_bonus (10%):未被使用过的素材加分
|
||||
维度权重(#2035 加入 AI 语义匹配 + 素材分类维度):
|
||||
- quality_score (28%):素材质量分(0-100),无质量分按 50 计
|
||||
- duration_fitness (22%):时长适配度,5-30s 为最优区间
|
||||
- recency (12%):新鲜度,30 天内衰减
|
||||
- unused (8%):未被/少被使用过的素材加分
|
||||
- ai_semantic (20%):AI 标签(scene/objects/action)与文案标签重合度;无数据给 50 中性分
|
||||
- category_match (10%):FFmpeg 自动分类结果(scenic/product/person/animal/food/tech/sport/music)
|
||||
与期望类别重合度;无分类或无期望类别时给 60 中性分
|
||||
|
||||
Args:
|
||||
script_tags: 标准化后的文案标签集合,用于 AI 语义匹配维度打分。
|
||||
ai_tags_by_asset: asset_id → ai_tags dict 映射,ai_tags 含 scene/objects/action 字段。
|
||||
|
||||
Returns:
|
||||
(total_score, breakdown_dict)
|
||||
@@ -73,7 +91,7 @@ def score_asset(
|
||||
|
||||
# 1. 质量分 (0-100) → 权重 40%
|
||||
raw_quality = asset.quality_score if asset.quality_score is not None else 50.0
|
||||
quality_component = raw_quality * 0.4
|
||||
quality_component = raw_quality * 0.28
|
||||
breakdown["quality"] = round(quality_component, 2)
|
||||
|
||||
# 2. 时长适配度 (0-100) → 权重 30%
|
||||
@@ -90,7 +108,7 @@ def score_asset(
|
||||
# >30s: 指数衰减,60s 时约 50 分
|
||||
duration_fitness = 100.0 * math.exp(-0.02 * (duration - 30))
|
||||
duration_fitness = max(duration_fitness, 10.0)
|
||||
duration_component = duration_fitness * 0.3
|
||||
duration_component = duration_fitness * 0.22
|
||||
breakdown["duration"] = round(duration_component, 2)
|
||||
|
||||
# 3. 新鲜度 (0-100) → 权重 20%
|
||||
@@ -103,7 +121,7 @@ def score_asset(
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
age_days = max(0, (now - created_at).total_seconds() / 86400)
|
||||
recency = 100.0 * math.exp(-0.05 * age_days) # ~14天半衰期
|
||||
recency_component = recency * 0.2
|
||||
recency_component = recency * 0.12
|
||||
breakdown["recency"] = round(recency_component, 2)
|
||||
|
||||
# 4. 未使用偏好 (0-100) → 权重 10%
|
||||
@@ -118,10 +136,55 @@ def score_asset(
|
||||
unused_score = 70.0
|
||||
else:
|
||||
unused_score = 30.0
|
||||
unused_component = unused_score * 0.1
|
||||
unused_component = unused_score * 0.08
|
||||
breakdown["unused"] = round(unused_component, 2)
|
||||
|
||||
total = quality_component + duration_component + recency_component + unused_component
|
||||
# 5. AI 语义匹配 (0-100) → 权重 20%
|
||||
if script_tags and ai_tags_by_asset:
|
||||
asset_ai = ai_tags_by_asset.get(getattr(asset, "id", "")) or {}
|
||||
ai_terms: set = set()
|
||||
for key in ("scene", "objects", "action"):
|
||||
vals = asset_ai.get(key) or []
|
||||
if isinstance(vals, list):
|
||||
for v in vals:
|
||||
norm = normalize_tag(v)
|
||||
if norm:
|
||||
ai_terms.add(norm)
|
||||
if ai_terms:
|
||||
norm_script = {normalize_tag(t) for t in script_tags if normalize_tag(t)}
|
||||
overlap = ai_terms & norm_script
|
||||
union = ai_terms | norm_script
|
||||
ratio = (len(overlap) / len(union)) if union else 0.0
|
||||
if overlap:
|
||||
ai_score = 50.0 + 50.0 * ratio
|
||||
else:
|
||||
ai_score = 20.0
|
||||
else:
|
||||
ai_score = 50.0
|
||||
else:
|
||||
ai_score = 50.0
|
||||
ai_component = ai_score * 0.20
|
||||
breakdown["ai_semantic"] = round(ai_component, 2)
|
||||
|
||||
# 6. 分类匹配 (0-100) → 权重 10%
|
||||
asset_meta = getattr(asset, "metadata", None) or {}
|
||||
asset_cat = (asset_meta.get("classification") or "").strip().lower()
|
||||
if expected_categories and asset_cat:
|
||||
norm_expected = {c.strip().lower() for c in expected_categories if c and c.strip()}
|
||||
if asset_cat == "other":
|
||||
cat_score = 50.0 # other 类不给额外加分也不扣分
|
||||
elif asset_cat in norm_expected:
|
||||
cat_score = 100.0
|
||||
else:
|
||||
cat_score = 30.0 # 分类明确但不匹配,略扣分
|
||||
elif expected_categories:
|
||||
cat_score = 60.0 # 无分类结果,中性
|
||||
else:
|
||||
cat_score = 60.0 # 无期望类别,中性
|
||||
cat_component = cat_score * 0.10
|
||||
breakdown["category_match"] = round(cat_component, 2)
|
||||
|
||||
total = quality_component + duration_component + recency_component + unused_component + ai_component + cat_component
|
||||
return round(total, 2), breakdown
|
||||
|
||||
|
||||
@@ -132,6 +195,9 @@ def smart_select_assets(
|
||||
kind: str | None = None,
|
||||
now: datetime | None = None,
|
||||
rng: random.Random | None = None,
|
||||
script_tags: set | None = None,
|
||||
ai_tags_by_asset: dict | None = None,
|
||||
expected_categories: set[str] | None = None,
|
||||
) -> list[SmartMatchResult]:
|
||||
"""从素材列表中智能选取素材。
|
||||
|
||||
@@ -159,7 +225,13 @@ def smart_select_assets(
|
||||
# Step 3: 评分
|
||||
scored: list[SmartMatchResult] = []
|
||||
for a in ready_assets:
|
||||
total, breakdown = score_asset(a, now=now)
|
||||
total, breakdown = score_asset(
|
||||
a,
|
||||
now=now,
|
||||
script_tags=script_tags,
|
||||
ai_tags_by_asset=ai_tags_by_asset,
|
||||
expected_categories=expected_categories,
|
||||
)
|
||||
scored.append(SmartMatchResult(asset=a, score=total, breakdown=breakdown))
|
||||
|
||||
# Step 4: 按「得分 + 随机噪声」降序排序
|
||||
|
||||
@@ -39,6 +39,47 @@ class DoubaoClient:
|
||||
self.max_retries: int = settings.doubao_max_retries
|
||||
self.vision_model: str = settings.doubao_vision_model
|
||||
|
||||
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
|
||||
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
|
||||
if not self.is_available or not text or not text.strip():
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/embeddings"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": getattr(self, "embedding_model", None) or "doubao-embedding-large-text-240915",
|
||||
"input": text.strip(),
|
||||
"encoding_format": "float",
|
||||
}
|
||||
|
||||
req_timeout = timeout or self.timeout
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = httpx.post(url, headers=headers, json=payload, timeout=req_timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
emb_list = data.get("data") or []
|
||||
if emb_list and isinstance(emb_list, list):
|
||||
vec = emb_list[0].get("embedding")
|
||||
if isinstance(vec, list) and vec:
|
||||
return [float(x) for x in vec]
|
||||
logger.warning("embedding 返回结构异常: %s", str(data)[:200])
|
||||
return None
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包 Embedding 调用失败,%.1fs 后重试 (%d/%d): %s", wait, attempt + 1, self.max_retries + 1, e
|
||||
)
|
||||
time.sleep(wait)
|
||||
logger.error("豆包 Embedding 调用最终失败: %s", last_error)
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否可用(配置了 API Key)."""
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Celery 队列定义与路由配置(API / Worker 共享)。
|
||||
|
||||
#1714 队列隔离:用户等待的视频生成任务路由到高优先级 `generation` 队列,
|
||||
由专用 worker 进程独占消费;素材入库/转码等后台批量任务路由到 `transcode`
|
||||
队列;其余杂项任务走默认 `celery` 队列。转码队列积压时,视频生成任务
|
||||
仍能被 generation worker 立即领取执行,不会排队。
|
||||
#1714 + #2073 队列分流:用户同步等待的实时任务路由到 `generation` 高优队列,
|
||||
由专用 generation worker 独占消费;素材入库/转码/AI 分析/查重等后台批量任务路由
|
||||
到 `transcode` 队列;beat 定时清理等轻量维护任务走默认 `celery` 队列。
|
||||
transcode / celery 队列积压时,generation 队列仍能被立即领取,不阻塞用户实时链路。
|
||||
|
||||
队列说明:
|
||||
- generation: 用户提交的视频生成/预览渲染(延迟敏感,资源消耗大)
|
||||
- transcode: 素材入库(HEVC 转码)、AI 分类、素材查重(批量、可排队)
|
||||
- celery(默认): 配音、语音、下载缩略图、定时清理等杂项
|
||||
- generation: 用户同步等待的实时任务(视频生成、TTS、音色克隆、lipsync、AI 数字人、人声/背景提取)
|
||||
- transcode: 后台批量/异步任务(素材入库转码、AI 分类打标、质量评分、原子切片、查重、批量下载/缩略图)
|
||||
- celery: beat 定时巡检/清理等轻量维护任务(极短、低优、不占业务槽)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,8 +20,9 @@ QUEUE_GENERATION = "generation"
|
||||
QUEUE_TRANSCODE = "transcode"
|
||||
QUEUE_DEFAULT = "celery"
|
||||
|
||||
# Worker 消费的队列列表(顺序即优先级:高优队列排在前面)
|
||||
WORKER_QUEUES = (QUEUE_GENERATION, QUEUE_TRANSCODE, QUEUE_DEFAULT)
|
||||
# 三个消费组各自消费的队列列表(顺序即优先级:高优队列排在前面)
|
||||
WORKER_QUEUES_GENERATION = (QUEUE_GENERATION,)
|
||||
WORKER_QUEUES_TRANSCODE = (QUEUE_TRANSCODE, QUEUE_DEFAULT)
|
||||
|
||||
# 队列声明:持久化队列,broker 重启不丢消息
|
||||
task_queues = (
|
||||
@@ -31,15 +32,45 @@ task_queues = (
|
||||
)
|
||||
|
||||
# ── 任务路由表:task name → 队列 ──
|
||||
# 键支持 celery 标准通配符。
|
||||
# 键支持 celery 标准通配符。所有生产端(API send_task / worker 内 send_task)
|
||||
# 未显式指定 queue 时按此表路由;漏配会走默认队列 celery,被 transcode worker 消费。
|
||||
# 新增实时任务务必在此表显式路由到 generation,避免落到后台队列排队。
|
||||
task_routes = {
|
||||
# 高优先级:用户等待的视频生成
|
||||
# ── 高优先级:用户同步等待的实时链路 ──
|
||||
# 视频生成(主链路)
|
||||
"worker.generate_video": {"queue": QUEUE_GENERATION},
|
||||
# 后台批量:素材入库/转码 + AI 分类 + 素材查重,积压不影响生成
|
||||
# TTS 合成 / 片段合成(配音页、视频生成配乐/TTS 链路)
|
||||
"worker.process_tts_synthesis": {"queue": QUEUE_GENERATION},
|
||||
"worker.process_tts_segment_synthesis": {"queue": QUEUE_GENERATION},
|
||||
# 音色克隆(用户主动上传样本等待克隆完成)
|
||||
"worker.process_voice_clone": {"queue": QUEUE_GENERATION},
|
||||
# 人声/背景提取(音色克隆前置步骤,用户同步等待)
|
||||
"worker.extract_voice": {"queue": QUEUE_GENERATION},
|
||||
"worker.extract_background": {"queue": QUEUE_GENERATION},
|
||||
# AI 数字人渲染(用户主动触发,等待成片)
|
||||
"ai_avatar_render.execute": {"queue": QUEUE_GENERATION},
|
||||
# GPU MuseTalk 口型同步(用户等成片,链路子任务全部走 generation 避免跨队列阻塞)
|
||||
"lipsync_gpu_process_async": {"queue": QUEUE_GENERATION},
|
||||
"lipsync_tts.synthesize_and_submit": {"queue": QUEUE_GENERATION},
|
||||
"lipsync_tts.poll_mediakit_status": {"queue": QUEUE_GENERATION},
|
||||
"lipsync_tts.persist_output_video": {"queue": QUEUE_GENERATION},
|
||||
# ── 后台批量:素材入库/转码 + AI 分析/打标 + 查重,积压不影响生成 ──
|
||||
"worker.ingest_asset": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.classify_asset": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.calculate_asset_quality": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.generate_atom_clips": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.tag_atom_clip": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.backfill_atom_clip_tags": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.process_duplication_check": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.check_duplicate": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.batch_download_videos": {"queue": QUEUE_TRANSCODE},
|
||||
"worker.batch_generate_thumbnails": {"queue": QUEUE_TRANSCODE},
|
||||
# ── beat 定时清理/巡检任务走默认 celery 队列(由 transcode worker 消费)──
|
||||
# 未在此表显式列出的 cleanup 任务会落到默认队列 celery,不占 generation 槽位。
|
||||
"worker.cleanup_stale_pending_tasks": {"queue": QUEUE_DEFAULT},
|
||||
"worker.cleanup_stale_running_tasks": {"queue": QUEUE_DEFAULT},
|
||||
"worker.cleanup_stale_ingest_jobs": {"queue": QUEUE_DEFAULT},
|
||||
"worker.cleanup_stale_voice_clones": {"queue": QUEUE_DEFAULT},
|
||||
}
|
||||
|
||||
# 生成任务的预取数:渲染是长任务,预取 1 避免任务被某个 worker 占住不调度
|
||||
@@ -47,11 +78,10 @@ GENERATION_WORKER_PREFETCH_MULTIPLIER = 1
|
||||
|
||||
|
||||
def apply_queue_settings(app) -> None:
|
||||
"""把队列隔离配置应用到 Celery app(API 生产端与 Worker 消费端都要调用)。
|
||||
"""把队列分流配置应用到 Celery app(API 生产端与 Worker 消费端都要调用)。
|
||||
|
||||
配置 task_queues / task_routes / task_default_queue。生产端靠 task_routes
|
||||
把消息投递到对应队列;消费端靠 task_queues 声明自己消费哪些队列
|
||||
(实际消费集由启动参数 -Q 控制)。
|
||||
把消息投递到对应队列;消费端靠启动参数 -Q 控制自己消费哪些队列(entrypoint)。
|
||||
"""
|
||||
app.conf.task_queues = task_queues
|
||||
app.conf.task_routes = task_routes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user