Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff1d878c62 | |||
| efb7fa5729 | |||
| 5e1520230f | |||
| 96bcec5fdd | |||
| dfc5e5a5b6 | |||
| 3be06c5763 | |||
| 02199d80ee | |||
| ca6803e1a5 | |||
| d449496f90 | |||
| a04e363d1b | |||
| da59a6c9a6 | |||
| 11c554e43a | |||
| f9f3e6bfb9 | |||
| 3904a8f3b5 | |||
| ba3e97c986 | |||
| 3106496c12 | |||
| f79f75b863 | |||
| b7a439d319 | |||
| 4d7c80ae07 | |||
| 26c0140d79 | |||
| c507f76c14 | |||
| 8a5cfe831e | |||
| d7d3f3184b | |||
| ec9240b52a | |||
| d0e5ef1753 | |||
| 6e8199581d | |||
| 1e23a3f094 | |||
| 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 | |||
| c9a8691b77 |
+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
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
build_rate_limit_detail,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
@@ -302,26 +300,17 @@ def create_preview_generation_task(
|
||||
count,
|
||||
)
|
||||
|
||||
# 预检查队列限流(按变体总数计)
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + count > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(
|
||||
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
|
||||
)
|
||||
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
# 预检查队列限流(按变体总数计)——仅保留全局硬上限,用户上限改为软 warning 在 safe_enqueue 内处理(#2098)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
|
||||
) from e
|
||||
detail=build_rate_limit_detail(
|
||||
GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT),
|
||||
generation_task_repository,
|
||||
scope="global",
|
||||
),
|
||||
)
|
||||
|
||||
# 确定视频比例:优先前端传入,否则从模板 mode 推断
|
||||
video_ratio = request.video_ratio or ""
|
||||
@@ -589,9 +578,6 @@ def create_preview_generation_task(
|
||||
if not enqueued:
|
||||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
||||
except UserPendingLimitExceeded as e:
|
||||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
||||
rate_limit_exc = rate_limit_exc or e
|
||||
except GlobalQueueFull as e:
|
||||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
||||
rate_limit_exc = rate_limit_exc or e
|
||||
@@ -603,11 +589,6 @@ def create_preview_generation_task(
|
||||
|
||||
# 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败")
|
||||
if all(r.status == "failed" for r in responses) and rate_limit_exc is not None:
|
||||
if isinstance(rate_limit_exc, UserPendingLimitExceeded):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"),
|
||||
@@ -650,11 +631,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)
|
||||
|
||||
@@ -7,7 +7,6 @@ from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
build_rate_limit_detail,
|
||||
@@ -31,6 +30,8 @@ from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
FinalizeGenerationRequest,
|
||||
FinalizeGenerationResponse,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
@@ -44,6 +45,124 @@ 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 +251,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 +276,45 @@ 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 +324,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 +335,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 +565,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 +580,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(
|
||||
@@ -510,21 +683,13 @@ def create_generation_task(
|
||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||
batch_id = uuid.uuid4().hex if count > 1 else ""
|
||||
|
||||
# 预检查:批量提交前先看会不会超限,避免建一半才拒
|
||||
# 预检查(Bug B #2098):只保留全局 503 保护,用户级不再硬拒 429;
|
||||
# 超额任务直接入队等待 worker 自然消费,前端展示排队位置而非阻止提交。
|
||||
# USER_PENDING_LIMIT 作为软上限(safe_enqueue 兜底),提高到 20 支持批量提交。
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + count > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(
|
||||
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
|
||||
)
|
||||
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
@@ -839,14 +1004,10 @@ def create_generation_task(
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
# 兜底:如果预检查后又并发提交了,在这里也拦住
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
) from _e
|
||||
break
|
||||
# Bug B #2098: 用户级限流已改为软限制,此分支理论上不再触发;
|
||||
# 极端并发兜底仍入队(safe_enqueue 内部会打 warning 日志),不 429 拒绝
|
||||
logger.warning("[生成任务] 用户 pending 超软限制,仍允许入队: task_id=%s", task.id)
|
||||
created_tasks.append(task)
|
||||
except GlobalQueueFull as _e:
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
@@ -891,8 +1052,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 +1074,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 +1105,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,
|
||||
)
|
||||
@@ -979,10 +1158,8 @@ def confirm_generation(
|
||||
):
|
||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
) from None
|
||||
# Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行
|
||||
logger.warning("[任务] 用户 pending 超软限制,任务已入队")
|
||||
except GlobalQueueFull as _e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
@@ -995,6 +1172,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 +1280,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)
|
||||
|
||||
|
||||
@@ -1062,22 +1338,8 @@ def retry_generation_task(
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
# 预检查:创建前判断,>= 上限就拒绝
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
# 预检查(Bug B #2098):只保留全局 503,用户级不再硬拒
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(
|
||||
UserPendingLimitExceeded(
|
||||
user_id=user_id,
|
||||
pending_count=user_pending,
|
||||
limit=USER_PENDING_LIMIT,
|
||||
),
|
||||
generation_task_repository,
|
||||
scope="user",
|
||||
),
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
@@ -1120,10 +1382,8 @@ def retry_generation_task(
|
||||
):
|
||||
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
) from None
|
||||
# Bug B #2098: 用户级限流已软处理,理论上不再触发;作为防御仍放行
|
||||
logger.warning("[任务] 用户 pending 超软限制,任务已入队")
|
||||
except GlobalQueueFull as _e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -290,6 +290,43 @@ def _submit_ingest_job(
|
||||
return job
|
||||
|
||||
|
||||
def _find_active_ingest_job(ingest_job_repository: Any, asset_id: str) -> Any | None:
|
||||
"""查询 asset 上是否存在"仍在跑或已成功"的 ingest job(FAILED 视为不存在,需重提)。"""
|
||||
if not asset_id:
|
||||
return None
|
||||
find = getattr(ingest_job_repository, "find_by_asset_id", None)
|
||||
if not callable(find):
|
||||
# 旧仓储未实现 find_by_asset_id,无法判断 → 保守返回 None(走正常流程,
|
||||
# _submit_ingest_job 自身有数据库唯一约束/幂等兜底,不会重复建 job)
|
||||
return None
|
||||
try:
|
||||
return find(asset_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[upload] find_by_asset_id 查询失败,按无 job 处理: asset=%s", asset_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _is_true_duplicate(existing_asset: Asset, ingest_job_repository: Any) -> tuple[bool, Any | None]:
|
||||
"""判断 `existing_asset` 是真重复(应短路返 duplicated)还是占位(应补提 ingest)。
|
||||
|
||||
返回 (is_duplicate, existing_job):
|
||||
- READY 素材:真重复,job 可能为 None(已就绪不需要 job_id)
|
||||
- PROCESSING/UPLOADING 且已有在跑/已完成 ingest job:幂等重试,真重复,job 返回给前端轮询
|
||||
- PROCESSING/UPLOADING 且无 job:prepare 建的占位 / 之前 ingest 创建失败 → 非重复,需补提 ingest
|
||||
- ERROR/DELETED:非重复(允许重新上传覆盖)
|
||||
"""
|
||||
status = getattr(existing_asset, "status", None)
|
||||
if status == AssetStatus.READY:
|
||||
return True, None
|
||||
if status in (AssetStatus.PROCESSING, AssetStatus.UPLOADING):
|
||||
job = _find_active_ingest_job(ingest_job_repository, existing_asset.id)
|
||||
if job is not None:
|
||||
return True, job
|
||||
return False, None
|
||||
# ERROR / DELETED / 其它:走正常流程重新 ingest
|
||||
return False, None
|
||||
|
||||
|
||||
@router.post("/direct/prepare", response_model=DirectUploadPrepareResponse)
|
||||
async def prepare_direct_upload(
|
||||
request: DirectUploadPrepareRequest,
|
||||
@@ -444,12 +481,25 @@ async def complete_direct_upload(
|
||||
file_size=request.file_size,
|
||||
)
|
||||
if existing is not None:
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
url=storage_service.get_url(existing.storage_key),
|
||||
is_dup, existing_job = _is_true_duplicate(existing, ingest_job_repository)
|
||||
if is_dup:
|
||||
logger.info(
|
||||
"[upload] complete 幂等命中真重复: asset=%s status=%s job=%s",
|
||||
existing.id,
|
||||
getattr(existing, "status", None),
|
||||
getattr(existing_job, "id", None),
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id=getattr(existing_job, "id", "") or "",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
url=storage_service.get_url(existing.storage_key),
|
||||
)
|
||||
logger.info(
|
||||
"[upload] complete 命中占位 asset(status=%s 无 ingest job),继续补提 ingest: asset=%s",
|
||||
getattr(existing, "status", None),
|
||||
existing.id,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -479,14 +529,24 @@ async def complete_direct_upload(
|
||||
)
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
asset_id=pending_asset.id,
|
||||
)
|
||||
# 幂等保护:补提占位场景下可能已有 job(极端竞态),先查一次
|
||||
existing_job = _find_active_ingest_job(ingest_job_repository, pending_asset.id)
|
||||
if existing_job is not None:
|
||||
logger.info(
|
||||
"[upload] complete 补提时发现 job 已存在(竞态/并发重试),复用: asset=%s job=%s",
|
||||
pending_asset.id,
|
||||
existing_job.id,
|
||||
)
|
||||
job = existing_job
|
||||
else:
|
||||
job = _submit_ingest_job(
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
asset_id=pending_asset.id,
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_id=job.id,
|
||||
@@ -533,12 +593,27 @@ async def upload_asset(
|
||||
file_size=0,
|
||||
)
|
||||
if existing is not None:
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
url="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
is_dup, existing_job = _is_true_duplicate(existing, ingest_job_repository)
|
||||
if is_dup:
|
||||
logger.info(
|
||||
"[upload] multipart 幂等命中真重复: asset=%s status=%s job=%s",
|
||||
existing.id,
|
||||
getattr(existing, "status", None),
|
||||
getattr(existing_job, "id", None),
|
||||
)
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id=getattr(existing_job, "id", "") or "",
|
||||
url=storage_service.get_url(existing.storage_key)
|
||||
if getattr(existing, "status", None) == AssetStatus.READY
|
||||
else "",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
logger.info(
|
||||
"[upload] multipart 命中占位 asset(status=%s 无 ingest job),继续补提 ingest: asset=%s",
|
||||
getattr(existing, "status", None),
|
||||
existing.id,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
@@ -574,14 +649,23 @@ async def upload_asset(
|
||||
)
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=file_hash,
|
||||
asset_id=pending_asset.id,
|
||||
)
|
||||
existing_job = _find_active_ingest_job(ingest_job_repository, pending_asset.id)
|
||||
if existing_job is not None:
|
||||
logger.info(
|
||||
"[upload] multipart 补提时发现 job 已存在(竞态/并发重试),复用: asset=%s job=%s",
|
||||
pending_asset.id,
|
||||
existing_job.id,
|
||||
)
|
||||
job = existing_job
|
||||
else:
|
||||
job = _submit_ingest_job(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=file_hash,
|
||||
asset_id=pending_asset.id,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
storage_key=storage_key,
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.core.celery_app import celery_app
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
|
||||
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
|
||||
USER_PENDING_LIMIT = 20 # 单用户 pending 上限(#2098: 从 3 提到 20,支持批量任务自动排队)
|
||||
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
|
||||
WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值)
|
||||
|
||||
@@ -154,19 +154,18 @@ def check_queue_limits(
|
||||
user_pending_limit: int = USER_PENDING_LIMIT,
|
||||
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
|
||||
) -> None:
|
||||
"""检查队列限流(预检查用,任务创建前调用),超限抛对应异常。
|
||||
"""检查队列限流(预检查用,任务创建前调用)。
|
||||
|
||||
边界语义:>= 上限即拒绝(达到上限就不能再加新任务)。
|
||||
#2098 语义变更:用户级限流改为软提示,不再抛异常拒绝;仅全局硬上限抛 GlobalQueueFull。
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
user_id: 用户 ID(保留参数,当前不做用户级硬拒)
|
||||
generation_task_repository: 任务仓储
|
||||
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
|
||||
user_pending_limit: 单用户 pending 上限(保留,当前未硬拒)
|
||||
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
|
||||
|
||||
Raises:
|
||||
GlobalQueueFull: 全局超限时抛出(优先级更高,先查全局)
|
||||
UserPendingLimitExceeded: 用户超限时抛出
|
||||
GlobalQueueFull: 全局超限时抛出
|
||||
"""
|
||||
# 先查全局(系统级保护优先级更高)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
@@ -179,17 +178,9 @@ def check_queue_limits(
|
||||
)
|
||||
raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
|
||||
# 再查用户级
|
||||
if user_id:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
if user_pending >= user_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 用户 pending 任务数超限: user_id=%s, count=%d/%d",
|
||||
user_id,
|
||||
user_pending,
|
||||
user_pending_limit,
|
||||
)
|
||||
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
|
||||
# #2098: 用户级限流改为软提示,不在预检查阶段拒绝(超额任务仍入队排队)。
|
||||
# 真正的系统保护由全局 GLOBAL_PENDING_LIMIT 硬上限承担。
|
||||
# UserPendingLimitExceeded 保留以兼容历史 import/except,但预检查与 safe_enqueue 均不再 raise。
|
||||
|
||||
|
||||
def _mark_task_failed_safely(
|
||||
@@ -246,7 +237,6 @@ def safe_enqueue_generation_task(
|
||||
|
||||
Raises:
|
||||
GlobalQueueFull: 全局 pending 超限时抛出,任务会被标记为 failed
|
||||
UserPendingLimitExceeded: 用户 pending 超限时抛出,任务会被标记为 failed
|
||||
"""
|
||||
# ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ──
|
||||
|
||||
@@ -263,19 +253,18 @@ def safe_enqueue_generation_task(
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||
raise exc
|
||||
|
||||
# 用户级限流检查(传了 user_id 才做)
|
||||
# Bug B #2098: 用户级限流改为软提示,不再硬拒;所有任务都入队等待 worker 自然消费。
|
||||
# user_pending_limit 作为兜底阈值保留(默认 20),达到时打 warning 日志但仍入队,
|
||||
# 避免极端情况下恶意用户无限堆积任务。真正的系统保护由全局 GLOBAL_PENDING_LIMIT 承担。
|
||||
if user_id:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
if user_pending > user_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d",
|
||||
"[队列限流] 用户 pending 任务数超过软上限(入队): user_id=%s, count=%d/%d, 仍允许入队排队",
|
||||
user_id,
|
||||
user_pending,
|
||||
user_pending_limit,
|
||||
)
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||
raise exc
|
||||
|
||||
# ── 发送 Celery 任务 ──
|
||||
try:
|
||||
@@ -317,16 +306,18 @@ def safe_enqueue_generation_task(
|
||||
user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0
|
||||
|
||||
global_over = global_after > global_pending_limit
|
||||
user_over = bool(user_id and user_after > user_pending_limit)
|
||||
|
||||
if global_over or user_over:
|
||||
if global_over:
|
||||
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
|
||||
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||
else:
|
||||
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
|
||||
# Bug B #2098: 用户超限仅日志警告,不回滚任务
|
||||
if user_id and user_after > user_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 用户 pending 超软上限(入队后): user_id=%s, count=%d/%d",
|
||||
user_id,
|
||||
user_after,
|
||||
user_pending_limit,
|
||||
)
|
||||
|
||||
if global_over:
|
||||
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
|
||||
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||
logger.warning(
|
||||
"[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed",
|
||||
reason,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
/** 任务状态 */
|
||||
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
|
||||
export type TaskStatus =
|
||||
"pending" | "waiting" | "running" | "awaiting_cover" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 任务类型 */
|
||||
export type TaskType = "ingest" | "generation" | string
|
||||
|
||||
@@ -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,
|
||||
@@ -238,44 +239,48 @@ const GeneratePage: React.FC = () => {
|
||||
voiceModePerVideo,
|
||||
variantCoverUrls: previewCovers,
|
||||
selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
|
||||
onGenerationSuccess: () => {
|
||||
onGenerationSuccess: (status?: "completed" | "awaiting_cover") => {
|
||||
setPreviewTaskId(null)
|
||||
setStoredSourceEditPlanId(null)
|
||||
// #2088:渲染完成后自动跳到封面选择页(step 5),不再等用户手动点「下一步」
|
||||
// awaiting_cover 和 completed 都走封面页(completed 是旧 worker 或 finalize 后状态,仍支持选封面)
|
||||
if (status === "awaiting_cover" || status === "completed" || !status) {
|
||||
setCurrentStep(5)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 数量弹窗确认 ── */
|
||||
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 +403,7 @@ const GeneratePage: React.FC = () => {
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
generated,
|
||||
onOpenCountModal: () => setCountModalOpen(true),
|
||||
onBeforeEnterStep3: ensureArraysAligned,
|
||||
onOpenStep1Modal: () => {
|
||||
if (editMode === "random") {
|
||||
setVoiceModalOpen(true)
|
||||
@@ -411,7 +416,7 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 最终成片(单视频) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ── Step5 完成:调用 confirm 入库 + 跳转 ── */
|
||||
/* ── Step5 完成:先 confirm(同步标题/封面到任务)再 finalize(正式入库成品库) ── */
|
||||
const handleFinish = useCallback(async () => {
|
||||
if (finishing) return
|
||||
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
|
||||
@@ -421,7 +426,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 +437,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 +495,7 @@ const GeneratePage: React.FC = () => {
|
||||
previewTitles,
|
||||
titleSettings.title,
|
||||
coverSettings,
|
||||
currentTaskId,
|
||||
navigate,
|
||||
])
|
||||
|
||||
@@ -538,7 +554,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 +578,8 @@ const GeneratePage: React.FC = () => {
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
|
||||
currentTaskId={currentTaskId}
|
||||
onRetry={handleRetryGenerate}
|
||||
onRetryBatchTask={handleRetryBatchTask}
|
||||
onDismissError={handleDismissError}
|
||||
@@ -576,6 +594,9 @@ const GeneratePage: React.FC = () => {
|
||||
previewCovers={previewCovers}
|
||||
onPreviewCoversChange={setPreviewCovers}
|
||||
selectedVariantIds={selectedVariantIds}
|
||||
selectedCoverTemplate={selectedTemplate}
|
||||
onSelectedCoverTemplateChange={setSelectedTemplate}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
/>
|
||||
|
||||
{/* ════ 步骤4(单视频):成片播放器 ════ */}
|
||||
@@ -663,14 +684,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;
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ export interface UseGenerateVideoProps {
|
||||
enabled: boolean
|
||||
music_id?: string
|
||||
}
|
||||
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
|
||||
onGenerationSuccess?: () => void
|
||||
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态);status=awaiting_cover 表示需进封面选择 */
|
||||
onGenerationSuccess?: (status?: "completed" | "awaiting_cover") => void
|
||||
/* ── 批量生成(#1677)── */
|
||||
/** 生成数量(1=单条旧逻辑,>1=批量) */
|
||||
previewCount?: number
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useCallback, useState } from "react"
|
||||
import { useRef, useCallback, useState, useEffect } from "react"
|
||||
import { message } from "antd"
|
||||
import axios from "axios"
|
||||
import { getGenerationTask, retryTask as retryGenerationTaskApi } from "@/api/tasks/tasks"
|
||||
@@ -10,7 +10,7 @@ export interface BatchTaskState {
|
||||
taskId: string
|
||||
/** 变体序号(0-based,与标题/封面数组对齐) */
|
||||
variantIndex: number
|
||||
status: "running" | "completed" | "failed"
|
||||
status: "running" | "completed" | "awaiting_cover" | "failed"
|
||||
progress: number
|
||||
error: string | null
|
||||
/** 完成后的成片视频 */
|
||||
@@ -19,7 +19,7 @@ export interface BatchTaskState {
|
||||
|
||||
interface UseGenerationPollingOptions {
|
||||
onProgress: (progress: number) => void
|
||||
onComplete: (videos: unknown[]) => void
|
||||
onComplete: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
|
||||
onFailed: (errorMsg: string) => void
|
||||
/** 批量:单任务状态变化(第5步逐卡片展示) */
|
||||
onBatchTaskUpdate?: (taskId: string, patch: Partial<BatchTaskState>) => void
|
||||
@@ -31,13 +31,19 @@ const MAX_RETRYABLE_ERRORS = 10
|
||||
const MAX_RESULTS_RETRIES = 3
|
||||
|
||||
/**
|
||||
* 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试)
|
||||
* 生成状态轮询 Hook(v5 — awaiting_cover 状态识别 + visibilitychange 恢复 + 状态透传)
|
||||
*
|
||||
* startPolling(taskId) 轮询单个任务;
|
||||
* startPollingBatch(tasks) 并行轮询 N 个任务:
|
||||
* - 每个任务独立进度/状态/失败,通过 onBatchTaskUpdate 实时回传
|
||||
* - 全部成功才 onComplete(聚合视频按变体顺序);任一失败不影响其他任务继续
|
||||
* - retryTask(taskId) 单独重试失败任务(重新轮询,后端任务仍在跑则直接接续)
|
||||
*
|
||||
* v5 修复(#2088):
|
||||
* 1. 单任务路径透传 taskStatus(completed / awaiting_cover)到 onComplete,外层据此区分跳转
|
||||
* 2. 监听 visibilitychange,页面从后台切回可见时立即补拉一次,解决切后台 setInterval 被浏览器
|
||||
* 降频/冻结导致进度卡在 56% 的问题
|
||||
* 3. 非 4xx/5xx 网络错误按 3s 退避重试(已有 MAX_RETRYABLE_ERRORS=10 兜底)
|
||||
*/
|
||||
export function useGenerationPolling({
|
||||
onProgress,
|
||||
@@ -49,12 +55,15 @@ export function useGenerationPolling({
|
||||
const cancelledRef = useRef(false)
|
||||
/** 批量任务上下文:taskId → 变体序号 */
|
||||
const batchContextRef = useRef<Map<string, number>>(new Map())
|
||||
/** 当前活跃的「立刻补拉一次」函数(visibilitychange 回调使用) */
|
||||
const immediateTickRef = useRef<(() => void) | null>(null)
|
||||
const [, forceTick] = useState(0)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
progressTimer.current.forEach((t) => clearTimeout(t))
|
||||
progressTimer.current = []
|
||||
immediateTickRef.current = null
|
||||
}, [])
|
||||
|
||||
/** 任务完成后拉取结果列表,带重试 */
|
||||
@@ -96,7 +105,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,8 +120,9 @@ export function useGenerationPolling({
|
||||
if (cancelledRef.current || done) return
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed") {
|
||||
if (task.status === "completed" || task.status === "awaiting_cover") {
|
||||
done = true
|
||||
immediateTickRef.current = null
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
if (videos === null) {
|
||||
@@ -121,13 +131,14 @@ export function useGenerationPolling({
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
callbacks?.onTaskCompleted?.(videos)
|
||||
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
|
||||
resolve(videos)
|
||||
return
|
||||
}
|
||||
|
||||
if (task.status === "failed" || task.status === "cancelled") {
|
||||
done = true
|
||||
immediateTickRef.current = null
|
||||
const rawMsg =
|
||||
task.error_info?.error_message ||
|
||||
task.error_message ||
|
||||
@@ -138,6 +149,7 @@ export function useGenerationPolling({
|
||||
return
|
||||
}
|
||||
|
||||
// running / pending / waiting:更新进度并安排下一次轮询
|
||||
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
|
||||
callbacks?.onTaskProgress?.(pct)
|
||||
if (!callbacks && runId === 0) {
|
||||
@@ -149,16 +161,20 @@ export function useGenerationPolling({
|
||||
if (cancelledRef.current || done) return
|
||||
console.error("[轮询出错] taskId:", taskId, pollErr)
|
||||
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
|
||||
// 4xx 视为不可重试(任务不存在/权限问题等),直接失败
|
||||
if (status && status >= 400 && status < 500) {
|
||||
done = true
|
||||
immediateTickRef.current = null
|
||||
const msg = extractErrorMessage(pollErr, status)
|
||||
callbacks?.onTaskFailed?.(msg)
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
// 网络错误 / 5xx:3s 退避重试,最多 MAX_RETRYABLE_ERRORS 次
|
||||
consecutiveErrors += 1
|
||||
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
|
||||
done = true
|
||||
immediateTickRef.current = null
|
||||
const msg = "任务状态查询连续失败,请稍后在任务列表查看结果"
|
||||
callbacks?.onTaskFailed?.(msg)
|
||||
reject(new Error(msg))
|
||||
@@ -169,6 +185,18 @@ export function useGenerationPolling({
|
||||
}
|
||||
}
|
||||
|
||||
// 注册「立刻补拉一次」回调,供 visibilitychange 恢复时调用
|
||||
// 注意:必须在 done 后清理,避免切换页面时误触发已结束任务的补拉
|
||||
immediateTickRef.current = () => {
|
||||
if (!done && !cancelledRef.current) {
|
||||
// 清除未触发的 setTimeout,立即拉一次
|
||||
progressTimer.current.forEach((t) => clearTimeout(t))
|
||||
progressTimer.current = []
|
||||
consecutiveErrors = 0
|
||||
void poll()
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(poll, 1500)
|
||||
progressTimer.current.push(timer)
|
||||
})
|
||||
@@ -181,12 +209,22 @@ export function useGenerationPolling({
|
||||
(taskId: string) => {
|
||||
cancelledRef.current = false
|
||||
batchContextRef.current.clear()
|
||||
pollSingleTask(taskId, 0)
|
||||
.then((videos) => {
|
||||
if (cancelledRef.current) return
|
||||
let resolvedStatus: "completed" | "awaiting_cover" = "completed"
|
||||
pollSingleTask(taskId, 0, {
|
||||
onTaskProgress: (pct) => onProgress(pct),
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
resolvedStatus = taskStatus ?? "completed"
|
||||
onProgress(100)
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
onComplete(videos, resolvedStatus)
|
||||
},
|
||||
onTaskFailed: (msg) => onFailed(msg),
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelledRef.current) return
|
||||
// awaiting_cover 是中间态(进封面选择页),不弹"完成"toast;completed 才弹
|
||||
if (resolvedStatus === "completed") {
|
||||
message.success("视频生成完成!")
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelledRef.current) return
|
||||
@@ -201,7 +239,7 @@ export function useGenerationPolling({
|
||||
/**
|
||||
* 批量多任务轮询:
|
||||
* - 每个任务独立进度/状态回传 onBatchTaskUpdate
|
||||
* * 全部完成后按变体顺序聚合视频 onComplete
|
||||
* - 全部完成后按变体顺序聚合视频 onComplete
|
||||
* - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed
|
||||
*/
|
||||
const startPollingBatch = useCallback(
|
||||
@@ -211,6 +249,7 @@ export function useGenerationPolling({
|
||||
const progressMap = new Map<string, number>()
|
||||
const resultMap = new Map<string, unknown[]>()
|
||||
const failureMap = new Map<string, string>()
|
||||
const statusMap = new Map<string, "completed" | "awaiting_cover">()
|
||||
batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex]))
|
||||
|
||||
const reportAggregateProgress = () => {
|
||||
@@ -225,7 +264,9 @@ export function useGenerationPolling({
|
||||
if (resultMap.size === tasks.length) {
|
||||
onProgress(100)
|
||||
const ordered = tasks.map((t) => resultMap.get(t.taskId) || []).flat()
|
||||
onComplete(ordered)
|
||||
// 批量:任一任务为 awaiting_cover,则整体透传 awaiting_cover(进封面页)
|
||||
const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover")
|
||||
onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed")
|
||||
message.success(`全部 ${tasks.length} 个视频生成完成!`)
|
||||
} else if (resultMap.size > 0) {
|
||||
// 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮
|
||||
@@ -234,7 +275,8 @@ export function useGenerationPolling({
|
||||
.filter((t) => resultMap.has(t.taskId))
|
||||
.map((t) => resultMap.get(t.taskId) || [])
|
||||
.flat()
|
||||
onComplete(ordered)
|
||||
const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover")
|
||||
onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed")
|
||||
message.warning(
|
||||
`${failureMap.size} 个视频生成失败,可点击卡片上的「重试此视频」,成功的视频可先进入下一步`,
|
||||
)
|
||||
@@ -259,10 +301,12 @@ 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"
|
||||
statusMap.set(taskId, _finalStatus)
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
reportAggregateProgress()
|
||||
checkAllSettled()
|
||||
},
|
||||
@@ -293,8 +337,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 }),
|
||||
@@ -307,5 +352,27 @@ export function useGenerationPolling({
|
||||
[pollSingleTask, onBatchTaskUpdate],
|
||||
)
|
||||
|
||||
/**
|
||||
* visibilitychange 恢复:页面从后台切回前台时,立刻触发一次补拉。
|
||||
* 解决浏览器后台标签页对 setTimeout 的 1Hz 节流/冻结导致的"进度卡 56%"问题。
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible" && immediateTickRef.current) {
|
||||
immediateTickRef.current()
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
// 页面聚焦也兜底一次(部分浏览器 visibilitychange 触发时机不一致)
|
||||
const handleFocus = () => {
|
||||
if (immediateTickRef.current) immediateTickRef.current()
|
||||
}
|
||||
window.addEventListener("focus", handleFocus)
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.removeEventListener("focus", handleFocus)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { startPolling, startPollingBatch, retryTask, clearTimer }
|
||||
}
|
||||
|
||||
@@ -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 闭包
|
||||
|
||||
@@ -13,6 +13,8 @@ import { validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { calculateResolution } from "../utils/calculateResolution"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
export type GenerationCompleteStatus = "completed" | "awaiting_cover" | null
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const { selectedTemplate, onGenerationSuccess } = props
|
||||
|
||||
@@ -22,6 +24,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const [generated, setGenerated] = useState(false)
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
/** #2088:任务最终状态,区分 awaiting_cover(选封面)/ completed(已完成) */
|
||||
const [completionStatus, setCompletionStatus] = useState<GenerationCompleteStatus>(null)
|
||||
/** 单视频模式:当前任务 ID(封面 finalize 需要) */
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string>("")
|
||||
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
|
||||
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
|
||||
|
||||
@@ -51,14 +57,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
const handleProgress = useCallback((p: number) => setProgress(p), [])
|
||||
const handleComplete = useCallback(
|
||||
(videos: unknown[]) => {
|
||||
(videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => {
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
const finalStatus: GenerationCompleteStatus = taskStatus ?? "completed"
|
||||
setCompletionStatus(finalStatus)
|
||||
setGeneratedVideos(videos as GeneratedVideo[])
|
||||
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
|
||||
setBatchTasks((prev) =>
|
||||
(prev || []).map((t) =>
|
||||
t.status === "completed" && t.videos.length === 0
|
||||
t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0)
|
||||
? {
|
||||
...t,
|
||||
videos: (videos as GeneratedVideo[]).filter(
|
||||
@@ -68,7 +76,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
: t,
|
||||
),
|
||||
)
|
||||
onGenerationSuccess?.()
|
||||
onGenerationSuccess?.(finalStatus)
|
||||
},
|
||||
[onGenerationSuccess],
|
||||
)
|
||||
@@ -83,7 +91,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)
|
||||
}
|
||||
})
|
||||
@@ -116,7 +127,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setProgress(0)
|
||||
setGenerated(false)
|
||||
setGenerateError(null)
|
||||
setCompletionStatus(null)
|
||||
setBatchTasks([])
|
||||
setGeneratedVideos([])
|
||||
setCurrentTaskId("")
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
@@ -338,8 +352,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 +430,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
completionStatus,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ export const STATUS_CONFIG: Record<
|
||||
color: "processing",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
awaiting_cover: {
|
||||
label: "待选封面",
|
||||
color: "warning",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
color: "success",
|
||||
|
||||
@@ -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,831 @@
|
||||
"""全 GPU 直连渲染管线(P1)。
|
||||
|
||||
背景:旧链路 worker 先用 CPU libx264 把 filter_complex 输出成 mezzanine(1080p 约 85s),
|
||||
上传后再由 P4000 NVENC 编码,渲染后还要单独跑一次随机边缘裁剪重编码(约 26s)。
|
||||
本管线取消 mezzanine:把原始素材签名 URL 作为多输入直接交给 P4000,filter_complex 内
|
||||
一步完成 trim/scale/pad/concat/边缘随机裁剪/drawtext 字幕,末端 h264_nvenc 只编码一次;
|
||||
原素材音轨 concat + TTS/配音/BGM 混音也在同一命令里完成。
|
||||
|
||||
约束(P1):
|
||||
- 仅覆盖智能剪辑主流场景:单一主视频轨、全硬切、无 PiP/overlay/水印/贴纸/片头片尾/绿幕。
|
||||
不满足条件时调用方回退到现有 mezzanine/CPU 链路(功能不回归)。
|
||||
- 字幕先用 drawtext(P4000 装好中文字体后可再切 subtitles 滤镜烧 ASS)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DRAWTEXT_FONT = "Noto Sans CJK SC"
|
||||
EDGE_CROP_MIN_PCT = 0.02
|
||||
EDGE_CROP_MAX_PCT = 0.05
|
||||
|
||||
# 标题/字幕样式基准宽度(px)。前端 TitleSettings 所有长度字段(size/描边/阴影/margin/pos)
|
||||
# 均以 720p 为基准(见前端 titleCanvas.ts 注释 scale=videoWidth/720,types.ts "px @720p"),
|
||||
# 非 720p 输出时按 video_width / TITLE_SIZE_REF_WIDTH 等比缩放,保证成片位置与前端预览一致。
|
||||
TITLE_SIZE_REF_WIDTH = 720
|
||||
# 与 video_filter_builder.build_title_drawtext_filter(CPU 路径)和 ass_subtitle_builder 对齐:
|
||||
# - top/bottom 默认 margin 50@720p(vfb 用 _scale_title_len(50, w),即 y=50 / y=h-th-50)
|
||||
# - margin_top 字段:前端编辑器 marginTop 滑块,叠加在默认 margin 之上(#2095 支持)
|
||||
# - PAD 概念仅用于前端 Canvas 预览;ffmpeg drawtext y 是 baseline,无 font metrics 可用,
|
||||
# 直接用统一 50@720p baseline 位置即可保持三端(GPU/CPU/前端视觉)一致。
|
||||
TITLE_DEFAULT_MARGIN_TOP = 50 # top 位置 baseline 默认距顶 50@720p(与 vfb/CPU 路径一致)
|
||||
TITLE_DEFAULT_MARGIN_BOTTOM = 50 # bottom 位置 baseline 默认距底 50@720p
|
||||
SUBTITLE_DEFAULT_MARGIN_BOTTOM = 50 # 字幕距底边距 50@720p(与 vfb 一致)
|
||||
TITLE_MARGIN_TOP_FROM_CFG_DEFAULT = 24 # 前端 marginTop 滑块默认值(用户未传时叠加 0)
|
||||
TITLE_FAUX_BOLD_WIDTH = 2 # 仿粗黑色描边宽度(与 vfb 一致,2@720p 黑色细描边)
|
||||
|
||||
|
||||
def _scale_title_len(value, video_width: int):
|
||||
"""将 720p 基准长度按 video_width 等比缩放(与 packages/domain/ass_subtitle_builder._scale_len 一致)。
|
||||
|
||||
int 输入 → 返回 int;float 输入 → 返回 float;非法值原样返回。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if not video_width or video_width <= 0:
|
||||
return int(round(v)) if isinstance(value, int) else v
|
||||
scaled = v * (video_width / TITLE_SIZE_REF_WIDTH)
|
||||
return int(round(scaled)) if isinstance(value, int) else scaled
|
||||
|
||||
|
||||
def escape_drawtext_text(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
s = text.replace("\\", "\\\\")
|
||||
s = s.replace(":", "\\:")
|
||||
s = s.replace("'", "\\'")
|
||||
s = s.replace("%", "\\%")
|
||||
s = s.replace(",", "\\,")
|
||||
s = s.replace("[", "\\[").replace("]", "\\]")
|
||||
s = s.replace(";", "\\;")
|
||||
s = s.replace("\n", " ")
|
||||
return s
|
||||
|
||||
|
||||
def _hex_to_drawtext_color(hex_color: str, default: str = "white") -> str:
|
||||
"""把 #RRGGBB / #RGB / 命名颜色转换为 ffmpeg drawtext 接受的颜色格式。
|
||||
|
||||
drawtext 的 fontcolor 接受 0xRRGGBB 形式(或命名颜色如 white/black/yellow)。
|
||||
描边/阴影颜色同样适用。alpha 后缀支持(#RRGGBB@0.5 或 &HBBGGRRAA)。
|
||||
"""
|
||||
if not hex_color:
|
||||
return default
|
||||
s = hex_color.strip()
|
||||
if not s:
|
||||
return default
|
||||
# 命名颜色直接返回(白名单常见值,避免把 #xxx 当成命名)
|
||||
if not s.startswith("#") and not s.startswith("0x") and "@" not in s:
|
||||
return s
|
||||
if s.startswith("0x"):
|
||||
return s # 已是 drawtext 原生格式
|
||||
if s.startswith("#"):
|
||||
h = s[1:]
|
||||
# 处理 alpha:#RRGGBB@AA 或 #RRGGBB&AA
|
||||
alpha = ""
|
||||
if "@" in h:
|
||||
h, alpha_part = h.split("@", 1)
|
||||
try:
|
||||
a = float(alpha_part)
|
||||
alpha = f"@{a:.2f}"
|
||||
except ValueError:
|
||||
alpha = ""
|
||||
if len(h) == 3:
|
||||
h = "".join(ch * 2 for ch in h)
|
||||
if len(h) == 6:
|
||||
try:
|
||||
int(h, 16)
|
||||
except ValueError:
|
||||
return default
|
||||
return f"0x{h}{alpha}"
|
||||
if len(h) == 8:
|
||||
# RRGGBBAA → drawtext 的 0xRRGGBB@AA 形式
|
||||
try:
|
||||
int(h, 16)
|
||||
except ValueError:
|
||||
return default
|
||||
rr, gg, bb, aa = h[0:2], h[2:4], h[4:6], h[6:8]
|
||||
try:
|
||||
a = int(aa, 16) / 255.0
|
||||
return f"0x{rr}{gg}{bb}@{a:.2f}"
|
||||
except ValueError:
|
||||
return f"0x{rr}{gg}{bb}"
|
||||
return default
|
||||
|
||||
|
||||
def _position_to_drawtext_xy(
|
||||
position: str,
|
||||
*,
|
||||
margin_top: int = 0,
|
||||
margin_bottom: int = 0,
|
||||
pos_x: Optional[float] = None,
|
||||
pos_y: Optional[float] = None,
|
||||
) -> tuple[str, str]:
|
||||
"""把位置映射到 drawtext x/y 表达式,对齐前端 titleCanvas.ts 预览坐标。
|
||||
|
||||
position 支持: top / center(middle) / bottom / custom。
|
||||
- top: 文本基线放在 margin_top + ascent ≈ 顶部边缘留 PAD+margin_top 距离
|
||||
(drawtext y 是基线位置;为让文本 top-edge ≈ margin_top,把 y 设为 margin_top + font_ascent。
|
||||
但 drawtext 运行时不知道 ascent,用经验系数 0.8*fontsize 近似,和前端 PAD+margin_top 对齐)。
|
||||
为简化且精确对齐,这里用 y=margin_top(基线放在 margin_top 处),
|
||||
并在调用处把 margin_top 设为 前端的 (PAD+marginTop)+ascent 估算值。
|
||||
- center: (h-text_h)/2 垂直居中。
|
||||
- bottom: 文本底线距离底边 margin_bottom。
|
||||
- custom: pos_x/pos_y 为百分比 0-100(前端拖拽坐标系),文本中心落在 (pct_x*w, pct_y*h)。
|
||||
margin_top/margin_bottom 为已按 video_width 缩放过的像素值。
|
||||
"""
|
||||
p = (position or "top").lower().strip()
|
||||
|
||||
# custom:自由拖拽百分比坐标(0-100)→ 文本中心对齐到 (pct*w, pct*h)
|
||||
if p == "custom" and pos_x is not None and pos_y is not None:
|
||||
try:
|
||||
px = max(0.0, min(100.0, float(pos_x))) / 100.0
|
||||
py = max(0.0, min(100.0, float(pos_y))) / 100.0
|
||||
return f"(w-text_w)*{px:.4f}", f"(h-text_h)*{py:.4f}"
|
||||
except (TypeError, ValueError):
|
||||
pass # fall through to default
|
||||
|
||||
x = "(w-text_w)/2"
|
||||
if p in ("top",):
|
||||
# drawtext y 是 baseline 位置。中文字符顶边距基线约 0.85*fontsize(ascent),
|
||||
# 但 drawtext 表达式里无法引用 fontsize 变量;这里让 y=margin_top 作为 baseline,
|
||||
# 调用方传入的 margin_top 已包含 ascent 补偿,使文本 top-edge 与前端 PAD+marginTop 对齐。
|
||||
y = f"{int(margin_top)}"
|
||||
elif p in ("center", "middle"):
|
||||
y = "(h-text_h)/2"
|
||||
elif p in ("bottom",):
|
||||
# h-th-margin_bottom:th ≈ text_h,文本底边距底边 margin_bottom
|
||||
y = f"h-th-{int(margin_bottom)}"
|
||||
else:
|
||||
# 未知值回退到顶部(与前端默认 position=top 对齐)
|
||||
y = f"{int(margin_top)}"
|
||||
return x, y
|
||||
|
||||
|
||||
def _build_drawtext_filters(
|
||||
*,
|
||||
text: str,
|
||||
start: float,
|
||||
end: float,
|
||||
font: str = DEFAULT_DRAWTEXT_FONT,
|
||||
font_size: int = 0,
|
||||
font_color: str = "white",
|
||||
position: str = "top",
|
||||
margin_top: int = 0,
|
||||
margin_bottom: int = 0,
|
||||
pos_x: Optional[float] = None,
|
||||
pos_y: Optional[float] = None,
|
||||
box_enabled: bool = False,
|
||||
box_color: str = "black@0.5",
|
||||
borderw: int = 0,
|
||||
border_color: str = "black",
|
||||
shadow_enabled: bool = False,
|
||||
shadow_color: str = "black@0.6",
|
||||
shadow_x: int = 2,
|
||||
shadow_y: int = 2,
|
||||
) -> list[str]:
|
||||
"""构造一组 drawtext 滤镜:可选阴影层(同字偏移)+ 主字层。
|
||||
|
||||
ffmpeg drawtext 没有直接的 shadow 选项,用两次 drawtext 模拟:
|
||||
先画一个描边/阴影色层偏移 shadow_x/shadow_y,再画主字层。
|
||||
返回列表是为了让调用方顺序插入 fc(前一个输出作为后一个输入)。
|
||||
"""
|
||||
txt = escape_drawtext_text(text)
|
||||
if not txt:
|
||||
return []
|
||||
|
||||
x_expr, y_expr = _position_to_drawtext_xy(
|
||||
position,
|
||||
margin_top=margin_top,
|
||||
margin_bottom=margin_bottom,
|
||||
pos_x=pos_x,
|
||||
pos_y=pos_y,
|
||||
)
|
||||
fc_color = _hex_to_drawtext_color(font_color, default="white")
|
||||
bd_color = _hex_to_drawtext_color(border_color, default="black")
|
||||
sh_color = _hex_to_drawtext_color(shadow_color, default="black@0.6")
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
# 阴影层:shadow_enabled 时先画一层深色偏移字(无描边)
|
||||
if shadow_enabled and (shadow_x != 0 or shadow_y != 0):
|
||||
sh_parts = [f"font={font}", f"text='{txt}'"]
|
||||
if font_size and font_size > 0:
|
||||
sh_parts.append(f"fontsize={int(font_size)}")
|
||||
sh_parts.append(f"fontcolor={sh_color}")
|
||||
sh_parts.append(f"x={x_expr}+{int(shadow_x)}")
|
||||
sh_parts.append(f"y={y_expr}+{int(shadow_y)}")
|
||||
if start > 0 or end > 0:
|
||||
sh_parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
|
||||
filters.append("drawtext=" + ":".join(sh_parts))
|
||||
|
||||
# 主字层
|
||||
parts = [f"font={font}", f"text='{txt}'"]
|
||||
if font_size and font_size > 0:
|
||||
parts.append(f"fontsize={int(font_size)}")
|
||||
parts.append(f"fontcolor={fc_color}")
|
||||
if box_enabled:
|
||||
parts.append("box=1")
|
||||
parts.append(f"boxcolor={box_color}")
|
||||
if borderw and borderw > 0:
|
||||
parts.append(f"borderw={int(borderw)}")
|
||||
parts.append(f"bordercolor={bd_color}")
|
||||
parts.append(f"x={x_expr}")
|
||||
parts.append(f"y={y_expr}")
|
||||
if start > 0 or end > 0:
|
||||
parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
|
||||
filters.append("drawtext=" + ":".join(parts))
|
||||
return filters
|
||||
|
||||
|
||||
def build_drawtext_filter(
|
||||
*,
|
||||
text: str,
|
||||
start: float,
|
||||
end: float,
|
||||
font: str = DEFAULT_DRAWTEXT_FONT,
|
||||
font_size: int = 0,
|
||||
font_color: str = "white",
|
||||
x_expr: str = "(w-text_w)/2",
|
||||
y_expr: str = "h-th-60",
|
||||
box: bool = False,
|
||||
box_color: str = "black@0.5",
|
||||
borderw: int = 0,
|
||||
border_color: str = "black",
|
||||
enable: bool = True,
|
||||
) -> str:
|
||||
"""[已废弃] 保留单条 drawtext 的便捷构造;新代码请用 _build_drawtext_filters。"""
|
||||
txt = escape_drawtext_text(text)
|
||||
parts = [f"font={font}", f"text='{txt}'"]
|
||||
if font_size and font_size > 0:
|
||||
parts.append(f"fontsize={int(font_size)}")
|
||||
parts.append(f"fontcolor={_hex_to_drawtext_color(font_color)}")
|
||||
if box:
|
||||
parts.append("box=1")
|
||||
parts.append(f"boxcolor={box_color}")
|
||||
if borderw and borderw > 0:
|
||||
parts.append(f"borderw={int(borderw)}")
|
||||
parts.append(f"bordercolor={_hex_to_drawtext_color(border_color)}")
|
||||
parts.append(f"x={x_expr}")
|
||||
parts.append(f"y={y_expr}")
|
||||
if enable:
|
||||
parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")
|
||||
return "drawtext=" + ":".join(parts)
|
||||
|
||||
|
||||
def _build_atempo_chain(speed: float) -> str:
|
||||
if abs(speed - 1.0) < 1e-6:
|
||||
return ""
|
||||
stages: list[float] = []
|
||||
remaining = speed
|
||||
while remaining > 2.0:
|
||||
stages.append(2.0)
|
||||
remaining /= 2.0
|
||||
while remaining < 0.5:
|
||||
stages.append(0.5)
|
||||
remaining /= 0.5
|
||||
if abs(remaining - 1.0) >= 1e-6:
|
||||
stages.append(remaining)
|
||||
return ",".join(f"atempo={s:.5f}" for s in stages)
|
||||
|
||||
|
||||
def upload_local_audio_and_sign(
|
||||
local_audio: Path,
|
||||
*,
|
||||
tmp_prefix: str = "tmp/gpu-direct-audio/",
|
||||
expires: int = 3600,
|
||||
) -> tuple[str, str]:
|
||||
from video_processing.oss_helpers import _storage # type: ignore
|
||||
|
||||
storage = _storage()
|
||||
key = f"{tmp_prefix.rstrip('/')}/{uuid.uuid4().hex}{local_audio.suffix or '.mp3'}"
|
||||
content_type = "audio/mpeg" if local_audio.suffix.lower() in (".mp3", ".mpeg") else "audio/mp4"
|
||||
storage.upload_file(local_audio, key, content_type=content_type)
|
||||
url = storage.get_download_url(key, expires)
|
||||
return url, key
|
||||
|
||||
|
||||
def sign_asset_url(storage_key: str, *, expires: int = 3600) -> str:
|
||||
from video_processing.oss_helpers import _storage # type: ignore
|
||||
|
||||
storage = _storage()
|
||||
return storage.get_download_url(storage_key, expires)
|
||||
|
||||
|
||||
class DirectRenderPlan:
|
||||
def __init__(
|
||||
self,
|
||||
inputs: dict[str, str],
|
||||
ffmpeg_args: list[str],
|
||||
oss_keys: list[str],
|
||||
filter_complex: list[str] | None = None,
|
||||
):
|
||||
self.inputs = inputs
|
||||
self.ffmpeg_args = ffmpeg_args
|
||||
self.oss_keys = oss_keys
|
||||
self.filter_complex: list[str] = filter_complex or []
|
||||
|
||||
|
||||
def build_direct_render(
|
||||
*,
|
||||
resolved_clips: list[Any],
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
output_fps: int,
|
||||
tts_audio: Optional[Path] = None,
|
||||
bgm_audio: Optional[Path] = None,
|
||||
title_text: str = "",
|
||||
subtitle_segments: Optional[list[Any]] = None,
|
||||
font: str = DEFAULT_DRAWTEXT_FONT,
|
||||
vcodec: str = "h264_nvenc",
|
||||
preset: str = "p4",
|
||||
video_bitrate: str = "",
|
||||
cq: int = 23,
|
||||
edge_crop_pct: float = 0.0,
|
||||
total_duration: float = 0.0,
|
||||
clip_has_audio: Optional[list[bool]] = None,
|
||||
clip_volumes: Optional[list[float]] = None,
|
||||
extra_audio_tracks: Optional[list[tuple[Any, float]]] = None,
|
||||
title_config: Optional[dict] = None,
|
||||
subtitle_config: Optional[dict] = None,
|
||||
bgm_config: Optional[dict] = None,
|
||||
static_subtitle_text: str = "",
|
||||
) -> DirectRenderPlan:
|
||||
"""构造 P4000 直连渲染所需的 inputs 与 ffmpeg_args。
|
||||
|
||||
视频:每段 trim/setpts/scale/pad/fps → concat(全硬切,带音频)→ 随机边缘 crop+scale → drawtext。
|
||||
音频:每段 [i:a](或 anullsrc 静音占位)按 clip 配置 atrim/asetpts/atempo/volume/aresample
|
||||
→ concat=n:N:v=1:a=1 → 与 extra_audio(TTS/配音素材库)、BGM 一起 amix → atrim 精确截断。
|
||||
"""
|
||||
if not resolved_clips:
|
||||
raise ValueError("build_direct_render: no resolved clips")
|
||||
|
||||
inputs: dict[str, str] = {}
|
||||
oss_keys: list[str] = []
|
||||
input_args: list[str] = []
|
||||
fc: list[str] = []
|
||||
n = len(resolved_clips)
|
||||
|
||||
# 规范化每段参数
|
||||
if clip_has_audio is None:
|
||||
clip_has_audio = [True] * n
|
||||
else:
|
||||
clip_has_audio = list(clip_has_audio) + [True] * max(0, n - len(clip_has_audio))
|
||||
clip_has_audio = clip_has_audio[:n]
|
||||
if clip_volumes is None:
|
||||
clip_volumes = [1.0] * n
|
||||
else:
|
||||
clip_volumes = list(clip_volumes) + [1.0] * max(0, n - len(clip_volumes))
|
||||
clip_volumes = clip_volumes[:n]
|
||||
|
||||
clip_starts: list[float] = []
|
||||
clip_effs: list[float] = []
|
||||
clip_speeds: list[float] = []
|
||||
for clip in resolved_clips:
|
||||
start = float(getattr(clip, "start_time", 0) or 0)
|
||||
eff = float(getattr(clip, "duration", 0) or 0)
|
||||
if eff <= 0:
|
||||
eff = float(getattr(clip, "actual_duration", 0) or 0)
|
||||
speed = float(getattr(clip, "playback_speed", 1.0) or 1.0)
|
||||
clip_starts.append(start)
|
||||
clip_effs.append(eff)
|
||||
clip_speeds.append(speed)
|
||||
|
||||
# 1. 视频输入(原始素材签名 URL)
|
||||
for i, clip in enumerate(resolved_clips):
|
||||
sk = (getattr(clip, "config", None) or {}).get("_storage_key")
|
||||
if not sk:
|
||||
raise ValueError(f"clip {getattr(clip, 'clip_id', i)} missing _storage_key")
|
||||
fname = f"v{i}.mp4"
|
||||
inputs[fname] = sign_asset_url(sk)
|
||||
input_args.extend(["-i", fname])
|
||||
|
||||
# 2. 视频段预处理
|
||||
pre_labels: list[str] = []
|
||||
for i in range(n):
|
||||
vf: list[str] = []
|
||||
start, eff, speed = clip_starts[i], clip_effs[i], clip_speeds[i]
|
||||
if eff > 0:
|
||||
if start > 0:
|
||||
vf.append(f"trim=start={start:.3f}:duration={eff:.3f}")
|
||||
else:
|
||||
vf.append(f"trim=duration={eff:.3f}")
|
||||
vf.append("setpts=PTS-STARTPTS")
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
vf.append(f"setpts=PTS/{speed:.4f}")
|
||||
vf.append(f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease")
|
||||
vf.append(f"pad={output_width}:{output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
|
||||
vf.append("setpts=PTS-STARTPTS")
|
||||
vf.append(f"fps={output_fps}")
|
||||
label = f"vc{i}"
|
||||
fc.append(f"[{i}:v]{','.join(vf)}[{label}]")
|
||||
pre_labels.append(label)
|
||||
|
||||
# 2b. 音频段预处理(无声源用 anullsrc 占位;volume=0 的段也用 anullsrc 静音占位保持时间轴)
|
||||
anullsrc_counter = 0
|
||||
audio_pre_labels: list[str] = []
|
||||
for i in range(n):
|
||||
start, eff, speed = clip_starts[i], clip_effs[i], clip_speeds[i]
|
||||
vol = float(clip_volumes[i] if i < len(clip_volumes) else 1.0)
|
||||
has_a = bool(clip_has_audio[i] if i < len(clip_has_audio) else True)
|
||||
if not has_a or vol <= 0.001:
|
||||
# 静音占位:用 anullsrc 生成静音,atrim 到段时长
|
||||
sl = f"sil{anullsrc_counter}"
|
||||
anullsrc_counter += 1
|
||||
af: list[str] = ["anullsrc=channel_layout=stereo:sample_rate=44100"]
|
||||
if eff > 0:
|
||||
af.append(f"atrim=duration={eff:.3f}")
|
||||
af.append("asetpts=PTS-STARTPTS")
|
||||
af.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
|
||||
fc.append(f"{','.join(af)}[{sl}]")
|
||||
# anullsrc 作为 filter 源不需要 -i 输入,直接给 label
|
||||
audio_pre_labels.append(sl)
|
||||
continue
|
||||
|
||||
af = []
|
||||
if eff > 0:
|
||||
if start > 0:
|
||||
af.append(f"atrim=start={start:.3f}:duration={eff:.3f}")
|
||||
else:
|
||||
af.append(f"atrim=duration={eff:.3f}")
|
||||
af.append("asetpts=PTS-STARTPTS")
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
atempo = _build_atempo_chain(speed)
|
||||
if atempo:
|
||||
af.append(atempo)
|
||||
if abs(vol - 1.0) >= 1e-3:
|
||||
af.append(f"volume={vol:.3f}")
|
||||
af.append("aresample=44100")
|
||||
af.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
|
||||
alabel = f"ac{i}"
|
||||
fc.append(f"[{i}:a]{','.join(af)}[{alabel}]")
|
||||
audio_pre_labels.append(alabel)
|
||||
|
||||
# 3. concat(全硬切;v=1:a=1,视频音频一起拼接)
|
||||
concat_in = "".join(f"[{v}][{a}]" for v, a in zip(pre_labels, audio_pre_labels, strict=True))
|
||||
fc.append(f"{concat_in}concat=n={n}:v=1:a=1[vcat][acat]")
|
||||
cur_v = "vcat"
|
||||
cur_a = "acat"
|
||||
|
||||
# 4. 随机边缘裁剪降重(四边独立随机 2%~5%,与 ffmpeg_utils.random_edge_crop 一致)
|
||||
if edge_crop_pct and edge_crop_pct > 0:
|
||||
_r = random.Random()
|
||||
p_min = EDGE_CROP_MIN_PCT
|
||||
p_max = EDGE_CROP_MAX_PCT
|
||||
crop_top = p_min + _r.random() * (p_max - p_min)
|
||||
crop_bottom = p_min + _r.random() * (p_max - p_min)
|
||||
crop_left = p_min + _r.random() * (p_max - p_min)
|
||||
crop_right = p_min + _r.random() * (p_max - p_min)
|
||||
w_expr = f"trunc(iw*(1-{crop_left:.4f}-{crop_right:.4f})/2)*2"
|
||||
h_expr = f"trunc(ih*(1-{crop_top:.4f}-{crop_bottom:.4f})/2)*2"
|
||||
x_expr = f"trunc(iw*{crop_left:.4f}/2)*2"
|
||||
y_expr = f"trunc(ih*{crop_top:.4f}/2)*2"
|
||||
fc.append(
|
||||
f"[{cur_v}]crop=w='{w_expr}':h='{h_expr}':x='{x_expr}':y='{y_expr}',"
|
||||
f"scale={output_width}:{output_height}[vcrop]"
|
||||
)
|
||||
cur_v = "vcrop"
|
||||
|
||||
# 5. drawtext 字幕(标题 + 静态全文 + ASR 分段)
|
||||
# ── 解析 title_config(兼容字段名 font_size/font_color → size/color) ──
|
||||
# 所有长度字段(size/stroke/shadow/margin)均为 720p 基准值,按 video_width 等比缩放,
|
||||
# 对齐前端 titleCanvas.ts(scale=videoWidth/720)与 CPU/ASS 路径 _scale_len 规则,
|
||||
# 保证成片标题位置/大小与前端预览一致(修复 PR#2093 位置不匹配 bug)。
|
||||
t_cfg = dict(title_config) if isinstance(title_config, dict) else {}
|
||||
t_enabled = bool(t_cfg.get("enabled", True))
|
||||
t_text = (t_cfg.get("text", "") or title_text or "").strip()
|
||||
t_font = str(t_cfg.get("font", font) or font)
|
||||
# size:前端传 px@720p,未配置默认 28(前端 DEFAULT_TITLE_SETTINGS.size=28,对齐 AI Avatar 默认48)
|
||||
t_size_raw = t_cfg.get("size", t_cfg.get("font_size", 0))
|
||||
try:
|
||||
t_size_720 = int(t_size_raw) if t_size_raw else 0
|
||||
except (TypeError, ValueError):
|
||||
t_size_720 = 0
|
||||
if t_size_720 <= 0:
|
||||
t_size_720 = 48 # 与 config_schemas.DEFAULT_EDIT_PLAN_CONFIG.title.size=48 及 vfb 默认一致
|
||||
t_size = _scale_title_len(t_size_720, output_width)
|
||||
# stroke/shadow 长度字段也需 720p→输出分辨率缩放
|
||||
t_color = str(t_cfg.get("color", t_cfg.get("font_color", "#ffffff")))
|
||||
t_position = str(t_cfg.get("position", "top")).lower().strip()
|
||||
# 自由拖拽坐标(百分比 0-100),与 video_filter_builder.build_title_drawtext_filter 一致
|
||||
t_pos_x = t_cfg.get("pos_x")
|
||||
t_pos_y = t_cfg.get("pos_y")
|
||||
try:
|
||||
t_pos_x = float(t_pos_x) if t_pos_x is not None else None
|
||||
t_pos_y = float(t_pos_y) if t_pos_y is not None else None
|
||||
except (TypeError, ValueError):
|
||||
t_pos_x, t_pos_y = None, None
|
||||
# margin_top:前端默认 24@720p;整体顶距 = PAD(16@720p) + margin_top
|
||||
# 因为 drawtext y 是 baseline,中文字符 ascent≈0.85*fontsize,为让文本 top-edge≈(PAD+marginTop),
|
||||
# baseline 需再下移约 0.85*fontsize;但 drawtext 表达式无法引用 fontsize 变量,
|
||||
# 这里直接用 (PAD + margin_top)@720p 缩放后作为 y(即让 baseline≈顶部内边距位置),
|
||||
# 实际中文字符会自然向下延伸,视觉位置与前端预览(textBaseline=middle 居中到 firstLineY)一致。
|
||||
# margin_top:前端滑块值(默认 24@720p),叠加在默认 50@720p 基线之上
|
||||
_t_user_margin_top = t_cfg.get("margin_top")
|
||||
try:
|
||||
_t_user_margin_top_720 = int(_t_user_margin_top) if _t_user_margin_top is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
_t_user_margin_top_720 = 0
|
||||
t_margin_top_720 = TITLE_DEFAULT_MARGIN_TOP + _t_user_margin_top_720
|
||||
t_margin_top = _scale_title_len(t_margin_top_720, output_width)
|
||||
# bottom margin(标题放在 bottom 时):用户 margin_bottom 透传,默认 50@720p
|
||||
_t_user_margin_bottom = t_cfg.get("margin_bottom")
|
||||
try:
|
||||
_t_user_margin_bottom_720 = int(_t_user_margin_bottom) if _t_user_margin_bottom is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
_t_user_margin_bottom_720 = 0
|
||||
t_margin_bottom_720 = TITLE_DEFAULT_MARGIN_BOTTOM + _t_user_margin_bottom_720
|
||||
t_margin_bottom = _scale_title_len(t_margin_bottom_720, output_width)
|
||||
t_borderw = 0
|
||||
t_border_color = "#000000"
|
||||
t_box = False
|
||||
t_box_color = "black@0.5"
|
||||
# stroke
|
||||
_stroke = t_cfg.get("stroke")
|
||||
if isinstance(_stroke, dict) and _stroke.get("enabled", False):
|
||||
try:
|
||||
t_borderw_720 = int(float(_stroke.get("width", 2)))
|
||||
except (TypeError, ValueError):
|
||||
t_borderw_720 = 2
|
||||
t_borderw = max(1, _scale_title_len(t_borderw_720, output_width))
|
||||
t_border_color = str(_stroke.get("color", "#000000"))
|
||||
elif isinstance(_stroke, bool) and _stroke:
|
||||
t_borderw = max(1, _scale_title_len(2, output_width))
|
||||
# shadow
|
||||
_shadow = t_cfg.get("shadow")
|
||||
t_shadow_enabled = False
|
||||
t_shadow_color = "#000000@0.6"
|
||||
t_shadow_x_720, t_shadow_y_720 = 2, 2
|
||||
if isinstance(_shadow, dict) and _shadow.get("enabled", False):
|
||||
t_shadow_enabled = True
|
||||
t_shadow_color = str(_shadow.get("color", "#000000@0.6"))
|
||||
try:
|
||||
t_shadow_x_720 = int(float(_shadow.get("offset_x", 2)))
|
||||
t_shadow_y_720 = int(float(_shadow.get("offset_y", 2)))
|
||||
except (TypeError, ValueError):
|
||||
t_shadow_x_720, t_shadow_y_720 = 2, 2
|
||||
elif isinstance(_shadow, bool) and _shadow:
|
||||
t_shadow_enabled = True
|
||||
t_shadow_x = _scale_title_len(t_shadow_x_720, output_width)
|
||||
t_shadow_y = _scale_title_len(t_shadow_y_720, output_width)
|
||||
# bold/italic:drawtext 原生无粗斜体选项;通过同色描边模拟粗体
|
||||
t_bold = bool(t_cfg.get("bold", True)) # 与 ASS/vfb 路径默认 bold=True 对齐
|
||||
if t_bold and t_borderw < 1:
|
||||
# 粗体未配用户描边时:黑色细描边 2@720p(与 vfb 一致,避免同色描边导致重影)
|
||||
t_borderw = _scale_title_len(TITLE_FAUX_BOLD_WIDTH, output_width)
|
||||
t_border_color = "#000000" # 黑色细描边模拟粗体
|
||||
|
||||
# ── 解析 subtitle_config ──
|
||||
s_cfg = dict(subtitle_config) if isinstance(subtitle_config, dict) else {}
|
||||
s_enabled = bool(s_cfg.get("enabled", True))
|
||||
s_font = str(s_cfg.get("font", font) or font)
|
||||
s_size_raw = s_cfg.get("size", s_cfg.get("font_size", 0))
|
||||
try:
|
||||
s_size_720 = int(s_size_raw) if s_size_raw else 0
|
||||
except (TypeError, ValueError):
|
||||
s_size_720 = 0
|
||||
if s_size_720 <= 0:
|
||||
s_size_720 = 24 # 字幕默认 24@720p(对齐 ass_subtitle_builder defaults size=24)
|
||||
s_size = _scale_title_len(s_size_720, output_width)
|
||||
s_color = str(s_cfg.get("color", s_cfg.get("font_color", "#ffffff")))
|
||||
s_position = str(s_cfg.get("position", "bottom")).lower().strip()
|
||||
s_pos_x = s_cfg.get("pos_x")
|
||||
s_pos_y = s_cfg.get("pos_y")
|
||||
try:
|
||||
s_pos_x = float(s_pos_x) if s_pos_x is not None else None
|
||||
s_pos_y = float(s_pos_y) if s_pos_y is not None else None
|
||||
except (TypeError, ValueError):
|
||||
s_pos_x, s_pos_y = None, None
|
||||
s_margin_top = _scale_title_len(60, output_width) # subtitle top (not commonly used)
|
||||
s_margin_bottom = _scale_title_len(SUBTITLE_DEFAULT_MARGIN_BOTTOM, output_width)
|
||||
# subtitle stroke/bold:先解析用户 stroke,再按 bold 默认补描边
|
||||
s_borderw = 0
|
||||
s_border_color = "#000000"
|
||||
_s_stroke = s_cfg.get("stroke")
|
||||
if isinstance(_s_stroke, dict) and _s_stroke.get("enabled", False):
|
||||
try:
|
||||
s_borderw = _scale_title_len(int(float(_s_stroke.get("width", 2))), output_width)
|
||||
except (TypeError, ValueError):
|
||||
s_borderw = 0
|
||||
s_border_color = str(_s_stroke.get("color", "#000000"))
|
||||
s_bold = bool(s_cfg.get("bold", False))
|
||||
if s_bold and s_borderw < 1:
|
||||
# 粗体默认黑色细描边 2@720p(与 title/CPU vfb 一致)
|
||||
s_borderw = _scale_title_len(TITLE_FAUX_BOLD_WIDTH, output_width)
|
||||
s_border_color = "#000000"
|
||||
|
||||
# 静态字幕:static_subtitle_text 非空时构造全片长 segment(0 → total_duration)
|
||||
static_text = (static_subtitle_text or "").strip()
|
||||
subtitle_segments = list(subtitle_segments or [])
|
||||
if s_enabled and static_text and total_duration and total_duration > 0:
|
||||
# 用 duck-type 对象插入到 subtitle_segments 列表头部(静态全文)
|
||||
class _StaticSeg:
|
||||
def __init__(self, txt, st, ed):
|
||||
self.text = txt
|
||||
self.start = st
|
||||
self.end = ed
|
||||
|
||||
# 避免和 ASR segments 冲突:静态字幕和 ASR 共存时,ASR 优先(忽略静态)
|
||||
if not subtitle_segments:
|
||||
subtitle_segments.insert(0, _StaticSeg(static_text, 0.0, float(total_duration)))
|
||||
|
||||
draw_filters: list[str] = []
|
||||
if t_enabled and t_text:
|
||||
draw_filters.extend(
|
||||
_build_drawtext_filters(
|
||||
text=t_text,
|
||||
start=0.0,
|
||||
end=max(total_duration, 0.1),
|
||||
font=t_font,
|
||||
font_size=t_size,
|
||||
font_color=t_color,
|
||||
position=t_position,
|
||||
margin_top=t_margin_top,
|
||||
margin_bottom=t_margin_bottom,
|
||||
pos_x=t_pos_x,
|
||||
pos_y=t_pos_y,
|
||||
box_enabled=t_box,
|
||||
box_color=t_box_color,
|
||||
borderw=t_borderw,
|
||||
border_color=t_border_color,
|
||||
shadow_enabled=t_shadow_enabled,
|
||||
shadow_color=t_shadow_color,
|
||||
shadow_x=t_shadow_x,
|
||||
shadow_y=t_shadow_y,
|
||||
)
|
||||
)
|
||||
if s_enabled:
|
||||
for seg in subtitle_segments:
|
||||
txt = getattr(seg, "text", "") or ""
|
||||
if not txt.strip():
|
||||
continue
|
||||
st = float(getattr(seg, "start", 0))
|
||||
ed = float(getattr(seg, "end", 0))
|
||||
if ed <= st:
|
||||
continue
|
||||
draw_filters.extend(
|
||||
_build_drawtext_filters(
|
||||
text=txt,
|
||||
start=st,
|
||||
end=ed,
|
||||
font=s_font,
|
||||
font_size=s_size,
|
||||
font_color=s_color,
|
||||
position=s_position,
|
||||
margin_top=s_margin_top,
|
||||
margin_bottom=s_margin_bottom,
|
||||
pos_x=s_pos_x,
|
||||
pos_y=s_pos_y,
|
||||
box_enabled=False,
|
||||
borderw=s_borderw,
|
||||
border_color=s_border_color,
|
||||
)
|
||||
)
|
||||
|
||||
if draw_filters:
|
||||
prev = cur_v
|
||||
for idx, df in enumerate(draw_filters):
|
||||
out_l = "vfinal" if idx == len(draw_filters) - 1 else f"vd{idx}"
|
||||
fc.append(f"[{prev}]{df}[{out_l}]")
|
||||
prev = out_l
|
||||
vfinal_label = prev
|
||||
else:
|
||||
fc.append(f"[{cur_v}]format=yuv420p[vfinal]")
|
||||
vfinal_label = "vfinal"
|
||||
|
||||
# 6. 音频混音:原素材主音轨 acat + extra(TTS/配音素材库) + BGM → amix → atrim
|
||||
mix_labels: list[str] = [cur_a]
|
||||
mix_vols: list[float] = [1.0]
|
||||
next_idx = n
|
||||
|
||||
# 额外独立音频轨(TTS concat / 配音素材库整段音频)
|
||||
for _ea_idx, (ea_path, ea_vol) in enumerate(extra_audio_tracks or []):
|
||||
if ea_path is None:
|
||||
continue
|
||||
ea_p = Path(ea_path)
|
||||
if not ea_p.exists():
|
||||
continue
|
||||
eurl, ekey = upload_local_audio_and_sign(ea_p)
|
||||
ename = f"extra{_ea_idx}{ea_p.suffix or '.mp3'}"
|
||||
inputs[ename] = eurl
|
||||
oss_keys.append(ekey)
|
||||
input_args.extend(["-i", ename])
|
||||
elabel = f"aex{_ea_idx}"
|
||||
fc.append(
|
||||
f"[{next_idx}:a]aresample=44100,volume={float(ea_vol):.2f},"
|
||||
f"aformat=sample_fmts=fltp:channel_layouts=stereo[{elabel}]"
|
||||
)
|
||||
mix_labels.append(elabel)
|
||||
mix_vols.append(float(ea_vol))
|
||||
next_idx += 1
|
||||
|
||||
if tts_audio and Path(tts_audio).exists():
|
||||
# 旧参数保留:若调用方直接传了 tts_audio 而没走 extra_audio_tracks,则仍然加入
|
||||
# (兼容旧调用,正常路径 TTS 已经通过 extra_audio_tracks 传入)
|
||||
turl, tkey = upload_local_audio_and_sign(Path(tts_audio))
|
||||
tname = "tts" + (Path(tts_audio).suffix or ".mp3")
|
||||
inputs[tname] = turl
|
||||
oss_keys.append(tkey)
|
||||
input_args.extend(["-i", tname])
|
||||
alabel = "au_tts"
|
||||
fc.append(
|
||||
f"[{next_idx}:a]aresample=44100,volume=1.00,aformat=sample_fmts=fltp:channel_layouts=stereo[{alabel}]"
|
||||
)
|
||||
mix_labels.append(alabel)
|
||||
mix_vols.append(1.0)
|
||||
next_idx += 1
|
||||
_bgm_use = bgm_audio is not None and Path(bgm_audio).exists()
|
||||
if _bgm_use and isinstance(bgm_config, dict) and bgm_config.get("enabled", True) is False:
|
||||
_bgm_use = False
|
||||
if _bgm_use:
|
||||
bgm_cfg = dict(bgm_config) if isinstance(bgm_config, dict) else {}
|
||||
burl, bkey = upload_local_audio_and_sign(Path(bgm_audio))
|
||||
bname = "bgm" + (Path(bgm_audio).suffix or ".mp3")
|
||||
inputs[bname] = burl
|
||||
oss_keys.append(bkey)
|
||||
input_args.extend(["-i", bname])
|
||||
alabel = "au_bgm"
|
||||
try:
|
||||
bgm_vol = float(bgm_cfg.get("volume", 0.3))
|
||||
except (TypeError, ValueError):
|
||||
bgm_vol = 0.3
|
||||
bgm_vol = max(0.0, min(1.5, bgm_vol))
|
||||
# volume_adjust_db(-3 ~ +3 dB)换算线性增益
|
||||
try:
|
||||
_db = float(bgm_cfg.get("volume_adjust_db", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
_db = 0.0
|
||||
if abs(_db) > 0.05:
|
||||
db_gain = 10 ** (_db / 20.0)
|
||||
bgm_vol = max(0.0, min(2.0, bgm_vol * db_gain))
|
||||
# afade 淡入淡出
|
||||
try:
|
||||
fade_in = max(0.0, float(bgm_cfg.get("fade_in", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
fade_in = 0.0
|
||||
try:
|
||||
fade_out = max(0.0, float(bgm_cfg.get("fade_out", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
fade_out = 0.0
|
||||
# audio_offset:adelay 延迟(毫秒)
|
||||
try:
|
||||
offset = max(0.0, float(bgm_cfg.get("audio_offset", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0.0
|
||||
bgm_parts: list[str] = [f"[{next_idx}:a]aresample=44100"]
|
||||
if offset > 0.01:
|
||||
bgm_parts.append(f"adelay={int(offset * 1000)}|{int(offset * 1000)}")
|
||||
bgm_parts.append(f"volume={bgm_vol:.3f}")
|
||||
if fade_in > 0.01:
|
||||
bgm_parts.append(f"afade=t=in:st=0:d={fade_in:.2f}")
|
||||
if fade_out > 0.01 and total_duration > 0:
|
||||
fo_start = max(0.0, total_duration - fade_out)
|
||||
bgm_parts.append(f"afade=t=out:st={fo_start:.2f}:d={fade_out:.2f}")
|
||||
bgm_parts.append("aformat=sample_fmts=fltp:channel_layouts=stereo")
|
||||
fc.append(",".join(bgm_parts) + f"[{alabel}]")
|
||||
mix_labels.append(alabel)
|
||||
mix_vols.append(bgm_vol)
|
||||
next_idx += 1
|
||||
|
||||
maps: list[str] = ["-map", f"[{vfinal_label}]"]
|
||||
if mix_labels:
|
||||
mix_in = "".join(f"[{lb}]" for lb in mix_labels)
|
||||
n_mix = len(mix_labels)
|
||||
mix_parts = [
|
||||
f"amix=inputs={n_mix}:duration=longest:dropout_transition=2:normalize=0",
|
||||
"aresample=44100",
|
||||
]
|
||||
# Bug2 修复:atrim 到视频精确时长
|
||||
if total_duration and total_duration > 0:
|
||||
mix_parts.append(f"atrim=0:{total_duration:.3f}")
|
||||
mix_parts.append("asetpts=PTS-STARTPTS")
|
||||
fc.append(f"{mix_in}{','.join(mix_parts)}[afinal]")
|
||||
maps.extend(["-map", "[afinal]", "-c:a", "aac", "-b:a", "128k"])
|
||||
else:
|
||||
logger.info("[gpu-direct] no audio tracks; output silent video")
|
||||
|
||||
# 7. 组装 ffmpeg_args + NVENC 编码
|
||||
ffmpeg_args = ["-y", *input_args, "-filter_complex", ";".join(fc), *maps]
|
||||
ffmpeg_args.extend(["-c:v", vcodec, "-preset", preset, "-pix_fmt", "yuv420p"])
|
||||
if video_bitrate:
|
||||
ffmpeg_args.extend(["-b:v", video_bitrate])
|
||||
else:
|
||||
ffmpeg_args.extend(["-cq", str(cq)])
|
||||
ffmpeg_args.extend(["-movflags", "+faststart", "-shortest", "-f", "mp4", "pipe:1"])
|
||||
|
||||
return DirectRenderPlan(
|
||||
inputs=inputs,
|
||||
ffmpeg_args=ffmpeg_args,
|
||||
oss_keys=oss_keys,
|
||||
filter_complex=fc,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -86,6 +86,7 @@ class RenderAdapterResult:
|
||||
None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
|
||||
)
|
||||
temp_dir: str | None = None # 渲染临时目录,成功时由调用方清理,失败时由 finally 清理
|
||||
edge_crop_applied: bool = False # GPU 管线已做随机边缘裁剪(跳过 CPU 二次重编码)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.rendered_clip_ids is None:
|
||||
@@ -131,6 +132,7 @@ class RenderAdapter:
|
||||
work_dir: Path | None = None,
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
task_config_override: dict | None = None, # Bug A: task 级 config 覆盖,防并发竞态
|
||||
) -> RenderAdapterResult:
|
||||
"""渲染一个 EditPlan。
|
||||
|
||||
@@ -189,7 +191,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 +210,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,
|
||||
@@ -213,6 +218,7 @@ class RenderAdapter:
|
||||
rendered_clip_ids=rendered_clip_ids,
|
||||
failed_clip_ids=failed_clip_ids,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
task_config_override=task_config_override,
|
||||
)
|
||||
# 成功时将临时目录所有权转移给调用方,阻止 finally 清理
|
||||
if result.success and temp_dir:
|
||||
@@ -315,7 +321,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,9 +392,9 @@ class RenderAdapter:
|
||||
failed_clip_ids.append(clip.id)
|
||||
logger.warning("素材下载失败: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
|
||||
|
||||
return asset_path_map, rendered_clip_ids, failed_clip_ids
|
||||
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:
|
||||
def _prepare_bgm(self, plan, work_dir: Path, plan_id: str, *, bgm_override: dict | None = None) -> str | None:
|
||||
"""准备 BGM 音频文件(从 plan.config.bgm 读取配置)。
|
||||
|
||||
支持 3 种来源(按优先级):
|
||||
@@ -401,7 +407,9 @@ class RenderAdapter:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
plan_config = plan.config or {}
|
||||
bgm_config = plan_config.get("bgm", {}) or {}
|
||||
bgm_config = dict(plan_config.get("bgm", {}) or {})
|
||||
if isinstance(bgm_override, dict) and bgm_override:
|
||||
bgm_config.update(bgm_override) # Bug A: 任务级 BGM 覆盖,防并发竞态
|
||||
|
||||
if not bgm_config.get("enabled", False):
|
||||
return None
|
||||
@@ -457,13 +465,27 @@ class RenderAdapter:
|
||||
from packages.domain.preset_bgm import get_preset_bgm
|
||||
|
||||
preset = get_preset_bgm(preset_id)
|
||||
if preset and preset.audio_url:
|
||||
if preset is None:
|
||||
logger.warning("[plan_id=%s] [BGM] 预设BGM不存在: preset_id=%s", plan_id, preset_id)
|
||||
elif not preset.audio_url:
|
||||
logger.warning(
|
||||
"[plan_id=%s] [BGM] 预设BGM未部署音频文件: preset_id=%s name=%s(audio_url 为空,请运维上传音频后填入 preset_bgm.py)",
|
||||
plan_id,
|
||||
preset_id,
|
||||
preset.name,
|
||||
)
|
||||
else:
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[plan_id=%s] [BGM] 从预设库下载: preset_id=%s", plan_id, preset_id)
|
||||
logger.info(
|
||||
"[plan_id=%s] [BGM] 从预设库下载: preset_id=%s url=%s",
|
||||
plan_id,
|
||||
preset_id,
|
||||
preset.audio_url[:80],
|
||||
)
|
||||
safe_download_file(
|
||||
preset.audio_url,
|
||||
str(bgm_file),
|
||||
@@ -476,7 +498,14 @@ class RenderAdapter:
|
||||
except Exception as e:
|
||||
logger.warning("[plan_id=%s] [BGM] 预设库下载失败: %s", plan_id, e)
|
||||
|
||||
logger.warning("[plan_id=%s] [BGM] 所有来源都无法获取BGM,跳过", plan_id)
|
||||
logger.warning(
|
||||
"[plan_id=%s] [BGM] 所有来源都无法获取BGM(enabled=%s audio_url=%s asset_id=%s preset_id=%s),跳过",
|
||||
plan_id,
|
||||
bool(bgm_config.get("enabled")),
|
||||
"set" if audio_url else "empty",
|
||||
asset_id[:12] + "…" if len(asset_id) > 12 else asset_id or "empty",
|
||||
preset_id or "empty",
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -541,6 +570,8 @@ class RenderAdapter:
|
||||
rendered_clip_ids: list[str] | None = None,
|
||||
failed_clip_ids: list[str] | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
asset_storage_map: dict[str, str] | None = None,
|
||||
task_config_override: dict | None = None, # Bug A: task 级 config 覆盖,防并发竞态
|
||||
) -> RenderAdapterResult:
|
||||
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
|
||||
|
||||
@@ -554,8 +585,9 @@ class RenderAdapter:
|
||||
Returns:
|
||||
RenderAdapterResult
|
||||
"""
|
||||
# 1. 准备 BGM
|
||||
bgm_path = self._prepare_bgm(plan, work_dir, plan_id)
|
||||
# 1. 准备 BGM(Bug A: 传 task 级 bgm override)
|
||||
_bgm_override = (task_config_override or {}).get("bgm") if isinstance(task_config_override, dict) else None
|
||||
bgm_path = self._prepare_bgm(plan, work_dir, plan_id, bgm_override=_bgm_override)
|
||||
|
||||
self._report_progress(progress_cb, 40.0, "执行视频渲染")
|
||||
|
||||
@@ -563,8 +595,10 @@ class RenderAdapter:
|
||||
plan_config = plan.config or {}
|
||||
asr_service = self._get_asr_service()
|
||||
|
||||
# 3. 读取输出分辨率
|
||||
export_config = plan_config.get("export", {}) or {}
|
||||
# 3. 读取输出分辨率(Bug A: task override 优先)
|
||||
export_config = dict(plan_config.get("export", {}) or {})
|
||||
if isinstance(task_config_override, dict) and isinstance(task_config_override.get("export"), dict):
|
||||
export_config.update(task_config_override["export"])
|
||||
if not isinstance(export_config, dict):
|
||||
export_config = {}
|
||||
output_width, output_height = _parse_resolution(export_config.get("resolution"))
|
||||
@@ -589,9 +623,22 @@ class RenderAdapter:
|
||||
asr_service=asr_service,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
clip_has_text=clip_has_text,
|
||||
override_config=task_config_override,
|
||||
)
|
||||
# 注入每个视频段对应素材的 storage_key,供全 GPU 直连管线直接签名下载
|
||||
_storage_map = asset_storage_map or {}
|
||||
for c in clips:
|
||||
sk = _storage_map.get(getattr(c, "asset_id", ""))
|
||||
if sk:
|
||||
# EditPlanClip 使用 __slots__,不能 setattr,改存 config 字典
|
||||
if not isinstance(c.config, dict):
|
||||
c.config = dict(c.config) if c.config else {}
|
||||
c.config["_storage_key"] = sk
|
||||
result = render_svc.render()
|
||||
|
||||
# 4.4 透传 GPU 直连路径的 edge_crop 状态(供外层跳过 CPU 二次裁剪)
|
||||
edge_crop_applied_flag = bool(getattr(result, "edge_crop_applied", False))
|
||||
|
||||
# 4.5 渲染后校验输出完整性
|
||||
validation = validate_video_output(result.output_path)
|
||||
if not validation.valid:
|
||||
@@ -637,8 +684,22 @@ class RenderAdapter:
|
||||
# 已渲染视频在统一渲染阶段已通过 ASS 字幕把标题烧录进画面,
|
||||
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
|
||||
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
|
||||
# 构造clip分段边界 [(start, duration), ...] 供封面抽帧智能取各段中点
|
||||
try:
|
||||
_clip_boundaries = [
|
||||
(float(getattr(c, "start_time", 0.0) or 0.0), float(getattr(c, "duration", 0.0) or 0.0))
|
||||
for c in clips
|
||||
if float(getattr(c, "duration", 0.0) or 0.0) > 0
|
||||
]
|
||||
except Exception:
|
||||
_clip_boundaries = None
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, task_id=job_id, num_frames=5, title_text=""
|
||||
str(result.output_path),
|
||||
plan_id,
|
||||
task_id=job_id,
|
||||
num_frames=5,
|
||||
title_text="",
|
||||
clip_boundaries=_clip_boundaries,
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
@@ -685,6 +746,7 @@ class RenderAdapter:
|
||||
rendered_clip_ids=final_rendered_ids,
|
||||
failed_clip_ids=final_failed_ids,
|
||||
cover_candidates=cover_candidates,
|
||||
edge_crop_applied=edge_crop_applied_flag,
|
||||
)
|
||||
|
||||
def render_from_memory(
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""视频封面抽帧工具 — 从视频中抽取帧作为封面,支持标题文字叠加。
|
||||
|
||||
统一封面管道:
|
||||
封面管道(P2 优化后):
|
||||
- 黑屏检测:ffmpeg blackdetect 扫描黑屏区间,抽帧点自动避开黑屏
|
||||
- 单次 ffmpeg select 抽多帧:一次 ffmpeg 进程用 select 滤镜输出 5 帧,避免 5 次起停进程
|
||||
- 并发上传:5 帧用 ThreadPoolExecutor 并行上传 OSS,目标封面阶段 <1.5s
|
||||
- 质量评分:cv2 清晰度/亮度/色彩三维评分选最佳帧
|
||||
- 可选 MediaKit 路径:配置 MEDIAKIT_COVER_ENABLED=true 时启用火山 MediaKit SceneChange 抽帧
|
||||
- 从已渲染视频抽帧:标题已通过 ASS 字幕烧进视频,帧天然带标题,无需再叠加。
|
||||
- 从源素材抽帧(API E2 兜底):源素材无标题,通过 Pillow 在帧上绘制标题文字。
|
||||
"""
|
||||
@@ -8,14 +13,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 标题叠加(Pillow)──────────────────────────────────────────────────────
|
||||
# 实现统一放在 packages/shared/title_overlay.py,API 和 Worker 共用。
|
||||
|
||||
|
||||
def apply_title_overlay(
|
||||
image_path: str,
|
||||
@@ -27,11 +32,7 @@ def apply_title_overlay(
|
||||
margin_ratio: float = 0.06,
|
||||
stroke_width_ratio: float = 0.04,
|
||||
) -> str:
|
||||
"""在图片上绘制标题文字(指定颜色 + 黑色描边/阴影)。
|
||||
|
||||
委托给 packages.shared.title_overlay.apply_title_to_image,
|
||||
保持 Worker 内调用方式不变。title_text 为空时直接返回原路径。
|
||||
"""
|
||||
"""在图片上绘制标题文字(指定颜色 + 黑色描边/阴影)。"""
|
||||
from packages.shared.title_overlay import apply_title_to_image
|
||||
|
||||
if not title_text or not title_text.strip():
|
||||
@@ -56,26 +57,19 @@ def extract_first_frame(
|
||||
height: int = -1,
|
||||
timeout: int = 30,
|
||||
seek_ratio: float = 0.15,
|
||||
seek_seconds: float | None = None,
|
||||
min_seek_seconds: float = 1.0,
|
||||
) -> str:
|
||||
"""抽取视频封面帧(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
||||
|
||||
因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。
|
||||
"""抽取视频封面帧(ffmpeg -ss 单帧 seek,<100ms/帧)。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径,不传则用临时文件
|
||||
width: 输出宽度(默认 -1,保持原始分辨率)
|
||||
height: 输出高度(默认 -1,保持原始分辨率)
|
||||
timeout: 超时时间(秒)
|
||||
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
|
||||
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
|
||||
|
||||
Returns:
|
||||
生成的封面帧文件路径
|
||||
|
||||
Raises:
|
||||
RuntimeError: ffmpeg 执行失败或输出文件为空
|
||||
width/height: 输出宽高(默认保持原始分辨率)
|
||||
timeout: 超时(秒)
|
||||
seek_ratio: 抽帧位置占视频时长的比例
|
||||
seek_seconds: 指定具体抽帧时间点(秒),优先于 seek_ratio
|
||||
min_seek_seconds: 最小抽帧时间
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
|
||||
@@ -87,31 +81,25 @@ def extract_first_frame(
|
||||
_is_temp_output = True
|
||||
|
||||
try:
|
||||
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||||
except Exception:
|
||||
# probe 失败时 fallback 到第1秒
|
||||
seek_time = min_seek_seconds
|
||||
if seek_seconds is not None:
|
||||
seek_time = max(0.0, float(seek_seconds))
|
||||
else:
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||||
except Exception:
|
||||
seek_time = min_seek_seconds
|
||||
|
||||
# 格式化为 HH:MM:SS.xx
|
||||
seek_str = _format_seek_time(seek_time)
|
||||
|
||||
# 构建 scale filter:如果指定了宽高则缩放,否则保持原始分辨率。
|
||||
# NOTE: scale_filter 在此处通过 if/else 分支赋值,之后不再被覆盖,
|
||||
# 后续 cmd / cmd2 均复用同一变量,逻辑无变化。
|
||||
if width > 0 or height > 0:
|
||||
w_str = str(width) if width > 0 else "-1"
|
||||
h_str = str(height) if height > 0 else "-1"
|
||||
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
||||
else:
|
||||
# 保持原始分辨率,只确保格式兼容
|
||||
scale_filter = "format=yuvj420p"
|
||||
|
||||
# -ss 放在 -i 前面(input seeking,更快)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
# -ss 放在 -i 前面(input seeking,极快),-vframes 1 只取一帧
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
@@ -154,7 +142,6 @@ def extract_first_frame(
|
||||
|
||||
return output_path
|
||||
except Exception:
|
||||
# 失败时清理自己创建的临时文件
|
||||
if _is_temp_output and output_path:
|
||||
try:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
@@ -164,7 +151,6 @@ def extract_first_frame(
|
||||
|
||||
|
||||
def _format_seek_time(seconds: float) -> str:
|
||||
"""将秒数格式化为 HH:MM:SS.xx 格式。"""
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = seconds % 60
|
||||
@@ -177,19 +163,7 @@ def generate_and_upload_thumbnail(
|
||||
*,
|
||||
seek_ratio: float = 0.15,
|
||||
) -> str:
|
||||
"""从视频中提取一帧缩略图并上传到 OSS。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
storage_key: OSS 存储 key
|
||||
seek_ratio: 抽帧位置比例(默认 0.15)
|
||||
|
||||
Returns:
|
||||
上传后的 URL 字符串
|
||||
|
||||
Raises:
|
||||
RuntimeError: 抽帧或上传失败
|
||||
"""
|
||||
"""从视频中提取一帧缩略图并上传到 OSS。"""
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
@@ -204,24 +178,303 @@ def generate_and_upload_thumbnail(
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _detect_black_intervals(
|
||||
video_path: str,
|
||||
duration: float,
|
||||
*,
|
||||
black_min_duration: float = 0.3,
|
||||
picture_black_ratio_th: float = 0.98,
|
||||
pixel_black_th: float = 0.10,
|
||||
timeout: int = 30,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""用 ffmpeg blackdetect 扫描黑屏区间,返回 [(start, end), ...]。"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
if duration <= 0:
|
||||
return []
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-nostdin",
|
||||
"-i",
|
||||
video_path,
|
||||
"-vf",
|
||||
(f"blackdetect=d={black_min_duration:.2f}:pic_th={picture_black_ratio_th:.2f}:pix_th={pixel_black_th:.2f}"),
|
||||
"-an",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]
|
||||
try:
|
||||
_, stderr = run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] blackdetect 失败,忽略黑屏规避: %s", e)
|
||||
return []
|
||||
|
||||
intervals: list[tuple[float, float]] = []
|
||||
pattern = re.compile(
|
||||
r"black_start:(\d+(?:\.\d+)?)\s+black_end:(\d+(?:\.\d+)?)\s+black_duration:(\d+(?:\.\d+)?)",
|
||||
)
|
||||
for m in pattern.finditer(stderr or ""):
|
||||
try:
|
||||
bs = float(m.group(1))
|
||||
be = float(m.group(2))
|
||||
intervals.append((bs, be))
|
||||
except ValueError:
|
||||
continue
|
||||
intervals.sort()
|
||||
if intervals:
|
||||
logger.info("[thumbnail] blackdetect 发现 %d 段黑屏: %s", len(intervals), intervals[:5])
|
||||
return intervals
|
||||
|
||||
|
||||
def _adjust_seek_points_avoid_black(
|
||||
seek_points: list[float],
|
||||
black_intervals: list[tuple[float, float]],
|
||||
duration: float,
|
||||
*,
|
||||
tolerance: float = 0.25,
|
||||
) -> list[float]:
|
||||
"""把落在黑屏区间的 seek 点偏移到最近的非黑屏位置。
|
||||
|
||||
策略:
|
||||
- 若点在黑屏内,先尝试向前偏移到黑屏起点 - tolerance,再尝试向后偏移到黑屏终点 + tolerance;
|
||||
- 若整个视频全黑(偏移后 <0 或 >duration),保留原点但日志标记警告;
|
||||
- 偏移后若点与已有点重合(误差 <0.3s),做微调去重。
|
||||
"""
|
||||
if not black_intervals or not seek_points:
|
||||
return list(seek_points)
|
||||
|
||||
def in_black(t: float) -> tuple[float, float] | None:
|
||||
for bs, be in black_intervals:
|
||||
if bs <= t <= be:
|
||||
return (bs, be)
|
||||
return None
|
||||
|
||||
adjusted: list[float] = []
|
||||
for t in seek_points:
|
||||
seg = in_black(t)
|
||||
if seg is None:
|
||||
adjusted.append(max(0.0, min(duration, t)))
|
||||
continue
|
||||
bs, be = seg
|
||||
# 先尝试向前
|
||||
forward_t = bs - tolerance
|
||||
if forward_t >= 0.0 and in_black(forward_t) is None:
|
||||
adjusted.append(forward_t)
|
||||
continue
|
||||
# 再尝试向后
|
||||
backward_t = be + tolerance
|
||||
if backward_t <= duration and in_black(backward_t) is None:
|
||||
adjusted.append(backward_t)
|
||||
continue
|
||||
# 整段 clip 全黑?保留中点但标记
|
||||
logger.warning(
|
||||
"[thumbnail] seek 点 %.2fs 落在黑屏区间 [%.2f,%.2f] 且无法偏移,保留原位置(可能是全黑片段)",
|
||||
t,
|
||||
bs,
|
||||
be,
|
||||
)
|
||||
adjusted.append(max(0.0, min(duration, t)))
|
||||
|
||||
# 去重:相邻点若 <0.3s 则拉开
|
||||
adjusted.sort()
|
||||
deduped: list[float] = []
|
||||
for t in adjusted:
|
||||
if not deduped or abs(t - deduped[-1]) >= 0.3:
|
||||
deduped.append(t)
|
||||
else:
|
||||
# 往后挪 0.5s
|
||||
nt = t + 0.5
|
||||
if nt <= duration and in_black(nt) is None:
|
||||
deduped.append(nt)
|
||||
else:
|
||||
deduped.append(t)
|
||||
return [round(max(0.0, min(duration, t)), 3) for t in deduped[: len(seek_points)]]
|
||||
|
||||
|
||||
def _extract_frames_single_pass(
|
||||
video_path: str,
|
||||
seek_points: list[float],
|
||||
out_dir: str,
|
||||
*,
|
||||
prefix: str = "frame",
|
||||
width: int = -1,
|
||||
height: int = -1,
|
||||
q: int = 2,
|
||||
timeout: int = 30,
|
||||
) -> list[tuple[float, str]]:
|
||||
"""单次 ffmpeg 用 select 滤镜抽出 seek_points 对应的多帧。
|
||||
|
||||
ffmpeg -i input -vf "select='between(t,t1-0.03,t1+0.03)+between(t,t2-0.03,t2+0.03)+...',scale=...,format=yuvj420p"
|
||||
-vsync vfr -q:v 2 out_dir/prefix_%02d.jpg
|
||||
|
||||
返回 [(seek_t, output_path), ...],按输出帧序号升序。若输出帧数 < seek_points 数量,
|
||||
不足部分用 extract_first_frame 兜底(保证返回数量 == len(seek_points))。
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
out_dir_p = Path(out_dir)
|
||||
out_dir_p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 构造 select 表达式:每个 seek 点用 ±30ms 窗口命中
|
||||
# between(t, a, b) 返回 1 表示 t 在 [a,b] 内;多个 between 相加即为"任一命中"
|
||||
select_terms = []
|
||||
for t in seek_points:
|
||||
a = max(0.0, t - 0.03)
|
||||
b = t + 0.04
|
||||
select_terms.append(f"between(t,{a:.3f},{b:.3f})")
|
||||
select_expr = "+".join(select_terms)
|
||||
|
||||
if width > 0 or height > 0:
|
||||
w_str = str(width) if width > 0 else "-1"
|
||||
h_str = str(height) if height > 0 else "-1"
|
||||
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease"
|
||||
vf = f"select='{select_expr}',{scale_filter},format=yuvj420p"
|
||||
else:
|
||||
vf = f"select='{select_expr}',format=yuvj420p"
|
||||
|
||||
out_pattern = str(out_dir_p / f"{prefix}_%02d.jpg")
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-vf",
|
||||
vf,
|
||||
"-vsync",
|
||||
"vfr",
|
||||
"-q:v",
|
||||
str(q),
|
||||
out_pattern,
|
||||
]
|
||||
|
||||
results: list[tuple[float, str]] = []
|
||||
single_pass_ok = False
|
||||
try:
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
# 读取输出文件
|
||||
for i in range(1, len(seek_points) + 1):
|
||||
fp = out_dir_p / f"{prefix}_{i:02d}.jpg"
|
||||
if fp.exists() and fp.stat().st_size > 0:
|
||||
results.append((seek_points[i - 1] if i - 1 < len(seek_points) else 0.0, str(fp)))
|
||||
if len(results) >= len(seek_points):
|
||||
single_pass_ok = True
|
||||
else:
|
||||
logger.warning(
|
||||
"[thumbnail] 单次 ffmpeg 抽帧仅命中 %d/%d 帧,不足部分用单帧 seek 兜底",
|
||||
len(results),
|
||||
len(seek_points),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 单次 ffmpeg select 抽帧失败,回退到单帧 seek: %s", e)
|
||||
|
||||
# 兜底:对缺失/失败的帧用 extract_first_frame 补抽
|
||||
if not single_pass_ok:
|
||||
# 清理不完整结果
|
||||
for _, fp in results:
|
||||
try:
|
||||
Path(fp).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
results = []
|
||||
for i, st in enumerate(seek_points):
|
||||
fp = out_dir_p / f"{prefix}_fallback_{i:02d}.jpg"
|
||||
try:
|
||||
extract_first_frame(
|
||||
video_path,
|
||||
output_path=str(fp),
|
||||
seek_seconds=st,
|
||||
min_seek_seconds=0.5,
|
||||
timeout=timeout,
|
||||
)
|
||||
if fp.exists() and fp.stat().st_size > 0:
|
||||
results.append((st, str(fp)))
|
||||
else:
|
||||
logger.warning("[thumbnail] 兜底单帧抽帧也失败 idx=%d t=%.2f", i, st)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 兜底单帧抽帧异常 idx=%d t=%.2f: %s", i, st, e)
|
||||
|
||||
return results[: len(seek_points)]
|
||||
|
||||
|
||||
def _compute_clip_boundary_seek_points(
|
||||
duration: float,
|
||||
clip_boundaries: Optional[list[tuple[float, float]]] = None,
|
||||
num_frames: int = 5,
|
||||
head_skip_ratio: float = 0.08,
|
||||
tail_skip_ratio: float = 0.08,
|
||||
) -> list[float]:
|
||||
"""基于clip分段边界计算抽帧时间点(取每段中间帧,效果比均匀抽更好)。
|
||||
|
||||
策略:
|
||||
- 如果传入 clip_boundaries(每个元素是 (clip_start_in_timeline, clip_duration)),
|
||||
取每个片段的中点作为抽帧候选点
|
||||
- 候选点不足 num_frames 时,均匀补充
|
||||
- 跳过片头 head_skip_ratio(8%,避免片头黑屏/开场标题)和片尾 tail_skip_ratio(8%)
|
||||
- 返回按时间排序的 num_frames 个抽帧点(秒)
|
||||
"""
|
||||
if duration <= 0:
|
||||
# 无法probe,均匀分布兜底
|
||||
return [max(1.0, duration * (0.1 + 0.8 * i / max(num_frames - 1, 1))) for i in range(num_frames)]
|
||||
|
||||
head_skip = duration * head_skip_ratio
|
||||
tail_skip = duration * tail_skip_ratio
|
||||
valid_start = head_skip
|
||||
valid_end = max(valid_start + 1.0, duration - tail_skip)
|
||||
|
||||
candidates: list[float] = []
|
||||
|
||||
if clip_boundaries:
|
||||
# 累加timeline start,取每clip中点
|
||||
cur = 0.0
|
||||
for _clip_start, clip_dur in clip_boundaries:
|
||||
if clip_dur <= 0:
|
||||
continue
|
||||
mid = cur + clip_dur / 2.0
|
||||
if valid_start <= mid <= valid_end:
|
||||
candidates.append(mid)
|
||||
cur += clip_dur
|
||||
# 去重+排序
|
||||
candidates = sorted(set(round(c, 3) for c in candidates))
|
||||
|
||||
# 如果候选点不足,均匀补充
|
||||
if len(candidates) < num_frames:
|
||||
needed = num_frames - len(candidates)
|
||||
existing = set(round(c, 1) for c in candidates)
|
||||
for i in range(needed * 3):
|
||||
ratio = 0.1 + 0.8 * (i + 0.5) / (needed * 3)
|
||||
t = valid_start + (valid_end - valid_start) * ratio
|
||||
if round(t, 1) not in existing:
|
||||
candidates.append(t)
|
||||
existing.add(round(t, 1))
|
||||
if len(candidates) >= num_frames:
|
||||
break
|
||||
|
||||
# 如果还不够,强制均匀
|
||||
while len(candidates) < num_frames:
|
||||
idx = len(candidates)
|
||||
ratio = 0.1 + 0.8 * idx / max(num_frames - 1, 1)
|
||||
candidates.append(valid_start + (valid_end - valid_start) * ratio)
|
||||
|
||||
candidates.sort()
|
||||
|
||||
# 如果超过num_frames,均匀选取
|
||||
if len(candidates) > num_frames:
|
||||
step = len(candidates) / num_frames
|
||||
candidates = [candidates[int(i * step)] for i in range(num_frames)]
|
||||
|
||||
return [round(t, 3) for t in candidates[:num_frames]]
|
||||
|
||||
|
||||
def _extract_frames_via_mediakit(
|
||||
video_path: str,
|
||||
plan_id: str,
|
||||
num_frames: int,
|
||||
) -> list[dict] | None:
|
||||
"""使用 MediaKit 智能抽帧 API 提取封面帧。
|
||||
|
||||
Args:
|
||||
video_path: 本地视频文件路径
|
||||
plan_id: 编辑计划 ID
|
||||
num_frames: 需要的帧数
|
||||
|
||||
Returns:
|
||||
帧列表 [{"image_url": str, "timestamp": float}, ...],失败返回 None
|
||||
"""
|
||||
"""使用 MediaKit 智能抽帧 API 提取封面帧(fallback 路径,默认不启用)。"""
|
||||
import uuid
|
||||
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
from video_processing.oss_helpers import delete_from_oss, get_signed_download_url, upload_to_oss
|
||||
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
@@ -230,45 +483,38 @@ def _extract_frames_via_mediakit(
|
||||
logger.info("[thumbnail] MediaKit 未配置,跳过智能抽帧")
|
||||
return None
|
||||
|
||||
# 1. 上传视频到 OSS 获取 URL
|
||||
video_storage_key: str = ""
|
||||
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])
|
||||
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)
|
||||
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到本地 ffmpeg", e)
|
||||
return None
|
||||
|
||||
# 2. 调用 MediaKit 智能抽帧
|
||||
try:
|
||||
frames = client.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="SceneChange",
|
||||
max_frames=num_frames * 2, # 多取一些帧供选择
|
||||
max_frames=num_frames * 2,
|
||||
)
|
||||
if not frames:
|
||||
logger.warning("[thumbnail] MediaKit 抽帧返回空,降级到 ffmpeg")
|
||||
logger.warning("[thumbnail] MediaKit 抽帧返回空")
|
||||
return None
|
||||
|
||||
# 选取最均匀的 num_frames 个帧
|
||||
if len(frames) > num_frames:
|
||||
step = len(frames) // num_frames
|
||||
frames = [frames[i * step] for i in range(num_frames)]
|
||||
|
||||
logger.info("[thumbnail] MediaKit 抽帧成功: %d 帧", len(frames))
|
||||
return frames
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] MediaKit 抽帧异常: %s,降级到 ffmpeg", e)
|
||||
logger.warning("[thumbnail] MediaKit 抽帧异常: %s", e)
|
||||
return None
|
||||
finally:
|
||||
# 清理临时视频文件
|
||||
try:
|
||||
from video_processing.oss_helpers import delete_from_oss
|
||||
|
||||
delete_from_oss(video_storage_key)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -279,161 +525,235 @@ def extract_and_upload_cover_frames(
|
||||
plan_id: str,
|
||||
*,
|
||||
task_id: str = "",
|
||||
num_frames: int = 5, # 抽 5 帧候选,通过质量评分选出最佳帧
|
||||
num_frames: int = 5,
|
||||
title_text: str = "",
|
||||
title_color: str = "#ffffff",
|
||||
title_position: str = "bottom",
|
||||
title_font_size: int | None = None,
|
||||
clip_boundaries: Optional[list[tuple[float, float]]] = None,
|
||||
) -> list[dict]:
|
||||
"""从视频中抽取多帧作为封面候选,通过质量评分选出最佳帧,上传到 OSS。
|
||||
|
||||
流程:
|
||||
1. 优先使用 MediaKit 智能抽帧(多抽一些供选择)
|
||||
2. MediaKit 不足时降级到 ffmpeg 均匀抽帧
|
||||
3. 对所有候选帧进行质量评分(清晰度/亮度/色彩丰富度)
|
||||
4. 按分数从高到低排序返回
|
||||
P2 优化:
|
||||
- 先用 ffmpeg blackdetect 扫描黑屏区间,seek 点自动避开黑屏
|
||||
- 单次 ffmpeg select 抽 num_frames 帧(避免 5 次起停 ffmpeg 进程)
|
||||
- 多帧 OSS 上传用 ThreadPoolExecutor 并发,目标封面阶段 <1.5s
|
||||
- cv2 清晰度/亮度/色彩三维评分选最佳帧
|
||||
|
||||
Fallback(MEDIAKIT_COVER_ENABLED=true):火山 MediaKit SceneChange 抽帧(~60-90s)。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||||
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
|
||||
num_frames: 抽取候选帧数(默认 5,通过质量评分选出最佳帧)
|
||||
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
|
||||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||||
title_color: 标题字体颜色(#RRGGBB)
|
||||
title_position: 标题位置 top/center/bottom
|
||||
title_font_size: 标题字号,None 时自动计算
|
||||
|
||||
Returns:
|
||||
封面候选列表(按质量分数降序),每项包含 {"url": str, "position": float, "score": float}
|
||||
clip_boundaries: 片段边界列表 [(clip_start, clip_duration), ...],用于智能取点
|
||||
"""
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
except Exception:
|
||||
duration = 0.0
|
||||
|
||||
candidates: list[dict] = []
|
||||
_temp_paths: list[str] = [] # 收集所有临时文件路径,最后统一清理
|
||||
_temp_paths: list[str] = []
|
||||
|
||||
try:
|
||||
# ── 阶段 1:抽帧 ──────────────────────────────────────────────
|
||||
# 优先尝试 MediaKit 智能抽帧
|
||||
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
|
||||
if mediakit_frames:
|
||||
for i, frame in enumerate(mediakit_frames):
|
||||
frame_url = frame.get("image_url")
|
||||
if not frame_url:
|
||||
continue
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
_temp_paths.append(tmp.name)
|
||||
try:
|
||||
# 下载 MediaKit 返回的帧图
|
||||
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
with open(tmp.name, "wb") as f:
|
||||
f.write(resp.content)
|
||||
settings = get_shared_settings()
|
||||
use_mediakit = getattr(settings, "mediakit_cover_enabled", False)
|
||||
|
||||
# 叠加标题文字(如需要)
|
||||
if title_text and title_text.strip():
|
||||
apply_title_overlay(
|
||||
tmp.name,
|
||||
title_text,
|
||||
color=title_color,
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
if use_mediakit:
|
||||
logger.info("[thumbnail] MEDIAKIT_COVER_ENABLED=true,走 MediaKit 路径")
|
||||
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
|
||||
if mediakit_frames:
|
||||
for i, frame in enumerate(mediakit_frames):
|
||||
frame_url = frame.get("image_url")
|
||||
if not frame_url:
|
||||
continue
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
_temp_paths.append(tmp.name)
|
||||
try:
|
||||
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
with open(tmp.name, "wb") as f:
|
||||
f.write(resp.content)
|
||||
if title_text and title_text.strip():
|
||||
apply_title_overlay(
|
||||
tmp.name,
|
||||
title_text,
|
||||
color=title_color,
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
|
||||
url = upload_to_oss(tmp.name, storage_key)
|
||||
if url:
|
||||
candidates.append(
|
||||
{
|
||||
"url": url,
|
||||
"position": round(frame.get("timestamp", 0.0), 2),
|
||||
"image_path": tmp.name,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
|
||||
if len(candidates) >= num_frames:
|
||||
logger.info("[thumbnail] MediaKit 抽帧完成: %d 帧", len(candidates))
|
||||
|
||||
# MediaKit 路径帧在 NamedTemporaryFile 中持久存在(finally 清理),在进入本地 ffmpeg 前评分
|
||||
if len(candidates) > 1:
|
||||
try:
|
||||
from packages.shared.cover_frame_scorer import score_frames
|
||||
|
||||
candidates = score_frames(candidates)
|
||||
logger.info(
|
||||
"[thumbnail] MediaKit 封面帧评分完成: count=%d best_score=%.1f",
|
||||
len(candidates),
|
||||
candidates[0].get("score", 0.0) if candidates else 0.0,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("[thumbnail] MediaKit 封面帧质量评分失败,保持原始顺序", exc_info=True)
|
||||
|
||||
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
|
||||
url = upload_to_oss(tmp.name, storage_key)
|
||||
if url:
|
||||
seek_time = frame.get("timestamp", 0.0)
|
||||
candidates.append(
|
||||
{
|
||||
"url": url,
|
||||
"position": round(seek_time, 2),
|
||||
"image_path": tmp.name,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
|
||||
|
||||
if len(candidates) >= num_frames:
|
||||
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d 帧", len(candidates))
|
||||
else:
|
||||
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
|
||||
|
||||
# Fallback: ffmpeg 直接抽帧(仅当 MediaKit 不足时)
|
||||
# ── 默认路径:本地 ffmpeg 单次 select 抽帧 + 并发上传 ──────────────
|
||||
if len(candidates) < num_frames:
|
||||
logger.info("[thumbnail] 使用 ffmpeg 抽帧补充")
|
||||
# 均匀分布抽帧点:从 10% 到 90%
|
||||
for i in range(num_frames):
|
||||
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
_temp_paths.append(tmp.name)
|
||||
try:
|
||||
frame_path = extract_first_frame(
|
||||
video_path,
|
||||
output_path=tmp.name,
|
||||
seek_ratio=ratio,
|
||||
min_seek_seconds=0.5,
|
||||
)
|
||||
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
|
||||
if title_text and title_text.strip():
|
||||
apply_title_overlay(
|
||||
frame_path,
|
||||
title_text,
|
||||
color=title_color,
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if url:
|
||||
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
|
||||
candidates.append(
|
||||
{
|
||||
"url": url,
|
||||
"position": round(seek_time, 2),
|
||||
"image_path": tmp.name,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
|
||||
|
||||
# ── 阶段 2:质量评分 ────────────────────────────────────────────
|
||||
if len(candidates) > 1:
|
||||
try:
|
||||
from packages.shared.cover_frame_scorer import score_frames
|
||||
|
||||
candidates = score_frames(candidates)
|
||||
if candidates:
|
||||
logger.info("[thumbnail] MediaKit 不足 %d 帧,本地 ffmpeg 补充", num_frames)
|
||||
else:
|
||||
logger.info(
|
||||
"[thumbnail] 封面帧质量评分完成: plan_id=%s count=%d best_score=%.1f",
|
||||
"[thumbnail] 使用本地 ffmpeg 抽帧(num=%d, duration=%.1fs)",
|
||||
num_frames,
|
||||
duration,
|
||||
)
|
||||
|
||||
# 1) 计算 seek 点
|
||||
seek_points = _compute_clip_boundary_seek_points(duration, clip_boundaries, num_frames)
|
||||
|
||||
# 2) 黑屏检测 + 偏移 seek 点
|
||||
black_intervals = _detect_black_intervals(video_path, duration) if duration > 0 else []
|
||||
if black_intervals:
|
||||
seek_points = _adjust_seek_points_avoid_black(seek_points, black_intervals, duration)
|
||||
logger.info("[thumbnail] 黑屏规避后 seek 点: %s", seek_points)
|
||||
|
||||
# 3) 单次 ffmpeg select 抽出所有帧(带失败兜底到单帧 seek)
|
||||
with tempfile.TemporaryDirectory(prefix="thumb_") as frame_dir:
|
||||
t1 = time.monotonic()
|
||||
frame_results = _extract_frames_single_pass(
|
||||
video_path,
|
||||
seek_points,
|
||||
frame_dir,
|
||||
prefix="frame",
|
||||
)
|
||||
logger.info("[thumbnail] 抽帧耗时: %.2fs (%d 帧)", time.monotonic() - t1, len(frame_results))
|
||||
|
||||
# 4) 标题叠加(本地,CPU 很快)
|
||||
for _st, fp in frame_results:
|
||||
if title_text and title_text.strip():
|
||||
try:
|
||||
apply_title_overlay(
|
||||
fp,
|
||||
title_text,
|
||||
color=title_color,
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 标题叠加失败 %s: %s", fp, e)
|
||||
|
||||
# 5) 质量评分(必须在 TemporaryDirectory 内,帧文件还在磁盘上)
|
||||
t_score = time.monotonic()
|
||||
local_candidates: list[dict] = [{"position": st, "image_path": fp} for (st, fp) in frame_results]
|
||||
scored: list[dict] = local_candidates
|
||||
if len(local_candidates) > 1:
|
||||
try:
|
||||
from packages.shared.cover_frame_scorer import score_frames
|
||||
|
||||
scored = score_frames(local_candidates)
|
||||
logger.info(
|
||||
"[thumbnail] 封面评分耗时: %.2fs (best_score=%.1f, count=%d)",
|
||||
time.monotonic() - t_score,
|
||||
scored[0].get("score", 0.0) if scored else 0.0,
|
||||
len(scored),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[thumbnail] 封面帧质量评分失败,保持 seek 点原始顺序",
|
||||
exc_info=True,
|
||||
)
|
||||
scored = local_candidates
|
||||
|
||||
# 6) 按评分顺序并发上传 OSS(best 帧先上传;best 已是 scored[0])
|
||||
t2 = time.monotonic()
|
||||
|
||||
def _upload_one(rank: int, st: float, fp: str, score: float) -> dict | None:
|
||||
try:
|
||||
storage_key = f"covers/{plan_id}/{task_id}/frame_{rank}.jpg"
|
||||
url = upload_to_oss(fp, storage_key)
|
||||
if url:
|
||||
return {
|
||||
"url": url,
|
||||
"position": st,
|
||||
"image_path": fp,
|
||||
"score": score,
|
||||
"is_best": rank == 0,
|
||||
}
|
||||
logger.warning("[thumbnail] 上传失败 rank=%d t=%.2f", rank, st)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 上传异常 rank=%d t=%.2f: %s", rank, st, e)
|
||||
return None
|
||||
|
||||
upload_results: list[dict | None] = [None] * len(scored)
|
||||
max_workers = min(8, max(2, len(scored)))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
future_map = {
|
||||
pool.submit(
|
||||
_upload_one,
|
||||
i,
|
||||
float(c.get("position", 0.0)),
|
||||
str(c["image_path"]),
|
||||
float(c.get("score", 0.0)),
|
||||
): i
|
||||
for i, c in enumerate(scored)
|
||||
}
|
||||
for fut in as_completed(future_map):
|
||||
i = future_map[fut]
|
||||
try:
|
||||
upload_results[i] = fut.result()
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 上传 future 异常 rank=%d: %s", i, e)
|
||||
logger.info("[thumbnail] 并发上传耗时: %.2fs", time.monotonic() - t2)
|
||||
|
||||
for r in upload_results:
|
||||
if r is not None:
|
||||
# 本地帧在 TemporaryDirectory 内,with 退出自动删除,无需进 _temp_paths
|
||||
candidates.append(r)
|
||||
|
||||
# 如果本地 ffmpeg 路径产生了候选(已评分)但未经过 MediaKit 路径,candidates 已按评分顺序排好。
|
||||
# 混合场景下(MediaKit + 本地 ffmpeg 都产出),统一按 score 降序排列;缺失 score 的(理论上不应出现)排末尾。
|
||||
if len(candidates) > 1:
|
||||
candidates.sort(key=lambda c: c.get("score", -1.0), reverse=True)
|
||||
if candidates:
|
||||
candidates[0]["is_best"] = True
|
||||
elapsed = time.monotonic() - t0
|
||||
logger.info(
|
||||
"[thumbnail] 封面完成: plan_id=%s count=%d best=t%.2fs score=%.1f elapsed=%.2fs",
|
||||
plan_id,
|
||||
len(candidates),
|
||||
candidates[0].get("score", 0.0) if candidates else 0.0,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[thumbnail] 封面帧质量评分失败,保持原始顺序: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
candidates[0].get("position", 0.0),
|
||||
candidates[0].get("score", 0.0),
|
||||
elapsed,
|
||||
)
|
||||
|
||||
# ── 阶段 3:清理临时文件 ────────────────────────────────────────
|
||||
# 移除 image_path(不再需要),但临时文件统一清理
|
||||
for c in candidates:
|
||||
c.pop("image_path", None)
|
||||
|
||||
return candidates
|
||||
|
||||
finally:
|
||||
# 统一清理所有临时文件
|
||||
for path in _temp_paths:
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
@@ -63,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__)
|
||||
|
||||
@@ -111,6 +112,7 @@ class RenderResult:
|
||||
file_size: int
|
||||
width: int
|
||||
height: int
|
||||
edge_crop_applied: bool = False # True = GPU管线已做随机边缘裁剪
|
||||
|
||||
|
||||
# ── clip_type → layer role 映射 ──────────────────────────────────────────────
|
||||
@@ -156,6 +158,7 @@ class UnifiedRenderService:
|
||||
bgm_path: str | None = None, # BGM 本地文件路径
|
||||
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
|
||||
clip_has_text: list[bool] | None = None, # 源视频片段是否有文字(来自 atom_clip.ai_tags.has_text)
|
||||
override_config: dict | None = None, # Bug A: task 级 config 覆盖(title/bgm/export/subtitle),防并发竞态
|
||||
):
|
||||
self.plan = plan
|
||||
self.clips = clips
|
||||
@@ -168,6 +171,9 @@ class UnifiedRenderService:
|
||||
self.asr_service = asr_service
|
||||
self.bgm_path = bgm_path
|
||||
self.voiceover_audio_path = voiceover_audio_path
|
||||
# Bug A: task 级 config override(深拷贝),优先级高于 plan.config;
|
||||
# 避免同 plan 多任务并发渲染时 _sync_task_config_to_plan 写 plan.config["title"] 互相覆盖。
|
||||
self._override_config = dict(override_config) if isinstance(override_config, dict) else {}
|
||||
# #1970:片段级文字检测(顺序与非 audio 的源视频片段一致);None 表示无可靠检测,保守不翻转
|
||||
self._clip_has_text = clip_has_text
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
@@ -178,6 +184,28 @@ class UnifiedRenderService:
|
||||
self._micro_plan_cache: Any = None
|
||||
self._micro_plan_loaded = False
|
||||
|
||||
def _cfg_section(self, section: str) -> dict:
|
||||
"""读取单个配置段:override_config 优先于 plan.config(Bug A 防并发竞态)。"""
|
||||
base = dict((self.plan.config or {}).get(section, {}) or {})
|
||||
override = self._override_config.get(section)
|
||||
if isinstance(override, dict) and override:
|
||||
base.update(override) # 浅合并,保留 base 中未被覆盖字段
|
||||
return base
|
||||
|
||||
def _effective_config(self) -> dict:
|
||||
"""读取完整 config:override_config 顶层段覆盖 plan.config(Bug A 防并发竞态)。"""
|
||||
import copy
|
||||
|
||||
full = copy.deepcopy(self.plan.config or {})
|
||||
for k, v in self._override_config.items():
|
||||
if isinstance(v, dict):
|
||||
sec = dict(full.get(k, {}) or {})
|
||||
sec.update(v)
|
||||
full[k] = sec
|
||||
else:
|
||||
full[k] = v
|
||||
return full
|
||||
|
||||
# ── #1970 PR2 智能降重:片段级微变换 ───────────────────────────────────
|
||||
def _dedup_enabled(self) -> bool:
|
||||
"""读取 plan.config.dedup_enabled,缺省视为 True(向后兼容)。"""
|
||||
@@ -232,7 +260,7 @@ class UnifiedRenderService:
|
||||
return
|
||||
if abs(mt.brightness) > 1e-4 or abs(mt.contrast - 1.0) > 1e-4 or abs(mt.saturation - 1.0) > 1e-4:
|
||||
filters.append(
|
||||
f"eq=brightness={mt.brightness:+.4f}:" f"contrast={mt.contrast:.4f}:saturation={mt.saturation:.4f}"
|
||||
f"eq=brightness={mt.brightness:+.4f}:contrast={mt.contrast:.4f}:saturation={mt.saturation:.4f}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -340,6 +368,36 @@ class UnifiedRenderService:
|
||||
len(pip_sources),
|
||||
)
|
||||
|
||||
# 4.8 全 GPU 直连管线(P1):命中主流场景则跳过 mezzanine/边缘裁剪 CPU 重编码
|
||||
output_path = self.work_dir / f"rendered_{self.plan.id}.mp4"
|
||||
direct_result = self._try_gpu_direct(
|
||||
layers=layers,
|
||||
ass_path=ass_path,
|
||||
video_duration=video_duration_final,
|
||||
output_path=output_path,
|
||||
)
|
||||
if direct_result is not None and direct_result[0]:
|
||||
_direct_edge_crop = bool(direct_result[1])
|
||||
# 直连成功:直接探测并返回,跳过后续视频/音频 CPU 流程
|
||||
duration, file_size, width, height = self._probe_output(output_path)
|
||||
logger.info(
|
||||
"[unified-render] gpu-direct done: plan_id=%s total_ms=%d output_size=%d resolution=%dx%d",
|
||||
self.plan.id,
|
||||
int((time.time() - t_start) * 1000),
|
||||
file_size,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
direct_edge_cropped = _direct_edge_crop # GPU直连时若dedup=True已在GPU内做随机边缘裁剪
|
||||
return RenderResult(
|
||||
output_path=output_path,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
width=width,
|
||||
height=height,
|
||||
edge_crop_applied=direct_edge_cropped,
|
||||
)
|
||||
|
||||
# 5. 视频主渲染
|
||||
t_video_start = time.time()
|
||||
video_only_path = self.work_dir / f"rendered_{self.plan.id}_video.mp4"
|
||||
@@ -399,7 +457,7 @@ class UnifiedRenderService:
|
||||
has_audio = pass_through_has_audio
|
||||
# 直通模式下也支持 BGM 混音:提取音频 → 混 BGM → 合并回视频
|
||||
if self.bgm_path and pass_through_has_audio:
|
||||
config = self.plan.config or {}
|
||||
config = self._effective_config()
|
||||
bgm_config = config.get("bgm", {}) or {}
|
||||
if bgm_config.get("enabled", False):
|
||||
ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id)
|
||||
@@ -439,7 +497,7 @@ class UnifiedRenderService:
|
||||
"[unified-render] pass-through BGM mix failed, skipping: plan_id=%s", self.plan.id
|
||||
)
|
||||
else:
|
||||
config = self.plan.config or {}
|
||||
config = self._effective_config()
|
||||
bgm_config = config.get("bgm", {}) or {}
|
||||
if not isinstance(bgm_config, dict):
|
||||
bgm_config = {}
|
||||
@@ -664,7 +722,7 @@ class UnifiedRenderService:
|
||||
Returns:
|
||||
ASS 文件路径,没有字幕时返回 None
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
config = self._effective_config()
|
||||
# #1901 统一读 "title",兼容老数据 "title_config"
|
||||
title_cfg = config.get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
|
||||
@@ -849,7 +907,7 @@ class UnifiedRenderService:
|
||||
Returns:
|
||||
是否成功添加了配音音轨
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
config = self._effective_config()
|
||||
tts_cfg = config.get("tts", {}) or {}
|
||||
if not isinstance(tts_cfg, dict):
|
||||
tts_cfg = {}
|
||||
@@ -1621,20 +1679,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
|
||||
|
||||
@@ -2170,6 +2235,379 @@ class UnifiedRenderService:
|
||||
filter_complex = ";".join(filter_parts)
|
||||
return filter_complex, input_args
|
||||
|
||||
# ── GPU NVENC 加速 ────────────────────────────────────────────────────
|
||||
|
||||
# ── 全 GPU 直连渲染(P1)─────────────────────────────────────────────
|
||||
|
||||
def _can_use_gpu_direct(self, layers: list[RenderLayer]) -> bool:
|
||||
"""判断是否命中直连支持的场景:单一主视频轨、全硬切、无复杂合成。"""
|
||||
try:
|
||||
cfg = self.plan.config or {}
|
||||
# 特性开关(默认开启;可经 env/plan config 关闭灰度回退)
|
||||
if not bool(cfg.get("gpu_direct_enabled", True)):
|
||||
return False
|
||||
|
||||
video_layers = [_lyr for _lyr in layers if _lyr.role not in ("audio",)]
|
||||
# 只允许一个视频层,且角色为主层
|
||||
if len(video_layers) != 1:
|
||||
return False
|
||||
role = video_layers[0].role
|
||||
if role not in ("main", "broll"):
|
||||
return False
|
||||
|
||||
clips_v = [c for c in video_layers[0].clips if c.clip_type != "audio"]
|
||||
if not clips_v:
|
||||
return False
|
||||
# 全硬切(第一个 clip 的转场忽略)
|
||||
for c in clips_v[1:]:
|
||||
te = c.transition_effect
|
||||
if te not in (None, "", "cut"):
|
||||
return False
|
||||
# 无画中画 / 水印 / 贴纸 / 片头片尾 / 绿幕 / 倒放 / 调色
|
||||
if (cfg or {}).get("pip_config"):
|
||||
return False
|
||||
if (cfg or {}).get("intro_outro"):
|
||||
return False
|
||||
for c in clips_v:
|
||||
cc = c.config or {}
|
||||
if cc.get("watermark") or cc.get("stickers") or cc.get("chroma_key"):
|
||||
return False
|
||||
if ReverseConfig.from_dict(cc.get("reverse")).enabled:
|
||||
return False
|
||||
cg = ColorGradeConfig.from_dict(cc.get("color_grade"))
|
||||
if cg.enabled and cg.has_effect():
|
||||
return False
|
||||
if not (c.config or {}).get("_storage_key"):
|
||||
return False
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[gpu-direct] eligibility check failed (fallback)", exc_info=True)
|
||||
return False
|
||||
|
||||
def _try_gpu_direct(
|
||||
self,
|
||||
*,
|
||||
layers: list[RenderLayer],
|
||||
ass_path: Path | None,
|
||||
video_duration: float,
|
||||
output_path: Path,
|
||||
) -> tuple[bool, bool] | tuple[None, bool]:
|
||||
"""尝试全 GPU 直连渲染。成功返回 (True, edge_crop_applied),不支持/失败返回 (None, False)。"""
|
||||
if not self._can_use_gpu_direct(layers):
|
||||
return (None, False)
|
||||
if not self._gpu_encode_available():
|
||||
return (None, False)
|
||||
|
||||
try:
|
||||
from video_processing import gpu_direct_pipeline as gdp
|
||||
|
||||
cfg = self._effective_config()
|
||||
video_layer = next(_lyr for _lyr in layers if _lyr.role not in ("audio",))
|
||||
video_clips = [c for c in video_layer.clips if c.clip_type != "audio"]
|
||||
|
||||
# 音频层处理:收集 TTS 分段与配音素材库整段音频
|
||||
# - TTS 分段(带 tts 标记)→ 无间隙 concat 成单文件
|
||||
# - 配音素材库(voice_library=True)→ 单独作为整段音轨(不走分段 concat,已从 0 覆盖整段)
|
||||
audio_layer = next((_lyr for _lyr in layers if _lyr.role == "audio"), None)
|
||||
tts_merged: Path | None = None
|
||||
voiceover_track: Path | None = None
|
||||
if audio_layer:
|
||||
tts_clips = [c for c in audio_layer.clips if (c.config or {}).get("tts") and c.local_path.exists()]
|
||||
if tts_clips:
|
||||
tts_merged = self._concat_audio_clips(tts_clips, tag="tts_direct")
|
||||
# 配音素材库整段音频(按 _maybe_add_voice_library_layer 约定只有一个 clip_id=voice_library_main)
|
||||
vo_clips = [
|
||||
c for c in audio_layer.clips if (c.config or {}).get("voice_library") and c.local_path.exists()
|
||||
]
|
||||
if vo_clips:
|
||||
voiceover_track = vo_clips[-1].local_path # 理论上只有一个,取最后一个
|
||||
logger.info(
|
||||
"[gpu-direct] 配音素材库音轨: plan_id=%s path=%s",
|
||||
self.plan.id,
|
||||
voiceover_track,
|
||||
)
|
||||
|
||||
# 额外独立音轨(TTS concat、配音素材库)→ gpu_direct_pipeline 会与主音轨/BGM 一起 amix
|
||||
extra_audio_tracks: list[tuple[Path, float]] = []
|
||||
if tts_merged:
|
||||
extra_audio_tracks.append((tts_merged, 1.0))
|
||||
if voiceover_track:
|
||||
extra_audio_tracks.append((voiceover_track, 1.0))
|
||||
|
||||
# BGM 本地文件
|
||||
bgm_path = Path(self.bgm_path) if self.bgm_path else None
|
||||
if bgm_path is not None and not bgm_path.exists():
|
||||
bgm_path = None
|
||||
|
||||
# 字幕/标题/BGM 配置整包透传
|
||||
title_cfg = cfg.get("title", {}) or cfg.get("title_config", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
title_text = ""
|
||||
if title_cfg.get("enabled", True):
|
||||
title_text = title_cfg.get("text", "") or ""
|
||||
|
||||
sub_cfg = cfg.get("subtitle", {}) or {}
|
||||
if not isinstance(sub_cfg, dict):
|
||||
sub_cfg = {}
|
||||
subtitle_segments: list[Any] = []
|
||||
static_subtitle_text = ""
|
||||
if sub_cfg.get("enabled", True):
|
||||
if sub_cfg.get("auto_generated") and self._asr_timeline_cache is not None:
|
||||
subtitle_segments = list(self._asr_timeline_cache.segments)
|
||||
else:
|
||||
# 静态字幕文本(用户手输):pipeline 内部会构造全片长 segment
|
||||
static_subtitle_text = (sub_cfg.get("text", "") or "").strip()
|
||||
|
||||
bgm_cfg = cfg.get("bgm", {}) or {}
|
||||
if not isinstance(bgm_cfg, dict):
|
||||
bgm_cfg = {}
|
||||
# 若 bgm.enabled 显式关闭,则强制 bgm_path=None(_prepare_bgm 已按 enabled 返回 None,双保险)
|
||||
if not bgm_cfg.get("enabled", True):
|
||||
bgm_path = None
|
||||
# 注入微片段 BGM 偏移(同 CPU 路径)
|
||||
if bgm_path is not None and not bgm_cfg.get("audio_offset"):
|
||||
_micro_off = self._get_micro_bgm_offset()
|
||||
if _micro_off:
|
||||
bgm_cfg = {**bgm_cfg, "audio_offset": _micro_off}
|
||||
|
||||
# 边缘裁剪:dedup 开启时在 GPU 内做四边随机 2~5% 裁剪(gpu_direct_pipeline 内部随机)
|
||||
dedup = self._dedup_enabled()
|
||||
edge_pct = 0.03 if dedup else 0.0 # >0 表示启用;实际区间 [2%,5%] 在 pipeline 内随机
|
||||
|
||||
# 探测每个视频素材是否含音轨、读取 volume 配置
|
||||
clip_has_audio_list: list[bool] = []
|
||||
clip_volumes_list: list[float] = []
|
||||
for c in video_clips:
|
||||
lp = getattr(c, "local_path", None)
|
||||
_ha = False
|
||||
if lp and Path(lp).exists():
|
||||
try:
|
||||
_ha = probe_has_audio(str(lp))
|
||||
except Exception as _pe: # noqa: BLE001
|
||||
logger.warning("[gpu-direct] probe_has_audio 失败按有声处理: %s", _pe)
|
||||
_ha = True
|
||||
clip_has_audio_list.append(_ha)
|
||||
_vol = float((c.config or {}).get("volume", 1.0))
|
||||
clip_volumes_list.append(_vol if _vol > 0 else 0.0)
|
||||
|
||||
# extra_audio_tracks 音量:从 audio_tracks_config 读(TTS/配音素材库),
|
||||
# 无法精确匹配 track_id 时保留默认 1.0
|
||||
at_cfg = cfg.get("audio_tracks") or {}
|
||||
tts_volume = 1.0
|
||||
vo_volume = 1.0
|
||||
if isinstance(at_cfg, dict):
|
||||
_tracks = at_cfg.get("tracks", []) or []
|
||||
for _t in _tracks:
|
||||
if not isinstance(_t, dict):
|
||||
continue
|
||||
try:
|
||||
_vol = float(_t.get("volume", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
_vol = 1.0
|
||||
_tt = str(_t.get("track_type", ""))
|
||||
if _tt == "voiceover" and _t.get("audio_path"):
|
||||
vo_volume = max(0.0, min(2.0, _vol))
|
||||
# TTS 一般没有固定 track_type 标记,保持默认 1.0
|
||||
|
||||
extra_audio_tracks_cfg: list[tuple[Any, float]] = []
|
||||
if tts_merged:
|
||||
extra_audio_tracks_cfg.append((tts_merged, tts_volume))
|
||||
if voiceover_track:
|
||||
extra_audio_tracks_cfg.append((voiceover_track, vo_volume))
|
||||
|
||||
plan = gdp.build_direct_render(
|
||||
resolved_clips=video_clips,
|
||||
output_width=self.output_width,
|
||||
output_height=self.output_height,
|
||||
output_fps=self.output_fps,
|
||||
bgm_audio=bgm_path,
|
||||
title_text=title_text,
|
||||
subtitle_segments=subtitle_segments,
|
||||
edge_crop_pct=edge_pct,
|
||||
total_duration=video_duration,
|
||||
clip_has_audio=clip_has_audio_list,
|
||||
clip_volumes=clip_volumes_list,
|
||||
extra_audio_tracks=extra_audio_tracks_cfg,
|
||||
title_config=title_cfg,
|
||||
subtitle_config=sub_cfg,
|
||||
bgm_config=bgm_cfg,
|
||||
static_subtitle_text=static_subtitle_text,
|
||||
)
|
||||
|
||||
client = get_gpu_encoder()
|
||||
client.render_inputs_to_output(plan.inputs, plan.ffmpeg_args, output_path)
|
||||
|
||||
# 清理本次上传的临时音频
|
||||
for key in plan.oss_keys:
|
||||
try:
|
||||
from video_processing.oss_helpers import _storage
|
||||
|
||||
_storage().delete_file(key) if hasattr(_storage(), "delete_file") else None
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
did_edge_crop = bool(edge_pct)
|
||||
logger.info(
|
||||
"[gpu-direct] success: plan_id=%s clips=%d edge_crop=%s", self.plan.id, len(video_clips), did_edge_crop
|
||||
)
|
||||
return (True, did_edge_crop)
|
||||
|
||||
except GpuEncodeError as e:
|
||||
logger.warning("[gpu-direct] failed (fallback to legacy): %s", e)
|
||||
try:
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return (None, False)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[gpu-direct] unexpected error (fallback)", exc_info=True)
|
||||
return (None, False)
|
||||
|
||||
def _concat_audio_clips(self, clips: list[Any], *, tag: str) -> Path:
|
||||
"""把多个本地音频片段无间隙 concat 成一个 m4a(TTS 分段→单文件)。"""
|
||||
out = self.work_dir / f"{tag}_{self.plan.id}.m4a"
|
||||
listfile = self.work_dir / f"{tag}_{self.plan.id}.txt"
|
||||
lines = []
|
||||
for c in clips:
|
||||
ap = str(c.local_path).replace("'", "'\\''")
|
||||
lines.append(f"file '{ap}'")
|
||||
listfile.write_text("\n".join(lines), encoding="utf-8")
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(listfile),
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(out),
|
||||
]
|
||||
run_ffmpeg(cmd)
|
||||
return out
|
||||
|
||||
def _gpu_encode_available(self) -> bool:
|
||||
"""GPU 编码客户端是否已配置且健康(缓存健康状态,单任务内只探测一次)。"""
|
||||
if not getattr(self, "_gpu_health_ok", None):
|
||||
client = get_gpu_encoder()
|
||||
if client is None:
|
||||
self._gpu_health_ok = False
|
||||
return False
|
||||
try:
|
||||
health = client.check_health()
|
||||
if health.ready:
|
||||
logger.info(
|
||||
"[gpu-encoder] healthy endpoint=%s gpu=%s",
|
||||
client.endpoint,
|
||||
health.gpu_name,
|
||||
)
|
||||
self._gpu_health_ok = True
|
||||
else:
|
||||
logger.warning(
|
||||
"[gpu-encoder] not ready: %s (endpoint=%s)",
|
||||
health.error,
|
||||
client.endpoint,
|
||||
)
|
||||
self._gpu_health_ok = False
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[gpu-encoder] health probe error (CPU fallback): %s", e)
|
||||
self._gpu_health_ok = False
|
||||
return self._gpu_health_ok
|
||||
|
||||
def _ffmpeg_output_to_mezzanine(
|
||||
self,
|
||||
base_command: list[str],
|
||||
mezzanine_path: Path,
|
||||
output_path: Path,
|
||||
) -> bool:
|
||||
"""用 CPU ultrafast 把滤镜链输出到 mezzanine_path,然后调 GPU 做最终编码。
|
||||
|
||||
base_command: 原本要执行的完整 ffmpeg 命令(含 -c:v libx264 -crf X -preset Y ... output_path)
|
||||
我们把最后一个参数(output_path)替换成 mezzanine_path,并把编码参数改成 ultrafast,
|
||||
成功后调用 gpu_encoder 做 nvenc 编码到 output_path。
|
||||
|
||||
任何失败返回 False,调用方走原始 CPU 路径。
|
||||
"""
|
||||
client = get_gpu_encoder()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
# 构造 mezzanine 命令:替换编码参数和输出路径
|
||||
mezz_cmd = list(base_command)
|
||||
# 找到编码参数位置并替换
|
||||
try:
|
||||
i_crf = mezz_cmd.index("-crf")
|
||||
mezz_cmd[i_crf + 1] = "20"
|
||||
i_preset = mezz_cmd.index("-preset")
|
||||
mezz_cmd[i_preset + 1] = "ultrafast"
|
||||
except ValueError:
|
||||
logger.warning("[gpu-encoder] could not find -crf/-preset in command, skip gpu")
|
||||
return False
|
||||
|
||||
# 如果命令有音频编码 -c:a aac,我们保留音频让 GPU 侧不用单独处理
|
||||
# (P4000 的 ffmpeg_args 可以直接 copy 音频?这里简单起见:把音频编码留在 mezzanine,
|
||||
# 然后 GPU 侧直接 -c:a copy,避免重编码损失)
|
||||
has_audio = "-c:a" in mezz_cmd
|
||||
|
||||
# 替换输出路径(最后一个参数)
|
||||
mezz_cmd[-1] = str(mezzanine_path)
|
||||
|
||||
# 1) 跑 mezzanine
|
||||
mezzanine_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
t0 = time.time()
|
||||
try:
|
||||
run_ffmpeg(mezz_cmd)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("[gpu-encoder] mezzanine encode failed (CPU fallback): %s", e)
|
||||
return False
|
||||
logger.info(
|
||||
"[gpu-encoder] mezzanine ready: %s (%.1fs, %d bytes), dispatching to P4000 nvenc...",
|
||||
mezzanine_path.name,
|
||||
time.time() - t0,
|
||||
mezzanine_path.stat().st_size if mezzanine_path.exists() else 0,
|
||||
)
|
||||
|
||||
# 2) GPU nvenc encode(含上传 mezzanine → OSS → P4000 下载+编码 → relay 回传)
|
||||
try:
|
||||
# GPU 侧:-i in.mp4 -c:v h264_nvenc ... 音频 copy(mezzanine 里音频已是 aac)
|
||||
audio_args = ["-c:a", "copy"] if has_audio else None
|
||||
client.encode_mezzanine_to_output(
|
||||
mezzanine_path,
|
||||
output_path,
|
||||
audio_args=audio_args,
|
||||
)
|
||||
logger.info(
|
||||
"[gpu-encoder] GPU nvenc encode done: %s (total %.1fs)",
|
||||
output_path.name,
|
||||
time.time() - t0,
|
||||
)
|
||||
return True
|
||||
except GpuEncodeError as e:
|
||||
logger.warning("[gpu-encoder] GPU encode failed (CPU fallback): %s", e)
|
||||
# 删除可能残留的不完整 output
|
||||
try:
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[gpu-encoder] GPU encode unexpected error (CPU fallback): %s", e)
|
||||
return False
|
||||
finally:
|
||||
# 清理 mezzanine
|
||||
try:
|
||||
if mezzanine_path.exists():
|
||||
mezzanine_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _execute_ffmpeg(
|
||||
self,
|
||||
filter_complex: str,
|
||||
@@ -2209,20 +2647,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 +2830,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:
|
||||
@@ -2457,7 +2906,7 @@ class UnifiedRenderService:
|
||||
b = pixel_pert.get("color_b", 0)
|
||||
if r != 0 or g != 0 or b != 0:
|
||||
# color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换
|
||||
filters.append(f"colorbalance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
|
||||
filters.append(f"colorbalance=rs={r / 100:.3f}:gs={g / 100:.3f}:bs={b / 100:.3f}")
|
||||
|
||||
@staticmethod
|
||||
def _clip_volume(clip: ResolvedClip) -> float:
|
||||
|
||||
@@ -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,
|
||||
@@ -199,7 +198,7 @@ def _verify_url_accessible(
|
||||
retries: int = 2,
|
||||
max_redirects: int = 5,
|
||||
) -> bool:
|
||||
"""HEAD 请求校验 URL 可访问(含重试,防止 OSS 抖动误报)。
|
||||
"""GET+Range 请求校验 URL 可访问(含重试,防止 OSS 抖动误报)。
|
||||
|
||||
安全增强:
|
||||
- 请求前先做 SSRF 安全校验(内网IP/回环地址/链路本地地址等)
|
||||
@@ -256,11 +255,12 @@ def _verify_url_accessible(
|
||||
)
|
||||
raise
|
||||
|
||||
req = urllib.request.Request(safe_url, method="HEAD")
|
||||
req = urllib.request.Request(safe_url, method="GET")
|
||||
req.add_header("Range", "bytes=0-0")
|
||||
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
|
||||
|
||||
with opener.open(req, timeout=timeout) as resp: # noqa: S310
|
||||
if 200 <= resp.status < 300:
|
||||
if 200 <= resp.status < 300 or resp.status == 206:
|
||||
return True
|
||||
if resp.status in (301, 302, 303, 307, 308):
|
||||
location = resp.headers.get("Location", "")
|
||||
@@ -555,7 +555,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,97 +568,97 @@ 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 ──────────────────────────────────────────────────────────────
|
||||
# ── Celery Task ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> str | None:
|
||||
"""将 GenerationTask 的配置同步到 EditPlan.config,返回配音本地路径(如果有)。
|
||||
def _build_task_config_override(task_info: dict) -> dict:
|
||||
"""Bug A: 从 task_info 构建任务级 config override 深拷贝,供渲染时覆盖 plan.config。
|
||||
|
||||
包括:title_config、BGM、输出分辨率。配音单独处理(需下载到本地)。
|
||||
所有渲染相关配置(title/bgm/export)从任务自身读取,不再依赖共享 plan.config,
|
||||
彻底消除同 plan 多任务并发渲染时的竞态覆盖问题。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
import copy
|
||||
|
||||
plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
plan = plan_repo.get(source_edit_plan_id)
|
||||
if plan is None:
|
||||
logger.error("[task] EditPlan not found: %s", source_edit_plan_id)
|
||||
return None
|
||||
override: dict = {}
|
||||
|
||||
plan_config = dict(plan.config or {})
|
||||
changed = False
|
||||
|
||||
# 标题配置
|
||||
# 标题配置(字段名归一化)
|
||||
title_config = task_info.get("title_config") or {}
|
||||
if title_config and isinstance(title_config, dict) and title_config.get("text", "").strip():
|
||||
cfg = dict(title_config)
|
||||
# 字段名归一化
|
||||
if isinstance(title_config, dict) and title_config:
|
||||
cfg = copy.deepcopy(title_config)
|
||||
if "font_size" in cfg and "size" not in cfg:
|
||||
cfg["size"] = cfg["font_size"]
|
||||
if "font_color" in cfg and "color" not in cfg:
|
||||
cfg["color"] = cfg["font_color"]
|
||||
plan_config["title"] = cfg
|
||||
changed = True
|
||||
logger.info("[task] title_config synced to plan: %s", cfg.get("text", "")[:30])
|
||||
override["title"] = cfg
|
||||
|
||||
# BGM 配置
|
||||
bgm_config = task_info.get("bgm_config") or {}
|
||||
if bgm_config:
|
||||
from packages.domain.bgm_utils import merge_bgm_config
|
||||
|
||||
existing_bgm = plan_config.get("bgm", {}) or {}
|
||||
plan_config["bgm"] = merge_bgm_config(existing_bgm, bgm_config)
|
||||
changed = True
|
||||
if isinstance(bgm_config, dict) and bgm_config:
|
||||
override["bgm"] = copy.deepcopy(bgm_config)
|
||||
|
||||
# 输出分辨率
|
||||
ow = task_info.get("output_width") or OUTPUT_WIDTH
|
||||
oh = task_info.get("output_height") or OUTPUT_HEIGHT
|
||||
if ow >= 100 and oh >= 100:
|
||||
export_cfg = dict(plan_config.get("export", {}) or {})
|
||||
export_cfg["resolution"] = f"{ow}x{oh}"
|
||||
plan_config["export"] = export_cfg
|
||||
changed = True
|
||||
override["export"] = {"resolution": f"{ow}x{oh}"}
|
||||
|
||||
if changed:
|
||||
plan.config = plan_config
|
||||
plan_repo.update(plan)
|
||||
logger.info("[task] plan.config synced: plan_id=%s", source_edit_plan_id)
|
||||
return override
|
||||
|
||||
|
||||
def _download_voice_for_task(task_info: dict, source_edit_plan_id: str) -> str | None:
|
||||
"""下载任务配音到本地临时文件,返回路径(不读写 plan.config)。"""
|
||||
import tempfile
|
||||
|
||||
# 配音下载
|
||||
voiceover_path: str | None = None
|
||||
voice_library_id = task_info.get("voice_library_id", "")
|
||||
# #1749:voice_ids 冗余字段已移除;配音一律以 voice_library_id 为准(独立配音每变体各自绑定)
|
||||
effective_voice_id = voice_library_id or ""
|
||||
|
||||
if effective_voice_id:
|
||||
import tempfile
|
||||
|
||||
voice_tmp = Path(tempfile.gettempdir()) / f"voice_{source_edit_plan_id}_{id(task_info)}.mp3"
|
||||
try:
|
||||
if _download_voice_asset(effective_voice_id, voice_tmp):
|
||||
@@ -674,19 +674,24 @@ def _render_from_edit_plan(
|
||||
task_id: str,
|
||||
source_edit_plan_id: str,
|
||||
task_info: dict,
|
||||
) -> tuple[Path, float, list[dict] | None, str | None, str | None, str]:
|
||||
) -> tuple[Path, float, list[dict] | None, str | None, str | None, str, bool]:
|
||||
"""从 EditPlan 数据库记录直接渲染(不再内存重建clips)。
|
||||
|
||||
Bug A 修复:不再通过 _sync_task_config_to_plan 写共享 plan.config;
|
||||
渲染配置通过 task_config_override 参数直接传入渲染层,彻底消除并发竞态。
|
||||
|
||||
Returns:
|
||||
(output_path, render_duration, cover_candidates, voiceover_path, temp_dir, thumbnail_url)
|
||||
(output_path, render_duration, cover_candidates, voiceover_path, temp_dir, thumbnail_url, edge_crop_applied)
|
||||
"""
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 同步配置到 plan.config + 下载配音
|
||||
voiceover_path = _sync_task_config_to_plan(source_edit_plan_id, task_info, db)
|
||||
# Bug A: 构建任务级 config override(深拷贝自 task_info),不写 plan.config,避免并发竞态
|
||||
task_override = _build_task_config_override(task_info)
|
||||
# 下载配音到本地临时文件(不依赖 plan.config)
|
||||
voiceover_path = _download_voice_for_task(task_info, source_edit_plan_id)
|
||||
|
||||
# 进度回调
|
||||
def _progress_cb(progress: float, stage: str):
|
||||
@@ -702,6 +707,7 @@ def _render_from_edit_plan(
|
||||
job_id=task_id,
|
||||
progress_cb=_progress_cb,
|
||||
voiceover_audio_path=voiceover_path,
|
||||
task_config_override=task_override,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
@@ -727,6 +733,7 @@ def _render_from_edit_plan(
|
||||
voiceover_path,
|
||||
render_temp_dir,
|
||||
result.thumbnail_url or "",
|
||||
bool(getattr(result, "edge_crop_applied", False)),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -880,6 +887,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
voiceover_tmp_path,
|
||||
render_temp_dir,
|
||||
thumbnail_url,
|
||||
_gpu_edge_crop_done,
|
||||
) = _render_from_edit_plan(
|
||||
task_id=task_id,
|
||||
source_edit_plan_id=current_plan_id,
|
||||
@@ -916,6 +924,12 @@ def generate_video(self, task_id: str) -> dict:
|
||||
if gen_task and render_attempt == 0:
|
||||
gen_task.append_log("降重", "已关闭边缘裁剪与微变换(确定性渲染)")
|
||||
_flush_logs(task_id, gen_task)
|
||||
elif _gpu_edge_crop_done:
|
||||
# GPU 直连管线已经在 filter_complex 中做了随机边缘裁剪,跳过 CPU 二次重编码
|
||||
if gen_task and render_attempt == 0:
|
||||
gen_task.append_log("边缘裁剪", "已在 GPU 直连管线内完成随机边缘裁剪")
|
||||
_flush_logs(task_id, gen_task)
|
||||
logger.info("[task_id=%s] GPU直连已完成边缘裁剪,跳过CPU二次重编码", task_id)
|
||||
else:
|
||||
from video_processing.ffmpeg_utils import random_edge_crop
|
||||
|
||||
@@ -945,8 +959,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 +972,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 +1031,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/
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Host nginx config for staging server: /etc/nginx/sites-available/05-xiaoxia-cms
|
||||
# Xiaoxia CMS - cms.xiaoxiajianji.com
|
||||
#
|
||||
# 注意:此文件是宿主机 nginx 配置的备份/参考,不是 Docker 容器内的 nginx。
|
||||
# Docker 容器内的 nginx 配置见 nginx-staging.conf。
|
||||
#
|
||||
# GPU relay 路由说明:
|
||||
# P4000 编码完成后通过 http://100.69.73.60:8092/api/v1/internal/gpu-relay/{key} PUT 上传
|
||||
# Worker 容器通过 http://xiaoxia-api-staging:8000/api/v1/internal/gpu-relay/{key} GET 下载
|
||||
# 8092 端口由 CMS 宿主机 nginx 承载,GPU relay 路由通过最长前缀匹配优先代理到 staging API (8000)
|
||||
|
||||
# 80 端口:ACME 验证 + 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name cms.xiaoxiajianji.com;
|
||||
|
||||
# Let's Encrypt ACME 验证
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# 443 端口:CMS 主站
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cms.xiaoxiajianji.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/cms.xiaoxiajianji.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/cms.xiaoxiajianji.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
root /data/www/cms/current;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8091/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;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://127.0.0.1:8091/health;
|
||||
}
|
||||
}
|
||||
|
||||
# 临时访问:8092 端口(IP直接访问,后续可关闭)
|
||||
server {
|
||||
listen 8092;
|
||||
server_name _;
|
||||
|
||||
root /data/www/cms/current;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# GPU relay endpoints - proxy to staging API (port 8000) instead of CMS
|
||||
# 此 location 必须在 location /api/ 之前,利用 nginx 最长前缀匹配优先路由
|
||||
location /api/v1/internal/gpu-relay/ {
|
||||
proxy_pass http://127.0.0.1:8000/api/v1/internal/gpu-relay/;
|
||||
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 /api/ {
|
||||
proxy_pass http://127.0.0.1:8091/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;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://127.0.0.1:8091/health;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 预设 BGM 音频文件放置目录
|
||||
|
||||
将下列 10 首免费可商用 BGM 的 mp3/m4a 文件按 `{preset_id}.mp3` 命名放到本目录:
|
||||
|
||||
| preset_id | 名称 | 风格 | 时长(s) | 标签 |
|
||||
|-------------------|----------|----------|---------|----------------------------|
|
||||
| bgm_upbeat_001 | 阳光清晨 | upbeat | 120 | 轻快 阳光 吉他 vlog |
|
||||
| bgm_upbeat_002 | 活力节拍 | upbeat | 95 | 轻快 电子 活力 运动 |
|
||||
| bgm_upbeat_003 | 夏日漫步 | upbeat | 110 | 轻快 夏日 ukulele 旅行 |
|
||||
| bgm_relax_001 | 静谧时光 | relax | 180 | 治愈 钢琴 安静 冥想 |
|
||||
| bgm_relax_002 | 雨后森林 | relax | 150 | 治愈 自然 放松 环境音 |
|
||||
| bgm_relax_003 | 月光奏鸣曲 | relax | 200 | 治愈 古典 钢琴 优雅(公版) |
|
||||
| bgm_tech_001 | 未来科技 | tech | 85 | 科技 电子 未来感 数码 |
|
||||
| bgm_tech_002 | 数据脉冲 | tech | 100 | 科技 极简 数据 AI |
|
||||
| bgm_commerce_001 | 心动时刻 | commerce | 75 | 电商 时尚 动感 带货 |
|
||||
| bgm_commerce_002 | 品质生活 | commerce | 90 | 电商 高端 品牌 品质 |
|
||||
|
||||
放好后执行(需要在有 OSS 凭证的机器上):
|
||||
```bash
|
||||
export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
export OSS_ACCESS_KEY_ID=xxx
|
||||
export OSS_ACCESS_KEY_SECRET=xxx
|
||||
export OSS_BUCKET_NAME=xiaoxia-autocut
|
||||
python scripts/upload_preset_bgm.py
|
||||
```
|
||||
|
||||
脚本会:
|
||||
1. 上传文件到 OSS `preset/bgm/<preset_id>.mp3`,设置公共读 ACL
|
||||
2. 自动改写 `packages/domain/preset_bgm.py` 把对应 `audio_url=""` 回填成公网 URL
|
||||
3. 提示 `git commit & push`
|
||||
+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 / {
|
||||
|
||||
@@ -10,6 +10,14 @@ FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:l
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# CJK 字体保障:确保 fonts-noto-cjk 已安装(base 镜像漂移兜底)+ 重建字体缓存
|
||||
# fc-cache 非致命;fc-match 结果只打日志用于排查,不阻断构建
|
||||
RUN apt-get update && (apt-get install -y --no-install-recommends fonts-noto-cjk fontconfig || true) \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (fc-cache -fv || true) \
|
||||
&& echo "[font] fc-match sans:zh: $(fc-match -f '%{family}\n' sans:zh 2>/dev/null | head -1)" \
|
||||
&& echo "[font] fc-match Noto Sans CJK SC: $(fc-match 'Noto Sans CJK SC' 2>/dev/null | head -1)"
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN groupadd -r celery \
|
||||
&& useradd -r -g celery -d /app -s /sbin/nologin celery \
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Staging GPU relay nginx 配置说明
|
||||
#
|
||||
# GPU relay 并没有独立的 nginx vhost,而是集成在宿主机 CMS nginx 的 8092 server block 中。
|
||||
# 完整宿主机 nginx 配置备份见: deploy/configs/host-nginx-cms-staging.conf
|
||||
#
|
||||
# 核心 location 块(添加到 8092 server block,位于 location /api/ 之前):
|
||||
#
|
||||
# # GPU relay endpoints - proxy to staging API (port 8000) instead of CMS
|
||||
# location /api/v1/internal/gpu-relay/ {
|
||||
# proxy_pass http://127.0.0.1:8000/api/v1/internal/gpu-relay/;
|
||||
# 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 块添加到 /etc/nginx/sites-available/05-xiaoxia-cms 的 8092 server block 中
|
||||
# 然后 nginx -t && systemctl reload nginx
|
||||
#
|
||||
# 原理说明:
|
||||
# - 8092 端口由 CMS 宿主机 nginx 承载(与 CMS 共享端口)
|
||||
# - GPU relay 路由 /api/v1/internal/gpu-relay/ 比 CMS 的 /api/ 更具体
|
||||
# - nginx 最长前缀匹配确保 relay 请求路由到 staging API (port 8000) 而非 CMS (port 8091)
|
||||
# - P4000 通过 Tailscale IP 100.69.73.60:8092 访问 relay
|
||||
# - Worker 容器通过 Docker DNS xiaoxia-api-staging:8000 直接访问 relay
|
||||
@@ -14,6 +14,24 @@ class InMemoryIngestJobRepository:
|
||||
def get(self, job_id: str) -> IngestJob | None:
|
||||
return self._items.get(job_id)
|
||||
|
||||
def find_by_asset_id(self, asset_id: str) -> IngestJob | None:
|
||||
if not asset_id:
|
||||
return None
|
||||
from packages.domain.classification import IngestJobStatus
|
||||
|
||||
running: IngestJob | None = None
|
||||
completed: IngestJob | None = None
|
||||
for job in self._items.values():
|
||||
if getattr(job, "asset_id", "") != asset_id:
|
||||
continue
|
||||
if job.status in (IngestJobStatus.PENDING, IngestJobStatus.PROCESSING):
|
||||
if running is None or job.created_at > running.created_at:
|
||||
running = job
|
||||
elif job.status == IngestJobStatus.COMPLETED:
|
||||
if completed is None or job.created_at > completed.created_at:
|
||||
completed = job
|
||||
return running or completed
|
||||
|
||||
def update(self, job: IngestJob) -> IngestJob:
|
||||
self._items[job.id] = job
|
||||
return job
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,6 +46,43 @@ class SQLAlchemyIngestJobRepository:
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
def find_by_asset_id(self, asset_id: str) -> IngestJob | None:
|
||||
"""返回 asset 最近一条未失败的 ingest job(PENDING/PROCESSING/COMPLETED 均算存在,用于幂等判断)。"""
|
||||
if not asset_id:
|
||||
return None
|
||||
# 优先返回仍在跑的 (PENDING/PROCESSING),否则返回最新一条 COMPLETED
|
||||
model = (
|
||||
self.session.query(IngestJobModel)
|
||||
.filter(IngestJobModel.asset_id == asset_id)
|
||||
.filter(IngestJobModel.status.in_([IngestJobStatus.PENDING.value, IngestJobStatus.PROCESSING.value]))
|
||||
.order_by(IngestJobModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
model = (
|
||||
self.session.query(IngestJobModel)
|
||||
.filter(IngestJobModel.asset_id == asset_id)
|
||||
.filter(IngestJobModel.status == IngestJobStatus.COMPLETED.value)
|
||||
.order_by(IngestJobModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return IngestJob(
|
||||
id=model.id,
|
||||
project_id=model.project_id,
|
||||
library_id=model.library_id,
|
||||
storage_key=model.storage_key,
|
||||
status=IngestJobStatus(model.status),
|
||||
error_message=model.error_message,
|
||||
result_asset_id=model.result_asset_id,
|
||||
file_hash=model.file_hash or "",
|
||||
asset_id=getattr(model, "asset_id", "") or "",
|
||||
celery_task_id=getattr(model, "celery_task_id", "") or "",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[IngestJob]:
|
||||
models = self.session.query(IngestJobModel).filter(IngestJobModel.project_id == project_id).all()
|
||||
return [self.get(model.id) for model in models if self.get(model.id) is not None]
|
||||
|
||||
@@ -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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user