Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db621b4fcb | |||
| 60cacdf280 | |||
| 08de0d9946 | |||
| b0b81a5d60 | |||
| d959dd874f | |||
| dcd0c56827 | |||
| 585bab9313 | |||
| 4a449ae496 | |||
| 112f0eb277 | |||
| 2e2d1cd73e | |||
| 32473485d7 | |||
| 1591259bb8 | |||
| a1f25a4426 | |||
| 65a77e3fb6 | |||
| 9a57b0d5b8 | |||
| 4d98e98b57 | |||
| 81e1eb47fb | |||
| d3e4d6a07d | |||
| 0d6ce433d0 | |||
| eb2b009b33 | |||
| fbd89b4089 | |||
| 9b50e0696e | |||
| 9af73dcd86 | |||
| 6002f7a5e4 | |||
| 7e88440ca9 | |||
| fbf8844f25 | |||
| 34ffe14aae | |||
| 4fa3e4eb92 | |||
| a59a6a588a | |||
| f1621ace9f | |||
| f9daa08b2e | |||
| 66409fde6f | |||
| 3c016af076 |
+24
-4
@@ -198,10 +198,13 @@ DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# ==================== 积分/会员系统 (#1895) ====================
|
||||
# 积分扣点总开关:默认 false(对现有用户零影响)。
|
||||
# P2 阶段各业务路由逐个接入 @points_gate 时,用
|
||||
# `if settings.points_enabled: ...`
|
||||
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。
|
||||
# 积分系统总开关:默认 false(暂停积分系统)。
|
||||
# - false:生成视频/口型同步/数字人/AI标题/TTS/克隆音色等所有功能对登录
|
||||
# 用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员状态查询接口
|
||||
# 保留可用,但数据不再变动。积分相关的表、代码、接口均保留不删除。
|
||||
# - 恢复积分:设置 ENABLE_CREDIT_SYSTEM=true 即可,无需改代码。
|
||||
ENABLE_CREDIT_SYSTEM=false
|
||||
# 旧开关名(兼容别名):与 ENABLE_CREDIT_SYSTEM 任一为 true 即启用。
|
||||
POINTS_ENABLED=false
|
||||
|
||||
# ==================== 抖音解析多源轮询 (#1963) ====================
|
||||
@@ -213,3 +216,20 @@ TIKHUB_API_KEY=
|
||||
# apizero.cn API Key (https://v1.apizero.cn) — 国内抖音解析服务
|
||||
APIZERO_API_KEY=
|
||||
|
||||
# ==================== GPU MuseTalk Worker(反向轮询口型同步)====================
|
||||
# GPU Worker 长期鉴权 Token,Worker 端 .env 的 GPU_WORKER_TOKEN 必须与此一致
|
||||
# 留空时 development 环境允许匿名访问(仅本地调试),staging/production 必须配置
|
||||
GPU_WORKER_TOKEN=
|
||||
# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed
|
||||
# #1970:RTX2060 6G 推理 720p 长视频需 5 分钟以上,默认 900
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
# 是否启用 GPU 口型同步(开关)。开启后需同时有 Worker 在心跳窗口内(5分钟)才会走 GPU 路径;
|
||||
# 开关关闭 / 无可用 Worker / GPU 任务失败或超时 → 自动回退现有 MediaKit 云端 lipsync
|
||||
USE_GPU_LIPSYNC=false
|
||||
# 业务侧轮询 GPU 任务结果的间隔(秒)
|
||||
GPU_LIPSYNC_POLL_INTERVAL=5
|
||||
# 业务侧等待 GPU 任务总超时(秒);超时回退 MediaKit
|
||||
GPU_LIPSYNC_WAIT_TIMEOUT=1200
|
||||
# Worker 心跳新鲜度窗口(秒),last_heartbeat_at 在此窗口内视为在线
|
||||
GPU_WORKER_STALE_SECONDS=300
|
||||
|
||||
|
||||
@@ -1186,10 +1186,12 @@ jobs:
|
||||
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
|
||||
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
|
||||
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
|
||||
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
|
||||
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
|
||||
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
|
||||
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
|
||||
APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}"
|
||||
GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}"
|
||||
run: |
|
||||
set -eu
|
||||
echo "Rendering .env from template + secrets..."
|
||||
@@ -1640,10 +1642,12 @@ jobs:
|
||||
DOUBAO_API_KEY: "${{ secrets.DOUBAO_API_KEY }}"
|
||||
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
|
||||
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
|
||||
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
|
||||
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
|
||||
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
|
||||
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
|
||||
APIZERO_API_KEY: "${{ secrets.APIZERO_API_KEY }}"
|
||||
GPU_WORKER_TOKEN: "${{ secrets.GPU_WORKER_TOKEN }}"
|
||||
run: |
|
||||
set -eu
|
||||
echo "Rendering .env from template + secrets..."
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add asset_atom_clips table
|
||||
|
||||
Revision ID: 079_asset_atom_clips
|
||||
Revises: 078_drop_script_title_fields
|
||||
Create Date: 2026-09-17
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "079_asset_atom_clips"
|
||||
down_revision = "078_drop_script_title_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"asset_atom_clips",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"asset_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("assets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("start_time", sa.Float(), nullable=False),
|
||||
sa.Column("end_time", sa.Float(), nullable=False),
|
||||
sa.Column("duration", sa.Float(), nullable=False),
|
||||
sa.Column("clip_index", sa.Integer(), nullable=False),
|
||||
sa.Column("tags", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
|
||||
sa.Column("scene_change_at", sa.Float(), nullable=True),
|
||||
sa.Column(
|
||||
"is_fallback",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
)
|
||||
# 按素材查片段并按索引排序(复合索引前缀可独立用于 asset_id 过滤)
|
||||
op.create_index(
|
||||
"ix_asset_atom_clips_asset_index",
|
||||
"asset_atom_clips",
|
||||
["asset_id", "clip_index"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_asset_atom_clips_asset_index", table_name="asset_atom_clips")
|
||||
op.drop_table("asset_atom_clips")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add edit_plan_clips.atom_clip_id for #1970
|
||||
|
||||
Revision ID: 080_edit_plan_clips_atom_clip_id
|
||||
Revises: 079_asset_atom_clips
|
||||
Create Date: 2026-09-17
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "080_edit_plan_clips_atom_clip_id"
|
||||
down_revision = "079_asset_atom_clips"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plan_clips",
|
||||
sa.Column(
|
||||
"atom_clip_id",
|
||||
sa.String(36),
|
||||
nullable=False,
|
||||
server_default=sa.text("''"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_edit_plan_clips_atom_clip_id",
|
||||
"edit_plan_clips",
|
||||
["atom_clip_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_edit_plan_clips_atom_clip_id", table_name="edit_plan_clips")
|
||||
op.drop_column("edit_plan_clips", "atom_clip_id")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add gpu_lipsync_tasks and gpu_workers tables for MuseTalk reverse-poll worker
|
||||
|
||||
Revision ID: 081_add_gpu_lipsync
|
||||
Revises: 080_edit_plan_clips_atom_clip_id
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "081_add_gpu_lipsync"
|
||||
down_revision = "080_edit_plan_clips_atom_clip_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# GPU Worker 注册表
|
||||
op.create_table(
|
||||
"gpu_workers",
|
||||
sa.Column("worker_id", sa.String(100), primary_key=True),
|
||||
sa.Column("hostname", sa.String(200), nullable=False, server_default=""),
|
||||
sa.Column("gpu_name", sa.String(200), nullable=False, server_default=""),
|
||||
sa.Column("free_vram_mb", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("capabilities", sa.String(500), nullable=False, server_default=""),
|
||||
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True, index=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# GPU 口型同步任务表
|
||||
op.create_table(
|
||||
"gpu_lipsync_tasks",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("lipsync_job_id", sa.String(36), nullable=False, server_default="", index=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, server_default="", index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default="", index=True),
|
||||
sa.Column("video_url", sa.Text(), nullable=False),
|
||||
sa.Column("audio_url", sa.Text(), nullable=False),
|
||||
sa.Column("result_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("result_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("worker_id", sa.String(100), nullable=False, server_default="", index=True),
|
||||
sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("error_msg", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_gpu_lipsync_status_created", "gpu_lipsync_tasks", ["status", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_gpu_lipsync_status_created", table_name="gpu_lipsync_tasks")
|
||||
op.drop_table("gpu_lipsync_tasks")
|
||||
op.drop_table("gpu_workers")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging
|
||||
|
||||
Revision ID: 082_atom_clip_ai_tags
|
||||
Revises: 081_add_gpu_lipsync
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "082_atom_clip_ai_tags"
|
||||
down_revision = "081_add_gpu_lipsync"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"asset_atom_clips",
|
||||
sa.Column("ai_tags", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("asset_atom_clips", "ai_tags")
|
||||
@@ -14,6 +14,7 @@ from app.api.routes.generation_cover import router as generation_cover_router
|
||||
from app.api.routes.generation_preview import router as generation_preview_router
|
||||
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.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
|
||||
@@ -211,3 +212,8 @@ api_router.include_router(
|
||||
prefix="/usage",
|
||||
tags=["Usage"],
|
||||
)
|
||||
api_router.include_router(
|
||||
gpu_lipsync_router,
|
||||
prefix="/gpu",
|
||||
tags=["GPU Worker"],
|
||||
)
|
||||
|
||||
@@ -16,10 +16,12 @@ from app.core.task_enqueue import (
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoResponse,
|
||||
@@ -132,6 +134,8 @@ def _select_assets_from_library(
|
||||
mode: str,
|
||||
count: int,
|
||||
rng=None,
|
||||
script_tags: list | None = None,
|
||||
tag_names_by_id: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
@@ -141,6 +145,8 @@ def _select_assets_from_library(
|
||||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||||
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
|
||||
测试可注入固定种子或零噪声随机源获得确定性结果。
|
||||
script_tags: #1970 叙事模式文案标签;非空时标签命中素材优先,不足再用其余素材兜底。
|
||||
tag_names_by_id: asset_id → 素材标签名列表(素材只存 tag_ids 时由调用方查名称注入)。
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
@@ -150,6 +156,20 @@ def _select_assets_from_library(
|
||||
if not ready_video_assets:
|
||||
return []
|
||||
|
||||
# 叙事模式(#1970 PR3):文案标签命中池优先;无任何命中时完全降级为现有随机逻辑。
|
||||
if script_tags:
|
||||
from packages.domain.narrative_match import pick_narrative_assets
|
||||
|
||||
limit = count if count > 0 else None
|
||||
picked = pick_narrative_assets(
|
||||
ready_video_assets,
|
||||
script_tags=script_tags,
|
||||
tag_names_by_id=tag_names_by_id,
|
||||
limit=limit,
|
||||
rng=rng,
|
||||
)
|
||||
return [a.id for a in picked]
|
||||
|
||||
if mode == "smart":
|
||||
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
@@ -162,16 +182,78 @@ def _select_assets_from_library(
|
||||
return [a.id for a in ready_video_assets]
|
||||
|
||||
|
||||
# #1970 PR3:video_ratio → 默认输出分辨率(显式 output_width/output_height 优先)
|
||||
_VIDEO_RATIO_DIMENSIONS = {
|
||||
"9:16": (1080, 1920),
|
||||
"16:9": (1920, 1080),
|
||||
"1:1": (1080, 1080),
|
||||
"3:4": (1080, 1440),
|
||||
"4:3": (1440, 1080),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_output_dimensions(request: CreateGenerationTaskRequest) -> tuple[int, int]:
|
||||
"""解析输出分辨率:显式 output_width/output_height 非旧默认值时优先,否则按 video_ratio。
|
||||
|
||||
前端 #1973 总是同时传 video_ratio 与具体分辨率,两者一致;此函数主要服务
|
||||
只传比例的调用方,并保证旧调用(不传比例)维持 1280x720 行为。
|
||||
"""
|
||||
width, height = request.output_width, request.output_height
|
||||
ratio = (request.video_ratio or "").strip()
|
||||
if ratio in _VIDEO_RATIO_DIMENSIONS and (width, height) == (1280, 720):
|
||||
return _VIDEO_RATIO_DIMENSIONS[ratio]
|
||||
return width, height
|
||||
|
||||
|
||||
def _load_asset_tag_names(db: Session, assets: list, user_id: str) -> dict[str, list[str]]:
|
||||
"""叙事模式:查 TagModel 名称,构造 asset_id → 标签名列表(失败返回空 dict 降级随机)。"""
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetTagModel, TagModel
|
||||
|
||||
tag_ids = {tid for a in assets for tid in (getattr(a, "tag_ids", None) or [])}
|
||||
if not tag_ids:
|
||||
return {}
|
||||
name_rows = (
|
||||
db.query(TagModel.id, TagModel.name).filter(TagModel.id.in_(tag_ids), TagModel.user_id == user_id).all()
|
||||
)
|
||||
name_by_id = {row.id: row.name for row in name_rows}
|
||||
links = db.query(AssetTagModel.asset_id, AssetTagModel.tag_id).filter(AssetTagModel.tag_id.in_(tag_ids)).all()
|
||||
index: dict[str, list[str]] = {}
|
||||
for asset_id, tag_id in links:
|
||||
name = name_by_id.get(tag_id)
|
||||
if name:
|
||||
index.setdefault(asset_id, []).append(name)
|
||||
return index
|
||||
except Exception: # noqa: BLE001 - 标签匹配是加分项,查询失败不阻断生成
|
||||
logger.warning("[叙事模式] 素材标签查询失败,降级随机选片", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def _writeback_edit_plan_config(
|
||||
plan_id: str,
|
||||
task_id: str,
|
||||
title_config: dict | None,
|
||||
db: Session,
|
||||
dedup_enabled: bool | None = None,
|
||||
video_index: int | None = None,
|
||||
assembly_mode: str | None = None,
|
||||
script_id: str | None = None,
|
||||
video_ratio: str | None = None,
|
||||
) -> None:
|
||||
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
return writeback_edit_plan_config(plan_id, task_id, title_config, db)
|
||||
return writeback_edit_plan_config(
|
||||
plan_id,
|
||||
task_id,
|
||||
title_config,
|
||||
db,
|
||||
dedup_enabled=dedup_enabled,
|
||||
video_index=video_index,
|
||||
assembly_mode=assembly_mode,
|
||||
script_id=script_id,
|
||||
video_ratio=video_ratio,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
@@ -221,16 +303,63 @@ def create_generation_task(
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
cosyvoice_service: Any = Depends(get_cosyvoice_service),
|
||||
voice_clone_repository: Any = Depends(get_voice_clone_profile_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
logger.info(
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, assembly=%s, count=%d",
|
||||
authenticated_user.user.id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
request.asset_select_mode,
|
||||
request.assembly_mode,
|
||||
request.count,
|
||||
)
|
||||
|
||||
# video_ratio → 默认分辨率(显式分辨率优先)
|
||||
request.output_width, request.output_height = _resolve_output_dimensions(request)
|
||||
|
||||
# ── #1970 PR3 叙事模式:入队前同步合成配音并落为 audio asset ──
|
||||
# 合成结果覆盖 voice_library_id(下游按 audio asset id 消费),失败直接 4xx 不入队。
|
||||
narrative_script_tags: list = []
|
||||
if request.assembly_mode == "narrative":
|
||||
from app.config import settings as _settings
|
||||
from app.services.narrative_service import NarrativeError, prepare_narrative_voice
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
|
||||
|
||||
try:
|
||||
narrative_ctx = prepare_narrative_voice(
|
||||
db=db,
|
||||
user_id=authenticated_user.user.id,
|
||||
script_id=request.script_id,
|
||||
tts_voice_id=request.tts_voice_id,
|
||||
tts_voice_source=request.tts_voice_source,
|
||||
tts_repository=SQLAlchemyTTSJobRepository(db),
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
voice_clone_repository=voice_clone_repository,
|
||||
asset_repository=asset_repository,
|
||||
asset_library_repository=asset_library_repository,
|
||||
project_repository=project_repository,
|
||||
storage_service=get_storage_service(),
|
||||
points_enabled=bool(getattr(_settings, "points_enabled", False)),
|
||||
is_member=bool(getattr(authenticated_user.user, "is_member", False)),
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
except NarrativeError as e:
|
||||
logger.warning("[叙事模式] 配音前置处理失败: %s", e.message)
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message) from e
|
||||
|
||||
request.voice_library_id = narrative_ctx.voice_asset_id
|
||||
narrative_script_tags = list(getattr(narrative_ctx.script, "tags", None) or [])
|
||||
logger.info(
|
||||
"[叙事模式] 配音已就绪: script_id=%s, tts_job=%s, voice_asset=%s, duration=%.2f",
|
||||
request.script_id,
|
||||
narrative_ctx.tts_job_id,
|
||||
narrative_ctx.voice_asset_id,
|
||||
narrative_ctx.audio_duration,
|
||||
)
|
||||
|
||||
try:
|
||||
project_id, asset_library_id = _resolve_project_and_library(
|
||||
request, project_repository, asset_library_repository, asset_repository, authenticated_user
|
||||
@@ -256,19 +385,29 @@ def create_generation_task(
|
||||
|
||||
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
|
||||
if not resolved_asset_ids:
|
||||
_tag_index = (
|
||||
_load_asset_tag_names(db, assets, authenticated_user.user.id) if narrative_script_tags else None
|
||||
)
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
assets,
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
script_tags=narrative_script_tags or None,
|
||||
tag_names_by_id=_tag_index,
|
||||
)
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取
|
||||
elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取
|
||||
assets = asset_repository.find_by_project(project_id)
|
||||
if assets:
|
||||
_tag_index = (
|
||||
_load_asset_tag_names(db, assets, authenticated_user.user.id) if narrative_script_tags else None
|
||||
)
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
assets,
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
script_tags=narrative_script_tags or None,
|
||||
tag_names_by_id=_tag_index,
|
||||
)
|
||||
if not resolved_asset_ids:
|
||||
raise HTTPException(
|
||||
@@ -332,6 +471,10 @@ def create_generation_task(
|
||||
task_id=preview_task.id,
|
||||
title_config=fallback_title_config,
|
||||
db=db,
|
||||
dedup_enabled=request.dedup_enabled,
|
||||
assembly_mode=request.assembly_mode,
|
||||
script_id=request.script_id or None,
|
||||
video_ratio=request.video_ratio or None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -476,9 +619,12 @@ def create_generation_task(
|
||||
variant_plan_ids.append(_plan0.id)
|
||||
|
||||
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数)
|
||||
from app.services.generation_common import collect_plan_atom_clip_ids as _collect_atom_ids
|
||||
from app.services.generation_common import collect_plan_segments as _collect_segments
|
||||
|
||||
_batch_segments = _collect_segments(_plan0.id, _plan_svc._clip_repo)
|
||||
# #1970:批次内原子片段硬避让集合
|
||||
_batch_atom_ids: list[str] = _collect_atom_ids(_plan0.id, _plan_svc._clip_repo)
|
||||
|
||||
# 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让)
|
||||
for task_index in range(1, count):
|
||||
@@ -493,6 +639,7 @@ def create_generation_task(
|
||||
name_suffix=f"批量{task_index + 1}",
|
||||
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
|
||||
batch_segments=_batch_segments,
|
||||
batch_used_atom_ids=_batch_atom_ids,
|
||||
)
|
||||
break
|
||||
except ValueError as ve:
|
||||
@@ -529,6 +676,8 @@ def create_generation_task(
|
||||
_new_segs = _collect_segments(variant.id, _plan_svc._clip_repo)
|
||||
for _aid, _ivs in _new_segs.items():
|
||||
_batch_segments.setdefault(_aid, []).extend(_ivs)
|
||||
# #1970:同步累积原子片段ID
|
||||
_batch_atom_ids.extend(_collect_atom_ids(variant.id, _plan_svc._clip_repo))
|
||||
except Exception:
|
||||
logger.exception("[生成任务] 变体%d 区间收集失败(不阻断)", task_index)
|
||||
|
||||
@@ -672,6 +821,11 @@ def create_generation_task(
|
||||
task_id=task.id,
|
||||
title_config=variant_title_config,
|
||||
db=db,
|
||||
dedup_enabled=request.dedup_enabled,
|
||||
video_index=task_index,
|
||||
assembly_mode=request.assembly_mode,
|
||||
script_id=request.script_id or None,
|
||||
video_ratio=request.video_ratio or None,
|
||||
)
|
||||
|
||||
if safe_enqueue_generation_task(
|
||||
@@ -762,6 +916,7 @@ def confirm_generation(
|
||||
generation_task_repository.update(source_task)
|
||||
|
||||
# 同步标题到 EditPlan.config
|
||||
# #1970:确认生成复用预览计划,dedup_enabled 沿用计划已有值,不在此覆盖
|
||||
if confirmed_title_config and source_task.source_edit_plan_id:
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=source_task.source_edit_plan_id,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""GPU MuseTalk Worker 反向轮询路由 — /api/v1/gpu/lipsync/*.
|
||||
|
||||
仅面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。
|
||||
鉴权方式:长期 API Token(`Authorization: Bearer <GPU_WORKER_TOKEN>`),不走用户 JWT。
|
||||
|
||||
接口:
|
||||
POST /api/v1/gpu/register Worker 注册/心跳
|
||||
GET /api/v1/gpu/lipsync/poll Worker 轮询拉任务(无任务返回 204)
|
||||
POST /api/v1/gpu/lipsync/result Worker multipart 上传结果视频/上报失败
|
||||
GET /api/v1/gpu/lipsync/status/{id} 业务侧查询任务状态(内部接口,暂开放给登录用户)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.gpu_lipsync import (
|
||||
GpuLipsyncPollResponse,
|
||||
GpuLipsyncResultResponse,
|
||||
GpuLipsyncStatusResponse,
|
||||
GpuLipsyncTaskPayload,
|
||||
GpuWorkerRegisterRequest,
|
||||
GpuWorkerRegisterResponse,
|
||||
)
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import Response
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from packages.config import get_api_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 复用 bearer scheme 抽 Token,但不校验用户 JWT
|
||||
_gpu_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _verify_gpu_token(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_gpu_bearer),
|
||||
) -> str:
|
||||
"""校验 GPU Worker Token,返回 worker 提供的 token 串(仅用于日志,不做身份识别).
|
||||
|
||||
- development 且未配置 token → 直接放行(方便本地调试)。
|
||||
- production/staging 未配置 token → 拒绝(避免裸奔)。
|
||||
- token 不匹配 → 401。
|
||||
"""
|
||||
settings = get_api_settings()
|
||||
expected = (settings.gpu_worker_token or "").strip()
|
||||
is_dev = settings.environment == "development"
|
||||
if not expected:
|
||||
if is_dev:
|
||||
return credentials.credentials if credentials else ""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="GPU_WORKER_TOKEN not configured on server",
|
||||
)
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||
if credentials.credentials != expected:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid GPU worker token")
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
def _get_svc(db=Depends(get_db_session)) -> GpuLipsyncService:
|
||||
return GpuLipsyncService(db)
|
||||
|
||||
|
||||
# ── POST /register — Worker 注册/心跳 ──────────────────────────────
|
||||
|
||||
|
||||
@router.post("/register", response_model=GpuWorkerRegisterResponse)
|
||||
def register_worker(
|
||||
body: GpuWorkerRegisterRequest,
|
||||
svc: GpuLipsyncService = Depends(_get_svc),
|
||||
_token: str = Depends(_verify_gpu_token),
|
||||
):
|
||||
svc.register_worker(
|
||||
worker_id=body.worker_id,
|
||||
hostname=body.hostname,
|
||||
gpu_name=body.gpu_name,
|
||||
free_vram_mb=body.free_vram_mb,
|
||||
capabilities=body.capabilities,
|
||||
task_id=body.task_id,
|
||||
)
|
||||
return GpuWorkerRegisterResponse(ok=True, server_time=datetime.now(UTC), message="ok")
|
||||
|
||||
|
||||
# ── GET /lipsync/poll — Worker 轮询拉任务 ─────────────────────────
|
||||
|
||||
|
||||
@router.get("/lipsync/poll")
|
||||
def poll_task(
|
||||
worker_id: str = Query(..., min_length=1, max_length=100, description="Worker 唯一 ID"),
|
||||
svc: GpuLipsyncService = Depends(_get_svc),
|
||||
_token: str = Depends(_verify_gpu_token),
|
||||
):
|
||||
task = svc.poll_task(worker_id=worker_id)
|
||||
if task is None:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
payload = GpuLipsyncTaskPayload(
|
||||
task_id=task.id,
|
||||
video_url=getattr(task, "_signed_video_url", task.video_url),
|
||||
audio_url=getattr(task, "_signed_audio_url", task.audio_url),
|
||||
lipsync_job_id=task.lipsync_job_id or "",
|
||||
user_id=task.user_id or "",
|
||||
project_id=task.project_id or "",
|
||||
created_at=task.created_at,
|
||||
upload_url=getattr(task, "_signed_upload_url", ""),
|
||||
upload_method="PUT",
|
||||
expires_at=getattr(task, "_upload_expires_at", datetime.now(UTC)),
|
||||
)
|
||||
return GpuLipsyncPollResponse(task=payload)
|
||||
|
||||
|
||||
# ── POST /lipsync/result — Worker 上报结果(multipart) ─────────────
|
||||
|
||||
|
||||
@router.post("/lipsync/result", response_model=GpuLipsyncResultResponse)
|
||||
async def report_result(
|
||||
request: Request,
|
||||
task_id: str = Form(...),
|
||||
worker_id: str = Form(...),
|
||||
success: bool = Form(True),
|
||||
duration_seconds: float = Form(0.0),
|
||||
error_msg: str = Form(""),
|
||||
result: Optional[UploadFile] = File(None),
|
||||
svc: GpuLipsyncService = Depends(_get_svc),
|
||||
_token: str = Depends(_verify_gpu_token),
|
||||
):
|
||||
# 参数校验:
|
||||
# - success=true + result 文件 → API 代为上传到 OSS(方便 Worker 端实现)
|
||||
# - success=true + 无文件 → Worker 已经自己 PUT 到预签名 upload_url,直接确认
|
||||
# - success=false → 不上传文件,错误信息通过 error_msg 传递
|
||||
if success and result is not None:
|
||||
# 把文件落盘到临时目录,然后 PUT 到预签名 URL
|
||||
storage = get_storage_service()
|
||||
result_key = svc._result_key(task_id)
|
||||
upload_url = storage.get_upload_url(result_key, expires_seconds=3600, content_type="video/mp4")
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="gpu_result_") as tmpdir:
|
||||
tmp_path = Path(tmpdir) / "result.mp4"
|
||||
content = await result.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="上传的 result 文件为空")
|
||||
tmp_path.write_bytes(content)
|
||||
headers = {"Content-Type": "video/mp4"}
|
||||
with open(tmp_path, "rb") as f:
|
||||
resp = requests.put(upload_url, data=f, headers=headers, timeout=300)
|
||||
if resp.status_code >= 400:
|
||||
logger.error(
|
||||
"上传 GPU 结果到 OSS 失败: status=%d body=%s",
|
||||
resp.status_code,
|
||||
resp.text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"上传结果视频到 OSS 失败 (HTTP {resp.status_code})",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("上传 GPU 结果视频异常: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=f"上传结果视频异常: {exc}") from exc
|
||||
elif not success:
|
||||
# 失败时忽略 result 文件(即便传了也没用)
|
||||
pass
|
||||
# 其他情况:success=true 且无文件 → Worker 已自行 PUT 到预签名 URL,直接标记完成
|
||||
|
||||
try:
|
||||
task = svc.report_result(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
success=success,
|
||||
duration_seconds=duration_seconds,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return GpuLipsyncResultResponse(
|
||||
ok=True,
|
||||
task_id=task.id,
|
||||
status=task.status,
|
||||
message="ok",
|
||||
)
|
||||
|
||||
|
||||
# ── GET /lipsync/status/{task_id} — 业务侧查询状态 ─────────────────
|
||||
# 说明:此接口会被 lipsync_service 内部在业务流程里直接读 DB,不通过 HTTP。
|
||||
# 但仍暴露一个简单查询接口,方便调试和前端轮询(如后续需要)。暂不做用户权限校验,
|
||||
# task_id 本身是 UUID,不可枚举。
|
||||
|
||||
|
||||
@router.get("/lipsync/status/{task_id}", response_model=GpuLipsyncStatusResponse)
|
||||
def get_task_status(
|
||||
task_id: str,
|
||||
svc: GpuLipsyncService = Depends(_get_svc),
|
||||
):
|
||||
task = svc.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return GpuLipsyncStatusResponse(
|
||||
task_id=task.id,
|
||||
status=task.status,
|
||||
result_url=task.result_url,
|
||||
result_duration=task.result_duration,
|
||||
error_msg=task.error_msg,
|
||||
worker_id=task.worker_id,
|
||||
attempt=task.attempt,
|
||||
created_at=task.created_at,
|
||||
started_at=task.started_at,
|
||||
finished_at=task.finished_at,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.points import (
|
||||
DailyUsageResponse,
|
||||
@@ -44,6 +45,12 @@ from packages.domain.points_service import PointsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _credits_enabled() -> bool:
|
||||
"""积分系统总开关(ENABLE_CREDIT_SYSTEM),关闭时全部功能免费放行。"""
|
||||
return bool(getattr(settings, "points_enabled", False))
|
||||
|
||||
|
||||
# ── 两个 router ──
|
||||
points_router = APIRouter()
|
||||
usage_router = APIRouter()
|
||||
@@ -172,6 +179,19 @@ def check_points(
|
||||
"valid_scenes": sorted(POINTS_SCENES.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
|
||||
if not _credits_enabled():
|
||||
svc = _get_service()
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
return PointsCheckResponse(
|
||||
allowed=True,
|
||||
required_points=0,
|
||||
current_balance=account["balance"],
|
||||
remaining_after=account["balance"],
|
||||
is_free_quota=False,
|
||||
)
|
||||
|
||||
is_mem = _is_member(current_user)
|
||||
mt = _member_type(current_user)
|
||||
|
||||
@@ -209,8 +229,19 @@ def deduct_points(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""积分扣减(内部服务调用)。"""
|
||||
"""积分扣减(内部服务调用)。
|
||||
|
||||
积分系统暂停(ENABLE_CREDIT_SYSTEM=false)时为 no-op:不扣分、余额不变,
|
||||
直接返回成功,保证内部调用方拿到 success=True 继续业务流程。
|
||||
"""
|
||||
svc = _get_service()
|
||||
if not _credits_enabled():
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
return SimpleMessageResponse(
|
||||
success=True,
|
||||
message="积分系统已暂停,未扣减积分",
|
||||
data={"transaction_id": "", "balance": account["balance"]},
|
||||
)
|
||||
result = svc.deduct_points(
|
||||
user_id=current_user.user.id,
|
||||
amount=body.amount,
|
||||
@@ -243,11 +274,7 @@ def refund_points(
|
||||
"""积分退还(内部服务调用)。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
|
||||
|
||||
txn = (
|
||||
db.query(PointsTransactionModel)
|
||||
.filter(PointsTransactionModel.id == body.transaction_id)
|
||||
.first()
|
||||
)
|
||||
txn = db.query(PointsTransactionModel).filter(PointsTransactionModel.id == body.transaction_id).first()
|
||||
if txn is None:
|
||||
raise HTTPException(status_code=404, detail="交易记录不存在")
|
||||
if txn.user_id != current_user.user.id:
|
||||
|
||||
@@ -55,7 +55,9 @@ _DOUYIN_DEBUG_ERRORS = os.environ.get("DOUYIN_DEBUG_ERRORS", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
) or os.environ.get("APP_ENV", "").lower() in ("staging", "dev", "development", "test")
|
||||
) or os.environ.get(
|
||||
"APP_ENV", ""
|
||||
).lower() in ("staging", "dev", "development", "test")
|
||||
|
||||
_TAIL_PUNCT = ".,;:!?,。;:!?))]》" + chr(34) + chr(39) + "<>"
|
||||
_URL_EXTRACT_RE = re.compile(r"https?://\S+", re.IGNORECASE)
|
||||
@@ -140,6 +142,7 @@ def _extract_and_validate_douyin_url(raw_input):
|
||||
|
||||
def _mk_post_json(self, path, payload):
|
||||
import httpx
|
||||
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
url = self._base_url + path
|
||||
@@ -168,6 +171,7 @@ def _mk_post_json(self, path, payload):
|
||||
|
||||
def _mk_get_json(self, path):
|
||||
import httpx
|
||||
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
url = self._base_url + path
|
||||
@@ -283,7 +287,7 @@ def _direct_url_download_and_local_asr(direct_url, page_url, temp_dir):
|
||||
raise
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("直链下载超时: %s", page_url)
|
||||
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="视频下载超时,请稍后重试")
|
||||
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="视频下载超时,请稍后重试") from None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("直链下载失败: url=%s err=%s", page_url, exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="视频下载失败: " + str(exc)[:200]) from exc
|
||||
@@ -420,7 +424,10 @@ def extract_from_douyin(
|
||||
if text:
|
||||
logger.info(
|
||||
"抖音 MediaKit ASR 成功: source=%s text_len=%d duration=%.1f total_time=%.1fs",
|
||||
result.source, len(text), duration, time.time() - t0,
|
||||
result.source,
|
||||
len(text),
|
||||
duration,
|
||||
time.time() - t0,
|
||||
)
|
||||
else:
|
||||
logger.info("抖音 MediaKit ASR 返回空文本(无旁白/BGM视频)")
|
||||
@@ -440,13 +447,25 @@ def extract_from_douyin(
|
||||
if text:
|
||||
logger.info(
|
||||
"抖音本地 ASR 成功: source=%s text_len=%d total_time=%.1fs",
|
||||
result.source, len(text), time.time() - t0,
|
||||
result.source,
|
||||
len(text),
|
||||
time.time() - t0,
|
||||
)
|
||||
last_err_stage = "asr"
|
||||
except HTTPException:
|
||||
raise
|
||||
except HTTPException as exc:
|
||||
# 下载超时(504)是明确的网络错误,直接抛出
|
||||
if exc.status_code == status.HTTP_504_GATEWAY_TIMEOUT:
|
||||
raise
|
||||
# 本地 ASR 不可用/失败(502/503)时记录后继续走 desc 兜底,
|
||||
# 不直接抛 502,避免 API 镜像缺 worker 模块时整条链路挂掉
|
||||
logger.warning("本地 ASR 链路失败(status=%d): %s", exc.status_code, exc.detail)
|
||||
text = ""
|
||||
# 如果是下载失败(非ASR错误),保持stage为download
|
||||
if "语音识别" in str(exc.detail) or "ASR" in str(exc.detail):
|
||||
last_err_stage = "asr"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("本地 ASR 链路异常: %s", exc)
|
||||
text = ""
|
||||
|
||||
# ── Phase C:结果判定 & 兜底 ──
|
||||
|
||||
@@ -529,6 +548,7 @@ def ai_generate_titles(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文案内容不能为空")
|
||||
count = max(1, min(5, request.count))
|
||||
from app.services.ai_service import generate_smart_titles
|
||||
|
||||
result = generate_smart_titles(description=content, style="viral", count=count)
|
||||
titles = result.get("titles", [])[:count]
|
||||
return AiGenerateTitlesResponse(titles=titles)
|
||||
|
||||
@@ -98,6 +98,24 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
description="各变体独立标题文字数组:长度1=共用,长度=count=独立。为空时使用 title_config.text",
|
||||
)
|
||||
|
||||
# ── 智能降重开关(#1970)──
|
||||
# True(默认):edge_crop + 片段级微变换(hflip/变速/亮度/对比度/饱和度/BGM偏移)全部生效;
|
||||
# False:跳过 edge_crop、不注入微变换,渲染确定性(固定种子)。
|
||||
dedup_enabled: bool = Field(default=True, description="智能降重开关,默认开启;关闭后跳过边缘裁切与微变换")
|
||||
|
||||
# ── 剪辑组装模式(#1970 PR3)──
|
||||
# random(默认,完全兼容现有随机混剪)/ narrative(叙事剪辑:文案→TTS 配音→标签匹配画面)
|
||||
assembly_mode: str = Field(default="random", description="组装模式:random=随机混剪(默认),narrative=叙事剪辑")
|
||||
# 叙事模式必填:文案库 scripts.id(后端据此读取 content 合成 TTS)
|
||||
script_id: str = Field(default="", description="叙事模式必填:文案库 ID")
|
||||
# 叙事模式必填:TTS 音色 ID(preset 为 CosyVoice 音色 id;clone 为克隆档案 id)
|
||||
tts_voice_id: str = Field(default="", description="叙事模式必填:TTS 音色 ID(系统音色或克隆档案 ID)")
|
||||
tts_voice_source: str = Field(default="preset", description="TTS 音色来源:preset=系统预设(默认),clone=克隆音色")
|
||||
# 视频比例:当前前端 9:16/16:9;与 output_width/output_height 并存,传了具体分辨率时以分辨率为准
|
||||
video_ratio: str = Field(
|
||||
default="", description="视频比例,如 9:16(默认竖屏)/16:9;与显式分辨率冲突时以分辨率为准"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_variant_arrays(self) -> "CreateGenerationTaskRequest":
|
||||
"""变体数组字段长度校验 + #1749 配音严格守卫。
|
||||
@@ -127,6 +145,26 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
raise ValueError(f"variant_plan_ids 长度({len(self.variant_plan_ids)})必须与 count({self.count})一致")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_assembly_mode(self) -> "CreateGenerationTaskRequest":
|
||||
"""#1970 组装模式与叙事模式入参校验。"""
|
||||
if self.assembly_mode not in ("random", "narrative"):
|
||||
raise ValueError("assembly_mode 仅支持 'random'(默认)或 'narrative'")
|
||||
if self.tts_voice_source not in ("preset", "clone"):
|
||||
raise ValueError("tts_voice_source 仅支持 'preset' 或 'clone'")
|
||||
if self.video_ratio:
|
||||
parts = self.video_ratio.split(":")
|
||||
if len(parts) != 2 or not all(p.isdigit() and int(p) > 0 for p in parts):
|
||||
raise ValueError("video_ratio 格式必须为 '宽:高',如 9:16 或 16:9")
|
||||
if self.video_ratio not in ("9:16", "16:9", "1:1", "3:4", "4:3"):
|
||||
raise ValueError("video_ratio 仅支持 9:16 / 16:9 / 1:1 / 3:4 / 4:3")
|
||||
if self.assembly_mode == "narrative":
|
||||
if not self.script_id.strip():
|
||||
raise ValueError("叙事模式(narrative)必须提供 script_id(文案库 ID)")
|
||||
if not self.tts_voice_id.strip():
|
||||
raise ValueError("叙事模式(narrative)必须提供 tts_voice_id(TTS 音色 ID)")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
has_project = bool(self.project_id.strip())
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""GPU MuseTalk 反向轮询 API Schema 定义.
|
||||
|
||||
面向部署在用户 RTX2060 本地的 GPU Worker 脚本,不面向前端用户。
|
||||
Worker 用长期 GPU_WORKER_TOKEN 鉴权(不是用户 JWT)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ── Worker 注册/心跳 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class GpuWorkerRegisterRequest(BaseModel):
|
||||
"""Worker 启动/心跳时上报自身信息."""
|
||||
|
||||
worker_id: str = Field(..., min_length=1, max_length=100, description="Worker 唯一 ID(机器名+UUID 等)")
|
||||
hostname: str = Field("", max_length=200, description="主机名,用于运维排查")
|
||||
gpu_name: str = Field("", max_length=200, description="GPU 型号,如 'NVIDIA GeForce RTX 2060'")
|
||||
free_vram_mb: int = Field(0, ge=0, description="当前空闲显存(MB)")
|
||||
capabilities: str = Field("musetalk", max_length=500, description="能力列表,逗号分隔,如 'musetalk'")
|
||||
task_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description=(
|
||||
"当前正在处理的任务 ID。Worker 推理期间定期心跳时携带,"
|
||||
"服务端同步刷新该任务 last_heartbeat_at,防止长推理被误判超时;空闲时不传"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class GpuWorkerRegisterResponse(BaseModel):
|
||||
ok: bool = True
|
||||
server_time: datetime
|
||||
message: str = "ok"
|
||||
|
||||
|
||||
# ── 轮询任务 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GpuLipsyncTaskPayload(BaseModel):
|
||||
"""下发给 Worker 的任务载荷(含预签名下载 URL)."""
|
||||
|
||||
task_id: str
|
||||
video_url: str = Field(..., description="人物视频预签名下载 URL(GET)")
|
||||
audio_url: str = Field(..., description="驱动音频预签名下载 URL(GET)")
|
||||
lipsync_job_id: str = ""
|
||||
user_id: str = ""
|
||||
project_id: str = ""
|
||||
created_at: datetime
|
||||
upload_url: str = Field(..., description="结果视频预签名上传 URL(PUT, video/mp4)")
|
||||
upload_method: str = Field("PUT", description="上传方式,目前只支持 PUT")
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class GpuLipsyncPollResponse(BaseModel):
|
||||
"""Worker poll 的返回:200 带任务,204 无任务."""
|
||||
|
||||
task: Optional[GpuLipsyncTaskPayload] = None
|
||||
|
||||
|
||||
# ── Worker 上报结果 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class GpuLipsyncResultRequest(BaseModel):
|
||||
"""Worker 通过 multipart 上传结果时携带的字段(非文件字段)."""
|
||||
|
||||
task_id: str = Field(..., min_length=1, max_length=64)
|
||||
worker_id: str = Field(..., min_length=1, max_length=100)
|
||||
success: bool = Field(True, description="true=成功(此时必须上传 result 视频文件);false=失败")
|
||||
duration_seconds: float = Field(0.0, ge=0, description="合成后视频时长(秒),成功时应填入")
|
||||
error_msg: str = Field("", max_length=2000, description="失败原因,success=false 时必填")
|
||||
|
||||
|
||||
class GpuLipsyncResultResponse(BaseModel):
|
||||
ok: bool = True
|
||||
task_id: str
|
||||
status: str # done / failed
|
||||
message: str = "ok"
|
||||
|
||||
|
||||
# ── 业务侧查询任务状态 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class GpuLipsyncStatusResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
result_url: str = ""
|
||||
result_duration: float = 0.0
|
||||
error_msg: str = ""
|
||||
worker_id: str = ""
|
||||
attempt: int = 0
|
||||
created_at: datetime
|
||||
started_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
|
||||
|
||||
# ── 创建任务(内部服务调用) ──────────────────────────────────────
|
||||
|
||||
|
||||
class GpuLipsyncCreateRequest(BaseModel):
|
||||
"""服务层内部创建 GPU 任务用(不通过 HTTP 暴露给 Worker/前端)."""
|
||||
|
||||
video_url: str # 已可访问的 OSS key 或公网 URL(API 侧会转预签名)
|
||||
audio_url: str
|
||||
lipsync_job_id: str = ""
|
||||
user_id: str = ""
|
||||
project_id: str = ""
|
||||
@@ -52,7 +52,7 @@ def _extract_url_from_text(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
m = re.search(r"https?://\S+", text)
|
||||
return m.group(0).rstrip("。,!?!?,,;;\"'))】") if m else ""
|
||||
return m.group(0).rstrip("。,!?!?,,;;\"'))】") if m else "" # noqa: B005
|
||||
|
||||
|
||||
def _canonicalize_url(url: str, timeout: int = 8) -> str:
|
||||
|
||||
@@ -423,6 +423,7 @@ class EditPlanService:
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id,
|
||||
atom_clip_id=clip_item.get("atom_clip_id", ""),
|
||||
text_content=clip.text_content,
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
@@ -474,6 +475,7 @@ class EditPlanService:
|
||||
voice_duration: float = 0.0,
|
||||
rng=None,
|
||||
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||||
batch_used_atom_ids: set[str] | list[str] | None = None,
|
||||
) -> EditPlan:
|
||||
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
|
||||
|
||||
@@ -608,18 +610,69 @@ class EditPlanService:
|
||||
st = float(c.start_time or 0.0)
|
||||
batch_segments_resolved.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
|
||||
|
||||
clips_data = reselect_clips_for_variant(
|
||||
source_clips_data,
|
||||
pool_ids,
|
||||
asset_durations=durations,
|
||||
asset_scene_points=scene_points,
|
||||
historical_used_segments=historical,
|
||||
batch_segments=batch_segments_resolved,
|
||||
target_durations=target_durations,
|
||||
rng=rng,
|
||||
)
|
||||
clips_data = None
|
||||
# #1970 原子片段级变体重选:候选素材已切片时优先按原子片段选片
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
from packages.domain.atom_clip_resolver import flatten_candidates, load_atom_clips_for_assets
|
||||
from packages.domain.atom_clip_selector import reselect_clips_from_atoms
|
||||
|
||||
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit)
|
||||
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
|
||||
|
||||
# 兜底切片只需要时长;本方法已查出 durations,封装一个只读假素材仓储
|
||||
class _DurationOnlyAssetRepo:
|
||||
def __init__(self, durations_map: dict[str, float]) -> None:
|
||||
self._durations = durations_map
|
||||
|
||||
def get(self, asset_id: str):
|
||||
if asset_id not in self._durations:
|
||||
return None
|
||||
|
||||
class _A:
|
||||
pass
|
||||
|
||||
a = _A()
|
||||
a.duration = self._durations[asset_id]
|
||||
return a
|
||||
|
||||
clips_by_asset = load_atom_clips_for_assets(
|
||||
pool_ids,
|
||||
atom_clip_repo=atom_repo,
|
||||
asset_repo=_DurationOnlyAssetRepo(durations),
|
||||
)
|
||||
atom_candidates = flatten_candidates(clips_by_asset)
|
||||
if atom_candidates:
|
||||
# 历史成片已用原子片段(降权);批次内前序变体已用(硬避让)
|
||||
historical_atom_ids = set(
|
||||
self._clip_repo.list_recent_atom_clip_ids_by_user(
|
||||
created_by_user_id or source.created_by_user_id or "",
|
||||
limit=200,
|
||||
)
|
||||
)
|
||||
clips_data = reselect_clips_from_atoms(
|
||||
source_clips_data,
|
||||
atom_candidates,
|
||||
historical_atom_ids=historical_atom_ids,
|
||||
batch_used_atom_ids=(set(batch_used_atom_ids) if batch_used_atom_ids else None),
|
||||
rng=rng,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("原子片段变体重选失败,回退整条素材选片", exc_info=True)
|
||||
clips_data = None
|
||||
|
||||
if clips_data is None:
|
||||
clips_data = reselect_clips_for_variant(
|
||||
source_clips_data,
|
||||
pool_ids,
|
||||
asset_durations=durations,
|
||||
asset_scene_points=scene_points,
|
||||
historical_used_segments=historical,
|
||||
batch_segments=batch_segments_resolved,
|
||||
target_durations=target_durations,
|
||||
rng=rng,
|
||||
) # 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit)
|
||||
for item in clips_data:
|
||||
aid = item.get("asset_id", "")
|
||||
if aid:
|
||||
|
||||
@@ -61,10 +61,17 @@ def writeback_edit_plan_config(
|
||||
task_id: str,
|
||||
title_config: dict | None,
|
||||
db: Session,
|
||||
dedup_enabled: bool | None = None,
|
||||
video_index: int | None = None,
|
||||
assembly_mode: str | None = None,
|
||||
script_id: str | None = None,
|
||||
video_ratio: str | None = None,
|
||||
) -> None:
|
||||
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。
|
||||
|
||||
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
|
||||
#1970:dedup_enabled 非 None 时一并写入,worker 据此决定 edge_crop/微变换;
|
||||
PR3 叙事模式再写 assembly_mode/script_id/video_ratio(可追溯,不影响渲染)。
|
||||
失败只记日志,不影响任务创建。
|
||||
"""
|
||||
if not plan_id:
|
||||
@@ -80,6 +87,16 @@ def writeback_edit_plan_config(
|
||||
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
|
||||
merged = dict(current_config)
|
||||
merged["generation_task_id"] = task_id
|
||||
if dedup_enabled is not None:
|
||||
merged["dedup_enabled"] = bool(dedup_enabled)
|
||||
if video_index is not None:
|
||||
merged["video_index"] = int(video_index)
|
||||
if assembly_mode:
|
||||
merged["assembly_mode"] = assembly_mode
|
||||
if script_id:
|
||||
merged["script_id"] = script_id
|
||||
if video_ratio:
|
||||
merged["video_ratio"] = video_ratio
|
||||
|
||||
if title_config:
|
||||
# #1901 统一字段名为 "title"(worker sync_configs_to_plan 写的是 "title")
|
||||
@@ -157,6 +174,33 @@ def collect_plan_segments(
|
||||
return segs
|
||||
|
||||
|
||||
def collect_plan_atom_clip_ids(
|
||||
plan_id: str,
|
||||
clip_repo: Any,
|
||||
*,
|
||||
page_size: int = 500,
|
||||
) -> list[str]:
|
||||
"""分页读取 plan 所有 clips,收集已选用的原子片段 ID(#1970)。
|
||||
|
||||
用于批量变体间原子片段级硬避让:同一原子片段在同批次内只用一次。
|
||||
旧路径 clips 的 atom_clip_id 为空串,自动忽略。
|
||||
"""
|
||||
ids: list[str] = []
|
||||
sk, pg = 0, page_size
|
||||
while True:
|
||||
batch = clip_repo.list_by_plan(plan_id, skip=sk, limit=pg)
|
||||
if not batch:
|
||||
break
|
||||
for c in batch:
|
||||
acid = getattr(c, "atom_clip_id", "") or ""
|
||||
if acid:
|
||||
ids.append(acid)
|
||||
if len(batch) < pg:
|
||||
break
|
||||
sk += pg
|
||||
return ids
|
||||
|
||||
|
||||
def resolve_latest_plan_by_template(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"""GPU MuseTalk 口型同步服务 — 反向轮询模式.
|
||||
|
||||
职责:
|
||||
1. 创建任务(由 lipsync 业务流程调用),为输入/输出生成预签名 URL,任务入队;
|
||||
2. Worker 心跳注册(register):登记/刷新 worker 状态;
|
||||
3. Worker 轮询拉任务(poll):原子地 CLAIM 一条 pending 任务,返回预签名 URL;
|
||||
4. Worker 上报结果(report_result):标记 done/failed,失败可重试;
|
||||
5. 业务侧查询状态(get_status)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.core.storage import get_storage_service
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel, GpuWorkerModel
|
||||
from packages.config import get_api_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 任务在 processing 超过此时长仍未完成 → 超时回退 pending 或置 failed
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
class GpuLipsyncService:
|
||||
"""GPU 口型同步服务(无状态方法,每次调用从 DI 拿 db/storage)."""
|
||||
|
||||
RESULT_PREFIX = "gpu-lipsync/results/"
|
||||
INPUT_SIGN_EXPIRES_PAD = 600 # 输入预签名 URL 在任务超时基础上再加 10min 余量
|
||||
|
||||
# ── 公共入口 ────────────────────────────────────────────────────
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.settings = get_api_settings()
|
||||
self.storage = get_storage_service()
|
||||
|
||||
# ── Worker 注册/心跳 ────────────────────────────────────────────
|
||||
|
||||
def register_worker(
|
||||
self,
|
||||
worker_id: str,
|
||||
hostname: str = "",
|
||||
gpu_name: str = "",
|
||||
free_vram_mb: int = 0,
|
||||
capabilities: str = "musetalk",
|
||||
task_id: Optional[str] = None,
|
||||
) -> GpuWorkerModel:
|
||||
"""Worker 注册/心跳。
|
||||
|
||||
task_id 非空时(Worker 推理期间的任务级心跳),同步把对应 processing
|
||||
任务的 last_heartbeat_at 续到当前时间,使长推理不会被
|
||||
``_recover_timed_out_tasks`` 误回退。任务已结束 / 不属于该 worker
|
||||
(如已被超时回收重新派发)时忽略,不报错。
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
|
||||
if worker is None:
|
||||
worker = GpuWorkerModel(
|
||||
worker_id=worker_id,
|
||||
hostname=hostname,
|
||||
gpu_name=gpu_name,
|
||||
free_vram_mb=free_vram_mb,
|
||||
capabilities=capabilities,
|
||||
last_heartbeat_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
self.db.add(worker)
|
||||
else:
|
||||
worker.hostname = hostname or worker.hostname
|
||||
worker.gpu_name = gpu_name or worker.gpu_name
|
||||
worker.free_vram_mb = free_vram_mb
|
||||
worker.capabilities = capabilities or worker.capabilities
|
||||
worker.last_heartbeat_at = now
|
||||
if task_id:
|
||||
self._touch_task_heartbeat(task_id, worker_id, now)
|
||||
self.db.commit()
|
||||
return worker
|
||||
|
||||
# ── 轮询拉任务(Worker 调用) ──────────────────────────────────
|
||||
|
||||
def poll_task(self, worker_id: str) -> Optional[GpuLipsyncTaskModel]:
|
||||
"""原子地认领一条最早的 pending 任务,返回给 worker;无任务返回 None.
|
||||
|
||||
同时会:
|
||||
- 把 processing 状态且真正超时(任务心跳停滞超过
|
||||
gpu_task_timeout_seconds;Worker 推理期会通过 register(task_id=...)
|
||||
续心跳,长推理不会误判)的任务回退为 pending(attempt++,超过
|
||||
MAX_ATTEMPTS 置 failed),让其它 worker 认领。
|
||||
- 刷新 worker 心跳。
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
self._recover_timed_out_tasks(now)
|
||||
# 更新 worker 心跳
|
||||
self._touch_worker(worker_id, now)
|
||||
|
||||
# 选一条最早 pending 任务(FOR UPDATE SKIP LOCKED 语义:简单起见先查再锁状态)
|
||||
task = (
|
||||
self.db.query(GpuLipsyncTaskModel)
|
||||
.filter(GpuLipsyncTaskModel.status == "pending")
|
||||
.order_by(GpuLipsyncTaskModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
self.db.commit()
|
||||
return None
|
||||
|
||||
# 原子 claim:用 UPDATE WHERE status=pending 避免并发
|
||||
upd_rows = (
|
||||
self.db.query(GpuLipsyncTaskModel)
|
||||
.filter(
|
||||
GpuLipsyncTaskModel.id == task.id,
|
||||
GpuLipsyncTaskModel.status == "pending",
|
||||
)
|
||||
.update(
|
||||
{
|
||||
GpuLipsyncTaskModel.status: "processing",
|
||||
GpuLipsyncTaskModel.worker_id: worker_id,
|
||||
GpuLipsyncTaskModel.started_at: now,
|
||||
GpuLipsyncTaskModel.last_heartbeat_at: now,
|
||||
GpuLipsyncTaskModel.attempt: GpuLipsyncTaskModel.attempt + 1,
|
||||
GpuLipsyncTaskModel.updated_at: now,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
if upd_rows == 0:
|
||||
# 被其它 worker 抢先了
|
||||
return None
|
||||
self.db.refresh(task)
|
||||
# 生成预签名输入/输出 URL(在 claim 时动态生成,避免长时间过期)
|
||||
expires = self.settings.gpu_task_timeout_seconds + self.INPUT_SIGN_EXPIRES_PAD
|
||||
task._signed_video_url = self.storage.get_download_url(task.video_url, expires_seconds=expires)
|
||||
task._signed_audio_url = self.storage.get_download_url(task.audio_url, expires_seconds=expires)
|
||||
task._signed_upload_url = self.storage.get_upload_url(
|
||||
self._result_key(task.id),
|
||||
expires_seconds=expires,
|
||||
content_type="video/mp4",
|
||||
)
|
||||
task._upload_expires_at = now + timedelta(seconds=expires)
|
||||
return task
|
||||
|
||||
# ── 上报结果 ──────────────────────────────────────────────────
|
||||
|
||||
def report_result(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
success: bool,
|
||||
duration_seconds: float = 0.0,
|
||||
error_msg: str = "",
|
||||
) -> GpuLipsyncTaskModel:
|
||||
task = self.db.get(GpuLipsyncTaskModel, task_id)
|
||||
if task is None:
|
||||
raise KeyError(f"task {task_id} not found")
|
||||
now = datetime.now(UTC)
|
||||
if success:
|
||||
task.status = "done"
|
||||
task.result_url = self._result_key(task_id)
|
||||
task.result_duration = duration_seconds or 0.0
|
||||
task.error_msg = ""
|
||||
task.finished_at = now
|
||||
else:
|
||||
# 失败:若仍可重试(已尝试次数 < MAX_ATTEMPTS)→ 回退 pending;否则 → failed
|
||||
if task.attempt < MAX_ATTEMPTS:
|
||||
task.status = "pending"
|
||||
task.worker_id = ""
|
||||
task.started_at = None
|
||||
task.error_msg = error_msg[:2000]
|
||||
logger.warning(
|
||||
"GPU 任务 %s 在 worker %s 上失败,回退 pending 等待重试(attempt=%d): %s",
|
||||
task_id,
|
||||
worker_id,
|
||||
task.attempt,
|
||||
error_msg[:200],
|
||||
)
|
||||
else:
|
||||
task.status = "failed"
|
||||
task.error_msg = error_msg[:2000]
|
||||
task.finished_at = now
|
||||
logger.error(
|
||||
"GPU 任务 %s 失败达到最大重试次数 %d,置为 failed: %s",
|
||||
task_id,
|
||||
MAX_ATTEMPTS,
|
||||
error_msg[:200],
|
||||
)
|
||||
task.updated_at = now
|
||||
task.last_heartbeat_at = now
|
||||
self._touch_worker(worker_id, now)
|
||||
self.db.commit()
|
||||
self.db.refresh(task)
|
||||
return task
|
||||
|
||||
# ── 业务侧查询 ────────────────────────────────────────────────
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[GpuLipsyncTaskModel]:
|
||||
return self.db.get(GpuLipsyncTaskModel, task_id)
|
||||
|
||||
def get_by_lipsync_job(self, lipsync_job_id: str) -> Optional[GpuLipsyncTaskModel]:
|
||||
return (
|
||||
self.db.query(GpuLipsyncTaskModel)
|
||||
.filter(GpuLipsyncTaskModel.lipsync_job_id == lipsync_job_id)
|
||||
.order_by(GpuLipsyncTaskModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
# ── 创建任务(业务侧调用) ────────────────────────────────────
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
lipsync_job_id: str = "",
|
||||
user_id: str = "",
|
||||
project_id: str = "",
|
||||
) -> GpuLipsyncTaskModel:
|
||||
task_id = str(uuid.uuid4())
|
||||
now = datetime.now(UTC)
|
||||
task = GpuLipsyncTaskModel(
|
||||
id=task_id,
|
||||
lipsync_job_id=lipsync_job_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.db.add(task)
|
||||
self.db.commit()
|
||||
self.db.refresh(task)
|
||||
logger.info(
|
||||
"创建 GPU 口型任务 %s (lipsync_job=%s, user=%s)",
|
||||
task_id,
|
||||
lipsync_job_id,
|
||||
user_id,
|
||||
)
|
||||
return task
|
||||
|
||||
# ── 内部辅助 ──────────────────────────────────────────────────
|
||||
|
||||
def _result_key(self, task_id: str) -> str:
|
||||
return f"{self.RESULT_PREFIX}{task_id}.mp4"
|
||||
|
||||
def _touch_task_heartbeat(self, task_id: str, worker_id: str, now: datetime) -> None:
|
||||
"""Worker 推理期间的任务级心跳:只刷新属于该 worker 且仍在 processing 的任务。
|
||||
|
||||
任务不存在 / 已被超时回收重新派发 / 已完成 → 静默忽略(此时旧 worker 的
|
||||
结果上报会被结果接口按最终态处理)。
|
||||
"""
|
||||
task = self.db.get(GpuLipsyncTaskModel, task_id)
|
||||
if task is None:
|
||||
return
|
||||
if task.status != "processing" or task.worker_id != worker_id:
|
||||
logger.info(
|
||||
"忽略过期任务心跳 task=%s worker=%s(status=%s owner=%s)",
|
||||
task_id,
|
||||
worker_id,
|
||||
task.status,
|
||||
task.worker_id,
|
||||
)
|
||||
return
|
||||
task.last_heartbeat_at = now
|
||||
task.updated_at = now
|
||||
self.db.flush()
|
||||
|
||||
def _touch_worker(self, worker_id: str, now: datetime) -> None:
|
||||
if not worker_id:
|
||||
return
|
||||
worker = self.db.query(GpuWorkerModel).filter(GpuWorkerModel.worker_id == worker_id).one_or_none()
|
||||
if worker is not None:
|
||||
worker.last_heartbeat_at = now
|
||||
self.db.flush()
|
||||
else:
|
||||
# 自注册(poll 时允许自动建一个空 worker 记录,运维可见)
|
||||
worker = GpuWorkerModel(
|
||||
worker_id=worker_id,
|
||||
hostname="",
|
||||
gpu_name="",
|
||||
free_vram_mb=0,
|
||||
capabilities="musetalk",
|
||||
last_heartbeat_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
self.db.add(worker)
|
||||
self.db.flush()
|
||||
|
||||
def _recover_timed_out_tasks(self, now: datetime) -> None:
|
||||
"""扫描 processing 状态且真正超时的任务,回退 pending 或失败。
|
||||
|
||||
判定只看任务自身 last_heartbeat_at:claim 时写入,Worker 推理期间通过
|
||||
/gpu/register(task_id=...) 每 30s 续期。因此仅在 Worker 崩溃/断网
|
||||
(任务心跳停滞超过 gpu_task_timeout_seconds)时才回收,
|
||||
不会因 Worker 主循环忙于推理而误回退。
|
||||
"""
|
||||
timeout = self.settings.gpu_task_timeout_seconds
|
||||
cutoff = now - timedelta(seconds=timeout)
|
||||
stuck_tasks = (
|
||||
self.db.query(GpuLipsyncTaskModel)
|
||||
.filter(
|
||||
GpuLipsyncTaskModel.status == "processing",
|
||||
GpuLipsyncTaskModel.last_heartbeat_at < cutoff,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for t in stuck_tasks:
|
||||
if t.attempt >= MAX_ATTEMPTS:
|
||||
t.status = "failed"
|
||||
t.error_msg = f"worker 心跳超时({timeout}s),重试次数已耗尽"
|
||||
t.finished_at = now
|
||||
else:
|
||||
t.status = "pending"
|
||||
t.worker_id = ""
|
||||
t.started_at = None
|
||||
t.error_msg = f"worker 心跳超时({timeout}s),等待重试"
|
||||
logger.warning("GPU 任务 %s 心跳超时,回退 pending(attempt=%d)", t.id, t.attempt)
|
||||
t.updated_at = now
|
||||
if stuck_tasks:
|
||||
self.db.flush()
|
||||
|
||||
# ── 业务侧辅助 ──────────────────────────────────────────────────
|
||||
|
||||
def has_available_worker(self) -> bool:
|
||||
"""判断是否有 Worker 在心跳新鲜窗口内可用."""
|
||||
stale_cutoff = datetime.now(UTC) - timedelta(seconds=self.settings.gpu_worker_stale_seconds)
|
||||
return (
|
||||
self.db.query(GpuWorkerModel).filter(GpuWorkerModel.last_heartbeat_at >= stale_cutoff).first() is not None
|
||||
)
|
||||
|
||||
def wait_for_result(
|
||||
self,
|
||||
task_id: str,
|
||||
timeout_seconds: Optional[int] = None,
|
||||
poll_interval: Optional[float] = None,
|
||||
) -> Optional[GpuLipsyncTaskModel]:
|
||||
"""同步轮询等待 GPU 任务完成。
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID(由 create_task 返回)
|
||||
timeout_seconds: 总超时,默认取 settings.gpu_lipsync_wait_timeout
|
||||
poll_interval: 轮询间隔秒,默认取 settings.gpu_lipsync_poll_interval
|
||||
|
||||
Returns:
|
||||
终态 task(status=done/failed);超时返回 None(此时调用方应回退 MediaKit)。
|
||||
等待期间会自动调用 _recover_timed_out_tasks 做超时回收。
|
||||
"""
|
||||
import time
|
||||
|
||||
timeout = timeout_seconds if timeout_seconds is not None else self.settings.gpu_lipsync_wait_timeout
|
||||
interval = poll_interval if poll_interval is not None else self.settings.gpu_lipsync_poll_interval
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
while True:
|
||||
now = datetime.now(UTC)
|
||||
# 顺手回收超时任务
|
||||
try:
|
||||
self._recover_timed_out_tasks(now)
|
||||
self.db.commit()
|
||||
except Exception as exc: # noqa: BLE001 - 回收失败不阻塞主流程
|
||||
logger.warning("wait_for_result 回收超时任务异常: %s", exc)
|
||||
self.db.rollback()
|
||||
|
||||
task = self.db.get(GpuLipsyncTaskModel, task_id)
|
||||
if task is None:
|
||||
return None
|
||||
if task.status == "done":
|
||||
return task
|
||||
if task.status == "failed":
|
||||
return task
|
||||
# pending/processing 继续等
|
||||
if time.monotonic() >= deadline:
|
||||
logger.warning("GPU 任务 %s 等待超时(%ds),回退 MediaKit", task_id, timeout)
|
||||
return None
|
||||
time.sleep(interval)
|
||||
@@ -29,6 +29,7 @@ from app.services.mediakit_client import (
|
||||
MediaKitError,
|
||||
get_mediakit_client,
|
||||
)
|
||||
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
|
||||
|
||||
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径)
|
||||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||||
@@ -36,6 +37,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
from packages.config import get_api_settings
|
||||
from packages.domain.sentence_timings import (
|
||||
compute_sentence_timings,
|
||||
probe_audio_duration,
|
||||
@@ -63,6 +65,7 @@ class LipsyncService:
|
||||
self.client = client or get_mediakit_client()
|
||||
self._cosyvoice = cosyvoice_service
|
||||
self._voice_clone_repo = voice_clone_repo
|
||||
self.settings = get_api_settings()
|
||||
|
||||
def _get_cosyvoice(self):
|
||||
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
|
||||
@@ -215,7 +218,57 @@ class LipsyncService:
|
||||
if timings:
|
||||
job.sentence_timings = timings
|
||||
|
||||
# 4. 签名 URL 并提交 MediaKit
|
||||
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
|
||||
use_gpu = False
|
||||
if self.settings.use_gpu_lipsync:
|
||||
try:
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
gpu_svc = GpuLipsyncService(self.db)
|
||||
if gpu_svc.has_available_worker():
|
||||
use_gpu = True
|
||||
logger.info("[lipsync] 检测到可用 GPU Worker,优先走 MuseTalk 本地推理: job_id=%s", job.id)
|
||||
else:
|
||||
logger.info("[lipsync] GPU 开关已开但无可用 Worker(心跳过期),回退 MediaKit: job_id=%s", job.id)
|
||||
except Exception as exc:
|
||||
logger.warning("[lipsync] GPU 服务初始化失败,回退 MediaKit: job_id=%s err=%s", job.id, exc)
|
||||
|
||||
if use_gpu:
|
||||
try:
|
||||
gpu_task = self._submit_to_gpu_create(job=job, gpu_svc=gpu_svc)
|
||||
if gpu_task is not None:
|
||||
# GPU 任务已创建,设为 processing 并异步等待结果
|
||||
job.mediakit_task_id = f"gpu:{gpu_task.id}"
|
||||
job.status = "processing"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
# 派发 Celery 异步任务处理 GPU 等待+结果回写
|
||||
try:
|
||||
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
|
||||
logger.info(
|
||||
"[lipsync] GPU 任务已异步派发: job_id=%s gpu_task=%s",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
)
|
||||
except Exception as celery_exc:
|
||||
logger.warning(
|
||||
"[lipsync] Celery 派发失败,降级同步等待: job_id=%s err=%s",
|
||||
job.id,
|
||||
celery_exc,
|
||||
)
|
||||
self._submit_to_gpu_wait(job=job, gpu_svc=gpu_svc, gpu_task=gpu_task)
|
||||
return
|
||||
# create 失败 → 回退 MediaKit
|
||||
logger.warning("[lipsync] GPU 任务创建失败,回退 MediaKit: job_id=%s", job.id)
|
||||
self.db.rollback()
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync] GPU 路径异常,回退 MediaKit: job_id=%s err=%s", job.id, exc)
|
||||
try:
|
||||
self.db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. 签名 URL 并提交 MediaKit(兜底路径)
|
||||
video_url = self._sign_media_url(job.video_url)
|
||||
signed_audio_url = self._sign_media_url(job.audio_url)
|
||||
job.audio_url = signed_audio_url
|
||||
@@ -244,6 +297,120 @@ class LipsyncService:
|
||||
self.db.commit()
|
||||
raise
|
||||
|
||||
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
|
||||
|
||||
def _is_own_oss_url(self, url: str, storage) -> bool:
|
||||
"""判断 URL / 存储 key 是否属于自家 OSS。
|
||||
|
||||
- 裸存储 key(无 scheme):自家对象
|
||||
- host 与 storage.public_url host 一致:自家对象
|
||||
- 其余 http(s) 公网链接(如 dashscope-result 临时地址):外部对象
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme:
|
||||
return True # 裸存储 key
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
own_host = urlparse(public_base).netloc.lower() if public_base else ""
|
||||
return bool(own_host) and parsed.netloc.lower() == own_host
|
||||
|
||||
def _persist_external_audio_for_gpu(self, *, job, storage) -> Optional[str]:
|
||||
"""GPU 任务创建前,把外部域名的预合成 TTS 音频转存到自家 OSS。
|
||||
|
||||
Worker 部署在用户家庭网络,dashscope-result 等第三方临时 OSS 地址
|
||||
可能无法访问;转存后 gpu_svc 在 poll 时会签自家预签名 URL 给 Worker。
|
||||
已是自家 OSS 对象(含裸 key)直接返回 None(无需转存);
|
||||
转存失败返回 None,调用方回退使用原始 URL(最坏情况是 Worker 拉取失败,
|
||||
服务端重试耗尽后回退 MediaKit,不阻断业务)。
|
||||
"""
|
||||
if self._is_own_oss_url(job.audio_url, storage):
|
||||
return None
|
||||
try:
|
||||
audio_data = safe_download_bytes(
|
||||
job.audio_url,
|
||||
purpose="lipsync_gpu_tts_audio",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
storage_key = f"lipsync-tts/{job.user_id}/{job.id}.mp3"
|
||||
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
|
||||
logger.info(
|
||||
"[lipsync] GPU 任务外部音频已转存自家 OSS: job_id=%s key=%s",
|
||||
job.id,
|
||||
storage_key,
|
||||
)
|
||||
return permanent_url
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync] GPU 任务外部音频转存 OSS 失败,回退原始 URL: job_id=%s err=%s",
|
||||
job.id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
def _submit_to_gpu_create(self, *, job, gpu_svc) -> Optional[object]:
|
||||
"""创建 GPU 任务并立即返回(异步模式)。
|
||||
|
||||
成功返回 gpu_task 对象;创建失败返回 None。
|
||||
不再同步等待结果,结果由 Celery 异步任务 lipsync_gpu_process_async 回写。
|
||||
"""
|
||||
storage = get_shared_storage_service()
|
||||
persisted_audio_url = self._persist_external_audio_for_gpu(job=job, storage=storage)
|
||||
audio_url_for_task = persisted_audio_url or job.audio_url
|
||||
gpu_task = gpu_svc.create_task(
|
||||
video_url=job.video_url,
|
||||
audio_url=audio_url_for_task,
|
||||
lipsync_job_id=job.id,
|
||||
user_id=job.user_id,
|
||||
project_id=job.project_id,
|
||||
)
|
||||
logger.info(
|
||||
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
)
|
||||
return gpu_task
|
||||
|
||||
def _submit_to_gpu_wait(self, *, job, gpu_svc, gpu_task) -> None:
|
||||
"""同步等待 GPU 结果(Celery 派发失败时的降级路径)。"""
|
||||
final_task = gpu_svc.wait_for_result(gpu_task.id)
|
||||
if final_task is None:
|
||||
logger.warning("[lipsync] GPU 同步等待超时,回退 MediaKit: gpu_task=%s", gpu_task.id)
|
||||
return
|
||||
if final_task.status != "done":
|
||||
logger.warning(
|
||||
"[lipsync] GPU 同步等待失败: gpu_task=%s status=%s",
|
||||
gpu_task.id,
|
||||
final_task.status,
|
||||
)
|
||||
return
|
||||
try:
|
||||
storage = get_shared_storage_service()
|
||||
signed_result_url = storage.get_download_url(
|
||||
final_task.result_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS
|
||||
)
|
||||
if signed_result_url:
|
||||
final_task.result_url = signed_result_url
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync] GPU 结果签名失败: gpu_task=%s err=%s",
|
||||
gpu_task.id,
|
||||
exc,
|
||||
)
|
||||
job.mediakit_task_id = ""
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = final_task.result_url
|
||||
job.output_duration = final_task.result_duration or 0.0
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
"[lipsync] GPU 同步等待完成: job_id=%s duration=%.2f",
|
||||
job.id,
|
||||
job.output_duration,
|
||||
)
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
@@ -478,6 +645,29 @@ class LipsyncService:
|
||||
if job.status in (STATUS_COMPLETED, "failed"):
|
||||
return job
|
||||
|
||||
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
|
||||
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
|
||||
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
|
||||
if job.status in ("processing", "gpu_processing"):
|
||||
_now = datetime.now(UTC)
|
||||
_upd = job.updated_at
|
||||
if _upd is not None and _upd.tzinfo is None:
|
||||
_upd = _upd.replace(tzinfo=UTC)
|
||||
stale_minutes = 30
|
||||
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
|
||||
logger.warning(
|
||||
"GPU 异步任务超时(>%d 分钟),标记失败: job_id=%s",
|
||||
stale_minutes,
|
||||
job_id,
|
||||
)
|
||||
job.status = "failed"
|
||||
job.error_message = f"GPU 处理超时(>{stale_minutes} 分钟)"
|
||||
job.error_code = "GpuTimeout"
|
||||
job.completed_at = _now
|
||||
job.updated_at = _now
|
||||
self.db.commit()
|
||||
return job
|
||||
|
||||
# 未提交的任务不轮询
|
||||
if not job.mediakit_task_id:
|
||||
return job
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""叙事剪辑前置服务 — #1970 PR3.
|
||||
|
||||
叙事模式(assembly_mode='narrative')在生成任务入队前同步完成:
|
||||
|
||||
1. 按 script_id 读取文案(归属校验);
|
||||
2. 按 tts_voice_source 解析音色(preset=CosyVoice 音色 id;clone=克隆档案 id,
|
||||
解析档案归属并取其 CosyVoice voice_id);
|
||||
3. 同步 TTS 合成(复用 tts_job 现有 workflow:提交即同步返回,未完成则轮询兜底),
|
||||
失败直接抛 NarrativeError(HTTP 层转 4xx,任务不入队);
|
||||
4. 把合成音频转存为配音库 audio asset(与 /tts/jobs/{id}/save-to-library 同一套
|
||||
存储路径与元信息约定),返回 asset_id —— 下游仍以 voice_library_id(实为
|
||||
audio asset id)消费,渲染链路零改动。
|
||||
|
||||
积分扣点与 /tts 合成端点保持一致(ai_voice 场景),失败退费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import ScriptModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POINTS_SCENE = "ai_voice"
|
||||
_SYNTH_TIMEOUT = 180.0 # 叙事配音在 HTTP 请求内同步等待,长文案分段合成时留出余量
|
||||
_CONTENT_TYPE_MAP = {"mp3": "audio/mpeg", "wav": "audio/wav", "pcm": "audio/pcm", "opus": "audio/opus"}
|
||||
|
||||
|
||||
class NarrativeError(Exception):
|
||||
"""叙事模式前置处理失败(文案/音色/TTS/落库)。"""
|
||||
|
||||
def __init__(self, message: str, *, status_code: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NarrativeContext:
|
||||
"""叙事模式前置处理结果。"""
|
||||
|
||||
script: ScriptModel
|
||||
voice_asset_id: str
|
||||
tts_job_id: str
|
||||
audio_duration: float
|
||||
|
||||
|
||||
def _find_or_create_voice_library(
|
||||
*,
|
||||
user_id: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
) -> AssetLibrary:
|
||||
"""找到(或自动创建)用户 voice 素材库;与 tts.py 保存配音库逻辑一致。"""
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
if not projects:
|
||||
raise NarrativeError("没有可用的项目,无法保存叙事配音", status_code=400)
|
||||
|
||||
for project in projects:
|
||||
for lib in asset_library_repository.find_by_project(project.id):
|
||||
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if kind == AssetLibraryKind.VOICE.value:
|
||||
return lib
|
||||
|
||||
project = projects[0]
|
||||
library = AssetLibrary.create(project_id=project.id, name="配音素材库", kind=AssetLibraryKind.VOICE)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
try:
|
||||
return asset_library_repository.create(library)
|
||||
except IntegrityError:
|
||||
session = getattr(asset_library_repository, "session", None)
|
||||
if session is not None:
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception: # noqa: BLE001 - 回滚失败不影响重查
|
||||
logger.warning("IntegrityError 后回滚 session 失败", exc_info=True)
|
||||
for lib in asset_library_repository.find_by_project(project.id):
|
||||
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if kind == AssetLibraryKind.VOICE.value:
|
||||
return lib
|
||||
raise NarrativeError("配音素材库创建失败,请重试", status_code=500) from None
|
||||
|
||||
|
||||
def _resolve_voice(
|
||||
*,
|
||||
user_id: str,
|
||||
tts_voice_id: str,
|
||||
tts_voice_source: str,
|
||||
voice_clone_repository: Any,
|
||||
) -> tuple[str, str]:
|
||||
"""解析音色 → (CosyVoice voice_id, voice_clone_profile_id)。"""
|
||||
if tts_voice_source == "clone":
|
||||
profile = voice_clone_repository.get(tts_voice_id)
|
||||
if profile is None:
|
||||
raise NarrativeError("克隆音色不存在", status_code=404)
|
||||
if profile.user_id != user_id:
|
||||
raise NarrativeError("无权使用该克隆音色", status_code=403)
|
||||
if not profile.voice_id:
|
||||
raise NarrativeError("音色克隆尚未完成,请稍后再试", status_code=400)
|
||||
return profile.voice_id, profile.id
|
||||
# preset:tts_voice_id 即 CosyVoice 音色 id;与 /tts 端点一致,
|
||||
# 若前端误传克隆档案 UUID,同样兼容解析。
|
||||
profile = voice_clone_repository.get(tts_voice_id)
|
||||
if profile is not None:
|
||||
if profile.user_id != user_id:
|
||||
raise NarrativeError("无权使用该音色", status_code=403)
|
||||
if not profile.voice_id:
|
||||
raise NarrativeError("音色克隆尚未完成,请稍后再试", status_code=400)
|
||||
return profile.voice_id, profile.id
|
||||
return tts_voice_id, ""
|
||||
|
||||
|
||||
def _save_tts_job_as_voice_asset(
|
||||
*,
|
||||
job: Any,
|
||||
user_id: str,
|
||||
name: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
asset_repository: Any,
|
||||
storage_service: SharedStorageService,
|
||||
) -> Asset:
|
||||
"""把已完成 TTS job 的音频转存为配音库 audio asset(同 save-to-library 约定)。"""
|
||||
if not job.output_audio_url and not job.output_audio_key:
|
||||
raise NarrativeError("TTS 合成缺少输出音频", status_code=502)
|
||||
|
||||
library = _find_or_create_voice_library(
|
||||
user_id=user_id,
|
||||
project_repository=project_repository,
|
||||
asset_library_repository=asset_library_repository,
|
||||
)
|
||||
|
||||
audio_format = (job.format or "mp3").strip() or "mp3"
|
||||
content_type = _CONTENT_TYPE_MAP.get(audio_format, "audio/mpeg")
|
||||
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
|
||||
|
||||
tmp_path: Path | None = None
|
||||
audio_duration: float | None = None
|
||||
file_size = 0
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
download_source = job.output_audio_key or job.output_audio_url
|
||||
downloaded = storage_service.download_asset(download_source, tmp_path)
|
||||
if not downloaded or not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
||||
raise NarrativeError("叙事配音音频转存失败", status_code=502)
|
||||
file_size = tmp_path.stat().st_size
|
||||
storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
str(tmp_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
dur = float(json.loads(proc.stdout).get("format", {}).get("duration", 0))
|
||||
if dur > 0:
|
||||
audio_duration = dur
|
||||
except Exception: # noqa: BLE001 - ffprobe 仅用于时长兜底
|
||||
logger.warning("叙事配音 ffprobe 时长提取失败: job_id=%s", job.id, exc_info=True)
|
||||
except NarrativeError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("叙事配音转存失败: job_id=%s, error=%s", job.id, e, exc_info=True)
|
||||
raise NarrativeError("叙事配音音频转存失败", status_code=502) from e
|
||||
finally:
|
||||
if tmp_path and tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
metadata_: dict[str, object] = {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": job.id,
|
||||
"narrative": True,
|
||||
"format": job.format,
|
||||
"sample_rate": job.sample_rate,
|
||||
"voice_id": job.voice_id,
|
||||
"voice_name": job.voice_model or "",
|
||||
}
|
||||
if job.metadata:
|
||||
for key in ("speed", "language"):
|
||||
if key in job.metadata:
|
||||
metadata_[key] = job.metadata[key]
|
||||
|
||||
asset = Asset.create(
|
||||
project_id=library.project_id,
|
||||
library_id=library.id,
|
||||
name=name or f"叙事配音-{job.id[:8]}",
|
||||
storage_key=storage_key,
|
||||
mime_type=content_type,
|
||||
metadata=metadata_,
|
||||
file_size=file_size,
|
||||
duration=job.duration or audio_duration or None,
|
||||
status=AssetStatus.READY,
|
||||
classification_status=ClassificationStatus.PENDING,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
try:
|
||||
return asset_repository.create(asset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("叙事配音 asset 落库失败,清理 OSS: %s, error=%s", storage_key, e, exc_info=True)
|
||||
try:
|
||||
storage_service.delete_file(storage_key)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("清理孤儿 OSS 文件失败: %s", storage_key, exc_info=True)
|
||||
raise NarrativeError("叙事配音保存失败,请重试", status_code=502) from e
|
||||
|
||||
|
||||
def prepare_narrative_voice(
|
||||
*,
|
||||
db: Session,
|
||||
user_id: str,
|
||||
script_id: str,
|
||||
tts_voice_id: str,
|
||||
tts_voice_source: str,
|
||||
tts_repository: Any,
|
||||
cosyvoice_service: CosyVoiceService,
|
||||
voice_clone_repository: Any,
|
||||
asset_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
project_repository: Any,
|
||||
storage_service: SharedStorageService,
|
||||
points_enabled: bool = False,
|
||||
is_member: bool = False,
|
||||
member_type: str | None = None,
|
||||
) -> NarrativeContext:
|
||||
"""叙事模式入队前同步合成配音并落为 audio asset。
|
||||
|
||||
Raises:
|
||||
NarrativeError: 文案缺失/归属不符、音色不可用、TTS 失败、转存失败。
|
||||
"""
|
||||
script = db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
|
||||
if script is None:
|
||||
raise NarrativeError("文案不存在或无权使用", status_code=404)
|
||||
content = (script.content or "").strip()
|
||||
if not content:
|
||||
raise NarrativeError("文案内容为空,无法合成配音", status_code=400)
|
||||
|
||||
actual_voice_id, clone_profile_id = _resolve_voice(
|
||||
user_id=user_id,
|
||||
tts_voice_id=tts_voice_id,
|
||||
tts_voice_source=tts_voice_source,
|
||||
voice_clone_repository=voice_clone_repository,
|
||||
)
|
||||
|
||||
# 积分扣点(与 /tts 合成端点同口径),失败时在合成失败分支退费
|
||||
points_svc = PointsService() if points_enabled else None
|
||||
points_deducted = 0
|
||||
if points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(content) / 240))
|
||||
points_deducted = calculate_points_cost(
|
||||
_POINTS_SCENE,
|
||||
is_member=is_member,
|
||||
duration_minutes=est_minutes,
|
||||
member_type=member_type,
|
||||
)
|
||||
deduct_res = points_svc.deduct_points(user_id, points_deducted, _POINTS_SCENE, db)
|
||||
if not deduct_res["success"]:
|
||||
raise NarrativeError(
|
||||
f"积分不足,需要 {points_deducted} 积分,当前余额 {deduct_res['balance']}",
|
||||
status_code=402,
|
||||
)
|
||||
|
||||
use_case = CreateTTSJobUseCase(tts_repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
input_text=content,
|
||||
voice_id=actual_voice_id,
|
||||
voice_clone_profile_id=clone_profile_id,
|
||||
metadata={"speed": 1.0, "emotion": "", "language": "zh-CN", "narrative": True, "script_id": script_id},
|
||||
)
|
||||
|
||||
workflow = TTSWorkflowService(repository=tts_repository, cosyvoice_service=cosyvoice_service)
|
||||
try:
|
||||
job = workflow.start_synthesis(job.id)
|
||||
if not job.is_completed:
|
||||
job = workflow.poll_and_process_synthesis(job.id, timeout=_SYNTH_TIMEOUT)
|
||||
except Exception as e: # noqa: BLE001 - 同步合成异常统一转 NarrativeError
|
||||
logger.error("叙事配音 TTS 合成失败: job_id=%s, error=%s", job.id, e, exc_info=True)
|
||||
try:
|
||||
workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("标记叙事 TTS job 失败出错: job_id=%s", job.id, exc_info=True)
|
||||
if points_deducted and points_svc is not None:
|
||||
try:
|
||||
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("叙事 TTS 失败退积分异常: job_id=%s", job.id, exc_info=True)
|
||||
raise NarrativeError(f"配音合成失败:{e}", status_code=502) from e
|
||||
|
||||
if not job.is_completed:
|
||||
if points_deducted and points_svc is not None:
|
||||
try:
|
||||
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("叙事 TTS 未完成退积分异常: job_id=%s", job.id, exc_info=True)
|
||||
raise NarrativeError("配音合成未完成,请稍后重试", status_code=504)
|
||||
|
||||
asset = _save_tts_job_as_voice_asset(
|
||||
job=job,
|
||||
user_id=user_id,
|
||||
name=(script.title or "叙事配音")[:60],
|
||||
project_repository=project_repository,
|
||||
asset_library_repository=asset_library_repository,
|
||||
asset_repository=asset_repository,
|
||||
storage_service=storage_service,
|
||||
)
|
||||
|
||||
return NarrativeContext(
|
||||
script=script,
|
||||
voice_asset_id=asset.id,
|
||||
tts_job_id=job.id,
|
||||
audio_duration=float(job.duration or asset.duration or 0.0),
|
||||
)
|
||||
@@ -22,6 +22,11 @@ from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
from packages.domain.atom_clip_resolver import load_atom_clips_for_assets
|
||||
from packages.domain.atom_clip_selector import (
|
||||
estimate_required_clip_count,
|
||||
select_atom_clips,
|
||||
)
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
@@ -52,10 +57,12 @@ class PlanGeneratorService:
|
||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session, asset_repo=None) -> None:
|
||||
def __init__(self, db: Session, asset_repo=None, atom_clip_repo=None) -> None:
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
self._asset_repo = asset_repo
|
||||
# #1970 原子化切片:可选注入;未注入时走旧的整条素材选片路径(向后兼容)
|
||||
self._atom_clip_repo = atom_clip_repo
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -121,18 +128,34 @@ class PlanGeneratorService:
|
||||
|
||||
# 4. 按 editing_mode 分配素材
|
||||
if asset_ids:
|
||||
# 获取素材时长信息,用于随机起始时间
|
||||
asset_durations = None
|
||||
if self._asset_repo:
|
||||
asset_durations = self._fetch_asset_durations(asset_ids)
|
||||
self._distribute_assets(
|
||||
clips,
|
||||
asset_ids,
|
||||
editing_mode,
|
||||
random_selection=random_preview,
|
||||
asset_durations=asset_durations,
|
||||
user_id=created_by_user_id,
|
||||
)
|
||||
# #1970 原子化切片:素材 clip 从 atom_clips 表选取(未就绪自动内存兜底)。
|
||||
# 预览随机模式保持旧路径(整条素材 + 随机起点),与现有预览契约一致。
|
||||
atom_applied = False
|
||||
if not random_preview and self._atom_clip_repo is not None:
|
||||
try:
|
||||
atom_applied = self._distribute_atom_clips(
|
||||
clips,
|
||||
asset_ids,
|
||||
editing_mode,
|
||||
user_id=created_by_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("原子片段选片失败,回退整条素材选片", exc_info=True)
|
||||
atom_applied = False
|
||||
|
||||
if not atom_applied:
|
||||
# 获取素材时长信息,用于随机起始时间
|
||||
asset_durations = None
|
||||
if self._asset_repo:
|
||||
asset_durations = self._fetch_asset_durations(asset_ids)
|
||||
self._distribute_assets(
|
||||
clips,
|
||||
asset_ids,
|
||||
editing_mode,
|
||||
random_selection=random_preview,
|
||||
asset_durations=asset_durations,
|
||||
user_id=created_by_user_id,
|
||||
)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: list[EditPlanClip] = []
|
||||
@@ -259,6 +282,103 @@ class PlanGeneratorService:
|
||||
external_used_segments=external_used_segments,
|
||||
)
|
||||
|
||||
def _distribute_atom_clips(
|
||||
self,
|
||||
clips: list[EditPlanClip],
|
||||
asset_ids: list[str],
|
||||
editing_mode: str,
|
||||
*,
|
||||
user_id: str = "",
|
||||
) -> bool:
|
||||
"""#1970 原子化切片选片(就地修改 clips,未持久化).
|
||||
|
||||
从 ``asset_atom_clips`` 表按原子片段选取;老素材/切片未就绪的素材
|
||||
内存兜底切片。同一原子片段在一次方案中只用一次;跨视频避让走
|
||||
edit_plan_clips.atom_clip_id 最近使用记录。
|
||||
|
||||
Returns:
|
||||
True 表示原子片段选片成功;False 表示无可用片段,调用方应回退
|
||||
到旧的整条素材 distribute_assets。
|
||||
"""
|
||||
# 1. 加载候选原子片段(DB + 兜底)
|
||||
clips_by_asset = load_atom_clips_for_assets(
|
||||
asset_ids,
|
||||
atom_clip_repo=self._atom_clip_repo,
|
||||
asset_repo=self._asset_repo,
|
||||
)
|
||||
if not clips_by_asset:
|
||||
return False
|
||||
|
||||
# 2. 最近使用片段(跨视频原子片段级避让)
|
||||
recently_used: set[str] = set()
|
||||
if user_id and hasattr(self._clip_repo, "list_recent_atom_clip_ids_by_user"):
|
||||
try:
|
||||
recently_used = set(self._clip_repo.list_recent_atom_clip_ids_by_user(user_id, limit=200))
|
||||
except Exception:
|
||||
logger.warning("跨视频原子片段避让查询失败", exc_info=True)
|
||||
|
||||
# 3. 片段需求估算:无配音时按 clips 数量;voice_over 的配音总时长存于
|
||||
# clip.config["voice_duration"],按 平均片段时长≈需要片段数 估算
|
||||
voice_total = 0.0
|
||||
for c in clips:
|
||||
cfg_vd = c.config.get("voice_duration") if c.config else None
|
||||
if cfg_vd:
|
||||
voice_total += float(cfg_vd)
|
||||
avg_clip_target = sum(float(c.duration or 0.0) for c in clips) / max(len(clips), 1)
|
||||
required_count = estimate_required_clip_count(
|
||||
voice_total or sum(float(c.duration or 0.0) for c in clips),
|
||||
avg_clip_target or 3.5,
|
||||
)
|
||||
required_count = max(required_count, len(clips))
|
||||
|
||||
rng = random.Random()
|
||||
|
||||
# 4. 正式生成:先按素材 smart_score 对素材池排序,再展开为片段池
|
||||
# (同素材的片段保持连续,高分素材的片段排在前面优先入选)
|
||||
if self._asset_repo:
|
||||
asset_order = self._sort_assets_by_smart_score(list(clips_by_asset.keys()))
|
||||
ordered: dict[str, list] = {}
|
||||
for aid in asset_order:
|
||||
if aid in clips_by_asset:
|
||||
ordered[aid] = clips_by_asset[aid]
|
||||
clips_by_asset = ordered
|
||||
|
||||
candidates: list = []
|
||||
for asset_clips in clips_by_asset.values():
|
||||
candidates.extend(asset_clips)
|
||||
|
||||
# 5. 逐虚拟片段选片:评分排序,同片段不重复使用
|
||||
used_atom_ids: set[str] = set()
|
||||
asset_usage: dict[str, int] = {}
|
||||
assigned = 0
|
||||
for clip in clips:
|
||||
# 对每个虚拟片段重新评分(usage_count 随选择动态变化)
|
||||
scored = select_atom_clips(
|
||||
candidates,
|
||||
target_duration=float(clip.duration or 0.0),
|
||||
used_atom_clip_ids=used_atom_ids,
|
||||
asset_usage_counts=asset_usage,
|
||||
recently_used_atom_ids=recently_used,
|
||||
required_count=required_count,
|
||||
limit=1,
|
||||
rng=rng,
|
||||
)
|
||||
if not scored:
|
||||
# 候选耗尽(同片段不可重复),交由调用方回退或留白
|
||||
continue
|
||||
picked = scored[0]
|
||||
clip.asset_id = picked.asset_id
|
||||
clip.atom_clip_id = picked.atom_clip_id
|
||||
clip.start_time = round(picked.start_time, 3)
|
||||
clip.duration = round(picked.duration, 3)
|
||||
used_atom_ids.add(picked.atom_clip_id)
|
||||
asset_usage[picked.asset_id] = asset_usage.get(picked.asset_id, 0) + 1
|
||||
assigned += 1
|
||||
|
||||
if assigned == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _fetch_asset_scene_points(self, asset_ids: list[str]) -> dict[str, list[float]]:
|
||||
"""从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。"""
|
||||
points_map: dict[str, list[float]] = {}
|
||||
|
||||
@@ -38,7 +38,12 @@ def transcribe_to_text(media_path: str | Path) -> str:
|
||||
ASRTranscriptionError: ASR 调用失败
|
||||
"""
|
||||
# 延迟导入,避免循环依赖和启动时副作用
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
try:
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
except ImportError as exc:
|
||||
# API 镜像未打包 worker 代码(本地 ASR 依赖 worker 的 asr_service_factory)
|
||||
logger.warning("本地 ASR 不可用(apps.worker 未安装): %s", exc)
|
||||
raise ASRNotConfiguredError("本地 ASR 服务不可用(worker 模块未安装)") from exc
|
||||
|
||||
asr = get_asr_service()
|
||||
if asr is None:
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""GPU MuseTalk 异步推理任务 — 将 GPU 推理等待从 HTTP 请求移至 Celery 后台执行.
|
||||
|
||||
优化目标:将 POST /lipsync/jobs 的 API 响应时间从 >200s 降到 <1s。
|
||||
任务流程:
|
||||
1. 加载 LipsyncJob,获取 gpu_task_id
|
||||
2. 调用 GpuLipsyncService.wait_for_result 轮询等待 GPU 完成
|
||||
3. 签名结果 URL(7 天),更新 job 为 completed
|
||||
4. 失败/超时时:尝试 MediaKit 兜底,若仍失败则标记 job 为 failed
|
||||
|
||||
使用 @shared_task 确保被 Worker 侧 celery_app 正确注册。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 与 LipsyncService 保持一致
|
||||
_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
def _get_db_session() -> Session:
|
||||
"""获取 DB session(兼容 API 和 Worker 两种运行时)."""
|
||||
try:
|
||||
from worker_app.db import SessionLocal # type: ignore
|
||||
except ImportError:
|
||||
from app.db import SessionLocal # type: ignore
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
def _sign_media_url(url: str) -> str:
|
||||
"""对自家 OSS URL 签 7 天预签名。"""
|
||||
if not url:
|
||||
return url
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return url
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
host = urlparse(url).netloc.lower()
|
||||
if not own_host or host != own_host:
|
||||
return url
|
||||
return storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="lipsync_gpu_process_async",
|
||||
bind=True,
|
||||
max_retries=0,
|
||||
acks_late=True,
|
||||
)
|
||||
def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str) -> None:
|
||||
"""异步处理 GPU MuseTalk 推理。
|
||||
|
||||
Args:
|
||||
job_id: LipsyncJob 的 ID
|
||||
user_id: 用户 ID
|
||||
gpu_task_id: GpuLipsyncTask 的 ID
|
||||
"""
|
||||
db: Session = _get_db_session()
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
|
||||
if job is None:
|
||||
logger.error("[lipsync_gpu_async] job 不存在: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
# 确保状态为 processing
|
||||
if job.status not in ("processing", "gpu_processing"):
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] job 状态异常,跳过: job_id=%s status=%s",
|
||||
job_id,
|
||||
job.status,
|
||||
)
|
||||
return
|
||||
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
gpu_svc = GpuLipsyncService(db)
|
||||
final_task = gpu_svc.wait_for_result(gpu_task_id)
|
||||
|
||||
if final_task is None:
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] GPU 超时,回退 MediaKit: job_id=%s gpu_task=%s",
|
||||
job_id,
|
||||
gpu_task_id,
|
||||
)
|
||||
_fallback_to_mediakit(db, job)
|
||||
return
|
||||
|
||||
if final_task.status != "done":
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] GPU 失败,回退 MediaKit: job_id=%s gpu_task=%s status=%s",
|
||||
job_id,
|
||||
gpu_task_id,
|
||||
final_task.status,
|
||||
)
|
||||
_fallback_to_mediakit(db, job)
|
||||
return
|
||||
|
||||
# 签名结果 URL
|
||||
result_url = final_task.result_url or ""
|
||||
try:
|
||||
storage = get_shared_storage_service()
|
||||
signed = storage.get_download_url(result_url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
|
||||
if signed:
|
||||
result_url = signed
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync_gpu_async] 签名失败,用原 URL: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
job.status = "completed"
|
||||
job.output_video_url = result_url
|
||||
job.output_duration = final_task.result_duration or 0.0
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[lipsync_gpu_async] GPU 完成: job_id=%s duration=%.2f",
|
||||
job_id,
|
||||
job.output_duration,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter_by(id=job_id).first()
|
||||
if job:
|
||||
job.status = "failed"
|
||||
job.error_message = f"GPU 异步处理异常: {exc}"
|
||||
job.error_code = "GpuAsyncError"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
|
||||
"""GPU 失败时回退到 MediaKit 云端渲染。"""
|
||||
try:
|
||||
from app.services.mediakit_client import MediaKitError, get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
video_url = _sign_media_url(job.video_url)
|
||||
audio_url = _sign_media_url(job.audio_url)
|
||||
|
||||
result = client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=job.enable_video_loop,
|
||||
client_token=job.id,
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[lipsync_gpu_async] 已回退 MediaKit: job_id=%s task_id=%s",
|
||||
job.id,
|
||||
result["task_id"],
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.error("[lipsync_gpu_async] MediaKit 也失败: job_id=%s err=%s", job.id, exc)
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"GPU+MediaKit 均失败: {exc}"
|
||||
job.error_code = "FallbackFailed"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.error("[lipsync_gpu_async] 兜底异常: job_id=%s err=%s", job.id, exc)
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
|
||||
for (let i = 0; i <= 2; i++) {
|
||||
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
|
||||
if (r.status() !== 429) {
|
||||
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
|
||||
return (await r.json()).access_token as string
|
||||
}
|
||||
console.log(`[douyin] 429 retry ${i + 1}/2`)
|
||||
await new Promise((res) => setTimeout(res, 65000))
|
||||
}
|
||||
throw new Error("Login retries exhausted")
|
||||
}
|
||||
|
||||
/**
|
||||
* #1972 抖音文案提取冒烟
|
||||
*
|
||||
* 路径:文案库页面 → 点「🎬 从抖音提取」→ 粘贴分享文案 → 点「开始提取」
|
||||
* → mock /api/v1/scripts/extract-from-douyin 返回稳定文案 → 断言「新建文案」弹窗中预填了非空文案
|
||||
*/
|
||||
test.describe("Douyin Script Extraction (#1972)", () => {
|
||||
test("extract flow: open modal, paste link, text prefilled in create modal", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000)
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const email = `e2e-douyin-${suffix}@example.com`
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username: `e2e_dy_${suffix}` },
|
||||
})
|
||||
const token = await loginWithRetry(request, email, PASSWORD)
|
||||
const authHeader = { Authorization: `Bearer ${token}` }
|
||||
|
||||
const proj = await request.post(`${apiBase}/projects`, {
|
||||
headers: authHeader,
|
||||
data: { name: `Smoke Douyin ${suffix}` },
|
||||
})
|
||||
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers: authHeader,
|
||||
data: { project_id: projectId, name: "Smoke", kind: "video" },
|
||||
})
|
||||
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// Mock 抖音提取接口返回稳定文案
|
||||
const extractedText = "大家好,今天给大家推荐一款超好用的产品,性价比非常高,快来看看吧!"
|
||||
await page.route("**/api/v1/scripts/extract-from-douyin", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ text: extractedText, duration_seconds: 15 }),
|
||||
}),
|
||||
)
|
||||
// 文案列表空态
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/scripts") && !url.pathname.includes("extract-from-douyin"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], total: 0, page: 1, page_size: 20 }),
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto("/app/scripts")
|
||||
// 文案库页面加载
|
||||
await expect(page.getByText(/文案库|文案/).first()).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// 点「🎬 从抖音提取」按钮
|
||||
await page.getByRole("button", { name: /从抖音提取/ }).click()
|
||||
await expect(page.getByText("从抖音视频提取文案")).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// 在 TextArea 粘贴"抖音分享文案"
|
||||
const textarea = page.locator(".ant-modal textarea").first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await textarea.fill("8.88 复制打开抖音,看看【推荐视频】https://v.douyin.com/abcDEF/")
|
||||
|
||||
// 点「开始提取」
|
||||
await page.getByRole("button", { name: "开始提取" }).click()
|
||||
await expect(page.getByText(/提取中/)).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// 等待抖音弹窗关闭,「新建文案」弹窗打开并预填提取文案
|
||||
await expect(page.getByText("从抖音视频提取文案")).not.toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText("新建文案")).toBeVisible({ timeout: 5000 })
|
||||
const createTextarea = page.locator(".ant-modal textarea").first()
|
||||
await expect(createTextarea).toBeVisible()
|
||||
await expect(createTextarea).toHaveValue(new RegExp(extractedText.slice(0, 10)))
|
||||
console.log("[douyin] Extraction flow completed ✓, text length:", extractedText.length)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
@@ -8,7 +8,8 @@ const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
@@ -24,276 +25,358 @@ async function loginWithRetry(
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
): Promise<string> {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
const resp = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
|
||||
if (resp.status() !== 429) {
|
||||
expect(resp.ok(), `Login should succeed: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.access_token
|
||||
}
|
||||
console.log(`[login] 429 rate limited, retry ${i + 1}/${maxRetries} after 65s`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
throw new Error("Login failed after retries")
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新用户 + 建项目/视频库/上传 sample.mp4,等素材 ready。返回 { token, projectId, libraryId, assetId }。
|
||||
*/
|
||||
async function setupFreshUser(
|
||||
request: APIRequestContext,
|
||||
label: string,
|
||||
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const email = `e2e-${label}-${suffix}@example.com`
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username: `e2e_${label}_${suffix}` },
|
||||
})
|
||||
}
|
||||
const token = await loginWithRetry(request, email, PASSWORD)
|
||||
const auth = { Authorization: `Bearer ${token}` }
|
||||
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
const proj = await request.post(`${apiBase}/projects`, {
|
||||
headers: auth,
|
||||
data: { name: `Smoke ${label} ${suffix}` },
|
||||
})
|
||||
expect(proj.ok(), `create project: ${await proj.text()}`).toBeTruthy()
|
||||
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers: auth,
|
||||
data: { project_id: projectId, name: "Smoke", kind: "video" },
|
||||
})
|
||||
expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy()
|
||||
const libraryId = (await lib.json()).id
|
||||
|
||||
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-${suffix}@example.com`
|
||||
const username = `e2e_gen_${suffix}`
|
||||
const libraryName = `E2E Gen Lib ${suffix}`
|
||||
|
||||
// Register
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
const registerData = (await register.json()) as { user_id: string }
|
||||
|
||||
// Login
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
|
||||
// Create project
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E Gen Proj ${suffix}` },
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
const projectData = (await project.json()) as ProjectResponse
|
||||
|
||||
// Create asset library
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectData.id, name: libraryName, kind: "video" },
|
||||
})
|
||||
expect(library.status()).toBe(200)
|
||||
const libraryData = (await library.json()) as LibraryResponse
|
||||
|
||||
// Upload source video
|
||||
const sourceFileName = "e2e-gen-source.mp4"
|
||||
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
|
||||
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: sourceFileName,
|
||||
mimeType: "video/mp4",
|
||||
buffer: sampleVideoBuffer,
|
||||
},
|
||||
const samplePath = path.join(__dirname, "fixtures", "sample.mp4")
|
||||
const sampleBuf = fs.readFileSync(samplePath)
|
||||
const up = await request.post(`${apiBase}/upload`, {
|
||||
headers: auth,
|
||||
multipart: {
|
||||
project_id: projectId,
|
||||
library_id: libraryId,
|
||||
file: {
|
||||
name: "sample.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: sampleBuf,
|
||||
},
|
||||
})
|
||||
expect(upload.status()).toBe(200)
|
||||
},
|
||||
})
|
||||
expect(up.ok(), `upload sample: ${await up.text()}`).toBeTruthy()
|
||||
const assetId = (await up.json()).asset_id
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const r = await request.get(`${apiBase}/assets/${assetId}`, { headers: auth })
|
||||
return r.ok() ? (await r.json()).status : "pending"
|
||||
},
|
||||
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
|
||||
)
|
||||
.toBe("ready")
|
||||
return { token, libraryId, assetId, suffix }
|
||||
}
|
||||
|
||||
// Wait for asset to be ready
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
})
|
||||
if (!assets.ok()) return `http_${assets.status()}`
|
||||
const data = (await assets.json()) as AssetListResponse
|
||||
const asset = data.items.find((a) => a.name === sourceFileName)
|
||||
if (!asset) return "missing"
|
||||
return asset.status
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
/**
|
||||
* #1970 智能剪辑核心冒烟(新 5 步向导)
|
||||
*
|
||||
* 新流程:选择模式 → 选择素材 → 选择标题 → 确认生成 → 选择封面
|
||||
*
|
||||
* 两条路径:
|
||||
* 1) 随机混剪(默认)→ Step1 下一步 → 配音选择弹窗 → Step2 选素材 → 数量弹窗
|
||||
* → Step3 标题 → Step4 确认生成 → 断言任务创建
|
||||
* 2) 叙事剪辑 → Step1 切模式 → 下一步 → 文案选择弹窗 → TTS 弹窗选音色(mock 合成)
|
||||
* → Step2 AI 提示卡可见 + 选素材 → 数量弹窗 → Step3 标题 → Step4 确认生成
|
||||
* → 断言任务创建
|
||||
*/
|
||||
test.describe("Core Smart-Edit Flow (#1970)", () => {
|
||||
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
|
||||
test.setTimeout(600_000)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
const { token, suffix } = await setupFreshUser(request, "random")
|
||||
const authHeader = { Authorization: `Bearer ${token}` }
|
||||
|
||||
// 确保默认模板存在(智能剪辑页依赖模板)
|
||||
const tmpls = await request.get(`${apiBase}/templates`, { headers: authHeader })
|
||||
const tmplsJson = await tmpls.json()
|
||||
const templates = Array.isArray(tmplsJson)
|
||||
? tmplsJson
|
||||
: Array.isArray(tmplsJson.items)
|
||||
? tmplsJson.items
|
||||
: []
|
||||
expect(templates.length).toBeGreaterThan(0)
|
||||
|
||||
// 注入登录态 + 路由 API
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
.toBe("ready")
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// GET /templates auto-creates a default template for new users
|
||||
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
|
||||
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
|
||||
const templatesData = (await templatesResp.json()) as {
|
||||
items: Array<{ id: string }>
|
||||
}
|
||||
expect(Array.isArray(templatesData.items)).toBe(true)
|
||||
expect(templatesData.items.length).toBeGreaterThan(0)
|
||||
const templateId = templatesData.items[0].id
|
||||
expect(templateId).toBeTruthy()
|
||||
|
||||
// Set auth in localStorage
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ──
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/assets") && url.searchParams.get("kind") === "voice",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: `asset-voice-${suffix}`,
|
||||
name: "测试配音.mp3",
|
||||
file_url: "data:audio/mpeg;base64,",
|
||||
duration: 10,
|
||||
file_size: 1024,
|
||||
kind: "voice",
|
||||
status: "ready",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
user: {
|
||||
id: registerData.user_id,
|
||||
user_id: registerData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Navigate to generate page
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
|
||||
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("随机混剪")).toBeVisible()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 1: 素材选择 ──
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
|
||||
await expect(materialCard).toBeVisible({ timeout: 10_000 })
|
||||
await materialCard.click({ position: { x: 15, y: 15 } })
|
||||
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// ── 配音选择弹窗:选第一个配音 → 确认 ─────────────────────────
|
||||
await expect(page.getByText("🎙️ 选择配音")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("测试配音.mp3").first().click()
|
||||
await page.getByRole("button", { name: "确认选择" }).click()
|
||||
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
|
||||
|
||||
// ── 数量弹窗(PreviewCountModal) ──
|
||||
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
// ── Step 2:选择素材 ──────────────────────────────────────────
|
||||
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
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 2: 配音(新注册用户无配音素材,跳过) ──
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// ── Step 3: 标题设置 ──
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
const titleInput = page.locator(".ant-select-auto-complete input")
|
||||
// ── Step 3:填写标题 ──────────────────────────────────────────
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
||||
await titleInput.fill(`E2E Test ${suffix}`)
|
||||
await titleInput.fill(`测试随机剪辑 ${suffix}`)
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// Step 3 底部是「下一步 →」,点击进入 Step 4(确认生成)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText("随机混剪")).toBeVisible()
|
||||
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
|
||||
await expect(confirmBtn).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
// ── Step 4: 确认生成 ──
|
||||
// 等待实时预览就绪(占位消失)
|
||||
await page
|
||||
.getByText("准备预览素材")
|
||||
.waitFor({ state: "detached", timeout: 30_000 })
|
||||
.catch(() => {})
|
||||
const createTask = page.waitForResponse(
|
||||
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
await confirmBtn.click()
|
||||
const taskResp = await createTask
|
||||
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
|
||||
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
|
||||
console.log("[random] Generation task created:", taskId)
|
||||
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
|
||||
console.log("[random] Wizard flow completed ✓")
|
||||
})
|
||||
|
||||
// Step 4 底部是「✨ 确认生成视频」
|
||||
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
|
||||
test("narrative mode: select script + mock TTS, create generation task", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(600_000)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
const { token, suffix } = await setupFreshUser(request, "narrative")
|
||||
|
||||
// 先挂 API 监听再点击
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
}, token)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
|
||||
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
|
||||
const mockScriptId = `script-mock-${suffix}`
|
||||
const mockVoiceId = `preset-voice-${suffix}`
|
||||
const mockJobId = `tts-job-${suffix}`
|
||||
|
||||
// 文案列表(ScriptSelectModal 查询 /scripts)
|
||||
await page.route("**/api/v1/scripts**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.includes("/extract-from-douyin")) {
|
||||
route.continue()
|
||||
return
|
||||
}
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: mockScriptId,
|
||||
title: "测试带货文案",
|
||||
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
|
||||
tags: ["带货"],
|
||||
title_category: "daihuo",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// 预设音色(TtsVoiceModal 查询 GET /voices/presets)
|
||||
await page.route("**/api/v1/voices/presets**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
voice_id: mockVoiceId,
|
||||
name: "晓晓(女声)",
|
||||
description: "温柔女声",
|
||||
gender: "female",
|
||||
language: "zh-CN",
|
||||
preview_url: null,
|
||||
tags: ["温柔"],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await confirmBtn.click()
|
||||
// 克隆音色:空列表
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/voice-clones"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
}),
|
||||
)
|
||||
|
||||
// 验证生成 API 被调用
|
||||
const genResp = await generatePromise.catch(() => null)
|
||||
if (!genResp) {
|
||||
// staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过
|
||||
console.log(
|
||||
"[E2E] Generation API not triggered (preview not ready) — wizard navigation verified",
|
||||
)
|
||||
} else if (genResp.ok()) {
|
||||
const genData = (await genResp.json()) as {
|
||||
items: Array<{ id: string; status: string }>
|
||||
total: number
|
||||
}
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
// TTS 合成:直接返回 completed 任务
|
||||
await page.route("**/api/v1/tts/synthesize", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
job_id: mockJobId,
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
audio_url: "data:audio/mpeg;base64,",
|
||||
duration: 5,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
|
||||
}),
|
||||
)
|
||||
|
||||
// race:渲染完成 vs 生成失败/超时
|
||||
const downloadReady = page
|
||||
.getByText("视频生成完成")
|
||||
.isVisible({ timeout: 180_000 })
|
||||
.then((v) => (v ? "completed" : null))
|
||||
const generationFailed = page
|
||||
.getByText(/生成失败|重新生成/)
|
||||
.isVisible({ timeout: 180_000 })
|
||||
.then((v) => (v ? "failed" : null))
|
||||
|
||||
const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout")
|
||||
|
||||
if (outcome === "completed") {
|
||||
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
} else {
|
||||
console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`)
|
||||
}
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
}
|
||||
|
||||
// 验证成品库页面加载
|
||||
await page.goto("/app/products")
|
||||
await expect(page).toHaveURL(/\/app\/products/)
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
})
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-api-${suffix}@example.com`
|
||||
const username = `e2e_gen_api_${suffix}`
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await page.getByText("叙事剪辑").click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E API Proj ${suffix}` },
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
|
||||
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("测试带货文案").first().click()
|
||||
await page.getByRole("button", { name: "确认选择" }).click()
|
||||
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
|
||||
|
||||
const tasks = await request.get(`${apiBase}/tasks`, { headers })
|
||||
expect(tasks.status()).toBe(200)
|
||||
const tasksData = await tasks.json()
|
||||
expect(Array.isArray(tasksData.items)).toBe(true)
|
||||
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
|
||||
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("晓晓(女声)").first().click()
|
||||
await page.getByRole("button", { name: "🎧 合成配音" }).click()
|
||||
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
|
||||
|
||||
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
|
||||
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
|
||||
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,但我们再覆盖一次) ─
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
|
||||
await expect(titleInput2).toBeVisible({ timeout: 5000 })
|
||||
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── Step 4:确认生成 ──────────────────────────────────────────
|
||||
await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText("叙事剪辑")).toBeVisible()
|
||||
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
|
||||
await expect(confirmBtn2).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
const createTask2 = page.waitForResponse(
|
||||
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
await confirmBtn2.click()
|
||||
const taskResp2 = await createTask2
|
||||
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
|
||||
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
|
||||
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
|
||||
console.log("[narrative] Wizard flow completed ✓")
|
||||
})
|
||||
})
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithRetry(request: APIRequestContext, email: string, password: string) {
|
||||
for (let i = 0; i <= 2; i++) {
|
||||
const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } })
|
||||
if (r.status() !== 429) {
|
||||
expect(r.ok(), `login: ${await r.text()}`).toBeTruthy()
|
||||
return (await r.json()).access_token as string
|
||||
}
|
||||
console.log(`[nav] 429 retry ${i + 1}/2`)
|
||||
await new Promise((res) => setTimeout(res, 65000))
|
||||
}
|
||||
throw new Error("Login retries exhausted")
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心页面导航冒烟:侧边栏主要入口能访问、文案库/配音库页面能正常加载(不出白屏/无致命 js error)
|
||||
*/
|
||||
test.describe("Core Navigation", () => {
|
||||
let authToken: string
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const email = `e2e-nav-${suffix}@example.com`
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username: `e2e_nav_${suffix}` },
|
||||
})
|
||||
authToken = await loginWithRetry(request, email, PASSWORD)
|
||||
const authHeader = { Authorization: `Bearer ${authToken}` }
|
||||
const proj = await request.post(`${apiBase}/projects`, {
|
||||
headers: authHeader,
|
||||
data: { name: `Smoke Nav ${suffix}` },
|
||||
})
|
||||
if (proj.ok()) {
|
||||
const projectId = (await proj.json()).id ?? (await proj.json()).project_id
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers: authHeader,
|
||||
data: { project_id: projectId, name: "Nav Lib", kind: "video" },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.addInitScript((t: string) => {
|
||||
window.localStorage.setItem("access_token", t)
|
||||
window.localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({ state: { token: t, user: null } }),
|
||||
)
|
||||
}, authToken)
|
||||
await routeBrowserApiToTestApi(page)
|
||||
})
|
||||
|
||||
const navCases = [
|
||||
{ path: "/app/dashboard", marker: /概览|工作台|最近/i, name: "概览" },
|
||||
{ path: "/app/generate", marker: /智能剪辑|剪辑/, name: "智能剪辑" },
|
||||
{ path: "/app/assets", marker: /视频库|素材/, name: "视频库" },
|
||||
{ path: "/app/scripts", marker: /文案/, name: "文案库" },
|
||||
{ path: "/app/voices", marker: /配音|我的音色|配音库/, name: "配音库" },
|
||||
{ path: "/app/products", marker: /成品|作品/, name: "成品库" },
|
||||
{ path: "/app/history", marker: /历史|任务/, name: "任务历史" },
|
||||
{ path: "/app/tasks", marker: /任务中心|任务列表/, name: "任务中心" },
|
||||
{ path: "/app/points", marker: /积分|我的积分/, name: "积分中心" },
|
||||
]
|
||||
|
||||
for (const c of navCases) {
|
||||
test(`visit ${c.name} (${c.path}) loads without fatal pageerror`, async ({ page }) => {
|
||||
const errors: Error[] = []
|
||||
page.on("pageerror", (e) => errors.push(e))
|
||||
await page.goto(c.path)
|
||||
await expect(page.locator("body")).not.toBeEmpty({ timeout: 20000 })
|
||||
// 过滤掉常见第三方/非致命错误
|
||||
const fatal = errors.filter(
|
||||
(e) =>
|
||||
!/ResizeObserver|Loading chunk|network error|Failed to fetch|chunkLoadError/i.test(
|
||||
e.message,
|
||||
),
|
||||
)
|
||||
expect(fatal, `${c.name} pageerrors: ${fatal.map((e) => e.message).join("; ")}`).toHaveLength(
|
||||
0,
|
||||
)
|
||||
await expect(
|
||||
page.getByText(c.marker).first(),
|
||||
`${c.name} should show relevant text`,
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
console.log(`[nav] ${c.name} loaded ✓`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -71,6 +71,16 @@ export interface CreateGenerationTaskRequest {
|
||||
duration?: number
|
||||
/** 视频宽高比,如 "9:16" */
|
||||
video_ratio?: string
|
||||
/** #1970:剪辑模式 random/narrative */
|
||||
assembly_mode?: "random" | "narrative"
|
||||
/** #1970:叙事模式下的文案 ID */
|
||||
script_id?: string
|
||||
/** #1970:TTS 音色 ID */
|
||||
tts_voice_id?: string
|
||||
/** #1970:TTS 音色来源 preset/clone */
|
||||
tts_voice_source?: "preset" | "clone"
|
||||
/** #1970:智能降重开关(默认 true) */
|
||||
dedup_enabled?: boolean
|
||||
/** 标题烧录配置 */
|
||||
title_config?: {
|
||||
text?: string
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import "./PointsBadge.css"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
@@ -32,9 +33,13 @@ const PointsBadge: React.FC = () => {
|
||||
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!ENABLE_CREDIT_SYSTEM) return
|
||||
if (!balance) init()
|
||||
}, [balance, init])
|
||||
|
||||
// 功能开关:积分系统关闭时直接隐藏徽章
|
||||
if (!ENABLE_CREDIT_SYSTEM) return null
|
||||
|
||||
// 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance
|
||||
const bal = membership?.points_balance ?? balance?.balance ?? 0
|
||||
const lowBalance = bal > 0 && bal < 10
|
||||
|
||||
@@ -15,6 +15,7 @@ import React, { useMemo } from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { WarningOutlined } from "@ant-design/icons"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import type { PointsSource } from "@/api/points/types"
|
||||
import "./PointsCost.css"
|
||||
|
||||
@@ -53,7 +54,7 @@ const PointsCost: React.FC<Props> = ({
|
||||
compact = false,
|
||||
showRechargeHint = true,
|
||||
className = "",
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const { balance, dailyUsage, rules, membership } = usePointsStore()
|
||||
const qty = quantity ?? units ?? 1
|
||||
|
||||
@@ -118,6 +119,9 @@ const PointsCost: React.FC<Props> = ({
|
||||
}
|
||||
}, [rules, balance, dailyUsage, membership, scene, qty, durationMinutes])
|
||||
|
||||
// 积分系统关闭时不展示消耗提示(组件保留,hooks 必须在 return 前调用)
|
||||
if (!ENABLE_CREDIT_SYSTEM) return null
|
||||
|
||||
if (!rule || !balance) {
|
||||
return <span className={`xx-points-cost ${className}`} />
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useLogout } from "@/hooks/useAuth"
|
||||
import type { MenuProps } from "antd"
|
||||
import { NAV_ITEMS } from "@/config/navigation"
|
||||
import PointsBadge from "@/components/common/PointsBadge"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./Header.css"
|
||||
|
||||
@@ -57,30 +58,36 @@ const Header: React.FC = () => {
|
||||
label: "订阅管理",
|
||||
onClick: () => navigate("/app/subscription"),
|
||||
},
|
||||
// v2: 我的积分入口
|
||||
{
|
||||
key: "points-center",
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: (
|
||||
<Space>
|
||||
我的积分
|
||||
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
|
||||
</Space>
|
||||
),
|
||||
onClick: () => navigate("/app/points"),
|
||||
},
|
||||
{
|
||||
key: "points-history",
|
||||
icon: <HistoryOutlined />,
|
||||
label: "积分明细",
|
||||
onClick: () => navigate("/app/points/transactions"),
|
||||
},
|
||||
{
|
||||
key: "recharge",
|
||||
icon: <WalletOutlined />,
|
||||
label: "充值积分",
|
||||
onClick: () => navigate("/app/points/recharge"),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分相关菜单项(代码保留不删除)
|
||||
...(ENABLE_CREDIT_SYSTEM
|
||||
? [
|
||||
{
|
||||
key: "points-center",
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: (
|
||||
<Space>
|
||||
我的积分
|
||||
{balance && (
|
||||
<span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
onClick: () => navigate("/app/points"),
|
||||
},
|
||||
{
|
||||
key: "points-history",
|
||||
icon: <HistoryOutlined />,
|
||||
label: "积分明细",
|
||||
onClick: () => navigate("/app/points/transactions"),
|
||||
},
|
||||
{
|
||||
key: "recharge",
|
||||
icon: <WalletOutlined />,
|
||||
label: "充值积分",
|
||||
onClick: () => navigate("/app/points/recharge"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "logout",
|
||||
@@ -130,7 +137,13 @@ const Header: React.FC = () => {
|
||||
|
||||
{/* v2: 升级会员入口(仅免费用户显示) */}
|
||||
{!isMember && (
|
||||
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
|
||||
<Tooltip
|
||||
title={
|
||||
ENABLE_CREDIT_SYSTEM
|
||||
? "升级会员解锁无限混剪、批量导出,积分 8 折起"
|
||||
: "升级会员解锁无限混剪、批量导出"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 功能开关配置
|
||||
* 集中管理前端特性的启用/隐藏,便于灰度与回滚。
|
||||
* 注意:仅控制 UI 展示与前端校验,后端扣减逻辑由后端对应开关控制。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 积分系统 UI 开关(默认 false = 隐藏)
|
||||
* - false:隐藏所有积分相关入口/余额/消耗提示/不足弹窗/充值入口;会员标识保留;
|
||||
* 功能流程不做积分预校验,直接走生成。
|
||||
* - true:展示完整积分系统 UI。
|
||||
*/
|
||||
export const ENABLE_CREDIT_SYSTEM = false
|
||||
@@ -3,6 +3,7 @@
|
||||
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
|
||||
*/
|
||||
import React from "react"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "./features"
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileOutlined,
|
||||
@@ -105,12 +106,17 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
|
||||
...(ENABLE_CREDIT_SYSTEM
|
||||
? [
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -200,12 +206,17 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
// 积分系统开关关闭时隐藏积分中心入口(代码保留不删除)
|
||||
...(ENABLE_CREDIT_SYSTEM
|
||||
? [
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -44,7 +44,8 @@ export const createLipsyncJob = async (data: {
|
||||
enable_video_loop?: boolean
|
||||
project_id?: string
|
||||
}): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
|
||||
// GPU 口型同步推理约 20s,留足余量到 120s 防止 10s 默认超时
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data, { timeout: 120_000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import VoiceSelectModal from "./components/VoiceSelectModal"
|
||||
import ScriptSelectModal from "./components/ScriptSelectModal"
|
||||
import TtsVoiceModal from "./components/TtsVoiceModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
|
||||
@@ -30,6 +33,7 @@ import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { hasEnoughPoints } from "./hooks/pointsCost"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import "./generate.css"
|
||||
import "./generate-points.css"
|
||||
|
||||
@@ -62,11 +66,26 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
editMode,
|
||||
setEditMode,
|
||||
selectedScript,
|
||||
setSelectedScript,
|
||||
ttsVoiceId,
|
||||
setTtsVoiceId,
|
||||
ttsVoiceSource,
|
||||
setTtsVoiceSource,
|
||||
ttsVoiceAssetId,
|
||||
setTtsVoiceAssetId,
|
||||
dedupEnabled,
|
||||
setDedupEnabled,
|
||||
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
videoRatio,
|
||||
setVideoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
@@ -124,6 +143,11 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 数量选择弹窗 ── */
|
||||
const [countModalOpen, setCountModalOpen] = useState(false)
|
||||
|
||||
/* ── #1970 流程重构:分支弹窗 ── */
|
||||
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
|
||||
const [scriptModalOpen, setScriptModalOpen] = useState(false)
|
||||
const [ttsModalOpen, setTtsModalOpen] = useState(false)
|
||||
|
||||
/* ── 标题样式回调 ── */
|
||||
const styleUpdaters = useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
@@ -301,6 +325,12 @@ const GeneratePage: React.FC = () => {
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
editMode,
|
||||
selectedScript,
|
||||
ttsVoiceId,
|
||||
ttsVoiceSource,
|
||||
ttsVoiceAssetId,
|
||||
dedupEnabled,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
@@ -340,7 +370,7 @@ const GeneratePage: React.FC = () => {
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? "")
|
||||
})
|
||||
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
|
||||
setCurrentStep(2)
|
||||
setCurrentStep(3)
|
||||
},
|
||||
[
|
||||
setPreviewCount,
|
||||
@@ -354,21 +384,76 @@ const GeneratePage: React.FC = () => {
|
||||
],
|
||||
)
|
||||
|
||||
/* ── #1970:Step1 弹窗回调 ── */
|
||||
const handleVoiceModalConfirm = useCallback(
|
||||
(voiceAssetId: string) => {
|
||||
setSelectedVoice(voiceAssetId)
|
||||
setVoiceMode("custom")
|
||||
setVoiceModalOpen(false)
|
||||
setCurrentStep(2)
|
||||
},
|
||||
[setSelectedVoice, setVoiceMode, setCurrentStep],
|
||||
)
|
||||
|
||||
const handleScriptModalConfirm = useCallback(
|
||||
(script: import("@/api/scripts").ScriptItem) => {
|
||||
setSelectedScript(script)
|
||||
// 自动带入标题(若标题为空则预填)
|
||||
if (!titleSettings.title?.trim() && script.title) {
|
||||
setTitleSettings((prev) => ({ ...prev, title: script.title, aiAutoSelect: false }))
|
||||
}
|
||||
setScriptModalOpen(false)
|
||||
// 自动打开 TTS 弹窗
|
||||
setTtsModalOpen(true)
|
||||
},
|
||||
[setSelectedScript, setTitleSettings, titleSettings.title],
|
||||
)
|
||||
|
||||
const handleTtsSynthesized = useCallback(
|
||||
(payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => {
|
||||
setTtsVoiceId(payload.ttsVoiceId)
|
||||
setTtsVoiceSource(payload.ttsVoiceSource)
|
||||
setTtsVoiceAssetId(payload.voiceAssetId)
|
||||
if (payload.ttsVoiceSource === "clone") {
|
||||
setSelectedClonedVoice(payload.ttsVoiceId)
|
||||
setVoiceMode("clone")
|
||||
} else {
|
||||
setSelectedVoice(payload.ttsVoiceId)
|
||||
setVoiceMode("preset")
|
||||
}
|
||||
setTtsModalOpen(false)
|
||||
message.success("配音合成成功")
|
||||
setCurrentStep(2)
|
||||
},
|
||||
[
|
||||
setTtsVoiceId,
|
||||
setTtsVoiceSource,
|
||||
setTtsVoiceAssetId,
|
||||
setSelectedVoice,
|
||||
setSelectedClonedVoice,
|
||||
setVoiceMode,
|
||||
setCurrentStep,
|
||||
],
|
||||
)
|
||||
|
||||
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
// 积分预检查
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const check = hasEnoughPoints(
|
||||
balance ?? null,
|
||||
units,
|
||||
dailyUsage ?? null,
|
||||
[],
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
)
|
||||
if (!check.sufficient) {
|
||||
message.error(check.reason ?? "积分不足,请充值")
|
||||
return
|
||||
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
|
||||
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
|
||||
if (ENABLE_CREDIT_SYSTEM) {
|
||||
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
check = hasEnoughPoints(
|
||||
balance ?? null,
|
||||
units,
|
||||
dailyUsage ?? null,
|
||||
[],
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
)
|
||||
if (!check.sufficient) {
|
||||
message.error(check.reason ?? "积分不足,请充值")
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isBatch) {
|
||||
if (selectedVariantIds.length === 0) {
|
||||
@@ -412,12 +497,20 @@ const GeneratePage: React.FC = () => {
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
editMode,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
generated,
|
||||
onOpenCountModal: () => setCountModalOpen(true),
|
||||
onOpenStep1Modal: () => {
|
||||
if (editMode === "random") {
|
||||
setVoiceModalOpen(true)
|
||||
} else {
|
||||
setScriptModalOpen(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 最终成片 ── */
|
||||
@@ -431,19 +524,18 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
const pointsEstimate = useMemo(
|
||||
() =>
|
||||
hasEnoughPoints(
|
||||
balance ?? null,
|
||||
unitsForCost,
|
||||
dailyUsage ?? null,
|
||||
[],
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
),
|
||||
[unitsForCost, balance, dailyUsage, rules],
|
||||
)
|
||||
const insufficientPoints = !pointsEstimate.sufficient
|
||||
const pointsEstimate = useMemo(() => {
|
||||
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
|
||||
return hasEnoughPoints(
|
||||
balance ?? null,
|
||||
unitsForCost,
|
||||
dailyUsage ?? null,
|
||||
[],
|
||||
"free",
|
||||
rules?.free_user_multiplier ?? 1.15,
|
||||
)
|
||||
}, [unitsForCost, balance, dailyUsage, rules])
|
||||
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -462,7 +554,7 @@ const GeneratePage: React.FC = () => {
|
||||
{!isBatch ? (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
videoRatio={videoRatio}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
@@ -497,7 +589,7 @@ const GeneratePage: React.FC = () => {
|
||||
<CanvasPreviewGrid
|
||||
count={previewCount}
|
||||
assets={previewAssets}
|
||||
videoRatio={videoRatio}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
@@ -545,6 +637,16 @@ const GeneratePage: React.FC = () => {
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
selectedVoice={selectedVoice}
|
||||
editMode={editMode}
|
||||
onEditModeChange={setEditMode}
|
||||
dedupEnabled={dedupEnabled}
|
||||
onDedupEnabledChange={setDedupEnabled}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
onVideoRatioChange={(r) => setVideoRatio(r)}
|
||||
selectedScript={selectedScript}
|
||||
ttsVoiceId={ttsVoiceId}
|
||||
ttsVoiceSource={ttsVoiceSource}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
generating={generating}
|
||||
@@ -671,6 +773,27 @@ const GeneratePage: React.FC = () => {
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={handleCloneSuccess}
|
||||
/>
|
||||
|
||||
{/* #1970 流程弹窗 */}
|
||||
<VoiceSelectModal
|
||||
open={voiceModalOpen}
|
||||
selectedVoice={selectedVoice}
|
||||
onCancel={() => setVoiceModalOpen(false)}
|
||||
onConfirm={handleVoiceModalConfirm}
|
||||
/>
|
||||
<ScriptSelectModal
|
||||
open={scriptModalOpen}
|
||||
selectedScriptId={selectedScript?.id ?? null}
|
||||
onCancel={() => setScriptModalOpen(false)}
|
||||
onConfirm={handleScriptModalConfirm}
|
||||
/>
|
||||
<TtsVoiceModal
|
||||
open={ttsModalOpen}
|
||||
scriptText={selectedScript?.content ?? ""}
|
||||
scriptTitle={selectedScript?.title ?? ""}
|
||||
onCancel={() => setTtsModalOpen(false)}
|
||||
onSynthesized={handleTtsSynthesized}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染(#1899 简化为 5 步,#1913 传递 selectedTemplate)
|
||||
* 步骤顺序:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5)
|
||||
* 步骤3预览(Canvas 网格)与步骤4进度(批量渲染网格)由 GeneratePage 直接渲染在左侧大区域。
|
||||
* GeneratePage 步骤内容渲染(#1970 流程重构)
|
||||
* 步骤顺序:选择模式(1) → 选择素材(2) → 选择标题(3) → 确认生成(4) → 选择封面(5)
|
||||
* 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { ScriptItem } from "@/api/scripts"
|
||||
import Step1EditMode from "./Step1EditMode"
|
||||
import type { EditMode } from "./Step1EditMode"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceWithMode from "./Step3VoiceWithMode"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import BatchGenerationGrid from "./BatchGenerationGrid"
|
||||
@@ -17,19 +19,28 @@ import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
export interface GenerateStepContentProps {
|
||||
currentStep: number
|
||||
/* 片段数量(#1899) */
|
||||
/* Step1:剪辑模式 + 生成设置 */
|
||||
editMode: EditMode
|
||||
onEditModeChange: (m: EditMode) => void
|
||||
dedupEnabled: boolean
|
||||
onDedupEnabledChange: (v: boolean) => void
|
||||
/* ── 片段数量(#1899) ── */
|
||||
clipCount: number
|
||||
onClipCountChange: (n: number) => void
|
||||
/* 素材 */
|
||||
/* ── 生成数量/比例(Step1 设置) ── */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (n: number) => void
|
||||
videoRatio: "9:16" | "16:9"
|
||||
onVideoRatioChange: (r: "9:16" | "16:9") => void
|
||||
/* ── 素材 ── */
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/* 当前选中的模板/草稿 ID;空串时由后端自动兜底(#1913) */
|
||||
selectedTemplate?: string
|
||||
/* 标题 */
|
||||
/* ── 标题 ── */
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
onUpdatePosition: (position: string) => void
|
||||
@@ -42,14 +53,14 @@ export interface GenerateStepContentProps {
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
/* 封面 */
|
||||
/* ── 封面 ── */
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
/* 配音 */
|
||||
/* ── 配音 ── */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
/* 生成 */
|
||||
/* ── 生成 ── */
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
@@ -58,14 +69,10 @@ export interface GenerateStepContentProps {
|
||||
onRetry: () => void
|
||||
onRetryBatchTask: (taskId: string) => void
|
||||
onDismissError: () => void
|
||||
/** 批量:每个正式生成任务的独立状态(步骤4进度网格) */
|
||||
batchTasks: BatchTaskState[]
|
||||
/** BGM 开关 */
|
||||
bgm: boolean
|
||||
/** BGM 配置 */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
/* ── 批量生成(#1677)── */
|
||||
previewCount: number
|
||||
/* ── 批量生成 ── */
|
||||
previewTitles: string[]
|
||||
onPreviewTitlesChange: (titles: string[]) => void
|
||||
voiceModePerVideo: boolean
|
||||
@@ -74,15 +81,27 @@ export interface GenerateStepContentProps {
|
||||
onVoiceLibraryIdsChange: (ids: string[]) => void
|
||||
previewCovers: string[]
|
||||
onPreviewCoversChange: (urls: string[]) => void
|
||||
/** 批量模式勾选的变体索引 */
|
||||
selectedVariantIds?: number[]
|
||||
/* ── 摘要信息(#1970 Step4 展示用) ── */
|
||||
selectedScript: ScriptItem | null
|
||||
ttsVoiceId: string
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
// Only destructure props actually referenced in JSX below
|
||||
const {
|
||||
currentStep,
|
||||
editMode,
|
||||
onEditModeChange,
|
||||
dedupEnabled,
|
||||
onDedupEnabledChange,
|
||||
clipCount,
|
||||
onClipCountChange,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
videoRatio,
|
||||
onVideoRatioChange,
|
||||
materialMode,
|
||||
onMaterialModeChange,
|
||||
selectedMaterials,
|
||||
@@ -104,31 +123,24 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
onServerClipsChange,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
onRetry,
|
||||
generatedVideos,
|
||||
batchTasks,
|
||||
onRetryBatchTask,
|
||||
previewCount,
|
||||
previewTitles,
|
||||
onPreviewTitlesChange,
|
||||
voiceModePerVideo,
|
||||
onVoiceModePerVideoChange,
|
||||
voiceLibraryIds,
|
||||
onVoiceLibraryIdsChange,
|
||||
previewCovers,
|
||||
onPreviewCoversChange,
|
||||
selectedVariantIds,
|
||||
selectedScript,
|
||||
ttsVoiceId,
|
||||
ttsVoiceSource,
|
||||
} = props
|
||||
|
||||
// #1913:包装 onServerClipsChange,适配 hook 的 (clips, templateId?) 签名
|
||||
// 如果 hook 传回了后端兜底创建的 templateId,同时通知外层更新 selectedTemplate
|
||||
const handleClipsChange = React.useCallback(
|
||||
(clips: EditPlanClip[], _templateId?: string) => {
|
||||
onServerClipsChange(clips)
|
||||
@@ -138,8 +150,22 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<Step1EditMode
|
||||
editMode={editMode}
|
||||
onEditModeChange={onEditModeChange}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
videoRatio={videoRatio}
|
||||
onVideoRatioChange={onVideoRatioChange}
|
||||
dedupEnabled={dedupEnabled}
|
||||
onDedupEnabledChange={onDedupEnabledChange}
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<Step2MaterialSelect
|
||||
editMode={editMode}
|
||||
materialMode={materialMode}
|
||||
onMaterialModeChange={onMaterialModeChange}
|
||||
selectedMaterials={selectedMaterials}
|
||||
@@ -152,18 +178,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onServerClipsChange={handleClipsChange}
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<Step3VoiceWithMode
|
||||
previewCount={previewCount}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
voiceModePerVideo={voiceModePerVideo}
|
||||
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
|
||||
voiceLibraryIds={voiceLibraryIds}
|
||||
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step4TitleSettings
|
||||
@@ -185,20 +199,49 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态 */
|
||||
if (previewCount > 1) {
|
||||
return (
|
||||
<BatchGenerationGrid
|
||||
tasks={batchTasks}
|
||||
titles={previewTitles}
|
||||
onRetryTask={onRetryBatchTask}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (generated && !generating && !generateError) return null
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
{generating && (
|
||||
{/* 配置摘要(#1970) */}
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
background: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6, color: "#111" }}>
|
||||
📋 生成配置
|
||||
</div>
|
||||
<div>🎬 剪辑模式:{editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}</div>
|
||||
{editMode === "random" ? (
|
||||
<div>🎙️ 配音来源:配音库音频</div>
|
||||
) : (
|
||||
<>
|
||||
<div>📝 文案:{selectedScript?.title ?? "未选择"}</div>
|
||||
<div>
|
||||
🎙️ 合成配音音色:
|
||||
{ttsVoiceId
|
||||
? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}(${ttsVoiceId.slice(0, 8)}...)`
|
||||
: "未选择"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>📱 视频比例:{videoRatio}</div>
|
||||
<div>🎯 智能降重:{dedupEnabled ? "已开启" : "已关闭"}</div>
|
||||
{previewCount > 1 && <div>📦 生成数量:{previewCount} 个</div>}
|
||||
</div>
|
||||
|
||||
{previewCount > 1 ? (
|
||||
<BatchGenerationGrid
|
||||
tasks={batchTasks}
|
||||
titles={previewTitles}
|
||||
onRetryTask={onRetryBatchTask}
|
||||
/>
|
||||
) : generating ? (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-info">
|
||||
@@ -217,8 +260,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
) : generateError ? (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
@@ -228,7 +270,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
🔄 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
case 5:
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 叙事剪辑 — 文案选择弹窗(#1970)
|
||||
* - 搜索框:防抖 300ms,命中文字黄色高亮
|
||||
* - 标签筛选行:全部/带货/工厂/测评/教程/口播/种草
|
||||
* - 数量统计 + 卡片列表(可滚动,max-height 420px)
|
||||
* - 调用 GET /api/v1/scripts?keyword=&tag=&page_size=200
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"
|
||||
import { Modal, Input, Tag, Spin } from "antd"
|
||||
import { SearchOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getScripts } from "@/api/scripts"
|
||||
import type { ScriptItem } from "@/api/scripts"
|
||||
|
||||
interface ScriptSelectModalProps {
|
||||
open: boolean
|
||||
selectedScriptId: string | null
|
||||
onCancel: () => void
|
||||
onConfirm: (script: ScriptItem) => void
|
||||
}
|
||||
|
||||
const SCRIPT_TABS = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "带货", label: "带货" },
|
||||
{ key: "工厂", label: "工厂" },
|
||||
{ key: "测评", label: "测评" },
|
||||
{ key: "教程", label: "教程" },
|
||||
{ key: "口播", label: "口播" },
|
||||
{ key: "种草", label: "种草" },
|
||||
]
|
||||
|
||||
/** 在文本中用 <mark> 高亮关键词(黄色背景) */
|
||||
function highlight(text: string, keyword: string): React.ReactNode {
|
||||
if (!keyword) return text
|
||||
const idx = text.toLowerCase().indexOf(keyword.toLowerCase())
|
||||
if (idx < 0) return text
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark style={{ background: "#fef08a", color: "#713f12", padding: "0 2px", borderRadius: 2 }}>
|
||||
{text.slice(idx, idx + keyword.length)}
|
||||
</mark>
|
||||
{text.slice(idx + keyword.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ScriptSelectModal: React.FC<ScriptSelectModalProps> = ({
|
||||
open,
|
||||
selectedScriptId,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [innerSelected, setInnerSelected] = useState<string | null>(selectedScriptId)
|
||||
const [activeTag, setActiveTag] = useState<string>("all")
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [debouncedKw, setDebouncedKw] = useState("")
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInnerSelected(selectedScriptId)
|
||||
setActiveTag("all")
|
||||
setSearchInput("")
|
||||
setDebouncedKw("")
|
||||
}
|
||||
}, [open, selectedScriptId])
|
||||
|
||||
// 300ms 防抖
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => setDebouncedKw(searchInput.trim()), 300)
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [searchInput])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["scripts", "select-modal", debouncedKw, activeTag],
|
||||
queryFn: () =>
|
||||
getScripts({
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
keyword: debouncedKw || undefined,
|
||||
tag: activeTag === "all" ? undefined : activeTag,
|
||||
}),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const scripts: ScriptItem[] = useMemo(() => data?.items ?? [], [data])
|
||||
const selected = useMemo(
|
||||
() => scripts.find((s) => s.id === innerSelected) ?? null,
|
||||
[scripts, innerSelected],
|
||||
)
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (selected) onConfirm(selected)
|
||||
}, [selected, onConfirm])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="📝 选择文案"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleConfirm}
|
||||
okText="确认选择"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ disabled: !selected, style: { background: "#7c3aed" } }}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
>
|
||||
{/* 搜索 */}
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined style={{ color: "#9ca3af" }} />}
|
||||
placeholder="搜索标题、内容或标签"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
|
||||
{SCRIPT_TABS.map((t) => {
|
||||
const active = activeTag === t.key
|
||||
return (
|
||||
<Tag
|
||||
key={t.key}
|
||||
onClick={() => setActiveTag(t.key)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
padding: "4px 14px",
|
||||
borderRadius: 16,
|
||||
border: active ? "1px solid #7c3aed" : "1px solid #e5e7eb",
|
||||
background: active ? "#ede9fe" : "#fff",
|
||||
color: active ? "#7c3aed" : "#4b5563",
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</Tag>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 数量统计 */}
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
|
||||
共 {data?.total ?? scripts.length} 条文案
|
||||
</div>
|
||||
|
||||
{/* 卡片列表 */}
|
||||
<div style={{ maxHeight: 420, overflowY: "auto", paddingRight: 4 }}>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : scripts.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#9ca3af" }}>
|
||||
暂无匹配文案
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{scripts.map((s) => {
|
||||
const isSel = innerSelected === s.id
|
||||
const preview = (s.content || "").replace(/\s+/g, " ").slice(0, 80)
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => setInnerSelected(s.id)}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb",
|
||||
background: isSel ? "#faf5ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{isSel && (
|
||||
<CheckCircleFilled
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
color: "#7c3aed",
|
||||
fontSize: 18,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: isSel ? "#6d28d9" : "#111",
|
||||
marginBottom: 4,
|
||||
paddingRight: 24,
|
||||
}}
|
||||
>
|
||||
{highlight(s.title || "未命名", debouncedKw)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{highlight(preview + ((s.content || "").length > 80 ? "..." : ""), debouncedKw)}
|
||||
</div>
|
||||
{s.tags && s.tags.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{s.tags.slice(0, 5).map((tg) => (
|
||||
<Tag
|
||||
key={tg}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 11,
|
||||
padding: "1px 8px",
|
||||
borderRadius: 10,
|
||||
background: "#f3f4f6",
|
||||
border: "none",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{tg}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScriptSelectModal
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Step 1 选择剪辑模式 + 生成设置(#1970 新流程第一步)
|
||||
* - 剪辑模式:🎲随机混剪 / 📖叙事剪辑,二选一,选中紫底紫框
|
||||
* - 生成设置:生成数量(-/+ 1-10 默认1)、视频比例(9:16/16:9 默认9:16)、智能降重开关(默认开)
|
||||
*/
|
||||
import React from "react"
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
|
||||
export type EditMode = "random" | "narrative"
|
||||
|
||||
interface Step1EditModeProps {
|
||||
editMode: EditMode
|
||||
onEditModeChange: (mode: EditMode) => void
|
||||
/** 生成数量(1-10,默认1) */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (n: number) => void
|
||||
/** 视频比例 */
|
||||
videoRatio: "9:16" | "16:9"
|
||||
onVideoRatioChange: (ratio: "9:16" | "16:9") => void
|
||||
/** 智能降重开关(默认 true) */
|
||||
dedupEnabled: boolean
|
||||
onDedupEnabledChange: (v: boolean) => void
|
||||
}
|
||||
|
||||
const PURPLE = "#7c3aed"
|
||||
const PURPLE_BG = "linear-gradient(135deg, #ede9fe, #ddd6fe)"
|
||||
const PURPLE_BORDER = "2px solid #7c3aed"
|
||||
|
||||
const MODE_CARDS: Array<{
|
||||
key: EditMode
|
||||
emoji: string
|
||||
title: string
|
||||
desc: string
|
||||
features: string[]
|
||||
}> = [
|
||||
{
|
||||
key: "random",
|
||||
emoji: "🎲",
|
||||
title: "随机混剪",
|
||||
desc: "根据配音时长随机抽取素材片段,灵活组合",
|
||||
features: ["随机抽帧组合", "每次画面不同", "适合批量生成"],
|
||||
},
|
||||
{
|
||||
key: "narrative",
|
||||
emoji: "📖",
|
||||
title: "叙事剪辑",
|
||||
desc: "按文案内容匹配相关画面,有逻辑组织镜头",
|
||||
features: ["画面匹配文案", "叙事感更强", "需要素材标签"],
|
||||
},
|
||||
]
|
||||
|
||||
const Step1EditMode: React.FC<Step1EditModeProps> = ({
|
||||
editMode,
|
||||
onEditModeChange,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
videoRatio,
|
||||
onVideoRatioChange,
|
||||
dedupEnabled,
|
||||
onDedupEnabledChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 选择剪辑模式</h3>
|
||||
<p style={{ color: "#666", fontSize: 14, marginBottom: 16 }}>
|
||||
选择适合您的剪辑方式,后续流程会根据模式自动调整
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{MODE_CARDS.map((card) => {
|
||||
const selected = editMode === card.key
|
||||
return (
|
||||
<div
|
||||
key={card.key}
|
||||
onClick={() => onEditModeChange(card.key)}
|
||||
style={{
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
border: selected ? PURPLE_BORDER : "1px solid #e5e7eb",
|
||||
background: selected ? PURPLE_BG : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>{card.emoji}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: selected ? PURPLE : "#111",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{card.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#666", marginBottom: 12 }}>{card.desc}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{card.features.map((f) => (
|
||||
<div key={f} style={{ fontSize: 12, color: selected ? "#6d28d9" : "#6b7280" }}>
|
||||
✅ {f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 8 }}>⚙️ 生成设置</h3>
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>生成数量</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(Math.max(1, previewCount - 1))}
|
||||
disabled={previewCount <= 1}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: previewCount <= 1 ? "not-allowed" : "pointer",
|
||||
color: previewCount <= 1 ? "#d1d5db" : "#374151",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 40,
|
||||
textAlign: "center",
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "#111",
|
||||
}}
|
||||
>
|
||||
{previewCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(Math.min(10, previewCount + 1))}
|
||||
disabled={previewCount >= 10}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: previewCount >= 10 ? "not-allowed" : "pointer",
|
||||
color: previewCount >= 10 ? "#d1d5db" : "#374151",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: "#6b7280" }}>最多一次生成 10 个</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 16 }}>
|
||||
<label>视频比例</label>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 4 }}>
|
||||
{[
|
||||
{ key: "9:16" as const, emoji: "📱", label: "竖屏 9:16" },
|
||||
{ key: "16:9" as const, emoji: "🖥️", label: "横屏 16:9" },
|
||||
].map((opt) => {
|
||||
const selected = videoRatio === opt.key
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => onVideoRatioChange(opt.key)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: 8,
|
||||
border: selected ? PURPLE_BORDER : "1px solid #e5e7eb",
|
||||
background: selected ? PURPLE_BG : "#fff",
|
||||
color: selected ? PURPLE : "#374151",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
fontWeight: selected ? 600 : 400,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{opt.emoji} {opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="xx-form-field"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "12px 16px",
|
||||
background: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: "#111" }}>
|
||||
🎯 智能降重 {dedupEnabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDedupEnabledChange(!dedupEnabled)}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
border: "none",
|
||||
background: dedupEnabled ? PURPLE : "#d1d5db",
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.2s",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label="toggle dedup"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
left: dedupEnabled ? 22 : 2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
transition: "left 0.2s",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
|
||||
自动对画面做微调,避免查重不过
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step1EditMode
|
||||
@@ -11,6 +11,8 @@ import SmartMatchInput from "./material/SmartMatchInput"
|
||||
import SmartMatchResults from "./material/SmartMatchResults"
|
||||
|
||||
interface Step2MaterialSelectProps {
|
||||
/** 剪辑模式:random 随机混剪 / narrative 叙事剪辑(#1970) */
|
||||
editMode?: "random" | "narrative"
|
||||
materialMode: "manual" | "auto"
|
||||
onMaterialModeChange: (mode: "manual" | "auto") => void
|
||||
selectedMaterials: string[]
|
||||
@@ -43,6 +45,27 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>📦 选择素材</h3>
|
||||
|
||||
{/* 叙事剪辑:AI 智能匹配提示卡(#1970) */}
|
||||
{props.editMode === "narrative" && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "12px 16px",
|
||||
background: "linear-gradient(135deg,#ede9fe,#f5f3ff)",
|
||||
border: "1px solid #c4b5fd",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: "#5b21b6",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>🤖</span>
|
||||
<span>AI智能匹配:系统将根据您的文案内容,从素材库自动匹配合适的视频片段</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片段数量(#1899) */}
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>片段数量</label>
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* 叙事剪辑 — TTS 音色选择 + 合成配音弹窗(#1970)
|
||||
* - Tabs:✨系统音色 / 🎙️我的克隆音色
|
||||
* - 2列音色卡片(头像emoji+名称+描述+标签+▶试听+选中✓)
|
||||
* - 底部:取消 / 🎧 合成配音(主按钮,必须选音色才能点)
|
||||
* - 合成中:紫色 spinner + "正在合成配音..." + "请稍候,通常需要10-30秒"
|
||||
* - 合成成功:保存到配音库并回调(voiceAssetId + ttsVoiceId + ttsVoiceSource)
|
||||
*
|
||||
* 复用现有 /api/tts 的 synthesizeSpeech + 轮询 getTTSJobStatus 逻辑;
|
||||
* 不直接复用 TtsModal(它是页面配音弹窗,含文本输入/语速/情感等字段,叙事模式文本来自文案)。
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"
|
||||
import { Modal, Tabs, Spin, message } from "antd"
|
||||
import { CheckCircleFilled, SoundOutlined } from "@ant-design/icons"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { getVoiceClones } from "@/api/voice-clone"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { VOICE_GENDER_ICON } from "../constants"
|
||||
|
||||
interface TtsVoiceModalProps {
|
||||
open: boolean
|
||||
/** 需要合成的文本(来自选中的文案 content) */
|
||||
scriptText: string
|
||||
scriptTitle: string
|
||||
onCancel: () => void
|
||||
/** 合成成功回调:asset_id 为保存到配音库后的素材ID */
|
||||
onSynthesized: (payload: {
|
||||
voiceAssetId: string
|
||||
ttsVoiceId: string
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
}) => void
|
||||
}
|
||||
|
||||
type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error"
|
||||
|
||||
const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
|
||||
open,
|
||||
scriptText,
|
||||
scriptTitle,
|
||||
onCancel,
|
||||
onSynthesized,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset")
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
|
||||
const [status, setStatus] = useState<TtsSynthStatus>("idle")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
/* 系统音色 */
|
||||
const { data: presetData } = useQuery({
|
||||
queryKey: ["preset-voices", "modal"],
|
||||
queryFn: fetchPresetVoices,
|
||||
enabled: open,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(() => presetData?.items ?? [], [presetData])
|
||||
|
||||
/* 克隆音色(仅 ready 状态可用) */
|
||||
const { data: cloneListRaw = [] } = useQuery({
|
||||
queryKey: ["voice-clones", "ready"],
|
||||
queryFn: () => getVoiceClones({ status: "ready" }),
|
||||
enabled: open,
|
||||
})
|
||||
const cloneVoices: VoiceClone[] = useMemo(
|
||||
() => cloneListRaw.filter((v: VoiceClone) => v.status === "ready"),
|
||||
[cloneListRaw],
|
||||
)
|
||||
|
||||
/* 打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedVoiceId("")
|
||||
setStatus("idle")
|
||||
setError(null)
|
||||
setActiveTab("preset")
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setPreviewingId(null)
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handlePreview = useCallback(
|
||||
(voiceId: string, previewUrl: string | null | undefined) => {
|
||||
if (!previewUrl) {
|
||||
message.info("该音色暂无试听音频")
|
||||
return
|
||||
}
|
||||
if (previewingId === voiceId && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPreviewingId(null)
|
||||
return
|
||||
}
|
||||
if (audioRef.current) audioRef.current.pause()
|
||||
const a = new Audio(previewUrl)
|
||||
audioRef.current = a
|
||||
setPreviewingId(voiceId)
|
||||
a.onended = () => {
|
||||
setPreviewingId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
a.play().catch(() => {
|
||||
setPreviewingId(null)
|
||||
audioRef.current = null
|
||||
})
|
||||
},
|
||||
[previewingId],
|
||||
)
|
||||
|
||||
const textToSynth = useMemo(() => {
|
||||
// 文案内容取首段(过长会被 TTS 截断,保持和用户感知一致)
|
||||
const t = (scriptText || "").trim()
|
||||
return t.length > 500 ? t.slice(0, 500) : t
|
||||
}, [scriptText])
|
||||
|
||||
const handleSynthesize = useCallback(async () => {
|
||||
if (!selectedVoiceId) {
|
||||
message.warning("请先选择一个音色")
|
||||
return
|
||||
}
|
||||
if (!textToSynth) {
|
||||
message.warning("文案内容为空,无法合成")
|
||||
return
|
||||
}
|
||||
setStatus("synthesizing")
|
||||
setError(null)
|
||||
try {
|
||||
const isClone = activeTab === "clone"
|
||||
const payload: Record<string, unknown> = {
|
||||
text: textToSynth,
|
||||
speed: 1.0,
|
||||
language: "zh-CN",
|
||||
}
|
||||
if (isClone) {
|
||||
payload.voice_clone_profile_id = selectedVoiceId
|
||||
} else {
|
||||
payload.voice_id = selectedVoiceId
|
||||
}
|
||||
const resp = await synthesizeSpeech(
|
||||
payload as unknown as Parameters<typeof synthesizeSpeech>[0],
|
||||
)
|
||||
const jobId = resp.job_id
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
timerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await getTTSJobStatus(jobId)
|
||||
if (job.status === "completed") {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
resolve()
|
||||
} else if (job.status === "failed") {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
reject(new Error(job.error_message || "合成失败"))
|
||||
}
|
||||
} catch (e) {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
reject(e)
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
// 保存到配音库
|
||||
setStatus("saving")
|
||||
await saveTtsToLibrary(jobId, { name: scriptTitle?.slice(0, 30) || "AI合成配音" })
|
||||
setStatus("done")
|
||||
|
||||
// 合成成功后回调;voiceAssetId 由后端在保存时产出,这里用 ttsVoiceId 占位,
|
||||
// 父流程会在下一次 asset 列表刷新后重新选取;前端直接以 ttsVoiceId 为 key 传给后端
|
||||
// (叙事模式后端通过 script_id + tts_voice_id 自行再合成,不依赖 asset_id)。
|
||||
onSynthesized({
|
||||
voiceAssetId: jobId,
|
||||
ttsVoiceId: selectedVoiceId,
|
||||
ttsVoiceSource: isClone ? "clone" : "preset",
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
setStatus("error")
|
||||
const msg = err instanceof Error ? err.message : "合成失败,请稍后重试"
|
||||
setError(msg)
|
||||
}
|
||||
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized])
|
||||
|
||||
const renderVoiceCard = (v: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
gender?: string
|
||||
tags?: string[]
|
||||
preview_url?: string | null
|
||||
}) => {
|
||||
const isSel = selectedVoiceId === v.id
|
||||
const isPlaying = previewingId === v.id
|
||||
const emoji = v.gender ? (VOICE_GENDER_ICON[v.gender] ?? "🎤") : "🎤"
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVoiceId(v.id)}
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb",
|
||||
background: isSel ? "#faf5ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{isSel && (
|
||||
<CheckCircleFilled
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
color: "#7c3aed",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: isSel ? "linear-gradient(135deg,#7c3aed,#a78bfa)" : "#f3f4f6",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: isSel ? "#6d28d9" : "#111",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{v.name}
|
||||
</div>
|
||||
{v.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{v.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{v.preview_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreview(v.id, v.preview_url)
|
||||
}}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
background: isPlaying ? "#ef4444" : "#7c3aed",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<SoundOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{v.tags && v.tags.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{v.tags.slice(0, 3).map((tg) => (
|
||||
<span
|
||||
key={tg}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 8,
|
||||
background: "#f3f4f6",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{tg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 合成中 loading 覆盖层 */
|
||||
const renderSynthOverlay = () => {
|
||||
if (status !== "synthesizing" && status !== "saving") return null
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(255,255,255,0.92)",
|
||||
zIndex: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" style={{ color: "#7c3aed" }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: "#6d28d9" }}>
|
||||
{status === "synthesizing" ? "正在合成配音..." : "正在保存到配音库..."}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280" }}>请稍候,通常需要 10-30 秒</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="🎙️ 合成配音"
|
||||
open={open}
|
||||
onCancel={status === "synthesizing" || status === "saving" ? undefined : onCancel}
|
||||
cancelText="取消"
|
||||
okText="🎧 合成配音"
|
||||
okButtonProps={{
|
||||
disabled: !selectedVoiceId || status === "synthesizing" || status === "saving",
|
||||
style: { background: "#7c3aed" },
|
||||
}}
|
||||
onOk={handleSynthesize}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
confirmLoading={status === "synthesizing" || status === "saving"}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
color: "#b91c1c",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
marginBottom: 12,
|
||||
padding: "8px 12px",
|
||||
background: "#f9fafb",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
将根据文案《{scriptTitle?.slice(0, 30) || "所选文案"}》合成配音,文本长度:
|
||||
{textToSynth.length} 字
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(k) => {
|
||||
setActiveTab(k as "preset" | "clone")
|
||||
setSelectedVoiceId("")
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: "preset",
|
||||
label: "✨ 系统音色",
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10,
|
||||
maxHeight: 420,
|
||||
overflowY: "auto",
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{presetVoices.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: "1/-1",
|
||||
textAlign: "center",
|
||||
padding: 30,
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
正在加载系统音色...
|
||||
</div>
|
||||
) : (
|
||||
presetVoices.map((v) =>
|
||||
renderVoiceCard({
|
||||
id: v.voice_id,
|
||||
name: v.name,
|
||||
description: v.description,
|
||||
gender: v.gender,
|
||||
tags: v.tags,
|
||||
preview_url: v.preview_url,
|
||||
}),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "clone",
|
||||
label: "🎙️ 我的克隆音色",
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10,
|
||||
maxHeight: 420,
|
||||
overflowY: "auto",
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{cloneVoices.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: "1/-1",
|
||||
textAlign: "center",
|
||||
padding: 30,
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
暂无就绪的克隆音色,请先在配音库完成音色克隆
|
||||
</div>
|
||||
) : (
|
||||
cloneVoices.map((v) =>
|
||||
renderVoiceCard({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
description: v.description,
|
||||
gender: "neutral",
|
||||
tags: ["克隆"],
|
||||
preview_url: v.sample_url || null,
|
||||
}),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{renderSynthOverlay()}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TtsVoiceModal
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 随机混剪 — 配音选择弹窗(#1970)
|
||||
* 内容复用 Step5VoiceSelect 的配音库音频卡片(图标+文件名+时长/大小+▶试听),
|
||||
* 无 TTS / 克隆音色入口;确认后进入 Step2。
|
||||
*/
|
||||
import React from "react"
|
||||
import { Modal } from "antd"
|
||||
import { AudioOutlined } from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface VoiceSelectModalProps {
|
||||
open: boolean
|
||||
selectedVoice: string
|
||||
onCancel: () => void
|
||||
onConfirm: (voiceAssetId: string) => void
|
||||
}
|
||||
|
||||
const getDuration = (item: AssetItem): number =>
|
||||
item.duration ?? (item.metadata?.duration as number) ?? 0
|
||||
const getFileSize = (item: AssetItem): number =>
|
||||
item.file_size ?? (item.metadata?.file_size as number) ?? 0
|
||||
const isAiVoice = (item: AssetItem): boolean => {
|
||||
const d = getDuration(item)
|
||||
const s = getFileSize(item)
|
||||
return (!d || d <= 0) && (!s || s <= 0)
|
||||
}
|
||||
const fmtDur = (s?: number): string => {
|
||||
if (!s || s <= 0) return "时长未知"
|
||||
return `${s.toFixed(1)}秒`
|
||||
}
|
||||
const fmtSize = (b?: number): string => {
|
||||
if (!b || b <= 0) return "未知"
|
||||
if (b < 1024) return `${b} B`
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const VoiceSelectModal: React.FC<VoiceSelectModalProps> = ({
|
||||
open,
|
||||
selectedVoice,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [innerSelected, setInnerSelected] = React.useState(selectedVoice)
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setInnerSelected(selectedVoice)
|
||||
}, [open, selectedVoice])
|
||||
|
||||
const { data: materials = [], isLoading } = useQuery({
|
||||
queryKey: ["assets", "voice", "modal"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const togglePlay = useCallback(
|
||||
(item: AssetItem) => {
|
||||
if (playingId === item.id && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingId(null)
|
||||
return
|
||||
}
|
||||
if (audioRef.current) audioRef.current.pause()
|
||||
if (!item.file_url) return
|
||||
const audio = new Audio(item.file_url)
|
||||
audioRef.current = audio
|
||||
setPlayingId(item.id)
|
||||
audio.onended = () => {
|
||||
setPlayingId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
audio.play().catch(() => {
|
||||
setPlayingId(null)
|
||||
audioRef.current = null
|
||||
})
|
||||
},
|
||||
[playingId],
|
||||
)
|
||||
|
||||
const handleGoUpload = () => navigate("/app/voices?tab=material&upload=1")
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!innerSelected) return
|
||||
onConfirm(innerSelected)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="🎙️ 选择配音"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleConfirm}
|
||||
okText="确认选择"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ disabled: !innerSelected, style: { background: "#7c3aed" } }}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<p style={{ color: "#666", fontSize: 13, marginBottom: 12 }}>
|
||||
从配音库中选择已上传的音频素材,点击 ▶ 可试听
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>加载中...</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
|
||||
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 12 }} />
|
||||
<p style={{ marginBottom: 12 }}>暂无配音素材</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoUpload}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
background: "#7c3aed",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去配音库上传
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: 12,
|
||||
maxHeight: 460,
|
||||
overflowY: "auto",
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{materials.map((item) => {
|
||||
const isSel = innerSelected === item.id
|
||||
const isPlaying = playingId === item.id
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setInnerSelected(item.id)}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
border: isSel ? "2px solid #7c3aed" : "1px solid #e8e8e8",
|
||||
background: isSel ? "#ede9fe" : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
background: isSel
|
||||
? "linear-gradient(135deg,#7c3aed,#a78bfa)"
|
||||
: "linear-gradient(135deg,#f0f0f0,#e8e8e8)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AudioOutlined style={{ color: isSel ? "#fff" : "#666" }} />
|
||||
</div>
|
||||
{item.file_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePlay(item)
|
||||
}}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
background: isPlaying ? "#ef4444" : "#7c3aed",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: isSel ? "#6d28d9" : "#333",
|
||||
}}
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "#999",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{isAiVoice(item) ? (
|
||||
<span style={{ color: "#7c3aed", fontWeight: 500 }}>AI 音色</span>
|
||||
) : (
|
||||
<span>{fmtDur(getDuration(item))}</span>
|
||||
)}
|
||||
<span>{isAiVoice(item) ? "按文本合成" : fmtSize(getFileSize(item))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceSelectModal
|
||||
@@ -27,10 +27,10 @@ export const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
neutral: "✨",
|
||||
}
|
||||
|
||||
/* ── 步骤定义(5步,#1899 简化:删除选模板步骤) ── */
|
||||
/* ── 步骤定义(5步,#1970 流程重构:选择模式 → 素材 → 标题 → 确认 → 封面) ── */
|
||||
export const STEPS = [
|
||||
{ key: 1, label: "选择素材" },
|
||||
{ key: 2, label: "选择配音" },
|
||||
{ key: 1, label: "选择模式" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择标题" },
|
||||
{ key: 4, label: "确认生成" },
|
||||
{ key: 5, label: "选择封面" },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { UseGenerateVideoProps } from "./types"
|
||||
|
||||
/**
|
||||
* 生成前置校验
|
||||
* 生成前置校验(#1970 适配新流程)
|
||||
* - 随机混剪:需选配音(selectedVoice,配音库音频)
|
||||
* - 叙事剪辑:需选文案 + TTS 音色
|
||||
* 返回错误信息,通过则返回 null
|
||||
*/
|
||||
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
|
||||
@@ -12,19 +14,30 @@ export const validateGenerateInputs = (props: UseGenerateVideoProps): string | n
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
editMode = "random",
|
||||
selectedScript,
|
||||
ttsVoiceId,
|
||||
selectedVoice,
|
||||
} = props
|
||||
|
||||
// AI 自动选择模式下,标题可以为空(后端会自行生成)
|
||||
if (!titleSettings.aiAutoSelect && !titleSettings.title?.trim()) {
|
||||
return "请先选择或输入标题"
|
||||
}
|
||||
// 无论手动还是自动模式,都必须有素材
|
||||
const materialIds = materialMode === "auto" ? smartSelectedIds || [] : selectedMaterials || []
|
||||
if (materialIds.length === 0) {
|
||||
return materialMode === "auto" ? "AI 未匹配到素材,请手动选择素材后重试" : "请至少选择一个素材"
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
return "请先选择一个克隆音色"
|
||||
if (editMode === "narrative") {
|
||||
if (!selectedScript?.id) return "请先选择文案"
|
||||
if (!ttsVoiceId) return "请先合成配音"
|
||||
} else {
|
||||
// 随机混剪:配音库音频
|
||||
if (!selectedVoice && voiceMode !== "clone") {
|
||||
return "请先选择配音"
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
return "请先选择一个克隆音色"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -13,7 +13,19 @@ export interface UseGenerateVideoProps {
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
coverSettings: CoverConfig
|
||||
videoRatio: string
|
||||
videoRatio: "9:16" | "16:9" | string
|
||||
/** #1970 剪辑模式 */
|
||||
editMode?: "random" | "narrative"
|
||||
/** 叙事模式下选中的文案 */
|
||||
selectedScript?: { id: string; title?: string; content?: string } | null
|
||||
/** TTS 音色 ID(叙事模式) */
|
||||
ttsVoiceId?: string
|
||||
/** TTS 音色来源 */
|
||||
ttsVoiceSource?: "preset" | "clone"
|
||||
/** 合成后保存到配音库的 asset id / job id(叙事模式) */
|
||||
ttsVoiceAssetId?: string
|
||||
/** 智能降重开关(默认 true) */
|
||||
dedupEnabled?: boolean
|
||||
style: string
|
||||
duration: number
|
||||
autoSubtitles: boolean
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { ScriptItem } from "@/api/scripts"
|
||||
import { DEFAULT_COVER_SETTINGS, DEFAULT_CLIP_COUNT } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { usePlanConfigLoader } from "./usePlanConfigLoader"
|
||||
@@ -82,8 +83,28 @@ export interface GenerateFormState {
|
||||
cloneModalOpen: boolean
|
||||
setCloneModalOpen: (open: boolean) => void
|
||||
|
||||
/* ── 剪辑模式(#1970 流程重构)── */
|
||||
editMode: "random" | "narrative"
|
||||
setEditMode: (mode: "random" | "narrative") => void
|
||||
/** 叙事模式下选中的文案 */
|
||||
selectedScript: ScriptItem | null
|
||||
setSelectedScript: (s: ScriptItem | null) => void
|
||||
/** TTS 音色 ID */
|
||||
ttsVoiceId: string
|
||||
setTtsVoiceId: (id: string) => void
|
||||
/** TTS 音色来源:preset 系统 / clone 克隆 */
|
||||
ttsVoiceSource: "preset" | "clone"
|
||||
setTtsVoiceSource: (src: "preset" | "clone") => void
|
||||
/** 合成后配音库 asset id(叙事模式保存到库后获得;随机模式 = selectedVoice) */
|
||||
ttsVoiceAssetId: string
|
||||
setTtsVoiceAssetId: (id: string) => void
|
||||
/** 智能降重开关(默认 true) */
|
||||
dedupEnabled: boolean
|
||||
setDedupEnabled: (v: boolean) => void
|
||||
|
||||
/* 高级设置 */
|
||||
videoRatio: string
|
||||
videoRatio: "9:16" | "16:9" | string
|
||||
setVideoRatio: (r: "9:16" | "16:9") => void
|
||||
duration: number
|
||||
style: string
|
||||
autoSubtitles: boolean
|
||||
@@ -201,13 +222,21 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
/* ── 克隆声音弹窗 ── */
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 高级设置(隐藏但保留) ── */
|
||||
const [videoRatio] = useState("9:16")
|
||||
/* ── 高级设置 ── */
|
||||
const [videoRatio, setVideoRatio] = useState<"9:16" | "16:9">("9:16")
|
||||
const [duration] = useState(30)
|
||||
const [style] = useState("business")
|
||||
const [autoSubtitles] = useState(true)
|
||||
const [bgm] = useState(true)
|
||||
|
||||
/* ── 剪辑模式状态(#1970) ── */
|
||||
const [editMode, setEditMode] = useState<"random" | "narrative">("random")
|
||||
const [selectedScript, setSelectedScript] = useState<ScriptItem | null>(null)
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
|
||||
const [ttsVoiceSource, setTtsVoiceSource] = useState<"preset" | "clone">("preset")
|
||||
const [ttsVoiceAssetId, setTtsVoiceAssetId] = useState<string>("")
|
||||
const [dedupEnabled, setDedupEnabled] = useState<boolean>(true)
|
||||
|
||||
/* ── 预览任务 ID ── */
|
||||
const previewStorageKey = editPlanId
|
||||
? `preview_task_id_${editPlanId}`
|
||||
@@ -274,9 +303,22 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
editMode,
|
||||
setEditMode,
|
||||
selectedScript,
|
||||
setSelectedScript,
|
||||
ttsVoiceId,
|
||||
setTtsVoiceId,
|
||||
ttsVoiceSource,
|
||||
setTtsVoiceSource,
|
||||
ttsVoiceAssetId,
|
||||
setTtsVoiceAssetId,
|
||||
dedupEnabled,
|
||||
setDedupEnabled,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
videoRatio,
|
||||
setVideoRatio,
|
||||
duration,
|
||||
style,
|
||||
autoSubtitles,
|
||||
|
||||
@@ -123,6 +123,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const { width: outputWidth, height: outputHeight } = calculateResolution(
|
||||
props.videoRatio || "9:16",
|
||||
)
|
||||
const editMode = props.editMode ?? "random"
|
||||
const dedupEnabled = props.dedupEnabled !== false
|
||||
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
@@ -151,10 +153,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
// #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice
|
||||
const voiceLibraryId =
|
||||
props.voiceMode === "clone"
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
editMode === "narrative"
|
||||
? props.ttsVoiceId || ""
|
||||
: props.voiceMode === "clone"
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
|
||||
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
|
||||
const indexes =
|
||||
@@ -197,6 +202,15 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
assembly_mode: editMode,
|
||||
...(editMode === "narrative" && props.selectedScript?.id
|
||||
? {
|
||||
script_id: props.selectedScript.id,
|
||||
tts_voice_id: props.ttsVoiceId || undefined,
|
||||
tts_voice_source: props.ttsVoiceSource || undefined,
|
||||
}
|
||||
: {}),
|
||||
dedup_enabled: dedupEnabled,
|
||||
voice_library_id: voiceLibraryId,
|
||||
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
|
||||
bgm_config: {
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航(#1899 简化为 5 步,单视频与批量一致)
|
||||
* 步骤:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5)
|
||||
* GeneratePage 步骤导航(#1970 流程重构)
|
||||
* 步骤:选择模式(1) → 选择素材(2) → 选择标题(3) → 确认生成(4) → 选择封面(5)
|
||||
*
|
||||
* - 步骤3底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate),
|
||||
* 创建成功后跳转步骤4;本 hook 的 goNext 只负责 1→2→3 和 4→5 的「下一步」。
|
||||
* - 步骤4(确认生成进度页):渲染全部完成(generated)后「下一步」解锁进封面。
|
||||
* - 步骤1(选择模式):下一步分支由外层弹窗处理(VoiceSelectModal / ScriptSelectModal),
|
||||
* 本 hook 的 goNext 仅在未选模式时拦截;外层 Modal onConfirm 里主动 setCurrentStep(2)。
|
||||
* - 步骤2(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3。
|
||||
* - 步骤3 底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate),
|
||||
* 创建成功后跳步骤4;本 hook 的 goNext 只负责 2→3 和 4→5 的「下一步」。
|
||||
* - 步骤4(确认生成进度页):全部渲染完成后「下一步」解锁进封面。
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { EditMode } from "../components/Step1EditMode"
|
||||
|
||||
export interface UseStepNavigationOptions {
|
||||
currentStep: number
|
||||
setCurrentStep: (step: number | ((prev: number) => number)) => void
|
||||
editMode: EditMode
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
@@ -20,6 +25,8 @@ export interface UseStepNavigationOptions {
|
||||
generated: boolean
|
||||
/** 点素材下一步时弹出数量选择弹窗 */
|
||||
onOpenCountModal: () => void
|
||||
/** 步骤1下一步:根据 editMode 打开对应弹窗(随机→配音 / 叙事→文案) */
|
||||
onOpenStep1Modal: () => void
|
||||
}
|
||||
|
||||
export interface UseStepNavigationReturn {
|
||||
@@ -36,22 +43,29 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
smartSelectedIds,
|
||||
generated,
|
||||
onOpenCountModal,
|
||||
onOpenStep1Modal,
|
||||
} = options
|
||||
|
||||
const goNext = () => {
|
||||
if (currentStep === 1) {
|
||||
// 选完素材弹数量选择弹窗
|
||||
// 步骤1:先校验素材/配音等由弹窗负责,goNext 只负责触发弹窗
|
||||
onOpenStep1Modal()
|
||||
return
|
||||
}
|
||||
if (currentStep === 2) {
|
||||
// 素材校验
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (materialMode === "auto" && smartSelectedIds.length === 0) {
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
// 弹数量选择弹窗
|
||||
onOpenCountModal()
|
||||
return
|
||||
}
|
||||
if (currentStep === 1 && materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 1 && materialMode === "auto" && smartSelectedIds.length === 0) {
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
// 步骤4(确认生成):全部渲染完成后才能下一步进封面
|
||||
if (currentStep === 4) {
|
||||
if (!generated) {
|
||||
|
||||
@@ -39,6 +39,7 @@ import { getDiscountPriceCents } from "@/api/points/types"
|
||||
import type { SubscriptionPlan } from "@/api/subscription/types"
|
||||
import { PLAN_LABEL, BILLING_CYCLE_LABEL } from "@/api/subscription/types"
|
||||
import "./Plans.css"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
@@ -249,17 +250,23 @@ const Plans: React.FC = () => {
|
||||
return (
|
||||
<div className="xx-plans-page">
|
||||
<PageHead
|
||||
title="会员与积分"
|
||||
description="开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
|
||||
title={ENABLE_CREDIT_SYSTEM ? "会员与积分" : "会员订阅"}
|
||||
description={
|
||||
ENABLE_CREDIT_SYSTEM
|
||||
? "开通会员解锁全部功能,按需充值积分灵活使用 AI 能力"
|
||||
: "开通会员解锁全部功能"
|
||||
}
|
||||
actions={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => navigate("/app/points/transactions")}
|
||||
>
|
||||
积分明细
|
||||
</Button>
|
||||
</Space>
|
||||
ENABLE_CREDIT_SYSTEM ? (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => navigate("/app/points/transactions")}
|
||||
>
|
||||
积分明细
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -296,13 +303,15 @@ const Plans: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">可用积分</Text>
|
||||
<div className="xx-current-balance">
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
|
||||
<span className="xx-current-balance-val">{bal}</span>
|
||||
{ENABLE_CREDIT_SYSTEM && (
|
||||
<div>
|
||||
<Text type="secondary">可用积分</Text>
|
||||
<div className="xx-current-balance">
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6" }} />
|
||||
<span className="xx-current-balance-val">{bal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isMember && freeLimit > 0 && (
|
||||
<div>
|
||||
<Text type="secondary">今日免费混剪</Text>
|
||||
@@ -319,18 +328,20 @@ const Plans: React.FC = () => {
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => {
|
||||
const el = document.getElementById("points-packages")
|
||||
el?.scrollIntoView({ behavior: "smooth" })
|
||||
}}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
</Col>
|
||||
{ENABLE_CREDIT_SYSTEM && (
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => {
|
||||
const el = document.getElementById("points-packages")
|
||||
el?.scrollIntoView({ behavior: "smooth" })
|
||||
}}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -461,69 +472,71 @@ const Plans: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 积分充值 */}
|
||||
<div id="points-packages">
|
||||
<Title level={4} style={{ marginTop: 40 }}>
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
|
||||
积分充值
|
||||
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
|
||||
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
|
||||
(永久有效)
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Title>
|
||||
{/* 积分充值(积分系统关闭时隐藏,代码保留不删除) */}
|
||||
{ENABLE_CREDIT_SYSTEM && (
|
||||
<div id="points-packages">
|
||||
<Title level={4} style={{ marginTop: 40 }}>
|
||||
<ThunderboltOutlined style={{ color: "#8b5cf6", marginRight: 8 }} />
|
||||
积分充值
|
||||
<Tooltip title="积分永久有效,可用于所有 AI 功能;付费会员享折扣">
|
||||
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8, fontWeight: "normal" }}>
|
||||
(永久有效)
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Title>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{packages.map((pkg) => {
|
||||
const priceCents = getDiscountPriceCents(pkg, userDiscount)
|
||||
const originalCents = pkg.price_cents
|
||||
const discount =
|
||||
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
|
||||
const unit = priceCents / 100 / pkg.points
|
||||
const isHot = pkg.unit_price < 0.1
|
||||
return (
|
||||
<Col xs={24} sm={8} key={pkg.code}>
|
||||
<Card
|
||||
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
|
||||
hoverable
|
||||
>
|
||||
{isHot && <div className="xx-pkg-badge">热门</div>}
|
||||
{discount > 0 && (
|
||||
<Tag color="gold" className="xx-pkg-discount">
|
||||
{Math.round((priceCents / originalCents) * 10) / 1}折
|
||||
</Tag>
|
||||
)}
|
||||
<div className="xx-pkg-name">{pkg.name}</div>
|
||||
<div className="xx-pkg-points">
|
||||
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="xx-pkg-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">
|
||||
{(priceCents / 100)
|
||||
.toFixed(priceCents % 100 === 0 ? 0 : 1)
|
||||
.replace(/\.0$/, "")}
|
||||
</span>
|
||||
{discount > 0 && (
|
||||
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分</div>
|
||||
<Button
|
||||
block
|
||||
type={isHot ? "primary" : "default"}
|
||||
loading={buying === pkg.code}
|
||||
onClick={() => handleBuyPoints(pkg)}
|
||||
style={{ marginTop: 12 }}
|
||||
<Row gutter={[16, 16]}>
|
||||
{packages.map((pkg) => {
|
||||
const priceCents = getDiscountPriceCents(pkg, userDiscount)
|
||||
const originalCents = pkg.price_cents
|
||||
const discount =
|
||||
priceCents < originalCents ? Math.round((1 - priceCents / originalCents) * 100) : 0
|
||||
const unit = priceCents / 100 / pkg.points
|
||||
const isHot = pkg.unit_price < 0.1
|
||||
return (
|
||||
<Col xs={24} sm={8} key={pkg.code}>
|
||||
<Card
|
||||
className={`xx-pkg-card ${discount > 0 ? "has-discount" : ""} ${isHot ? "recommended" : ""}`}
|
||||
hoverable
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
{isHot && <div className="xx-pkg-badge">热门</div>}
|
||||
{discount > 0 && (
|
||||
<Tag color="gold" className="xx-pkg-discount">
|
||||
{Math.round((priceCents / originalCents) * 10) / 1}折
|
||||
</Tag>
|
||||
)}
|
||||
<div className="xx-pkg-name">{pkg.name}</div>
|
||||
<div className="xx-pkg-points">
|
||||
<ThunderboltOutlined /> {pkg.points.toLocaleString()} 积分
|
||||
</div>
|
||||
<div className="xx-pkg-price">
|
||||
<span className="currency">¥</span>
|
||||
<span className="amount">
|
||||
{(priceCents / 100)
|
||||
.toFixed(priceCents % 100 === 0 ? 0 : 1)
|
||||
.replace(/\.0$/, "")}
|
||||
</span>
|
||||
{discount > 0 && (
|
||||
<span className="xx-pkg-origin">¥{(originalCents / 100).toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分</div>
|
||||
<Button
|
||||
block
|
||||
type={isHot ? "primary" : "default"}
|
||||
loading={buying === pkg.code}
|
||||
onClick={() => handleBuyPoints(pkg)}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - subscription: GET /subscription/current(plan_id + billing_cycle)
|
||||
*/
|
||||
import { create } from "zustand"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
import { getPointsBalance, getPointsRules, getDailyUsage, getMembership } from "@/api/points"
|
||||
import { getCurrentSubscription } from "@/api/subscription"
|
||||
import type {
|
||||
@@ -49,14 +50,29 @@ export const usePointsStore = create<PointsState>((set, get) => ({
|
||||
|
||||
init: async () => {
|
||||
// 已加载过不重复拉取
|
||||
if (get().balance && get().rules && get().subscription) return
|
||||
// 积分系统关闭时:只要 subscription/membership 已有值就跳过;开启时需 balance+rules+subscription 齐了才跳过
|
||||
if (ENABLE_CREDIT_SYSTEM) {
|
||||
if (get().balance && get().rules && get().subscription) return
|
||||
} else {
|
||||
if (get().subscription && get().membership) return
|
||||
}
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
// 积分系统关闭时不拉取余额/规则/每日额度,但仍拉会员/订阅用于 VIP 标识展示
|
||||
const balancePromise = ENABLE_CREDIT_SYSTEM
|
||||
? getPointsBalance().catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
const rulesPromise = ENABLE_CREDIT_SYSTEM
|
||||
? getPointsRules().catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
const dailyUsagePromise = ENABLE_CREDIT_SYSTEM
|
||||
? getDailyUsage().catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
const [balance, rules, subscription, dailyUsage, membership] = await Promise.all([
|
||||
getPointsBalance().catch(() => null),
|
||||
getPointsRules().catch(() => null),
|
||||
balancePromise,
|
||||
rulesPromise,
|
||||
getCurrentSubscription().catch(() => null),
|
||||
getDailyUsage().catch(() => null),
|
||||
dailyUsagePromise,
|
||||
getMembership().catch(() => null),
|
||||
])
|
||||
set({
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""智能降重微变换纯逻辑模块 — #1970 PR2.
|
||||
|
||||
所有函数均为纯函数:不调用 FFmpeg、不读写文件,只负责按可复现种子
|
||||
生成每个片段 / 整片的微变换参数与 filter_complex 片段。
|
||||
|
||||
6 个维度:
|
||||
1. hflip 水平翻转(每片段 50%,有字幕/文字的片段不翻转)
|
||||
2. 播放速度 0.97~1.03x(视频 setpts + 音频 atempo)
|
||||
3. 亮度 ±2%(eq=brightness)
|
||||
4. 对比度 ±2%(eq=contrast)
|
||||
5. 饱和度 ±2%(eq=saturation)
|
||||
6. BGM 起始偏移 2~8 秒(音频 atrim 起点)
|
||||
|
||||
随机种子 = hash(task_id + video_index) % 10000,保证同一任务同一视频
|
||||
可复现;dedup_enabled=False 时不生成本模块任何输出。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ── 常量(与需求文档 §2 对齐)──────────────────────────────────────────────────
|
||||
|
||||
SPEED_MIN = 0.97
|
||||
SPEED_MAX = 1.03
|
||||
COLOR_DELTA = 0.02
|
||||
HFLIP_PROBABILITY = 0.5
|
||||
BGM_OFFSET_MIN = 2.0
|
||||
BGM_OFFSET_MAX = 8.0
|
||||
SEED_MODULO = 10000
|
||||
|
||||
|
||||
def make_video_seed(task_id: str, video_index: int) -> int:
|
||||
"""生成视频级可复现种子:hash(task_id+video_index) % 10000。
|
||||
|
||||
用 sha256 而非内置 hash():内置 hash 对字符串带进程级随机盐(PYTHONHASHSEED),
|
||||
跨进程不可复现。结果映射到 0~9999。
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
raw = f"{task_id or ''}:{int(video_index)}"
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
return int(digest[:8], 16) % SEED_MODULO
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ClipMicroTransform:
|
||||
"""单个片段的微变换参数。"""
|
||||
|
||||
clip_index: int
|
||||
hflip: bool = False
|
||||
speed: float = 1.0
|
||||
brightness: float = 0.0
|
||||
contrast: float = 1.0
|
||||
saturation: float = 1.0
|
||||
has_text: bool = False
|
||||
|
||||
def video_filter_suffix(self) -> str:
|
||||
"""返回追加在片段视频处理链上的 filter 后缀(无末尾标签)。
|
||||
|
||||
顺序:trim/setpts(已有)→ 调速 setpts → hflip → eq → format。
|
||||
调速的 setpts 必须位于 trim 之后;hflip/eq 在缩放之后即可,
|
||||
concat_engine 按「调速 → hflip → eq」顺序拼接到 scale/fps 之前的
|
||||
trim 之后、scale 之后均可,这里只产出独立步骤、由引擎决定插入点。
|
||||
"""
|
||||
parts: list[str] = []
|
||||
# 速度:setpts=PTS/speed(speed>1 时画面加速,时间戳变小)
|
||||
if abs(self.speed - 1.0) > 1e-4:
|
||||
parts.append(f"setpts=PTS/{self.speed:.5f}")
|
||||
# 水平翻转:有文字/字幕片段不翻转
|
||||
if self.hflip and not self.has_text:
|
||||
parts.append("hflip")
|
||||
# 色彩微调:brightness 取值 -1~1(±0.02),contrast/saturation 围绕 1.0
|
||||
if abs(self.brightness) > 1e-4 or abs(self.contrast - 1.0) > 1e-4 or abs(self.saturation - 1.0) > 1e-4:
|
||||
parts.append(
|
||||
f"eq=brightness={self.brightness:+.4f}:"
|
||||
f"contrast={self.contrast:.4f}:saturation={self.saturation:.4f}"
|
||||
)
|
||||
return ",".join(parts)
|
||||
|
||||
def audio_filter_suffix(self) -> str:
|
||||
"""返回片段音频链上的调速 filter(atempo),无调速时返回空串。"""
|
||||
if abs(self.speed - 1.0) <= 1e-4:
|
||||
return ""
|
||||
return f"atempo={self.speed:.5f}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VideoMicroTransformPlan:
|
||||
"""一个成片视频的全部微变换参数。"""
|
||||
|
||||
task_id: str
|
||||
video_index: int
|
||||
seed: int
|
||||
clips: list[ClipMicroTransform] = field(default_factory=list)
|
||||
bgm_start_offset: float = 0.0
|
||||
|
||||
def clip(self, index: int) -> ClipMicroTransform | None:
|
||||
for c in self.clips:
|
||||
if c.clip_index == index:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _draw_speed(rng: random.Random) -> float:
|
||||
return round(rng.uniform(SPEED_MIN, SPEED_MAX), 5)
|
||||
|
||||
|
||||
def _draw_signed_delta(rng: random.Random) -> float:
|
||||
return round(rng.uniform(-COLOR_DELTA, COLOR_DELTA), 4)
|
||||
|
||||
|
||||
def build_micro_transform_plan(
|
||||
task_id: str,
|
||||
video_index: int,
|
||||
clip_count: int,
|
||||
*,
|
||||
clip_has_text: list[bool] | None = None,
|
||||
enable_bgm_offset: bool = True,
|
||||
) -> VideoMicroTransformPlan:
|
||||
"""按可复现种子生成整片的微变换计划。
|
||||
|
||||
Args:
|
||||
task_id: 生成任务 ID(种子输入)
|
||||
video_index: 视频在批次中的序号(0 起)
|
||||
clip_count: 片段数量
|
||||
clip_has_text: 每个片段是否有字幕/文字轨道(True 的片段不翻转);
|
||||
None 时按 P1 约定视为无可靠文字检测——保守起见 hflip 一律关闭
|
||||
enable_bgm_offset: 是否生成 BGM 起始偏移(无 BGM 时调用方可忽略该值)
|
||||
|
||||
Returns:
|
||||
VideoMicroTransformPlan
|
||||
"""
|
||||
seed = make_video_seed(task_id, video_index)
|
||||
rng = random.Random(seed)
|
||||
|
||||
# P1 字幕检测约定:无法判断片段是否有文字时,一律不翻转(宁可少一个维度也不误翻字幕)
|
||||
safe_has_text = clip_has_text if clip_has_text is not None else [True] * max(clip_count, 0)
|
||||
|
||||
clips: list[ClipMicroTransform] = []
|
||||
for i in range(max(clip_count, 0)):
|
||||
has_text = bool(safe_has_text[i]) if i < len(safe_has_text) else True
|
||||
do_hflip = (not has_text) and rng.random() < HFLIP_PROBABILITY
|
||||
clips.append(
|
||||
ClipMicroTransform(
|
||||
clip_index=i,
|
||||
hflip=do_hflip,
|
||||
speed=_draw_speed(rng),
|
||||
brightness=_draw_signed_delta(rng),
|
||||
contrast=round(1.0 + _draw_signed_delta(rng), 4),
|
||||
saturation=round(1.0 + _draw_signed_delta(rng), 4),
|
||||
has_text=has_text,
|
||||
)
|
||||
)
|
||||
|
||||
bgm_offset = rng.uniform(BGM_OFFSET_MIN, BGM_OFFSET_MAX) if enable_bgm_offset else 0.0
|
||||
return VideoMicroTransformPlan(
|
||||
task_id=task_id,
|
||||
video_index=video_index,
|
||||
seed=seed,
|
||||
clips=clips,
|
||||
bgm_start_offset=round(bgm_offset, 3),
|
||||
)
|
||||
|
||||
|
||||
def build_bgm_offset_trim(start_offset: float, bgm_duration: float) -> str:
|
||||
"""生成 BGM 起始偏移的 atrim 片段。
|
||||
|
||||
偏移超出 BGM 长度时回退为 0(从头播放),避免空输入。
|
||||
返回的字符串形如 "atrim=start=3.200,",可拼到 BGM filter chain 最前面;
|
||||
无需偏移时返回空串。
|
||||
"""
|
||||
if start_offset <= 0 or bgm_duration <= 0 or start_offset >= bgm_duration - 0.5:
|
||||
return ""
|
||||
return f"atrim=start={start_offset:.3f},"
|
||||
@@ -493,6 +493,41 @@ class RenderAdapter:
|
||||
logger.warning("ASR 服务初始化失败,自动字幕将不可用: %s", e)
|
||||
return None
|
||||
|
||||
def _resolve_clip_has_text(self, clips: list[Any]) -> list[bool] | None:
|
||||
"""#1970:按源视频片段顺序解析 atom_clip.ai_tags.has_text。
|
||||
|
||||
顺序与 UnifiedRenderService 的「非 audio 源片段」口径一致。
|
||||
仅当 atom_clip 存在 ai_tags 字典且 has_text 显式为 False 时标记为
|
||||
无文字(允许 hflip);atom_clip_id 缺失、ai_tags 未生成、has_text 为
|
||||
true/null/非布尔值时一律按有文字处理(保守不翻转)。
|
||||
查询失败时返回 None,渲染层回退到全保守路径。
|
||||
"""
|
||||
video_clips = [c for c in clips if getattr(c, "clip_type", "main") != "audio"]
|
||||
atom_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for c in video_clips:
|
||||
atom_id = getattr(c, "atom_clip_id", "") or ""
|
||||
if atom_id and atom_id not in seen:
|
||||
seen.add(atom_id)
|
||||
atom_ids.append(atom_id)
|
||||
if not atom_ids:
|
||||
return None
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
|
||||
atom_clips = SQLAlchemyAssetAtomClipRepository(self._db).find_by_ids(atom_ids)
|
||||
except Exception as exc:
|
||||
logger.warning("[render-adapter] atom_clip ai_tags 查询失败,hflip 全量保守处理: %s", exc)
|
||||
return None
|
||||
has_text_map: dict[str, bool] = {}
|
||||
for ac in atom_clips:
|
||||
ai_tags = getattr(ac, "ai_tags", None)
|
||||
no_text = isinstance(ai_tags, dict) and ai_tags.get("has_text") is False
|
||||
has_text_map[ac.id] = not no_text
|
||||
return [has_text_map.get((getattr(c, "atom_clip_id", "") or ""), True) for c in video_clips]
|
||||
|
||||
def _do_render(
|
||||
self,
|
||||
plan: Any,
|
||||
@@ -542,6 +577,7 @@ class RenderAdapter:
|
||||
)
|
||||
|
||||
# 4. 执行统一渲染
|
||||
clip_has_text = self._resolve_clip_has_text(clips)
|
||||
render_svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
@@ -552,6 +588,7 @@ class RenderAdapter:
|
||||
bgm_path=bgm_path,
|
||||
asr_service=asr_service,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
clip_has_text=clip_has_text,
|
||||
)
|
||||
result = render_svc.render()
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ def mix_audio(
|
||||
bgm_path: str | None = None,
|
||||
bgm_config: dict | None = None,
|
||||
audio_tracks_config: dict | None = None,
|
||||
bgm_start_offset: float = 0.0,
|
||||
) -> Path | None:
|
||||
"""音频后处理混音.
|
||||
|
||||
@@ -157,7 +158,10 @@ def mix_audio(
|
||||
if bgm_path and bgm_config and isinstance(bgm_config, dict) and bgm_config.get("enabled", False):
|
||||
from video_processing.bgm_mixer import BGMConfig, build_bgm_only
|
||||
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
|
||||
_bgm_cfg_dict = dict(bgm_config or {})
|
||||
if bgm_start_offset and not _bgm_cfg_dict.get("audio_offset"):
|
||||
_bgm_cfg_dict["audio_offset"] = round(float(bgm_start_offset), 3)
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, _bgm_cfg_dict)
|
||||
try:
|
||||
return build_bgm_only(ctx, bgm_cfg, video_duration)
|
||||
except Exception:
|
||||
@@ -187,7 +191,10 @@ def mix_audio(
|
||||
if bgm_path and bgm_config and isinstance(bgm_config, dict) and bgm_config.get("enabled", False):
|
||||
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
|
||||
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
|
||||
_bgm_cfg_dict = dict(bgm_config or {})
|
||||
if bgm_start_offset and not _bgm_cfg_dict.get("audio_offset"):
|
||||
_bgm_cfg_dict["audio_offset"] = round(float(bgm_start_offset), 3)
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, _bgm_cfg_dict)
|
||||
|
||||
try:
|
||||
# 这里 main_audio 就是 output_path,先有主音频再混 BGM
|
||||
|
||||
@@ -155,6 +155,7 @@ class UnifiedRenderService:
|
||||
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
|
||||
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)
|
||||
):
|
||||
self.plan = plan
|
||||
self.clips = clips
|
||||
@@ -167,10 +168,98 @@ class UnifiedRenderService:
|
||||
self.asr_service = asr_service
|
||||
self.bgm_path = bgm_path
|
||||
self.voiceover_audio_path = voiceover_audio_path
|
||||
# #1970:片段级文字检测(顺序与非 audio 的源视频片段一致);None 表示无可靠检测,保守不翻转
|
||||
self._clip_has_text = clip_has_text
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
self._speed_engine = SpeedEngine()
|
||||
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
|
||||
self._asr_timeline_cached = False
|
||||
# #1970 PR2:片段级微变换计划缓存(懒构建,dedup_enabled=False 时为 None)
|
||||
self._micro_plan_cache: Any = None
|
||||
self._micro_plan_loaded = False
|
||||
|
||||
# ── #1970 PR2 智能降重:片段级微变换 ───────────────────────────────────
|
||||
def _dedup_enabled(self) -> bool:
|
||||
"""读取 plan.config.dedup_enabled,缺省视为 True(向后兼容)。"""
|
||||
cfg = self.plan.config or {}
|
||||
return bool(cfg.get("dedup_enabled", True))
|
||||
|
||||
def _get_micro_transform_plan(self, clip_count: int) -> Any:
|
||||
"""按 task_id+视频序号构建可复现的片段级微变换计划。
|
||||
|
||||
种子 hash(generation_task_id + video_index)%10000,同一任务重渲结果一致。
|
||||
dedup_enabled=False 时返回 None,调用方不注入任何微变换。
|
||||
hflip 放开(#1970):clip_has_text 来自 atom_clip.ai_tags.has_text,
|
||||
仅 AI 明确判定无文字的片段可参与 50% 翻转;未打标签 / has_text 为
|
||||
true/null 或缺位时一律视为有文字,保持保守不翻转。
|
||||
"""
|
||||
if self._micro_plan_loaded:
|
||||
return self._micro_plan_cache
|
||||
self._micro_plan_loaded = True
|
||||
if not self._dedup_enabled() or clip_count <= 0:
|
||||
self._micro_plan_cache = None
|
||||
return None
|
||||
try:
|
||||
from video_processing.micro_transform_pure import build_micro_transform_plan
|
||||
|
||||
cfg = self.plan.config or {}
|
||||
task_id = str(cfg.get("generation_task_id", "") or "")
|
||||
video_index = int(cfg.get("video_index", 0) or 0)
|
||||
# self._clip_has_text 顺序与非 audio 源片段一致;
|
||||
# None(未提供检测,如内存直渲/旧任务)→ 纯函数层按全有文字保守处理;
|
||||
# 列表短于片段数时缺位片段同样按有文字处理
|
||||
self._micro_plan_cache = build_micro_transform_plan(
|
||||
task_id,
|
||||
video_index,
|
||||
clip_count,
|
||||
clip_has_text=self._clip_has_text,
|
||||
enable_bgm_offset=bool(cfg.get("bgm")),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[unified-render] 微变换计划构建失败,本次不注入: %s", e)
|
||||
self._micro_plan_cache = None
|
||||
return self._micro_plan_cache
|
||||
|
||||
@staticmethod
|
||||
def _apply_micro_transform_video(filters: list[str], mt: Any) -> None:
|
||||
"""把片段视频微变换就地追加到 filter 链(post-scale 阶段调用)。
|
||||
|
||||
顺序:hflip 在 pre-scale 阶段由 _apply_micro_hflip 处理,这里只加
|
||||
eq 亮度/对比度/饱和度。速度 setpts 与既有 clip speed 相乘(见调用点),
|
||||
避免出现两条 setpts 互相覆盖。
|
||||
"""
|
||||
if mt is None:
|
||||
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}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_micro_hflip(filters: list[str], mt: Any) -> None:
|
||||
"""片段级水平翻转(pre-scale 阶段)。P1 有文字/无法判定时 mt.hflip=False。"""
|
||||
if mt is not None and mt.hflip and not mt.has_text:
|
||||
filters.append("hflip")
|
||||
|
||||
@staticmethod
|
||||
def _micro_speed_factor(mt: Any) -> float:
|
||||
"""片段微变换速度因子(0.97~1.03),无计划返回 1.0。"""
|
||||
if mt is None:
|
||||
return 1.0
|
||||
return float(getattr(mt, "speed", 1.0) or 1.0)
|
||||
|
||||
def _get_micro_bgm_offset(self) -> float:
|
||||
"""#1970 PR2:读取本视频 BGM 起始偏移(秒),无 BGM/禁用时为 0。"""
|
||||
if not self.plan.config:
|
||||
return 0.0
|
||||
try:
|
||||
count = len([c for c in (self.plan.clips or []) if getattr(c, "clip_type", "main") != "audio"])
|
||||
plan = self._get_micro_transform_plan(count)
|
||||
if plan:
|
||||
return round(float(plan.bgm_start_offset or 0.0), 3)
|
||||
except Exception:
|
||||
logger.debug("微变换 BGM 偏移读取失败,按 0 处理: plan_id=%s", getattr(self.plan, "id", "?"))
|
||||
return 0.0
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
"""执行渲染,返回 RenderResult.
|
||||
@@ -316,6 +405,9 @@ class UnifiedRenderService:
|
||||
ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id)
|
||||
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
|
||||
|
||||
_bgm_off = self._get_micro_bgm_offset()
|
||||
if _bgm_off and not (bgm_config or {}).get("audio_offset"):
|
||||
bgm_config = {**bgm_config, "audio_offset": _bgm_off}
|
||||
bgm_cfg = BGMConfig.from_config_dict(self.bgm_path, bgm_config)
|
||||
# 从直通输出中提取音频
|
||||
main_audio_path = self.work_dir / f"pass_through_audio_{self.plan.id}.aac"
|
||||
@@ -365,6 +457,7 @@ class UnifiedRenderService:
|
||||
bgm_path=self.bgm_path,
|
||||
bgm_config=bgm_config,
|
||||
audio_tracks_config=audio_tracks_config,
|
||||
bgm_start_offset=self._get_micro_bgm_offset(),
|
||||
)
|
||||
t_audio_end = time.time()
|
||||
audio_mix_ms = int((t_audio_end - t_audio_start) * 1000)
|
||||
@@ -1112,6 +1205,28 @@ class UnifiedRenderService:
|
||||
if ass_path is not None:
|
||||
return False, "有字幕叠加"
|
||||
|
||||
# #1970 PR2:片段级微变换(变速/hflip/亮度/对比度/饱和度)需要重编码
|
||||
try:
|
||||
_video_sources = [c for c in (self.clips or []) if getattr(c, "clip_type", "main") != "audio"]
|
||||
_ordinal = -1
|
||||
for _i, _c in enumerate(_video_sources):
|
||||
if getattr(_c, "id", None) == getattr(clip, "clip_id", None):
|
||||
_ordinal = _i
|
||||
break
|
||||
_mt_plan = self._get_micro_transform_plan(len(_video_sources))
|
||||
if _mt_plan and 0 <= _ordinal < len(_mt_plan.clips):
|
||||
_mt = _mt_plan.clips[_ordinal]
|
||||
if (
|
||||
abs(UnifiedRenderService._micro_speed_factor(_mt) - 1.0) >= 1e-6
|
||||
or (_mt.hflip and not _mt.has_text)
|
||||
or abs(_mt.brightness) > 1e-4
|
||||
or abs(_mt.contrast - 1.0) > 1e-4
|
||||
or abs(_mt.saturation - 1.0) > 1e-4
|
||||
):
|
||||
return False, "启用了片段级微变换"
|
||||
except Exception:
|
||||
logger.debug("stream copy 微变换门控检查异常,按可 copy 处理", exc_info=True)
|
||||
|
||||
# 有调速 → 需要重编码 → 不能 copy
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
@@ -1318,11 +1433,16 @@ class UnifiedRenderService:
|
||||
|
||||
# 视觉扰动(plan 级别,直通模式同样适用)
|
||||
vp = self._get_visual_perturbation()
|
||||
# #1970 PR2:单片段直通;计划按源视频片段数构建,序号取 config._micro_index
|
||||
_src_video_count = len([c for c in (self.clips or []) if getattr(c, "clip_type", "main") != "audio"])
|
||||
mt_plan = self._get_micro_transform_plan(max(1, _src_video_count))
|
||||
_mi = int(clip.config.get("_micro_index", 0)) if isinstance(clip.config, dict) else 0
|
||||
mt = mt_plan.clips[_mi] if mt_plan and 0 <= _mi < len(mt_plan.clips) else None
|
||||
|
||||
# 调速 — 与 filter_complex 路径一致(叠加视觉扰动 speed_factor)
|
||||
# 调速 — 与 filter_complex 路径一致(叠加视觉扰动 speed_factor 与 #1970 微变换速度)
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
|
||||
effective_speed = speed * vp_speed
|
||||
effective_speed = speed * vp_speed # 微变换速度已烘焙进 playback_speed
|
||||
if abs(effective_speed - 1.0) >= 1e-6:
|
||||
filters.append(f"setpts=PTS/{effective_speed:.4f}")
|
||||
|
||||
@@ -1336,6 +1456,8 @@ class UnifiedRenderService:
|
||||
# 视觉扰动:hflip(在 scale 之前)
|
||||
if vp:
|
||||
self._apply_visual_perturbation_pre_scale(filters, vp)
|
||||
# #1970 PR2:片段级 hflip(P1 保守:有文字/无法判定时不翻转)
|
||||
UnifiedRenderService._apply_micro_hflip(filters, mt)
|
||||
|
||||
# scale + pad(等比缩放+留黑边)
|
||||
if role in ("overlay", "corner_voice"):
|
||||
@@ -1354,6 +1476,8 @@ class UnifiedRenderService:
|
||||
# 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前)
|
||||
if vp:
|
||||
self._apply_visual_perturbation_post_scale(filters, vp)
|
||||
# #1970 PR2:片段级亮度/对比度/饱和度微调
|
||||
UnifiedRenderService._apply_micro_transform_video(filters, mt)
|
||||
|
||||
# 调色滤镜
|
||||
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
|
||||
@@ -1450,7 +1574,8 @@ class UnifiedRenderService:
|
||||
# 音频调速(在降噪之后、音量之前,与 render_audio.py concat 路径保持一致)
|
||||
# SpeedEngine.build_audio_filter 内部已实现多级 atempo 串联,
|
||||
# 自动处理超出 [0.5, 2.0] 范围的速度(如 0.25x → atempo=0.5,atempo=0.5)。
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
# #1970 PR2:叠加片段微变换速度因子,保持音画同步。
|
||||
speed = UnifiedRenderService._clip_speed(clip) # 微变换速度已烘焙进 playback_speed
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
try:
|
||||
from video_processing.speed_engine import SpeedConfig, SpeedEngine
|
||||
@@ -1522,11 +1647,20 @@ class UnifiedRenderService:
|
||||
支持多段裁剪:一个 clip 配置了 trim_segments 时会展开为多个 ResolvedClip。
|
||||
"""
|
||||
resolved: list[ResolvedClip] = []
|
||||
# #1970 PR2:预建片段级微变换计划,按源视频片段序号取速度因子,
|
||||
# 烘焙进 playback_speed,保证视频 setpts 与音频 atempo 一致。
|
||||
video_source_clips = [c for c in self.clips if getattr(c, "clip_type", "main") != "audio"]
|
||||
mt_plan = self._get_micro_transform_plan(len(video_source_clips))
|
||||
_video_ordinal = {id(c): i for i, c in enumerate(video_source_clips)}
|
||||
|
||||
for clip in self.clips:
|
||||
asset_id = clip.asset_id
|
||||
if not asset_id:
|
||||
logger.warning("片段无素材: clip_id=%s", clip.id)
|
||||
continue
|
||||
_mt_idx = _video_ordinal.get(id(clip), -1)
|
||||
_mt = mt_plan.clips[_mt_idx] if mt_plan and 0 <= _mt_idx < len(mt_plan.clips) else None
|
||||
_micro_speed = UnifiedRenderService._micro_speed_factor(_mt)
|
||||
|
||||
local_path = self.asset_path_map.get(asset_id)
|
||||
if local_path is None or not local_path.exists():
|
||||
@@ -1555,7 +1689,7 @@ class UnifiedRenderService:
|
||||
seg_duration = seg.trim.duration
|
||||
|
||||
# 多段裁剪:如果段的时长超过素材实际时长,减速补偿
|
||||
seg_speed = configured_speed
|
||||
seg_speed = configured_speed * _micro_speed
|
||||
if actual_duration > 0 and seg_duration > actual_duration + 0.05:
|
||||
seg_speed = max(0.25, round(configured_speed * actual_duration / seg_duration, 4))
|
||||
logger.info(
|
||||
@@ -1578,7 +1712,7 @@ class UnifiedRenderService:
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
|
||||
playback_speed=seg_speed,
|
||||
config={**clip_config, "_segment_id": seg.segment_id},
|
||||
config={**clip_config, "_segment_id": seg.segment_id, "_micro_index": _mt_idx},
|
||||
actual_duration=actual_duration,
|
||||
trim_config=seg.trim,
|
||||
)
|
||||
@@ -1633,12 +1767,13 @@ class UnifiedRenderService:
|
||||
avail_in_asset,
|
||||
freeze_seconds,
|
||||
)
|
||||
final_speed = configured_speed
|
||||
final_speed = configured_speed * _micro_speed
|
||||
|
||||
# freeze 标记写入 config,供视频 tpad / 音频 apad 读取
|
||||
resolved_config = dict(clip_config)
|
||||
if freeze_seconds > 0:
|
||||
resolved_config["_freeze_seconds"] = freeze_seconds
|
||||
resolved_config["_micro_index"] = _mt_idx
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=clip.id,
|
||||
@@ -1754,9 +1889,15 @@ class UnifiedRenderService:
|
||||
preprocessed_labels: list[str] = []
|
||||
# 视觉扰动(plan 级别,所有 clip 共享同一套扰动参数)
|
||||
vp = self._get_visual_perturbation()
|
||||
# #1970 PR2:片段级微变换(每片段独立参数,dedup_enabled=False 时为 None)
|
||||
# 计划按源视频片段数构建,trim 多段展开时各段通过 config._micro_index 找参数
|
||||
_src_video_count = len([c for c in (self.clips or []) if getattr(c, "clip_type", "main") != "audio"])
|
||||
mt_plan = self._get_micro_transform_plan(_src_video_count)
|
||||
for i, clip in enumerate(all_clips):
|
||||
label = f"v{i}"
|
||||
role = _resolve_layer_role(clip.clip_type, clip.config)
|
||||
_mi = int(clip.config.get("_micro_index", i)) if isinstance(clip.config, dict) else i
|
||||
mt = mt_plan.clips[_mi] if mt_plan and 0 <= _mi < len(mt_plan.clips) else None
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
@@ -1774,10 +1915,10 @@ class UnifiedRenderService:
|
||||
filters.append(f"trim=duration={trim_dur:.3f}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor)
|
||||
# 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor 与 #1970 微变换速度)
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
|
||||
effective_speed = speed * vp_speed
|
||||
effective_speed = speed * vp_speed # 微变换速度已烘焙进 playback_speed
|
||||
if abs(effective_speed - 1.0) >= 1e-6:
|
||||
filters.append(f"setpts=PTS/{effective_speed:.4f}")
|
||||
|
||||
@@ -1791,6 +1932,8 @@ class UnifiedRenderService:
|
||||
# 视觉扰动:hflip(在 scale 之前,翻转原始画面)
|
||||
if vp:
|
||||
self._apply_visual_perturbation_pre_scale(filters, vp)
|
||||
# #1970 PR2:片段级 hflip(P1 保守:有文字/无法判定时不翻转)
|
||||
UnifiedRenderService._apply_micro_hflip(filters, mt)
|
||||
|
||||
# scale
|
||||
if role in ("overlay", "corner_voice"):
|
||||
@@ -1809,6 +1952,8 @@ class UnifiedRenderService:
|
||||
# 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前)
|
||||
if vp:
|
||||
self._apply_visual_perturbation_post_scale(filters, vp)
|
||||
# #1970 PR2:片段级亮度/对比度/饱和度微调
|
||||
UnifiedRenderService._apply_micro_transform_video(filters, mt)
|
||||
|
||||
# 调色滤镜(每个 clip 独立的 color grade 配置)
|
||||
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
|
||||
|
||||
@@ -27,6 +27,11 @@ celery_app.conf.broker_transport_options = {"visibility_timeout": 4 * 60 * 60}
|
||||
celery_app.conf.imports = (
|
||||
"worker_app.tasks.health",
|
||||
"worker_app.tasks.ingest",
|
||||
"worker_app.tasks.atom_clips",
|
||||
# #1970 片段级 AI 标签:必须显式 import 注册,否则 worker 报
|
||||
# "Received unregistered task of type 'worker.tag_atom_clip'"
|
||||
"worker_app.tasks.atom_clip_tagging",
|
||||
"worker_app.tasks.backfill_atom_clip_tags",
|
||||
"worker_app.tasks.classification",
|
||||
"worker_app.tasks.generation",
|
||||
"worker_app.tasks.voice_extraction",
|
||||
|
||||
@@ -53,12 +53,25 @@ def __getattr__(name: str):
|
||||
from .batch_thumbnail import batch_generate_thumbnails
|
||||
|
||||
return batch_generate_thumbnails
|
||||
elif name == "generate_atom_clips":
|
||||
from .atom_clips import generate_atom_clips
|
||||
|
||||
return generate_atom_clips
|
||||
elif name == "tag_atom_clip_task":
|
||||
from .atom_clip_tagging import tag_atom_clip_task
|
||||
|
||||
return tag_atom_clip_task
|
||||
elif name == "backfill_atom_clip_tags":
|
||||
from .backfill_atom_clip_tags import backfill_atom_clip_tags
|
||||
|
||||
return backfill_atom_clip_tags
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"batch_generate_thumbnails",
|
||||
"classify_asset",
|
||||
"generate_atom_clips",
|
||||
"generate_video",
|
||||
"healthcheck",
|
||||
"ingest_asset",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""片段级 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
|
||||
|
||||
为单个 atom_clip 调用视觉 AI 生成结构化标签,并更新到 ai_tags 字段。
|
||||
失败不阻断流程(降级为仅继承素材标签)。
|
||||
|
||||
任务名:worker.tag_atom_clip
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.domain.atom_clip_tagger import tag_atom_clip
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
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 标签.
|
||||
|
||||
Args:
|
||||
atom_clip_id: 原子片段 ID。
|
||||
force: True 时允许覆盖只有 inherited_tags 的降级记录
|
||||
(视觉 API 曾失败写入的占位标签,#1970)。
|
||||
已有完整标签(含 has_text)始终跳过,保证幂等。
|
||||
|
||||
Returns:
|
||||
任务结果 dict:status / clip_id / ai_tags(部分字段)。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
|
||||
clip = atom_repo.find_by_id(atom_clip_id)
|
||||
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}
|
||||
|
||||
# 获取素材信息
|
||||
asset = asset_repo.find_by_id(clip.asset_id)
|
||||
if asset is None:
|
||||
return {"status": "skipped", "reason": "asset not found", "clip_id": atom_clip_id}
|
||||
|
||||
# 获取视频可访问 URL
|
||||
storage = get_shared_storage_service()
|
||||
video_url = storage.get_download_url(asset.storage_key, expires_seconds=3600)
|
||||
|
||||
# 初始化客户端
|
||||
doubao_client = get_doubao_client()
|
||||
mediakit_client = get_mediakit_client()
|
||||
|
||||
# 调用 tagger
|
||||
ai_tags = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url=video_url,
|
||||
doubao_client=doubao_client,
|
||||
mediakit_client=mediakit_client,
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
# 更新数据库
|
||||
atom_repo.update_ai_tags(atom_clip_id, ai_tags)
|
||||
|
||||
logger.info(
|
||||
"[atom_clip_tagging] clip_id=%s ai_tags=%s",
|
||||
atom_clip_id,
|
||||
{k: v for k, v in ai_tags.items() if k != "inherited_tags"},
|
||||
)
|
||||
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),
|
||||
}
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.exception("[atom_clip_tagging] clip_id=%s 失败: %s", atom_clip_id, exc)
|
||||
# 可重试异常
|
||||
if self.request.retries < self.max_retries:
|
||||
raise self.retry(exc=exc) from None
|
||||
return {"status": "failed", "clip_id": atom_clip_id, "error": str(exc)}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""素材原子切片 Celery 任务 — #1970 智能剪辑流程重构 P1.
|
||||
|
||||
素材入库预处理完成(ingest 置 READY)后异步触发:
|
||||
根据素材时长和已缓存的 scdet 切换点计算原子片段并落库。
|
||||
失败不阻断素材入库主流程(atom_clips 未就绪时选片有内存兜底)。
|
||||
|
||||
P2 增强:切片完成后自动链式触发 AI 标签任务(每个 clip 一个 tag_atom_clip 任务)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
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
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.generate_atom_clips")
|
||||
def generate_atom_clips(asset_id: str) -> dict:
|
||||
"""为单条视频素材生成原子片段。
|
||||
|
||||
Returns:
|
||||
任务结果 dict:status / asset_id / clips_count。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
atom_repo = SQLAlchemyAssetAtomClipRepository(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 asset.mime_type and not asset.mime_type.startswith("video/"):
|
||||
return {"status": "skipped", "reason": "not a video", "asset_id": asset_id}
|
||||
if not asset.duration or asset.duration <= 0:
|
||||
return {"status": "skipped", "reason": "invalid duration", "asset_id": asset_id}
|
||||
|
||||
# 已生成过则幂等跳过(重新切片需先显式删除)
|
||||
existing = atom_repo.count_by_asset(asset_id)
|
||||
if existing > 0:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "already generated",
|
||||
"asset_id": asset_id,
|
||||
"clips_count": existing,
|
||||
}
|
||||
|
||||
scene_points = extract_scene_points_from_metadata(asset.metadata)
|
||||
# P1 阶段继承素材的标签 ID;片段级语义标签是 P2 功能
|
||||
tags = list(getattr(asset, "tag_ids", []) or [])
|
||||
|
||||
clips = compute_atom_clips(
|
||||
asset_id=asset_id,
|
||||
duration=float(asset.duration),
|
||||
scene_change_points=scene_points,
|
||||
tags=tags,
|
||||
)
|
||||
if not clips:
|
||||
return {"status": "skipped", "reason": "no clips computed", "asset_id": asset_id}
|
||||
|
||||
atom_repo.batch_create(clips)
|
||||
logger.info(
|
||||
"[atom_clips] asset_id=%s 生成 %d 个原子片段",
|
||||
asset_id,
|
||||
len(clips),
|
||||
)
|
||||
|
||||
# P2 增强:链式触发 AI 标签任务(每个 clip 一个异步任务)
|
||||
_dispatch_tagging_tasks(clips)
|
||||
|
||||
return {"status": "completed", "asset_id": asset_id, "clips_count": len(clips)}
|
||||
except Exception as exc: # noqa: BLE001 - 后台任务兜底,失败不阻断主流程
|
||||
db.rollback()
|
||||
logger.exception("[atom_clips] asset_id=%s 生成失败: %s", asset_id, exc)
|
||||
return {"status": "failed", "asset_id": asset_id, "error": str(exc)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _dispatch_tagging_tasks(clips: list) -> None:
|
||||
"""为每个新建片段发送 AI 标签异步任务.
|
||||
|
||||
失败不阻断(标签任务是锦上添花,不影响核心流程)。
|
||||
"""
|
||||
try:
|
||||
for clip in clips:
|
||||
celery_app.send_task(
|
||||
"worker.tag_atom_clip",
|
||||
args=[clip.id],
|
||||
)
|
||||
logger.info(
|
||||
"[atom_clips] 已发送 %d 个 AI 标签任务",
|
||||
len(clips),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[atom_clips] 发送 AI 标签任务失败(不影响切片结果): %s",
|
||||
e,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""批量回填 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
|
||||
|
||||
查找所有 ai_tags IS NULL 的 atom_clips,分批触发 tag_atom_clip 任务。
|
||||
可通过 API 路由触发(管理员权限)。
|
||||
|
||||
任务名:worker.backfill_atom_clip_tags
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
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_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
# 默认批量参数
|
||||
DEFAULT_BATCH_SIZE = 10
|
||||
DEFAULT_BATCH_INTERVAL = 5 # 秒
|
||||
|
||||
|
||||
@celery_app.task(name="worker.backfill_atom_clip_tags")
|
||||
def backfill_atom_clip_tags(
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
batch_interval: int = DEFAULT_BATCH_INTERVAL,
|
||||
max_clips: int = 0,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""批量回填未打标的 atom_clips.
|
||||
|
||||
Args:
|
||||
batch_size: 每批处理数量,默认 10。
|
||||
batch_interval: 每批间隔秒数,默认 5。
|
||||
max_clips: 最大处理总数,0 表示不限。
|
||||
force: True 时连同只有 inherited_tags 的降级记录一起强制重打
|
||||
(视觉 API 曾失败、DOUBAO_VISION_MODEL 修复后重跑用,#1970)。
|
||||
|
||||
Returns:
|
||||
任务结果 dict:total_submitted / batches。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
atom_repo = SQLAlchemyAssetAtomClipRepository(db)
|
||||
total_submitted = 0
|
||||
batches = 0
|
||||
|
||||
while True:
|
||||
# 查找未打标的片段
|
||||
remaining = max_clips - total_submitted if max_clips > 0 else batch_size
|
||||
fetch_limit = min(batch_size, remaining) if max_clips > 0 else batch_size
|
||||
|
||||
untagged = atom_repo.find_untagged(limit=fetch_limit, include_downgraded=force)
|
||||
if not untagged:
|
||||
break
|
||||
|
||||
# 逐个发送 tag 任务
|
||||
for clip in untagged:
|
||||
try:
|
||||
celery_app.send_task(
|
||||
"worker.tag_atom_clip",
|
||||
args=[clip.id],
|
||||
kwargs={"force": force},
|
||||
)
|
||||
total_submitted += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[backfill] 提交任务失败 clip_id=%s: %s",
|
||||
clip.id,
|
||||
e,
|
||||
)
|
||||
|
||||
batches += 1
|
||||
logger.info(
|
||||
"[backfill] 第 %d 批完成,已提交 %d 个任务",
|
||||
batches,
|
||||
total_submitted,
|
||||
)
|
||||
|
||||
# 检查是否达到上限
|
||||
if max_clips > 0 and total_submitted >= max_clips:
|
||||
break
|
||||
|
||||
# 批间间隔
|
||||
time.sleep(batch_interval)
|
||||
|
||||
logger.info(
|
||||
"[backfill] 回填完成: total_submitted=%d batches=%d",
|
||||
total_submitted,
|
||||
batches,
|
||||
)
|
||||
return {
|
||||
"status": "completed",
|
||||
"total_submitted": total_submitted,
|
||||
"batches": batches,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.exception("[backfill] 回填失败: %s", exc)
|
||||
return {"status": "failed", "error": str(exc)}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -890,25 +890,51 @@ def generate_video(self, task_id: str) -> dict:
|
||||
_flush_logs(task_id, gen_task)
|
||||
_update_task_progress(task_id, 80, "渲染完成")
|
||||
|
||||
# ── 3.5 随机边缘裁剪降重(#1664) ──────────────────────────
|
||||
from video_processing.ffmpeg_utils import random_edge_crop
|
||||
|
||||
# ── 3.5 随机边缘裁剪降重(#1664;#1970 dedup_enabled=False 时跳过) ──
|
||||
_dedup_enabled = True
|
||||
try:
|
||||
cropped_path = random_edge_crop(output_path)
|
||||
if cropped_path != output_path:
|
||||
output_path = cropped_path
|
||||
if gen_task and render_attempt == 0:
|
||||
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
|
||||
_flush_logs(task_id, gen_task)
|
||||
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
|
||||
except Exception as crop_err:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
with SessionLocal() as _dedup_db:
|
||||
_plan_row = (
|
||||
_dedup_db.query(EditPlanModel.config)
|
||||
.filter(EditPlanModel.id == current_plan_id)
|
||||
.first()
|
||||
)
|
||||
if _plan_row is not None:
|
||||
_cfg = _plan_row[0] if isinstance(_plan_row[0], dict) else {}
|
||||
_dedup_enabled = bool(_cfg.get("dedup_enabled", True))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 随机边缘裁剪失败,使用原始视频继续: %s",
|
||||
"[task_id=%s] 读取 plan dedup_enabled 失败,按开启处理",
|
||||
task_id,
|
||||
crop_err,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not _dedup_enabled:
|
||||
logger.info("[task_id=%s] dedup_enabled=False,跳过边缘裁剪与微变换", task_id)
|
||||
if gen_task and render_attempt == 0:
|
||||
gen_task.append_log("降重", "已关闭边缘裁剪与微变换(确定性渲染)")
|
||||
_flush_logs(task_id, gen_task)
|
||||
else:
|
||||
from video_processing.ffmpeg_utils import random_edge_crop
|
||||
|
||||
try:
|
||||
cropped_path = random_edge_crop(output_path)
|
||||
if cropped_path != output_path:
|
||||
output_path = cropped_path
|
||||
if gen_task and render_attempt == 0:
|
||||
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
|
||||
_flush_logs(task_id, gen_task)
|
||||
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
|
||||
except Exception as crop_err:
|
||||
logger.warning(
|
||||
"[task_id=%s] 随机边缘裁剪失败,使用原始视频继续: %s",
|
||||
task_id,
|
||||
crop_err,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# ── 4. 上传 OSS(不落库) ───────────────────────────────
|
||||
_update_task_progress(task_id, 85, "开始上传")
|
||||
file_url, _storage_key = _upload_rendered_video(
|
||||
|
||||
@@ -808,6 +808,21 @@ def ingest_asset(job_id: str) -> dict:
|
||||
|
||||
db.commit()
|
||||
|
||||
# ── #1970 素材原子切片:视频 READY 后异步触发,失败不阻断入库 ──
|
||||
# atom_clips 未就绪时选片逻辑有内存兜底(compute_fallback_clips)。
|
||||
try:
|
||||
if media_type == "video" and float(asset.duration or 0) > 0:
|
||||
celery_app.send_task(
|
||||
"worker.generate_atom_clips",
|
||||
args=[asset.id],
|
||||
)
|
||||
except Exception as atom_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"触发原子切片任务失败(不影响入库): asset_id=%s err=%s",
|
||||
asset.id,
|
||||
atom_err,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"job_id": job.id,
|
||||
|
||||
@@ -234,6 +234,9 @@ DOUBAO_TIMEOUT=60
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
@@ -252,3 +255,11 @@ DOUYIN_DEBUG_ERRORS=false
|
||||
TIKHUB_API_KEY=${TIKHUB_API_KEY}
|
||||
# P2: apizero.cn(国内付费,https://apizero.cn)
|
||||
APIZERO_API_KEY=${APIZERO_API_KEY}
|
||||
|
||||
# ==================== GPU MuseTalk Worker(反向轮询) ====================
|
||||
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
USE_GPU_LIPSYNC=false
|
||||
GPU_LIPSYNC_POLL_INTERVAL=5
|
||||
GPU_LIPSYNC_WAIT_TIMEOUT=1200
|
||||
GPU_WORKER_STALE_SECONDS=300
|
||||
|
||||
@@ -251,6 +251,9 @@ DOUBAO_TIMEOUT=60
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
@@ -269,3 +272,11 @@ DOUYIN_DEBUG_ERRORS=false
|
||||
TIKHUB_API_KEY=${TIKHUB_API_KEY}
|
||||
# P2: apizero.cn(国内付费,https://apizero.cn)
|
||||
APIZERO_API_KEY=${APIZERO_API_KEY}
|
||||
|
||||
# ==================== GPU MuseTalk Worker(反向轮询) ====================
|
||||
GPU_WORKER_TOKEN=${GPU_WORKER_TOKEN}
|
||||
GPU_TASK_TIMEOUT_SECONDS=900
|
||||
USE_GPU_LIPSYNC=true
|
||||
GPU_LIPSYNC_POLL_INTERVAL=5
|
||||
GPU_LIPSYNC_WAIT_TIMEOUT=1200
|
||||
GPU_WORKER_STALE_SECONDS=300
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# ============================================================
|
||||
# MuseTalk GPU Worker 环境变量
|
||||
# 部署到 RTX2060 电脑后,复制为 .env 并修改值
|
||||
# ============================================================
|
||||
|
||||
# SaaS API 基础 URL(staging / production)
|
||||
API_BASE_URL=https://staging-api.xiaoxiajianji.com
|
||||
# API_BASE_URL=https://api.xiaoxiajianji.com # 生产
|
||||
|
||||
# 长期 API Token,必须与服务端 GPU_WORKER_TOKEN 一致(找后端拿)
|
||||
GPU_WORKER_TOKEN=replace-with-real-token
|
||||
|
||||
# 本机 Worker 唯一 ID(默认自动生成 hostname+MAC 后4位,可手动指定)
|
||||
# WORKER_ID=rtx2060-0193
|
||||
|
||||
# 本地 MuseTalk 地址(默认 http://127.0.0.1:7861)
|
||||
MUSE_TALK_URL=http://127.0.0.1:7861
|
||||
|
||||
# 轮询/心跳/超时(秒)
|
||||
POLL_INTERVAL=5
|
||||
HEARTBEAT_INTERVAL=15
|
||||
# 下载/推理/上传 HTTP 超时,需与服务端 GPU_TASK_TIMEOUT_SECONDS 对齐(默认 900)
|
||||
REQUEST_TIMEOUT=900
|
||||
|
||||
# 单个任务本地最大重试次数(仅网络/MuseTalk 瞬时错误才重试,默认 1)
|
||||
TASK_MAX_RETRY=1
|
||||
# 推理期间任务心跳间隔(秒,独立线程,无需改动)
|
||||
TASK_HEARTBEAT_INTERVAL=30
|
||||
# 输入视频最短时长(秒),小于则直接上报失败,不调用 MuseTalk
|
||||
MIN_VIDEO_DURATION_SECONDS=3
|
||||
@@ -0,0 +1,353 @@
|
||||
# MuseTalk GPU Worker 部署指南
|
||||
|
||||
本目录包含两个组件:
|
||||
|
||||
1. **gpu_worker.py**:反向轮询客户端,部署在 RTX2060 本地,轮询 SaaS API 拉取口型任务,调用本地 MuseTalk 服务推理,上传结果回 SaaS。
|
||||
2. **musetalk_server.py**:MuseTalk Flask HTTP 服务端,接收 gpu_worker.py 的推理请求,调用 MuseTalk 模型生成口型同步视频。
|
||||
|
||||
---
|
||||
|
||||
## 一、环境准备
|
||||
|
||||
### 1.1 硬件要求
|
||||
|
||||
- GPU: NVIDIA RTX 2060 或更高(显存 ≥ 6GB)
|
||||
- CUDA: 11.8+
|
||||
- Python: 3.10+
|
||||
- ffmpeg: 需安装并加入 PATH
|
||||
|
||||
### 1.2 安装依赖
|
||||
|
||||
```bash
|
||||
cd deploy/gpu_worker
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、MuseTalk 服务端部署(musetalk_server.py)
|
||||
|
||||
### 2.1 配置环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env`,修改配置:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
```
|
||||
|
||||
关键配置:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `MUSE_PORT` | 监听端口 | `7861` |
|
||||
| `MUSE_INFERENCE_TIMEOUT` | 推理超时秒数 | `600` |
|
||||
| `MUSE_VIDEO_MAX_MB` | 视频上传大小限制 MB | `100` |
|
||||
| `MUSE_AUDIO_MAX_MB` | 音频上传大小限制 MB | `20` |
|
||||
| `MUSE_DEFAULT_FPS` | 视频 fps 兜底值 | `25.0` |
|
||||
| `MUSE_TEMP_DIR` | 临时文件目录 | `/tmp/musetalk_$$` |
|
||||
| `MUSE_VIDEO_ENCODER` | 兜底循环视频时的编码器:`auto`(优先 h264_nvenc,失败回退 libx264)/`h264_nvenc`/`libx264` | `auto` |
|
||||
|
||||
### 2.2 更新部署(v2 性能修复,必做)
|
||||
|
||||
> ⚠️ 2026-09-20 v2 架构:修复 16 倍性能回归。旧版在推理前 loop 视频导致 MuseTalk 处理帧数翻倍、RTX2060 推理 >200s、nginx 504。**必须重新拉取并重启**:
|
||||
|
||||
```bash
|
||||
# 在 RTX2060 上备份旧文件并拉取新版本
|
||||
cp ~/projects/MuseTalk/musetalk_server.py ~/projects/MuseTalk/musetalk_server.py.bak
|
||||
wget -O ~/projects/MuseTalk/musetalk_server.py \
|
||||
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/musetalk_server.py"
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart musetalk-server
|
||||
sudo systemctl status musetalk-server
|
||||
curl http://127.0.0.1:7861/health
|
||||
```
|
||||
|
||||
v2 架构核心变化:
|
||||
|
||||
- **MuseTalk 直传全量音频**:不再在推理前用 ffmpeg 循环视频。MuseTalk 原生支持长音频输入,内部自动循环视频帧。推理时间不变(~14s/5s 视频)
|
||||
- **ffmpeg 只做快速封装**:`-c:v copy -c:a aac -shortest`,秒级完成,不重编码
|
||||
- **循环仅兜底**:仅当 MuseTalk 输出画面短于音频时(极端情况),才 `-stream_loop` + NVENC 兜底
|
||||
- **删除 `MUSE_ENABLE_VIDEO_LOOP`**:不再需要此开关,MuseTalk 原生处理
|
||||
|
||||
### 2.3 启动服务
|
||||
|
||||
```bash
|
||||
# 前台运行(调试用)
|
||||
python musetalk_server.py
|
||||
|
||||
# 后台运行(生产用 systemd)
|
||||
sudo systemctl start musetalk-server
|
||||
sudo systemctl enable musetalk-server
|
||||
```
|
||||
|
||||
### 2.4 验证健康检查
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:7861/health
|
||||
```
|
||||
|
||||
应返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"gpu": {
|
||||
"gpu_name": "NVIDIA GeForce RTX 2060",
|
||||
"memory_total_mb": 6144,
|
||||
"memory_used_mb": 1024,
|
||||
"memory_free_mb": 5120
|
||||
},
|
||||
"current_task": {
|
||||
"task_id": null,
|
||||
"running": false,
|
||||
"elapsed_seconds": 0.0
|
||||
},
|
||||
"timestamp": 1700000000.0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、GPU Worker 客户端部署(gpu_worker.py)
|
||||
|
||||
### 3.1 配置环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env`,修改配置:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
```
|
||||
|
||||
关键配置:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `API_BASE_URL` | SaaS API 基础 URL | `https://staging-api.xiaoxiajianji.com` |
|
||||
| `GPU_WORKER_TOKEN` | 长期 API Token(与服务端一致) | - |
|
||||
| `MUSE_TALK_URL` | 本地 MuseTalk 服务地址 | `http://127.0.0.1:7861` |
|
||||
| `POLL_INTERVAL` | 轮询间隔秒 | `5` |
|
||||
| `HEARTBEAT_INTERVAL` | 空闲心跳间隔秒 | `15` |
|
||||
| `REQUEST_TIMEOUT` | HTTP 请求超时秒 | `900` |
|
||||
| `TASK_MAX_RETRY` | 本地最大重试次数 | `1` |
|
||||
| `TASK_HEARTBEAT_INTERVAL` | 推理期间任务心跳间隔秒 | `30` |
|
||||
| `MIN_VIDEO_DURATION_SECONDS` | 最短输入视频时长秒 | `3` |
|
||||
|
||||
### 3.2 启动 Worker
|
||||
|
||||
```bash
|
||||
# 前台运行(调试用)
|
||||
python gpu_worker.py
|
||||
|
||||
# 后台运行(生产用 systemd)
|
||||
sudo systemctl start xiaoxia-gpu-worker
|
||||
sudo systemctl enable xiaoxia-gpu-worker
|
||||
```
|
||||
|
||||
### 3.3 验证启动日志
|
||||
|
||||
应看到:
|
||||
|
||||
```
|
||||
============================================================
|
||||
MuseTalk GPU Worker 启动
|
||||
worker_id = rtx2060-xxxx
|
||||
api_base = https://staging-api.xiaoxiajianji.com
|
||||
muse_talk = http://127.0.0.1:7861
|
||||
poll = 5.0s / heartbeat = 15.0s
|
||||
============================================================
|
||||
MuseTalk 健康检查通过: {...}
|
||||
注册/心跳成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、常见问题排查
|
||||
|
||||
| 现象 | 可能原因 / 排查 |
|
||||
|---|---|
|
||||
| 日志 401 `Invalid GPU worker token` | `.env` 的 `GPU_WORKER_TOKEN` 与服务端不一致 |
|
||||
| 日志 `MuseTalk 健康检查未通过` | 本地 MuseTalk 没启动,或端口不是 7861;`curl http://127.0.0.1:7861/health` 验证 |
|
||||
| 任务长时间不被拉取 | Worker 和服务端连不上;检查 API_BASE_URL 是否可达、Token 是否正确 |
|
||||
| 推理后上传 OSS 失败 | 本地出口网络被防火墙拦截 OSS 域名(oss-cn-hangzhou.aliyuncs.com) |
|
||||
| 服务端看到任务回退到 pending 重试 | 任务心跳真正超时(默认 900s):Worker 进程崩溃/断网,或推理彻底卡死;正常长推理期间心跳线程每 30s 续期,不会回退 |
|
||||
| 日志 `MuseTalk 推理超时或连接失败` | 视频太长或显存不足;可临时调大 REQUEST_TIMEOUT(服务端 GPU_TASK_TIMEOUT_SECONDS 需同步调大),或限制输入视频时长 |
|
||||
| 日志 `视频过短(x.xxs < 3s)` | 输入视频不足 3s,MuseTalk 对短视频会 division by zero,已在本地直接上报失败;可用 MIN_VIDEO_DURATION_SECONDS 调整阈值 |
|
||||
| MuseTalk 服务端 503 `GPU 正在处理其他任务` | 并发请求被锁拒绝,等当前推理完成即可 |
|
||||
| MuseTalk 服务端 504 `推理超时` | 推理超过 MUSE_INFERENCE_TIMEOUT,客户端会调 /cancel 终止服务端任务 |
|
||||
|
||||
---
|
||||
|
||||
## 五、安全注意事项
|
||||
|
||||
- `.env` 包含长期 Token,文件权限设为 600(`chmod 600 .env`)
|
||||
- Token 泄露要立即在服务端更换 `GPU_WORKER_TOKEN` 并重启 Worker
|
||||
- Worker 只需要出站访问 SaaS API 和 OSS,不需要开放任何入站端口
|
||||
- MuseTalk 服务端只监听本地 127.0.0.1(或 0.0.0.0 但通过防火墙限制),不暴露到公网
|
||||
- 临时文件自动清理(推理完成/失败后),无需手动维护
|
||||
|
||||
---
|
||||
|
||||
## 六、工程改进记录(musetalk_server.py)
|
||||
|
||||
相比原 `worker.py`,修复了以下 8 个 bug:
|
||||
|
||||
1. **Flask 单线程阻塞**:`app.run(threaded=True)`,推理时 `/health` 仍可响应
|
||||
2. **fps=0 除零崩溃**:`_get_video_fps()` 兜底 `MUSE_DEFAULT_FPS`
|
||||
3. **ffmpeg 不检查返回码**:`subprocess.run(check=True)` + 超时检查,失败立即报错
|
||||
4. **无并发锁**:`threading.Lock` 控制并发,第二请求立即 503
|
||||
5. **无推理超时**:线程 join timeout,超时返回 504 并调 `/cancel`
|
||||
6. **结果文件不清理**:推理完成/失败后自动删除临时目录
|
||||
7. **无人脸检测兜底**:MuseTalk 推理内部处理(TODO: 可在 `_run_inference` 前置检查)
|
||||
8. **上传无大小限制**:`_check_file_size()` 校验,超限返回 413
|
||||
|
||||
新增:
|
||||
- `/cancel` 端点:终止当前推理任务,清理临时文件
|
||||
- `/health` 端点:返回 GPU 显存信息和当前任务状态
|
||||
|
||||
2026-09-20 追加修复(音轨正确性,上线阻断级):
|
||||
|
||||
9. **音轨未替换(严重)**:旧最终封装让 ffmpeg 默认选流,结果保留了源视频自带音轨(与画面相关系数 0.9998,与 TTS 无关)。改为 `_mux_video_with_audio()` 统一封装,强制 `-map 0:v:0 -map 1:a:0`,画面取 MuseTalk 无声产物、音轨只取驱动音频
|
||||
10. **音视频时长不对齐**:TTS 长于原视频时 `-shortest` 会截短语音。改为探测双方时长,音频更长时 `-stream_loop -1` 循环画面 + `h264_nvenc` 硬件重编码(`MUSE_VIDEO_ENCODER=auto`,失败回退 libx264)+ `-t <音频时长>`;不循环时 `-c:v copy` 秒封装
|
||||
- 开关 `MUSE_ENABLE_VIDEO_LOOP=0` 可关闭循环;请求也支持 form 参数 `enable_video_loop` 单任务覆盖
|
||||
|
||||
2026-09-20 v2 架构重构(性能回归修复,上线阻断级):
|
||||
|
||||
11. **16 倍性能回归**:#9/#10 的实现虽然音轨正确,但在某些集成场景下(推理前 loop 视频再喂 MuseTalk)导致推理帧数 ×2.2 + 叠加 ffmpeg 软编码预处理,5s 视频 +11s 音频推理 >200s,nginx 60s 超时 504
|
||||
- **正确架构**:MuseTalk 原生支持长音频输入,内部自动循环视频帧。把【原视频】+【全量音频】直传 MuseTalk,输出时长=音频时长
|
||||
- **ffmpeg 后置快速封装**:`-c:v copy -c:a aac -shortest` 秒级完成,不重编码
|
||||
- **循环仅兜底**:仅当 MuseTalk 输出画面短于音频时(极端情况),才 `-stream_loop` + NVENC 兜底补齐
|
||||
- **业务侧异步化**:POST /lipsync/jobs 创建 GPU 任务后立即返回 `job.status="processing"`,Celery 异步等待结果回写。前端 GET /jobs/{id} 轮询。避免同步阻塞 HTTP 请求 >200s
|
||||
- **删除 `MUSE_ENABLE_VIDEO_LOOP`**:不再需要此开关
|
||||
|
||||
---
|
||||
|
||||
## 七、自动部署
|
||||
|
||||
从 2026-09-20 起,GPU 节点配置文件和脚本全部入库到 `deploy/gpu_worker/`,支持一键初始化新节点 + develop 分支 push 后 30 秒内自动拉取更新。
|
||||
|
||||
### 7.1 服务架构
|
||||
|
||||
每个 GPU 渲染节点运行三个 systemd 单元:
|
||||
|
||||
| 单元 | 类型 | 作用 |
|
||||
|---|---|---|
|
||||
| `musetalk-worker.service` | simple(常驻) | MuseTalk Flask 推理 API(监听 127.0.0.1:7861) |
|
||||
| `xiaoxia-gpu-worker.service` | simple(常驻) | 反向轮询 SaaS API 拉口型任务的 Worker 客户端 |
|
||||
| `gpu-poll.timer` + `gpu-poll.service` | timer(每 30s 触发 oneshot) | 轮询 Gitea `deploy/gpu_worker/` 最新 commit,有变更自动执行 update 脚本 |
|
||||
|
||||
脚本目录(节点本地):
|
||||
|
||||
| 路径 | 来源 | 作用 |
|
||||
|---|---|---|
|
||||
| `~/projects/update-gpu-worker.sh` | `scripts/update-gpu-worker.sh` | 备份 → 拉代码 → 重启两个服务 → 健康检查 → 失败回滚 |
|
||||
| `~/projects/gpu-webhook/poll_and_update.sh` | `scripts/poll_and_update.sh` | 轮询 Gitea API 比对 SHA,有新 commit 时触发 update |
|
||||
|
||||
### 7.2 新节点部署步骤
|
||||
|
||||
**前置准备**(手动,首次部署必做):
|
||||
|
||||
1. 安装 NVIDIA 驱动 + CUDA 11.8+,`nvidia-smi` 能看到 GPU
|
||||
2. 克隆 MuseTalk 代码到 `~/projects/MuseTalk/`,下载模型权重到 `~/projects/MuseTalk/models/musetalk/`(权重约几 GB,不适合自动下载)
|
||||
3. 创建 Python 虚拟环境 `~/projects/MuseTalk/venv/` 并安装 MuseTalk 依赖(PyTorch CUDA 版等)
|
||||
4. 创建 Worker 虚拟环境 `/opt/xiaoxia-gpu-worker/venv/` 并 `pip install -r requirements.txt`
|
||||
5. 准备 `.env` 文件(Worker 端):`/opt/xiaoxia-gpu-worker/.env`,填好 `API_BASE_URL`、`GPU_WORKER_TOKEN`、`MUSE_TALK_URL` 等(参考 `.env.example`)
|
||||
|
||||
> ⚠️ 模型权重和 Python 虚拟环境(含 CUDA 版 PyTorch)体积大、安装慢,首次部署必须手动准备;后续脚本只更新 `.py` 文件和配置,不碰权重和 venv。
|
||||
|
||||
**一键初始化**:
|
||||
|
||||
```bash
|
||||
# 从仓库拉取 setup 脚本并执行(在全新 GPU 机器上以 ying 用户执行)
|
||||
wget -q -O /tmp/setup-gpu-node.sh \
|
||||
"https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker/scripts/setup-gpu-node.sh"
|
||||
bash /tmp/setup-gpu-node.sh
|
||||
```
|
||||
|
||||
脚本自动完成:
|
||||
|
||||
1. apt 安装系统依赖(python3、ffmpeg、wget、curl、git)
|
||||
2. 创建必要目录(`~/projects/MuseTalk`、`~/projects/gpu-webhook`、`/opt/xiaoxia-gpu-worker`)
|
||||
3. 从仓库拉取三个 systemd 单元文件 + update/poll 脚本到本地
|
||||
4. 安装 systemd 服务到 `/etc/systemd/system/`
|
||||
5. 配置 sudo 免密(仅允许 `ying` 用户免密 restart 两个服务、status、journalctl、cp、chmod、tee)
|
||||
6. 首次执行 update 脚本拉取最新 `musetalk_server.py` 和 `gpu_worker.py`
|
||||
7. `systemctl daemon-reload` + enable + start 三个单元
|
||||
|
||||
**初始化后检查**:
|
||||
|
||||
```bash
|
||||
sudo systemctl status musetalk-worker # 应 active (running)
|
||||
sudo systemctl status xiaoxia-gpu-worker # 应 active (running)
|
||||
sudo systemctl status gpu-poll.timer # 应 active (waiting)
|
||||
curl http://127.0.0.1:7861/health # 应返回 healthy + GPU 显存信息
|
||||
```
|
||||
|
||||
### 7.3 自动更新机制
|
||||
|
||||
push 到 `develop` 分支且修改了 `deploy/gpu_worker/` 下任何文件后:
|
||||
|
||||
1. `gpu-poll.timer` 每 30 秒触发 `gpu-poll.service`
|
||||
2. `poll_and_update.sh` 调用 Gitea API 取 `deploy/gpu_worker/` 路径最新 commit SHA
|
||||
3. 与本地 `~/projects/gpu-webhook/.last_commit` 比对,无变更直接退出
|
||||
4. 有变更:写入新 SHA → 执行 `update-gpu-worker.sh`
|
||||
5. `update-gpu-worker.sh` 执行流程:
|
||||
- 备份当前 `musetalk_server.py` / `gpu_worker.py`(带时间戳后缀)
|
||||
- wget 拉取最新 `musetalk_server.py`、`gpu_worker.py`
|
||||
- 比对 `requirements.txt`,有变化则 pip install
|
||||
- `sudo systemctl restart musetalk-worker`,等 5 秒
|
||||
- `sudo systemctl restart xiaoxia-gpu-worker`,等 8 秒
|
||||
- `curl http://127.0.0.1:7861/health` 健康检查
|
||||
- 健康 → 写日志退出 0
|
||||
- 不健康 → 回滚到最新备份 → 重启 → 退出 1(日志记录 rolled back)
|
||||
|
||||
端到端延迟:从 push 到节点拉到新代码并重启,约 30~60 秒。
|
||||
|
||||
### 7.4 手动更新命令
|
||||
|
||||
```bash
|
||||
# 立即手动触发一次更新(不依赖 timer)
|
||||
bash ~/projects/update-gpu-worker.sh
|
||||
|
||||
# 查看更新日志
|
||||
tail -f /tmp/gpu-worker-update.log
|
||||
|
||||
# 查看轮询日志
|
||||
tail -f /tmp/gpu-poll.log
|
||||
|
||||
# 查看服务运行日志
|
||||
journalctl -u musetalk-worker -f # MuseTalk 推理服务日志
|
||||
journalctl -u xiaoxia-gpu-worker -f # GPU Worker 客户端日志
|
||||
journalctl -u gpu-poll.service -f # 轮询/更新触发日志
|
||||
```
|
||||
|
||||
### 7.5 仓库文件清单(自动部署相关)
|
||||
|
||||
```
|
||||
deploy/gpu_worker/
|
||||
├── musetalk-worker.service # MuseTalk 推理 API 的 systemd 服务
|
||||
├── gpu-poll.service # 自动更新轮询 oneshot service
|
||||
├── gpu-poll.timer # 每 30 秒触发轮询的 timer
|
||||
├── xiaoxia-gpu-worker.service # GPU Worker 客户端 systemd 服务(已有)
|
||||
├── gpu_worker.py # GPU Worker 客户端脚本(已有,自动更新)
|
||||
├── musetalk_server.py # MuseTalk Flask 服务端(已有,自动更新)
|
||||
├── requirements.txt # Worker Python 依赖(已有)
|
||||
├── .env.example # Worker 环境变量模板(已有)
|
||||
├── README.md # 本文档
|
||||
└── scripts/
|
||||
├── update-gpu-worker.sh # 更新脚本:备份→拉取→重启→健康检查→回滚
|
||||
├── poll_and_update.sh # 轮询脚本:SHA 比对→触发更新
|
||||
└── setup-gpu-node.sh # 新节点一键初始化脚本
|
||||
```
|
||||
|
||||
### 7.6 注意事项
|
||||
|
||||
- **首次部署必须手动准备**:MuseTalk 代码仓库、模型权重(`models/musetalk/`,几 GB)、MuseTalk 的 Python 虚拟环境(`venv/`,含 CUDA 版 PyTorch)。这些体积大、安装耗时长,不在自动更新范围内。
|
||||
- **脚本路径写死**:当前脚本路径固定为 `/home/ying/projects/` 和 `/opt/xiaoxia-gpu-worker/`,用户名固定 `ying`。后续如有多节点/多用户需求再做参数化。
|
||||
- **sudo 免密范围最小化**:setup 脚本写入 `/etc/sudoers.d/ying-gpu-update`,仅放行 restart/status 两个 GPU 相关服务、daemon-reload、journalctl、cp、chmod、tee,不开放全量 root。
|
||||
- **回滚只回滚 .py 文件**:健康检查失败只回滚 `musetalk_server.py` 和 `gpu_worker.py`,不回滚 pip 依赖(requirements.txt 变化概率低,且 pip 操作本身可能失败)。如需完全回滚,手动 `pip install -r requirements.txt` 指定旧版本。
|
||||
- **poll 脚本容错**:Gitea API 请求失败直接跳过,不触发更新,不会因为网络抖动误重启服务。
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=GPU Worker Auto-Update Poller
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=ying
|
||||
ExecStart=/bin/bash /home/ying/projects/gpu-webhook/poll_and_update.sh
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Poll Gitea for GPU worker updates every 30 seconds
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30
|
||||
OnUnitActiveSec=30
|
||||
AccuracySec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,485 @@
|
||||
"""MuseTalk GPU Worker — 反向轮询模式.
|
||||
|
||||
部署在有 RTX2060 的本地电脑上(192.168.0.193),
|
||||
主动轮询 SaaS API 拉取口型任务、调用本地 MuseTalk 推理、上传结果回 SaaS。
|
||||
|
||||
环境变量:
|
||||
API_BASE_URL SaaS API 基础 URL(不含 /api/v1),如 https://staging-api.xiaoxiajianji.com
|
||||
GPU_WORKER_TOKEN 长期 API Token(服务端 GPU_WORKER_TOKEN 需一致)
|
||||
WORKER_ID 本机唯一 ID(默认 hostname+网卡MAC 后4位)
|
||||
MUSE_TALK_URL 本地 MuseTalk 地址,默认 http://127.0.0.1:7861
|
||||
POLL_INTERVAL 轮询间隔秒,默认 5
|
||||
HEARTBEAT_INTERVAL 空闲心跳间隔秒,默认 15
|
||||
REQUEST_TIMEOUT HTTP 请求超时秒(下载/推理/上传统一使用),默认 900
|
||||
需与服务端 GPU_TASK_TIMEOUT_SECONDS(默认 900)对齐
|
||||
TASK_MAX_RETRY 单任务本地最大重试次数(仅对瞬时错误重试),默认 1
|
||||
TASK_HEARTBEAT_INTERVAL 推理期间任务心跳间隔秒,默认 30
|
||||
MIN_VIDEO_DURATION_SECONDS 最短输入视频时长秒,小于则直接上报失败,默认 3
|
||||
|
||||
用法:
|
||||
python gpu_worker.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("musetalk-worker")
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
v = os.environ.get(name, default)
|
||||
return v.strip() if isinstance(v, str) else default
|
||||
|
||||
|
||||
class Config:
|
||||
api_base_url: str = _env("API_BASE_URL", "https://staging-api.xiaoxiajianji.com").rstrip("/")
|
||||
gpu_worker_token: str = _env("GPU_WORKER_TOKEN")
|
||||
muse_talk_url: str = _env("MUSE_TALK_URL", "http://127.0.0.1:7861").rstrip("/")
|
||||
poll_interval: float = float(_env("POLL_INTERVAL", "5"))
|
||||
heartbeat_interval: float = float(_env("HEARTBEAT_INTERVAL", "15"))
|
||||
# #1970:RTX2060 6G 处理 720p 长视频可能 >5min;与服务端
|
||||
# GPU_TASK_TIMEOUT_SECONDS 默认值对齐为 900,避免推理被本地/服务端先掐断。
|
||||
request_timeout: float = float(_env("REQUEST_TIMEOUT", "900"))
|
||||
# 本地只在网络/MuseTalk 瞬时错误时重试 1 次;服务端 MAX_ATTEMPTS=3
|
||||
# 负责跨 worker/真正超时后的重派发,总尝试次数不再相乘放大。
|
||||
task_max_retry: int = int(_env("TASK_MAX_RETRY", "1"))
|
||||
# 推理期间任务心跳间隔(独立线程 POST /gpu/register 带 task_id)
|
||||
task_heartbeat_interval: float = float(_env("TASK_HEARTBEAT_INTERVAL", "30"))
|
||||
# 输入视频最短时长(秒):过短(如 1s)MuseTalk 会 division by zero,
|
||||
# 本地前置拦截,直接上报 failed,不浪费 GPU 时间
|
||||
min_video_duration_seconds: float = float(_env("MIN_VIDEO_DURATION_SECONDS", "3"))
|
||||
worker_id: str = _env("WORKER_ID", "")
|
||||
|
||||
@classmethod
|
||||
def derived_worker_id(cls) -> str:
|
||||
if cls.worker_id:
|
||||
return cls.worker_id
|
||||
# hostname + MAC 后4位 → 稳定唯一 ID
|
||||
try:
|
||||
mac = uuid.getnode()
|
||||
mac_suffix = f"{mac:012x}"[-4:]
|
||||
except Exception:
|
||||
mac_suffix = "0000"
|
||||
host = platform.node() or socket.gethostname() or "rtx2060"
|
||||
return f"{host}-{mac_suffix}"
|
||||
|
||||
|
||||
# ── 辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _api_headers() -> dict[str, str]:
|
||||
token = Config.gpu_worker_token
|
||||
if not token:
|
||||
logger.warning("GPU_WORKER_TOKEN 未配置,开发模式下会被服务端拒绝(生产环境必须配置)")
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
|
||||
def _check_musetalk_health() -> tuple[bool, dict]:
|
||||
"""检查本地 MuseTalk 健康状态,返回 (ok, info)."""
|
||||
try:
|
||||
r = requests.get(f"{Config.muse_talk_url}/health", timeout=5)
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
return True, r.json()
|
||||
except Exception:
|
||||
return True, {}
|
||||
return False, {"status_code": r.status_code, "body": r.text[:200]}
|
||||
except Exception as exc:
|
||||
return False, {"error": str(exc)}
|
||||
|
||||
|
||||
def _register(task_id: Optional[str] = None) -> bool:
|
||||
"""向服务端注册 / 心跳,附带 GPU 信息。
|
||||
|
||||
推理期间的心跳线程传 task_id:服务端会同步刷新该 processing 任务的
|
||||
last_heartbeat_at,防止长推理被误判超时回收。
|
||||
"""
|
||||
ok, info = _check_musetalk_health()
|
||||
free_vram = int(info.get("free_vram_mb", 0) or 0) if isinstance(info, dict) else 0
|
||||
gpu_name = info.get("gpu_name", "") if isinstance(info, dict) else ""
|
||||
if not gpu_name:
|
||||
# 尝试在 Windows 上读 nvidia-smi
|
||||
gpu_name = _probe_gpu_name()
|
||||
payload = {
|
||||
"worker_id": Config.derived_worker_id(),
|
||||
"hostname": platform.node(),
|
||||
"gpu_name": gpu_name,
|
||||
"free_vram_mb": free_vram,
|
||||
"capabilities": "musetalk",
|
||||
}
|
||||
if task_id:
|
||||
payload["task_id"] = task_id
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{Config.api_base_url}/api/v1/gpu/register",
|
||||
json=payload,
|
||||
headers=_api_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return True
|
||||
logger.error("注册/心跳失败: HTTP %d body=%s", r.status_code, r.text[:300])
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("注册/心跳异常: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _probe_gpu_name() -> str:
|
||||
"""尽力探测 GPU 型号(不强制依赖 pynvml)."""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
out = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
)
|
||||
return out.decode("utf-8", errors="ignore").strip().splitlines()[0].strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _poll_task() -> Optional[dict]:
|
||||
"""轮询拉取一条待处理任务;无任务返回 None."""
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{Config.api_base_url}/api/v1/gpu/lipsync/poll",
|
||||
params={"worker_id": Config.derived_worker_id()},
|
||||
headers=_api_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
if r.status_code == 204:
|
||||
return None
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return data.get("task")
|
||||
logger.error("poll 返回 %d: %s", r.status_code, r.text[:300])
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.error("poll 异常: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _download(url: str, path: Path) -> bool:
|
||||
"""下载文件到本地,支持预签名 URL."""
|
||||
try:
|
||||
with requests.get(url, stream=True, timeout=Config.request_timeout) as r:
|
||||
if r.status_code >= 400:
|
||||
logger.error("下载失败 HTTP %d: %s", r.status_code, url[:120])
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=1024 * 256):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return path.stat().st_size > 0
|
||||
except Exception as exc:
|
||||
logger.error("下载异常 %s: %s", url[:120], exc)
|
||||
return False
|
||||
|
||||
|
||||
def _call_musetalk(video_path: Path, audio_path: Path, out_path: Path) -> tuple[bool, float, str, bool]:
|
||||
"""调用本地 MuseTalk /inference.
|
||||
|
||||
返回 (success, duration_seconds, error_msg, retryable)。
|
||||
duration 用 ffprobe 读结果视频,失败填 0。
|
||||
retryable 仅对瞬时错误(连接失败/超时/5xx)为 True;HTTP 4xx、结果过小
|
||||
等确定性失败不重试,直接上报服务端(服务端 MAX_ATTEMPTS 再决定是否重派发)。
|
||||
"""
|
||||
try:
|
||||
with open(video_path, "rb") as vf, open(audio_path, "rb") as af:
|
||||
files = {
|
||||
"video": (video_path.name, vf, "video/mp4"),
|
||||
"audio": (audio_path.name, af, "application/octet-stream"),
|
||||
}
|
||||
r = requests.post(
|
||||
f"{Config.muse_talk_url}/inference",
|
||||
files=files,
|
||||
timeout=Config.request_timeout,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
retryable = r.status_code >= 500
|
||||
return False, 0.0, f"MuseTalk HTTP {r.status_code}: {r.text[:500]}", retryable
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(r.content)
|
||||
if out_path.stat().st_size < 1024:
|
||||
# 确定性失败(推理产物异常),本地重试大概率还是坏的,不重试
|
||||
return False, 0.0, f"MuseTalk 返回结果过小 ({out_path.stat().st_size} bytes)", False
|
||||
duration = _probe_duration(out_path)
|
||||
return True, duration, "", False
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
|
||||
# 瞬时网络/超时错误,允许本地重试 1 次;同时调 /cancel 让服务端终止僵尸推理
|
||||
_cancel_musetalk()
|
||||
return False, 0.0, f"MuseTalk 推理超时或连接失败(>{Config.request_timeout}s)", True
|
||||
except Exception as exc:
|
||||
return False, 0.0, f"MuseTalk 调用异常: {exc}", False
|
||||
|
||||
|
||||
def _cancel_musetalk() -> None:
|
||||
"""调 MuseTalk /cancel 端点终止服务端僵尸推理进程,避免超时后任务还在跑占显存."""
|
||||
try:
|
||||
r = requests.post(f"{Config.muse_talk_url}/cancel", timeout=10)
|
||||
if r.status_code == 200:
|
||||
logger.info("已调 MuseTalk /cancel,服务端终止推理")
|
||||
else:
|
||||
logger.warning("MuseTalk /cancel 返回 %d: %s", r.status_code, r.text[:200])
|
||||
except Exception as exc:
|
||||
# /cancel 失败不应影响主流程上报
|
||||
logger.warning("调 MuseTalk /cancel 异常(忽略): %s", exc)
|
||||
|
||||
|
||||
def _probe_duration(path: Path) -> float:
|
||||
"""用 ffprobe 读视频时长(若系统装了 ffmpeg);否则返回 0."""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(path),
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
return float(out.decode().strip() or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _upload_result(upload_url: str, file_path: Path) -> bool:
|
||||
"""PUT 上传结果视频到预签名 URL."""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
r = requests.put(
|
||||
upload_url,
|
||||
data=f,
|
||||
headers={"Content-Type": "video/mp4"},
|
||||
timeout=Config.request_timeout,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
logger.error("上传结果失败 HTTP %d: %s", r.status_code, r.text[:500])
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("上传结果异常: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _report_result(task_id: str, success: bool, duration: float = 0.0, error_msg: str = "") -> bool:
|
||||
"""通知服务端结果。失败时也尝试上报错误(不含视频文件)."""
|
||||
try:
|
||||
data = {
|
||||
"task_id": task_id,
|
||||
"worker_id": Config.derived_worker_id(),
|
||||
"success": "true" if success else "false",
|
||||
"duration_seconds": str(duration),
|
||||
"error_msg": error_msg,
|
||||
}
|
||||
r = requests.post(
|
||||
f"{Config.api_base_url}/api/v1/gpu/lipsync/result",
|
||||
data=data,
|
||||
headers=_api_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.error("上报结果失败 HTTP %d: %s", r.status_code, r.text[:300])
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("上报结果异常: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
class TaskHeartbeat(threading.Thread):
|
||||
"""推理期间的任务心跳线程。
|
||||
|
||||
主循环的空闲心跳在 ``_handle_task`` 同步阻塞(下载/推理/上传最长 900s)
|
||||
期间无法发送,服务端会因任务 last_heartbeat_at 停滞而误判超时回退 pending。
|
||||
本线程每 task_heartbeat_interval 秒(默认 30s)POST /gpu/register 并
|
||||
携带当前 task_id,让服务端持续续期任务心跳;任务处理结束 stop()。
|
||||
"""
|
||||
|
||||
def __init__(self, task_id: str, interval: float):
|
||||
super().__init__(daemon=True, name=f"hb-{task_id[:8]}")
|
||||
self.task_id = task_id
|
||||
self.interval = max(5.0, interval)
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def run(self) -> None:
|
||||
# 先立即发一次,再按间隔循环(首次心跳失败不影响主流程)
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
if _register(self.task_id):
|
||||
logger.debug("任务 %s 心跳已发送", self.task_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("任务 %s 心跳异常(忽略): %s", self.task_id, exc)
|
||||
self._stop_event.wait(self.interval)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
|
||||
def _handle_task(task: dict) -> None:
|
||||
"""处理一条任务(整个串行流程:下载→时长校验→推理→上传→上报)。"""
|
||||
task_id = task["task_id"]
|
||||
logger.info("开始处理任务 %s", task_id)
|
||||
# 领取任务后立即启动任务级心跳线程,覆盖下载/推理/上报全过程
|
||||
hb = TaskHeartbeat(task_id, Config.task_heartbeat_interval)
|
||||
hb.start()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="musetalk_") as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
video_path = tmp / "input.mp4"
|
||||
audio_path = tmp / "input_audio.bin"
|
||||
out_path = tmp / "output.mp4"
|
||||
|
||||
# 1. 下载
|
||||
if not _download(task["video_url"], video_path):
|
||||
_report_result(task_id, False, 0.0, "下载人物视频失败")
|
||||
return
|
||||
if not _download(task["audio_url"], audio_path):
|
||||
_report_result(task_id, False, 0.0, "下载驱动音频失败")
|
||||
return
|
||||
|
||||
# 2. 输入时长前置校验:短视频 MuseTalk 会 division by zero,
|
||||
# 直接上报 failed,不浪费 GPU 时间。ffprobe 不可用/读失败(0.0)
|
||||
# 时不拦截,交给 MuseTalk 处理,避免误杀。
|
||||
video_duration = _probe_duration(video_path)
|
||||
if video_duration and video_duration < Config.min_video_duration_seconds:
|
||||
msg = (
|
||||
f"视频过短({video_duration:.2f}s < {Config.min_video_duration_seconds:.0f}s),"
|
||||
"MuseTalk 无法处理"
|
||||
)
|
||||
logger.error("任务 %s %s", task_id, msg)
|
||||
_report_result(task_id, False, 0.0, msg)
|
||||
return
|
||||
|
||||
# 3. 推理(本地仅对瞬时错误重试)
|
||||
success = False
|
||||
duration = 0.0
|
||||
err = ""
|
||||
retryable = False
|
||||
for attempt in range(Config.task_max_retry + 1):
|
||||
if attempt > 0:
|
||||
logger.info("任务 %s 第 %d 次重试(瞬时错误)...", task_id, attempt + 1)
|
||||
time.sleep(2)
|
||||
success, duration, err, retryable = _call_musetalk(video_path, audio_path, out_path)
|
||||
if success or not retryable:
|
||||
break
|
||||
if not success:
|
||||
logger.error("任务 %s 推理失败: %s", task_id, err)
|
||||
_report_result(task_id, False, 0.0, err)
|
||||
return
|
||||
|
||||
# 4. 上报结果(multipart 同时上传文件 → API 代为 PUT 到 OSS,逻辑最稳)
|
||||
_report_success_with_file(task_id, duration, out_path)
|
||||
finally:
|
||||
hb.stop()
|
||||
|
||||
|
||||
def _report_success_with_file(task_id: str, duration: float, file_path: Path) -> None:
|
||||
"""上报成功并 multipart 附带结果视频."""
|
||||
try:
|
||||
data = {
|
||||
"task_id": task_id,
|
||||
"worker_id": Config.derived_worker_id(),
|
||||
"success": "true",
|
||||
"duration_seconds": str(duration),
|
||||
"error_msg": "",
|
||||
}
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"result": (f"{task_id}.mp4", f, "video/mp4")}
|
||||
r = requests.post(
|
||||
f"{Config.api_base_url}/api/v1/gpu/lipsync/result",
|
||||
data=data,
|
||||
files=files,
|
||||
headers=_api_headers(),
|
||||
timeout=Config.request_timeout,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.error("上报成功结果失败 HTTP %d: %s", r.status_code, r.text[:300])
|
||||
return
|
||||
logger.info("任务 %s 完成,duration=%.1fs", task_id, duration)
|
||||
except Exception as exc:
|
||||
logger.error("上报成功结果异常: %s", exc)
|
||||
|
||||
|
||||
# ── 主循环 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logger.info("=" * 60)
|
||||
logger.info("MuseTalk GPU Worker 启动")
|
||||
logger.info(" worker_id = %s", Config.derived_worker_id())
|
||||
logger.info(" api_base = %s", Config.api_base_url)
|
||||
logger.info(" muse_talk = %s", Config.muse_talk_url)
|
||||
logger.info(" poll = %.1fs / heartbeat = %.1fs", Config.poll_interval, Config.heartbeat_interval)
|
||||
logger.info("=" * 60)
|
||||
|
||||
if not Config.gpu_worker_token:
|
||||
logger.warning("GPU_WORKER_TOKEN 未配置(开发模式),生产环境必须设置")
|
||||
|
||||
# 先检查一次 MuseTalk
|
||||
ok, info = _check_musetalk_health()
|
||||
if ok:
|
||||
logger.info("MuseTalk 健康检查通过: %s", info)
|
||||
else:
|
||||
logger.warning("MuseTalk 健康检查未通过: %s(继续运行,等待服务可用)", info)
|
||||
|
||||
# 启动时立即注册
|
||||
_register()
|
||||
last_heartbeat = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 心跳
|
||||
now = time.time()
|
||||
if now - last_heartbeat >= Config.heartbeat_interval:
|
||||
if _register():
|
||||
last_heartbeat = now
|
||||
|
||||
# 轮询任务
|
||||
task = _poll_task()
|
||||
if task is not None:
|
||||
_handle_task(task)
|
||||
# 处理完立即再 poll(不 sleep),尽可能拉满 GPU
|
||||
continue
|
||||
|
||||
time.sleep(Config.poll_interval)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到中断信号,退出")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.exception("主循环异常: %s", exc)
|
||||
time.sleep(Config.poll_interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=MuseTalk Inference API Server
|
||||
After=network.target nvidia-persistenced.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ying
|
||||
WorkingDirectory=/home/ying/projects/MuseTalk
|
||||
Environment=PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
|
||||
Environment=PATH=/home/ying/projects/MuseTalk/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ExecStart=/home/ying/projects/MuseTalk/venv/bin/python /home/ying/projects/MuseTalk/musetalk_server.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=musetalk-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,647 @@
|
||||
"""MuseTalk Flask HTTP 服务 — 反向轮询架构的服务端部分.
|
||||
|
||||
部署在 RTX2060 本地,接收 gpu_worker.py 的推理请求,调用 MuseTalk 生成口型同步视频。
|
||||
本文件修复了原 worker.py 的 8 个工程 bug,并新增 /cancel 端点。
|
||||
|
||||
#1978 性能修复(v2 架构):
|
||||
MuseTalk 原生支持长音频输入(内部循环视频帧),不需要我们先 loop 视频。
|
||||
正确流程:原视频 + 全量音频 → MuseTalk 推理 → 输出时长=音频时长的无声画面
|
||||
→ ffmpeg 快速 -c:v copy 替换音轨。推理时间不变(~14s),后处理几秒。
|
||||
禁止在推理前用 ffmpeg 循环视频(会导致 MuseTalk 处理 2x+ 帧数,慢 16 倍)。
|
||||
|
||||
环境变量:
|
||||
MUSE_PORT 监听端口,默认 7861
|
||||
MUSE_MAX_CONCURRENT 最大并发推理数,默认 1(GPU 一次只能处理一个)
|
||||
MUSE_INFERENCE_TIMEOUT 推理超时秒数,默认 600
|
||||
MUSE_VIDEO_MAX_MB 视频上传大小限制 MB,默认 100
|
||||
MUSE_AUDIO_MAX_MB 音频上传大小限制 MB,默认 20
|
||||
MUSE_DEFAULT_FPS 视频 fps 兜底值,默认 25.0
|
||||
MUSE_TEMP_DIR 临时文件目录,默认 /tmp/musetalk_$$
|
||||
MUSE_VIDEO_ENCODER 循环视频时的编码器(仅兜底):auto(默认)/h264_nvenc/libx264
|
||||
|
||||
接口:
|
||||
GET /health 健康检查 + GPU 显存信息
|
||||
POST /inference 推理请求(multipart: video + audio)
|
||||
POST /cancel 终止当前推理任务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from flask import Flask, jsonify, request, send_file
|
||||
|
||||
# ── 日志 ──────────────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("musetalk-server")
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
v = os.environ.get(name, default)
|
||||
return v.strip() if isinstance(v, str) else default
|
||||
|
||||
|
||||
class Config:
|
||||
port: int = int(_env("MUSE_PORT", "7861"))
|
||||
max_concurrent: int = int(_env("MUSE_MAX_CONCURRENT", "1"))
|
||||
inference_timeout: float = float(_env("MUSE_INFERENCE_TIMEOUT", "600"))
|
||||
video_max_mb: int = int(_env("MUSE_VIDEO_MAX_MB", "100"))
|
||||
audio_max_mb: int = int(_env("MUSE_AUDIO_MAX_MB", "20"))
|
||||
default_fps: float = float(_env("MUSE_DEFAULT_FPS", "25.0"))
|
||||
temp_dir: str = _env("MUSE_TEMP_DIR", f"/tmp/musetalk_{os.getpid()}")
|
||||
# 循环视频时的编码器(仅当 MuseTalk 输出画面短于音频时的兜底)
|
||||
video_encoder: str = _env("MUSE_VIDEO_ENCODER", "auto") or "auto"
|
||||
# 判定音视频时长差异的容差(秒)
|
||||
duration_epsilon: float = 0.25
|
||||
|
||||
|
||||
# ── 全局状态 ──────────────────────────────────────────────────────────
|
||||
inference_lock = threading.Lock()
|
||||
current_task: dict = {"task_id": None, "process": None, "start_time": 0.0}
|
||||
shutdown_event = threading.Event()
|
||||
|
||||
# ── Flask App ─────────────────────────────────────────────────────────
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _cleanup_temp_dir():
|
||||
"""退出时清理临时目录."""
|
||||
if os.path.exists(Config.temp_dir):
|
||||
try:
|
||||
shutil.rmtree(Config.temp_dir)
|
||||
logger.info("已清理临时目录: %s", Config.temp_dir)
|
||||
except Exception as exc:
|
||||
logger.warning("清理临时目录失败: %s", exc)
|
||||
|
||||
|
||||
atexit.register(_cleanup_temp_dir)
|
||||
|
||||
|
||||
def _signal_handler(signum, frame):
|
||||
"""优雅退出."""
|
||||
logger.info("收到信号 %s,准备退出...", signum)
|
||||
shutdown_event.set()
|
||||
if current_task["process"]:
|
||||
logger.info("终止正在进行的推理进程...")
|
||||
try:
|
||||
current_task["process"].terminate()
|
||||
current_task["process"].wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
_cleanup_temp_dir()
|
||||
exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_gpu_info() -> dict:
|
||||
"""获取 GPU 显存信息(通过 nvidia-smi)."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=name,memory.total,memory.used,memory.free",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
)
|
||||
parts = out.decode().strip().split(",")
|
||||
if len(parts) >= 4:
|
||||
return {
|
||||
"gpu_name": parts[0].strip(),
|
||||
"memory_total_mb": int(parts[1].strip()),
|
||||
"memory_used_mb": int(parts[2].strip()),
|
||||
"memory_free_mb": int(parts[3].strip()),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("nvidia-smi 失败: %s", exc)
|
||||
return {"gpu_name": "unknown", "memory_total_mb": 0, "memory_used_mb": 0, "memory_free_mb": 0}
|
||||
|
||||
|
||||
def _get_video_fps(video_path: Path) -> float:
|
||||
"""用 ffprobe 读视频帧率,失败或为 0 时返回 default_fps."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=r_frame_rate",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(video_path),
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
fps_str = out.decode().strip()
|
||||
if "/" in fps_str:
|
||||
num, den = fps_str.split("/")
|
||||
fps = float(num) / float(den) if float(den) != 0 else 0.0
|
||||
else:
|
||||
fps = float(fps_str) if fps_str else 0.0
|
||||
return fps if fps > 0 else Config.default_fps
|
||||
except Exception as exc:
|
||||
logger.warning("ffprobe 读 fps 失败: %s,使用默认 %.1f", exc, Config.default_fps)
|
||||
return Config.default_fps
|
||||
|
||||
|
||||
def _get_media_duration(path: Path) -> float:
|
||||
"""用 ffprobe 读媒体时长(秒),失败返回 0.0."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(path),
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
duration = float(out.decode().strip())
|
||||
return duration if duration > 0 else 0.0
|
||||
except Exception as exc:
|
||||
logger.warning("ffprobe 读时长失败 %s: %s", path, exc)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _pick_video_encoder() -> str:
|
||||
"""选择视频编码器:配置指定则用指定值;auto 时探测 NVENC 是否可用,不可用回退 libx264."""
|
||||
configured = Config.video_encoder.strip()
|
||||
if configured in ("h264_nvenc", "libx264"):
|
||||
return configured
|
||||
# auto:探测本机 ffmpeg 是否编译了 h264_nvenc
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-encoders"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if b"h264_nvenc" in result.stdout:
|
||||
return "h264_nvenc"
|
||||
except Exception as exc:
|
||||
logger.warning("探测 ffmpeg 编码器失败,回退 libx264: %s", exc)
|
||||
return "libx264"
|
||||
|
||||
|
||||
def _mux_video_with_audio(
|
||||
video_path: Path,
|
||||
audio_path: Path,
|
||||
output_path: Path,
|
||||
timeout: float = 300,
|
||||
) -> None:
|
||||
"""把无声画面视频与驱动音频封装为最终结果.
|
||||
|
||||
#1978 v2 架构:MuseTalk 已处理全量音频,输出视频时长=音频时长。
|
||||
此处仅做快速封装:-map 0:v:0 -map 1:a:0 强制取画面+驱动音频,
|
||||
-c:v copy 无损秒级封装(不重编码),-shortest 以较短流为准。
|
||||
|
||||
仅当 MuseTalk 输出画面短于音频时(极端兜底),才启用 -stream_loop + NVENC
|
||||
循环视频到音频长度。正常情况下走 copy 快速路径。
|
||||
"""
|
||||
video_duration = _get_media_duration(video_path)
|
||||
audio_duration = _get_media_duration(audio_path)
|
||||
|
||||
# 判断是否需要兜底循环(正常情况下 MuseTalk 输出已 >= 音频时长)
|
||||
need_loop_fallback = bool(
|
||||
audio_duration > 0 and video_duration > 0 and video_duration < audio_duration - Config.duration_epsilon
|
||||
)
|
||||
|
||||
if need_loop_fallback:
|
||||
# 兜底:MuseTalk 输出画面不足,循环补齐
|
||||
encoder = _pick_video_encoder()
|
||||
preset = "p4" if encoder == "h264_nvenc" else "veryfast"
|
||||
logger.warning(
|
||||
"MuseTalk 输出(%.2fs)短于音频(%.2fs),兜底循环视频以 %s 重编码",
|
||||
video_duration,
|
||||
audio_duration,
|
||||
encoder,
|
||||
)
|
||||
|
||||
def build_cmd(enc: str, pre: str) -> list:
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-c:v",
|
||||
enc,
|
||||
"-preset",
|
||||
pre,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-t",
|
||||
f"{audio_duration:.3f}",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
_run_ffmpeg(build_cmd(encoder, preset), timeout=timeout)
|
||||
except RuntimeError:
|
||||
if encoder == "h264_nvenc":
|
||||
logger.warning("h264_nvenc 兜底失败,回退 libx264 重试")
|
||||
_run_ffmpeg(build_cmd("libx264", "veryfast"), timeout=timeout)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# 正常快速路径:-c:v copy 无损封装,仅替换音轨为驱动音频
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
str(output_path),
|
||||
]
|
||||
_run_ffmpeg(cmd, timeout=timeout)
|
||||
|
||||
|
||||
def _check_file_size(file, max_mb: int, label: str) -> Optional[str]:
|
||||
"""检查文件大小,超限返回错误信息,否则返回 None."""
|
||||
file.seek(0, 2)
|
||||
size = file.tell()
|
||||
file.seek(0)
|
||||
max_bytes = max_mb * 1024 * 1024
|
||||
if size > max_bytes:
|
||||
return f"{label} 文件大小 {size / (1024*1024):.1f}MB 超过限制 {max_mb}MB"
|
||||
if size == 0:
|
||||
return f"{label} 文件为空"
|
||||
return None
|
||||
|
||||
|
||||
def _run_ffmpeg(cmd: list, timeout: float = 120) -> subprocess.CompletedProcess:
|
||||
"""运行 ffmpeg 命令,检查返回码和超时."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=True,
|
||||
)
|
||||
return result
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.decode(errors="ignore") if exc.stderr else ""
|
||||
raise RuntimeError(f"ffmpeg 失败 (code={exc.returncode}): {stderr[:500]}") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"ffmpeg 超时(>{timeout}s)") from exc
|
||||
|
||||
|
||||
def _run_inference(
|
||||
video_path: Path,
|
||||
audio_path: Path,
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
"""执行 MuseTalk 推理(v2 架构:全量音频直传,不在推理前 loop 视频).
|
||||
|
||||
#1978 性能修复核心:
|
||||
MuseTalk 原生支持长音频输入,内部会自动循环视频帧。
|
||||
我们只需把【原视频】和【全量音频】传给 MuseTalk,
|
||||
输出视频时长 = 音频时长(MuseTalk 自行处理帧循环)。
|
||||
禁止在推理前用 ffmpeg 循环视频(会导致慢 16 倍)。
|
||||
|
||||
实际部署时替换为 MuseTalk 真实推理逻辑。
|
||||
此处为示例实现:提取帧 → 模拟 MuseTalk 产出音频时长的无声画面 → 快速封装。
|
||||
"""
|
||||
fps = _get_video_fps(video_path)
|
||||
audio_duration = _get_media_duration(audio_path)
|
||||
video_duration = _get_media_duration(video_path)
|
||||
logger.info(
|
||||
"推理开始: video=%.2fs, audio=%.2fs, fps=%.2f",
|
||||
video_duration,
|
||||
audio_duration,
|
||||
fps,
|
||||
)
|
||||
|
||||
frames_dir = video_path.parent / "frames"
|
||||
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. 从原视频提取帧(仅原视频长度,不循环)
|
||||
_run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-r",
|
||||
str(fps),
|
||||
str(frames_dir / "frame_%05d.png"),
|
||||
],
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
frame_files = sorted(frames_dir.glob("*.png"))
|
||||
if not frame_files:
|
||||
raise RuntimeError("未从视频中提取到帧")
|
||||
|
||||
# 2. 模拟 MuseTalk 推理:输入原视频帧 + 全量音频,输出音频时长的无声画面。
|
||||
# TODO: 替换为 MuseTalk 真实推理逻辑。
|
||||
# MuseTalk 真实调用示例(伪代码):
|
||||
# from musetalk import MuseTalkModel
|
||||
# model = MuseTalkModel(...)
|
||||
# silent_video = model.infer(video_path=video_path, audio_path=audio_path)
|
||||
# # MuseTalk 内部会循环视频帧匹配音频长度,输出时长=音频时长
|
||||
logger.warning("使用示例推理逻辑,未实际调用 MuseTalk 模型")
|
||||
|
||||
# 示例:生成音频时长的无声画面(循环原视频帧到音频长度)
|
||||
# 真实部署时 silent_video_path 应替换为 MuseTalk 输出的无声视频路径
|
||||
silent_video_path = video_path.parent / "visual_silent.mp4"
|
||||
|
||||
if audio_duration > video_duration + Config.duration_epsilon:
|
||||
# 音频更长:循环视频帧到音频长度(仅用于示例,真实 MuseTalk 内部处理)
|
||||
encoder = _pick_video_encoder()
|
||||
preset = "p4" if encoder == "h264_nvenc" else "veryfast"
|
||||
logger.info(
|
||||
"示例:循环视频帧到音频长度 %.2fs(真实 MuseTalk 内部处理,无需此步骤)",
|
||||
audio_duration,
|
||||
)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-an",
|
||||
"-c:v",
|
||||
encoder,
|
||||
"-preset",
|
||||
preset,
|
||||
"-t",
|
||||
f"{audio_duration:.3f}",
|
||||
str(silent_video_path),
|
||||
]
|
||||
try:
|
||||
_run_ffmpeg(cmd, timeout=300)
|
||||
except RuntimeError:
|
||||
if encoder == "h264_nvenc":
|
||||
cmd[cmd.index(encoder)] = "libx264"
|
||||
cmd[cmd.index(preset) + 1] = "veryfast"
|
||||
_run_ffmpeg(cmd, timeout=300)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# 音频不长:直接生成无声视频(原视频长度)
|
||||
_run_ffmpeg(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
str(silent_video_path),
|
||||
],
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# 3. 快速封装:-map 取推理画面 + 驱动音频,-c:v copy 无损秒级封装
|
||||
# MuseTalk 输出已匹配音频长度,此处无需循环,仅替换音轨
|
||||
_mux_video_with_audio(silent_video_path, audio_path, output_path)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size < 1024:
|
||||
raise RuntimeError("推理产物不存在或过小")
|
||||
|
||||
logger.info(
|
||||
"推理完成: output=%.2fs (audio=%.2fs)",
|
||||
_get_media_duration(output_path),
|
||||
audio_duration,
|
||||
)
|
||||
|
||||
|
||||
# ── 路由 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""健康检查 + GPU 显存信息."""
|
||||
gpu_info = _get_gpu_info()
|
||||
task_info = {
|
||||
"task_id": current_task["task_id"],
|
||||
"running": current_task["process"] is not None,
|
||||
"elapsed_seconds": time.time() - current_task["start_time"] if current_task["start_time"] else 0.0,
|
||||
}
|
||||
return jsonify(
|
||||
{
|
||||
"status": "healthy",
|
||||
"gpu": gpu_info,
|
||||
"current_task": task_info,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/inference", methods=["POST"])
|
||||
def inference():
|
||||
"""推理请求:multipart form 包含 video 和 audio 文件.
|
||||
|
||||
#1978 v2:MuseTalk 直接处理全量音频,输出时长=音频时长,无需预处理循环。
|
||||
"""
|
||||
# 并发控制:检查锁
|
||||
if not inference_lock.acquire(blocking=False):
|
||||
return jsonify({"error": "GPU 正在处理其他任务,请稍后重试", "status": "busy"}), 503
|
||||
|
||||
task_id = None
|
||||
video_path = None
|
||||
audio_path = None
|
||||
output_path = None
|
||||
|
||||
try:
|
||||
# 解析参数
|
||||
if "video" not in request.files or "audio" not in request.files:
|
||||
return jsonify({"error": "缺少 video 或 audio 文件"}), 400
|
||||
|
||||
video_file = request.files["video"]
|
||||
audio_file = request.files["audio"]
|
||||
task_id = request.form.get("task_id", f"task_{int(time.time())}")
|
||||
|
||||
# 文件大小检查
|
||||
err = _check_file_size(video_file, Config.video_max_mb, "视频")
|
||||
if err:
|
||||
return jsonify({"error": err}), 413
|
||||
err = _check_file_size(audio_file, Config.audio_max_mb, "音频")
|
||||
if err:
|
||||
return jsonify({"error": err}), 413
|
||||
|
||||
# 保存到临时目录
|
||||
task_dir = Path(Config.temp_dir) / task_id
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
video_path = task_dir / "input.mp4"
|
||||
audio_path = task_dir / "input_audio.wav"
|
||||
output_path = task_dir / "output.mp4"
|
||||
|
||||
video_file.save(str(video_path))
|
||||
audio_file.save(str(audio_path))
|
||||
|
||||
logger.info("开始推理 task_id=%s, video=%s, audio=%s", task_id, video_path.name, audio_path.name)
|
||||
|
||||
# 更新当前任务信息
|
||||
current_task["task_id"] = task_id
|
||||
current_task["start_time"] = time.time()
|
||||
current_task["process"] = "inference_thread" # 标记为运行中
|
||||
|
||||
# 在线程中运行推理(支持超时)
|
||||
result_container = {"error": None}
|
||||
|
||||
def inference_thread():
|
||||
try:
|
||||
_run_inference(video_path, audio_path, output_path)
|
||||
except Exception as exc:
|
||||
result_container["error"] = str(exc)
|
||||
|
||||
thread = threading.Thread(target=inference_thread)
|
||||
thread.start()
|
||||
thread.join(timeout=Config.inference_timeout)
|
||||
|
||||
if thread.is_alive():
|
||||
# 超时,终止
|
||||
logger.error("推理超时 (>%ds),终止任务 %s", Config.inference_timeout, task_id)
|
||||
return jsonify({"error": f"推理超时(>{Config.inference_timeout}s)", "task_id": task_id}), 504
|
||||
|
||||
if result_container["error"]:
|
||||
logger.error("推理失败 task_id=%s: %s", task_id, result_container["error"])
|
||||
return jsonify({"error": result_container["error"], "task_id": task_id}), 500
|
||||
|
||||
# 返回结果文件
|
||||
logger.info("推理完成 task_id=%s, output=%s", task_id, output_path)
|
||||
return send_file(str(output_path), mimetype="video/mp4", as_attachment=True, download_name=f"{task_id}.mp4")
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("推理异常: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
finally:
|
||||
# 释放锁,清理当前任务信息
|
||||
inference_lock.release()
|
||||
current_task["task_id"] = None
|
||||
current_task["process"] = None
|
||||
current_task["start_time"] = 0.0
|
||||
|
||||
# 清理临时文件
|
||||
if video_path and video_path.parent.exists():
|
||||
try:
|
||||
shutil.rmtree(video_path.parent)
|
||||
logger.info("已清理临时目录: %s", video_path.parent)
|
||||
except Exception as exc:
|
||||
logger.warning("清理临时目录失败: %s", exc)
|
||||
|
||||
|
||||
@app.route("/cancel", methods=["POST"])
|
||||
def cancel():
|
||||
"""终止当前正在进行的推理任务."""
|
||||
if current_task["task_id"] is None:
|
||||
return jsonify({"message": "当前无正在运行的任务"})
|
||||
|
||||
task_id = current_task["task_id"]
|
||||
logger.info("收到取消请求,终止任务 %s", task_id)
|
||||
|
||||
# 终止推理进程(如果是 subprocess)
|
||||
if current_task["process"] and current_task["process"] != "inference_thread":
|
||||
try:
|
||||
current_task["process"].terminate()
|
||||
current_task["process"].wait(timeout=5)
|
||||
logger.info("已终止推理进程")
|
||||
except Exception as exc:
|
||||
logger.warning("终止进程失败: %s", exc)
|
||||
|
||||
# 清理临时文件
|
||||
task_dir = Path(Config.temp_dir) / task_id
|
||||
if task_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(task_dir)
|
||||
logger.info("已清理临时目录: %s", task_dir)
|
||||
except Exception as exc:
|
||||
logger.warning("清理临时目录失败: %s", exc)
|
||||
|
||||
# 重置当前任务
|
||||
current_task["task_id"] = None
|
||||
current_task["process"] = None
|
||||
current_task["start_time"] = 0.0
|
||||
|
||||
return jsonify({"message": f"已取消任务 {task_id}"})
|
||||
|
||||
|
||||
# ── 主入口 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main():
|
||||
"""启动 Flask 服务."""
|
||||
# 创建临时目录
|
||||
Path(Config.temp_dir).mkdir(parents=True, exist_ok=True)
|
||||
logger.info("临时目录: %s", Config.temp_dir)
|
||||
|
||||
gpu_info = _get_gpu_info()
|
||||
logger.info(
|
||||
"GPU: %s (显存 %dMB / %dMB)",
|
||||
gpu_info["gpu_name"],
|
||||
gpu_info["memory_used_mb"],
|
||||
gpu_info["memory_total_mb"],
|
||||
)
|
||||
logger.info(
|
||||
"启动 MuseTalk Server: port=%d, timeout=%.0fs, max_concurrent=%d",
|
||||
Config.port,
|
||||
Config.inference_timeout,
|
||||
Config.max_concurrent,
|
||||
)
|
||||
|
||||
app.run(host="0.0.0.0", port=Config.port, threaded=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
REPO_API="https://git.xiaoxiajianji.com/api/v1/repos/xiaoxia/xiaoxia-saas/commits?sha=develop&path=deploy/gpu_worker&limit=1"
|
||||
STATE_FILE="/home/ying/projects/gpu-webhook/.last_commit"
|
||||
UPDATE_SCRIPT="/home/ying/projects/update-gpu-worker.sh"
|
||||
LOG_FILE="/tmp/gpu-poll.log"
|
||||
|
||||
log() {
|
||||
echo "[$(date +"%Y-%m-%d %H:%M:%S")] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
LATEST_SHA=$(curl -sk --max-time 10 "$REPO_API" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
print(data[0].get('sha', ''))
|
||||
else:
|
||||
print('')
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$LATEST_SHA" ]; then
|
||||
log "get latest commit failed, skip"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LAST_SHA=""
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
LAST_SHA=$(cat "$STATE_FILE")
|
||||
fi
|
||||
|
||||
if [ "$LATEST_SHA" = "$LAST_SHA" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$LAST_SHA" ]; then
|
||||
echo "$LATEST_SHA" > "$STATE_FILE"
|
||||
log "first run, recording SHA: $LATEST_SHA"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "new commit detected: $LAST_SHA -> $LATEST_SHA, triggering update"
|
||||
echo "$LATEST_SHA" > "$STATE_FILE"
|
||||
bash "$UPDATE_SCRIPT" >> "$LOG_FILE" 2>&1
|
||||
log "update completed"
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# GPU节点一键初始化脚本 - 在全新GPU机器上执行
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== 1. 安装系统依赖 ==="
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq python3 python3-pip python3-venv ffmpeg wget curl git
|
||||
|
||||
echo "=== 2. 创建目录 ==="
|
||||
mkdir -p ~/projects/MuseTalk ~/projects/gpu-webhook /opt/xiaoxia-gpu-worker
|
||||
|
||||
echo "=== 3. 安装nvidia-container-toolkit(如需要Docker)==="
|
||||
# 可选,当前不使用Docker,跳过
|
||||
# distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
|
||||
# curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
|
||||
# curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
|
||||
# sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||
# sudo nvidia-ctk runtime configure --runtime=docker
|
||||
# sudo systemctl restart docker
|
||||
|
||||
echo "=== 4. 拉取服务配置和脚本 ==="
|
||||
REPO_URL="https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker"
|
||||
wget -q -O /tmp/musetalk-worker.service "$REPO_URL/musetalk-worker.service"
|
||||
wget -q -O /tmp/gpu-poll.service "$REPO_URL/gpu-poll.service"
|
||||
wget -q -O /tmp/gpu-poll.timer "$REPO_URL/gpu-poll.timer"
|
||||
wget -q -O ~/projects/update-gpu-worker.sh "$REPO_URL/scripts/update-gpu-worker.sh"
|
||||
wget -q -O ~/projects/gpu-webhook/poll_and_update.sh "$REPO_URL/scripts/poll_and_update.sh"
|
||||
chmod +x ~/projects/update-gpu-worker.sh ~/projects/gpu-webhook/poll_and_update.sh
|
||||
|
||||
echo "=== 5. 安装systemd服务 ==="
|
||||
sudo cp /tmp/musetalk-worker.service /etc/systemd/system/
|
||||
sudo cp /tmp/gpu-poll.service /etc/systemd/system/
|
||||
sudo cp /tmp/gpu-poll.timer /etc/systemd/system/
|
||||
|
||||
echo "=== 6. 配置sudo免密 ==="
|
||||
sudo bash -c 'cat > /etc/sudoers.d/ying-gpu-update << EOF
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl restart musetalk-worker
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl restart xiaoxia-gpu-worker
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl status musetalk-worker
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl status xiaoxia-gpu-worker
|
||||
ying ALL=(ALL) NOPASSWD: /bin/systemctl daemon-reload
|
||||
ying ALL=(ALL) NOPASSWD: /usr/bin/journalctl
|
||||
ying ALL=(ALL) NOPASSWD: /bin/cp
|
||||
ying ALL=(ALL) NOPASSWD: /bin/chmod
|
||||
ying ALL=(ALL) NOPASSWD: /usr/bin/tee
|
||||
EOF'
|
||||
sudo chmod 440 /etc/sudoers.d/ying-gpu-update
|
||||
|
||||
echo "=== 7. 首次拉取代码并启动服务 ==="
|
||||
bash ~/projects/update-gpu-worker.sh
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable musetalk-worker xiaoxia-gpu-worker gpu-poll.timer
|
||||
sudo systemctl start musetalk-worker xiaoxia-gpu-worker gpu-poll.timer
|
||||
|
||||
echo "=== 完成! ==="
|
||||
echo "检查服务状态:"
|
||||
echo " sudo systemctl status musetalk-worker"
|
||||
echo " sudo systemctl status xiaoxia-gpu-worker"
|
||||
echo " sudo systemctl status gpu-poll.timer"
|
||||
echo "健康检查:curl http://127.0.0.1:7861/health"
|
||||
echo "更新日志:tail -f /tmp/gpu-worker-update.log"
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
REPO_URL="https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/raw/branch/develop/deploy/gpu_worker"
|
||||
MUSE_DIR="/home/ying/projects/MuseTalk"
|
||||
WORKER_DIR="/opt/xiaoxia-gpu-worker"
|
||||
LOG_FILE="/tmp/gpu-worker-update.log"
|
||||
|
||||
log() {
|
||||
local NOW
|
||||
NOW=$(date +"%Y-%m-%d %H:%M:%S")
|
||||
echo "[$NOW] $*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log "========== start update =========="
|
||||
|
||||
BAK_SUFFIX=$(date +"%Y%m%d%H%M%S")
|
||||
cp "$MUSE_DIR/musetalk_server.py" "$MUSE_DIR/musetalk_server.py.bak.$BAK_SUFFIX"
|
||||
cp "$WORKER_DIR/gpu_worker.py" "$WORKER_DIR/gpu_worker.py.bak.$BAK_SUFFIX"
|
||||
log "backup done ($BAK_SUFFIX)"
|
||||
|
||||
wget -q -O "$MUSE_DIR/musetalk_server.py" "$REPO_URL/musetalk_server.py"
|
||||
log "musetalk_server.py updated"
|
||||
|
||||
wget -q -O "$WORKER_DIR/gpu_worker.py" "$REPO_URL/gpu_worker.py"
|
||||
log "gpu_worker.py updated"
|
||||
|
||||
wget -q -O /tmp/gpu-requirements.txt "$REPO_URL/requirements.txt"
|
||||
if [ -f "$WORKER_DIR/requirements.txt" ] && ! diff -q "$WORKER_DIR/requirements.txt" /tmp/gpu-requirements.txt > /dev/null 2>&1; then
|
||||
log "requirements changed, updating..."
|
||||
cp /tmp/gpu-requirements.txt "$WORKER_DIR/requirements.txt"
|
||||
"$WORKER_DIR/venv/bin/pip" install -r "$WORKER_DIR/requirements.txt" -q
|
||||
log "pip install done"
|
||||
else
|
||||
log "requirements no change, skip pip"
|
||||
fi
|
||||
|
||||
sudo systemctl restart musetalk-worker
|
||||
log "musetalk restarted"
|
||||
sleep 5
|
||||
|
||||
sudo systemctl restart xiaoxia-gpu-worker
|
||||
log "gpu-worker restarted"
|
||||
sleep 8
|
||||
|
||||
HEALTH=$(curl -s http://127.0.0.1:7861/health 2>/dev/null)
|
||||
if echo "$HEALTH" | grep -q "healthy\|ok"; then
|
||||
log "health check OK"
|
||||
log "========== update done =========="
|
||||
exit 0
|
||||
else
|
||||
log "health check FAILED, rolling back..."
|
||||
LATEST_MUSE_BAK=$(ls -t "$MUSE_DIR/musetalk_server.py.bak."* 2>/dev/null | head -1)
|
||||
LATEST_WORKER_BAK=$(ls -t "$WORKER_DIR/gpu_worker.py.bak."* 2>/dev/null | head -1)
|
||||
[ -n "$LATEST_MUSE_BAK" ] && cp "$LATEST_MUSE_BAK" "$MUSE_DIR/musetalk_server.py"
|
||||
[ -n "$LATEST_WORKER_BAK" ] && cp "$LATEST_WORKER_BAK" "$WORKER_DIR/gpu_worker.py"
|
||||
sudo systemctl restart musetalk-worker
|
||||
sleep 5
|
||||
sudo systemctl restart xiaoxia-gpu-worker
|
||||
log "rolled back"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=MuseTalk GPU Worker (xiaoxia-saas 反向轮询)
|
||||
After=network.target musetalk.service
|
||||
# 本地 MuseTalk 服务启动后再启动本 Worker;若 MuseTalk 没有 systemd 服务则删除 musetalk.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%i
|
||||
WorkingDirectory=/opt/xiaoxia-gpu-worker
|
||||
# 读取环境变量(API 地址、Token、轮询间隔等)
|
||||
EnvironmentFile=/opt/xiaoxia-gpu-worker/.env
|
||||
ExecStart=/opt/xiaoxia-gpu-worker/venv/bin/python /opt/xiaoxia-gpu-worker/gpu_worker.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# 日志走 journal,用 journalctl -u xiaoxia-gpu-worker -f 查看
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=xiaoxia-gpu-worker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,138 @@
|
||||
"""素材原子片段仓储 SQLAlchemy 实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
|
||||
|
||||
class SQLAlchemyAssetAtomClipRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, clip: AssetAtomClip) -> AssetAtomClip:
|
||||
model = self._to_model(clip)
|
||||
self.session.add(model)
|
||||
self.session.flush()
|
||||
self.session.commit()
|
||||
return clip
|
||||
|
||||
def batch_create(self, clips: list[AssetAtomClip]) -> list[AssetAtomClip]:
|
||||
if not clips:
|
||||
return []
|
||||
models = [self._to_model(c) for c in clips]
|
||||
self.session.add_all(models)
|
||||
self.session.flush()
|
||||
self.session.commit()
|
||||
return clips
|
||||
|
||||
def find_by_asset(self, asset_id: str) -> list[AssetAtomClip]:
|
||||
models = (
|
||||
self.session.query(AssetAtomClipModel)
|
||||
.filter(AssetAtomClipModel.asset_id == asset_id)
|
||||
.order_by(AssetAtomClipModel.clip_index.asc())
|
||||
.all()
|
||||
)
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def find_by_id(self, clip_id: str) -> AssetAtomClip | None:
|
||||
model = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def find_by_ids(self, clip_ids: list[str]) -> list[AssetAtomClip]:
|
||||
if not clip_ids:
|
||||
return []
|
||||
models = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id.in_(clip_ids)).all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def delete_by_asset(self, asset_id: str) -> int:
|
||||
count = (
|
||||
self.session.query(AssetAtomClipModel)
|
||||
.filter(AssetAtomClipModel.asset_id == asset_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def count_by_asset(self, asset_id: str) -> int:
|
||||
return self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.asset_id == asset_id).count()
|
||||
|
||||
def find_candidates_for_selection(
|
||||
self,
|
||||
asset_ids: list[str],
|
||||
*,
|
||||
min_duration: float | None = None,
|
||||
max_duration: float | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[AssetAtomClip]:
|
||||
"""按筛选条件查找候选原子片段,按时长排序。用于选片逻辑。"""
|
||||
query = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.asset_id.in_(asset_ids))
|
||||
if min_duration is not None:
|
||||
query = query.filter(AssetAtomClipModel.duration >= min_duration)
|
||||
if max_duration is not None:
|
||||
query = query.filter(AssetAtomClipModel.duration <= max_duration)
|
||||
query = query.order_by(AssetAtomClipModel.clip_index.asc())
|
||||
if limit > 0:
|
||||
query = query.limit(limit)
|
||||
models = query.all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def update_ai_tags(self, clip_id: str, ai_tags: dict) -> bool:
|
||||
"""更新指定片段的 ai_tags 字段."""
|
||||
count = (
|
||||
self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).update({"ai_tags": ai_tags})
|
||||
)
|
||||
self.session.commit()
|
||||
return count > 0
|
||||
|
||||
def find_untagged(self, limit: int = 100, include_downgraded: bool = False) -> list[AssetAtomClip]:
|
||||
"""查找未完成 AI 打标的片段,用于回填.
|
||||
|
||||
默认仅匹配 ai_tags IS NULL;include_downgraded=True 时额外包含
|
||||
只有 inherited_tags 的降级记录(视觉 API 失败时写入,无 has_text 字段),
|
||||
供强制回填(#1970 force backfill)使用。
|
||||
"""
|
||||
query = self.session.query(AssetAtomClipModel)
|
||||
if include_downgraded:
|
||||
# as_string() → JSON/JSONB ->> 取值;NULL 记录或缺 has_text 键
|
||||
# (降级记录)均为 NULL,has_text 为 true/false 的完整记录被排除
|
||||
query = query.filter(AssetAtomClipModel.ai_tags["has_text"].as_string().is_(None))
|
||||
else:
|
||||
query = query.filter(AssetAtomClipModel.ai_tags.is_(None))
|
||||
models = query.order_by(AssetAtomClipModel.created_at.asc()).limit(limit).all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def _to_model(self, clip: AssetAtomClip) -> AssetAtomClipModel:
|
||||
return AssetAtomClipModel(
|
||||
id=clip.id,
|
||||
asset_id=clip.asset_id,
|
||||
start_time=clip.start_time,
|
||||
end_time=clip.end_time,
|
||||
duration=clip.duration,
|
||||
clip_index=clip.clip_index,
|
||||
tags=clip.tags,
|
||||
ai_tags=clip.ai_tags,
|
||||
scene_change_at=clip.scene_change_at,
|
||||
is_fallback=clip.is_fallback,
|
||||
created_at=clip.created_at or datetime.now(UTC),
|
||||
)
|
||||
|
||||
def _to_domain(self, model: AssetAtomClipModel) -> AssetAtomClip:
|
||||
return AssetAtomClip(
|
||||
id=model.id,
|
||||
asset_id=model.asset_id,
|
||||
start_time=model.start_time,
|
||||
end_time=model.end_time,
|
||||
duration=model.duration,
|
||||
clip_index=model.clip_index,
|
||||
tags=model.tags or [],
|
||||
scene_change_at=model.scene_change_at,
|
||||
is_fallback=model.is_fallback,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
@@ -50,6 +50,7 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
order=clip.order,
|
||||
template_clip_config_id=clip.template_clip_config_id,
|
||||
asset_id=clip.asset_id,
|
||||
atom_clip_id=getattr(clip, "atom_clip_id", "") or "",
|
||||
text_content=clip.text_content,
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
@@ -74,6 +75,7 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
model.order = clip.order
|
||||
model.template_clip_config_id = clip.template_clip_config_id
|
||||
model.asset_id = clip.asset_id
|
||||
model.atom_clip_id = getattr(clip, "atom_clip_id", "") or ""
|
||||
model.text_content = clip.text_content
|
||||
model.start_time = clip.start_time
|
||||
model.duration = clip.duration
|
||||
@@ -120,6 +122,7 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
order=model.order,
|
||||
template_clip_config_id=model.template_clip_config_id or "",
|
||||
asset_id=model.asset_id or "",
|
||||
atom_clip_id=getattr(model, "atom_clip_id", "") or "",
|
||||
text_content=model.text_content or "",
|
||||
start_time=model.start_time or 0.0,
|
||||
duration=model.duration or 0.0,
|
||||
@@ -193,3 +196,53 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
result[asset_id].append((start_time or 0.0, (start_time or 0.0) + (duration or 0.0)))
|
||||
|
||||
return result
|
||||
|
||||
def list_recent_atom_clip_ids_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
limit: int = 200,
|
||||
) -> list[str]:
|
||||
"""#1970 跨视频原子片段级避让:查询用户最近成片用过的 atom_clip_id.
|
||||
|
||||
只统计已完成 plan 下已渲染且 atom_clip_id 非空的 clips,按 plan
|
||||
创建时间倒序,返回去重后的 ID 列表。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
if not user_id:
|
||||
return []
|
||||
|
||||
recent_plan_ids = [
|
||||
row[0]
|
||||
for row in self.session.query(EditPlanModel.id)
|
||||
.filter(
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
EditPlanModel.status == "completed",
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
]
|
||||
if not recent_plan_ids:
|
||||
return []
|
||||
|
||||
rows = (
|
||||
self.session.query(EditPlanClipModel.atom_clip_id)
|
||||
.filter(
|
||||
EditPlanClipModel.plan_id.in_(recent_plan_ids),
|
||||
EditPlanClipModel.status == "rendered",
|
||||
EditPlanClipModel.atom_clip_id.isnot(None),
|
||||
EditPlanClipModel.atom_clip_id != "",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for (atom_clip_id,) in rows:
|
||||
if atom_clip_id and atom_clip_id not in seen:
|
||||
seen.add(atom_clip_id)
|
||||
ordered.append(atom_clip_id)
|
||||
if len(ordered) >= limit:
|
||||
break
|
||||
return ordered
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Index, Integer, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base: Any = declarative_base()
|
||||
@@ -234,6 +247,8 @@ class EditPlanClipModel(Base):
|
||||
order = Column(Integer, nullable=False)
|
||||
template_clip_config_id = Column(String(36), nullable=False, default="", index=True)
|
||||
asset_id = Column(String(36), nullable=False, default="", index=True)
|
||||
# #1970 原子化切片:片段选中的原子片段 ID(空串表示旧的整条素材选取路径)
|
||||
atom_clip_id = Column(String(36), nullable=False, default="", index=True)
|
||||
text_content = Column(Text, nullable=False, default="")
|
||||
start_time = Column(Float, nullable=False, default=0.0)
|
||||
duration = Column(Float, nullable=False, default=0.0)
|
||||
@@ -801,6 +816,33 @@ class PointsOrderModel(Base):
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AssetAtomClipModel(Base):
|
||||
"""素材原子片段 ORM 模型 (#1970 智能剪辑流程重构)。
|
||||
|
||||
逻辑切分单元,不物理切割视频文件。
|
||||
"""
|
||||
|
||||
__tablename__ = "asset_atom_clips"
|
||||
__table_args__ = (UniqueConstraint("asset_id", "clip_index", name="uq_asset_atom_clips_asset_index"),)
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
asset_id = Column(
|
||||
String(36),
|
||||
ForeignKey("assets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
start_time = Column(Float, nullable=False)
|
||||
end_time = Column(Float, nullable=False)
|
||||
duration = Column(Float, nullable=False)
|
||||
clip_index = Column(Integer, nullable=False)
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
ai_tags = 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))
|
||||
|
||||
|
||||
class DailyUsageRecordModel(Base):
|
||||
"""每日使用记录 ORM 模型 (#1895)"""
|
||||
|
||||
@@ -813,3 +855,61 @@ class DailyUsageRecordModel(Base):
|
||||
usage_type = Column(String(50), nullable=False, default="free_clip")
|
||||
count = Column(Integer, nullable=False, default=0)
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class GpuLipsyncTaskModel(Base):
|
||||
"""GPU 口型同步任务 ORM 模型 — MuseTalk 反向轮询模式.
|
||||
|
||||
业务侧(AI 数字人生成/lipsync 流程)提交任务后,GPU Worker 主动 poll 拉取、
|
||||
调用本地 MuseTalk 推理、再通过 result 接口回传结果视频。
|
||||
"""
|
||||
|
||||
__tablename__ = "gpu_lipsync_tasks"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
# 业务关联(原 lipsync_job_id,方便双向查询)
|
||||
lipsync_job_id = Column(String(36), nullable=False, default="", index=True)
|
||||
user_id = Column(String(36), nullable=False, default="", index=True)
|
||||
project_id = Column(String(36), nullable=False, default="", index=True)
|
||||
|
||||
# 输入(预签名下载 URL,由 API 侧生成)
|
||||
video_url = Column(Text, nullable=False)
|
||||
audio_url = Column(Text, nullable=False)
|
||||
|
||||
# 结果
|
||||
result_url = Column(Text, nullable=False, default="")
|
||||
result_duration = Column(Float, nullable=False, default=0.0)
|
||||
|
||||
# 任务状态
|
||||
status = Column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
index=True,
|
||||
) # pending → processing → done / failed / timeout
|
||||
worker_id = Column(String(100), nullable=False, default="", index=True)
|
||||
attempt = Column(Integer, nullable=False, default=0)
|
||||
error_msg = Column(Text, nullable=False, default="")
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
# 心跳:worker 最近一次 poll/result 的时间,用于判定 worker 失联
|
||||
last_heartbeat_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class GpuWorkerModel(Base):
|
||||
"""GPU Worker 注册表 — 反向轮询模式下用于心跳与监控."""
|
||||
|
||||
__tablename__ = "gpu_workers"
|
||||
|
||||
worker_id = Column(String(100), primary_key=True)
|
||||
hostname = Column(String(200), nullable=False, default="")
|
||||
gpu_name = Column(String(200), nullable=False, default="")
|
||||
free_vram_mb = Column(Integer, nullable=False, default=0)
|
||||
capabilities = Column(String(500), nullable=False, default="") # 逗号分隔,如 "musetalk"
|
||||
last_heartbeat_at = Column(DateTime, nullable=True, index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
+52
-3
@@ -8,6 +8,7 @@ API 和 Worker 各自的 Settings 类继承本类,只追加服务特有字段
|
||||
import os
|
||||
from typing import Optional, TypeVar
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
T = TypeVar("T", bound=BaseSettings)
|
||||
@@ -68,6 +69,7 @@ class SharedSettings(BaseSettings):
|
||||
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
doubao_vision_model: str = "doubao-1-5-vision-pro-250915"
|
||||
|
||||
# ── MediaKit (火山引擎 AI 媒体工具) ──────────────────────────────────
|
||||
mediakit_api_key: str = ""
|
||||
@@ -75,9 +77,56 @@ class SharedSettings(BaseSettings):
|
||||
mediakit_timeout: int = 60
|
||||
|
||||
# ── 积分/会员系统 (#1895) ────────────────────────────────────────────
|
||||
# 总开关:默认 false(对所有用户零影响),P2 路由逐个接入时用
|
||||
# `if settings.points_enabled:` 包裹,防止未完善的扣点逻辑影响现有用户。
|
||||
points_enabled: bool = False
|
||||
# 积分系统总开关(产品要求 #1895:暂停积分系统但保留全部代码/表/接口)。
|
||||
# - false(默认):所有 AI 功能(生成视频/口型/数字人/AI标题/TTS/克隆音色…)
|
||||
# 对全部登录用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员
|
||||
# 状态等查询接口保持可用,但数据不再变动。
|
||||
# - 未来恢复:只需设置环境变量 ENABLE_CREDIT_SYSTEM=true。
|
||||
# 旧开关 POINTS_ENABLED 仍保留作为兼容别名(两者任一为 true 即启用)。
|
||||
# 主开关(推荐环境变量名 ENABLE_CREDIT_SYSTEM)
|
||||
credits_enabled: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("ENABLE_CREDIT_SYSTEM", "credits_enabled"),
|
||||
)
|
||||
# 旧开关兼容(POINTS_ENABLED);两者任一为 true 即启用
|
||||
points_enabled_compat: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("POINTS_ENABLED", "points_enabled_compat"),
|
||||
)
|
||||
|
||||
@property
|
||||
def points_enabled(self) -> bool:
|
||||
"""旧代码/测试使用的属性名,等价于积分系统总开关(兼容别名)。"""
|
||||
return bool(self.credits_enabled or self.points_enabled_compat)
|
||||
|
||||
@points_enabled.setter
|
||||
def points_enabled(self, value: bool) -> None:
|
||||
# 支持旧测试/代码 ``settings.points_enabled = True`` 的写法
|
||||
self.credits_enabled = bool(value)
|
||||
self.points_enabled_compat = False
|
||||
|
||||
# ── GPU MuseTalk 反向轮询 Worker ────────────────────────────────────
|
||||
# Worker 用这个长期 Token 鉴权(不是用户 JWT)。多 Worker 共用同一个 Token;
|
||||
# worker_id 用于区分具体机器。生产必须配置;development 留空会跳过校验。
|
||||
gpu_worker_token: str = ""
|
||||
# GPU 任务超时(秒):processing 状态超过此时长(以任务心跳为准)才回退
|
||||
# pending / failed。#1970:RTX2060 6G 推理 720p 长视频需 5 分钟以上,300→900。
|
||||
# Worker 推理期间每 30s 通过 /gpu/register(task_id=...) 续心跳,
|
||||
# 只有真正超时或 Worker 明确上报 failed 才会回退。
|
||||
gpu_task_timeout_seconds: int = 900
|
||||
# 结果预签名 URL 有效期(秒)
|
||||
gpu_result_url_expires: int = 3600
|
||||
# 输入预签名 URL 有效期(秒,需留出 Worker 下载时间)
|
||||
gpu_input_url_expires: int = 3600
|
||||
# 业务侧是否启用 GPU 口型同步(开关);关或无可用 Worker 时回退 MediaKit 云端
|
||||
use_gpu_lipsync: bool = False
|
||||
# 业务侧轮询 GPU 任务结果的间隔(秒)
|
||||
gpu_lipsync_poll_interval: float = 5.0
|
||||
# 业务侧等待 GPU 任务结果的总超时(秒);超时后回退 MediaKit。
|
||||
# 应小于等于 gpu_task_timeout_seconds(默认900s)+ 冗余,留足 Worker 下载/上传时间。
|
||||
gpu_lipsync_wait_timeout: int = 1200
|
||||
# 判断 Worker 可用的心跳新鲜度窗口(秒)—— last_heartbeat_at 在窗口内视为在线
|
||||
gpu_worker_stale_seconds: int = 300
|
||||
|
||||
@property
|
||||
def effective_database_url(self) -> str:
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
"""Domain package for core business entities and rules."""
|
||||
|
||||
from . import atom_clip_resolver
|
||||
from .asset_atom_clip import AssetAtomClip
|
||||
from .atom_clip_selector import (
|
||||
ScoredAtomClip,
|
||||
clips_to_segments,
|
||||
estimate_required_clip_count,
|
||||
score_atom_clip,
|
||||
select_atom_clips,
|
||||
)
|
||||
from .atom_clip_service import (
|
||||
compute_atom_clips,
|
||||
compute_fallback_clips,
|
||||
)
|
||||
from .classification import (
|
||||
AssetClassification,
|
||||
ClassificationJob,
|
||||
@@ -37,6 +50,15 @@ from .voice_library import VoiceLibraryItem
|
||||
|
||||
__all__ = [
|
||||
"Asset",
|
||||
"AssetAtomClip",
|
||||
"ScoredAtomClip",
|
||||
"clips_to_segments",
|
||||
"compute_atom_clips",
|
||||
"compute_fallback_clips",
|
||||
"estimate_required_clip_count",
|
||||
"score_atom_clip",
|
||||
"select_atom_clips",
|
||||
"atom_clip_resolver",
|
||||
"AssetClassification",
|
||||
"DailyUsageRecord",
|
||||
"PointsAccount",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""素材原子片段(Atom Clip)领域实体 — #1970 智能剪辑流程重构。
|
||||
|
||||
原子片段是素材的逻辑切分单元,不物理切割视频文件。
|
||||
每条记录指向某条素材的一段 [start_time, end_time] 区间。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetAtomClip:
|
||||
"""素材原子片段。
|
||||
|
||||
Attributes:
|
||||
id: 唯一标识。
|
||||
asset_id: 所属素材 ID。
|
||||
start_time: 片段起始时间(秒,浮点)。
|
||||
end_time: 片段结束时间(秒,浮点)。
|
||||
duration: 片段时长 = end_time - start_time(秒)。
|
||||
clip_index: 在同一素材内的顺序编号(从 0 开始)。
|
||||
tags: 继承自素材的标签,JSONB 存储,可为空列表。
|
||||
scene_change_at: 片段尾部是否对齐了 scdet 镜头切换点(存储该切点的精确时间),
|
||||
未对齐时为 None。
|
||||
is_fallback: 是否为兜底逻辑在内存中生成的临时片段(不入库)。
|
||||
created_at: 创建时间。
|
||||
"""
|
||||
|
||||
id: str
|
||||
asset_id: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
duration: float
|
||||
clip_index: int
|
||||
tags: list[str] = field(default_factory=list)
|
||||
ai_tags: dict | None = None
|
||||
scene_change_at: float | None = None
|
||||
is_fallback: bool = False
|
||||
created_at: datetime | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.id:
|
||||
self.id = str(uuid.uuid4())
|
||||
if self.duration <= 0:
|
||||
self.duration = round(self.end_time - self.start_time, 3)
|
||||
if self.duration < 0:
|
||||
raise ValueError(f"duration must be >= 0, got start={self.start_time}, end={self.end_time}")
|
||||
if self.start_time < 0:
|
||||
raise ValueError(f"start_time must be >= 0, got {self.start_time}")
|
||||
if self.end_time <= self.start_time:
|
||||
raise ValueError(f"end_time must be > start_time, got start={self.start_time}, end={self.end_time}")
|
||||
if self.clip_index < 0:
|
||||
raise ValueError(f"clip_index must be >= 0, got {self.clip_index}")
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now(UTC)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
asset_id: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
clip_index: int,
|
||||
tags: list[str] | None = None,
|
||||
scene_change_at: float | None = None,
|
||||
is_fallback: bool = False,
|
||||
) -> AssetAtomClip:
|
||||
"""工厂方法:创建一个新的原子片段。"""
|
||||
return cls(
|
||||
id="", # __post_init__ 会自动生成
|
||||
asset_id=asset_id,
|
||||
start_time=round(start_time, 3),
|
||||
end_time=round(end_time, 3),
|
||||
duration=round(end_time - start_time, 3),
|
||||
clip_index=clip_index,
|
||||
tags=tags or [],
|
||||
scene_change_at=scene_change_at,
|
||||
is_fallback=is_fallback,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""原子片段加载与兜底 — #1970 智能剪辑流程重构 P1.
|
||||
|
||||
选片前从 ``asset_atom_clips`` 表加载素材池的原子片段;老素材/切片任务尚未
|
||||
完成/切片失败导致某些素材没有片段时,按需求兜底:内存中按 3-6 秒临时均匀
|
||||
切片(不存库,片段标记 is_fallback=True)。
|
||||
|
||||
本模块对 repository 做鸭子类型约束(只需 find_by_asset / find_candidates_for_selection
|
||||
和 asset_repo.get),方便 API 侧(SQLAlchemy)与 worker 侧复用,也便于单测注入内存假实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
from packages.domain.atom_clip_service import compute_fallback_clips
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 兜底均匀切片步长(秒),落在 3~6s 区间中段
|
||||
FALLBACK_CLIP_SECONDS = 4.5
|
||||
|
||||
|
||||
def load_atom_clips_for_assets(
|
||||
asset_ids: list[str],
|
||||
*,
|
||||
atom_clip_repo,
|
||||
asset_repo=None,
|
||||
) -> dict[str, list[AssetAtomClip]]:
|
||||
"""加载素材池的原子片段(缺失素材走内存兜底).
|
||||
|
||||
Args:
|
||||
asset_ids: 候选素材 ID(去重保序)。
|
||||
atom_clip_repo: AssetAtomClipRepository 实现(需有
|
||||
``find_candidates_for_selection`` 或 ``find_by_asset``)。
|
||||
asset_repo: 可选,素材仓储(需有 ``get``),用于读取时长兜底切片。
|
||||
为 None 时,没有原子片段的素材直接跳过(不兜底)。
|
||||
|
||||
Returns:
|
||||
{asset_id: [AssetAtomClip, ...]},仅包含至少有一个片段的素材,
|
||||
片段按 clip_index 排序。
|
||||
"""
|
||||
result: dict[str, list[AssetAtomClip]] = {}
|
||||
unique_ids = list(dict.fromkeys(asset_ids))
|
||||
if not unique_ids:
|
||||
return result
|
||||
|
||||
# 1. 批量查询已生成的原子片段
|
||||
persisted: dict[str, list[AssetAtomClip]] = {}
|
||||
try:
|
||||
if hasattr(atom_clip_repo, "find_candidates_for_selection"):
|
||||
clips = atom_clip_repo.find_candidates_for_selection(unique_ids, limit=0)
|
||||
else:
|
||||
clips = []
|
||||
for asset_id in unique_ids:
|
||||
clips.extend(atom_clip_repo.find_by_asset(asset_id))
|
||||
for clip in clips:
|
||||
persisted.setdefault(clip.asset_id, []).append(clip)
|
||||
except Exception:
|
||||
logger.warning("加载 atom_clips 失败,全部走内存兜底", exc_info=True)
|
||||
persisted = {}
|
||||
|
||||
for asset_id in unique_ids:
|
||||
clips = persisted.get(asset_id)
|
||||
if clips:
|
||||
clips.sort(key=lambda c: c.clip_index)
|
||||
result[asset_id] = clips
|
||||
continue
|
||||
|
||||
# 2. 兜底:内存均匀切片(不存库)
|
||||
if asset_repo is None:
|
||||
continue
|
||||
duration = _safe_asset_duration(asset_repo, asset_id)
|
||||
if duration <= 0:
|
||||
continue
|
||||
result[asset_id] = compute_fallback_clips(
|
||||
asset_id,
|
||||
duration,
|
||||
clip_seconds=FALLBACK_CLIP_SECONDS,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def flatten_candidates(
|
||||
clips_by_asset: dict[str, list[AssetAtomClip]],
|
||||
) -> list[AssetAtomClip]:
|
||||
"""把 {asset_id: [clips]} 摊平为候选片段列表(素材顺序内片段有序)。"""
|
||||
flat: list[AssetAtomClip] = []
|
||||
for clips in clips_by_asset.values():
|
||||
flat.extend(clips)
|
||||
return flat
|
||||
|
||||
|
||||
def _safe_asset_duration(asset_repo, asset_id: str) -> float:
|
||||
"""安全读取素材时长,任何异常返回 0。"""
|
||||
try:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if asset is None:
|
||||
return 0.0
|
||||
return float(getattr(asset, "duration", 0.0) or 0.0)
|
||||
except Exception:
|
||||
logger.warning("读取素材时长失败: asset_id=%s", asset_id, exc_info=True)
|
||||
return 0.0
|
||||
@@ -0,0 +1,264 @@
|
||||
"""原子片段级选片核心 — #1970 智能剪辑流程重构 P1.
|
||||
|
||||
选片单元从"整条素材 + 随机起点"升级为"原子片段(atom clip)":
|
||||
|
||||
- 每个 EditPlanClip 指向一个 atom_clip_id(含 asset_id + start/end);
|
||||
- 同一素材的不同原子片段可被同一视频多次选用;
|
||||
- 同一原子片段在一个视频内只用一次;
|
||||
- 跨变体/跨任务的避让升级为原子片段级(同 asset 的不同片段天然不重叠);
|
||||
- atom_clips 未就绪(老素材/切片失败)时由调用方走内存兜底切片,
|
||||
再不行回退到现有的整条素材随机起点逻辑。
|
||||
|
||||
本模块是纯函数:原子片段数据由调用方从 repository 读取后注入,不直接碰 DB,
|
||||
便于单元测试。评分维度与 smart_match 保持一致(质量分、时长适配、新鲜度、
|
||||
未使用加分),只是评分对象从素材变为原子片段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScoredAtomClip:
|
||||
"""带评分的候选原子片段。"""
|
||||
|
||||
clip: AssetAtomClip
|
||||
score: float
|
||||
|
||||
@property
|
||||
def atom_clip_id(self) -> str:
|
||||
return self.clip.id
|
||||
|
||||
@property
|
||||
def asset_id(self) -> str:
|
||||
return self.clip.asset_id
|
||||
|
||||
@property
|
||||
def start_time(self) -> float:
|
||||
return self.clip.start_time
|
||||
|
||||
@property
|
||||
def end_time(self) -> float:
|
||||
return self.clip.end_time
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.clip.duration
|
||||
|
||||
|
||||
# 评分权重(与 smart_match.score_asset 的维度对齐)
|
||||
W_QUALITY = 0.35
|
||||
W_DURATION_FIT = 0.30
|
||||
W_FRESHNESS = 0.15
|
||||
W_UNUSED_BONUS = 0.10
|
||||
W_ASSET_BALANCE = 0.10
|
||||
|
||||
# 评分随机噪声上限(与 SCORE_RANDOM_NOISE_MAX 同量级,避免反复选同一组合)
|
||||
SCORE_NOISE_MAX = 0.05
|
||||
|
||||
|
||||
def score_atom_clip(
|
||||
clip: AssetAtomClip,
|
||||
*,
|
||||
target_duration: float,
|
||||
asset_quality: dict[str, float] | None = None,
|
||||
asset_freshness: dict[str, float] | None = None,
|
||||
used_in_video: set[str] | None = None,
|
||||
asset_usage_counts: dict[str, int] | None = None,
|
||||
recently_used: set[str] | None = None,
|
||||
required_count: int = 1,
|
||||
total_candidates: int = 1,
|
||||
) -> float:
|
||||
"""评估单个原子片段对某个目标槽位的适配分(越高越优先).
|
||||
|
||||
评分维度:
|
||||
- 质量分(继承素材质量,缺省中性 0.6);
|
||||
- 时长适配(片段时长越接近目标越好,覆盖不满显著扣分);
|
||||
- 新鲜度(缺省中性 0.5);
|
||||
- 未使用加分(本视频内未用过 +1,已用 0);
|
||||
- 素材均衡(同一素材在本视频用得越多,其剩余片段扣分越多,鼓励分散到多素材);
|
||||
- 跨视频/历史使用降权(recently_used 中的片段扣分,不硬禁)。
|
||||
"""
|
||||
asset_quality = asset_quality or {}
|
||||
asset_freshness = asset_freshness or {}
|
||||
used_in_video = used_in_video or set()
|
||||
asset_usage_counts = asset_usage_counts or {}
|
||||
recently_used = recently_used or set()
|
||||
|
||||
quality = asset_quality.get(clip.asset_id, 0.6)
|
||||
|
||||
if target_duration > 0:
|
||||
coverage = min(1.0, clip.duration / target_duration)
|
||||
overshoot = max(0.0, (clip.duration - target_duration) / target_duration)
|
||||
duration_fit = max(0.0, coverage - 0.15 * overshoot)
|
||||
else:
|
||||
duration_fit = 0.5
|
||||
|
||||
freshness = asset_freshness.get(clip.asset_id, 0.5)
|
||||
unused_bonus = 0.0 if clip.id in used_in_video else 1.0
|
||||
|
||||
# 素材均衡:该素材已被本视频选用 k 次,其片段逐次扣分
|
||||
times_used = asset_usage_counts.get(clip.asset_id, 0)
|
||||
balance = 1.0 / (1.0 + times_used)
|
||||
|
||||
# 跨视频/历史使用降权(不硬禁)
|
||||
history_penalty = 0.35 if clip.id in recently_used else 0.0
|
||||
|
||||
score = (
|
||||
W_QUALITY * quality
|
||||
+ W_DURATION_FIT * duration_fit
|
||||
+ W_FRESHNESS * freshness
|
||||
+ W_UNUSED_BONUS * unused_bonus
|
||||
+ W_ASSET_BALANCE * balance
|
||||
- history_penalty
|
||||
)
|
||||
return score
|
||||
|
||||
|
||||
def select_atom_clips(
|
||||
candidates: list[AssetAtomClip],
|
||||
*,
|
||||
target_duration: float = 0.0,
|
||||
used_atom_clip_ids: set[str] | None = None,
|
||||
asset_usage_counts: dict[str, int] | None = None,
|
||||
recently_used_atom_ids: set[str] | None = None,
|
||||
required_count: int = 1,
|
||||
limit: int = 0,
|
||||
asset_quality: dict[str, float] | None = None,
|
||||
asset_freshness: dict[str, float] | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> list[ScoredAtomClip]:
|
||||
"""为一个目标槽位从候选原子片段中评分选片(纯函数).
|
||||
|
||||
Args:
|
||||
candidates: 候选原子片段(可跨多素材)。
|
||||
target_duration: 槽位目标时长(秒)。
|
||||
used_atom_clip_ids: 本视频已用过的原子片段 ID(硬排除,同片段不重复)。
|
||||
asset_usage_counts: 本视频各素材已选片段数(均衡评分用)。
|
||||
recently_used_atom_ids: 跨视频/历史成片用过的片段 ID(降权,不硬禁)。
|
||||
required_count: 整个视频需要的片段总数(预留,供覆盖策略判断)。
|
||||
limit: 最多返回条数;<=0 表示返回全部排序结果。
|
||||
asset_quality / asset_freshness: 评分注入。
|
||||
rng: 可选随机源(测试注入)。
|
||||
|
||||
Returns:
|
||||
评分降序的 ScoredAtomClip 列表(已排除本视频用过的片段)。
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
used = used_atom_clip_ids or set()
|
||||
asset_usage_counts = asset_usage_counts or {}
|
||||
recently_used = recently_used_atom_ids or set()
|
||||
|
||||
available = [c for c in candidates if c.id not in used]
|
||||
scored: list[ScoredAtomClip] = []
|
||||
for clip in available:
|
||||
base = score_atom_clip(
|
||||
clip,
|
||||
target_duration=target_duration,
|
||||
asset_quality=asset_quality,
|
||||
asset_freshness=asset_freshness,
|
||||
used_in_video=used,
|
||||
asset_usage_counts=asset_usage_counts,
|
||||
recently_used=recently_used,
|
||||
required_count=required_count,
|
||||
total_candidates=len(candidates),
|
||||
)
|
||||
noise = rng.uniform(0.0, SCORE_NOISE_MAX)
|
||||
scored.append(ScoredAtomClip(clip=clip, score=base + noise))
|
||||
|
||||
scored.sort(key=lambda s: s.score, reverse=True)
|
||||
if limit and limit > 0:
|
||||
return scored[:limit]
|
||||
return scored
|
||||
|
||||
|
||||
def clips_to_segments(clips: list[AssetAtomClip]) -> dict[str, list[tuple[float, float]]]:
|
||||
"""把选中的原子片段转换为旧的 {asset_id: [(start, end), ...]} 区间结构.
|
||||
|
||||
用于与现有跨变体区间避让(variant_plan_selector / metadata.used_segments)对接。
|
||||
原子片段级天然不重叠,同素材多片段直接形成多段不重叠区间。
|
||||
"""
|
||||
segments: dict[str, list[tuple[float, float]]] = {}
|
||||
for clip in clips:
|
||||
segments.setdefault(clip.asset_id, []).append((clip.start_time, clip.end_time))
|
||||
for asset_id in segments:
|
||||
segments[asset_id].sort()
|
||||
return segments
|
||||
|
||||
|
||||
def estimate_required_clip_count(
|
||||
voice_total_duration: float,
|
||||
average_clip_duration: float = 4.5,
|
||||
) -> int:
|
||||
"""配音总时长 / 平均片段时长 ≈ 需要的片段数(至少 1)。"""
|
||||
if voice_total_duration <= 0 or average_clip_duration <= 0:
|
||||
return 1
|
||||
return max(1, round(voice_total_duration / average_clip_duration))
|
||||
|
||||
|
||||
def reselect_clips_from_atoms(
|
||||
source_clips: list[dict[str, Any]],
|
||||
candidates: list[AssetAtomClip],
|
||||
*,
|
||||
historical_atom_ids: set[str] | None = None,
|
||||
batch_used_atom_ids: set[str] | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""#1970 变体重选的原子片段级实现.
|
||||
|
||||
与 variant_plan_selector.reselect_clips_for_variant 对应:保留源 plan 的
|
||||
片段骨架(order/clip_type/文案/转场),从候选原子片段中为每个 main 片段
|
||||
选取一个原子片段;同变体/批次内同一片段不可重复,历史成片用过的片段降权。
|
||||
|
||||
Returns:
|
||||
新 clips_data(dict 列表,含 asset_id/atom_clip_id/start_time/duration),
|
||||
候选不足(main 片段多于去重后片段数)时返回 None,由调用方回退整条素材路径。
|
||||
非 main 片段(intro/outro 等)原样保留不分配素材。
|
||||
"""
|
||||
if not source_clips or not candidates:
|
||||
return None
|
||||
|
||||
rng = rng or random.Random()
|
||||
main_indexes = [i for i, c in enumerate(source_clips) if c.get("clip_type", "main") == "main"]
|
||||
if len(main_indexes) > len({c.id for c in candidates}):
|
||||
return None
|
||||
|
||||
used: set[str] = set(batch_used_atom_ids or ())
|
||||
result: list[dict[str, Any]] = [dict(c) for c in source_clips]
|
||||
asset_usage: dict[str, int] = {}
|
||||
|
||||
for idx in main_indexes:
|
||||
skeleton = source_clips[idx]
|
||||
target_duration = float(skeleton.get("duration") or 0.0)
|
||||
ranked = select_atom_clips(
|
||||
candidates,
|
||||
target_duration=target_duration,
|
||||
used_atom_clip_ids=used,
|
||||
asset_usage_counts=asset_usage,
|
||||
recently_used_atom_ids=historical_atom_ids or set(),
|
||||
required_count=len(main_indexes),
|
||||
limit=1,
|
||||
rng=rng,
|
||||
)
|
||||
if not ranked:
|
||||
return None
|
||||
picked = ranked[0]
|
||||
# 段长:片段短于槽位时取片段全长(渲染末帧冻结铺满),长于槽位时按槽位时长 trim
|
||||
new_duration = picked.duration if target_duration <= 0 else min(target_duration, picked.duration)
|
||||
result[idx].update(
|
||||
{
|
||||
"asset_id": picked.asset_id,
|
||||
"atom_clip_id": picked.atom_clip_id,
|
||||
"start_time": round(picked.start_time, 3),
|
||||
"duration": round(new_duration, 3),
|
||||
}
|
||||
)
|
||||
used.add(picked.atom_clip_id)
|
||||
asset_usage[picked.asset_id] = asset_usage.get(picked.asset_id, 0) + 1
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,215 @@
|
||||
"""素材原子切片服务 — #1970 智能剪辑流程重构 P1.
|
||||
|
||||
切片规则(见 docs/smart-edit-flow-redesign-20260916.md §1):
|
||||
- 3~6 秒一个片段,具体时长在此范围内随机(避免固定节奏)
|
||||
- 切点附近 0.5 秒内有 scdet 镜头切换点时,切点偏移到切换处
|
||||
(复用素材 metadata 中已缓存的 scene_change_points,不重新计算)
|
||||
- <6 秒素材整条作为一个片段,不切
|
||||
- 最后一个片段不足 3 秒的合并到前一个;超过 3 秒独立成段
|
||||
- 片段是逻辑索引,不物理切割视频文件
|
||||
|
||||
片段在内存中计算;持久化由上层调用 repository 完成,保证本模块可单测、无 IO 依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
|
||||
# 切片参数(集中常量,便于后续抽配置)
|
||||
MIN_CLIP_SECONDS = 3.0
|
||||
MAX_CLIP_SECONDS = 6.0
|
||||
# 切点与 scdet 切换点的对齐窗口
|
||||
SCENE_SNAP_WINDOW = 0.5
|
||||
# 末段最小独立时长:不足则并入前一段
|
||||
MIN_TAIL_SECONDS = 3.0
|
||||
# 浮点比较容差
|
||||
_EPS = 0.05
|
||||
|
||||
|
||||
def _round3(value: float) -> float:
|
||||
return round(float(value), 3)
|
||||
|
||||
|
||||
def _snap_to_scene(
|
||||
cut: float,
|
||||
scene_points: list[float] | None,
|
||||
lower: float,
|
||||
upper: float,
|
||||
) -> tuple[float, float | None]:
|
||||
"""将切点 ``cut`` 对齐到窗口内最近的 scdet 切换点.
|
||||
|
||||
Args:
|
||||
cut: 原始切点(秒)。
|
||||
scene_points: 候选切换点(秒,已排序),可为空。
|
||||
lower: 允许偏移的下界(不早于当前片段起点)。
|
||||
upper: 允许偏移的上界(不晚于素材总时长)。
|
||||
|
||||
Returns:
|
||||
(对齐后的切点, 命中的切换点);未命中返回 (cut, None)。
|
||||
"""
|
||||
if not scene_points:
|
||||
return cut, None
|
||||
|
||||
best: float | None = None
|
||||
best_dist = SCENE_SNAP_WINDOW
|
||||
for point in scene_points:
|
||||
# 切换点必须严格落在片段内部(不能与边界重合),且在窗口内
|
||||
if point <= lower + _EPS or point >= upper - _EPS:
|
||||
continue
|
||||
dist = abs(point - cut)
|
||||
if dist <= best_dist:
|
||||
best_dist = dist
|
||||
best = point
|
||||
if best is None:
|
||||
return cut, None
|
||||
return _round3(best), _round3(best)
|
||||
|
||||
|
||||
def compute_atom_clips(
|
||||
asset_id: str,
|
||||
duration: float,
|
||||
*,
|
||||
scene_change_points: list[float] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> list[AssetAtomClip]:
|
||||
"""根据素材时长计算原子片段(纯函数,不落库).
|
||||
|
||||
Args:
|
||||
asset_id: 素材 ID。
|
||||
duration: 素材总时长(秒)。
|
||||
scene_change_points: metadata 中缓存的 scdet 切换点(秒)。
|
||||
tags: 继承自素材的标签。
|
||||
rng: 可选随机源(测试可注入固定种子)。
|
||||
|
||||
Returns:
|
||||
有序的原子片段列表(clip_index 从 0 开始)。
|
||||
"""
|
||||
if duration <= 0:
|
||||
return []
|
||||
|
||||
r = rng or random.Random()
|
||||
points = _normalize_scene_points(scene_change_points, duration)
|
||||
|
||||
# <6 秒素材整条作为一个片段,不切
|
||||
if duration < MAX_CLIP_SECONDS:
|
||||
return [
|
||||
AssetAtomClip.create(
|
||||
asset_id=asset_id,
|
||||
start_time=0.0,
|
||||
end_time=_round3(duration),
|
||||
clip_index=0,
|
||||
tags=list(tags or []),
|
||||
)
|
||||
]
|
||||
|
||||
boundaries: list[float] = [0.0]
|
||||
scene_hits: dict[int, float] = {}
|
||||
|
||||
cursor = 0.0
|
||||
while duration - cursor > MAX_CLIP_SECONDS + _EPS:
|
||||
# 在 [3, 6] 内随机决定本段目标时长
|
||||
target_len = r.uniform(MIN_CLIP_SECONDS, MAX_CLIP_SECONDS)
|
||||
raw_cut = cursor + target_len
|
||||
if raw_cut >= duration - _EPS:
|
||||
break
|
||||
cut, hit = _snap_to_scene(raw_cut, points, lower=cursor, upper=duration)
|
||||
|
||||
# 对齐后若导致本段短于 3 秒(切换点太靠近段首),放弃对齐
|
||||
if cut - cursor < MIN_CLIP_SECONDS - _EPS:
|
||||
cut = _round3(raw_cut)
|
||||
hit = None
|
||||
|
||||
boundaries.append(_round3(cut))
|
||||
if hit is not None:
|
||||
scene_hits[len(boundaries) - 1] = hit
|
||||
cursor = cut
|
||||
|
||||
boundaries.append(_round3(duration))
|
||||
|
||||
# 末段处理:最后一个片段不足 3 秒则合并到前一个
|
||||
if len(boundaries) >= 3:
|
||||
tail_start = boundaries[-2]
|
||||
tail_len = duration - tail_start
|
||||
if tail_len < MIN_TAIL_SECONDS - _EPS:
|
||||
boundaries.pop(-2)
|
||||
|
||||
clips: list[AssetAtomClip] = []
|
||||
for index in range(len(boundaries) - 1):
|
||||
start = boundaries[index]
|
||||
end = boundaries[index + 1]
|
||||
if end - start < _EPS:
|
||||
continue
|
||||
# 片段尾部对齐的切换点 = 该片段右边界(若它来自 snap)
|
||||
scene_at = scene_hits.get(index + 1)
|
||||
clips.append(
|
||||
AssetAtomClip.create(
|
||||
asset_id=asset_id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
clip_index=index,
|
||||
tags=list(tags or []),
|
||||
scene_change_at=scene_at,
|
||||
)
|
||||
)
|
||||
return clips
|
||||
|
||||
|
||||
def compute_fallback_clips(
|
||||
asset_id: str,
|
||||
duration: float,
|
||||
*,
|
||||
tags: list[str] | None = None,
|
||||
clip_seconds: float = 4.5,
|
||||
) -> list[AssetAtomClip]:
|
||||
"""兜底切片:atom_clips 未就绪时,内存中按固定步长临时均匀切片(不存库).
|
||||
|
||||
与 :func:`compute_atom_clips` 的区别:不随机、不对齐切点,
|
||||
产出的片段标记 ``is_fallback=True``。
|
||||
"""
|
||||
if duration <= 0:
|
||||
return []
|
||||
|
||||
step = min(max(clip_seconds, MIN_CLIP_SECONDS), MAX_CLIP_SECONDS)
|
||||
clips: list[AssetAtomClip] = []
|
||||
cursor = 0.0
|
||||
index = 0
|
||||
while cursor < duration - _EPS:
|
||||
end = min(cursor + step, duration)
|
||||
clips.append(
|
||||
AssetAtomClip.create(
|
||||
asset_id=asset_id,
|
||||
start_time=_round3(cursor),
|
||||
end_time=_round3(end),
|
||||
clip_index=index,
|
||||
tags=list(tags or []),
|
||||
is_fallback=True,
|
||||
)
|
||||
)
|
||||
cursor = end
|
||||
index += 1
|
||||
|
||||
# 末段不足 3 秒合并
|
||||
if len(clips) >= 2 and clips[-1].duration < MIN_TAIL_SECONDS - _EPS:
|
||||
last = clips.pop()
|
||||
prev = clips[-1]
|
||||
merged = AssetAtomClip.create(
|
||||
asset_id=asset_id,
|
||||
start_time=prev.start_time,
|
||||
end_time=last.end_time,
|
||||
clip_index=prev.clip_index,
|
||||
tags=list(tags or []),
|
||||
is_fallback=True,
|
||||
)
|
||||
clips[-1] = merged
|
||||
return clips
|
||||
|
||||
|
||||
def _normalize_scene_points(points: list[float] | None, duration: float) -> list[float]:
|
||||
"""清洗切换点:去重、排序、限定在 (0, duration) 内。"""
|
||||
if not points:
|
||||
return []
|
||||
cleaned = sorted({round(float(p), 3) for p in points if 0 < float(p) < duration})
|
||||
return cleaned
|
||||
@@ -0,0 +1,292 @@
|
||||
"""片段级 AI 标签 — #1970 智能剪辑流程重构 P2.
|
||||
|
||||
对每个 atom_clip 提取关键帧,调用豆包视觉理解 API 识别内容,
|
||||
生成结构化标签(场景、物体、动作、景别、是否有文字)。
|
||||
|
||||
纯函数 + IO 分离设计:
|
||||
- build_vision_prompt() 返回结构化 prompt
|
||||
- parse_vision_response(text) 解析 AI 返回的 JSON 标签
|
||||
- tag_atom_clip(...) 主入口,组合帧提取 → 视觉 API → 解析标签
|
||||
|
||||
降级策略:任何环节失败都返回 {"inherited_tags": clip.tags},不阻断流程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# AI 标签结构的键
|
||||
AI_TAG_KEYS = ("scene", "objects", "action", "shot", "has_text")
|
||||
|
||||
|
||||
def build_vision_prompt() -> str:
|
||||
"""返回结构化标签提取 prompt.
|
||||
|
||||
要求 AI 以 JSON 格式返回片段内容标签,包含:
|
||||
- scene: 场景类型列表(如 "工厂", "办公室", "户外")
|
||||
- objects: 出现的物体列表(如 "产品", "手机", "电脑")
|
||||
- action: 动作类型列表(如 "演示", "说话", "操作")
|
||||
- shot: 景别("特写" / "中景" / "远景" 之一)
|
||||
- has_text: 画面中是否有显著文字(true/false)
|
||||
"""
|
||||
return """请分析这段视频片段的关键帧,识别内容并返回 JSON 格式标签。
|
||||
|
||||
要求返回以下 JSON 结构(严格 JSON,不要添加其他文字):
|
||||
{
|
||||
"scene": ["场景1", "场景2"],
|
||||
"objects": ["物体1", "物体2"],
|
||||
"action": ["动作1"],
|
||||
"shot": "特写|中景|远景",
|
||||
"has_text": true/false
|
||||
}
|
||||
|
||||
规则:
|
||||
- scene: 场景类型,如"工厂"、"办公室"、"户外"、"商店"、"家庭"等,1-3个
|
||||
- objects: 画面中可见的主要物体,如"产品"、"手机"、"电脑"、"食品"等,1-5个
|
||||
- action: 人物或物体正在进行的动作,如"演示"、"说话"、"操作"、"展示"等,1-3个
|
||||
- shot: 景别判断,只能是"特写"、"中景"或"远景"之一
|
||||
- has_text: 画面中是否有显著可读文字(标题、字幕、标语等)
|
||||
|
||||
请只返回 JSON,不要有其他说明文字。"""
|
||||
|
||||
|
||||
def parse_vision_response(text: str) -> dict:
|
||||
"""解析 AI 返回的 JSON 标签文本.
|
||||
|
||||
Args:
|
||||
text: 视觉 API 返回的文本,期望是 JSON 格式。
|
||||
|
||||
Returns:
|
||||
结构化标签 dict,格式如:
|
||||
{"scene": [...], "objects": [...], "action": [...], "shot": "...", "has_text": bool}
|
||||
|
||||
解析失败时返回空 dict。
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return {}
|
||||
|
||||
# 尝试直接解析
|
||||
cleaned = text.strip()
|
||||
|
||||
# 去除可能的 markdown 代码块包裹
|
||||
if cleaned.startswith("```"):
|
||||
lines = cleaned.split("\n")
|
||||
# 去掉首尾的 ``` 行
|
||||
start = 1
|
||||
end = len(lines)
|
||||
for i in range(len(lines) - 1, 0, -1):
|
||||
if lines[i].strip().startswith("```"):
|
||||
end = i
|
||||
break
|
||||
cleaned = "\n".join(lines[start:end]).strip()
|
||||
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
# 尝试从文本中提取 JSON 块
|
||||
try:
|
||||
start_idx = cleaned.index("{")
|
||||
end_idx = cleaned.rindex("}") + 1
|
||||
data = json.loads(cleaned[start_idx:end_idx])
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
logger.warning("无法解析 AI 标签响应: %s", text[:200])
|
||||
return {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
|
||||
# 验证和清洗各字段
|
||||
result: dict[str, Any] = {}
|
||||
for key in ("scene", "objects", "action"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, list):
|
||||
result[key] = [str(v).strip() for v in val if str(v).strip()]
|
||||
elif isinstance(val, str) and val.strip():
|
||||
result[key] = [val.strip()]
|
||||
else:
|
||||
result[key] = []
|
||||
|
||||
shot_val = data.get("shot", "")
|
||||
if isinstance(shot_val, str) and shot_val.strip() in ("特写", "中景", "远景"):
|
||||
result["shot"] = shot_val.strip()
|
||||
else:
|
||||
result["shot"] = ""
|
||||
|
||||
has_text_val = data.get("has_text")
|
||||
if isinstance(has_text_val, bool):
|
||||
result["has_text"] = has_text_val
|
||||
elif isinstance(has_text_val, str):
|
||||
result["has_text"] = has_text_val.lower() in ("true", "yes", "1")
|
||||
else:
|
||||
result["has_text"] = False
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _extract_frames_via_mediakit(
|
||||
mediakit_client: Any,
|
||||
video_url: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
) -> Optional[list[str]]:
|
||||
"""通过 MediaKit 提取 3 帧(首、中、尾).
|
||||
|
||||
Returns:
|
||||
图片 URL 列表(3 个),失败返回 None。
|
||||
"""
|
||||
try:
|
||||
frames = mediakit_client.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="SpecifiedTime",
|
||||
max_frames=3,
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=30,
|
||||
)
|
||||
# MediaKit SpecifiedTime 策略可能不支持直接传时间点
|
||||
# 如果返回结果不够 3 帧,降级到 ffmpeg
|
||||
if frames and len(frames) >= 1:
|
||||
urls = [f.get("image_url", "") for f in frames if f.get("image_url")]
|
||||
if urls:
|
||||
return urls
|
||||
except Exception as e:
|
||||
logger.warning("MediaKit 抽帧失败,将降级为 ffmpeg: %s", e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_frames_via_ffmpeg(
|
||||
video_url: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
) -> Optional[list[str]]:
|
||||
"""通过 ffmpeg 本地提取 3 帧并转为 base64.
|
||||
|
||||
Returns:
|
||||
base64 data URI 列表(3 个),失败返回 None。
|
||||
"""
|
||||
import base64
|
||||
|
||||
mid_time = round((start_time + end_time) / 2, 3)
|
||||
timestamps = [round(start_time, 3), mid_time, round(end_time, 3)]
|
||||
|
||||
try:
|
||||
frames_b64: list[str] = []
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for i, ts in enumerate(timestamps):
|
||||
out_path = Path(tmpdir) / f"frame_{i}.jpg"
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
str(ts),
|
||||
"-i",
|
||||
video_url,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
"2",
|
||||
str(out_path),
|
||||
]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0 or not out_path.exists():
|
||||
logger.warning("ffmpeg 抽帧失败 ts=%s: %s", ts, result.stderr[:200])
|
||||
continue
|
||||
|
||||
img_data = out_path.read_bytes()
|
||||
b64 = base64.b64encode(img_data).decode("ascii")
|
||||
frames_b64.append(f"data:image/jpeg;base64,{b64}")
|
||||
|
||||
if frames_b64:
|
||||
return frames_b64
|
||||
except Exception as e:
|
||||
logger.warning("ffmpeg 抽帧异常: %s", e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def tag_atom_clip(
|
||||
clip: Any,
|
||||
video_url: str,
|
||||
doubao_client: Any,
|
||||
mediakit_client: Any | None = None,
|
||||
storage: Any | None = None,
|
||||
) -> dict:
|
||||
"""主入口:为单个 atom_clip 生成 AI 标签.
|
||||
|
||||
流程:提取帧 → 调视觉 API → 解析标签 → 返回结构化标签 dict。
|
||||
任何环节失败返回 {"inherited_tags": clip.tags},不阻断流程。
|
||||
|
||||
Args:
|
||||
clip: AssetAtomClip 领域对象(需有 start_time, end_time, tags)。
|
||||
video_url: 素材视频的公网可访问 URL。
|
||||
doubao_client: DoubaoClient 实例。
|
||||
mediakit_client: MediaKitClient 实例(可选,不可用时降级 ffmpeg)。
|
||||
storage: SharedStorageService 实例(可选,用于获取签名 URL)。
|
||||
|
||||
Returns:
|
||||
结构化标签 dict,格式如:
|
||||
{"scene": [...], "objects": [...], "action": [...], "shot": "...",
|
||||
"has_text": bool, "inherited_tags": [...]}
|
||||
"""
|
||||
inherited = list(getattr(clip, "tags", []) or [])
|
||||
|
||||
# 检查 DoubaoClient 是否可用
|
||||
if not getattr(doubao_client, "is_available", False):
|
||||
logger.info("DoubaoClient 不可用,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
# 提取帧图片
|
||||
frame_urls: Optional[list[str]] = None
|
||||
start_time = getattr(clip, "start_time", 0.0)
|
||||
end_time = getattr(clip, "end_time", 0.0)
|
||||
|
||||
# 优先使用 MediaKit
|
||||
if mediakit_client and getattr(mediakit_client, "is_available", False):
|
||||
frame_urls = _extract_frames_via_mediakit(mediakit_client, video_url, start_time, end_time)
|
||||
|
||||
# MediaKit 不可用或失败 → 降级 ffmpeg
|
||||
if not frame_urls:
|
||||
frame_urls = _extract_frames_via_ffmpeg(video_url, start_time, end_time)
|
||||
|
||||
if not frame_urls:
|
||||
logger.warning("帧提取失败,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
# 调用视觉 API
|
||||
prompt = build_vision_prompt()
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
try:
|
||||
response_text = doubao_client.vision_completion(
|
||||
messages=messages,
|
||||
images=frame_urls,
|
||||
timeout=60,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("视觉 API 调用异常: clip_id=%s error=%s", getattr(clip, "id", ""), e)
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
if not response_text:
|
||||
logger.warning("视觉 API 返回空: clip_id=%s", getattr(clip, "id", ""))
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
# 解析标签
|
||||
ai_tags = parse_vision_response(response_text)
|
||||
if not ai_tags:
|
||||
logger.warning("标签解析失败: clip_id=%s response=%s", getattr(clip, "id", ""), response_text[:200])
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
# 合并 inherited_tags
|
||||
ai_tags["inherited_tags"] = inherited
|
||||
return ai_tags
|
||||
@@ -65,6 +65,7 @@ class EditPlanClip:
|
||||
order: int
|
||||
template_clip_config_id: str = ""
|
||||
asset_id: str = ""
|
||||
atom_clip_id: str = ""
|
||||
text_content: str = ""
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
@@ -85,6 +86,7 @@ class EditPlanClip:
|
||||
*,
|
||||
template_clip_config_id: str = "",
|
||||
asset_id: str = "",
|
||||
atom_clip_id: str = "",
|
||||
text_content: str = "",
|
||||
start_time: float = 0.0,
|
||||
duration: float = 0.0,
|
||||
@@ -117,6 +119,7 @@ class EditPlanClip:
|
||||
order=order,
|
||||
template_clip_config_id=template_clip_config_id.strip() if template_clip_config_id else "",
|
||||
asset_id=asset_id.strip() if asset_id else "",
|
||||
atom_clip_id=atom_clip_id.strip() if atom_clip_id else "",
|
||||
text_content=text_content.strip(),
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
@@ -127,16 +130,25 @@ class EditPlanClip:
|
||||
config=config or {},
|
||||
)
|
||||
|
||||
def assign_asset(self, asset_id: str, *, start_time: float | None = None) -> None:
|
||||
def assign_asset(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
start_time: float | None = None,
|
||||
atom_clip_id: str | None = None,
|
||||
) -> None:
|
||||
"""分配素材
|
||||
|
||||
Args:
|
||||
asset_id: 素材 ID
|
||||
start_time: 可选,素材播放起始时间(秒)。如果提供且在有效范围内,则设置;否则保持默认 0.0
|
||||
atom_clip_id: 可选,选中的原子片段 ID(#1970 原子化切片)。
|
||||
"""
|
||||
if not asset_id.strip():
|
||||
raise ValueError("asset_id 不能为空")
|
||||
self.asset_id = asset_id.strip()
|
||||
if atom_clip_id is not None:
|
||||
self.atom_clip_id = atom_clip_id.strip() if atom_clip_id else ""
|
||||
if start_time is not None and start_time >= 0:
|
||||
self.start_time = start_time
|
||||
self.updated_at = datetime.now(UTC)
|
||||
|
||||
@@ -12,9 +12,21 @@ else:
|
||||
|
||||
|
||||
class EditingMode(StrEnum):
|
||||
"""剪辑模式枚举"""
|
||||
"""剪辑模式枚举。
|
||||
|
||||
ONE_TAKE = "one_take" # 顺序拼接模式
|
||||
PIP = "pip" # 画中画模式
|
||||
VOICE_OVER = "voice_over" # 口播+B-roll模式
|
||||
VOICE_PIP = "voice_pip" # 口播+画中画组合模式
|
||||
#1970 智能剪辑流程重构(2026-09)后,剪辑组装模式改由
|
||||
``CreateGenerationTaskRequest.assembly_mode``('random'/'narrative')表达。
|
||||
本枚举仅保留模板体系仍在使用的模式;以下三个模式标记 deprecated,
|
||||
不主动删除代码(pip/voice_pip 在路由入口已统一映射为 one_take),
|
||||
待确认无存量引用后在技术债清理中移除:
|
||||
|
||||
- ONE_TAKE(deprecated):顺序拼接,等同 assembly_mode='random'
|
||||
- PIP(deprecated):画中画已下线,入口映射 one_take
|
||||
- VOICE_PIP(deprecated):口播+画中画已下线,入口映射 one_take
|
||||
- VOICE_OVER:保留,口播+B-roll 模板仍在使用
|
||||
"""
|
||||
|
||||
ONE_TAKE = "one_take" # deprecated(#1970):顺序拼接,等同 assembly_mode='random'
|
||||
PIP = "pip" # deprecated(#1970):画中画已下线,入口映射 one_take
|
||||
VOICE_OVER = "voice_over" # 口播+B-roll模式(保留)
|
||||
VOICE_PIP = "voice_pip" # deprecated(#1970):口播+画中画已下线,入口映射 one_take
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""叙事剪辑素材标签匹配 — #1970 PR3 + P2 AI 标签加权.
|
||||
|
||||
叙事模式下,选片在现有评分(smart_match / atom_clip_selector)之前先做一层
|
||||
文案标签匹配:
|
||||
|
||||
- 文案 tags 与素材 tag 名归一化后求交集;
|
||||
- 命中任一标签的素材作为「优先候选池」,未命中的作为普通池;
|
||||
- 调用方对优先池跑现有 smart_select_assets,数量不足时用普通池补足
|
||||
(无任何匹配 → 完全降级为现有随机逻辑,行为与改造前一致)。
|
||||
|
||||
P2 AI 标签加权(#1970 fragment-level AI tagging):
|
||||
- 片段级 AI 标签(scene/objects/action)与文案标签做交集时权重 2.0
|
||||
- 素材级标签(tag_ids 映射名)与文案标签交集时权重 1.0
|
||||
- 综合得分 = sum(命中权重) / max(可能权重)
|
||||
- 有 AI 标签的片段命中时优先于仅素材标签命中的片段
|
||||
|
||||
纯函数模块:标签 id→名称映射由调用方查 TagModel 后注入,不直接碰 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
# 标签归一化后仍短于此长度的标签不参与匹配(避免「的」「是」这类噪声短词)
|
||||
MIN_TAG_LEN = 2
|
||||
|
||||
# 标签匹配权重
|
||||
AI_TAG_WEIGHT = 2.0 # AI 标签命中权重
|
||||
ASSET_TAG_WEIGHT = 1.0 # 素材标签命中权重
|
||||
|
||||
|
||||
def normalize_tag(tag: Any) -> str:
|
||||
"""标签归一化:去空白、小写。数字/英文统一小写,中文不受影响。"""
|
||||
if tag is None:
|
||||
return ""
|
||||
return str(tag).strip().lower()
|
||||
|
||||
|
||||
def _normalize_tags(tags: Iterable[Any]) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for t in tags or []:
|
||||
norm = normalize_tag(t)
|
||||
if len(norm) >= MIN_TAG_LEN:
|
||||
out.add(norm)
|
||||
return out
|
||||
|
||||
|
||||
def build_asset_tag_name_index(tag_names_by_id: dict[str, Any]) -> dict[str, set[str]]:
|
||||
"""构造 asset_id → 归一化标签名集合 的索引。
|
||||
|
||||
Args:
|
||||
tag_names_by_id: {asset_id: [标签名或标签id, ...]},允许混入 None/空值
|
||||
"""
|
||||
index: dict[str, set[str]] = {}
|
||||
for asset_id, names in (tag_names_by_id or {}).items():
|
||||
index[asset_id] = _normalize_tags(names)
|
||||
return index
|
||||
|
||||
|
||||
def _extract_ai_tag_names(ai_tags: dict) -> set[str]:
|
||||
"""从 AI 标签 dict 中提取所有标签名(scene + objects + action).
|
||||
|
||||
Args:
|
||||
ai_tags: 片段级 AI 标签 dict,如 {"scene": [...], "objects": [...], "action": [...], ...}
|
||||
|
||||
Returns:
|
||||
归一化后的标签名集合。
|
||||
"""
|
||||
names: set[str] = set()
|
||||
for key in ("scene", "objects", "action"):
|
||||
values = ai_tags.get(key)
|
||||
if isinstance(values, list):
|
||||
names |= _normalize_tags(values)
|
||||
return names
|
||||
|
||||
|
||||
def _compute_ai_score(
|
||||
asset_id: str,
|
||||
wanted: set[str],
|
||||
clip_ai_tags_by_asset: dict[str, list[dict]] | None,
|
||||
) -> float:
|
||||
"""计算单个素材的 AI 标签加权得分.
|
||||
|
||||
对该素材的所有片段 AI 标签,求各片段标签名与文案标签交集的加权总和。
|
||||
每个片段的命中权重 = 命中数 × AI_TAG_WEIGHT。
|
||||
最终取所有片段的最高得分(而非累加,避免片段数多的素材不公平占优)。
|
||||
|
||||
Args:
|
||||
asset_id: 素材 ID。
|
||||
wanted: 归一化后的文案标签集合。
|
||||
clip_ai_tags_by_asset: {asset_id: [ai_tag_dict, ...]} 每个片段一个。
|
||||
|
||||
Returns:
|
||||
AI 标签加权得分(≥0)。
|
||||
"""
|
||||
if not clip_ai_tags_by_asset or not wanted:
|
||||
return 0.0
|
||||
|
||||
clips = clip_ai_tags_by_asset.get(asset_id)
|
||||
if not clips:
|
||||
return 0.0
|
||||
|
||||
best_score = 0.0
|
||||
for ai_tags in clips:
|
||||
if not ai_tags or not isinstance(ai_tags, dict):
|
||||
continue
|
||||
ai_names = _extract_ai_tag_names(ai_tags)
|
||||
hits = ai_names & wanted
|
||||
score = len(hits) * AI_TAG_WEIGHT
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
|
||||
return best_score
|
||||
|
||||
|
||||
def match_assets_by_script_tags(
|
||||
assets: list[Any],
|
||||
*,
|
||||
script_tags: Iterable[Any],
|
||||
tag_names_by_id: dict[str, Any] | None = None,
|
||||
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
|
||||
) -> tuple[list[Any], list[Any]]:
|
||||
"""按文案标签把素材拆成「命中池 / 未命中池」,保持输入相对顺序。
|
||||
|
||||
P2 加权逻辑:
|
||||
- AI 标签命中(scene/objects/action ∩ 文案标签)权重 2.0
|
||||
- 素材标签命中(tag_ids 映射名 ∩ 文案标签)权重 1.0
|
||||
- 任一权重 > 0 → 命中池,否则 → 未命中池
|
||||
|
||||
Args:
|
||||
assets: 候选素材(domain Asset,需有 id 与 tag_ids)。
|
||||
script_tags: 文案 tags(字符串数组,名称语义)。
|
||||
tag_names_by_id: asset_id → 素材标签名列表。
|
||||
clip_ai_tags_by_asset: #1970 P2 — {asset_id: [ai_tag_dict, ...]}。
|
||||
|
||||
Returns:
|
||||
(matched, unmatched):命中任一文案标签的素材 / 其余素材。
|
||||
文案无有效标签时 matched 为空(调用方直接走随机逻辑)。
|
||||
"""
|
||||
wanted = _normalize_tags(script_tags)
|
||||
if not wanted:
|
||||
return [], list(assets)
|
||||
|
||||
name_index = build_asset_tag_name_index(tag_names_by_id or {})
|
||||
matched: list[Any] = []
|
||||
unmatched: list[Any] = []
|
||||
for asset in assets:
|
||||
asset_id = str(getattr(asset, "id", "") or "")
|
||||
|
||||
# P2: AI 标签加权得分
|
||||
ai_score = _compute_ai_score(asset_id, wanted, clip_ai_tags_by_asset)
|
||||
|
||||
# 素材标签得分
|
||||
names = set(name_index.get(asset_id, set()))
|
||||
raw_tags = getattr(asset, "tags", None)
|
||||
if raw_tags:
|
||||
names |= _normalize_tags(raw_tags)
|
||||
asset_score = len(names & wanted) * ASSET_TAG_WEIGHT
|
||||
|
||||
# 综合得分 > 0 → 命中池
|
||||
if ai_score > 0 or asset_score > 0:
|
||||
matched.append(asset)
|
||||
else:
|
||||
unmatched.append(asset)
|
||||
return matched, unmatched
|
||||
|
||||
|
||||
def compute_tag_match_score(
|
||||
asset_id: str,
|
||||
*,
|
||||
script_tags: Iterable[Any],
|
||||
tag_names_by_id: dict[str, Any] | None = None,
|
||||
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
|
||||
) -> float:
|
||||
"""计算单个素材的标签匹配综合得分(0.0 ~ 1.0).
|
||||
|
||||
综合得分 = sum(命中权重) / max(可能权重)
|
||||
- AI 标签每命中一个 +2.0
|
||||
- 素材标签每命中一个 +1.0
|
||||
- max_possible = len(wanted) * (AI_TAG_WEIGHT + ASSET_TAG_WEIGHT)
|
||||
|
||||
Args:
|
||||
asset_id: 素材 ID。
|
||||
script_tags: 文案标签。
|
||||
tag_names_by_id: 素材标签名索引。
|
||||
clip_ai_tags_by_asset: AI 标签索引。
|
||||
|
||||
Returns:
|
||||
归一化得分 0.0~1.0。
|
||||
"""
|
||||
wanted = _normalize_tags(script_tags)
|
||||
if not wanted:
|
||||
return 0.0
|
||||
|
||||
# AI 得分
|
||||
ai_score = _compute_ai_score(asset_id, wanted, clip_ai_tags_by_asset)
|
||||
|
||||
# 素材标签得分
|
||||
name_index = build_asset_tag_name_index(tag_names_by_id or {})
|
||||
names = name_index.get(asset_id, set())
|
||||
asset_score = len(names & wanted) * ASSET_TAG_WEIGHT
|
||||
|
||||
# 归一化:最大可能得分 = 文案标签数 × (AI权重 + 素材权重)
|
||||
max_possible = len(wanted) * (AI_TAG_WEIGHT + ASSET_TAG_WEIGHT)
|
||||
if max_possible <= 0:
|
||||
return 0.0
|
||||
|
||||
return min((ai_score + asset_score) / max_possible, 1.0)
|
||||
|
||||
|
||||
def pick_narrative_assets(
|
||||
assets: list[Any],
|
||||
*,
|
||||
script_tags: Iterable[Any],
|
||||
tag_names_by_id: dict[str, Any] | None = None,
|
||||
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
|
||||
limit: int | None = None,
|
||||
rng: Any = None,
|
||||
) -> list[Any]:
|
||||
"""叙事模式选片:标签命中池优先,不足部分从未命中池按现有评分补齐。
|
||||
|
||||
本函数只负责「标签优先 + 兜底降级」的顺序编排;评分仍复用
|
||||
smart_match.smart_select_assets(质量/时长/新鲜度/未使用 + 随机噪声),
|
||||
不重写评分维度。
|
||||
|
||||
P2 增强:有 AI 标签的片段命中时权重更高(2.0 vs 1.0),
|
||||
命中池内部按综合标签得分排序(AI 标签命中多的排前面)。
|
||||
|
||||
Args:
|
||||
assets: ready 视频素材候选(调用方负责状态/类型过滤)。
|
||||
script_tags / tag_names_by_id: 见 match_assets_by_script_tags。
|
||||
clip_ai_tags_by_asset: #1970 P2 — {asset_id: [ai_tag_dict, ...]}。
|
||||
limit: 需要的素材数量;None 表示全部(命中池 + 全部未命中池)。
|
||||
rng: 注入 smart_select_assets 的随机源(可复现)。
|
||||
|
||||
Returns:
|
||||
选中的素材列表。无任何标签命中时等价于对全量跑 smart_select_assets。
|
||||
"""
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
assets,
|
||||
script_tags=script_tags,
|
||||
tag_names_by_id=tag_names_by_id,
|
||||
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
|
||||
)
|
||||
|
||||
need = limit if (limit is not None and limit > 0) else None
|
||||
|
||||
if not matched:
|
||||
# 完全降级:与改造前随机混剪同一逻辑
|
||||
return [r.asset for r in smart_select_assets(assets, kind="video", limit=need, rng=rng)]
|
||||
|
||||
picked = [r.asset for r in smart_select_assets(matched, kind="video", limit=need, rng=rng)]
|
||||
if need is not None and len(picked) < need and unmatched:
|
||||
rest_need = need - len(picked)
|
||||
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", limit=rest_need, rng=rng))
|
||||
elif need is None:
|
||||
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", rng=rng))
|
||||
return picked
|
||||
@@ -0,0 +1,57 @@
|
||||
"""素材原子片段仓储接口定义。"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
|
||||
|
||||
class AssetAtomClipRepository(ABC):
|
||||
@abstractmethod
|
||||
def create(self, clip: AssetAtomClip) -> AssetAtomClip:
|
||||
"""创建一条原子片段记录。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def batch_create(self, clips: list[AssetAtomClip]) -> list[AssetAtomClip]:
|
||||
"""批量创建原子片段记录。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_asset(self, asset_id: str) -> list[AssetAtomClip]:
|
||||
"""查找某个素材的所有原子片段,按 clip_index 排序。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_id(self, clip_id: str) -> AssetAtomClip | None:
|
||||
"""按 ID 查找单个原子片段。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_ids(self, clip_ids: list[str]) -> list[AssetAtomClip]:
|
||||
"""批量查找原子片段。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_by_asset(self, asset_id: str) -> int:
|
||||
"""删除某素材的所有原子片段(级联删除),返回删除数量。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def count_by_asset(self, asset_id: str) -> int:
|
||||
"""统计某素材的原子片段数量。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_candidates_for_selection(
|
||||
self,
|
||||
asset_ids: list[str],
|
||||
*,
|
||||
min_duration: float | None = None,
|
||||
max_duration: float | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[AssetAtomClip]:
|
||||
"""按素材集合和时长条件查找候选原子片段,按 clip_index 排序。
|
||||
|
||||
选片逻辑一次拉取多条素材的候选片段时使用,避免 N+1 查询。
|
||||
"""
|
||||
pass
|
||||
@@ -37,6 +37,7 @@ class DoubaoClient:
|
||||
self.base_url: str = settings.doubao_base_url.rstrip("/")
|
||||
self.timeout: int = settings.doubao_timeout
|
||||
self.max_retries: int = settings.doubao_max_retries
|
||||
self.vision_model: str = settings.doubao_vision_model
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
@@ -103,6 +104,99 @@ class DoubaoClient:
|
||||
logger.error("豆包API调用最终失败: %s", last_error)
|
||||
return None
|
||||
|
||||
def vision_completion(
|
||||
self,
|
||||
messages: list[dict],
|
||||
images: list[str] | None = None,
|
||||
max_tokens: int = 2048,
|
||||
temperature: float = 0.3,
|
||||
timeout: int | None = None,
|
||||
) -> Optional[str]:
|
||||
"""调用豆包视觉理解 API(OpenAI 兼容多模态格式).
|
||||
|
||||
将 images 附加到最后一条 user message 的 content 中,
|
||||
使用 vision_model(默认 doubao-1-5-vision-pro-250915)。
|
||||
|
||||
Args:
|
||||
messages: 对话消息列表。最后一条 user message 会被注入图片内容。
|
||||
images: 图片列表,支持 base64 data URI 或 HTTP(S) URL。
|
||||
max_tokens: 最大生成 token 数,默认 2048。
|
||||
temperature: 采样温度,默认 0.3(视觉任务偏低更稳定)。
|
||||
timeout: 单次请求超时秒数,不传则使用默认 self.timeout。
|
||||
|
||||
Returns:
|
||||
模型返回的文本内容,失败返回 None。
|
||||
"""
|
||||
if not self.is_available:
|
||||
return None
|
||||
|
||||
# 构造多模态 content:先追加文本,再追加图片
|
||||
vision_messages = []
|
||||
for msg in messages:
|
||||
vision_messages.append(dict(msg))
|
||||
|
||||
# 将图片注入最后一条 user message
|
||||
if images and vision_messages:
|
||||
# 找到最后一条 user message
|
||||
for i in range(len(vision_messages) - 1, -1, -1):
|
||||
if vision_messages[i].get("role") == "user":
|
||||
text_content = vision_messages[i].get("content", "")
|
||||
multi_content: list[dict[str, Any]] = []
|
||||
if text_content:
|
||||
multi_content.append({"type": "text", "text": text_content})
|
||||
for img in images:
|
||||
if img.startswith("data:") or img.startswith("http://") or img.startswith("https://"):
|
||||
multi_content.append({"type": "image_url", "image_url": {"url": img}})
|
||||
else:
|
||||
# 当作 base64 编码
|
||||
multi_content.append(
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}}
|
||||
)
|
||||
vision_messages[i]["content"] = multi_content
|
||||
break
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.vision_model,
|
||||
"messages": vision_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
req_timeout = timeout or self.timeout
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=req_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包视觉API调用失败,%.1fs后重试 (第%d/%d次): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.error("豆包视觉API调用最终失败: %s", last_error)
|
||||
return None
|
||||
|
||||
|
||||
# ── 单例 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -339,6 +339,44 @@ class SharedStorageService(StoragePort):
|
||||
|
||||
# ── 浏览器直传 POST ────────────────────────────────────────────────
|
||||
|
||||
def get_upload_url(
|
||||
self,
|
||||
storage_key_or_url: str,
|
||||
expires_seconds: int = 3600,
|
||||
content_type: str = "video/mp4",
|
||||
) -> str:
|
||||
"""获取预签名 PUT 上传 URL(供外部 Worker 上传结果文件)。
|
||||
|
||||
bucket未配置时降级为 public_url(本地/开发环境);
|
||||
本地产物 key 原样返回。
|
||||
"""
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
logger.warning(
|
||||
"get_upload_url: OSS bucket not configured, returning raw URL. key=%s",
|
||||
storage_key_or_url[:200],
|
||||
)
|
||||
return self.get_url(self.normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self.normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
# oss2 sign_url 支持 'PUT',需指定 headers 才能限定 Content-Type
|
||||
headers = {"Content-Type": content_type} if content_type else None
|
||||
signed = self.bucket.sign_url("PUT", storage_key, expires_seconds, headers=headers)
|
||||
logger.info(
|
||||
"get_upload_url: signed PUT URL generated. key=%s url_prefix=%s",
|
||||
storage_key[:80],
|
||||
signed[:60],
|
||||
)
|
||||
return signed
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_upload_url: sign_url failed, falling back to raw URL. key=%s",
|
||||
storage_key[:200],
|
||||
)
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def create_direct_upload_post(
|
||||
self,
|
||||
storage_key: str,
|
||||
|
||||
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
|
||||
fi
|
||||
|
||||
# 共用 secrets 直接导出(如果存在)
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY"
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""#1970 PR3 schema 校验 + 路由辅助函数测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from app.api.routes import generation_tasks as gt
|
||||
from app.schemas.generation_task import CreateGenerationTaskRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
# ── schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _base_payload(**overrides):
|
||||
payload = dict(
|
||||
template_id="tpl1",
|
||||
asset_ids=["a1", "a2"],
|
||||
duration=30,
|
||||
title_text="t",
|
||||
editing_mode="voice_over",
|
||||
)
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
class TestAssemblySchema:
|
||||
def test_defaults(self):
|
||||
req = CreateGenerationTaskRequest(**_base_payload())
|
||||
assert req.assembly_mode == "random"
|
||||
assert req.script_id == ""
|
||||
assert req.tts_voice_id == ""
|
||||
assert req.tts_voice_source == "preset"
|
||||
assert req.video_ratio == "" # 空串=沿用模板默认(前端新流程显式传 9:16)
|
||||
assert req.dedup_enabled is True
|
||||
|
||||
def test_narrative_accepts_fields(self):
|
||||
req = CreateGenerationTaskRequest(
|
||||
**_base_payload(
|
||||
assembly_mode="narrative",
|
||||
script_id="s1",
|
||||
tts_voice_id="longxiaochun",
|
||||
tts_voice_source="clone",
|
||||
video_ratio="16:9",
|
||||
)
|
||||
)
|
||||
assert req.assembly_mode == "narrative"
|
||||
assert req.script_id == "s1"
|
||||
|
||||
def test_bad_assembly_mode_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateGenerationTaskRequest(**_base_payload(assembly_mode="movie"))
|
||||
|
||||
def test_bad_voice_source_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateGenerationTaskRequest(**_base_payload(tts_voice_source="elevenlabs"))
|
||||
|
||||
def test_bad_video_ratio_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateGenerationTaskRequest(**_base_payload(video_ratio="4:5"))
|
||||
|
||||
def test_narrative_without_script_rejected(self):
|
||||
with pytest.raises(ValidationError) as ei:
|
||||
CreateGenerationTaskRequest(**_base_payload(assembly_mode="narrative"))
|
||||
assert "script_id" in str(ei.value)
|
||||
|
||||
def test_narrative_without_voice_rejected(self):
|
||||
with pytest.raises(ValidationError) as ei:
|
||||
CreateGenerationTaskRequest(**_base_payload(assembly_mode="narrative", script_id="s1"))
|
||||
assert "tts_voice_id" in str(ei.value)
|
||||
|
||||
def test_random_mode_ignores_script_absence(self):
|
||||
req = CreateGenerationTaskRequest(**_base_payload())
|
||||
assert req.assembly_mode == "random"
|
||||
|
||||
|
||||
# ── _select_assets_from_library 的叙事分支 ─────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Asset:
|
||||
id: str
|
||||
status: object = field(default_factory=lambda: SimpleNamespace(value="ready"))
|
||||
mime_type: str = "video/mp4"
|
||||
tags: list[str] = field(default_factory=list)
|
||||
tag_ids: list[str] = field(default_factory=list)
|
||||
file_type: str = "video"
|
||||
quality_score: float | None = None
|
||||
duration: float = 8.0
|
||||
created_at: object = None
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class TestNarrativeSelectInRoute:
|
||||
def test_narrative_tags_prioritize_matched(self):
|
||||
assets = [
|
||||
_Asset("a1", tags=["工厂"]),
|
||||
_Asset("a2", tags=["旅游"]),
|
||||
_Asset("a3", tags=["工厂"]),
|
||||
]
|
||||
picked = gt._select_assets_from_library(assets, mode="all", count=2, script_tags=["工厂"])
|
||||
assert set(picked) == {"a1", "a3"}
|
||||
|
||||
def test_narrative_no_match_falls_back_to_full_pool(self):
|
||||
assets = [_Asset("a1", tags=["工厂"]), _Asset("a2", tags=["旅游"])]
|
||||
picked = gt._select_assets_from_library(assets, mode="all", count=2, script_tags=["美食"])
|
||||
assert set(picked) == {"a1", "a2"}
|
||||
|
||||
def test_tag_ids_via_index(self):
|
||||
assets = [_Asset("a1", tag_ids=["t1"]), _Asset("a2", tag_ids=["t2"])]
|
||||
picked = gt._select_assets_from_library(
|
||||
assets,
|
||||
mode="all",
|
||||
count=1,
|
||||
script_tags=["教程"],
|
||||
tag_names_by_id={"a1": ["教程"], "a2": ["旅游"]},
|
||||
)
|
||||
assert picked == ["a1"]
|
||||
|
||||
def test_no_script_tags_smart_path_unchanged(self):
|
||||
assets = [_Asset("a1"), _Asset("a2")]
|
||||
picked = gt._select_assets_from_library(assets, mode="smart", count=1)
|
||||
assert picked # 非空即可,评分逻辑由 smart_match 自己的测试覆盖
|
||||
|
||||
|
||||
# ── _load_asset_tag_names(DB 替身) ────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeRow:
|
||||
def __init__(self, **kw):
|
||||
self.__dict__.update(kw)
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self, name_rows, link_rows):
|
||||
self._maps = {
|
||||
"names": name_rows,
|
||||
"links": link_rows,
|
||||
}
|
||||
|
||||
def query(self, *cols):
|
||||
# _load_asset_tag_names 两次查询:第一次取 (id, name),第二次取 (asset_id, tag_id)
|
||||
keys = tuple(getattr(c, "key", None) for c in cols)
|
||||
if keys and keys[0] == "id":
|
||||
return _FakeQuery(self._maps["names"])
|
||||
return _FakeQuery(self._maps["links"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TagIdAsset:
|
||||
id: str
|
||||
tag_ids: list[str]
|
||||
|
||||
|
||||
class TestLoadAssetTagNames:
|
||||
def test_builds_index(self):
|
||||
assets = [_TagIdAsset("a1", ["t1", "t2"]), _TagIdAsset("a2", ["t2"])]
|
||||
db = _FakeDb(
|
||||
name_rows=[_FakeRow(id="t1", name="工厂"), _FakeRow(id="t2", name="带货")],
|
||||
link_rows=[
|
||||
("a1", "t1"),
|
||||
("a1", "t2"),
|
||||
("a2", "t2"),
|
||||
],
|
||||
)
|
||||
idx = gt._load_asset_tag_names(db, assets, "u1")
|
||||
assert idx == {"a1": ["工厂", "带货"], "a2": ["带货"]}
|
||||
|
||||
def test_no_tag_ids_returns_empty(self):
|
||||
assert gt._load_asset_tag_names(_FakeDb([], []), [_TagIdAsset("a1", [])], "u1") == {}
|
||||
|
||||
def test_query_failure_degrades_empty(self):
|
||||
class BoomQuery:
|
||||
def filter(self, *a, **k):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
class BoomDb:
|
||||
def query(self, *a):
|
||||
return BoomQuery()
|
||||
|
||||
idx = gt._load_asset_tag_names(BoomDb(), [_TagIdAsset("a1", ["t1"])], "u1")
|
||||
assert idx == {}
|
||||
|
||||
|
||||
# ── _resolve_output_dimensions ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveOutputDimensions:
|
||||
def _req(self, ratio="", width=1280, height=720):
|
||||
return CreateGenerationTaskRequest(**_base_payload(video_ratio=ratio, output_width=width, output_height=height))
|
||||
|
||||
def test_known_ratios(self):
|
||||
assert gt._resolve_output_dimensions(self._req("9:16")) == (1080, 1920)
|
||||
assert gt._resolve_output_dimensions(self._req("16:9")) == (1920, 1080)
|
||||
assert gt._resolve_output_dimensions(self._req("1:1")) == (1080, 1080)
|
||||
assert gt._resolve_output_dimensions(self._req("4:3")) == (1440, 1080)
|
||||
assert gt._resolve_output_dimensions(self._req("3:4")) == (1080, 1440)
|
||||
|
||||
def test_old_call_default_kept_when_no_ratio(self):
|
||||
assert gt._resolve_output_dimensions(self._req("")) == (1280, 720)
|
||||
|
||||
def test_explicit_dimensions_take_precedence(self):
|
||||
# 非旧默认值(720p)的显式分辨率优先于 ratio 映射
|
||||
req = self._req("9:16", width=1440, height=2560)
|
||||
assert gt._resolve_output_dimensions(req) == (1440, 2560)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""#1970 原子片段 resolver 单元测试:DB 加载 + 内存兜底."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
from packages.domain.atom_clip_resolver import (
|
||||
flatten_candidates,
|
||||
load_atom_clips_for_assets,
|
||||
)
|
||||
|
||||
|
||||
def _atom(asset_id: str, idx: int, start: float, end: float) -> AssetAtomClip:
|
||||
return AssetAtomClip(
|
||||
id=f"{asset_id}-clip-{idx}",
|
||||
asset_id=asset_id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
duration=round(end - start, 3),
|
||||
clip_index=idx,
|
||||
)
|
||||
|
||||
|
||||
class FakeAtomRepo:
|
||||
def __init__(self, by_asset):
|
||||
self._by_asset = by_asset
|
||||
|
||||
def find_candidates_for_selection(self, asset_ids, *, limit=0):
|
||||
out = []
|
||||
for aid in asset_ids:
|
||||
out.extend(self._by_asset.get(aid, []))
|
||||
return out
|
||||
|
||||
def find_by_asset(self, asset_id):
|
||||
return list(self._by_asset.get(asset_id, []))
|
||||
|
||||
|
||||
class _Asset:
|
||||
def __init__(self, duration):
|
||||
self.duration = duration
|
||||
|
||||
|
||||
class FakeAssetRepo:
|
||||
def __init__(self, durations):
|
||||
self._durations = durations
|
||||
|
||||
def get(self, asset_id):
|
||||
d = self._durations.get(asset_id)
|
||||
return _Asset(d) if d is not None else None
|
||||
|
||||
|
||||
class TestLoadAtomClips:
|
||||
def test_persisted_clips_loaded_sorted(self):
|
||||
clips = [_atom("a", 1, 4.5, 9.0), _atom("a", 0, 0.0, 4.5)]
|
||||
repo = FakeAtomRepo({"a": clips})
|
||||
result = load_atom_clips_for_assets(["a"], atom_clip_repo=repo)
|
||||
assert [c.clip_index for c in result["a"]] == [0, 1]
|
||||
|
||||
def test_dedup_asset_ids_preserves_order(self):
|
||||
repo = FakeAtomRepo({"a": [_atom("a", 0, 0, 4)], "b": [_atom("b", 0, 0, 4)]})
|
||||
result = load_atom_clips_for_assets(["a", "b", "a"], atom_clip_repo=repo)
|
||||
assert list(result.keys()) == ["a", "b"]
|
||||
|
||||
def test_fallback_when_no_persisted_clips(self):
|
||||
"""老素材没有 atom_clips 时,内存按 3-6 秒均匀切片,标记 is_fallback。"""
|
||||
atom_repo = FakeAtomRepo({})
|
||||
asset_repo = FakeAssetRepo({"old": 20.0})
|
||||
result = load_atom_clips_for_assets(["old"], atom_clip_repo=atom_repo, asset_repo=asset_repo)
|
||||
assert "old" in result
|
||||
clips = result["old"]
|
||||
assert clips
|
||||
assert all(c.is_fallback for c in clips)
|
||||
assert abs(clips[-1].end_time - 20.0) < 0.01
|
||||
|
||||
def test_missing_duration_skipped(self):
|
||||
atom_repo = FakeAtomRepo({})
|
||||
asset_repo = FakeAssetRepo({})
|
||||
result = load_atom_clips_for_assets(["ghost"], atom_clip_repo=atom_repo, asset_repo=asset_repo)
|
||||
assert result == {}
|
||||
|
||||
def test_no_asset_repo_skips_empty_assets(self):
|
||||
atom_repo = FakeAtomRepo({})
|
||||
result = load_atom_clips_for_assets(["a"], atom_clip_repo=atom_repo, asset_repo=None)
|
||||
assert result == {}
|
||||
|
||||
def test_mixed_persisted_and_fallback(self):
|
||||
atom_repo = FakeAtomRepo({"new": [_atom("new", 0, 0, 5)]})
|
||||
asset_repo = FakeAssetRepo({"new": 5.0, "old": 10.0})
|
||||
result = load_atom_clips_for_assets(["new", "old"], atom_clip_repo=atom_repo, asset_repo=asset_repo)
|
||||
assert not result["new"][0].is_fallback
|
||||
assert all(c.is_fallback for c in result["old"])
|
||||
|
||||
def test_repo_exception_falls_back(self):
|
||||
class BrokenRepo(FakeAtomRepo):
|
||||
def find_candidates_for_selection(self, asset_ids, *, limit=0):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
asset_repo = FakeAssetRepo({"a": 9.0})
|
||||
result = load_atom_clips_for_assets(["a"], atom_clip_repo=BrokenRepo({}), asset_repo=asset_repo)
|
||||
assert result["a"]
|
||||
assert all(c.is_fallback for c in result["a"])
|
||||
|
||||
def test_empty_input(self):
|
||||
assert load_atom_clips_for_assets([], atom_clip_repo=FakeAtomRepo({})) == {}
|
||||
|
||||
|
||||
class TestFlatten:
|
||||
def test_flatten_order(self):
|
||||
clips = flatten_candidates({"a": [_atom("a", 0, 0, 4)], "b": [_atom("b", 0, 0, 4), _atom("b", 1, 4, 8)]})
|
||||
assert len(clips) == 3
|
||||
assert clips[0].asset_id == "a"
|
||||
@@ -0,0 +1,205 @@
|
||||
"""#1970 原子片段级选片核心单元测试(纯函数,不依赖 DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
from packages.domain.atom_clip_selector import (
|
||||
clips_to_segments,
|
||||
estimate_required_clip_count,
|
||||
reselect_clips_from_atoms,
|
||||
score_atom_clip,
|
||||
select_atom_clips,
|
||||
)
|
||||
from packages.domain.atom_clip_service import compute_atom_clips
|
||||
|
||||
|
||||
def _clip(asset_id: str, start: float, end: float, clip_id: str = "") -> AssetAtomClip:
|
||||
return (
|
||||
AssetAtomClip.create(
|
||||
asset_id=asset_id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
clip_index=int(start),
|
||||
)
|
||||
if not clip_id
|
||||
else AssetAtomClip(
|
||||
id=clip_id,
|
||||
asset_id=asset_id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
duration=round(end - start, 3),
|
||||
clip_index=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestEstimateCount:
|
||||
def test_basic(self):
|
||||
assert estimate_required_clip_count(30.0, 4.5) == 7
|
||||
assert estimate_required_clip_count(18.0, 4.0) == round(18 / 4)
|
||||
|
||||
def test_invalid_inputs_returns_one(self):
|
||||
assert estimate_required_clip_count(0) == 1
|
||||
assert estimate_required_clip_count(10, 0) == 1
|
||||
assert estimate_required_clip_count(-1) == 1
|
||||
|
||||
|
||||
class TestScore:
|
||||
def test_unused_beats_used(self):
|
||||
c = _clip("a1", 0, 4)
|
||||
s_unused = score_atom_clip(c, target_duration=4.0, used_in_video=set())
|
||||
s_used = score_atom_clip(c, target_duration=4.0, used_in_video={c.id})
|
||||
assert s_unused > s_used
|
||||
|
||||
def test_duration_fit_better_when_closer(self):
|
||||
target = 4.0
|
||||
exact = score_atom_clip(_clip("a", 0, 4.0), target_duration=target)
|
||||
short = score_atom_clip(_clip("b", 0, 1.5), target_duration=target)
|
||||
assert exact > short
|
||||
|
||||
def test_history_penalty(self):
|
||||
c = _clip("a1", 0, 4)
|
||||
normal = score_atom_clip(c, target_duration=4.0)
|
||||
penalized = score_atom_clip(c, target_duration=4.0, recently_used={c.id})
|
||||
assert normal > penalized
|
||||
|
||||
def test_asset_balance_penalizes_repeated_asset(self):
|
||||
c1 = _clip("a", 0, 4)
|
||||
first = score_atom_clip(c1, target_duration=4.0, asset_usage_counts={})
|
||||
third = score_atom_clip(c1, target_duration=4.0, asset_usage_counts={"a": 2})
|
||||
assert first > third
|
||||
|
||||
|
||||
class TestSelect:
|
||||
def test_no_duplicate_atom_within_video(self):
|
||||
pool = compute_atom_clips("a", 30.0, rng=random.Random(1))
|
||||
used: set[str] = set()
|
||||
usage: dict[str, int] = {}
|
||||
chosen = []
|
||||
rng = random.Random(5)
|
||||
for _ in range(4):
|
||||
ranked = select_atom_clips(
|
||||
pool,
|
||||
target_duration=4.0,
|
||||
used_atom_clip_ids=used,
|
||||
asset_usage_counts=usage,
|
||||
required_count=4,
|
||||
limit=1,
|
||||
rng=rng,
|
||||
)
|
||||
assert ranked
|
||||
pick = ranked[0]
|
||||
assert pick.atom_clip_id not in used
|
||||
chosen.append(pick)
|
||||
used.add(pick.atom_clip_id)
|
||||
usage[pick.asset_id] = usage.get(pick.asset_id, 0) + 1
|
||||
assert len(used) == 4
|
||||
|
||||
def test_same_asset_different_clips_allowed(self):
|
||||
pool = compute_atom_clips("a", 30.0, rng=random.Random(2))
|
||||
used: set[str] = set()
|
||||
usage: dict[str, int] = {}
|
||||
rng = random.Random(7)
|
||||
picked_assets = set()
|
||||
for _ in range(3):
|
||||
pick = select_atom_clips(
|
||||
pool,
|
||||
target_duration=4.0,
|
||||
used_atom_clip_ids=used,
|
||||
asset_usage_counts=usage,
|
||||
limit=1,
|
||||
rng=rng,
|
||||
)[0]
|
||||
used.add(pick.atom_clip_id)
|
||||
usage[pick.asset_id] = usage.get(pick.asset_id, 0) + 1
|
||||
picked_assets.add(pick.asset_id)
|
||||
# 单素材池允许同素材多片段
|
||||
assert picked_assets == {"a"}
|
||||
assert len(used) == 3
|
||||
|
||||
def test_exhausted_pool_returns_empty(self):
|
||||
pool = [_clip("a", 0, 4)]
|
||||
ranked = select_atom_clips(pool, used_atom_clip_ids={pool[0].id}, target_duration=4.0)
|
||||
assert ranked == []
|
||||
|
||||
def test_recently_used_deprioritized_not_hard_blocked(self):
|
||||
# 两个片段,recent 中包含更合适的那个;它应被降权但不会从候选中消失
|
||||
fresh = _clip("a", 0, 2.0, clip_id="fresh")
|
||||
recent = _clip("b", 0, 4.0, clip_id="recent")
|
||||
ranked = select_atom_clips(
|
||||
[fresh, recent],
|
||||
target_duration=4.0,
|
||||
recently_used_atom_ids={"recent"},
|
||||
limit=2,
|
||||
rng=random.Random(0), # 噪声 0 不影响
|
||||
)
|
||||
ids = [r.atom_clip_id for r in ranked]
|
||||
assert set(ids) == {"fresh", "recent"}
|
||||
# 降权 + 噪声可能导致排序不稳定,只验证 recent 仍在候选中(不硬禁)
|
||||
|
||||
def test_limit(self):
|
||||
pool = compute_atom_clips("a", 40.0, rng=random.Random(4))
|
||||
ranked = select_atom_clips(pool, target_duration=4.0, limit=3)
|
||||
assert len(ranked) == 3
|
||||
scores = [r.score for r in ranked]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
|
||||
class TestClipsToSegments:
|
||||
def test_grouped_by_asset_sorted(self):
|
||||
clips = [
|
||||
_clip("a", 10, 14),
|
||||
_clip("a", 0, 4),
|
||||
_clip("b", 2, 6),
|
||||
]
|
||||
segs = clips_to_segments(clips)
|
||||
assert segs["a"] == [(0, 4), (10, 14)]
|
||||
assert segs["b"] == [(2, 6)]
|
||||
|
||||
|
||||
class TestReselectFromAtoms:
|
||||
def _src(self, n):
|
||||
return [{"order": i, "clip_type": "main", "duration": 4.0, "start_time": 0.0} for i in range(n)]
|
||||
|
||||
def test_skeleton_preserved_and_unique(self):
|
||||
pool = compute_atom_clips("a", 30.0, rng=random.Random(11)) + compute_atom_clips(
|
||||
"b", 30.0, rng=random.Random(12)
|
||||
)
|
||||
out = reselect_clips_from_atoms(self._src(5), pool, rng=random.Random(13))
|
||||
assert out is not None
|
||||
assert len(out) == 5
|
||||
ids = [c["atom_clip_id"] for c in out]
|
||||
assert len(set(ids)) == 5
|
||||
for c in out:
|
||||
assert c["asset_id"]
|
||||
assert c["start_time"] >= 0
|
||||
assert c["duration"] > 0
|
||||
|
||||
def test_insufficient_candidates_returns_none(self):
|
||||
pool = compute_atom_clips("a", 10.0, rng=random.Random(1))
|
||||
assert reselect_clips_from_atoms(self._src(20), pool) is None
|
||||
|
||||
def test_non_main_clips_left_untouched(self):
|
||||
pool = compute_atom_clips("a", 30.0, rng=random.Random(8))
|
||||
src = [
|
||||
{"order": 0, "clip_type": "intro", "duration": 2.0, "asset_id": "fixed"},
|
||||
{"order": 1, "clip_type": "main", "duration": 4.0},
|
||||
]
|
||||
out = reselect_clips_from_atoms(src, pool, rng=random.Random(3))
|
||||
assert out is not None
|
||||
assert out[0]["asset_id"] == "fixed"
|
||||
assert "atom_clip_id" not in out[0]
|
||||
assert out[1].get("atom_clip_id")
|
||||
|
||||
def test_empty_inputs(self):
|
||||
assert reselect_clips_from_atoms([], [_clip("a", 0, 4)]) is None
|
||||
assert reselect_clips_from_atoms(self._src(2), []) is None
|
||||
|
||||
def test_batch_used_excluded(self):
|
||||
pool = compute_atom_clips("a", 30.0, rng=random.Random(21))
|
||||
batch_used = {pool[0].id}
|
||||
out = reselect_clips_from_atoms(self._src(3), pool, batch_used_atom_ids=batch_used, rng=random.Random(22))
|
||||
assert out is not None
|
||||
assert pool[0].id not in {c["atom_clip_id"] for c in out}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""#1970 素材原子化切片逻辑单元测试(纯函数,不依赖 DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
from packages.domain.atom_clip_service import (
|
||||
MAX_CLIP_SECONDS,
|
||||
MIN_CLIP_SECONDS,
|
||||
compute_atom_clips,
|
||||
compute_fallback_clips,
|
||||
)
|
||||
|
||||
|
||||
class TestComputeAtomClips:
|
||||
def test_short_asset_under_6s_single_clip(self):
|
||||
"""<6 秒素材整条作为一个片段,不切。"""
|
||||
for dur in (0.1, 3.0, 5.99):
|
||||
clips = compute_atom_clips("a1", dur, rng=random.Random(1))
|
||||
assert len(clips) == 1
|
||||
assert clips[0].start_time == 0.0
|
||||
assert abs(clips[0].end_time - dur) < 0.01
|
||||
assert clips[0].clip_index == 0
|
||||
|
||||
def test_exactly_6s_single_clip(self):
|
||||
clips = compute_atom_clips("a1", 6.0, rng=random.Random(1))
|
||||
assert len(clips) == 1
|
||||
assert clips[0].start_time == 0.0
|
||||
|
||||
def test_zero_and_negative_duration_returns_empty(self):
|
||||
assert compute_atom_clips("a1", 0) == []
|
||||
assert compute_atom_clips("a1", -1.0) == []
|
||||
|
||||
@pytest.mark.parametrize("seed", range(30))
|
||||
def test_clips_in_3_to_6_range(self, seed):
|
||||
"""除末段外,每段时长在 3~6 秒;末段 >=3 秒。"""
|
||||
clips = compute_atom_clips("a1", 60.0, rng=random.Random(seed))
|
||||
assert len(clips) >= 2
|
||||
for clip in clips[:-1]:
|
||||
assert MIN_CLIP_SECONDS - 0.06 <= clip.duration <= MAX_CLIP_SECONDS + 0.06
|
||||
# 末段 >=3(不足 3 应已合并)
|
||||
assert clips[-1].duration >= MIN_CLIP_SECONDS - 0.06
|
||||
|
||||
@pytest.mark.parametrize("dur", [6.01, 7.0, 9.0, 12.3, 30.0, 45.3, 100.0])
|
||||
def test_full_coverage_no_gaps_no_overlap(self, dur):
|
||||
clips = compute_atom_clips("a1", dur, rng=random.Random(int(dur * 100) % 10000))
|
||||
assert abs(clips[0].start_time) < 0.001
|
||||
assert abs(clips[-1].end_time - dur) < 0.01
|
||||
for prev, nxt in zip(clips, clips[1:], strict=False):
|
||||
assert abs(prev.end_time - nxt.start_time) < 0.001
|
||||
|
||||
def test_clip_index_sequential(self):
|
||||
clips = compute_atom_clips("a1", 40.0, rng=random.Random(5))
|
||||
assert [c.clip_index for c in clips] == list(range(len(clips)))
|
||||
|
||||
def test_tail_shorter_than_3s_merges_into_previous(self):
|
||||
"""末段不足 3 秒必须合并到前一段。"""
|
||||
# 多跑种子,保证任何随机结果都不存在 <3s 的末段
|
||||
for seed in range(100):
|
||||
clips = compute_atom_clips("a1", 7.5, rng=random.Random(seed))
|
||||
assert clips[-1].duration >= MIN_CLIP_SECONDS - 0.06
|
||||
assert abs(clips[-1].end_time - 7.5) < 0.01
|
||||
|
||||
def test_tail_between_3_and_6_stands_alone(self):
|
||||
"""末段 >=3 秒独立成段。"""
|
||||
found_standalone = False
|
||||
for seed in range(100):
|
||||
clips = compute_atom_clips("a1", 9.5, rng=random.Random(seed))
|
||||
if len(clips) == 2:
|
||||
found_standalone = True
|
||||
assert clips[-1].duration >= MIN_CLIP_SECONDS - 0.06
|
||||
assert found_standalone, "9.5s 至少在某些种子下应切为两段"
|
||||
|
||||
def test_scene_change_snap_within_window(self):
|
||||
"""切点 0.5s 窗口内有切换点时,切点对齐到切换处。"""
|
||||
aligned = 0
|
||||
for seed in range(500):
|
||||
clips = compute_atom_clips("a1", 20.0, scene_change_points=[4.52], rng=random.Random(seed))
|
||||
if any(c.scene_change_at == 4.52 for c in clips):
|
||||
aligned += 1
|
||||
hit = next(c for c in clips if c.scene_change_at == 4.52)
|
||||
# 命中片段的右边界即切换点
|
||||
assert abs(hit.end_time - 4.52) < 0.001
|
||||
assert aligned > 0
|
||||
|
||||
def test_scene_change_outside_window_not_force_aligned(self):
|
||||
"""窗口外的切换点不应强行对齐。"""
|
||||
clips = compute_atom_clips("a1", 30.0, scene_change_points=[15.0], rng=random.Random(1))
|
||||
for c in clips:
|
||||
if c.scene_change_at is not None:
|
||||
assert abs(c.end_time - c.scene_change_at) < 0.001
|
||||
|
||||
def test_scene_snap_never_creates_sub_3s_clip(self):
|
||||
"""对齐不能导致片段短于 3 秒。"""
|
||||
for seed in range(100):
|
||||
clips = compute_atom_clips("a1", 40.0, scene_change_points=[3.2, 6.3, 9.4], rng=random.Random(seed))
|
||||
for c in clips:
|
||||
assert c.duration >= MIN_CLIP_SECONDS - 0.06
|
||||
|
||||
def test_scene_points_out_of_duration_ignored(self):
|
||||
clips = compute_atom_clips("a1", 20.0, scene_change_points=[-1.0, 25.0, 4.0], rng=random.Random(3))
|
||||
assert all(c.scene_change_at != -1.0 and c.scene_change_at != 25.0 for c in clips)
|
||||
|
||||
def test_tags_inherited(self):
|
||||
clips = compute_atom_clips("a1", 30.0, tags=["t1", "t2"], rng=random.Random(2))
|
||||
assert all(c.tags == ["t1", "t2"] for c in clips)
|
||||
|
||||
def test_random_not_fixed_rhythm(self):
|
||||
"""随机切片:不同种子产出的切点集合应不同(避免固定节奏)。"""
|
||||
cuts1 = [c.end_time for c in compute_atom_clips("a1", 60.0, rng=random.Random(1))]
|
||||
cuts2 = [c.end_time for c in compute_atom_clips("a1", 60.0, rng=random.Random(2))]
|
||||
assert cuts1 != cuts2
|
||||
|
||||
def test_seed_reproducible(self):
|
||||
"""相同种子结果可复现。"""
|
||||
a = [(c.start_time, c.end_time) for c in compute_atom_clips("a1", 60.0, rng=random.Random(42))]
|
||||
b = [(c.start_time, c.end_time) for c in compute_atom_clips("a1", 60.0, rng=random.Random(42))]
|
||||
assert a == b
|
||||
|
||||
|
||||
class TestComputeFallbackClips:
|
||||
def test_fallback_marked_and_uniform(self):
|
||||
clips = compute_fallback_clips("a1", 20.0, clip_seconds=4.5)
|
||||
assert clips
|
||||
assert all(c.is_fallback for c in clips)
|
||||
for prev, nxt in zip(clips, clips[1:], strict=False):
|
||||
assert abs(prev.end_time - nxt.start_time) < 0.001
|
||||
assert abs(clips[-1].end_time - 20.0) < 0.01
|
||||
|
||||
def test_fallback_tail_merge(self):
|
||||
"""11.5s = 4.5+4.5+2.5 → 末段 2.5<3 合并 → 4.5+7.0。"""
|
||||
clips = compute_fallback_clips("a1", 11.5, clip_seconds=4.5)
|
||||
assert len(clips) == 2
|
||||
assert abs(clips[-1].duration - 7.0) < 0.01
|
||||
|
||||
def test_fallback_short_asset(self):
|
||||
clips = compute_fallback_clips("a1", 2.0)
|
||||
assert len(clips) == 1
|
||||
assert clips[0].is_fallback
|
||||
|
||||
def test_fallback_invalid_duration(self):
|
||||
assert compute_fallback_clips("a1", 0) == []
|
||||
assert compute_fallback_clips("a1", -5) == []
|
||||
|
||||
def test_fallback_clip_has_no_persisted_id(self):
|
||||
clips = compute_fallback_clips("a1", 10.0)
|
||||
# 兜底片段仍有运行时 id(dataclass 生成),但 is_fallback 是判别标记
|
||||
assert all(isinstance(c, AssetAtomClip) for c in clips)
|
||||
@@ -0,0 +1,292 @@
|
||||
"""#1970 P2 片段级 AI 标签模块测试。
|
||||
|
||||
测试范围:
|
||||
- build_vision_prompt: 返回有效 prompt
|
||||
- parse_vision_response: 正常/异常/空值
|
||||
- tag_atom_clip: 成功/MediaKit不可用/视觉API失败/超时降级
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.atom_clip_tagger import (
|
||||
build_vision_prompt,
|
||||
parse_vision_response,
|
||||
tag_atom_clip,
|
||||
)
|
||||
|
||||
# ── Fake 对象 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeClip:
|
||||
id: str = "clip-001"
|
||||
asset_id: str = "asset-001"
|
||||
start_time: float = 0.0
|
||||
end_time: float = 5.0
|
||||
duration: float = 5.0
|
||||
clip_index: int = 0
|
||||
tags: list[str] = field(default_factory=lambda: ["tag1", "tag2"])
|
||||
ai_tags: dict | None = None
|
||||
|
||||
|
||||
class FakeDoubaoClient:
|
||||
"""模拟豆包客户端."""
|
||||
|
||||
def __init__(self, available: bool = True, response: str | None = None, raise_error: bool = False):
|
||||
self._available = available
|
||||
self._response = response
|
||||
self._raise_error = raise_error
|
||||
self.vision_calls: list[dict] = []
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
def vision_completion(self, messages, images=None, timeout=None, **kwargs):
|
||||
self.vision_calls.append({"messages": messages, "images": images, "timeout": timeout})
|
||||
if self._raise_error:
|
||||
raise RuntimeError("API error")
|
||||
return self._response
|
||||
|
||||
|
||||
class FakeMediaKitClient:
|
||||
"""模拟 MediaKit 客户端."""
|
||||
|
||||
def __init__(self, available: bool = True, frames: list[dict] | None = None):
|
||||
self._available = available
|
||||
self._frames = frames
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
def extract_frames(self, video_url, strategy=None, max_frames=None, **kwargs):
|
||||
return self._frames
|
||||
|
||||
|
||||
# ── build_vision_prompt ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildVisionPrompt:
|
||||
def test_returns_non_empty_string(self):
|
||||
prompt = build_vision_prompt()
|
||||
assert isinstance(prompt, str)
|
||||
assert len(prompt) > 100
|
||||
|
||||
def test_contains_required_keys(self):
|
||||
prompt = build_vision_prompt()
|
||||
assert "scene" in prompt
|
||||
assert "objects" in prompt
|
||||
assert "action" in prompt
|
||||
assert "shot" in prompt
|
||||
assert "has_text" in prompt
|
||||
|
||||
def test_requests_json_format(self):
|
||||
prompt = build_vision_prompt()
|
||||
assert "JSON" in prompt or "json" in prompt
|
||||
|
||||
|
||||
# ── parse_vision_response ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseVisionResponse:
|
||||
def test_valid_json(self):
|
||||
response = json.dumps(
|
||||
{
|
||||
"scene": ["工厂", "车间"],
|
||||
"objects": ["产品", "机器"],
|
||||
"action": ["演示"],
|
||||
"shot": "特写",
|
||||
"has_text": True,
|
||||
}
|
||||
)
|
||||
result = parse_vision_response(response)
|
||||
assert result["scene"] == ["工厂", "车间"]
|
||||
assert result["objects"] == ["产品", "机器"]
|
||||
assert result["action"] == ["演示"]
|
||||
assert result["shot"] == "特写"
|
||||
assert result["has_text"] is True
|
||||
|
||||
def test_json_with_markdown_code_block(self):
|
||||
response = '```json\n{"scene": ["办公室"], "objects": ["电脑"], "action": ["说话"], "shot": "中景", "has_text": false}\n```'
|
||||
result = parse_vision_response(response)
|
||||
assert result["scene"] == ["办公室"]
|
||||
assert result["has_text"] is False
|
||||
|
||||
def test_json_embedded_in_text(self):
|
||||
response = '这是一些说明文字\n{"scene": ["户外"], "objects": ["汽车"], "action": ["展示"], "shot": "远景", "has_text": false}\n结束'
|
||||
result = parse_vision_response(response)
|
||||
assert result["scene"] == ["户外"]
|
||||
|
||||
def test_empty_response(self):
|
||||
assert parse_vision_response("") == {}
|
||||
assert parse_vision_response(None) == {}
|
||||
assert parse_vision_response(" ") == {}
|
||||
|
||||
def test_invalid_json(self):
|
||||
assert parse_vision_response("这不是JSON") == {}
|
||||
|
||||
def test_partial_fields(self):
|
||||
response = json.dumps({"scene": ["工厂"]})
|
||||
result = parse_vision_response(response)
|
||||
assert result["scene"] == ["工厂"]
|
||||
assert result["objects"] == []
|
||||
assert result["shot"] == ""
|
||||
assert result["has_text"] is False
|
||||
|
||||
def test_invalid_shot_value(self):
|
||||
response = json.dumps({"scene": [], "objects": [], "action": [], "shot": "全景", "has_text": False})
|
||||
result = parse_vision_response(response)
|
||||
# "全景" 不在有效值 ("特写", "中景", "远景") 中
|
||||
assert result["shot"] == ""
|
||||
|
||||
def test_string_values_converted_to_list(self):
|
||||
response = json.dumps(
|
||||
{"scene": "工厂", "objects": "产品", "action": "演示", "shot": "特写", "has_text": "true"}
|
||||
)
|
||||
result = parse_vision_response(response)
|
||||
assert result["scene"] == ["工厂"]
|
||||
assert result["objects"] == ["产品"]
|
||||
assert result["has_text"] is True
|
||||
|
||||
def test_non_dict_json(self):
|
||||
assert parse_vision_response("[1, 2, 3]") == {}
|
||||
assert parse_vision_response('"hello"') == {}
|
||||
|
||||
|
||||
# ── tag_atom_clip ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTagAtomClip:
|
||||
def test_success_with_mediakit(self):
|
||||
"""MediaKit 可用 + 视觉 API 成功 → 返回完整 AI 标签."""
|
||||
clip = FakeClip()
|
||||
fake_doubao = FakeDoubaoClient(
|
||||
response=json.dumps(
|
||||
{
|
||||
"scene": ["工厂"],
|
||||
"objects": ["产品"],
|
||||
"action": ["演示"],
|
||||
"shot": "特写",
|
||||
"has_text": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
fake_mediakit = FakeMediaKitClient(
|
||||
frames=[
|
||||
{"image_url": "https://example.com/frame1.jpg", "timestamp": 0.0},
|
||||
{"image_url": "https://example.com/frame2.jpg", "timestamp": 2.5},
|
||||
{"image_url": "https://example.com/frame3.jpg", "timestamp": 5.0},
|
||||
]
|
||||
)
|
||||
|
||||
result = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url="https://example.com/video.mp4",
|
||||
doubao_client=fake_doubao,
|
||||
mediakit_client=fake_mediakit,
|
||||
)
|
||||
|
||||
assert result["scene"] == ["工厂"]
|
||||
assert result["objects"] == ["产品"]
|
||||
assert result["shot"] == "特写"
|
||||
assert result["inherited_tags"] == ["tag1", "tag2"]
|
||||
assert len(fake_doubao.vision_calls) == 1
|
||||
|
||||
def test_doubao_unavailable_returns_inherited(self):
|
||||
"""DoubaoClient 不可用 → 返回 inherited_tags."""
|
||||
clip = FakeClip()
|
||||
fake_doubao = FakeDoubaoClient(available=False)
|
||||
|
||||
result = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url="https://example.com/video.mp4",
|
||||
doubao_client=fake_doubao,
|
||||
)
|
||||
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
assert len(fake_doubao.vision_calls) == 0
|
||||
|
||||
def test_mediakit_unavailable_no_ffmpeg(self):
|
||||
"""MediaKit 不可用 + 无 ffmpeg → 降级 inherited_tags."""
|
||||
clip = FakeClip()
|
||||
fake_doubao = FakeDoubaoClient()
|
||||
fake_mediakit = FakeMediaKitClient(available=False)
|
||||
|
||||
result = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url="https://example.com/video.mp4",
|
||||
doubao_client=fake_doubao,
|
||||
mediakit_client=fake_mediakit,
|
||||
)
|
||||
|
||||
# 没有 ffmpeg 的情况下,帧提取失败
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
|
||||
def test_vision_api_error_returns_inherited(self):
|
||||
"""视觉 API 抛异常 → 降级 inherited_tags."""
|
||||
clip = FakeClip()
|
||||
fake_doubao = FakeDoubaoClient(raise_error=True)
|
||||
fake_mediakit = FakeMediaKitClient(frames=[{"image_url": "https://example.com/frame.jpg", "timestamp": 0.0}])
|
||||
|
||||
result = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url="https://example.com/video.mp4",
|
||||
doubao_client=fake_doubao,
|
||||
mediakit_client=fake_mediakit,
|
||||
)
|
||||
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
|
||||
def test_vision_api_empty_response(self):
|
||||
"""视觉 API 返回空 → 降级 inherited_tags."""
|
||||
clip = FakeClip()
|
||||
fake_doubao = FakeDoubaoClient(response=None)
|
||||
fake_mediakit = FakeMediaKitClient(frames=[{"image_url": "https://example.com/frame.jpg", "timestamp": 0.0}])
|
||||
|
||||
result = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url="https://example.com/video.mp4",
|
||||
doubao_client=fake_doubao,
|
||||
mediakit_client=fake_mediakit,
|
||||
)
|
||||
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
|
||||
def test_vision_api_invalid_json_response(self):
|
||||
"""视觉 API 返回无效 JSON → 降级 inherited_tags."""
|
||||
clip = FakeClip()
|
||||
fake_doubao = FakeDoubaoClient(response="这不是JSON格式")
|
||||
fake_mediakit = FakeMediaKitClient(frames=[{"image_url": "https://example.com/frame.jpg", "timestamp": 0.0}])
|
||||
|
||||
result = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url="https://example.com/video.mp4",
|
||||
doubao_client=fake_doubao,
|
||||
mediakit_client=fake_mediakit,
|
||||
)
|
||||
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
|
||||
def test_clip_with_empty_tags(self):
|
||||
"""空素材标签 → inherited_tags 为空列表."""
|
||||
clip = FakeClip(tags=[])
|
||||
fake_doubao = FakeDoubaoClient(available=False)
|
||||
|
||||
result = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url="https://example.com/video.mp4",
|
||||
doubao_client=fake_doubao,
|
||||
)
|
||||
|
||||
assert result == {"inherited_tags": []}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
@@ -0,0 +1,181 @@
|
||||
"""#1970 PlanGeneratorService 原子片段选片端到端单元测试.
|
||||
|
||||
用 SQLite 内存库 + 真实仓储验证:注入 atom_clip_repo 后,正式生成(非预览)
|
||||
从原子片段选片,EditPlanClip.atom_clip_id 落库;预览模式保持旧路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
|
||||
class _FakeAsset:
|
||||
def __init__(self, aid, duration):
|
||||
self.id = aid
|
||||
self.duration = duration
|
||||
self.quality_score = 60.0
|
||||
self.metadata = {}
|
||||
self.created_at = None
|
||||
|
||||
|
||||
class FakeAssetRepo:
|
||||
def __init__(self, durations):
|
||||
self._durations = durations
|
||||
|
||||
def get(self, aid):
|
||||
return _FakeAsset(aid, self._durations[aid]) if aid in self._durations else None
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session():
|
||||
engine = create_engine("sqlite://")
|
||||
# 只建相关表,避免全模型依赖
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Base.metadata.tables["edit_plans"],
|
||||
Base.metadata.tables["edit_plan_clips"],
|
||||
Base.metadata.tables["asset_atom_clips"],
|
||||
],
|
||||
)
|
||||
connection = engine.connect()
|
||||
Session = sessionmaker(bind=connection)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
connection.close()
|
||||
|
||||
|
||||
def _template(mode=EditingMode.ONE_TAKE.value):
|
||||
return EditTemplate(
|
||||
id="tpl-1",
|
||||
name="测试模板",
|
||||
editing_mode=mode,
|
||||
status=EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
|
||||
|
||||
def _clip_configs(n=3):
|
||||
return [
|
||||
TemplateClipConfig(
|
||||
id=f"cfg-{i}",
|
||||
template_id="tpl-1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=i,
|
||||
min_duration=3.0,
|
||||
max_duration=6.0,
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
class TestAtomClipPlanGeneration:
|
||||
def test_generation_uses_atom_clips(self, db_session):
|
||||
atom_repo = SQLAlchemyAssetAtomClipRepository(db_session)
|
||||
# 两个素材各 30s,各切若干片段
|
||||
clips_a = [AssetAtomClip.create("asset-a", i * 5.0, i * 5.0 + 5.0, i) for i in range(6)]
|
||||
clips_b = [AssetAtomClip.create("asset-b", i * 5.0, i * 5.0 + 5.0, i) for i in range(6)]
|
||||
atom_repo.batch_create(clips_a + clips_b)
|
||||
db_session.commit()
|
||||
|
||||
svc = PlanGeneratorService(
|
||||
db_session,
|
||||
asset_repo=FakeAssetRepo({"asset-a": 30.0, "asset-b": 30.0}),
|
||||
atom_clip_repo=atom_repo,
|
||||
)
|
||||
result = svc.generate_from_template(
|
||||
template=_template(),
|
||||
clip_configs=_clip_configs(3),
|
||||
asset_ids=["asset-a", "asset-b"],
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
clips = result["clips"]
|
||||
assert len(clips) == 3
|
||||
# 每个 clip 都绑定了原子片段
|
||||
atom_ids = [c.atom_clip_id for c in clips]
|
||||
assert all(atom_ids)
|
||||
# 同一原子片段一个视频只用一次
|
||||
assert len(set(atom_ids)) == 3
|
||||
# start_time/duration 与选中片段一致
|
||||
for c in clips:
|
||||
assert c.start_time >= 0
|
||||
assert 0 < c.duration <= 6.0 + 0.01
|
||||
# asset_id 与 atom_clip 归属一致
|
||||
for c in clips:
|
||||
assert c.asset_id.startswith("asset-")
|
||||
|
||||
def test_fallback_when_atom_clips_not_ready(self, db_session):
|
||||
"""素材没有 atom_clips 时内存兜底切片,仍能选出片段。"""
|
||||
atom_repo = SQLAlchemyAssetAtomClipRepository(db_session)
|
||||
svc = PlanGeneratorService(
|
||||
db_session,
|
||||
asset_repo=FakeAssetRepo({"old-asset": 20.0}),
|
||||
atom_clip_repo=atom_repo,
|
||||
)
|
||||
result = svc.generate_from_template(
|
||||
template=_template(),
|
||||
clip_configs=_clip_configs(3),
|
||||
asset_ids=["old-asset"],
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
clips = result["clips"]
|
||||
# 兜底片段不落库、无持久 ID,clip 不绑定 atom_clip_id(回退旧路径)或绑定运行时 ID
|
||||
# 关键:必须成功选出素材,不报错
|
||||
assert all(c.asset_id == "old-asset" for c in clips)
|
||||
|
||||
def test_preview_mode_keeps_legacy_path(self, db_session):
|
||||
"""随机预览模式走旧路径,不要求 atom clips。"""
|
||||
atom_repo = SQLAlchemyAssetAtomClipRepository(db_session)
|
||||
svc = PlanGeneratorService(
|
||||
db_session,
|
||||
asset_repo=FakeAssetRepo({"asset-a": 30.0, "asset-b": 30.0, "asset-c": 30.0}),
|
||||
atom_clip_repo=atom_repo,
|
||||
)
|
||||
result = svc.generate_from_template(
|
||||
template=_template(),
|
||||
clip_configs=_clip_configs(3),
|
||||
asset_ids=["asset-a", "asset-b", "asset-c"],
|
||||
created_by_user_id="user-1",
|
||||
random_preview=True,
|
||||
)
|
||||
clips = result["clips"]
|
||||
assert len(clips) == 3
|
||||
assert {c.asset_id for c in clips} == {"asset-a", "asset-b", "asset-c"}
|
||||
# 预览路径不绑定 atom_clip_id
|
||||
assert all(not c.atom_clip_id for c in clips)
|
||||
|
||||
def test_no_atom_repo_uses_legacy_path(self, db_session):
|
||||
"""未注入 atom_clip_repo(旧调用方)时行为不变。"""
|
||||
svc = PlanGeneratorService(
|
||||
db_session,
|
||||
asset_repo=FakeAssetRepo({"asset-a": 30.0, "asset-b": 30.0, "asset-c": 30.0}),
|
||||
)
|
||||
result = svc.generate_from_template(
|
||||
template=_template(),
|
||||
clip_configs=_clip_configs(3),
|
||||
asset_ids=["asset-a", "asset-b", "asset-c"],
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
clips = result["clips"]
|
||||
assert len(clips) == 3
|
||||
assert {c.asset_id for c in clips} == {"asset-a", "asset-b", "asset-c"}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""#1970 AI 标签 Celery 任务注册回归测试。
|
||||
|
||||
背景:staging 上 worker.generate_atom_clips 正常派发 tag_atom_clip,
|
||||
但消费端报 "Received unregistered task of type 'worker.tag_atom_clip'",
|
||||
根因是 celery_app.conf.imports 漏列任务模块,worker 进程从未 import 之。
|
||||
|
||||
注意:tests/unit 下大量旧测试在 import 期向 sys.modules 注入
|
||||
worker_app.celery_app 的 MagicMock 且不还原,全量收集时会污染本测试,
|
||||
因此这里用 AST 静态解析 + 隔离子进程验证,不依赖 sys.modules 状态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
CELERY_APP_PY = REPO_ROOT / "apps" / "worker" / "worker_app" / "celery_app.py"
|
||||
|
||||
REQUIRED_MODULES = (
|
||||
"worker_app.tasks.atom_clip_tagging",
|
||||
"worker_app.tasks.backfill_atom_clip_tags",
|
||||
)
|
||||
|
||||
|
||||
def _conf_imports_values() -> set[str]:
|
||||
"""从 celery_app.py AST 中提取 celery_app.conf.imports 元组的字符串项。"""
|
||||
tree = ast.parse(CELERY_APP_PY.read_text(encoding="utf-8"))
|
||||
values: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Assign) and len(node.targets) == 1):
|
||||
continue
|
||||
target = node.targets[0]
|
||||
# celery_app.conf.imports = (...) 或 conf.imports = (...)
|
||||
if not (isinstance(target, ast.Attribute) and target.attr == "imports"):
|
||||
continue
|
||||
if isinstance(node.value, (ast.Tuple, ast.List)):
|
||||
for elt in node.value.elts:
|
||||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
||||
values.add(elt.value)
|
||||
return values
|
||||
|
||||
|
||||
def test_ai_tag_modules_in_celery_imports():
|
||||
imports = _conf_imports_values()
|
||||
for module in REQUIRED_MODULES:
|
||||
assert module in imports, f"{module} 未加入 celery_app.conf.imports"
|
||||
|
||||
|
||||
def test_ai_tag_tasks_registered_in_isolated_process():
|
||||
"""隔离子进程(无 conftest / 无 sys.modules mock)真实加载 Celery app。"""
|
||||
# 模拟 worker 启动时按 conf.imports import 任务模块的行为;
|
||||
# 只导入 AI 标签两个模块(其他模块依赖 cv2 等本地未安装的重依赖)。
|
||||
code = (
|
||||
"import importlib, sys; "
|
||||
"from worker_app.celery_app import celery_app; "
|
||||
"mods = [m for m in celery_app.conf.imports or () "
|
||||
"if 'atom_clip_tagging' in m or 'backfill_atom_clip_tags' in m]; "
|
||||
"[importlib.import_module(m) for m in mods]; "
|
||||
"missing = [n for n in "
|
||||
"['worker.tag_atom_clip', 'worker.backfill_atom_clip_tags'] "
|
||||
"if n not in celery_app.tasks]; "
|
||||
"sys.exit(1 if missing or len(mods) < 2 else 0)"
|
||||
)
|
||||
env = os.environ.copy()
|
||||
paths = [
|
||||
str(REPO_ROOT),
|
||||
str(REPO_ROOT / "apps" / "worker"),
|
||||
str(REPO_ROOT / "packages"),
|
||||
]
|
||||
env["PYTHONPATH"] = os.pathsep.join(paths) + os.pathsep + env.get("PYTHONPATH", "")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, "隔离子进程中任务未注册成功:\n" f"stdout={result.stdout}\nstderr={result.stderr}"
|
||||
@@ -0,0 +1,347 @@
|
||||
"""#1970 force 回填降级 AI 标签记录的回归测试。
|
||||
|
||||
背景:DOUBAO_VISION_MODEL 未配置时,tagger 降级写入
|
||||
{"inherited_tags": [...]}(非 NULL),默认 backfill 只捞 ai_tags IS NULL,
|
||||
这批记录永远不会重打。force=True 时应纳入降级记录,并在打标成功后覆盖。
|
||||
|
||||
覆盖:
|
||||
- find_untagged(include_downgraded) 的 SQL 过滤(SQLite 验证跨库 JSON 取值)
|
||||
- tag_atom_clip_task 的 force 跳过/放行/覆盖逻辑
|
||||
- backfill_atom_clip_tags(force=True) 给 tag 任务传 kwargs={"force": True}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
|
||||
SQLAlchemyAssetAtomClipRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
|
||||
|
||||
# ── 仓储层:find_untagged 过滤 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
AssetAtomClipModel.__table__.create(engine)
|
||||
SessionTest = sessionmaker(bind=engine)
|
||||
session = SessionTest()
|
||||
now = datetime.now(UTC)
|
||||
session.add_all(
|
||||
[
|
||||
AssetAtomClipModel(
|
||||
id="c-null",
|
||||
asset_id="a1",
|
||||
start_time=0,
|
||||
end_time=1,
|
||||
duration=1,
|
||||
clip_index=0,
|
||||
tags=[],
|
||||
ai_tags=None,
|
||||
created_at=now,
|
||||
),
|
||||
AssetAtomClipModel(
|
||||
id="c-downgraded-empty",
|
||||
asset_id="a1",
|
||||
start_time=1,
|
||||
end_time=2,
|
||||
duration=1,
|
||||
clip_index=1,
|
||||
tags=[],
|
||||
ai_tags={"inherited_tags": []},
|
||||
created_at=now,
|
||||
),
|
||||
AssetAtomClipModel(
|
||||
id="c-downgraded-tags",
|
||||
asset_id="a1",
|
||||
start_time=2,
|
||||
end_time=3,
|
||||
duration=1,
|
||||
clip_index=2,
|
||||
tags=[],
|
||||
ai_tags={"inherited_tags": ["口播"]},
|
||||
created_at=now,
|
||||
),
|
||||
AssetAtomClipModel(
|
||||
id="c-tagged-true",
|
||||
asset_id="a1",
|
||||
start_time=3,
|
||||
end_time=4,
|
||||
duration=1,
|
||||
clip_index=3,
|
||||
tags=[],
|
||||
ai_tags={"has_text": True, "scene": ["室内"], "inherited_tags": []},
|
||||
created_at=now,
|
||||
),
|
||||
AssetAtomClipModel(
|
||||
id="c-tagged-false",
|
||||
asset_id="a1",
|
||||
start_time=4,
|
||||
end_time=5,
|
||||
duration=1,
|
||||
clip_index=4,
|
||||
tags=[],
|
||||
ai_tags={"has_text": False, "inherited_tags": ["风景"]},
|
||||
created_at=now,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
# SQLAlchemy JSON 在 SQLite 下把 None 序列化为 'null' 字符串,
|
||||
# 而生产 PostgreSQL 存的是真 SQL NULL;用原生 SQL 对齐生产语义。
|
||||
from sqlalchemy import text
|
||||
|
||||
session.execute(text("UPDATE asset_atom_clips SET ai_tags = NULL WHERE id = 'c-null'"))
|
||||
session.commit()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def test_find_untagged_default_only_null(repo_session):
|
||||
repo = SQLAlchemyAssetAtomClipRepository(repo_session)
|
||||
ids = {c.id for c in repo.find_untagged(limit=100)}
|
||||
assert ids == {"c-null"}
|
||||
|
||||
|
||||
def test_find_untagged_include_downgraded(repo_session):
|
||||
repo = SQLAlchemyAssetAtomClipRepository(repo_session)
|
||||
ids = {c.id for c in repo.find_untagged(limit=100, include_downgraded=True)}
|
||||
# NULL + 两条降级记录;含 has_text=true/false 的完整记录都排除
|
||||
assert ids == {"c-null", "c-downgraded-empty", "c-downgraded-tags"}
|
||||
|
||||
|
||||
# ── 任务层:tag_atom_clip_task 的 force 语义 ───────────────────────────────
|
||||
|
||||
|
||||
def _import_tag_task_module():
|
||||
from worker_app.tasks import atom_clip_tagging as mod
|
||||
|
||||
return mod
|
||||
|
||||
|
||||
def _call_tag_task(mod, clip_id, force):
|
||||
"""直接调用任务,兼容两种环境。
|
||||
|
||||
全量收集时旧测试向 sys.modules 注入 celery_app MagicMock(其 task
|
||||
装饰器原样返回裸函数),此时是普通函数需显式传 self=None;
|
||||
正常 Celery 环境下属性是 Task 代理对象(非普通 function),
|
||||
已绑定 self,按业务签名直接调用即可。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
obj = mod.tag_atom_clip_task
|
||||
if inspect.isfunction(obj):
|
||||
return obj(None, clip_id, force=force)
|
||||
return obj(clip_id, force=force)
|
||||
|
||||
|
||||
def test_tag_task_skips_downgraded_without_force(monkeypatch):
|
||||
mod = _import_tag_task_module()
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"SessionLocal",
|
||||
lambda: SimpleNamespace(
|
||||
rollback=lambda: None,
|
||||
close=lambda: None,
|
||||
),
|
||||
)
|
||||
|
||||
class _Repo:
|
||||
def __init__(self, db):
|
||||
pass
|
||||
|
||||
def find_by_id(self, clip_id):
|
||||
return SimpleNamespace(
|
||||
id=clip_id,
|
||||
ai_tags={"inherited_tags": []},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod, "SQLAlchemyAssetAtomClipRepository", _Repo)
|
||||
|
||||
result = _call_tag_task(mod, "clip-downgraded", force=False)
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "already tagged"
|
||||
|
||||
|
||||
def test_tag_task_force_retags_downgraded_and_overwrites(monkeypatch):
|
||||
mod = _import_tag_task_module()
|
||||
updated: dict[str, dict] = {}
|
||||
|
||||
class _FakeSession:
|
||||
def rollback(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mod, "SessionLocal", _FakeSession)
|
||||
|
||||
class _AtomRepo:
|
||||
def __init__(self, db):
|
||||
pass
|
||||
|
||||
def find_by_id(self, clip_id):
|
||||
return SimpleNamespace(
|
||||
id=clip_id,
|
||||
asset_id="asset-1",
|
||||
start_time=0.0,
|
||||
end_time=2.0,
|
||||
tags=["旧标签"],
|
||||
ai_tags={"inherited_tags": []},
|
||||
)
|
||||
|
||||
def update_ai_tags(self, clip_id, ai_tags):
|
||||
updated[clip_id] = ai_tags
|
||||
|
||||
class _AssetRepo:
|
||||
def __init__(self, db):
|
||||
pass
|
||||
|
||||
def find_by_id(self, asset_id):
|
||||
return SimpleNamespace(id=asset_id, storage_key="k/video.mp4")
|
||||
|
||||
monkeypatch.setattr(mod, "SQLAlchemyAssetAtomClipRepository", _AtomRepo)
|
||||
monkeypatch.setattr(mod, "SQLAlchemyAssetRepository", _AssetRepo)
|
||||
|
||||
class _Storage:
|
||||
def get_download_url(self, key, expires_seconds=3600):
|
||||
return "https://example.com/signed.mp4"
|
||||
|
||||
monkeypatch.setattr(mod, "get_shared_storage_service", lambda: _Storage())
|
||||
monkeypatch.setattr(mod, "get_doubao_client", lambda: object())
|
||||
monkeypatch.setattr(mod, "get_mediakit_client", lambda: None)
|
||||
|
||||
new_tags = {
|
||||
"scene": ["室内"],
|
||||
"objects": ["人物"],
|
||||
"action": ["说话"],
|
||||
"shot": "中景",
|
||||
"has_text": True,
|
||||
"inherited_tags": ["旧标签"],
|
||||
}
|
||||
monkeypatch.setattr(mod, "tag_atom_clip", lambda **kw: new_tags)
|
||||
|
||||
result = _call_tag_task(mod, "clip-downgraded", force=True)
|
||||
assert result["status"] == "completed"
|
||||
assert result["has_ai_tags"] is True
|
||||
assert updated["clip-downgraded"] == new_tags
|
||||
|
||||
|
||||
def test_tag_task_force_still_skips_complete_tags(monkeypatch):
|
||||
mod = _import_tag_task_module()
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"SessionLocal",
|
||||
lambda: SimpleNamespace(rollback=lambda: None, close=lambda: None),
|
||||
)
|
||||
|
||||
class _Repo:
|
||||
def __init__(self, db):
|
||||
pass
|
||||
|
||||
def find_by_id(self, clip_id):
|
||||
return SimpleNamespace(
|
||||
id=clip_id,
|
||||
ai_tags={"has_text": False, "inherited_tags": []},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod, "SQLAlchemyAssetAtomClipRepository", _Repo)
|
||||
|
||||
result = _call_tag_task(mod, "clip-complete", force=True)
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "already tagged"
|
||||
|
||||
|
||||
# ── backfill 任务:force 透传到 send_task ──────────────────────────────────
|
||||
|
||||
|
||||
def test_backfill_force_passes_kwarg(monkeypatch):
|
||||
from worker_app.tasks import backfill_atom_clip_tags as bmod
|
||||
|
||||
sent: list[tuple] = []
|
||||
|
||||
class _FakeSession:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(bmod, "SessionLocal", _FakeSession)
|
||||
|
||||
class _AtomRepo:
|
||||
def __init__(self, db):
|
||||
self.calls: list[bool] = []
|
||||
|
||||
def find_untagged(self, limit, include_downgraded=False):
|
||||
self.calls.append(include_downgraded)
|
||||
# 第一批返回一条降级记录,第二批返回空结束循环
|
||||
if len(self.calls) == 1:
|
||||
return [SimpleNamespace(id="clip-1")]
|
||||
return []
|
||||
|
||||
repo_holder = {}
|
||||
|
||||
def _repo_factory(db):
|
||||
repo = _AtomRepo(db)
|
||||
repo_holder["repo"] = repo
|
||||
return repo
|
||||
|
||||
monkeypatch.setattr(bmod, "SQLAlchemyAssetAtomClipRepository", _repo_factory)
|
||||
|
||||
def _send_task(name, args=None, kwargs=None):
|
||||
sent.append((name, args, kwargs))
|
||||
|
||||
monkeypatch.setattr(bmod.celery_app, "send_task", _send_task)
|
||||
|
||||
result = bmod.backfill_atom_clip_tags(batch_size=10, batch_interval=0, force=True)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["total_submitted"] == 1
|
||||
assert repo_holder["repo"].calls == [True, True]
|
||||
assert sent == [
|
||||
("worker.tag_atom_clip", ["clip-1"], {"force": True}),
|
||||
]
|
||||
|
||||
|
||||
def test_backfill_default_does_not_force(monkeypatch):
|
||||
from worker_app.tasks import backfill_atom_clip_tags as bmod
|
||||
|
||||
sent_kwargs: list[dict | None] = []
|
||||
|
||||
class _FakeSession:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(bmod, "SessionLocal", _FakeSession)
|
||||
|
||||
class _AtomRepo:
|
||||
def __init__(self, db):
|
||||
self.calls: list[bool] = []
|
||||
|
||||
def find_untagged(self, limit, include_downgraded=False):
|
||||
self.calls.append(include_downgraded)
|
||||
return [SimpleNamespace(id="clip-null")] if self.calls == [False] else []
|
||||
|
||||
holder = {}
|
||||
|
||||
def _repo_factory(db):
|
||||
holder["repo"] = _AtomRepo(db)
|
||||
return holder["repo"]
|
||||
|
||||
monkeypatch.setattr(bmod, "SQLAlchemyAssetAtomClipRepository", _repo_factory)
|
||||
monkeypatch.setattr(
|
||||
bmod.celery_app,
|
||||
"send_task",
|
||||
lambda name, args=None, kwargs=None: sent_kwargs.append(kwargs),
|
||||
)
|
||||
|
||||
result = bmod.backfill_atom_clip_tags(batch_size=10, batch_interval=0)
|
||||
|
||||
assert result["total_submitted"] == 1
|
||||
assert holder["repo"].calls == [False, False]
|
||||
assert sent_kwargs == [{"force": False}]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""#1970 GPU Worker 修复单测.
|
||||
|
||||
覆盖 deploy/gpu_worker/gpu_worker.py(独立部署脚本,不在 apps/packages 包内,
|
||||
按文件路径动态加载):
|
||||
1. 默认配置:REQUEST_TIMEOUT=900 / TASK_MAX_RETRY=1 / 心跳 30s / 最短 3s;
|
||||
2. 推理期心跳线程 POST /gpu/register 带 task_id,任务结束能停;
|
||||
3. <3s 短视频直接上报失败,不调用 MuseTalk;
|
||||
4. _call_musetalk 仅对 5xx/网络瞬时错误标记 retryable,4xx 不重试;
|
||||
5. _handle_task 只对 retryable 错误本地重试 1 次。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKER_PATH = ROOT / "deploy" / "gpu_worker" / "gpu_worker.py"
|
||||
|
||||
|
||||
def _load_worker_module():
|
||||
spec = importlib.util.spec_from_file_location("gpu_worker_standalone_1970", WORKER_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker():
|
||||
return _load_worker_module()
|
||||
|
||||
|
||||
# ── 默认配置 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_config_defaults_900_and_retry_one(monkeypatch):
|
||||
"""CI/本机若显式导出过这些 env,说明是运维覆盖,不应拿默认值断言;
|
||||
因此只在四个 env 全部缺失时校验脚本内置默认值(#1970:900/1/30/3)。"""
|
||||
keys = (
|
||||
"REQUEST_TIMEOUT",
|
||||
"TASK_MAX_RETRY",
|
||||
"TASK_HEARTBEAT_INTERVAL",
|
||||
"MIN_VIDEO_DURATION_SECONDS",
|
||||
)
|
||||
if any(k in os.environ for k in keys):
|
||||
pytest.skip("环境显式设置了 worker 超时/重试变量,跳过默认值断言")
|
||||
for key in keys:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
mod = _load_worker_module()
|
||||
assert mod.Config.request_timeout == 900.0
|
||||
assert mod.Config.task_max_retry == 1
|
||||
assert mod.Config.task_heartbeat_interval == 30.0
|
||||
assert mod.Config.min_video_duration_seconds == 3.0
|
||||
|
||||
|
||||
# ── register 携带 task_id ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_register_payload_includes_task_id_only_when_provided(worker, monkeypatch):
|
||||
captured = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def _fake_post(url, json=None, headers=None, timeout=None):
|
||||
captured.append(json)
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(worker.requests, "post", _fake_post)
|
||||
monkeypatch.setattr(worker, "_check_musetalk_health", lambda: (True, {}))
|
||||
|
||||
assert worker._register("task-abc") is True
|
||||
assert captured[-1]["task_id"] == "task-abc"
|
||||
assert captured[-1]["worker_id"]
|
||||
|
||||
worker._register() # 空闲心跳不带 task_id
|
||||
assert "task_id" not in captured[-1]
|
||||
|
||||
|
||||
# ── 推理期心跳线程 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_task_heartbeat_thread_sends_and_stops(worker, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_register(task_id=None):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(worker, "_register", _fake_register)
|
||||
hb = worker.TaskHeartbeat("task-hb1", interval=5)
|
||||
hb.start()
|
||||
time.sleep(0.3) # 启动后立即发一次
|
||||
hb.stop()
|
||||
hb.join(timeout=2)
|
||||
assert not hb.is_alive()
|
||||
assert calls and all(c == "task-hb1" for c in calls)
|
||||
|
||||
|
||||
# ── 短视频前置拦截 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_handle_task_short_video_reports_failed_without_inference(worker, monkeypatch, tmp_path):
|
||||
video = tmp_path / "input.mp4"
|
||||
video.write_bytes(b"fake-mp4-bytes")
|
||||
audio = tmp_path / "input_audio.bin"
|
||||
audio.write_bytes(b"fake-audio")
|
||||
reports = []
|
||||
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
# ffprobe 读出 1.2s → 低于 3s 阈值
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 1.2)
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("短视频不应调用 MuseTalk 推理")
|
||||
|
||||
monkeypatch.setattr(worker, "_call_musetalk", _boom)
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_result",
|
||||
lambda task_id, success, duration=0.0, error_msg="": reports.append((task_id, success, error_msg)) or True,
|
||||
)
|
||||
|
||||
task = {
|
||||
"task_id": "task-short",
|
||||
"video_url": "https://example.com/v.mp4",
|
||||
"audio_url": "https://example.com/a.bin",
|
||||
}
|
||||
worker._handle_task(task)
|
||||
|
||||
assert len(reports) == 1
|
||||
tid, ok, err = reports[0]
|
||||
assert tid == "task-short"
|
||||
assert ok is False
|
||||
assert "视频过短" in err
|
||||
assert "3" in err
|
||||
|
||||
|
||||
def test_handle_task_probe_failure_does_not_block(worker, monkeypatch):
|
||||
"""ffprobe 不可用(duration=0.0)时不能误杀,应继续推理."""
|
||||
reports = []
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 0.0)
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_call_musetalk",
|
||||
lambda v, a, o: (True, 8.0, "", False),
|
||||
)
|
||||
uploaded = []
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_success_with_file",
|
||||
lambda task_id, duration, path: uploaded.append((task_id, duration)),
|
||||
)
|
||||
monkeypatch.setattr(worker, "_report_result", lambda *a, **k: True)
|
||||
|
||||
worker._handle_task({"task_id": "task-probe0", "video_url": "u", "audio_url": "u"})
|
||||
assert uploaded == [("task-probe0", 8.0)]
|
||||
assert reports == []
|
||||
|
||||
|
||||
# ── 重试语义:仅瞬时错误重试 ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_call_musetalk_4xx_not_retryable_5xx_retryable(worker, monkeypatch, tmp_path):
|
||||
video = tmp_path / "v.mp4"
|
||||
audio = tmp_path / "a.bin"
|
||||
video.write_bytes(b"v")
|
||||
audio.write_bytes(b"a")
|
||||
out = tmp_path / "o.mp4"
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, code, body=b"x" * 2048):
|
||||
self.status_code = code
|
||||
self.content = body
|
||||
self.text = "err"
|
||||
|
||||
# 4xx:确定性失败,不重试
|
||||
monkeypatch.setattr(worker.requests, "post", lambda *a, **k: _Resp(400))
|
||||
ok, _, _, retryable = worker._call_musetalk(video, audio, out)
|
||||
assert ok is False and retryable is False
|
||||
|
||||
monkeypatch.setattr(worker.requests, "post", lambda *a, **k: _Resp(503))
|
||||
ok, _, _, retryable = worker._call_musetalk(video, audio, out)
|
||||
assert ok is False and retryable is True
|
||||
|
||||
# 连接异常:瞬时错误,可重试
|
||||
import requests as _requests
|
||||
|
||||
def _conn_err(*a, **k):
|
||||
raise _requests.exceptions.ConnectionError("reset")
|
||||
|
||||
monkeypatch.setattr(worker.requests, "post", _conn_err)
|
||||
ok, _, _, retryable = worker._call_musetalk(video, audio, out)
|
||||
assert ok is False and retryable is True
|
||||
|
||||
|
||||
def test_handle_task_retries_once_for_transient_then_succeeds(worker, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_call(v, a, o):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
return False, 0.0, "MuseTalk HTTP 503: busy", True
|
||||
return True, 6.5, "", False
|
||||
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 12.0)
|
||||
monkeypatch.setattr(worker, "_call_musetalk", _fake_call)
|
||||
monkeypatch.setattr(worker, "time", mock.MagicMock()) # 重试 sleep 立即返回
|
||||
uploaded = []
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_success_with_file",
|
||||
lambda task_id, duration, path: uploaded.append((task_id, duration)),
|
||||
)
|
||||
|
||||
worker._handle_task({"task_id": "t-retry", "video_url": "u", "audio_url": "u"})
|
||||
assert len(calls) == 2
|
||||
assert uploaded == [("t-retry", 6.5)]
|
||||
|
||||
|
||||
def test_handle_task_no_retry_for_deterministic_failure(worker, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_call(v, a, o):
|
||||
calls.append(1)
|
||||
return False, 0.0, "MuseTalk HTTP 400: bad input", False
|
||||
|
||||
reports = []
|
||||
monkeypatch.setattr(worker, "_register", lambda *a, **k: True)
|
||||
monkeypatch.setattr(worker, "_download", lambda url, path: True)
|
||||
monkeypatch.setattr(worker, "_probe_duration", lambda path: 12.0)
|
||||
monkeypatch.setattr(worker, "_call_musetalk", _fake_call)
|
||||
monkeypatch.setattr(
|
||||
worker,
|
||||
"_report_result",
|
||||
lambda task_id, success, duration=0.0, error_msg="": reports.append(error_msg) or True,
|
||||
)
|
||||
|
||||
worker._handle_task({"task_id": "t-4xx", "video_url": "u", "audio_url": "u"})
|
||||
assert len(calls) == 1 # 4xx 本地不重试,直接交服务端决定
|
||||
assert reports and "400" in reports[0]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""#1970 hflip 放开(has_text 来自 atom_clip.ai_tags)端到端参数链路测试。
|
||||
|
||||
覆盖:
|
||||
1. UnifiedRenderService 传入 clip_has_text 后微变换计划的翻转门控;
|
||||
2. RenderAdapter._resolve_clip_has_text 按 atom_clip.ai_tags.has_text
|
||||
解析布尔列表(显式 False 才可翻转,其余保守),失败回退 None;
|
||||
3. 纯函数层在「混合有/无文字」列表下的行为(顺序对齐)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.micro_transform_pure import build_micro_transform_plan
|
||||
|
||||
|
||||
def _make_service(plan_config: dict | None = None, clip_has_text=None):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
svc = object.__new__(UnifiedRenderService)
|
||||
svc.plan = MagicMock()
|
||||
svc.plan.config = plan_config or {}
|
||||
svc.plan.id = "plan-1"
|
||||
svc.plan.clips = []
|
||||
svc._micro_plan_cache = None
|
||||
svc._micro_plan_loaded = False
|
||||
svc._clip_has_text = clip_has_text
|
||||
return svc
|
||||
|
||||
|
||||
def _clip(clip_id: str, atom_clip_id: str = "", clip_type: str = "main"):
|
||||
return SimpleNamespace(id=clip_id, atom_clip_id=atom_clip_id, clip_type=clip_type)
|
||||
|
||||
|
||||
def _atom(clip_id: str, ai_tags):
|
||||
return SimpleNamespace(id=clip_id, ai_tags=ai_tags)
|
||||
|
||||
|
||||
class TestServiceClipHasText:
|
||||
def test_none_stays_conservative(self):
|
||||
# 未注入检测列表:所有片段一律不翻转
|
||||
svc = _make_service({"generation_task_id": "t1"}, clip_has_text=None)
|
||||
plan = svc._get_micro_transform_plan(30)
|
||||
assert plan is not None
|
||||
assert all(c.has_text for c in plan.clips)
|
||||
assert all(not c.hflip for c in plan.clips)
|
||||
|
||||
def test_explicit_no_text_allows_hflip(self):
|
||||
# AI 明确判定无文字:允许参与 50% 翻转(40 段应至少出现一些翻转)
|
||||
svc = _make_service({"generation_task_id": "t-allow"}, clip_has_text=[False] * 40)
|
||||
plan = svc._get_micro_transform_plan(40)
|
||||
assert plan is not None
|
||||
assert all(not c.has_text for c in plan.clips)
|
||||
assert any(c.hflip for c in plan.clips)
|
||||
assert all(not c.hflip or not c.has_text for c in plan.clips)
|
||||
|
||||
def test_all_text_never_flips(self):
|
||||
svc = _make_service({"generation_task_id": "t-text"}, clip_has_text=[True] * 40)
|
||||
plan = svc._get_micro_transform_plan(40)
|
||||
assert all(c.has_text for c in plan.clips)
|
||||
assert all(not c.hflip for c in plan.clips)
|
||||
|
||||
def test_mixed_order_alignment(self):
|
||||
# 仅第 0、2 个片段无文字;has_text 标记必须与片段序号严格对齐
|
||||
svc = _make_service({"generation_task_id": "t-mix"}, clip_has_text=[False, True, False, True])
|
||||
plan = svc._get_micro_transform_plan(4)
|
||||
assert [c.has_text for c in plan.clips] == [False, True, False, True]
|
||||
assert all(not plan.clips[i].hflip for i in (1, 3))
|
||||
for i in (0, 2):
|
||||
# 无文字片段的翻转由 50% 种子决定,但允许翻转(不强制一定翻)
|
||||
assert plan.clips[i].has_text is False
|
||||
|
||||
def test_list_shorter_than_clips_missing_are_conservative(self):
|
||||
# 列表短于片段数:缺位片段按有文字处理
|
||||
svc = _make_service({"generation_task_id": "t-short"}, clip_has_text=[False])
|
||||
plan = svc._get_micro_transform_plan(3)
|
||||
assert [c.has_text for c in plan.clips] == [False, True, True]
|
||||
assert not plan.clips[1].hflip and not plan.clips[2].hflip
|
||||
|
||||
def test_plan_reproducible_with_real_list(self):
|
||||
cfg = {"generation_task_id": "task-x", "video_index": 1}
|
||||
flags = [False, True, False, False, True]
|
||||
p1 = _make_service(cfg, clip_has_text=flags)._get_micro_transform_plan(5)
|
||||
p2 = _make_service(dict(cfg), clip_has_text=list(flags))._get_micro_transform_plan(5)
|
||||
assert [c.hflip for c in p1.clips] == [c.hflip for c in p2.clips]
|
||||
|
||||
|
||||
class TestPureMixedFlags:
|
||||
def test_pure_function_mixed_flags(self):
|
||||
plan = build_micro_transform_plan("seed-1", 0, 4, clip_has_text=[False, True, False, True])
|
||||
assert [c.has_text for c in plan.clips] == [False, True, False, True]
|
||||
# 有文字片段绝不翻转
|
||||
assert not plan.clips[1].hflip and not plan.clips[3].hflip
|
||||
|
||||
|
||||
class TestResolveClipHasText:
|
||||
def _adapter(self):
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
|
||||
return RenderAdapter(MagicMock())
|
||||
|
||||
def test_no_atom_ids_returns_none(self):
|
||||
adapter = self._adapter()
|
||||
clips = [_clip("c1", ""), _clip("c2", "")]
|
||||
assert adapter._resolve_clip_has_text(clips) is None
|
||||
|
||||
def test_explicit_false_only_maps_to_false(self):
|
||||
adapter = self._adapter()
|
||||
clips = [
|
||||
_clip("c1", "a1"),
|
||||
_clip("c2", "a2"),
|
||||
_clip("c3", "a3"),
|
||||
_clip("c4", "a4"),
|
||||
_clip("c5", "a5"),
|
||||
]
|
||||
atoms = [
|
||||
_atom("a1", {"has_text": False}), # 明确无文字 → False
|
||||
_atom("a2", {"has_text": True}), # 有文字
|
||||
_atom("a3", None), # 标签未生成
|
||||
_atom("a4", {"scene": ["工厂"]}), # has_text 缺失(null)
|
||||
_atom("a5", {"has_text": "false"}), # 非布尔 → 保守
|
||||
]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
return_value=atoms,
|
||||
):
|
||||
result = adapter._resolve_clip_has_text(clips)
|
||||
assert result == [False, True, True, True, True]
|
||||
|
||||
def test_audio_clips_excluded_and_order_kept(self):
|
||||
adapter = self._adapter()
|
||||
clips = [
|
||||
_clip("c1", "a1", clip_type="main"),
|
||||
_clip("bgm", "", clip_type="audio"),
|
||||
_clip("c2", "a2", clip_type="pip"),
|
||||
]
|
||||
atoms = [
|
||||
_atom("a1", {"has_text": False}),
|
||||
_atom("a2", {"has_text": False}),
|
||||
]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
return_value=atoms,
|
||||
) as mock_find:
|
||||
result = adapter._resolve_clip_has_text(clips)
|
||||
# 只查非 audio 片段的 atom id,且顺序为 main → pip
|
||||
assert mock_find.call_args.args[0] == ["a1", "a2"]
|
||||
assert result == [False, False]
|
||||
|
||||
def test_missing_atom_record_defaults_true(self):
|
||||
adapter = self._adapter()
|
||||
clips = [_clip("c1", "a1"), _clip("c2", "a2")]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
return_value=[_atom("a1", {"has_text": False})], # a2 查不到
|
||||
):
|
||||
result = adapter._resolve_clip_has_text(clips)
|
||||
assert result == [False, True]
|
||||
|
||||
def test_query_failure_returns_none(self):
|
||||
adapter = self._adapter()
|
||||
clips = [_clip("c1", "a1")]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
side_effect=RuntimeError("db down"),
|
||||
):
|
||||
assert adapter._resolve_clip_has_text(clips) is None
|
||||
|
||||
def test_duplicate_atom_ids_queried_once(self):
|
||||
adapter = self._adapter()
|
||||
clips = [_clip("c1", "a1"), _clip("c2", "a1")]
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_atom_clip_repository."
|
||||
"SQLAlchemyAssetAtomClipRepository.find_by_ids",
|
||||
return_value=[_atom("a1", {"has_text": False})],
|
||||
) as mock_find:
|
||||
result = adapter._resolve_clip_has_text(clips)
|
||||
assert mock_find.call_args.args[0] == ["a1"]
|
||||
assert result == [False, False]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""#1970 PR2 微变换纯逻辑单元测试。
|
||||
|
||||
覆盖:
|
||||
- 种子可复现(同 task_id+video_index 跨调用一致;不同 video_index 不同)
|
||||
- 6 维参数取值范围(speed 0.97~1.03、色彩 ±0.02、hflip 概率与字幕门控)
|
||||
- BGM 偏移 2~8s 与 atrim 片段边界
|
||||
- filter 片段格式
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from video_processing.micro_transform_pure import (
|
||||
BGM_OFFSET_MAX,
|
||||
BGM_OFFSET_MIN,
|
||||
COLOR_DELTA,
|
||||
HFLIP_PROBABILITY,
|
||||
SPEED_MAX,
|
||||
SPEED_MIN,
|
||||
build_bgm_offset_trim,
|
||||
build_micro_transform_plan,
|
||||
make_video_seed,
|
||||
)
|
||||
|
||||
|
||||
class TestSeed:
|
||||
def test_seed_in_range(self):
|
||||
for i in range(50):
|
||||
s = make_video_seed("task-xyz", i)
|
||||
assert 0 <= s < 10000
|
||||
|
||||
def test_seed_deterministic_across_calls(self):
|
||||
a = make_video_seed("task-1", 2)
|
||||
b = make_video_seed("task-1", 2)
|
||||
assert a == b
|
||||
|
||||
def test_seed_differs_by_task_or_index(self):
|
||||
base = make_video_seed("task-1", 0)
|
||||
assert make_video_seed("task-2", 0) != base or make_video_seed("task-1", 1) != base
|
||||
# 至少 video_index 不同时种子不同(概率上必然,用多组确认)
|
||||
seeds = {make_video_seed("task-fixed", i) for i in range(8)}
|
||||
assert len(seeds) > 1
|
||||
|
||||
def test_empty_task_id_does_not_raise(self):
|
||||
assert 0 <= make_video_seed("", 0) < 10000
|
||||
|
||||
|
||||
class TestBuildPlan:
|
||||
def test_zero_clips_plan_has_bgm_offset(self):
|
||||
plan = build_micro_transform_plan("t1", 0, 0)
|
||||
assert plan.clips == []
|
||||
assert BGM_OFFSET_MIN <= plan.bgm_start_offset <= BGM_OFFSET_MAX
|
||||
|
||||
def test_clip_param_ranges(self):
|
||||
plan = build_micro_transform_plan("t-range", 0, 30)
|
||||
assert len(plan.clips) == 30
|
||||
for c in plan.clips:
|
||||
assert SPEED_MIN <= c.speed <= SPEED_MAX
|
||||
assert -COLOR_DELTA - 1e-9 <= c.brightness <= COLOR_DELTA + 1e-9
|
||||
assert 1.0 - COLOR_DELTA - 1e-9 <= c.contrast <= 1.0 + COLOR_DELTA + 1e-9
|
||||
assert 1.0 - COLOR_DELTA - 1e-9 <= c.saturation <= 1.0 + COLOR_DELTA + 1e-9
|
||||
|
||||
def test_plan_reproducible(self):
|
||||
p1 = build_micro_transform_plan("repro", 1, 10)
|
||||
p2 = build_micro_transform_plan("repro", 1, 10)
|
||||
assert [c.speed for c in p1.clips] == [c.speed for c in p2.clips]
|
||||
assert [c.brightness for c in p1.clips] == [c.brightness for c in p2.clips]
|
||||
assert p1.bgm_start_offset == p2.bgm_start_offset
|
||||
|
||||
def test_hflip_disabled_when_no_text_info(self):
|
||||
# clip_has_text=None(P1 保守):全部按有文字处理,一律不翻转
|
||||
plan = build_micro_transform_plan("t1", 0, 40, clip_has_text=None)
|
||||
assert all(not c.hflip for c in plan.clips)
|
||||
assert all(c.has_text for c in plan.clips)
|
||||
|
||||
def test_hflip_never_on_text_clips(self):
|
||||
# 全部标记有文字:无论如何都不翻转
|
||||
plan = build_micro_transform_plan("t-text", 0, 40, clip_has_text=[True] * 40)
|
||||
assert all(not c.hflip for c in plan.clips)
|
||||
|
||||
def test_hflip_roughly_half_on_clean_clips(self):
|
||||
# 全部无文字:翻转比例应接近 50%(给宽松区间防 flaky)
|
||||
plan = build_micro_transform_plan("t-clean", 0, 2000, clip_has_text=[False] * 2000)
|
||||
flipped = sum(1 for c in plan.clips if c.hflip)
|
||||
ratio = flipped / 2000
|
||||
assert HFLIP_PROBABILITY == 0.5
|
||||
assert 0.40 < ratio < 0.60
|
||||
|
||||
def test_hflip_mixed_text_mask(self):
|
||||
mask = [i % 2 == 0 for i in range(100)] # 偶数位有文字
|
||||
plan = build_micro_transform_plan("t-mask", 0, 100, clip_has_text=mask)
|
||||
for c in plan.clips:
|
||||
if mask[c.clip_index]:
|
||||
assert not c.hflip
|
||||
|
||||
def test_bgm_offset_disabled(self):
|
||||
plan = build_micro_transform_plan("t1", 0, 5, enable_bgm_offset=False)
|
||||
assert plan.bgm_start_offset == 0.0
|
||||
|
||||
def test_clip_lookup(self):
|
||||
plan = build_micro_transform_plan("t1", 0, 3)
|
||||
assert plan.clip(0) is plan.clips[0]
|
||||
assert plan.clip(2) is plan.clips[2]
|
||||
assert plan.clip(99) is None
|
||||
|
||||
|
||||
class TestFilterSuffix:
|
||||
def test_identity_transform_empty_suffix(self):
|
||||
plan = build_micro_transform_plan("t", 0, 1, clip_has_text=[True])
|
||||
c = plan.clips[0]
|
||||
# 强制为恒等参数验证格式
|
||||
object.__setattr__(c, "speed", 1.0)
|
||||
object.__setattr__(c, "brightness", 0.0)
|
||||
object.__setattr__(c, "contrast", 1.0)
|
||||
object.__setattr__(c, "saturation", 1.0)
|
||||
object.__setattr__(c, "hflip", False)
|
||||
assert c.video_filter_suffix() == ""
|
||||
assert c.audio_filter_suffix() == ""
|
||||
|
||||
def test_video_filter_order_speed_hflip_eq(self):
|
||||
plan = build_micro_transform_plan("t", 0, 1, clip_has_text=[False])
|
||||
c = plan.clips[0]
|
||||
object.__setattr__(c, "speed", 1.02)
|
||||
object.__setattr__(c, "hflip", True)
|
||||
object.__setattr__(c, "has_text", False)
|
||||
object.__setattr__(c, "brightness", 0.01)
|
||||
suffix = c.video_filter_suffix()
|
||||
steps = suffix.split(",")
|
||||
assert steps[0].startswith("setpts=")
|
||||
assert steps[1] == "hflip"
|
||||
assert steps[2].startswith("eq=brightness=")
|
||||
|
||||
def test_hflip_blocked_by_text_in_suffix(self):
|
||||
plan = build_micro_transform_plan("t", 0, 1)
|
||||
c = plan.clips[0]
|
||||
object.__setattr__(c, "hflip", True)
|
||||
object.__setattr__(c, "has_text", True)
|
||||
assert "hflip" not in c.video_filter_suffix()
|
||||
|
||||
def test_audio_suffix_only_for_speed(self):
|
||||
plan = build_micro_transform_plan("t", 0, 1)
|
||||
c = plan.clips[0]
|
||||
object.__setattr__(c, "speed", 0.98)
|
||||
assert c.audio_filter_suffix() == "atempo=0.98000"
|
||||
object.__setattr__(c, "speed", 1.0)
|
||||
assert c.audio_filter_suffix() == ""
|
||||
|
||||
|
||||
class TestBgmTrim:
|
||||
def test_normal_offset(self):
|
||||
assert build_bgm_offset_trim(3.0, 30.0) == "atrim=start=3.000,"
|
||||
|
||||
def test_zero_or_negative(self):
|
||||
assert build_bgm_offset_trim(0.0, 30.0) == ""
|
||||
assert build_bgm_offset_trim(-1.0, 30.0) == ""
|
||||
|
||||
def test_offset_near_end_falls_back(self):
|
||||
# 距尾部不足 0.5s → 空串
|
||||
assert build_bgm_offset_trim(29.7, 30.0) == ""
|
||||
|
||||
def test_invalid_duration(self):
|
||||
assert build_bgm_offset_trim(3.0, 0.0) == ""
|
||||
|
||||
|
||||
class TestDistributionSanity:
|
||||
def test_speed_distribution_spans_range(self):
|
||||
# 多片段采样确认速度在全区间有分布(非常量)
|
||||
plan = build_micro_transform_plan("t-dist", 0, 500)
|
||||
speeds = [c.speed for c in plan.clips]
|
||||
assert min(speeds) < 0.99
|
||||
assert max(speeds) > 1.01
|
||||
|
||||
def test_bgm_offset_range_many_seeds(self):
|
||||
for i in range(100):
|
||||
plan = build_micro_transform_plan("t", i, 1)
|
||||
assert BGM_OFFSET_MIN <= plan.bgm_start_offset <= BGM_OFFSET_MAX
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user