Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4c6f492a7 |
@@ -213,20 +213,3 @@ 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,12 +1186,10 @@ 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..."
|
||||
@@ -1642,12 +1640,10 @@ 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..."
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,37 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,26 +0,0 @@
|
||||
"""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,7 +14,6 @@ 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
|
||||
@@ -212,8 +211,3 @@ api_router.include_router(
|
||||
prefix="/usage",
|
||||
tags=["Usage"],
|
||||
)
|
||||
api_router.include_router(
|
||||
gpu_lipsync_router,
|
||||
prefix="/gpu",
|
||||
tags=["GPU Worker"],
|
||||
)
|
||||
|
||||
@@ -16,12 +16,10 @@ 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,
|
||||
@@ -134,8 +132,6 @@ 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。
|
||||
|
||||
@@ -145,8 +141,6 @@ def _select_assets_from_library(
|
||||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||||
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
|
||||
测试可注入固定种子或零噪声随机源获得确定性结果。
|
||||
script_tags: #1970 叙事模式文案标签;非空时标签命中素材优先,不足再用其余素材兜底。
|
||||
tag_names_by_id: asset_id → 素材标签名列表(素材只存 tag_ids 时由调用方查名称注入)。
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
@@ -156,20 +150,6 @@ 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%)
|
||||
@@ -182,78 +162,16 @@ 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,
|
||||
dedup_enabled=dedup_enabled,
|
||||
video_index=video_index,
|
||||
assembly_mode=assembly_mode,
|
||||
script_id=script_id,
|
||||
video_ratio=video_ratio,
|
||||
)
|
||||
return writeback_edit_plan_config(plan_id, task_id, title_config, db)
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
@@ -303,63 +221,16 @@ 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, assembly=%s, count=%d",
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%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
|
||||
@@ -385,29 +256,19 @@ 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",) or narrative_script_tags):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",):
|
||||
# 项目级模式:未指定 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(
|
||||
@@ -471,10 +332,6 @@ 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(
|
||||
@@ -619,12 +476,9 @@ 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):
|
||||
@@ -639,7 +493,6 @@ 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:
|
||||
@@ -676,8 +529,6 @@ 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)
|
||||
|
||||
@@ -821,11 +672,6 @@ 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(
|
||||
@@ -916,7 +762,6 @@ 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,
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -55,9 +55,7 @@ _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)
|
||||
@@ -142,7 +140,6 @@ 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
|
||||
@@ -171,7 +168,6 @@ 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
|
||||
@@ -287,7 +283,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="视频下载超时,请稍后重试") from None
|
||||
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="视频下载超时,请稍后重试")
|
||||
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
|
||||
@@ -424,10 +420,7 @@ 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视频)")
|
||||
@@ -447,25 +440,13 @@ 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 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 HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("本地 ASR 链路异常: %s", exc)
|
||||
text = ""
|
||||
|
||||
# ── Phase C:结果判定 & 兜底 ──
|
||||
|
||||
@@ -548,7 +529,6 @@ 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,24 +98,6 @@ 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 配音严格守卫。
|
||||
@@ -145,26 +127,6 @@ 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())
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""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 "" # noqa: B005
|
||||
return m.group(0).rstrip("。,!?!?,,;;\"'))】") if m else ""
|
||||
|
||||
|
||||
def _canonicalize_url(url: str, timeout: int = 8) -> str:
|
||||
|
||||
@@ -423,7 +423,6 @@ 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,
|
||||
@@ -475,7 +474,6 @@ 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)。
|
||||
|
||||
@@ -610,69 +608,18 @@ 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 = 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
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit)
|
||||
for item in clips_data:
|
||||
aid = item.get("asset_id", "")
|
||||
if aid:
|
||||
|
||||
@@ -61,17 +61,10 @@ 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:
|
||||
@@ -87,16 +80,6 @@ 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")
|
||||
@@ -174,33 +157,6 @@ 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,
|
||||
*,
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
"""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)
|
||||
@@ -36,7 +36,6 @@ 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,
|
||||
@@ -64,7 +63,6 @@ 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 预签名配置)."""
|
||||
@@ -217,52 +215,7 @@ class LipsyncService:
|
||||
if timings:
|
||||
job.sentence_timings = timings
|
||||
|
||||
# 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(job=job, gpu_svc=gpu_svc)
|
||||
if gpu_task is not None:
|
||||
# GPU 任务完成:直接把结果写入 job,标为 completed
|
||||
job.mediakit_task_id = "" # GPU 路径不走 MediaKit
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = gpu_task.result_url
|
||||
job.output_duration = gpu_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 MuseTalk 推理完成: job_id=%s gpu_task=%s duration=%.2f",
|
||||
job.id,
|
||||
gpu_task.id,
|
||||
job.output_duration,
|
||||
)
|
||||
# 转存到持久 OSS 路径(GPU 结果已在 gpu-lipsync/results/ 下,直接签短链)
|
||||
return
|
||||
# wait_for_result 返回 None 表示超时/最终失败 → 继续走 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(兜底路径)
|
||||
# 4. 签名 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
|
||||
@@ -291,51 +244,6 @@ class LipsyncService:
|
||||
self.db.commit()
|
||||
raise
|
||||
|
||||
# ── GPU MuseTalk 路径 ────────────────────────────────────────────────
|
||||
|
||||
def _submit_to_gpu(self, *, job, gpu_svc) -> Optional[object]:
|
||||
"""创建 GPU 任务并同步等待结果。
|
||||
|
||||
成功返回终态 task 对象(status=done);超时或 GPU 最终失败返回 None,
|
||||
调用方回退 MediaKit。
|
||||
|
||||
注意:job.video_url / job.audio_url 可能是:
|
||||
- 自家 OSS 存储 key(storage.is_own_url 判断,gpu_svc.create_task 内部
|
||||
get_download_url 会自动签预签名 URL 给 Worker)
|
||||
- 外部公网 URL(CosyVoice 临时链接等):poll 返回时原样透传给 Worker,
|
||||
Worker 可直接 GET 下载。
|
||||
"""
|
||||
# 创建 GPU 任务
|
||||
gpu_task = gpu_svc.create_task(
|
||||
video_url=job.video_url,
|
||||
audio_url=job.audio_url,
|
||||
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,
|
||||
)
|
||||
# 同步等待 Worker 处理完成(轮询 DB)
|
||||
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 None
|
||||
if final_task.status != "done":
|
||||
logger.warning(
|
||||
"[lipsync] GPU 任务失败: gpu_task=%s status=%s err=%s",
|
||||
gpu_task.id,
|
||||
final_task.status,
|
||||
final_task.error_msg,
|
||||
)
|
||||
return None
|
||||
# result_url 是 OSS 存储 key;签一个长有效期 URL 写回 job.output_video_url
|
||||
result_signed = self._sign_media_url(final_task.result_url)
|
||||
final_task.result_url = result_signed or final_task.result_url
|
||||
return final_task
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
"""叙事剪辑前置服务 — #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,11 +22,6 @@ 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
|
||||
@@ -57,12 +52,10 @@ class PlanGeneratorService:
|
||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session, asset_repo=None, atom_clip_repo=None) -> None:
|
||||
def __init__(self, db: Session, asset_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
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -128,34 +121,18 @@ class PlanGeneratorService:
|
||||
|
||||
# 4. 按 editing_mode 分配素材
|
||||
if asset_ids:
|
||||
# #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,
|
||||
)
|
||||
# 获取素材时长信息,用于随机起始时间
|
||||
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] = []
|
||||
@@ -282,103 +259,6 @@ 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,12 +38,7 @@ def transcribe_to_text(media_path: str | Path) -> str:
|
||||
ASRTranscriptionError: ASR 调用失败
|
||||
"""
|
||||
# 延迟导入,避免循环依赖和启动时副作用
|
||||
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
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
|
||||
asr = get_asr_service()
|
||||
if asr is None:
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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, type Page } from "@playwright/test"
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
@@ -8,8 +8,7 @@ const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
|
||||
async function routeBrowserApiToTestApi(page: Page) {
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
@@ -25,358 +24,276 @@ async function loginWithRetry(
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
): Promise<string> {
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
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`)
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
throw new Error("Login failed after retries")
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新用户 + 建项目/视频库/上传 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}` }
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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(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 }
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* #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}` }
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 360_000 })
|
||||
|
||||
// 确保默认模板存在(智能剪辑页依赖模板)
|
||||
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)
|
||||
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(360_000)
|
||||
|
||||
// 注入登录态 + 路由 API
|
||||
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)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-${suffix}@example.com`
|
||||
const username = `e2e_gen_${suffix}`
|
||||
const libraryName = `E2E Gen Lib ${suffix}`
|
||||
|
||||
// ── 提前 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,
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(upload.status()).toBe(200)
|
||||
|
||||
// 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] },
|
||||
)
|
||||
.toBe("ready")
|
||||
|
||||
// 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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
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: 30000,
|
||||
timeout: 20_000,
|
||||
})
|
||||
|
||||
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("随机混剪")).toBeVisible()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
|
||||
|
||||
// ── 配音选择弹窗:选第一个配音 → 确认 ─────────────────────────
|
||||
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()
|
||||
// ── 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()
|
||||
|
||||
// ── 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 })
|
||||
// ── 数量弹窗(PreviewCountModal) ──
|
||||
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
await page.getByRole("button", { name: "生成 1 个视频" }).click()
|
||||
|
||||
// ── Step 3:填写标题 ──────────────────────────────────────────
|
||||
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
|
||||
const titleInput = page.getByPlaceholder("输入或从标题库选择")
|
||||
// ── 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")
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
||||
await titleInput.fill(`测试随机剪辑 ${suffix}`)
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
await titleInput.fill(`E2E Test ${suffix}`)
|
||||
|
||||
// ── 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 3 底部是「下一步 →」,点击进入 Step 4(确认生成)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
const createTask = page.waitForResponse(
|
||||
(r) => r.url().includes("/generation/tasks") && r.request().method() === "POST",
|
||||
{ timeout: 30000 },
|
||||
// ── Step 4: 确认生成 ──
|
||||
// 等待实时预览就绪(占位消失)
|
||||
await page
|
||||
.getByText("准备预览素材")
|
||||
.waitFor({ state: "detached", timeout: 30_000 })
|
||||
.catch(() => {})
|
||||
|
||||
// Step 4 底部是「✨ 确认生成视频」
|
||||
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 先挂 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 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 ✓")
|
||||
|
||||
// 验证生成 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)
|
||||
|
||||
// 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("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")
|
||||
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}`
|
||||
|
||||
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,
|
||||
}),
|
||||
})
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
|
||||
// 预设音色(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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
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}` }
|
||||
|
||||
// 克隆音色:空列表
|
||||
await page.route(
|
||||
(url) => url.pathname.endsWith("/voice-clones"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
}),
|
||||
)
|
||||
|
||||
// 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合成配音" }),
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
||||
timeout: 30000,
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E API Proj ${suffix}` },
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
|
||||
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
|
||||
await expect(page.getByText("选择模式", { exact: true })).toBeVisible()
|
||||
await page.getByText("叙事剪辑").click()
|
||||
await page.getByRole("button", { name: /下一步/ }).click()
|
||||
|
||||
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
|
||||
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
|
||||
await page.getByText("测试带货文案").first().click()
|
||||
await page.getByRole("button", { name: "确认选择" }).click()
|
||||
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
|
||||
|
||||
// ── 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 ✓")
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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 ✓`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -212,50 +212,52 @@ const Step1EditMode: React.FC<Step1EditModeProps> = ({
|
||||
className="xx-form-field"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
background: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: "#111" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: "#111" }}>
|
||||
🎯 智能降重 {dedupEnabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDedupEnabledChange(!dedupEnabled)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
|
||||
自动对画面做微调,避免查重不过
|
||||
</div>
|
||||
</div>
|
||||
<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,
|
||||
}}
|
||||
aria-label="toggle dedup"
|
||||
>
|
||||
<span
|
||||
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,
|
||||
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)",
|
||||
}}
|
||||
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>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""智能降重微变换纯逻辑模块 — #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,41 +493,6 @@ 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,
|
||||
@@ -577,7 +542,6 @@ class RenderAdapter:
|
||||
)
|
||||
|
||||
# 4. 执行统一渲染
|
||||
clip_has_text = self._resolve_clip_has_text(clips)
|
||||
render_svc = UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
@@ -588,7 +552,6 @@ 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,7 +98,6 @@ 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:
|
||||
"""音频后处理混音.
|
||||
|
||||
@@ -158,10 +157,7 @@ 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_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)
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
|
||||
try:
|
||||
return build_bgm_only(ctx, bgm_cfg, video_duration)
|
||||
except Exception:
|
||||
@@ -191,10 +187,7 @@ 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_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)
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
|
||||
|
||||
try:
|
||||
# 这里 main_audio 就是 output_path,先有主音频再混 BGM
|
||||
|
||||
@@ -155,7 +155,6 @@ 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
|
||||
@@ -168,98 +167,10 @@ 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.
|
||||
@@ -405,9 +316,6 @@ 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"
|
||||
@@ -457,7 +365,6 @@ 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)
|
||||
@@ -1205,28 +1112,6 @@ 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:
|
||||
@@ -1433,16 +1318,11 @@ 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 与 #1970 微变换速度)
|
||||
# 调速 — 与 filter_complex 路径一致(叠加视觉扰动 speed_factor)
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
|
||||
effective_speed = speed * vp_speed # 微变换速度已烘焙进 playback_speed
|
||||
effective_speed = speed * vp_speed
|
||||
if abs(effective_speed - 1.0) >= 1e-6:
|
||||
filters.append(f"setpts=PTS/{effective_speed:.4f}")
|
||||
|
||||
@@ -1456,8 +1336,6 @@ 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"):
|
||||
@@ -1476,8 +1354,6 @@ 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"))
|
||||
@@ -1574,8 +1450,7 @@ class UnifiedRenderService:
|
||||
# 音频调速(在降噪之后、音量之前,与 render_audio.py concat 路径保持一致)
|
||||
# SpeedEngine.build_audio_filter 内部已实现多级 atempo 串联,
|
||||
# 自动处理超出 [0.5, 2.0] 范围的速度(如 0.25x → atempo=0.5,atempo=0.5)。
|
||||
# #1970 PR2:叠加片段微变换速度因子,保持音画同步。
|
||||
speed = UnifiedRenderService._clip_speed(clip) # 微变换速度已烘焙进 playback_speed
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
try:
|
||||
from video_processing.speed_engine import SpeedConfig, SpeedEngine
|
||||
@@ -1647,20 +1522,11 @@ 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():
|
||||
@@ -1689,7 +1555,7 @@ class UnifiedRenderService:
|
||||
seg_duration = seg.trim.duration
|
||||
|
||||
# 多段裁剪:如果段的时长超过素材实际时长,减速补偿
|
||||
seg_speed = configured_speed * _micro_speed
|
||||
seg_speed = configured_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(
|
||||
@@ -1712,7 +1578,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, "_micro_index": _mt_idx},
|
||||
config={**clip_config, "_segment_id": seg.segment_id},
|
||||
actual_duration=actual_duration,
|
||||
trim_config=seg.trim,
|
||||
)
|
||||
@@ -1767,13 +1633,12 @@ class UnifiedRenderService:
|
||||
avail_in_asset,
|
||||
freeze_seconds,
|
||||
)
|
||||
final_speed = configured_speed * _micro_speed
|
||||
final_speed = configured_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,
|
||||
@@ -1889,15 +1754,9 @@ 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] = []
|
||||
|
||||
@@ -1915,10 +1774,10 @@ class UnifiedRenderService:
|
||||
filters.append(f"trim=duration={trim_dur:.3f}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor 与 #1970 微变换速度)
|
||||
# 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor)
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0
|
||||
effective_speed = speed * vp_speed # 微变换速度已烘焙进 playback_speed
|
||||
effective_speed = speed * vp_speed
|
||||
if abs(effective_speed - 1.0) >= 1e-6:
|
||||
filters.append(f"setpts=PTS/{effective_speed:.4f}")
|
||||
|
||||
@@ -1932,8 +1791,6 @@ 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"):
|
||||
@@ -1952,8 +1809,6 @@ 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,11 +27,6 @@ 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,25 +53,12 @@ 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",
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""片段级 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()
|
||||
@@ -1,109 +0,0 @@
|
||||
"""素材原子切片 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,
|
||||
)
|
||||
@@ -1,106 +0,0 @@
|
||||
"""批量回填 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,51 +890,25 @@ def generate_video(self, task_id: str) -> dict:
|
||||
_flush_logs(task_id, gen_task)
|
||||
_update_task_progress(task_id, 80, "渲染完成")
|
||||
|
||||
# ── 3.5 随机边缘裁剪降重(#1664;#1970 dedup_enabled=False 时跳过) ──
|
||||
_dedup_enabled = True
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
# ── 3.5 随机边缘裁剪降重(#1664) ──────────────────────────
|
||||
from video_processing.ffmpeg_utils import random_edge_crop
|
||||
|
||||
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:
|
||||
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] 读取 plan dedup_enabled 失败,按开启处理",
|
||||
"[task_id=%s] 随机边缘裁剪失败,使用原始视频继续: %s",
|
||||
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,21 +808,6 @@ 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,9 +234,6 @@ DOUBAO_TIMEOUT=60
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
@@ -255,11 +252,3 @@ 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,9 +251,6 @@ DOUBAO_TIMEOUT=60
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
@@ -272,11 +269,3 @@ 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
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# ============================================================
|
||||
# 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
|
||||
@@ -1,100 +0,0 @@
|
||||
# MuseTalk GPU Worker — 部署指南
|
||||
|
||||
本目录包含 RTX2060 本地电脑上运行的 GPU Worker 脚本。
|
||||
Worker 采用 **反向轮询模式**:主动向 SaaS API 拉取待处理的口型同步任务 → 调用本地 MuseTalk 推理 → 把结果视频回传到 SaaS。不需要内网穿透。
|
||||
|
||||
## 目录文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `gpu_worker.py` | Worker 主程序(单文件,零项目代码依赖,仅依赖 `requests`) |
|
||||
| `requirements.txt` | Python 依赖(只有 `requests`) |
|
||||
| `xiaoxia-gpu-worker.service` | systemd 服务单元(开机自启、异常自动重启) |
|
||||
| `.env.example` | 环境变量样例,复制为 `.env` 后填入真实值 |
|
||||
|
||||
## 一、环境准备
|
||||
|
||||
1. **Python 3.10+**(Windows 建议从 python.org 安装;Linux 自带)
|
||||
2. **本地 MuseTalk 服务** 已启动在 `http://127.0.0.1:7861`,health 接口返回 `{"status":"ok","free_vram_mb":...}`
|
||||
3. **ffmpeg**(可选,用于读取输出视频时长;未装则 duration 报 0,不影响功能)
|
||||
4. 网络能访问 staging / 生产 API(`curl https://staging-api.xiaoxiajianji.com/health` 应返回 `{"status":"healthy"}`)
|
||||
|
||||
## 二、部署步骤(Linux,推荐 systemd)
|
||||
|
||||
```bash
|
||||
# 1. 创建部署目录
|
||||
sudo mkdir -p /opt/xiaoxia-gpu-worker
|
||||
sudo chown $USER:$USER /opt/xiaoxia-gpu-worker
|
||||
cd /opt/xiaoxia-gpu-worker
|
||||
|
||||
# 2. 拷贝脚本和依赖
|
||||
cp /path/to/deploy/gpu_worker/{gpu_worker.py,requirements.txt,xiaoxia-gpu-worker.service,.env.example} .
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入 API_BASE_URL 和 GPU_WORKER_TOKEN
|
||||
|
||||
# 3. 创建虚拟环境并安装依赖
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
|
||||
# 4. 前台先跑一次,确认日志正常
|
||||
./venv/bin/python gpu_worker.py
|
||||
# 看到 "MuseTalk 健康检查通过" 和 "注册/心跳" 成功即可 Ctrl+C 退出
|
||||
|
||||
# 5. 安装 systemd 服务
|
||||
sudo cp xiaoxia-gpu-worker.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now xiaoxia-gpu-worker
|
||||
|
||||
# 6. 查看日志
|
||||
sudo journalctl -u xiaoxia-gpu-worker -f
|
||||
```
|
||||
|
||||
## 三、部署步骤(Windows,快速测试)
|
||||
|
||||
```bat
|
||||
:: 创建虚拟环境
|
||||
python -m venv venv
|
||||
venv\Scripts\pip install -r requirements.txt
|
||||
|
||||
:: 复制并编辑 .env
|
||||
copy .env.example .env
|
||||
notepad .env
|
||||
|
||||
:: 运行
|
||||
venv\Scripts\python gpu_worker.py
|
||||
```
|
||||
|
||||
可在任务计划程序中添加开机启动项:程序选 `venv\Scripts\python.exe`,参数填 `gpu_worker.py`,起始目录填脚本所在目录。
|
||||
|
||||
## 四、SaaS 侧配套配置
|
||||
|
||||
SaaS 后端部署完成后需配置:
|
||||
|
||||
1. 服务端环境变量 `GPU_WORKER_TOKEN` 设为一个随机强 Token(和 Worker `.env` 中一致)
|
||||
2. 数据库已跑迁移 `081_add_gpu_lipsync_tasks`(自动随 API 启动的 alembic upgrade head 完成)
|
||||
3. OSS bucket 中 `gpu-lipsync/results/` 路径可写(默认 bucket 已配)
|
||||
|
||||
## 五、验证联调
|
||||
|
||||
1. Worker 启动后日志看到 `注册/心跳` 成功
|
||||
2. 后端调用 `GpuLipsyncService.create_task(video_url=..., audio_url=...)` 放入一条测试任务
|
||||
3. Worker 在 5 秒内拉到任务,下载 → 推理 → 上传 → 上报
|
||||
4. 后端 `GET /api/v1/gpu/lipsync/status/{task_id}` 返回 `status=done`,`result_url` 非空
|
||||
|
||||
## 六、故障排查
|
||||
|
||||
| 现象 | 可能原因 / 排查 |
|
||||
|---|---|
|
||||
| 日志 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 调整阈值 |
|
||||
|
||||
## 七、安全注意事项
|
||||
|
||||
- `.env` 包含长期 Token,文件权限设为 600(`chmod 600 .env`)
|
||||
- Token 泄露要立即在服务端更换 `GPU_WORKER_TOKEN` 并重启 Worker
|
||||
- Worker 只需要出站访问 SaaS API 和 OSS,不需要开放任何入站端口
|
||||
@@ -1,471 +0,0 @@
|
||||
"""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 次
|
||||
return False, 0.0, f"MuseTalk 推理超时或连接失败(>{Config.request_timeout}s)", True
|
||||
except Exception as exc:
|
||||
return False, 0.0, f"MuseTalk 调用异常: {exc}", False
|
||||
|
||||
|
||||
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())
|
||||
@@ -1 +0,0 @@
|
||||
requests>=2.31.0
|
||||
@@ -1,21 +0,0 @@
|
||||
[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
|
||||
@@ -1,138 +0,0 @@
|
||||
"""素材原子片段仓储 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,7 +50,6 @@ 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,
|
||||
@@ -75,7 +74,6 @@ 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
|
||||
@@ -122,7 +120,6 @@ 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,
|
||||
@@ -196,53 +193,3 @@ 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,20 +1,7 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Index, Integer, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base: Any = declarative_base()
|
||||
@@ -247,8 +234,6 @@ 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)
|
||||
@@ -816,33 +801,6 @@ 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)"""
|
||||
|
||||
@@ -855,61 +813,3 @@ 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))
|
||||
|
||||
@@ -68,7 +68,6 @@ 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 = ""
|
||||
@@ -80,29 +79,6 @@ class SharedSettings(BaseSettings):
|
||||
# `if settings.points_enabled:` 包裹,防止未完善的扣点逻辑影响现有用户。
|
||||
points_enabled: bool = 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:
|
||||
"""返回实际使用的数据库 URL。
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
"""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,
|
||||
@@ -50,15 +37,6 @@ 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",
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""素材原子片段(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,
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
"""原子片段加载与兜底 — #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
|
||||
@@ -1,264 +0,0 @@
|
||||
"""原子片段级选片核心 — #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
|
||||
@@ -1,215 +0,0 @@
|
||||
"""素材原子切片服务 — #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
|
||||
@@ -1,292 +0,0 @@
|
||||
"""片段级 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,7 +65,6 @@ 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
|
||||
@@ -86,7 +85,6 @@ 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,
|
||||
@@ -119,7 +117,6 @@ 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,
|
||||
@@ -130,25 +127,16 @@ class EditPlanClip:
|
||||
config=config or {},
|
||||
)
|
||||
|
||||
def assign_asset(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
start_time: float | None = None,
|
||||
atom_clip_id: str | None = None,
|
||||
) -> None:
|
||||
def assign_asset(self, asset_id: str, *, start_time: float | 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,21 +12,9 @@ else:
|
||||
|
||||
|
||||
class EditingMode(StrEnum):
|
||||
"""剪辑模式枚举。
|
||||
"""剪辑模式枚举"""
|
||||
|
||||
#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
|
||||
ONE_TAKE = "one_take" # 顺序拼接模式
|
||||
PIP = "pip" # 画中画模式
|
||||
VOICE_OVER = "voice_over" # 口播+B-roll模式
|
||||
VOICE_PIP = "voice_pip" # 口播+画中画组合模式
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
"""叙事剪辑素材标签匹配 — #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
|
||||
@@ -1,57 +0,0 @@
|
||||
"""素材原子片段仓储接口定义。"""
|
||||
|
||||
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,7 +37,6 @@ 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:
|
||||
@@ -104,99 +103,6 @@ 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,44 +339,6 @@ 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 DOUBAO_VISION_MODEL WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
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"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"""#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)
|
||||
@@ -1,110 +0,0 @@
|
||||
"""#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"
|
||||
@@ -1,205 +0,0 @@
|
||||
"""#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}
|
||||
@@ -1,151 +0,0 @@
|
||||
"""#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)
|
||||
@@ -1,292 +0,0 @@
|
||||
"""#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"])
|
||||
@@ -1,181 +0,0 @@
|
||||
"""#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"}
|
||||
@@ -1,82 +0,0 @@
|
||||
"""#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}"
|
||||
@@ -1,347 +0,0 @@
|
||||
"""#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}]
|
||||
@@ -1,254 +0,0 @@
|
||||
"""#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]
|
||||
@@ -1,185 +0,0 @@
|
||||
"""#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]
|
||||
@@ -1,178 +0,0 @@
|
||||
"""#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
|
||||
@@ -1,198 +0,0 @@
|
||||
"""#1970 PR2 渲染服务微变换注入测试。
|
||||
|
||||
不做真实渲染,只验证 UnifiedRenderService 上微变换计划的开关、缓存、
|
||||
滤镜注入与速度因子;纯参数生成在 test_1970_micro_transform_pure 覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_service(plan_config: dict | None = None, clips=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 = clips or []
|
||||
svc._micro_plan_cache = None
|
||||
svc._micro_plan_loaded = False
|
||||
svc._clip_has_text = None
|
||||
return svc
|
||||
|
||||
|
||||
class TestDedupGate:
|
||||
def test_default_enabled_when_config_missing(self):
|
||||
svc = _make_service({})
|
||||
assert svc._dedup_enabled() is True
|
||||
|
||||
def test_explicit_true(self):
|
||||
svc = _make_service({"dedup_enabled": True})
|
||||
assert svc._dedup_enabled() is True
|
||||
|
||||
def test_explicit_false(self):
|
||||
svc = _make_service({"dedup_enabled": False})
|
||||
assert svc._dedup_enabled() is False
|
||||
|
||||
def test_plan_none_config_treated_enabled(self):
|
||||
svc = _make_service(None)
|
||||
svc.plan.config = None
|
||||
assert svc._dedup_enabled() is True
|
||||
|
||||
|
||||
class TestPlanBuild:
|
||||
def test_disabled_returns_none_and_cached(self):
|
||||
svc = _make_service({"dedup_enabled": False, "generation_task_id": "t1"})
|
||||
assert svc._get_micro_transform_plan(5) is None
|
||||
# 第二次走缓存
|
||||
svc._dedup_enabled = MagicMock(side_effect=AssertionError("不应再次计算"))
|
||||
assert svc._get_micro_transform_plan(5) is None
|
||||
|
||||
def test_zero_clips_returns_none(self):
|
||||
svc = _make_service({"generation_task_id": "t1"})
|
||||
assert svc._get_micro_transform_plan(0) is None
|
||||
|
||||
def test_enabled_builds_reproducible_plan(self):
|
||||
cfg = {"generation_task_id": "task-abc", "video_index": 2, "bgm": {"enabled": True}}
|
||||
svc1 = _make_service(cfg)
|
||||
svc2 = _make_service(dict(cfg))
|
||||
p1 = svc1._get_micro_transform_plan(6)
|
||||
p2 = svc2._get_micro_transform_plan(6)
|
||||
assert p1 is not None and p2 is not None
|
||||
assert [c.speed for c in p1.clips] == [c.speed for c in p2.clips]
|
||||
assert p1.seed == p2.seed
|
||||
assert len(p1.clips) == 6
|
||||
|
||||
def test_p1_conservative_no_hflip(self):
|
||||
svc = _make_service({"generation_task_id": "t1"})
|
||||
plan = svc._get_micro_transform_plan(30)
|
||||
assert all(not c.hflip for c in plan.clips)
|
||||
|
||||
def test_no_bgm_config_zero_offset(self):
|
||||
svc = _make_service({"generation_task_id": "t1"})
|
||||
plan = svc._get_micro_transform_plan(3)
|
||||
assert plan.bgm_start_offset == 0.0
|
||||
|
||||
def test_bgm_enabled_offset_in_range(self):
|
||||
svc = _make_service({"generation_task_id": "t1", "bgm": {"enabled": True}})
|
||||
plan = svc._get_micro_transform_plan(3)
|
||||
assert 2.0 <= plan.bgm_start_offset <= 8.0
|
||||
|
||||
|
||||
class TestFilterInjection:
|
||||
def test_none_mt_noop(self):
|
||||
svc = _make_service({})
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
filters = ["scale=100:100"]
|
||||
UnifiedRenderService._apply_micro_transform_video(filters, None)
|
||||
UnifiedRenderService._apply_micro_hflip(filters, None)
|
||||
assert filters == ["scale=100:100"]
|
||||
|
||||
def test_eq_injection(self):
|
||||
svc = _make_service({"generation_task_id": "t1"})
|
||||
plan = svc._get_micro_transform_plan(1)
|
||||
mt = plan.clips[0]
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
filters: list[str] = []
|
||||
UnifiedRenderService._apply_micro_transform_video(filters, mt)
|
||||
assert filters and filters[0].startswith("eq=brightness=")
|
||||
assert "contrast=" in filters[0] and "saturation=" in filters[0]
|
||||
|
||||
def test_hflip_skipped_p1(self):
|
||||
svc = _make_service({"generation_task_id": "t1"})
|
||||
plan = svc._get_micro_transform_plan(10)
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
for mt in plan.clips:
|
||||
filters: list[str] = []
|
||||
UnifiedRenderService._apply_micro_hflip(filters, mt)
|
||||
assert filters == []
|
||||
|
||||
def test_speed_factor(self):
|
||||
from video_processing.micro_transform_pure import ClipMicroTransform
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
assert UnifiedRenderService._micro_speed_factor(None) == 1.0
|
||||
assert UnifiedRenderService._micro_speed_factor(ClipMicroTransform(0, speed=1.025)) == pytest.approx(1.025)
|
||||
assert UnifiedRenderService._micro_speed_factor(MagicMock(speed=0.97)) == pytest.approx(0.97)
|
||||
|
||||
def test_bgm_offset_reader_respects_flag(self):
|
||||
svc_off = _make_service({"dedup_enabled": False})
|
||||
assert svc_off._get_micro_bgm_offset() == 0.0
|
||||
|
||||
svc_on = _make_service({"generation_task_id": "t1", "bgm": {"enabled": True}}, clips=[MagicMock()])
|
||||
off = svc_on._get_micro_bgm_offset()
|
||||
assert 2.0 <= off <= 8.0
|
||||
|
||||
def test_bgm_offset_zero_without_bgm(self):
|
||||
svc = _make_service({"generation_task_id": "t1"}, clips=[MagicMock()])
|
||||
assert svc._get_micro_bgm_offset() == 0.0
|
||||
|
||||
|
||||
class TestStreamCopyGate:
|
||||
"""dedup 开启时微变换需要重编码,stream copy 必须被拒绝。"""
|
||||
|
||||
def _build(self, config):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from video_processing.unified_render_service import ResolvedClip, UnifiedRenderService
|
||||
|
||||
source = SimpleNamespace(
|
||||
id="c1",
|
||||
clip_type="main",
|
||||
)
|
||||
svc = object.__new__(UnifiedRenderService)
|
||||
svc.output_width = 1280
|
||||
svc.output_height = 720
|
||||
svc.output_fps = 25
|
||||
svc.plan = MagicMock()
|
||||
svc.plan.id = "plan-1"
|
||||
svc.plan.config = config
|
||||
svc.plan.clips = [source]
|
||||
svc.clips = [source]
|
||||
svc._micro_plan_cache = None
|
||||
svc._micro_plan_loaded = False
|
||||
svc._clip_has_text = None
|
||||
resolved = ResolvedClip(
|
||||
clip_id="c1",
|
||||
asset_id="a1",
|
||||
local_path=Path("/tmp/a1.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
)
|
||||
info = {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 25.0,
|
||||
"video_codec": "h264",
|
||||
"pix_fmt": "yuv420p",
|
||||
"duration": 5.0,
|
||||
"has_audio": True,
|
||||
"audio_codec": "aac",
|
||||
}
|
||||
return svc, resolved, info
|
||||
|
||||
def test_dedup_enabled_blocks_stream_copy(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
svc, resolved, info = self._build({"dedup_enabled": True, "generation_task_id": "t1"})
|
||||
with patch("video_processing.unified_render_service.probe_video_info", return_value=info):
|
||||
can_copy, reason = svc._can_use_stream_copy(resolved)
|
||||
assert can_copy is False
|
||||
assert "微变换" in reason
|
||||
|
||||
def test_dedup_disabled_allows_stream_copy(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
svc, resolved, info = self._build({"dedup_enabled": False})
|
||||
with patch("video_processing.unified_render_service.probe_video_info", return_value=info):
|
||||
can_copy, _ = svc._can_use_stream_copy(resolved)
|
||||
assert can_copy is True
|
||||
@@ -1,306 +0,0 @@
|
||||
"""#1970 P2 叙事匹配 AI 标签加权测试。
|
||||
|
||||
测试范围:
|
||||
- AI 标签命中时权重 2.0
|
||||
- 无 AI 标签时降级到素材标签权重 1.0
|
||||
- 混合场景(部分素材有 AI 标签,部分只有素材标签)
|
||||
- compute_tag_match_score 归一化得分
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.narrative_match import (
|
||||
AI_TAG_WEIGHT,
|
||||
ASSET_TAG_WEIGHT,
|
||||
_compute_ai_score,
|
||||
_extract_ai_tag_names,
|
||||
compute_tag_match_score,
|
||||
match_assets_by_script_tags,
|
||||
pick_narrative_assets,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAsset:
|
||||
id: str
|
||||
tag_ids: list[str] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
status: str = "ready"
|
||||
file_type: str = "video"
|
||||
duration: float = 10.0
|
||||
quality_score: float | None = None
|
||||
created_at: object = None
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _make_old_dt():
|
||||
return dt.datetime(2020, 1, 1, tzinfo=dt.UTC)
|
||||
|
||||
|
||||
# ── _extract_ai_tag_names ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractAiTagNames:
|
||||
def test_extracts_all_keys(self):
|
||||
ai_tags = {
|
||||
"scene": ["工厂", "车间"],
|
||||
"objects": ["产品"],
|
||||
"action": ["演示"],
|
||||
"shot": "特写", # shot 不参与标签匹配
|
||||
"has_text": False,
|
||||
}
|
||||
names = _extract_ai_tag_names(ai_tags)
|
||||
assert names == {"工厂", "车间", "产品", "演示"}
|
||||
|
||||
def test_empty_dict(self):
|
||||
assert _extract_ai_tag_names({}) == set()
|
||||
|
||||
def test_none_values(self):
|
||||
ai_tags = {"scene": None, "objects": None, "action": None}
|
||||
assert _extract_ai_tag_names(ai_tags) == set()
|
||||
|
||||
def test_case_insensitive(self):
|
||||
ai_tags = {"scene": ["Factory"], "objects": [], "action": []}
|
||||
names = _extract_ai_tag_names(ai_tags)
|
||||
assert "factory" in names
|
||||
|
||||
|
||||
# ── _compute_ai_score ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestComputeAiScore:
|
||||
def test_single_clip_hit(self):
|
||||
wanted = {"工厂", "演示"}
|
||||
clips = [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]
|
||||
score = _compute_ai_score("a1", wanted, {"a1": clips})
|
||||
# 命中 2 个 × 2.0 = 4.0
|
||||
assert score == 2 * AI_TAG_WEIGHT
|
||||
|
||||
def test_multiple_clips_takes_best(self):
|
||||
wanted = {"工厂", "演示"}
|
||||
clips = [
|
||||
{"scene": ["工厂"], "objects": [], "action": []}, # 1 hit = 2.0
|
||||
{"scene": ["工厂"], "objects": [], "action": ["演示"]}, # 2 hits = 4.0
|
||||
]
|
||||
score = _compute_ai_score("a1", wanted, {"a1": clips})
|
||||
assert score == 2 * AI_TAG_WEIGHT # best = 2 hits
|
||||
|
||||
def test_no_match(self):
|
||||
wanted = {"美食"}
|
||||
clips = [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]
|
||||
score = _compute_ai_score("a1", wanted, {"a1": clips})
|
||||
assert score == 0.0
|
||||
|
||||
def test_no_clips_for_asset(self):
|
||||
wanted = {"工厂"}
|
||||
assert _compute_ai_score("a1", wanted, {}) == 0.0
|
||||
assert _compute_ai_score("a1", wanted, None) == 0.0
|
||||
|
||||
def test_empty_wanted(self):
|
||||
clips = [{"scene": ["工厂"], "objects": [], "action": []}]
|
||||
assert _compute_ai_score("a1", set(), {"a1": clips}) == 0.0
|
||||
|
||||
|
||||
# ── match_assets_by_script_tags with AI tags ──────────────────────────────
|
||||
|
||||
|
||||
class TestMatchWithAiTags:
|
||||
def test_ai_tag_hit_puts_in_matched(self):
|
||||
"""有 AI 标签命中 → 进入命中池."""
|
||||
assets = [FakeAsset("a1", created_at=_make_old_dt())]
|
||||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||||
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
assets,
|
||||
script_tags=["工厂"],
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
)
|
||||
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
assert unmatched == []
|
||||
|
||||
def test_ai_tag_no_match_puts_in_unmatched(self):
|
||||
"""AI 标签未命中 → 进入未命中池."""
|
||||
assets = [FakeAsset("a1", created_at=_make_old_dt())]
|
||||
clip_ai_tags = {"a1": [{"scene": ["办公室"], "objects": [], "action": []}]}
|
||||
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
assets,
|
||||
script_tags=["工厂"],
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
)
|
||||
|
||||
assert matched == []
|
||||
assert [a.id for a in unmatched] == ["a1"]
|
||||
|
||||
def test_asset_tag_still_works_without_ai_tags(self):
|
||||
"""无 AI 标签时,素材标签仍按权重 1.0 匹配."""
|
||||
assets = [FakeAsset("a1", tags=["工厂"], created_at=_make_old_dt())]
|
||||
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
assets,
|
||||
script_tags=["工厂"],
|
||||
)
|
||||
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
|
||||
def test_mixed_ai_and_asset_tags(self):
|
||||
"""混合场景:一个素材有 AI 标签,另一个只有素材标签."""
|
||||
assets = [
|
||||
FakeAsset("a1", created_at=_make_old_dt()), # AI 标签命中
|
||||
FakeAsset("a2", tags=["工厂"], created_at=_make_old_dt()), # 素材标签命中
|
||||
FakeAsset("a3", tags=["美食"], created_at=_make_old_dt()), # 无命中
|
||||
]
|
||||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||||
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
assets,
|
||||
script_tags=["工厂"],
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
)
|
||||
|
||||
assert {a.id for a in matched} == {"a1", "a2"}
|
||||
assert [a.id for a in unmatched] == ["a3"]
|
||||
|
||||
def test_ai_tag_and_asset_tag_both_hit(self):
|
||||
"""同一素材 AI 标签和素材标签都命中 → 仍在命中池."""
|
||||
assets = [FakeAsset("a1", tags=["工厂"], created_at=_make_old_dt())]
|
||||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||||
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
assets,
|
||||
script_tags=["工厂"],
|
||||
tag_names_by_id={"a1": ["工厂"]},
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
)
|
||||
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
|
||||
|
||||
# ── compute_tag_match_score ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestComputeTagMatchScore:
|
||||
def test_ai_only_score(self):
|
||||
"""仅 AI 标签命中."""
|
||||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]}
|
||||
score = compute_tag_match_score(
|
||||
"a1",
|
||||
script_tags=["工厂", "演示"],
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
)
|
||||
# AI: 2 hits × 2.0 = 4.0; asset: 0; max = 2 × 3.0 = 6.0
|
||||
assert abs(score - 4.0 / 6.0) < 0.01
|
||||
|
||||
def test_asset_only_score(self):
|
||||
"""仅素材标签命中."""
|
||||
score = compute_tag_match_score(
|
||||
"a1",
|
||||
script_tags=["工厂", "演示"],
|
||||
tag_names_by_id={"a1": ["工厂"]},
|
||||
)
|
||||
# AI: 0; asset: 1 hit × 1.0 = 1.0; max = 2 × 3.0 = 6.0
|
||||
assert abs(score - 1.0 / 6.0) < 0.01
|
||||
|
||||
def test_both_ai_and_asset_score(self):
|
||||
"""AI 标签 + 素材标签同时命中."""
|
||||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||||
score = compute_tag_match_score(
|
||||
"a1",
|
||||
script_tags=["工厂", "演示"],
|
||||
tag_names_by_id={"a1": ["工厂"]},
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
)
|
||||
# AI: 1 hit × 2.0 = 2.0; asset: 1 hit × 1.0 = 1.0; max = 2 × 3.0 = 6.0
|
||||
assert abs(score - 3.0 / 6.0) < 0.01
|
||||
|
||||
def test_no_match_score_zero(self):
|
||||
"""无命中 → 得分 0."""
|
||||
score = compute_tag_match_score(
|
||||
"a1",
|
||||
script_tags=["工厂"],
|
||||
tag_names_by_id={"a1": ["美食"]},
|
||||
)
|
||||
assert score == 0.0
|
||||
|
||||
def test_full_match_score_one(self):
|
||||
"""全命中 → 得分接近 1.0."""
|
||||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}]}
|
||||
score = compute_tag_match_score(
|
||||
"a1",
|
||||
script_tags=["工厂", "产品", "演示"],
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
)
|
||||
# AI: 3 hits × 2.0 = 6.0; max = 3 × 3.0 = 9.0 → 6/9 = 0.667
|
||||
# 注意:仅 AI 标签命中不可能达到 1.0(因为 max 包含素材权重)
|
||||
assert score > 0.5
|
||||
|
||||
def test_empty_script_tags(self):
|
||||
"""空文案标签 → 得分 0."""
|
||||
assert compute_tag_match_score("a1", script_tags=[]) == 0.0
|
||||
|
||||
|
||||
# ── pick_narrative_assets with AI tags ────────────────────────────────────
|
||||
|
||||
|
||||
class TestPickNarrativeWithAiTags:
|
||||
def _assets(self):
|
||||
old = _make_old_dt()
|
||||
return [
|
||||
FakeAsset("ai_match", created_at=old), # AI 标签命中
|
||||
FakeAsset("asset_match", tags=["工厂"], created_at=old), # 素材标签命中
|
||||
FakeAsset("no_match", tags=["美食"], created_at=old), # 无命中
|
||||
]
|
||||
|
||||
def test_ai_match_prioritized(self):
|
||||
"""AI 标签命中的素材进入命中池."""
|
||||
clip_ai_tags = {"ai_match": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||||
|
||||
picked = pick_narrative_assets(
|
||||
self._assets(),
|
||||
script_tags=["工厂"],
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
limit=2,
|
||||
rng=random.Random(0),
|
||||
)
|
||||
|
||||
ids = {a.id for a in picked}
|
||||
assert "ai_match" in ids
|
||||
assert "asset_match" in ids
|
||||
|
||||
def test_fallback_when_no_ai_match(self):
|
||||
"""AI 标签和素材标签都未命中 → 降级."""
|
||||
clip_ai_tags = {"ai_match": [{"scene": ["办公室"], "objects": [], "action": []}]}
|
||||
|
||||
picked = pick_narrative_assets(
|
||||
self._assets(),
|
||||
script_tags=["不存在"],
|
||||
clip_ai_tags_by_asset=clip_ai_tags,
|
||||
limit=2,
|
||||
rng=random.Random(0),
|
||||
)
|
||||
|
||||
assert len(picked) == 2 # 从全量中选取
|
||||
|
||||
def test_backward_compat_without_ai_tags(self):
|
||||
"""不传 clip_ai_tags_by_asset 时行为与之前完全一致."""
|
||||
picked = pick_narrative_assets(
|
||||
self._assets(),
|
||||
script_tags=["工厂"],
|
||||
limit=2,
|
||||
rng=random.Random(0),
|
||||
)
|
||||
|
||||
# 仅素材标签匹配
|
||||
ids = {a.id for a in picked}
|
||||
assert "asset_match" in ids
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
@@ -1,167 +0,0 @@
|
||||
"""#1970 PR3 叙事模式文案标签匹配纯函数测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.narrative_match import (
|
||||
build_asset_tag_name_index,
|
||||
match_assets_by_script_tags,
|
||||
normalize_tag,
|
||||
pick_narrative_assets,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAsset:
|
||||
id: str
|
||||
tag_ids: list[str] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
status: str = "ready"
|
||||
file_type: str = "video"
|
||||
duration: float = 10.0
|
||||
quality_score: float | None = None
|
||||
created_at: object = None
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
# ── normalize_tag ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeTag:
|
||||
def test_strip_and_lower(self):
|
||||
assert normalize_tag(" 带货 ") == "带货"
|
||||
assert normalize_tag("Factory") == "factory"
|
||||
|
||||
def test_none_and_non_string(self):
|
||||
assert normalize_tag(None) == ""
|
||||
assert normalize_tag(123) == "123"
|
||||
|
||||
def test_short_tag_filtered_by_normalize_set(self):
|
||||
# 单字噪声标签不参与匹配(_normalize_tags 层过滤)
|
||||
from packages.domain.narrative_match import _normalize_tags
|
||||
|
||||
assert _normalize_tags(["的", " a ", "工厂"]) == {"工厂"}
|
||||
|
||||
|
||||
# ── match_assets_by_script_tags ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMatchSplit:
|
||||
def test_split_by_tag_names(self):
|
||||
assets = [
|
||||
FakeAsset("a1", tags=["工厂"]),
|
||||
FakeAsset("a2", tags=["旅游"]),
|
||||
FakeAsset("a3", tags=["工厂", "车间"]),
|
||||
]
|
||||
matched, unmatched = match_assets_by_script_tags(assets, script_tags=["工厂"])
|
||||
assert [a.id for a in matched] == ["a1", "a3"]
|
||||
assert [a.id for a in unmatched] == ["a2"]
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assets = [FakeAsset("a1", tags=["Factory"])]
|
||||
matched, unmatched = match_assets_by_script_tags(assets, script_tags=["FACTORY"])
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
assert unmatched == []
|
||||
|
||||
def test_tag_ids_via_name_index(self):
|
||||
assets = [FakeAsset("a1", tag_ids=["t1"]), FakeAsset("a2", tag_ids=["t2"])]
|
||||
index = {"a1": ["测评"], "a2": ["vlog"]}
|
||||
matched, unmatched = match_assets_by_script_tags(assets, script_tags=["测评"], tag_names_by_id=index)
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
assert [a.id for a in unmatched] == ["a2"]
|
||||
|
||||
def test_empty_script_tags_degrades_all_unmatched(self):
|
||||
assets = [FakeAsset("a1", tags=["工厂"])]
|
||||
matched, unmatched = match_assets_by_script_tags(assets, script_tags=[])
|
||||
assert matched == []
|
||||
assert [a.id for a in unmatched] == ["a1"]
|
||||
|
||||
def test_no_match_degrades(self):
|
||||
assets = [FakeAsset("a1", tags=["工厂"]), FakeAsset("a2", tags=["车间"])]
|
||||
matched, unmatched = match_assets_by_script_tags(assets, script_tags=["美食"])
|
||||
assert matched == []
|
||||
assert {a.id for a in unmatched} == {"a1", "a2"}
|
||||
|
||||
def test_order_preserved(self):
|
||||
assets = [FakeAsset(f"a{i}", tags=["x" if i % 2 else "工厂"]) for i in range(6)]
|
||||
matched, _ = match_assets_by_script_tags(assets, script_tags=["工厂"])
|
||||
assert [a.id for a in matched] == ["a0", "a2", "a4"]
|
||||
|
||||
def test_build_index_ignores_blank(self):
|
||||
# 空白/None/单字符噪声标签均不参与匹配
|
||||
idx = build_asset_tag_name_index({"a1": [" 工厂 ", "", None, "A"]})
|
||||
assert idx == {"a1": {"工厂"}}
|
||||
|
||||
|
||||
# ── pick_narrative_assets ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPickNarrativeAssets:
|
||||
def _assets(self):
|
||||
# smart_match 需要 created_at(None 走 recency 兜底)
|
||||
import datetime as dt
|
||||
|
||||
old = dt.datetime(2020, 1, 1, tzinfo=dt.UTC)
|
||||
return [
|
||||
FakeAsset("match1", tags=["工厂"], created_at=old),
|
||||
FakeAsset("nomatch1", tags=["旅游"], created_at=old),
|
||||
FakeAsset("match2", tags=["工厂"], created_at=old),
|
||||
FakeAsset("nomatch2", tags=["美食"], created_at=old),
|
||||
]
|
||||
|
||||
def test_matched_pool_prioritized(self):
|
||||
picked = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=2, rng=random.Random(0))
|
||||
assert {a.id for a in picked} <= {"match1", "match2"}
|
||||
assert all(a.id.startswith("match") for a in picked)
|
||||
|
||||
def test_fallback_fills_from_unmatched(self):
|
||||
picked = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=4, rng=random.Random(0))
|
||||
ids = {a.id for a in picked}
|
||||
assert ids == {"match1", "match2", "nomatch1", "nomatch2"}
|
||||
# 命中池排在前面
|
||||
assert picked[0].id.startswith("match")
|
||||
assert picked[1].id.startswith("match")
|
||||
|
||||
def test_no_tag_match_equals_random_selection(self):
|
||||
assets = self._assets()
|
||||
picked = pick_narrative_assets(assets, script_tags=["不存在"], limit=3, rng=random.Random(42))
|
||||
assert len(picked) == 3
|
||||
|
||||
def test_empty_tags_selects_all_pool(self):
|
||||
assets = self._assets()
|
||||
picked = pick_narrative_assets(assets, script_tags=[], limit=None, rng=random.Random(1))
|
||||
assert len(picked) == 4
|
||||
|
||||
def test_limit_none_returns_all_with_matched_first(self):
|
||||
picked = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=None, rng=random.Random(1))
|
||||
assert len(picked) == 4
|
||||
assert {a.id for a in picked[:2]} == {"match1", "match2"}
|
||||
|
||||
def test_tag_ids_index_path(self):
|
||||
assets = [FakeAsset("a1", tag_ids=["t1"]), FakeAsset("a2", tag_ids=["t2"])]
|
||||
# 补 created_at
|
||||
import datetime as dt
|
||||
|
||||
for a in assets:
|
||||
a.created_at = dt.datetime(2020, 1, 1, tzinfo=dt.UTC)
|
||||
picked = pick_narrative_assets(
|
||||
assets,
|
||||
script_tags=["教程"],
|
||||
tag_names_by_id={"a1": ["教程"], "a2": ["旅游"]},
|
||||
limit=1,
|
||||
rng=random.Random(0),
|
||||
)
|
||||
assert [a.id for a in picked] == ["a1"]
|
||||
|
||||
def test_deterministic_with_seed(self):
|
||||
r1 = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=4, rng=random.Random(7))
|
||||
r2 = pick_narrative_assets(self._assets(), script_tags=["工厂"], limit=4, rng=random.Random(7))
|
||||
assert [a.id for a in r1] == [a.id for a in r2]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
@@ -1,454 +0,0 @@
|
||||
"""#1970 PR3 叙事前置服务 narrative_service 单元测试(不依赖真实 PG/OSS/CosyVoice)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.api.app.services import narrative_service as ns
|
||||
from apps.api.app.services.narrative_service import (
|
||||
NarrativeError,
|
||||
_resolve_voice,
|
||||
_save_tts_job_as_voice_asset,
|
||||
prepare_narrative_voice,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import ScriptModel
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
|
||||
# ── fakes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProfile:
|
||||
id: str = "prof-1"
|
||||
user_id: str = "u1"
|
||||
voice_id: str = "cv-voice-1"
|
||||
|
||||
|
||||
class FakeCloneRepo:
|
||||
def __init__(self, profile: FakeProfile | None = None):
|
||||
self._profile = profile
|
||||
|
||||
def get(self, pid: str) -> FakeProfile | None:
|
||||
if self._profile and self._profile.id == pid:
|
||||
return self._profile
|
||||
return None
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, script: ScriptModel | None):
|
||||
self._script = script
|
||||
|
||||
def filter(self, *conditions):
|
||||
# 服务端写 filter(...).filter(...) 链式调用;归属/ID 已在 FakeDb 构造时过滤
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._script
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, script: ScriptModel | None, *, current_user: str = "u1", query_script_id: str = "script-1"):
|
||||
self._script = script
|
||||
self._current_user = current_user
|
||||
self._query_script_id = query_script_id
|
||||
|
||||
def query(self, model):
|
||||
visible = self._script
|
||||
if visible is not None and (visible.user_id != self._current_user or visible.id != self._query_script_id):
|
||||
visible = None
|
||||
return FakeQuery(visible)
|
||||
|
||||
|
||||
def _make_script(*, user_id: str = "u1", content: str = "这是一段口播文案", title: str = "测试文案", tags=None):
|
||||
return ScriptModel(
|
||||
id="script-1",
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
segments=[],
|
||||
tags=tags if tags is not None else ["带货"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeLibrary:
|
||||
id: str = "lib-voice"
|
||||
project_id: str = "p1"
|
||||
kind: Any = field(default_factory=lambda: SimpleNamespace(value="voice"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProject:
|
||||
id: str = "p1"
|
||||
|
||||
|
||||
class FakeProjectRepo:
|
||||
def __init__(self, projects=None):
|
||||
self._projects = projects if projects is not None else [FakeProject()]
|
||||
|
||||
def find_accessible_projects(self, user_id):
|
||||
return self._projects
|
||||
|
||||
|
||||
class FakeLibraryRepo:
|
||||
def __init__(self, libs=None):
|
||||
self._libs = libs if libs is not None else [FakeLibrary()]
|
||||
self.created: list = []
|
||||
|
||||
def find_by_project(self, project_id):
|
||||
return list(self._libs)
|
||||
|
||||
def create(self, library):
|
||||
self.created.append(library)
|
||||
return library
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAsset:
|
||||
id: str = "asset-new"
|
||||
duration: float | None = 12.0
|
||||
|
||||
|
||||
class FakeAssetRepo:
|
||||
def __init__(self):
|
||||
self.created: list = []
|
||||
|
||||
def create(self, asset):
|
||||
wrapped = FakeAsset(id="asset-new", duration=getattr(asset, "duration", None))
|
||||
self.created.append(asset)
|
||||
return wrapped
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self, *, fail_download: bool = False):
|
||||
self.fail_download = fail_download
|
||||
self.uploaded: list = []
|
||||
|
||||
def download_asset(self, source, dest_path) -> bool:
|
||||
if self.fail_download:
|
||||
return False
|
||||
dest_path.write_bytes(b"FAKEAUDIO")
|
||||
return True
|
||||
|
||||
def upload_file(self, path, key, content_type="", **kwargs):
|
||||
self.uploaded.append((key, content_type))
|
||||
|
||||
def delete_file(self, key):
|
||||
pass
|
||||
|
||||
|
||||
class FakeTTSRepo:
|
||||
def __init__(self, job: TTSJob):
|
||||
self.job = job
|
||||
self.saved: list[TTSJob] = []
|
||||
|
||||
def create(self, job: TTSJob) -> TTSJob:
|
||||
self.saved.append(job)
|
||||
self.job = job
|
||||
return job
|
||||
|
||||
def update(self, job: TTSJob) -> TTSJob:
|
||||
self.job = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> TTSJob | None:
|
||||
return self.job if self.job.id == job_id else None
|
||||
|
||||
|
||||
class FakeCosyVoice:
|
||||
pass
|
||||
|
||||
|
||||
def _make_completed_job() -> TTSJob:
|
||||
job = TTSJob.create(
|
||||
user_id="u1",
|
||||
input_text="这是一段口播文案",
|
||||
voice_id="cv-voice-1",
|
||||
voice_clone_profile_id="",
|
||||
format="mp3",
|
||||
sample_rate=22050,
|
||||
)
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url="https://oss/tts/output/job-1.mp3",
|
||||
output_audio_key="tts/output/job-1.mp3",
|
||||
duration=12.5,
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
# ── _resolve_voice ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveVoice:
|
||||
def test_preset_returns_id_directly_when_no_profile(self):
|
||||
voice_id, clone_id = _resolve_voice(
|
||||
user_id="u1",
|
||||
tts_voice_id="longxiaochun",
|
||||
tts_voice_source="preset",
|
||||
voice_clone_repository=FakeCloneRepo(None),
|
||||
)
|
||||
assert voice_id == "longxiaochun"
|
||||
assert clone_id == ""
|
||||
|
||||
def test_preset_id_that_is_clone_profile_uuid_resolves(self):
|
||||
repo = FakeCloneRepo(FakeProfile())
|
||||
voice_id, clone_id = _resolve_voice(
|
||||
user_id="u1",
|
||||
tts_voice_id="prof-1",
|
||||
tts_voice_source="preset",
|
||||
voice_clone_repository=repo,
|
||||
)
|
||||
assert voice_id == "cv-voice-1"
|
||||
assert clone_id == "prof-1"
|
||||
|
||||
def test_clone_source(self):
|
||||
voice_id, clone_id = _resolve_voice(
|
||||
user_id="u1",
|
||||
tts_voice_id="prof-1",
|
||||
tts_voice_source="clone",
|
||||
voice_clone_repository=FakeCloneRepo(FakeProfile()),
|
||||
)
|
||||
assert voice_id == "cv-voice-1"
|
||||
assert clone_id == "prof-1"
|
||||
|
||||
def test_clone_missing_404(self):
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
_resolve_voice(
|
||||
user_id="u1",
|
||||
tts_voice_id="nope",
|
||||
tts_voice_source="clone",
|
||||
voice_clone_repository=FakeCloneRepo(None),
|
||||
)
|
||||
assert ei.value.status_code == 404
|
||||
|
||||
def test_clone_other_user_403(self):
|
||||
repo = FakeCloneRepo(FakeProfile(user_id="someone-else"))
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
_resolve_voice(
|
||||
user_id="u1",
|
||||
tts_voice_id="prof-1",
|
||||
tts_voice_source="clone",
|
||||
voice_clone_repository=repo,
|
||||
)
|
||||
assert ei.value.status_code == 403
|
||||
|
||||
def test_clone_not_ready_400(self):
|
||||
repo = FakeCloneRepo(FakeProfile(voice_id=""))
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
_resolve_voice(
|
||||
user_id="u1",
|
||||
tts_voice_id="prof-1",
|
||||
tts_voice_source="clone",
|
||||
voice_clone_repository=repo,
|
||||
)
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
|
||||
# ── save asset ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSaveVoiceAsset:
|
||||
def _deps(self, **storage_kw):
|
||||
return dict(
|
||||
user_id="u1",
|
||||
name="测试配音",
|
||||
project_repository=FakeProjectRepo(),
|
||||
asset_library_repository=FakeLibraryRepo(),
|
||||
asset_repository=FakeAssetRepo(),
|
||||
storage_service=FakeStorage(**storage_kw),
|
||||
)
|
||||
|
||||
def test_save_creates_asset(self):
|
||||
job = _make_completed_job()
|
||||
deps = self._deps()
|
||||
asset = _save_tts_job_as_voice_asset(job=job, **deps)
|
||||
assert asset.id == "asset-new"
|
||||
assert deps["asset_repository"].created[0].mime_type == "audio/mpeg"
|
||||
assert deps["storage_service"].uploaded[0][0] == "uploads/voice/tts/" + job.id + ".mp3"
|
||||
|
||||
def test_no_project_raises(self):
|
||||
job = _make_completed_job()
|
||||
deps = self._deps()
|
||||
deps["project_repository"] = FakeProjectRepo(projects=[])
|
||||
with pytest.raises(NarrativeError):
|
||||
_save_tts_job_as_voice_asset(job=job, **deps)
|
||||
|
||||
def test_download_fail_raises_502(self):
|
||||
job = _make_completed_job()
|
||||
deps = self._deps(fail_download=True)
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
_save_tts_job_as_voice_asset(job=job, **deps)
|
||||
assert ei.value.status_code == 502
|
||||
|
||||
def test_job_without_output_raises(self):
|
||||
job = TTSJob.create(user_id="u1", input_text="x", voice_id="v", voice_clone_profile_id="")
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
_save_tts_job_as_voice_asset(job=job, **self._deps())
|
||||
assert ei.value.status_code == 502
|
||||
|
||||
|
||||
# ── prepare_narrative_voice 主流程(monkeypatch workflow) ─────────────────
|
||||
|
||||
|
||||
class TestPrepareNarrativeVoice:
|
||||
def _deps(self, db_script=None, *, has_script=True, clone_profile=None, storage_fail=False, points_enabled=False):
|
||||
job = _make_completed_job()
|
||||
return dict(
|
||||
db=FakeDb(db_script if db_script is not None else (_make_script() if has_script else None)),
|
||||
user_id="u1",
|
||||
script_id="script-1",
|
||||
tts_voice_id="longxiaochun",
|
||||
tts_voice_source="preset",
|
||||
tts_repository=FakeTTSRepo(job),
|
||||
cosyvoice_service=FakeCosyVoice(),
|
||||
voice_clone_repository=FakeCloneRepo(clone_profile),
|
||||
asset_repository=FakeAssetRepo(),
|
||||
asset_library_repository=FakeLibraryRepo(),
|
||||
project_repository=FakeProjectRepo(),
|
||||
storage_service=FakeStorage(fail_download=storage_fail),
|
||||
points_enabled=points_enabled,
|
||||
)
|
||||
|
||||
def test_success_returns_context(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeWorkflow:
|
||||
def __init__(self, *, repository, cosyvoice_service):
|
||||
captured["repo"] = repository
|
||||
self._repo = repository
|
||||
|
||||
def start_synthesis(self, job_id):
|
||||
job = self._repo.get(job_id)
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url="https://oss/tts/output/x.mp3",
|
||||
output_audio_key="tts/output/x.mp3",
|
||||
duration=12.5,
|
||||
)
|
||||
return job
|
||||
|
||||
def poll_and_process_synthesis(self, job_id, timeout=120.0):
|
||||
return self._repo.get(job_id)
|
||||
|
||||
monkeypatch.setattr(ns, "TTSWorkflowService", FakeWorkflow)
|
||||
ctx = prepare_narrative_voice(**self._deps())
|
||||
assert ctx.voice_asset_id == "asset-new"
|
||||
assert ctx.tts_job_id
|
||||
assert ctx.audio_duration == pytest.approx(12.5)
|
||||
assert ctx.script.tags == ["带货"]
|
||||
|
||||
def test_script_missing_404(self):
|
||||
deps = self._deps(has_script=False)
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
prepare_narrative_voice(**deps)
|
||||
assert ei.value.status_code == 404
|
||||
|
||||
def test_script_other_user_404(self):
|
||||
deps = self._deps(db_script=_make_script(user_id="other"))
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
prepare_narrative_voice(**deps)
|
||||
assert ei.value.status_code == 404
|
||||
|
||||
def test_empty_content_400(self):
|
||||
deps = self._deps(db_script=_make_script(content=" "))
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
prepare_narrative_voice(**deps)
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
def test_synth_failure_raises_502(self, monkeypatch):
|
||||
class FailingWorkflow:
|
||||
def __init__(self, *, repository, cosyvoice_service):
|
||||
self._repo = repository
|
||||
|
||||
def start_synthesis(self, job_id):
|
||||
raise RuntimeError("cosyvoice down")
|
||||
|
||||
def process_synthesis_failure(self, job_id, error):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ns, "TTSWorkflowService", FailingWorkflow)
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
prepare_narrative_voice(**self._deps())
|
||||
assert ei.value.status_code == 502
|
||||
assert "配音合成失败" in ei.value.message
|
||||
|
||||
def test_points_insufficient_402(self, monkeypatch):
|
||||
class FakePoints:
|
||||
def deduct_points(self, *a, **k):
|
||||
return {"success": False, "balance": 0}
|
||||
|
||||
monkeypatch.setattr(ns, "PointsService", lambda: FakePoints())
|
||||
deps = self._deps(points_enabled=True)
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
prepare_narrative_voice(**deps)
|
||||
assert ei.value.status_code == 402
|
||||
|
||||
def test_points_refund_on_failure(self, monkeypatch):
|
||||
class FakePoints:
|
||||
def __init__(self):
|
||||
self.refunded = 0
|
||||
|
||||
def deduct_points(self, *a, **k):
|
||||
return {"success": True, "balance": 100}
|
||||
|
||||
def refund_points(self, user_id, amount, source, db, ref_id="", **k):
|
||||
self.refunded += amount
|
||||
|
||||
points = FakePoints()
|
||||
monkeypatch.setattr(ns, "PointsService", lambda: points)
|
||||
|
||||
class FailingWorkflow:
|
||||
def __init__(self, *, repository, cosyvoice_service):
|
||||
pass
|
||||
|
||||
def start_synthesis(self, job_id):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def process_synthesis_failure(self, job_id, error):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ns, "TTSWorkflowService", FailingWorkflow)
|
||||
deps = self._deps(points_enabled=True)
|
||||
with pytest.raises(NarrativeError):
|
||||
prepare_narrative_voice(**deps)
|
||||
assert points.refunded > 0
|
||||
|
||||
def test_clone_source_resolves_profile(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeWorkflow:
|
||||
def __init__(self, *, repository, cosyvoice_service):
|
||||
self._repo = repository
|
||||
captured["cosy"] = cosyvoice_service
|
||||
|
||||
def start_synthesis(self, job_id):
|
||||
job = self._repo.get(job_id)
|
||||
captured["voice_id"] = job.voice_id
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url="https://oss/tts/output/x.mp3",
|
||||
output_audio_key="tts/output/x.mp3",
|
||||
duration=12.5,
|
||||
)
|
||||
return job
|
||||
|
||||
def poll_and_process_synthesis(self, job_id, timeout=120.0):
|
||||
return self._repo.get(job_id)
|
||||
|
||||
monkeypatch.setattr(ns, "TTSWorkflowService", FakeWorkflow)
|
||||
deps = self._deps(clone_profile=FakeProfile())
|
||||
deps["tts_voice_id"] = "prof-1"
|
||||
deps["tts_voice_source"] = "clone"
|
||||
prepare_narrative_voice(**deps)
|
||||
assert captured["voice_id"] == "cv-voice-1"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest as _pytest
|
||||
|
||||
_pytest.main([__file__, "-q"])
|
||||
@@ -187,39 +187,29 @@ def test_asr_not_configured_returns_503(fake_user):
|
||||
# ── ASR 转写失败 → 502 ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_asr_transcription_failure_returns_503_with_desc_fallback(fake_user):
|
||||
"""ASR 转写异常(MediaKit+本地都失败)→ desc 兜底;desc 也空则 503。"""
|
||||
from app.api.routes import scripts_ai
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import HTTPException
|
||||
def test_asr_transcription_failure_returns_502(fake_user):
|
||||
"""ASR 转写异常 → 502(被 _direct_url_download_and_local_asr 包装)。"""
|
||||
from app.services.script_asr_service import ASRTranscriptionError
|
||||
|
||||
scripts_ai = _import_target()
|
||||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/")
|
||||
|
||||
# MediaKit 失败
|
||||
fake_mk = mock.MagicMock()
|
||||
fake_mk.is_available = True
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
|
||||
fake_mk.asr_submit.side_effect = MediaKitError("ASR failed", code="TaskFailed")
|
||||
|
||||
# desc 为空 → 最终 503(stage=asr)
|
||||
with _fake_resolver_success(desc=""):
|
||||
with mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk):
|
||||
with mock.patch(
|
||||
"app.api.routes.scripts_ai._direct_url_download_and_local_asr",
|
||||
side_effect=HTTPException(status_code=502, detail="语音识别失败: No module named 'apps.worker'"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
# desc 非空 → desc 兜底成功,返回 200
|
||||
with _fake_resolver_success(desc="这是视频文案描述"):
|
||||
with mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk):
|
||||
with mock.patch(
|
||||
"app.api.routes.scripts_ai._direct_url_download_and_local_asr",
|
||||
side_effect=HTTPException(status_code=502, detail="语音识别失败"),
|
||||
):
|
||||
resp = scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert resp.text == "这是视频文案描述"
|
||||
assert resp.duration_seconds == 0.0
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||||
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
# ── 下载超时 → 504 ────────────────────────────────────────────
|
||||
|
||||
@@ -297,74 +297,3 @@ class TestResolveLatestPlanByTemplate:
|
||||
with caplog.at_level("WARNING"):
|
||||
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
|
||||
assert any("查找最新plan失败" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# collect_plan_atom_clip_ids (#1970)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _make_atom_clip(atom_clip_id):
|
||||
c = MagicMock()
|
||||
c.atom_clip_id = atom_clip_id
|
||||
return c
|
||||
|
||||
|
||||
class TestCollectPlanAtomClipIds:
|
||||
def test_empty_plan_returns_empty(self):
|
||||
from app.services.generation_common import collect_plan_atom_clip_ids
|
||||
|
||||
repo = MagicMock()
|
||||
repo.list_by_plan.return_value = []
|
||||
assert collect_plan_atom_clip_ids("p1", repo) == []
|
||||
|
||||
def test_collects_non_empty_ids_and_ignores_blank(self):
|
||||
from app.services.generation_common import collect_plan_atom_clip_ids
|
||||
|
||||
repo = MagicMock()
|
||||
repo.list_by_plan.side_effect = [
|
||||
[
|
||||
_make_atom_clip("atom-1"),
|
||||
_make_atom_clip(""),
|
||||
_make_atom_clip("atom-2"),
|
||||
],
|
||||
[],
|
||||
]
|
||||
assert collect_plan_atom_clip_ids("p1", repo) == ["atom-1", "atom-2"]
|
||||
|
||||
def test_missing_attribute_treated_as_blank(self):
|
||||
from app.services.generation_common import collect_plan_atom_clip_ids
|
||||
|
||||
legacy = MagicMock()
|
||||
del legacy.atom_clip_id # 旧对象无该属性
|
||||
repo = MagicMock()
|
||||
repo.list_by_plan.side_effect = [[legacy, _make_atom_clip("atom-9")], []]
|
||||
assert collect_plan_atom_clip_ids("p1", repo) == ["atom-9"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# writeback_edit_plan_config:#1970 dedup_enabled / video_index
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestWritebackDedupAndVideoIndex:
|
||||
def test_writes_dedup_enabled_and_video_index(self):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", None, db, dedup_enabled=False, video_index=3)
|
||||
assert plan.config["dedup_enabled"] is False
|
||||
assert plan.config["video_index"] == 3
|
||||
assert plan.config["generation_task_id"] == "t1"
|
||||
|
||||
def test_none_dedup_does_not_touch_flag(self):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({"dedup_enabled": True})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", None, db)
|
||||
assert plan.config["dedup_enabled"] is True
|
||||
assert "video_index" not in plan.config
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""GpuLipsyncService 单元测试 — 覆盖任务创建、轮询认领、结果上报、超时回退等核心逻辑.
|
||||
|
||||
使用 SQLite 内存数据库,mock 掉存储层(不真实调用 OSS)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
# 确保 packages / apps/api 可导入
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
for p in (ROOT, os.path.join(ROOT, "apps", "api"), os.path.join(ROOT, "packages")):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
# 强制使用内存 SQLite(避免依赖 PG)
|
||||
os.environ["APP_ENV"] = "development"
|
||||
os.environ["JWT_SECRET_KEY"] = "dev-secret-key-for-testing-00000000"
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
os.environ["USE_IN_MEMORY_DB"] = "1"
|
||||
os.environ["GPU_WORKER_TOKEN"] = "" # development 空 token 放行
|
||||
|
||||
|
||||
def _build_session():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# 使用 packages 的 Base
|
||||
from packages.adapters.sqlalchemy_impl import models as _ # noqa: F401 # 触发 ORM 注册
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
return Session()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc():
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
db = _build_session()
|
||||
service = GpuLipsyncService(db)
|
||||
# mock 存储签名(SQLite 测试无 OSS)
|
||||
service.storage = mock.MagicMock()
|
||||
service.storage.get_download_url.side_effect = (
|
||||
lambda k, expires_seconds=3600: f"https://signed.example.com/download/{k}?e={expires_seconds}"
|
||||
)
|
||||
service.storage.get_upload_url.side_effect = (
|
||||
lambda k, expires_seconds=3600, content_type="video/mp4": f"https://signed.example.com/upload/{k}?e={expires_seconds}"
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_task(svc):
|
||||
task = svc.create_task(
|
||||
video_url="uploads/v.mp4",
|
||||
audio_url="uploads/a.mp3",
|
||||
lipsync_job_id="lip-1",
|
||||
user_id="u-1",
|
||||
project_id="p-1",
|
||||
)
|
||||
assert task.id
|
||||
assert task.status == "pending"
|
||||
assert task.lipsync_job_id == "lip-1"
|
||||
assert task.attempt == 0
|
||||
assert task.video_url == "uploads/v.mp4"
|
||||
|
||||
|
||||
# ── 轮询认领 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_poll_returns_none_when_empty(svc):
|
||||
assert svc.poll_task("w-1") is None
|
||||
|
||||
|
||||
def test_poll_claims_pending_task(svc):
|
||||
svc.create_task(video_url="uploads/v.mp4", audio_url="uploads/a.mp3")
|
||||
claimed = svc.poll_task("w-1")
|
||||
assert claimed is not None
|
||||
assert claimed.status == "processing"
|
||||
assert claimed.worker_id == "w-1"
|
||||
assert claimed.attempt == 1
|
||||
# 带签名 URL
|
||||
assert claimed._signed_video_url.startswith("https://signed.example.com/download/")
|
||||
assert claimed._signed_upload_url.startswith("https://signed.example.com/upload/")
|
||||
# 再 poll 无任务
|
||||
assert svc.poll_task("w-1") is None
|
||||
|
||||
|
||||
def test_poll_concurrent_claim_only_one_wins(svc):
|
||||
"""并发场景:两个 worker 同时 poll 只有一个能拿到任务(借助 update where status=pending)。"""
|
||||
svc.create_task(video_url="v", audio_url="a")
|
||||
t1 = svc.poll_task("w-1")
|
||||
t2 = svc.poll_task("w-2")
|
||||
assert t1 is not None
|
||||
assert t2 is None
|
||||
|
||||
|
||||
# ── 结果上报 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_report_result_success(svc):
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1") # claim
|
||||
done = svc.report_result(t.id, "w-1", success=True, duration_seconds=12.5)
|
||||
assert done.status == "done"
|
||||
assert done.result_duration == 12.5
|
||||
assert done.result_url.startswith("gpu-lipsync/results/")
|
||||
assert done.finished_at is not None
|
||||
|
||||
|
||||
def test_report_result_failure_requeues(svc):
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
failed = svc.report_result(t.id, "w-1", success=False, error_msg="MuseTalk crash")
|
||||
assert failed.status == "pending" # 仍在重试次数内 → 回队
|
||||
assert failed.worker_id == ""
|
||||
assert failed.started_at is None
|
||||
assert "MuseTalk crash" in failed.error_msg
|
||||
|
||||
|
||||
def test_report_failure_exhausted_goes_failed(svc):
|
||||
"""失败达到 MAX_ATTEMPTS 后标记 failed,不再回队.
|
||||
|
||||
poll 成功会将 attempt 从 0 开始自增;
|
||||
第 1/2 次失败回队,第 3 次失败(attempt==MAX_ATTEMPTS)置 failed。
|
||||
"""
|
||||
from app.services import gpu_lipsync_service as mod
|
||||
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
# 模拟失败到上限:poll + fail 重复 MAX_ATTEMPTS 次
|
||||
for i in range(mod.MAX_ATTEMPTS):
|
||||
claimed = svc.poll_task(f"w-{i}")
|
||||
assert claimed is not None, f"第 {i} 次 poll 应能拿到任务"
|
||||
svc.report_result(t.id, claimed.worker_id, success=False, error_msg=f"fail {i}")
|
||||
svc.db.refresh(t)
|
||||
if i == mod.MAX_ATTEMPTS - 1:
|
||||
assert t.status == "failed"
|
||||
else:
|
||||
assert t.status == "pending"
|
||||
|
||||
|
||||
# ── 心跳/超时回退 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_timed_out_task_is_redispatched(svc):
|
||||
"""processing 超过 gpu_task_timeout_seconds 无心跳 → 回退 pending."""
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
svc.db.refresh(t)
|
||||
assert t.status == "processing"
|
||||
# 手动把 last_heartbeat_at 设到很久以前
|
||||
t.last_heartbeat_at = datetime.now(UTC) - timedelta(seconds=svc.settings.gpu_task_timeout_seconds + 10)
|
||||
svc.db.commit()
|
||||
# 再次 poll 会触发 _recover_timed_out_tasks 把它回队
|
||||
claimed = svc.poll_task("w-2")
|
||||
assert claimed is not None
|
||||
assert claimed.id == t.id
|
||||
assert claimed.worker_id == "w-2"
|
||||
assert claimed.attempt == 2 # 又认领了一次
|
||||
|
||||
|
||||
# ── Worker 注册 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_register_worker_creates_then_updates(svc):
|
||||
w = svc.register_worker("w-1", hostname="pc1", gpu_name="RTX2060", free_vram_mb=3500)
|
||||
assert w.worker_id == "w-1"
|
||||
assert w.gpu_name == "RTX2060"
|
||||
w2 = svc.register_worker("w-1", hostname="pc1", gpu_name="RTX2060", free_vram_mb=2000)
|
||||
assert w2.free_vram_mb == 2000 # 更新
|
||||
assert w2.created_at == w.created_at # 没新建
|
||||
|
||||
|
||||
def test_register_with_task_id_refreshes_task_heartbeat(svc):
|
||||
"""#1970 推理期心跳:register(task_id=...) 只刷新本 worker 的 processing 任务."""
|
||||
from packages.adapters.sqlalchemy_impl.models import GpuWorkerModel
|
||||
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
svc.db.refresh(t)
|
||||
old_hb = t.last_heartbeat_at
|
||||
assert t.status == "processing"
|
||||
# 模拟时间流逝后心跳到达
|
||||
svc.db.query(GpuWorkerModel).filter_by(worker_id="w-1").update(
|
||||
{"last_heartbeat_at": old_hb - timedelta(seconds=300)}
|
||||
)
|
||||
svc.db.commit()
|
||||
svc.register_worker("w-1", task_id=t.id)
|
||||
svc.db.refresh(t)
|
||||
assert t.last_heartbeat_at > old_hb
|
||||
assert t.status == "processing" # 心跳不改变状态
|
||||
# worker 表心跳也被刷新
|
||||
w = svc.db.query(GpuWorkerModel).filter_by(worker_id="w-1").one()
|
||||
assert w.last_heartbeat_at > old_hb
|
||||
|
||||
|
||||
def test_register_task_heartbeat_ignores_finished_or_foreign_task(svc):
|
||||
"""任务已 done,或已被超时回收重新派发给别的 worker 时,旧心跳必须忽略."""
|
||||
from packages.adapters.sqlalchemy_impl.models import GpuLipsyncTaskModel, GpuWorkerModel
|
||||
|
||||
# 场景 1:任务已完成 → register 带 task_id 不得改写任务心跳
|
||||
t = svc.create_task(video_url="v", audio_url="a")
|
||||
svc.poll_task("w-1")
|
||||
done = svc.report_result(t.id, "w-1", success=True, duration_seconds=10.0)
|
||||
hb_when_done = done.last_heartbeat_at
|
||||
svc.register_worker("w-1", task_id=t.id)
|
||||
svc.db.refresh(t)
|
||||
assert t.status == "done"
|
||||
assert t.last_heartbeat_at == hb_when_done # 没被改写
|
||||
|
||||
# 场景 2:任务超时回收后被 w-2 重新认领,旧 worker w-1 的迟到心跳无效
|
||||
t2 = svc.create_task(video_url="v2", audio_url="a2")
|
||||
svc.poll_task("w-1")
|
||||
svc.db.refresh(t2)
|
||||
t2.last_heartbeat_at = datetime.now(UTC) - timedelta(days=1)
|
||||
svc.db.commit()
|
||||
claimed = svc.poll_task("w-2") # 触发回收并由 w-2 重新认领
|
||||
assert claimed is not None and claimed.id == t2.id
|
||||
owner_hb = claimed.last_heartbeat_at
|
||||
# 把 w-2 的 worker 心跳拨早,确认旧心跳不会影响任务归属
|
||||
svc.db.query(GpuWorkerModel).filter_by(worker_id="w-2").update(
|
||||
{"last_heartbeat_at": owner_hb - timedelta(seconds=600)}
|
||||
)
|
||||
svc.db.commit()
|
||||
svc.register_worker("w-1", task_id=t2.id) # 旧 worker 迟到心跳
|
||||
svc.db.refresh(t2)
|
||||
assert t2.worker_id == "w-2"
|
||||
assert t2.status == "processing"
|
||||
assert t2.last_heartbeat_at == owner_hb
|
||||
|
||||
# 场景 3:不存在的 task_id 不报错
|
||||
svc.register_worker("w-1", task_id="nonexistent-id")
|
||||
assert svc.db.get(GpuLipsyncTaskModel, "nonexistent-id") is None
|
||||
|
||||
|
||||
def test_default_gpu_task_timeout_is_900(svc):
|
||||
"""#1970 默认超时 300→900,覆盖 RTX2060 长视频推理."""
|
||||
assert svc.settings.gpu_task_timeout_seconds == 900
|
||||
|
||||
|
||||
# ── get_by_lipsync_job ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_by_lipsync_job_returns_latest(svc):
|
||||
svc.create_task(video_url="v", audio_url="a", lipsync_job_id="lip-1")
|
||||
svc.create_task(video_url="v", audio_url="a", lipsync_job_id="lip-1")
|
||||
latest = svc.get_by_lipsync_job("lip-1")
|
||||
assert latest is not None
|
||||
@@ -1,155 +0,0 @@
|
||||
"""LipsyncService GPU 路径集成测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_db():
|
||||
db = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_mediakit():
|
||||
client = MagicMock()
|
||||
client.submit_lipsync.return_value = {"task_id": "mk-task-1"}
|
||||
return client
|
||||
|
||||
|
||||
def _make_job(video_url="oss://video.mp4", audio_url="oss://audio.wav"):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
job.user_id = "u1"
|
||||
job.project_id = "p1"
|
||||
job.video_url = video_url
|
||||
job.audio_url = audio_url
|
||||
job.enable_video_loop = True
|
||||
job.script_text = ""
|
||||
job.sentence_timings = None
|
||||
return job
|
||||
|
||||
|
||||
def _make_svc(db, mediakit, use_gpu=False):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService(db=db, client=mediakit)
|
||||
svc.settings.use_gpu_lipsync = use_gpu
|
||||
svc._sign_media_url = lambda u: (u or "") + "?signed"
|
||||
return svc
|
||||
|
||||
|
||||
class TestGpuFallback:
|
||||
def test_switch_off_uses_mediakit(self, fake_db, fake_mediakit):
|
||||
"""开关关闭时直接走 MediaKit,不调用 _submit_to_gpu."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=False)
|
||||
job = _make_job()
|
||||
with patch.object(svc, "_submit_to_gpu") as m_sub:
|
||||
svc._submit_audio_direct(job=job)
|
||||
m_sub.assert_not_called()
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_switch_on_no_worker_falls_back(self, fake_db, fake_mediakit):
|
||||
"""开关打开但 has_available_worker=False → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = False
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_gpu_svc.create_task.assert_not_called()
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_success_marks_completed(self, fake_db, fake_mediakit):
|
||||
"""GPU 路径成功:job 直接 completed,不调 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
gpu_done = MagicMock(
|
||||
id="gpu-task-1",
|
||||
status="done",
|
||||
result_url="oss://gpu-results/r.mp4",
|
||||
result_duration=12.5,
|
||||
)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-task-1")
|
||||
fake_gpu_svc.wait_for_result.return_value = gpu_done
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_gpu_svc.create_task.assert_called_once()
|
||||
fake_mediakit.submit_lipsync.assert_not_called()
|
||||
assert job.status == "completed"
|
||||
assert job.output_duration == 12.5
|
||||
assert "?signed" in job.output_video_url
|
||||
fake_db.commit.assert_called()
|
||||
|
||||
def test_gpu_timeout_falls_back(self, fake_db, fake_mediakit):
|
||||
"""wait_for_result 返回 None(超时)→ 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-t")
|
||||
fake_gpu_svc.wait_for_result.return_value = None
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_failed_status_falls_back(self, fake_db, fake_mediakit):
|
||||
"""GPU 终态 failed → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.return_value = MagicMock(id="gpu-t")
|
||||
fake_gpu_svc.wait_for_result.return_value = MagicMock(status="failed", error_msg="musetalk crash")
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
def test_gpu_exception_falls_back(self, fake_db, fake_mediakit):
|
||||
"""GPU 路径抛异常 → 回退 MediaKit."""
|
||||
svc = _make_svc(fake_db, fake_mediakit, use_gpu=True)
|
||||
fake_gpu_svc = MagicMock()
|
||||
fake_gpu_svc.has_available_worker.return_value = True
|
||||
fake_gpu_svc.create_task.side_effect = RuntimeError("DB down")
|
||||
with patch("app.services.gpu_lipsync_service.GpuLipsyncService", return_value=fake_gpu_svc):
|
||||
job = _make_job()
|
||||
svc._submit_audio_direct(job=job)
|
||||
fake_mediakit.submit_lipsync.assert_called_once()
|
||||
assert job.status == "submitted"
|
||||
|
||||
|
||||
class TestGpuServiceHelpers:
|
||||
"""GpuLipsyncService.has_available_worker 测试."""
|
||||
|
||||
def test_no_workers(self, fake_db):
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
svc = GpuLipsyncService(db=fake_db)
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert svc.has_available_worker() is False
|
||||
|
||||
def test_fresh_worker_available(self, fake_db):
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
svc = GpuLipsyncService(db=fake_db)
|
||||
svc.settings.gpu_worker_stale_seconds = 300
|
||||
# 模拟SQL filter条件成立 → first() 返回非None
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = MagicMock()
|
||||
assert svc.has_available_worker() is True
|
||||
|
||||
def test_stale_worker_unavailable(self, fake_db):
|
||||
from app.services.gpu_lipsync_service import GpuLipsyncService
|
||||
|
||||
svc = GpuLipsyncService(db=fake_db)
|
||||
# filter条件不成立(stale)→ first() 返回None
|
||||
fake_db.query.return_value.filter.return_value.first.return_value = None
|
||||
assert svc.has_available_worker() is False
|
||||
@@ -53,16 +53,11 @@ class FakeClip:
|
||||
|
||||
@dataclass
|
||||
class FakePlan:
|
||||
"""模拟 EditPlan。
|
||||
|
||||
注意:config 默认 dedup_enabled=False,关闭 #1970 片段级微变换,
|
||||
让本文件既有的确定性渲染/stream copy 断言不受随机微变换影响;
|
||||
微变换本身的行为在 test_1970_micro_transform_render.py 覆盖。
|
||||
"""
|
||||
"""模拟 EditPlan。"""
|
||||
|
||||
id: str = "plan_001"
|
||||
name: str = "测试计划"
|
||||
config: dict[str, Any] = field(default_factory=lambda: {"dedup_enabled": False})
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
Reference in New Issue
Block a user