Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88269bf3e0 |
@@ -1,33 +0,0 @@
|
||||
"""asset_atom_clips 新增 caption/embedding 字段(#2035 语义标签增强)
|
||||
|
||||
Revision ID: 085_atom_clip_caption_embedding
|
||||
Revises: 084_lipsync_jobs_style
|
||||
Create Date: 2026-09-25
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "085_atom_clip_caption_embedding"
|
||||
down_revision = "084_lipsync_jobs_style"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# caption: 中文画面描述(10-30字)
|
||||
op.add_column(
|
||||
"asset_atom_clips",
|
||||
sa.Column("caption", sa.Text(), nullable=True),
|
||||
)
|
||||
# embedding: caption 对应的向量(豆包 embedding 接口返回,JSON 存 float 数组)
|
||||
op.add_column(
|
||||
"asset_atom_clips",
|
||||
sa.Column("embedding", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("asset_atom_clips", "embedding")
|
||||
op.drop_column("asset_atom_clips", "caption")
|
||||
@@ -8,7 +8,6 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.clips_standalone import router as clips_standalone_router
|
||||
from app.api.routes.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.drafts_standalone import router as drafts_standalone_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_cover import router as generation_cover_router
|
||||
@@ -20,8 +19,7 @@ from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.lipsync import router as lipsync_router
|
||||
from app.api.routes.points import router as points_router
|
||||
from app.api.routes.points import usage_router
|
||||
from app.api.routes.points import points_router, usage_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.scripts import router as scripts_router
|
||||
from app.api.routes.scripts_ai import router as scripts_ai_router
|
||||
@@ -43,19 +41,6 @@ api_router = APIRouter(prefix="/api/v1")
|
||||
health_router = APIRouter()
|
||||
health_router.include_router(health_check_router)
|
||||
|
||||
# ── /api/health 别名:部分前端/探针把 health 放在 /api 前缀下 ──────────────
|
||||
# 原来 /health 在根路径;额外加一个 /api/health 别名避免 404。
|
||||
api_health_router = APIRouter(prefix="/api")
|
||||
api_health_router.include_router(health_check_router)
|
||||
health_router.include_router(api_health_router)
|
||||
|
||||
# ── 旧前端路径别名(无需 template_id 路径参数)────────────────────────────
|
||||
# /api/v1/clips/from-assets 已有 clips_standalone;此处额外挂 /api/v1/editor/*,
|
||||
# 解决前端调 /api/v1/editor/clips/from-assets 和 /api/v1/editor/drafts 的 404。
|
||||
editor_legacy_router = APIRouter(prefix="/editor", tags=["Editor Legacy Alias"])
|
||||
editor_legacy_router.include_router(clips_standalone_router)
|
||||
editor_legacy_router.include_router(drafts_standalone_router)
|
||||
|
||||
api_router.include_router(
|
||||
auth_router,
|
||||
tags=["Auth"],
|
||||
@@ -184,9 +169,6 @@ api_router.include_router(
|
||||
prefix="/templates/{template_id}/editor",
|
||||
tags=["TemplateEditor"],
|
||||
)
|
||||
api_router.include_router(
|
||||
editor_legacy_router,
|
||||
)
|
||||
api_router.include_router(
|
||||
tts_router,
|
||||
prefix="/tts",
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"""独立的草稿端点(不依赖 template_id 路径参数,兼容旧前端路径).
|
||||
|
||||
提供以下别名端点,与 /api/v1/templates/{template_id}/editor/draft 功能一致:
|
||||
- GET /api/v1/editor/drafts 获取草稿详情(template_id 从 query/body/默认模板兜底)
|
||||
- PUT /api/v1/editor/drafts 更新草稿(兼容前端 useDraftAutoSave 调用)
|
||||
|
||||
根因:前端 useDraftAutoSave 调用 /api/v1/editor/drafts(复数、无 template_id),
|
||||
与后端以 template_id 为路径参数的设计不一致,导致 404 并触发 10s timeout。
|
||||
本模块参照 clips_standalone.py 的模式,通过默认模板兜底复用 draft.py 的核心逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._default_template import get_or_create_default_template_id
|
||||
from .templates_editor.dependencies import resolve_draft_plan_id
|
||||
from .templates_editor.draft import get_editor_draft, update_editor_draft
|
||||
from .templates_editor.schemas import EditorDraftResponse, EditorUpdateRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Editor Legacy Alias"])
|
||||
|
||||
|
||||
def _resolve_editor_services(db: Session) -> tuple[EditTemplateService, EditPlanService]:
|
||||
return EditTemplateService(db), EditPlanService(db)
|
||||
|
||||
|
||||
def _resolve_template_id(
|
||||
template_id: str | None,
|
||||
db: Session,
|
||||
current_user: AuthenticatedUser,
|
||||
) -> str:
|
||||
"""解析 template_id:query/body 优先,否则兜底默认模板。"""
|
||||
tid = (template_id or "").strip()
|
||||
if tid:
|
||||
return tid
|
||||
user_id = str(current_user.user.id)
|
||||
tid = get_or_create_default_template_id(db, user_id)
|
||||
if not tid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
return tid
|
||||
|
||||
|
||||
@router.get("/drafts", response_model=EditorDraftResponse)
|
||||
def get_editor_drafts_alias(
|
||||
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditorDraftResponse:
|
||||
"""获取草稿详情(复数路径别名,兼容旧前端调用)。"""
|
||||
tid = _resolve_template_id(template_id, db, current_user)
|
||||
services = _resolve_editor_services(db)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id=tid,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
return get_editor_draft(
|
||||
template_id=tid,
|
||||
plan_id=plan_id,
|
||||
services=services,
|
||||
_=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/drafts", response_model=EditorDraftResponse)
|
||||
def update_editor_drafts_alias(
|
||||
req: EditorUpdateRequest,
|
||||
template_id: str | None = Query(default=None, description="模板ID,不传则兜底默认模板"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditorDraftResponse:
|
||||
"""更新草稿(复数路径别名,兼容前端 useDraftAutoSave 调用)。"""
|
||||
tid = _resolve_template_id(template_id, db, current_user)
|
||||
services = _resolve_editor_services(db)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id=tid,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
return update_editor_draft(
|
||||
template_id=tid,
|
||||
req=req,
|
||||
plan_id=plan_id,
|
||||
services=services,
|
||||
_=current_user,
|
||||
)
|
||||
@@ -76,7 +76,10 @@ class GenerateCoverResponse(BaseModel):
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _select_best_frame_from_snapshots(snapshots: list[dict], plan_id: str) -> str:
|
||||
|
||||
def _select_best_frame_from_snapshots(
|
||||
snapshots: list[dict], plan_id: str
|
||||
) -> str:
|
||||
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
|
||||
|
||||
降级策略:cv2 不可用或评分失败时,返回第一帧。
|
||||
@@ -229,10 +232,7 @@ def _persist_cover_frame(
|
||||
|
||||
|
||||
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
|
||||
|
||||
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
|
||||
"""
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
@@ -241,20 +241,6 @@ def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
except Exception:
|
||||
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
|
||||
# awaiting_cover 兜底:从 extra_meta.rendered_output 取
|
||||
try:
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = task_repo.get(task_id)
|
||||
if task is not None:
|
||||
_status = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if _status == "awaiting_cover":
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
_url = _ro.get("file_url") or ""
|
||||
if _url:
|
||||
return _url
|
||||
except Exception:
|
||||
logger.warning("[封面生成] awaiting_cover 兜底读取失败: task_id=%s", task_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -46,37 +46,6 @@ from packages.application import (
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
# #2035:文案关键词 → 素材分类 映射表(用于 smart_match category_match 维度)
|
||||
# AssetClassification 枚举: scenic / product / person / animal / food / tech / sport / music / other
|
||||
_CATEGORY_KEYWORDS: dict[str, set[str]] = {
|
||||
"scenic": {"风景", "自然", "山水", "大海", "天空", "日落", "日出", "森林", "城市", "建筑", "夜景", "街道", "公园", "景区", "旅行", "旅游", "户外"},
|
||||
"product": {"产品", "商品", "展示", "演示", "开箱", "评测", "好物", "推荐", "种草", "购物", "电商", "带货", "品牌", "广告", "包装"},
|
||||
"person": {"人物", "人物采访", "对话", "说话", "讲解", "演讲", "采访", "聊天", "开会", "工作", "办公室", "团队", "员工", "老板", "女性", "男性", "美女", "帅哥"},
|
||||
"animal": {"动物", "宠物", "狗", "猫", "鸟", "鱼", "马", "牛", "羊", "野生动物", "动物园"},
|
||||
"food": {"美食", "食物", "餐饮", "餐厅", "做饭", "烹饪", "厨房", "菜品", "饮料", "水果", "甜点", "蛋糕", "咖啡", "茶", "零食", "吃"},
|
||||
"tech": {"科技", "数码", "电脑", "手机", "屏幕", "软件", "APP", "互联网", "AI", "人工智能", "机器人", "办公", "程序员", "代码", "屏幕录制"},
|
||||
"sport": {"运动", "健身", "跑步", "篮球", "足球", "游泳", "瑜伽", "户外", "锻炼", "体育", "比赛", "球场"},
|
||||
"music": {"音乐", "歌曲", "演唱会", "乐器", "唱歌", "跳舞", "舞蹈", "MV", "演出", "乐队", "钢琴", "吉他", "节奏"},
|
||||
}
|
||||
|
||||
|
||||
def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
|
||||
"""从文案标签集合推断期望的素材分类(可能命中多个)。标签为空返回 None。"""
|
||||
if not script_tags:
|
||||
return None
|
||||
matched: set[str] = set()
|
||||
for cat, kws in _CATEGORY_KEYWORDS.items():
|
||||
for tag in script_tags:
|
||||
tag.lower()
|
||||
for kw in kws:
|
||||
if kw in tag or tag in kw:
|
||||
matched.add(cat)
|
||||
break
|
||||
if cat in matched:
|
||||
break
|
||||
return matched or None
|
||||
|
||||
from packages.middleware.points_gate import points_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -165,11 +134,10 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int = 0,
|
||||
count: int,
|
||||
rng=None,
|
||||
script_tags: list | None = None,
|
||||
tag_names_by_id: dict | None = None,
|
||||
db=None,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
@@ -190,42 +158,6 @@ def _select_assets_from_library(
|
||||
if not ready_video_assets:
|
||||
return []
|
||||
|
||||
# #2035:加载片段级 AI 标签,供叙事模式 AI 加权和 smart 模式语义匹配使用。
|
||||
# 失败降级为空(不影响选片主流程)。
|
||||
clip_ai_tags_by_asset: dict[str, list[dict]] = {}
|
||||
ai_tags_by_asset: dict[str, dict] = {} # asset_id → 聚合后的 ai_tags dict(取首个有 has_text 的片段;合并 scene/objects/action 去重)
|
||||
try:
|
||||
if db is not None:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
|
||||
ready_ids = [a.id for a in ready_video_assets]
|
||||
clip_rows = (
|
||||
db.query(AssetAtomClipModel.asset_id, AssetAtomClipModel.ai_tags)
|
||||
.filter(AssetAtomClipModel.asset_id.in_(ready_ids))
|
||||
.filter(AssetAtomClipModel.ai_tags.isnot(None))
|
||||
.all()
|
||||
)
|
||||
agg: dict[str, dict] = {}
|
||||
for asset_id, ai_tags in clip_rows:
|
||||
if not isinstance(ai_tags, dict):
|
||||
continue
|
||||
clip_ai_tags_by_asset.setdefault(asset_id, []).append(ai_tags)
|
||||
# 聚合:合并 scene/objects/action 去重
|
||||
agg.setdefault(asset_id, {"scene": [], "objects": [], "action": [], "shot": "", "has_text": False})
|
||||
for key in ("scene", "objects", "action"):
|
||||
for v in ai_tags.get(key) or []:
|
||||
v = str(v).strip()
|
||||
if v and v not in agg[asset_id][key]:
|
||||
agg[asset_id][key].append(v)
|
||||
if ai_tags.get("has_text") is True:
|
||||
agg[asset_id]["has_text"] = True
|
||||
if not agg[asset_id]["shot"] and ai_tags.get("shot"):
|
||||
agg[asset_id]["shot"] = ai_tags["shot"]
|
||||
ai_tags_by_asset = agg
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[选片] 加载片段 AI 标签失败,降级不使用语义匹配", exc_info=True)
|
||||
clip_ai_tags_by_asset = {}
|
||||
ai_tags_by_asset = {}
|
||||
|
||||
# 叙事模式(#1970 PR3):文案标签命中池优先;无任何命中时完全降级为现有随机逻辑。
|
||||
if script_tags:
|
||||
from packages.domain.narrative_match import pick_narrative_assets
|
||||
@@ -235,7 +167,6 @@ def _select_assets_from_library(
|
||||
ready_video_assets,
|
||||
script_tags=script_tags,
|
||||
tag_names_by_id=tag_names_by_id,
|
||||
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
|
||||
limit=limit,
|
||||
rng=rng,
|
||||
)
|
||||
@@ -246,18 +177,7 @@ def _select_assets_from_library(
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
|
||||
limit = count if count > 0 else None
|
||||
# #2035:给 smart_select_assets 传入文案标签和 AI 标签映射,启用语义维度
|
||||
norm_script = {t.strip().lower() for t in (script_tags or []) if t and t.strip()}
|
||||
expected_categories = _infer_expected_categories(norm_script)
|
||||
results = smart_select_assets(
|
||||
ready_video_assets,
|
||||
limit=limit,
|
||||
kind="video",
|
||||
rng=rng,
|
||||
script_tags=norm_script if norm_script else None,
|
||||
ai_tags_by_asset=ai_tags_by_asset or None,
|
||||
expected_categories=expected_categories,
|
||||
)
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
|
||||
return [r.asset.id for r in results]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
@@ -476,7 +396,6 @@ def create_generation_task(
|
||||
count=request.asset_select_count,
|
||||
script_tags=narrative_script_tags or None,
|
||||
tag_names_by_id=_tag_index,
|
||||
db=db,
|
||||
)
|
||||
elif project_id and not resolved_asset_ids and (request.asset_select_mode in ("smart",) or narrative_script_tags):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 smart 模式(或叙事模式按标签匹配)时自动选取
|
||||
@@ -491,7 +410,6 @@ def create_generation_task(
|
||||
count=request.asset_select_count,
|
||||
script_tags=narrative_script_tags or None,
|
||||
tag_names_by_id=_tag_index,
|
||||
db=db,
|
||||
)
|
||||
if not resolved_asset_ids:
|
||||
raise HTTPException(
|
||||
@@ -1132,29 +1050,17 @@ def finalize_generation_task(
|
||||
task_id=task_id,
|
||||
user_id=authenticated_user.user.id,
|
||||
cover_url=request.cover_url or None,
|
||||
custom_title=(request.custom_title or "").strip() or None,
|
||||
)
|
||||
except GenerationFinalizeError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
|
||||
try:
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
download_url = video.file_url
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
return FinalizeGenerationResponse(
|
||||
video_id=video.id,
|
||||
project_id=getattr(video, "project_id", "") or "",
|
||||
name=getattr(video, "name", "") or "",
|
||||
file_size=int(getattr(video, "file_size", 0) or 0),
|
||||
duration=float(getattr(video, "duration", 0.0) or 0.0),
|
||||
thumbnail_url=video.thumbnail_url or "",
|
||||
cover_url=video.thumbnail_url or "",
|
||||
file_url=download_url,
|
||||
width=int(getattr(video, "width", 0) or 0),
|
||||
height=int(getattr(video, "height", 0) or 0),
|
||||
fps=float(getattr(video, "fps", 0.0) or 0.0),
|
||||
status="success",
|
||||
is_duplicate=bool(getattr(video, "is_duplicate", False)),
|
||||
is_duplicate=bool(video.is_duplicate),
|
||||
)
|
||||
|
||||
|
||||
@@ -1205,44 +1111,6 @@ def list_generation_results(
|
||||
for item in items:
|
||||
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
|
||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
||||
|
||||
# #2024/#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,
|
||||
# 从 extra_meta["rendered_output"] 合成一条轻量视频响应,供前端预览与智能封面使用。
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if not responses and status_val == "awaiting_cover":
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
_file_url = _ro.get("file_url") or ""
|
||||
if _file_url:
|
||||
if _file_url.startswith("http"):
|
||||
_download = _file_url
|
||||
else:
|
||||
try:
|
||||
_download = storage_service.get_download_url(_file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
_download = _file_url
|
||||
_name = _ro.get("name") or ""
|
||||
if not _name:
|
||||
_name = f"generated-{task_id[:8]}"
|
||||
responses.append(
|
||||
GeneratedVideoResponse(
|
||||
id=f"preview-{task_id}",
|
||||
project_id=getattr(task, "project_id", "") or "",
|
||||
generation_task_id=task_id,
|
||||
name=_name,
|
||||
file_url=_file_url,
|
||||
file_size=int(_ro.get("file_size") or 0),
|
||||
duration=float(_ro.get("duration") or 0.0),
|
||||
thumbnail_url=_ro.get("thumbnail_url") or getattr(task, "cover_url", "") or "",
|
||||
width=int(_ro.get("width") or 0),
|
||||
height=int(_ro.get("height") or 0),
|
||||
fps=float(_ro.get("fps") or 0.0),
|
||||
mode=_ro.get("mode", ""),
|
||||
download_url=_download,
|
||||
created_at=getattr(task, "updated_at", None) or getattr(task, "created_at", None),
|
||||
)
|
||||
)
|
||||
|
||||
return ListGeneratedVideosResponse(items=responses)
|
||||
|
||||
|
||||
|
||||
@@ -682,50 +682,17 @@ def create_clips_from_assets_editor(
|
||||
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
|
||||
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
|
||||
asset_scene_points: dict[str, list[float]] = {}
|
||||
invalid_asset_ids: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
for asset_id in unique_asset_ids:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if asset is None:
|
||||
logger.warning("from-assets 素材不存在或已删除,跳过: asset_id=%s", asset_id)
|
||||
invalid_asset_ids.append(asset_id)
|
||||
continue
|
||||
_dur = float(getattr(asset, "duration", 0.0) or 0.0)
|
||||
if _dur <= 0:
|
||||
# 素材时长缺失(刚上传/分析未完成)或为0,跳过该素材——避免按兜底时长分配无效片段。
|
||||
# 若所有素材都无效,在下面统一抛 400。
|
||||
logger.warning("from-assets 素材时长缺失或为0,跳过: asset_id=%s", asset_id)
|
||||
invalid_asset_ids.append(asset_id)
|
||||
continue
|
||||
valid_asset_ids.append(asset_id)
|
||||
asset_durations[asset_id] = _dur
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
try:
|
||||
if asset and hasattr(asset, "duration"):
|
||||
asset_durations[asset_id] = float(asset.duration or 0.0)
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
smart_score, _ = score_asset(asset)
|
||||
asset_smart_scores[asset_id] = smart_score
|
||||
except Exception:
|
||||
asset_smart_scores[asset_id] = 0.0
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
try:
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
|
||||
if cached_points:
|
||||
asset_scene_points[asset_id] = cached_points
|
||||
except Exception:
|
||||
pass
|
||||
if invalid_asset_ids:
|
||||
logger.info(
|
||||
"from-assets %d 个素材无效(时长缺失/不存在,已跳过): %s",
|
||||
len(invalid_asset_ids),
|
||||
",".join(invalid_asset_ids[:5]),
|
||||
)
|
||||
# 所有素材都无效(刚上传未分析完)→ 400 让前端稍后重试,而不是用兜底时长产生错乱片段
|
||||
if not valid_asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材尚未完成分析,请稍后重试",
|
||||
)
|
||||
# 后续分配素材时只在 valid_asset_ids 里挑选
|
||||
unique_asset_ids = valid_asset_ids
|
||||
logger.info(
|
||||
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
|
||||
len(asset_scene_points),
|
||||
|
||||
@@ -19,23 +19,14 @@ class FinalizeGenerationRequest(BaseModel):
|
||||
cover_url: str = Field(
|
||||
default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)"
|
||||
)
|
||||
custom_title: str = Field(default="", description="用户自定义成片标题,非空时覆盖 rendered_output.name")
|
||||
|
||||
|
||||
class FinalizeGenerationResponse(BaseModel):
|
||||
"""finalize 响应:返回新创建的成品库视频信息。"""
|
||||
|
||||
video_id: str = Field(description="新创建的成品视频 ID")
|
||||
project_id: str = Field(default="", description="成品所属项目 ID")
|
||||
name: str = Field(default="", description="成片名称")
|
||||
file_size: int = Field(default=0, description="文件大小(字节)")
|
||||
duration: float = Field(default=0.0, description="时长(秒)")
|
||||
thumbnail_url: str = Field(default="", description="最终绑定的缩略图/封面 URL")
|
||||
cover_url: str = Field(default="", description="最终绑定的封面 URL")
|
||||
file_url: str = Field(default="", description="成品视频下载 URL")
|
||||
width: int = Field(default=0)
|
||||
height: int = Field(default=0)
|
||||
fps: float = Field(default=0.0)
|
||||
file_url: str = Field(default="", description="成品视频 OSS URL")
|
||||
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
|
||||
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
|
||||
|
||||
|
||||
@@ -999,35 +999,26 @@ class EditPlanService:
|
||||
|
||||
source_bgm_config: dict = {}
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
# #2034:读取源 plan 的 dedup_enabled 决定变体是否注入视觉/像素扰动
|
||||
# 默认 True;关了则保留节奏模板+BGM差异化,但跳过 visual/pixel 扰动
|
||||
_dedup_enabled = True
|
||||
if source_plan and source_plan.config:
|
||||
source_bgm_config = source_plan.config.get("bgm", {}) or {}
|
||||
_dedup_enabled = bool(source_plan.config.get("dedup_enabled", True))
|
||||
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in range(count)]
|
||||
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
|
||||
|
||||
def _build_variant_config_update(idx: int) -> dict:
|
||||
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。
|
||||
|
||||
#2034:dedup_enabled=False 时跳过 visual_perturbation/pixel_perturbation,
|
||||
保留 rhythm_template 和 BGM 池分配(合理的多变体差异,不属于降重扰动)。
|
||||
"""
|
||||
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。"""
|
||||
upd: dict = {}
|
||||
if _dedup_enabled:
|
||||
try:
|
||||
perturbation = generate_visual_perturbation(rng)
|
||||
if idx == 0:
|
||||
perturbation["hflip"] = False
|
||||
upd["visual_perturbation"] = perturbation
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
|
||||
try:
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
upd["pixel_perturbation"] = pixel_pert
|
||||
except Exception:
|
||||
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
|
||||
try:
|
||||
perturbation = generate_visual_perturbation(rng)
|
||||
if idx == 0:
|
||||
perturbation["hflip"] = False
|
||||
upd["visual_perturbation"] = perturbation
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
|
||||
try:
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
upd["pixel_perturbation"] = pixel_pert
|
||||
except Exception:
|
||||
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
|
||||
rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None
|
||||
if rt is not None:
|
||||
upd["rhythm_template"] = rt
|
||||
|
||||
@@ -32,13 +32,7 @@ class GenerationFinalizeService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def finalize_task(
|
||||
self,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
cover_url: Optional[str] = None,
|
||||
custom_title: Optional[str] = None,
|
||||
):
|
||||
def finalize_task(self, task_id: str, user_id: str, cover_url: Optional[str] = None):
|
||||
"""执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。
|
||||
|
||||
Returns:
|
||||
@@ -64,15 +58,9 @@ class GenerationFinalizeService:
|
||||
existing = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == task_id).first()
|
||||
if existing is not None:
|
||||
logger.info("[finalize] 幂等命中 task=%s video=%s", task_id, existing.id)
|
||||
_changed = False
|
||||
if cover_url and cover_url.strip() and existing.thumbnail_url != cover_url.strip():
|
||||
existing.thumbnail_url = cover_url.strip()
|
||||
task.cover_url = cover_url.strip()
|
||||
_changed = True
|
||||
if custom_title and custom_title.strip() and (getattr(existing, "name", "") or "") != custom_title.strip():
|
||||
existing.name = custom_title.strip()
|
||||
_changed = True
|
||||
if _changed:
|
||||
self.db.commit()
|
||||
if task.status.value != "completed":
|
||||
try:
|
||||
@@ -103,23 +91,12 @@ class GenerationFinalizeService:
|
||||
task=task,
|
||||
session=self.db,
|
||||
effective_cover_url=effective_cover,
|
||||
custom_name=custom_title,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise GenerationFinalizeError(str(e), "RenderedOutputMissing", 400) from e
|
||||
|
||||
video_id = result["video_id"]
|
||||
|
||||
# 应用自定义标题
|
||||
if custom_title and custom_title.strip():
|
||||
try:
|
||||
_v = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
|
||||
if _v is not None:
|
||||
_v.name = custom_title.strip()
|
||||
self.db.flush()
|
||||
except Exception:
|
||||
logger.warning("[finalize] 更新标题失败: video_id=%s", video_id, exc_info=True)
|
||||
|
||||
# ── 推进任务 ─────────────────────────────────────────────
|
||||
task.mark_completed(result_count=1)
|
||||
task.cover_url = effective_cover
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
baseURL: "/api/v1",
|
||||
timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -4,25 +4,14 @@ import apiClient from "../client"
|
||||
export interface FinalizeGenerationRequest {
|
||||
/** 用户选定的封面图片 URL;为空则使用任务默认封面(自动截帧/智能封面) */
|
||||
cover_url?: string
|
||||
/** 用户自定义成片标题,非空时覆盖 rendered_output.name */
|
||||
custom_title?: string
|
||||
}
|
||||
|
||||
export interface FinalizeGenerationResponse {
|
||||
video_id: string
|
||||
project_id: string
|
||||
name: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string
|
||||
cover_url: string
|
||||
file_url: string
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
/** success=新建成功;already_finalized=幂等返回已有记录 */
|
||||
status: string
|
||||
is_duplicate: boolean
|
||||
}
|
||||
|
||||
export const finalizeGeneration = async (
|
||||
|
||||
@@ -6,20 +6,17 @@ import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`, { timeout: 30_000 })
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消;超时 60s 防止大 config 写入失败) */
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
|
||||
export async function updateEditPlan(
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, {
|
||||
signal,
|
||||
timeout: 60_000,
|
||||
})
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
/**
|
||||
* 共享封面编辑器样式(智能剪辑 generate + AI数字人 ai-avatar 共用)
|
||||
* #2033:从 generate.css 抽取 xx-ce-* / xx-cover-template-* / xx-cover-modal-* 规则
|
||||
*/
|
||||
|
||||
.xx-cover-modal-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xx-cover-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-template-card {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.xx-cover-template-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-cover-template-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.xx-cover-template-thumb {
|
||||
aspect-ratio: 9/16;
|
||||
background: linear-gradient(135deg, #f0f0f0, #e0e0e0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #ccc;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-cover-template-info {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.xx-cover-template-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.xx-cover-template-badge {
|
||||
font-size: 11px;
|
||||
color: #7c3aed;
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-cover-template-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.xx-cover-template-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-ce-name-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xx-ce-name-input:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.xx-ce-header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-ce-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.xx-ce-left {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.xx-ce-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.xx-ce-section {
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-ce-section-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f0f4ff;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-ce-section-header:hover {
|
||||
background: #e8edf8;
|
||||
}
|
||||
|
||||
.xx-ce-section-body {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.xx-ce-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-ce-status-text {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.xx-ce-row {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.xx-ce-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-sub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.xx-ce-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xx-ce-switch-item {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.xx-ce-switch-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-ce-color-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-ce-color-picker input[type="color"] {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.xx-ce-color-hex {
|
||||
width: 70px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-ce-position {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-ce-position .ant-input-number {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-ce-radio-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn {
|
||||
padding: 4px 14px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn + .xx-ce-radio-btn {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn.active {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
|
||||
border-left: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.xx-ce-font-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.xx-ce-font-dot--preset {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.xx-ce-font-dot--system {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.xx-ce-shadow-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-add-shadow-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-ce-add-shadow-btn:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
.xx-ce-preset-shadow-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-ce-text-bg-section {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.xx-ce-readonly-text {
|
||||
padding: 6px 10px;
|
||||
background: #eff6ff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-ce-file-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-ce-file-name {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.xx-ce-file-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-ce-file-btn:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.xx-ce-canvas-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-ce-canvas {
|
||||
width: 225px;
|
||||
height: 400px;
|
||||
background: #ddd;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-ce-anchor-dot {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xx-ce-el-portrait {
|
||||
position: absolute;
|
||||
background: #a8d4f0;
|
||||
border: 2px solid #333;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.xx-ce-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3b82f6;
|
||||
border: 1px solid #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.xx-ce-handle--0 {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--1 {
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--2 {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--3 {
|
||||
top: 50%;
|
||||
right: -4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--4 {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--5 {
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--6 {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-handle--7 {
|
||||
top: 50%;
|
||||
left: -4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.xx-ce-el-bg {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xx-ce-el-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-ce-text-bg {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.xx-cover-template-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
.xx-cover-template-thumb {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-ce-preview-tip {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.xx-ce-canvas {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider-rail {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider-track {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider-handle::after {
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
|
||||
.xx-ce-section-body .ant-slider-mark-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.xx-ce-font-select-dropdown .ant-select-item-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-ce-canvas > div {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Text panel wrapper */
|
||||
.xx-ce-text-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Canvas base gradient layer (behind all elements) */
|
||||
.xx-ce-canvas-base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useSharedCover } from "./useSharedCover"
|
||||
export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover"
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* 共享封面选择 Hook(供智能剪辑 generate 与 AI 数字人 ai-avatar 共同使用)
|
||||
*
|
||||
* 能力:
|
||||
* - 封面模板列表加载 / 选择 / 创建 / 编辑 / 删除(调用 /cover-templates 接口)
|
||||
* - 自动生成封面按钮点击 → 调用调用方传入的 generateFn
|
||||
* - 封面编辑器弹窗状态
|
||||
* - 本地封面上传文件选择
|
||||
*/
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
updateCoverTemplate,
|
||||
deleteCoverTemplate,
|
||||
} from "@/api/cover-templates"
|
||||
|
||||
export interface UseSharedCoverOptions {
|
||||
canGenerate: boolean
|
||||
disabledHint?: string
|
||||
generateFn: (templateId: string) => Promise<string | null | undefined>
|
||||
initialTemplateId?: string
|
||||
}
|
||||
|
||||
export interface UseSharedCoverReturn {
|
||||
templates: CoverTemplate[]
|
||||
templatesLoading: boolean
|
||||
templatesError: string | null
|
||||
selectedTemplateId: string
|
||||
selectedTemplateName: string
|
||||
handleSelectTemplate: (id: string) => void
|
||||
reloadTemplates: () => void
|
||||
showCoverSettings: boolean
|
||||
setShowCoverSettings: (v: boolean) => void
|
||||
showCoverEditor: boolean
|
||||
setShowCoverEditor: (v: boolean) => void
|
||||
editingTemplate: CoverTemplate | null
|
||||
handleEditTemplate: (tpl: CoverTemplate) => void
|
||||
handleCreateTemplate: () => void
|
||||
handleSaveTemplate: (tpl: CoverTemplate) => Promise<void>
|
||||
handleDeleteTemplate: (id: string) => Promise<void>
|
||||
generating: boolean
|
||||
generateAutoCover: () => Promise<void>
|
||||
uploadInputRef: React.RefObject<HTMLInputElement>
|
||||
handleUploadClick: () => void
|
||||
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
setOnUploadFile: (fn: (file: File) => Promise<string | null> | string | null) => void
|
||||
}
|
||||
|
||||
export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverReturn {
|
||||
const { canGenerate, disabledHint, generateFn, initialTemplateId = "default" } = opts
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
const [showCoverEditor, setShowCoverEditor] = useState(false)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(initialTemplateId)
|
||||
const [editingTemplate, setEditingTemplate] = useState<CoverTemplate | null>(null)
|
||||
const [templates, setTemplates] = useState<CoverTemplate[]>([])
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const onUploadFileRef = useRef<
|
||||
((file: File) => Promise<string | null> | string | null) | undefined
|
||||
>(undefined)
|
||||
|
||||
const setOnUploadFile = useCallback(
|
||||
(fn: (file: File) => Promise<string | null> | string | null) => {
|
||||
onUploadFileRef.current = fn
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const reloadTemplates = useCallback(async () => {
|
||||
setTemplatesLoading(true)
|
||||
setTemplatesError(null)
|
||||
try {
|
||||
const res = await fetchCoverTemplates()
|
||||
// 兼容两种响应:{items:[...]} 或直接数组
|
||||
const rawList = (res as unknown as { items?: CoverTemplate[] }).items ?? []
|
||||
// 确保每个模板都有 config 字段(避免编辑器打开时访问 cfg.title.text 崩溃)
|
||||
const list: CoverTemplate[] = rawList.map((t) => ({
|
||||
...t,
|
||||
config: t.config,
|
||||
}))
|
||||
setTemplates(list)
|
||||
} catch (err) {
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
status?: number
|
||||
data?: { detail?: string; message?: string; error?: { message?: string } }
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
const status = axiosErr?.response?.status
|
||||
const detail =
|
||||
axiosErr?.response?.data?.detail ||
|
||||
axiosErr?.response?.data?.message ||
|
||||
axiosErr?.response?.data?.error?.message ||
|
||||
axiosErr?.message
|
||||
console.error("[SharedCover] 加载封面模板失败:", err, "status=", status, "detail=", detail)
|
||||
if (status === 401) {
|
||||
setTemplatesError("登录已过期,请刷新页面重新登录")
|
||||
} else if (status === 403) {
|
||||
setTemplatesError(detail ? "权限不足:" + detail : "无权限访问封面模板")
|
||||
} else {
|
||||
setTemplatesError("加载模板失败:" + (detail || "请稍后重试"))
|
||||
}
|
||||
} finally {
|
||||
setTemplatesLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (showCoverSettings) {
|
||||
void reloadTemplates()
|
||||
}
|
||||
}, [showCoverSettings, reloadTemplates])
|
||||
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
setSelectedTemplateId(id)
|
||||
}, [])
|
||||
|
||||
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
|
||||
// 系统模板不可修改:复制为新模板草稿,走另存为流程
|
||||
if (tpl.is_system) {
|
||||
setEditingTemplate({
|
||||
...tpl,
|
||||
id: "",
|
||||
name: tpl.name + " 副本",
|
||||
is_system: false,
|
||||
created_at: "",
|
||||
})
|
||||
} else {
|
||||
setEditingTemplate(tpl)
|
||||
}
|
||||
setShowCoverEditor(true)
|
||||
}, [])
|
||||
|
||||
const handleCreateTemplate = useCallback(() => {
|
||||
setEditingTemplate(null)
|
||||
setShowCoverEditor(true)
|
||||
}, [])
|
||||
|
||||
const handleSaveTemplate = useCallback(
|
||||
async (tpl: CoverTemplate) => {
|
||||
try {
|
||||
// 系统模板或无 id(新建/副本)→ 走创建分支;否则走更新
|
||||
const isSystem = templates.find((t) => t.id === tpl.id)?.is_system === true
|
||||
const shouldCreate = !tpl.id || isSystem
|
||||
if (shouldCreate) {
|
||||
const created = await createCoverTemplate({
|
||||
name: tpl.name || "我的封面模板",
|
||||
config: tpl.config,
|
||||
})
|
||||
setTemplates((prev) => [...prev, created])
|
||||
setSelectedTemplateId(created.id || tpl.id)
|
||||
} else {
|
||||
const updated = await updateCoverTemplate(tpl.id, { name: tpl.name, config: tpl.config })
|
||||
setTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
|
||||
}
|
||||
setShowCoverEditor(false)
|
||||
setEditingTemplate(null)
|
||||
} catch (err) {
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
status?: number
|
||||
data?: { detail?: string; message?: string; error?: { message?: string } }
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
const status = axiosErr?.response?.status
|
||||
const detail =
|
||||
axiosErr?.response?.data?.detail ||
|
||||
axiosErr?.response?.data?.message ||
|
||||
axiosErr?.response?.data?.error?.message ||
|
||||
axiosErr?.message
|
||||
console.error("[SharedCover] 保存模板失败:", err, "status=", status, "detail=", detail)
|
||||
if (status === 403) {
|
||||
message.error("保存失败(权限不足):" + (detail || "无权操作该模板"))
|
||||
} else {
|
||||
message.error("保存模板失败:" + (detail || "请稍后重试"))
|
||||
}
|
||||
}
|
||||
},
|
||||
[templates],
|
||||
)
|
||||
|
||||
const handleDeleteTemplate = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await deleteCoverTemplate(id)
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
if (selectedTemplateId === id) {
|
||||
setSelectedTemplateId("default")
|
||||
}
|
||||
} catch (err) {
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
status?: number
|
||||
data?: { detail?: string; message?: string; error?: { message?: string } }
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
const status = axiosErr?.response?.status
|
||||
const detail =
|
||||
axiosErr?.response?.data?.detail ||
|
||||
axiosErr?.response?.data?.message ||
|
||||
axiosErr?.response?.data?.error?.message ||
|
||||
axiosErr?.message
|
||||
console.error("[SharedCover] 删除模板失败:", err, "status=", status, "detail=", detail)
|
||||
if (status === 403) {
|
||||
message.error("删除失败(权限不足):" + (detail || "无权操作该模板"))
|
||||
} else {
|
||||
message.error("删除模板失败:" + (detail || "请稍后重试"))
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedTemplateId],
|
||||
)
|
||||
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (generating) {
|
||||
message.warning("封面正在生成中,请稍候…")
|
||||
return
|
||||
}
|
||||
if (!canGenerate) {
|
||||
if (disabledHint) message.warning(disabledHint)
|
||||
return
|
||||
}
|
||||
setGenerating(true)
|
||||
try {
|
||||
const url = await generateFn(selectedTemplateId || "default")
|
||||
if (!url) {
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SharedCover] 自动生成封面失败:", err)
|
||||
const anyErr = err as { __msgShown?: boolean; message?: string }
|
||||
if (!anyErr?.__msgShown) {
|
||||
message.error(anyErr?.message || "封面生成失败")
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [generating, canGenerate, disabledHint, generateFn, selectedTemplateId])
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
uploadInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (!file) return
|
||||
if (onUploadFileRef.current) {
|
||||
const ret = onUploadFileRef.current(file)
|
||||
if (ret instanceof Promise) {
|
||||
ret.catch((err) => {
|
||||
console.error("[SharedCover] 上传封面失败:", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedTemplateName =
|
||||
templates.find((t) => t.id === selectedTemplateId)?.name ||
|
||||
(selectedTemplateId === "default" ? "默认模板" : "自定义")
|
||||
|
||||
return {
|
||||
templates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
selectedTemplateId,
|
||||
selectedTemplateName,
|
||||
handleSelectTemplate,
|
||||
reloadTemplates,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
editingTemplate,
|
||||
handleEditTemplate,
|
||||
handleCreateTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
uploadInputRef,
|
||||
handleUploadClick,
|
||||
handleFileInputChange,
|
||||
setOnUploadFile,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSharedCover
|
||||
@@ -726,11 +726,17 @@ const AiAvatarPage: React.FC = () => {
|
||||
<div className="aa-panel__body">
|
||||
{currentRenderJob?.status !== "completed" ? (
|
||||
<PanelCoverAndGenerate
|
||||
variant="setup"
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
renderJob={currentRenderJob}
|
||||
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
renderJob={currentRenderJob}
|
||||
summary={summary}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -99,18 +99,15 @@ export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
}
|
||||
|
||||
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ──
|
||||
* #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧)
|
||||
*/
|
||||
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
|
||||
export const generateRenderSmartCover = async (
|
||||
jobId: string,
|
||||
templateId: string = "default",
|
||||
): Promise<{ cover_url: string; status: string; message: string }> => {
|
||||
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
|
||||
`/ai-avatar/render/${jobId}/smart-cover`,
|
||||
templateId && templateId !== "default" ? { template_id: templateId } : {},
|
||||
// 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s
|
||||
{ timeout: templateId && templateId !== "default" ? 180000 : 120000 },
|
||||
{},
|
||||
// 抽帧+评分+转存 OSS 链路较长,120s 超时
|
||||
{ timeout: 120000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
/**
|
||||
* AI数字人 — 封面选择弹窗(#2033 共享封面组件重构)
|
||||
*
|
||||
* 复用智能剪辑的 CoverSettingsModal(模板选择)+ CoverEditorModal(7 面板自定义编辑器)
|
||||
* + 智能生成 / 本地上传 / 封面预览,与智能剪辑侧 UI 一致。
|
||||
*
|
||||
* 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构:
|
||||
* - 智能生成封面:mode="auto_frame",thumbnail_url/smart_cover_url 指向后端返回的 cover_url
|
||||
* - 本地上传封面:mode="upload",upload_url/thumbnail_url 指向 blob 预览 URL
|
||||
*
|
||||
* 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。
|
||||
* AI数字人 — 封面选择弹窗
|
||||
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供
|
||||
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo } from "react"
|
||||
import { Modal as AntModal, Spin, message } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "@/pages/generate/components/cover-settings/CoverSettingsModal"
|
||||
import CoverEditorModal from "@/pages/generate/components/cover-settings/CoverEditorModal"
|
||||
import { useSharedCover } from "@/components/cover/useSharedCover"
|
||||
import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar"
|
||||
import React from "react"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
|
||||
|
||||
interface ModalCoverSelectProps {
|
||||
open: boolean
|
||||
@@ -27,13 +13,7 @@ interface ModalCoverSelectProps {
|
||||
renderJob: RenderJob | null
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
/**
|
||||
* 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入
|
||||
* 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。
|
||||
*/
|
||||
onGenerateRenderSmartCover?: (
|
||||
renderId: string,
|
||||
) => Promise<{ cover_url: string; message?: string }>
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
onUploadCover?: (file: File) => void
|
||||
onCoverSelected: (coverUrl: string) => void
|
||||
}
|
||||
@@ -48,302 +28,31 @@ const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
|
||||
onUploadCover,
|
||||
onCoverSelected,
|
||||
}) => {
|
||||
const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id
|
||||
|
||||
const generateFn = useCallback(
|
||||
async (templateId: string): Promise<string | null> => {
|
||||
if (!renderJob || !isRenderCompleted) return null
|
||||
try {
|
||||
let coverUrl = ""
|
||||
if (onGenerateRenderSmartCover) {
|
||||
const res = await onGenerateRenderSmartCover(renderJob.id)
|
||||
coverUrl = res.cover_url
|
||||
} else {
|
||||
const res = await apiGenerateSmartCover(renderJob.id, templateId)
|
||||
coverUrl = res.cover_url
|
||||
if (!coverUrl && res.message) {
|
||||
const err = new Error(res.message) as Error & { __msgShown?: boolean }
|
||||
err.__msgShown = true
|
||||
message.error(res.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if (coverUrl) {
|
||||
onCoverConfigChange({
|
||||
mode: "auto_frame",
|
||||
thumbnail_url: coverUrl,
|
||||
smart_cover_url: coverUrl,
|
||||
})
|
||||
onCoverSelected(coverUrl)
|
||||
message.success("智能封面已生成")
|
||||
}
|
||||
return coverUrl || null
|
||||
} catch (err) {
|
||||
const anyErr = err as { __msgShown?: boolean; message?: string }
|
||||
if (!anyErr?.__msgShown) {
|
||||
message.error(anyErr?.message || "智能封面生成失败")
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[
|
||||
renderJob,
|
||||
isRenderCompleted,
|
||||
onGenerateRenderSmartCover,
|
||||
onCoverConfigChange,
|
||||
onCoverSelected,
|
||||
],
|
||||
)
|
||||
|
||||
const shared = useSharedCover({
|
||||
canGenerate: isRenderCompleted,
|
||||
disabledHint: "请先完成视频生成再选择封面",
|
||||
initialTemplateId: "default",
|
||||
generateFn,
|
||||
})
|
||||
|
||||
// 父层 onUploadCover 走 onUploadFile 回调(兼容老父组件)
|
||||
useEffect(() => {
|
||||
shared.setOnUploadFile((file: File) => {
|
||||
if (onUploadCover) {
|
||||
onUploadCover(file)
|
||||
} else {
|
||||
const url = URL.createObjectURL(file)
|
||||
onCoverConfigChange({
|
||||
mode: "upload",
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
})
|
||||
onCoverSelected(url)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}, [shared, onUploadCover, onCoverConfigChange, onCoverSelected])
|
||||
|
||||
// 打开时同步刷新模板列表
|
||||
useEffect(() => {
|
||||
if (open) void shared.reloadTemplates()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/** 当前预览 URL:智能封面 > 自定义上传 */
|
||||
const previewUrl = useMemo(
|
||||
() => coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "",
|
||||
[coverConfig.smart_cover_url, coverConfig.thumbnail_url, coverConfig.upload_url],
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="选择封面"
|
||||
width={560}
|
||||
footer={
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={onClose}>
|
||||
确定
|
||||
</Button>
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择封面</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
{renderJob && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "rgba(16, 185, 129, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
🎬 从渲染成片中智能选帧
|
||||
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
|
||||
<>
|
||||
{" "}
|
||||
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{/* 左:封面预览 */}
|
||||
<div
|
||||
style={{
|
||||
width: 180,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="xx-ce-canvas"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "9 / 16",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
background: "linear-gradient(135deg, #1e3a8a 0%, #312e81 100%)",
|
||||
border: previewUrl ? "none" : "1px dashed #d9d9d9",
|
||||
}}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="封面预览"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
gap: 6,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{shared.generating && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
<span>AI 选帧中…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "#8c8ca1",
|
||||
}}
|
||||
>
|
||||
9:16 竖版封面
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右:操作按钮 */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void shared.generateAutoCover()}
|
||||
disabled={!isRenderCompleted || shared.generating}
|
||||
loading={shared.generating}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() => shared.setShowCoverSettings(true)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
⚙️ 封面模板
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={shared.handleUploadClick}
|
||||
disabled={!isRenderCompleted || shared.generating}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
📷 本地上传
|
||||
</Button>
|
||||
<input
|
||||
ref={shared.uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={shared.handleFileInputChange}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#8c8ca1",
|
||||
lineHeight: 1.5,
|
||||
marginTop: 4,
|
||||
padding: "6px 8px",
|
||||
background: "#f7f8fa",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-modal__body" style={{ padding: 20 }}>
|
||||
<PanelCoverAndGenerate
|
||||
variant="select-cover"
|
||||
coverConfig={coverConfig}
|
||||
onCoverConfigChange={onCoverConfigChange}
|
||||
renderJob={renderJob}
|
||||
onGenerateRenderSmartCover={onGenerateRenderSmartCover}
|
||||
onUploadCover={onUploadCover}
|
||||
onClose={onClose}
|
||||
onCoverSelected={onCoverSelected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模板选择弹窗 */}
|
||||
<CoverSettingsModal
|
||||
open={shared.showCoverSettings}
|
||||
onClose={() => shared.setShowCoverSettings(false)}
|
||||
templates={shared.templates}
|
||||
loading={shared.templatesLoading}
|
||||
error={shared.templatesError}
|
||||
selectedTemplateId={shared.selectedTemplateId}
|
||||
onSelectTemplate={shared.handleSelectTemplate}
|
||||
onEditTemplate={shared.handleEditTemplate}
|
||||
onDeleteTemplate={shared.handleDeleteTemplate}
|
||||
onCreateNew={shared.handleCreateTemplate}
|
||||
/>
|
||||
|
||||
{/* 自定义编辑器弹窗 */}
|
||||
<CoverEditorModal
|
||||
open={shared.showCoverEditor}
|
||||
onClose={() => shared.setShowCoverEditor(false)}
|
||||
template={shared.editingTemplate}
|
||||
onSave={shared.handleSaveTemplate}
|
||||
/>
|
||||
|
||||
{/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */}
|
||||
<AntModal open={shared.generating} closable={false} footer={null} centered width={320}>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
|
||||
AI 正在从最终成片选帧,请稍候...
|
||||
</p>
|
||||
</div>
|
||||
</AntModal>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
/**
|
||||
* AI数字人 — 面板5 / 生成配置面板(渲染前)
|
||||
* #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮)
|
||||
* 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件)
|
||||
* AI数字人 — 面板5 / 封面选择弹窗内容:
|
||||
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
|
||||
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
|
||||
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
|
||||
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
|
||||
*
|
||||
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
|
||||
*/
|
||||
import React from "react"
|
||||
import type { RenderJob } from "../types"
|
||||
import React, { useRef, useState } from "react"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
|
||||
type PanelVariant = "setup" | "select-cover"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
variant?: PanelVariant
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution?: string
|
||||
onResolutionChange?: (r: string) => void
|
||||
isGenerating?: boolean
|
||||
onGenerate?: () => void
|
||||
/** 当前渲染任务 */
|
||||
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */
|
||||
renderJob: RenderJob | null
|
||||
/** 配置汇总信息 */
|
||||
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
|
||||
onUploadCover?: (file: File) => void
|
||||
/** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */
|
||||
onClose?: () => void
|
||||
/** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */
|
||||
onCoverSelected?: (coverUrl: string) => void
|
||||
/** 配置汇总信息(仅 variant="setup" 使用) */
|
||||
summary?: {
|
||||
videoName: string | null
|
||||
voiceName: string | null
|
||||
@@ -21,6 +38,7 @@ interface PanelCoverAndGenerateProps {
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
|
||||
coverStatus: "not_ready" | "pending" | "selected"
|
||||
}
|
||||
}
|
||||
@@ -40,15 +58,89 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
}
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
variant = "setup",
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
resolution = "720p",
|
||||
onResolutionChange,
|
||||
isGenerating = false,
|
||||
onGenerate,
|
||||
renderJob: _renderJob,
|
||||
renderJob,
|
||||
onGenerateRenderSmartCover,
|
||||
onUploadCover,
|
||||
onClose,
|
||||
onCoverSelected,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
|
||||
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
|
||||
|
||||
/** 自定义上传封面 */
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => {
|
||||
const partial: Partial<AiAvatarCoverConfig> = {
|
||||
mode,
|
||||
thumbnail_url: url,
|
||||
}
|
||||
if (mode === "auto_frame") {
|
||||
partial.smart_cover_url = url
|
||||
} else {
|
||||
partial.upload_url = url
|
||||
}
|
||||
onCoverConfigChange(partial)
|
||||
onCoverSelected?.(url)
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (onUploadCover) {
|
||||
onUploadCover(file)
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
|
||||
const url = URL.createObjectURL(file)
|
||||
_applyCoverUrl(url, "upload")
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 智能获取封面(从最终成片抽帧;必须等 render 完成) */
|
||||
const handleSmartCover = async () => {
|
||||
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
|
||||
setSmartCoverLoading(true)
|
||||
try {
|
||||
const res = await onGenerateRenderSmartCover(renderJob.id)
|
||||
if (res.cover_url) {
|
||||
_applyCoverUrl(res.cover_url, "auto_frame")
|
||||
} else {
|
||||
// 失败由父组件 message 提示,这里不重复弹窗
|
||||
console.warn("[智能封面] 返回空 cover_url:", res.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[智能封面] 调用失败:", err)
|
||||
} finally {
|
||||
setSmartCoverLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
|
||||
// 渲染已完成 → 封面区可用
|
||||
const isRenderCompleted = renderJob?.status === "completed"
|
||||
const canSmartCover = isRenderCompleted && !smartCoverLoading
|
||||
|
||||
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
|
||||
const coverUrl =
|
||||
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
|
||||
const hasCoverImage = Boolean(coverUrl)
|
||||
|
||||
/** 封面区占位文字 */
|
||||
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
|
||||
|
||||
/** 配置摘要中的封面状态标签 */
|
||||
const coverSummaryNode = (() => {
|
||||
@@ -62,6 +154,69 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
return <span className="aa-config-summary__empty">生成视频后可选</span>
|
||||
})()
|
||||
|
||||
// ── 封面选择区(两种 variant 共用) ─────────────────────────────────
|
||||
const coverSection = (
|
||||
<div className="aa-cover-section" style={{ marginTop: variant === "select-cover" ? 0 : 16 }}>
|
||||
<div className="aa-label" style={{ marginBottom: 8 }}>
|
||||
{variant === "select-cover" ? "选择封面" : "封面"}
|
||||
</div>
|
||||
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
|
||||
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
|
||||
{hasCoverImage ? (
|
||||
<img src={coverUrl!} alt="封面预览" draggable={false} />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
|
||||
)}
|
||||
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleSmartCover}
|
||||
disabled={!canSmartCover}
|
||||
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
|
||||
>
|
||||
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
disabled={!isRenderCompleted || smartCoverLoading}
|
||||
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ──
|
||||
if (variant === "select-cover") {
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{coverSection}
|
||||
{onClose && (
|
||||
<div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
|
||||
<button type="button" className="aa-btn aa-btn--primary" onClick={onClose}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ──
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 分辨率选择 */}
|
||||
|
||||
@@ -21,6 +21,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { confirmGeneration } from "@/api/generation/confirm"
|
||||
import { finalizeGeneration } from "@/api/generation/finalize"
|
||||
|
||||
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
|
||||
@@ -436,25 +437,42 @@ const GeneratePage: React.FC = () => {
|
||||
? [finalVideo.generation_task_id]
|
||||
: []
|
||||
|
||||
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题)
|
||||
// 第一步:confirm(同步封面+标题,把 is_preview 翻 false,任务进入/停留在 awaiting_cover)
|
||||
let confirmedTaskIds: string[] = []
|
||||
if (isBatch && previewCovers.length > 0) {
|
||||
await Promise.all(
|
||||
const results = await Promise.all(
|
||||
taskIds.map(async (taskId, idx) => {
|
||||
const coverUrl = previewCovers[idx] || ""
|
||||
return finalizeGeneration(taskId, {
|
||||
const resp = await confirmGeneration(taskId, {
|
||||
cover_url: coverUrl || undefined,
|
||||
custom_title: previewTitles[idx] || titleSettings.title || "",
|
||||
})
|
||||
return resp.items?.[0]?.id || taskId
|
||||
}),
|
||||
)
|
||||
confirmedTaskIds = results
|
||||
} else if (finalVideo?.generation_task_id) {
|
||||
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
|
||||
await finalizeGeneration(finalVideo.generation_task_id, {
|
||||
const resp = await confirmGeneration(finalVideo.generation_task_id, {
|
||||
cover_url: coverUrl || undefined,
|
||||
custom_title: titleSettings.title || "",
|
||||
})
|
||||
confirmedTaskIds = [resp.items?.[0]?.id || finalVideo.generation_task_id]
|
||||
} else {
|
||||
confirmedTaskIds = taskIds
|
||||
}
|
||||
|
||||
// 第二步:finalize(真正入成品库,生成 GeneratedVideo 记录,任务推进到 completed)
|
||||
// 批量每个任务独立 finalize;单视频只 finalize 当前这一个
|
||||
const coverUrlList = isBatch
|
||||
? confirmedTaskIds.map((_, idx) => previewCovers[idx] || "")
|
||||
: [coverSettings.thumbnail_url || coverSettings.upload_url || ""]
|
||||
await Promise.all(
|
||||
confirmedTaskIds.map((tid, idx) =>
|
||||
finalizeGeneration(tid, { cover_url: coverUrlList[idx] || undefined }),
|
||||
),
|
||||
)
|
||||
|
||||
hide()
|
||||
message.success("已保存到视频库")
|
||||
navigate("/app/products")
|
||||
|
||||
@@ -37,9 +37,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
完成{" "}
|
||||
{tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "}
|
||||
{tasks.length}
|
||||
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
|
||||
@@ -51,7 +49,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
|
||||
<div className="xx-batch-gen-card-head">
|
||||
<span className="xx-batch-gen-card-title" title={title}>
|
||||
{task.status === "completed" || task.status === "awaiting_cover" ? (
|
||||
{task.status === "completed" ? (
|
||||
<CheckCircleFilled
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#52c41a" }}
|
||||
@@ -85,7 +83,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
|
||||
</>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
|
||||
{task.status === "completed" && video && (
|
||||
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
|
||||
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
|
||||
<div
|
||||
@@ -113,7 +111,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
|
||||
{task.status === "completed" && !video && (
|
||||
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
|
||||
@@ -1,137 +1,84 @@
|
||||
/**
|
||||
* Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复 + #2044 批量模板选择)
|
||||
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传)
|
||||
* - N 个视频:N 张封面卡片,每张带对应视频标题,支持统一选择封面模板、逐个自动生成或上传
|
||||
*
|
||||
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
|
||||
* Step 5 选择封面(Issue #1677 批量生成改造)
|
||||
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
|
||||
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
|
||||
*/
|
||||
import React, { useEffect, useMemo } from "react"
|
||||
import React, { useRef } from "react"
|
||||
import { Modal, Spin } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import { useBatchCovers } from "../hooks/useBatchCovers"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||
import { useSharedCover } from "@/components/cover/useSharedCover"
|
||||
import { generateCover as apiGenerateCover } from "@/api/generation"
|
||||
|
||||
interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 确认生成步骤产出的最终视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
/* ── 批量生成(#1677)── */
|
||||
previewCount?: number
|
||||
/** 每个变体的标题文字 */
|
||||
previewTitles?: string[]
|
||||
/** 每个变体的封面URL(按变体索引) */
|
||||
previewCovers?: string[]
|
||||
onPreviewCoversChange?: (urls: string[]) => void
|
||||
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
|
||||
selectedVariantIndexes?: number[]
|
||||
onTemplateChange?: (templateId: string) => void
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
const {
|
||||
coverSettings,
|
||||
generating,
|
||||
generateAutoCover,
|
||||
finalVideo,
|
||||
showCoverSettings,
|
||||
setShowCoverSettings,
|
||||
showCoverEditor,
|
||||
setShowCoverEditor,
|
||||
selectedTemplateId,
|
||||
editingTemplate,
|
||||
coverTemplates,
|
||||
templatesLoading,
|
||||
templatesError,
|
||||
handleSelectTemplate,
|
||||
handleEditTemplate,
|
||||
handleSaveTemplate,
|
||||
handleDeleteTemplate,
|
||||
} = useStep6Cover({
|
||||
coverSettings: props.coverSettings,
|
||||
onCoverSettingsChange: props.onCoverSettingsChange,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
titleSettings: props.titleSettings,
|
||||
generatedVideos: props.generatedVideos,
|
||||
})
|
||||
|
||||
const previewCount = props.previewCount || 1
|
||||
const isBatch = previewCount > 1
|
||||
const previewTitles = props.previewTitles || []
|
||||
const previewCovers = props.previewCovers || []
|
||||
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
|
||||
const cardIndexes =
|
||||
isBatch && props.selectedVariantIndexes?.length
|
||||
? props.selectedVariantIndexes
|
||||
: Array.from({ length: previewCount }, (_, i) => i)
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const uploadTargetRef = useRef<number>(0)
|
||||
|
||||
/** 最终成片:取第一个已完成视频(单视频场景) */
|
||||
const finalVideo =
|
||||
props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") ||
|
||||
props.generatedVideos[0]
|
||||
|
||||
const completedVideos = useMemo(
|
||||
() =>
|
||||
props.generatedVideos.filter(
|
||||
(v) => v.status === "completed" || v.status === "awaiting_cover",
|
||||
),
|
||||
[props.generatedVideos],
|
||||
)
|
||||
|
||||
/**
|
||||
* 单视频自动生成(点击"自动生成封面"按钮):使用当前选中的模板
|
||||
* 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发
|
||||
*/
|
||||
const shared = useSharedCover({
|
||||
canGenerate: !!finalVideo && !isBatch,
|
||||
disabledHint: isBatch ? "批量场景请在上方操作卡片" : "请先生成视频再选择封面",
|
||||
initialTemplateId: props.selectedTemplate || "default",
|
||||
generateFn: async (tplId) => {
|
||||
if (!finalVideo || isBatch) return null
|
||||
const response = await apiGenerateCover(tplId, {
|
||||
generated_video_id: finalVideo.id,
|
||||
video_url: finalVideo.file_url || finalVideo.download_url || "",
|
||||
cover_type: "ai_frame",
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
font_color: props.titleSettings.color,
|
||||
position: props.titleSettings.position,
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const url = response.cover?.image_url || ""
|
||||
if (url) {
|
||||
props.onCoverSettingsChange({
|
||||
...props.coverSettings,
|
||||
thumbnail_url: url,
|
||||
ai_suggested_time: response.cover?.frame_time ?? null,
|
||||
})
|
||||
}
|
||||
return url
|
||||
},
|
||||
})
|
||||
|
||||
// 选中模板变化时通知父组件(用于批量生成时透传 template_id)
|
||||
const { onTemplateChange, selectedTemplate: parentSelectedTemplate } = props
|
||||
useEffect(() => {
|
||||
if (isBatch && onTemplateChange && shared.selectedTemplateId !== parentSelectedTemplate) {
|
||||
onTemplateChange(shared.selectedTemplateId)
|
||||
}
|
||||
}, [isBatch, shared.selectedTemplateId, parentSelectedTemplate, onTemplateChange])
|
||||
|
||||
useEffect(() => {
|
||||
shared.setOnUploadFile(() => null)
|
||||
}, [shared])
|
||||
|
||||
const batchUploadRef = React.useRef<HTMLInputElement>(null)
|
||||
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
|
||||
const handleBatchUploadClick = (cardPos: number) => {
|
||||
setBatchUploadCard(cardPos)
|
||||
batchUploadRef.current?.click()
|
||||
}
|
||||
const handleBatchUploadChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
const cardPos = batchUploadCard
|
||||
setBatchUploadCard(null)
|
||||
if (!file || cardPos == null) return
|
||||
void batchCovers.uploadOne(cardPos, file)
|
||||
}
|
||||
|
||||
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
|
||||
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
|
||||
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
|
||||
|
||||
/**
|
||||
* 批量生成:selectedTemplateId 来自用户在 CoverSettingsModal 中选择的模板,
|
||||
* 透传给 useBatchCovers,由其在 generateOne/generateAll 中发给后端。
|
||||
*/
|
||||
const batchCovers = useBatchCovers({
|
||||
selectedTemplate: shared.selectedTemplateId || "default",
|
||||
selectedTemplate: props.selectedTemplate || "",
|
||||
generatedVideos: props.generatedVideos,
|
||||
titles: batchTitles,
|
||||
titleStyle: {
|
||||
@@ -145,6 +92,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
},
|
||||
covers: batchCoversList,
|
||||
onCoversChange: (updater) => {
|
||||
// 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖)
|
||||
const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "")
|
||||
const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater
|
||||
const next = [...(props.previewCovers || [])]
|
||||
@@ -155,7 +103,22 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
},
|
||||
})
|
||||
|
||||
const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url
|
||||
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
|
||||
|
||||
const handleUploadClick = (variantIndex: number) => {
|
||||
uploadTargetRef.current = variantIndex
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (file) {
|
||||
const variantIndex = uploadTargetRef.current
|
||||
const cardPos = cardIndexes.indexOf(variantIndex)
|
||||
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量封面 ── */
|
||||
if (isBatch) {
|
||||
@@ -175,32 +138,17 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题
|
||||
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
|
||||
<>
|
||||
{" · "}当前模板:<strong>{shared.selectedTemplateName}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void batchCovers.generateAll()}
|
||||
disabled={completedVideos.length === 0 || batchCovers.busyIndexes.length > 0}
|
||||
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
|
||||
loading={batchCovers.busyIndexes.length > 0}
|
||||
>
|
||||
✨ 一键全部自动生成
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
|
||||
⚙️ 封面模板
|
||||
{shared.selectedTemplateId && shared.selectedTemplateId !== "default"
|
||||
? `:${shared.selectedTemplateName}`
|
||||
: ""}
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={shared.handleCreateTemplate}>
|
||||
➕ 新建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="xx-cover-grid">
|
||||
@@ -261,7 +209,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
|
||||
onClick={() => handleBatchUploadClick(cardPos)}
|
||||
onClick={() => handleUploadClick(variantIndex)}
|
||||
disabled={isLoading || isUploading}
|
||||
>
|
||||
📤 上传
|
||||
@@ -272,43 +220,23 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 隐藏的文件选择 input,批量上传复用 */}
|
||||
<input
|
||||
ref={batchUploadRef}
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleBatchUploadChange}
|
||||
/>
|
||||
|
||||
<CoverSettingsModal
|
||||
open={shared.showCoverSettings}
|
||||
onClose={() => shared.setShowCoverSettings(false)}
|
||||
templates={shared.templates}
|
||||
loading={shared.templatesLoading}
|
||||
error={shared.templatesError}
|
||||
selectedTemplateId={shared.selectedTemplateId}
|
||||
onSelectTemplate={shared.handleSelectTemplate}
|
||||
onEditTemplate={shared.handleEditTemplate}
|
||||
onDeleteTemplate={shared.handleDeleteTemplate}
|
||||
onCreateNew={shared.handleCreateTemplate}
|
||||
/>
|
||||
|
||||
<CoverEditorModal
|
||||
open={shared.showCoverEditor}
|
||||
onClose={() => shared.setShowCoverEditor(false)}
|
||||
template={shared.editingTemplate}
|
||||
onSave={shared.handleSaveTemplate}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 单视频 ── */
|
||||
/* ── 单视频:原有流程保持不变 ── */
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 最终成片信息 */}
|
||||
{finalVideo && (
|
||||
<div
|
||||
style={{
|
||||
@@ -322,55 +250,16 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
🎬 封面将从最终成片「{finalVideo.name}」中智能选帧
|
||||
{shared.selectedTemplateId && shared.selectedTemplateId !== "default" && (
|
||||
<>
|
||||
{" "}
|
||||
· 当前模板:<strong>{shared.selectedTemplateName}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-cover-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void shared.generateAutoCover()}
|
||||
disabled={!finalVideo || shared.generating}
|
||||
loading={shared.generating}
|
||||
>
|
||||
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
|
||||
⚙️ 封面模板
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
⚙️ 封面设置
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={shared.handleUploadClick}>
|
||||
📷 本地上传
|
||||
</Button>
|
||||
<input
|
||||
ref={shared.uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (!file) return
|
||||
const url = URL.createObjectURL(file)
|
||||
props.onCoverSettingsChange({
|
||||
...props.coverSettings,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={batchUploadRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleBatchUploadChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
@@ -387,26 +276,30 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<CoverSettingsModal
|
||||
open={shared.showCoverSettings}
|
||||
onClose={() => shared.setShowCoverSettings(false)}
|
||||
templates={shared.templates}
|
||||
loading={shared.templatesLoading}
|
||||
error={shared.templatesError}
|
||||
selectedTemplateId={shared.selectedTemplateId}
|
||||
onSelectTemplate={shared.handleSelectTemplate}
|
||||
onEditTemplate={shared.handleEditTemplate}
|
||||
onDeleteTemplate={shared.handleDeleteTemplate}
|
||||
onCreateNew={shared.handleCreateTemplate}
|
||||
open={showCoverSettings}
|
||||
onClose={() => setShowCoverSettings(false)}
|
||||
templates={coverTemplates}
|
||||
loading={templatesLoading}
|
||||
error={templatesError}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onEditTemplate={handleEditTemplate}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
onCreateNew={() => {
|
||||
setShowCoverSettings(false)
|
||||
setShowCoverEditor(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<CoverEditorModal
|
||||
open={shared.showCoverEditor}
|
||||
onClose={() => shared.setShowCoverEditor(false)}
|
||||
template={shared.editingTemplate}
|
||||
onSave={shared.handleSaveTemplate}
|
||||
open={showCoverEditor}
|
||||
onClose={() => setShowCoverEditor(false)}
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
/>
|
||||
|
||||
<Modal open={shared.generating} closable={false} footer={null} centered>
|
||||
{/* AI 生成封面进度弹窗 */}
|
||||
<Modal open={generating} closable={false} footer={null} centered>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
|
||||
@@ -418,6 +311,4 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
Step6CoverSettings.displayName = "Step6CoverSettings"
|
||||
|
||||
export default Step6CoverSettings
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ import React from "react"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "@/components/cover/cover.css"
|
||||
|
||||
interface CoverSettingsModalProps {
|
||||
open: boolean
|
||||
@@ -41,26 +40,12 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
onCreateNew,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
title="封面设置"
|
||||
centered
|
||||
footer={
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" onClick={onClose}>
|
||||
确认应用
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Modal open={open} onCancel={onClose} width={800} title="封面设置" centered footer={null}>
|
||||
<div className="xx-cover-modal-toolbar">
|
||||
<Button buttonType="primary">选择素材文件</Button>
|
||||
<Button buttonType="ghost">导出全部</Button>
|
||||
<Button buttonType="primary" onClick={onCreateNew}>
|
||||
+ 创建新模板
|
||||
创建新模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -74,83 +59,52 @@ const CoverSettingsModal: React.FC<CoverSettingsModalProps> = ({
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#ef4444" }}>{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && templates.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "40px 0",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
暂无封面模板,点击右上角「创建新模板」可自定义封面样式
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && templates.length > 0 && (
|
||||
{!loading && !error && (
|
||||
<div className="xx-cover-template-grid">
|
||||
{templates.map((tpl) => {
|
||||
const isSelected = selectedTemplateId === tpl.id
|
||||
return (
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-cover-template-card${selectedTemplateId === tpl.id ? " selected" : ""}`}
|
||||
onClick={() => onSelectTemplate(tpl.id)}
|
||||
>
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-cover-template-card${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelectTemplate(tpl.id)}
|
||||
className="xx-cover-template-thumb"
|
||||
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
|
||||
>
|
||||
<div
|
||||
className="xx-cover-template-thumb"
|
||||
style={{ background: GRADIENT_MAP[tpl.id] || GRADIENT_MAP.default }}
|
||||
>
|
||||
{isSelected && <span className="xx-cover-template-check">✓</span>}
|
||||
🖼️
|
||||
🖼️
|
||||
</div>
|
||||
<div className="xx-cover-template-info">
|
||||
<div className="xx-cover-template-name">
|
||||
{tpl.name}
|
||||
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统模板</span>}
|
||||
</div>
|
||||
<div className="xx-cover-template-info">
|
||||
<div className="xx-cover-template-name">
|
||||
{tpl.name}
|
||||
{tpl.is_system && <span className="xx-cover-template-badge">✨ 系统</span>}
|
||||
</div>
|
||||
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="xx-cover-template-date">{tpl.created_at}</div>
|
||||
<div className="xx-cover-template-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => onEditTemplate(tpl)}>
|
||||
编辑
|
||||
</Button>
|
||||
{!tpl.is_system && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => onEditTemplate(tpl)}
|
||||
title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"}
|
||||
onClick={() => {
|
||||
if (confirm("确定删除此模板?")) {
|
||||
onDeleteTemplate(tpl.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tpl.is_system ? "复制" : "编辑"}
|
||||
删除
|
||||
</Button>
|
||||
{!tpl.is_system && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
if (confirm("确定删除此模板?")) {
|
||||
onDeleteTemplate(tpl.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button buttonType="ghost" buttonSize="sm">
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "8px 12px",
|
||||
background: "rgba(124,58,237,0.06)",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: "#6d28d9",
|
||||
}}
|
||||
>
|
||||
💡 点击卡片选中模板后,点击右下角「确认应用」即可使用该模板生成封面
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2739,7 +2739,6 @@
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: #ccc;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 卡片信息区 */
|
||||
@@ -3664,42 +3663,14 @@
|
||||
border: 1px solid #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
.xx-ce-handle--0 {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
.xx-ce-handle--1 {
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.xx-ce-handle--2 {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
.xx-ce-handle--3 {
|
||||
top: 50%;
|
||||
right: -4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.xx-ce-handle--4 {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
.xx-ce-handle--5 {
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.xx-ce-handle--6 {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
.xx-ce-handle--7 {
|
||||
top: 50%;
|
||||
left: -4px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.xx-ce-handle--0 { top: -4px; left: -4px; }
|
||||
.xx-ce-handle--1 { top: -4px; left: 50%; margin-left: -4px; }
|
||||
.xx-ce-handle--2 { top: -4px; right: -4px; }
|
||||
.xx-ce-handle--3 { top: 50%; right: -4px; margin-top: -4px; }
|
||||
.xx-ce-handle--4 { bottom: -4px; right: -4px; }
|
||||
.xx-ce-handle--5 { bottom: -4px; left: 50%; margin-left: -4px; }
|
||||
.xx-ce-handle--6 { bottom: -4px; left: -4px; }
|
||||
.xx-ce-handle--7 { top: 50%; left: -4px; margin-top: -4px; }
|
||||
|
||||
/* Background element */
|
||||
.xx-ce-el-bg {
|
||||
@@ -3722,41 +3693,6 @@
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Cover template selected check */
|
||||
.xx-cover-template-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
.xx-cover-template-thumb {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Preview tip */
|
||||
.xx-ce-preview-tip {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Cover editor modal base gradient */
|
||||
.xx-ce-canvas {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Antd Slider overrides for editor */
|
||||
.xx-ce-section-body .ant-slider {
|
||||
margin: 4px 0 8px;
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BatchTaskState {
|
||||
taskId: string
|
||||
/** 变体序号(0-based,与标题/封面数组对齐) */
|
||||
variantIndex: number
|
||||
status: "running" | "completed" | "awaiting_cover" | "failed"
|
||||
status: "running" | "completed" | "failed"
|
||||
progress: number
|
||||
error: string | null
|
||||
/** 完成后的成片视频 */
|
||||
@@ -96,7 +96,7 @@ export function useGenerationPolling({
|
||||
runId: number,
|
||||
callbacks?: {
|
||||
onTaskProgress?: (pct: number) => void
|
||||
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
|
||||
onTaskCompleted?: (videos: unknown[]) => void
|
||||
onTaskFailed?: (msg: string) => void
|
||||
},
|
||||
): Promise<unknown[]> => {
|
||||
@@ -111,7 +111,7 @@ export function useGenerationPolling({
|
||||
if (cancelledRef.current || done) return
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed" || task.status === "awaiting_cover") {
|
||||
if (task.status === "completed") {
|
||||
done = true
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
@@ -121,7 +121,7 @@ export function useGenerationPolling({
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover")
|
||||
callbacks?.onTaskCompleted?.(videos)
|
||||
resolve(videos)
|
||||
return
|
||||
}
|
||||
@@ -259,11 +259,10 @@ export function useGenerationPolling({
|
||||
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
|
||||
reportAggregateProgress()
|
||||
},
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
onTaskCompleted: (videos) => {
|
||||
progressMap.set(taskId, 100)
|
||||
resultMap.set(taskId, videos)
|
||||
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
|
||||
reportAggregateProgress()
|
||||
checkAllSettled()
|
||||
},
|
||||
@@ -294,9 +293,8 @@ export function useGenerationPolling({
|
||||
}
|
||||
pollSingleTask(taskId, Date.now(), {
|
||||
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
onTaskCompleted: (videos) => {
|
||||
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
|
||||
message.success(`视频 ${variantIndex + 1} 重试成功`)
|
||||
},
|
||||
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
|
||||
|
||||
@@ -53,7 +53,7 @@ interface UseBatchCoversOptions {
|
||||
}
|
||||
|
||||
export function useBatchCovers({
|
||||
selectedTemplate,
|
||||
selectedTemplate: _selectedTemplate,
|
||||
generatedVideos,
|
||||
titles,
|
||||
titleStyle,
|
||||
@@ -93,12 +93,7 @@ export function useBatchCovers({
|
||||
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
|
||||
const generateOne = useCallback(
|
||||
async (index: number): Promise<boolean> => {
|
||||
const finalVideos = generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" ||
|
||||
v.status === "awaiting_cover" ||
|
||||
v.status === "awaiting_cover",
|
||||
)
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const target = finalVideos[index] || generatedVideos[index]
|
||||
if (!target) {
|
||||
message.warning("该视频尚未生成完成")
|
||||
@@ -107,7 +102,7 @@ export function useBatchCovers({
|
||||
addBusy(index)
|
||||
try {
|
||||
const titleText = titles[index] || ""
|
||||
const response = await generateCover(selectedTemplate || "default", {
|
||||
const response = await generateCover("default", {
|
||||
generated_video_id: target.id,
|
||||
video_url: target.file_url || target.download_url || "",
|
||||
cover_type: "ai_frame",
|
||||
@@ -171,7 +166,7 @@ export function useBatchCovers({
|
||||
removeBusy(index)
|
||||
}
|
||||
},
|
||||
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
|
||||
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
|
||||
)
|
||||
|
||||
/** 为第 index 个视频上传自定义封面 */
|
||||
@@ -208,10 +203,7 @@ export function useBatchCovers({
|
||||
|
||||
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
|
||||
const generateAll = useCallback(async () => {
|
||||
const finalVideos = generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
)
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const total = finalVideos.length
|
||||
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
|
||||
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
// 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底
|
||||
setBatchTasks((prev) =>
|
||||
(prev || []).map((t) =>
|
||||
t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0)
|
||||
t.status === "completed" && t.videos.length === 0
|
||||
? {
|
||||
...t,
|
||||
videos: (videos as GeneratedVideo[]).filter(
|
||||
@@ -83,10 +83,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
if (batchTasks.length === 0) return
|
||||
const byVariant = new Map<number, GeneratedVideo>()
|
||||
batchTasks.forEach((t) => {
|
||||
if (
|
||||
t.status === "completed" ||
|
||||
(t.status === "awaiting_cover" && t.videos && t.videos.length > 0)
|
||||
) {
|
||||
if (t.status === "completed" && t.videos && t.videos.length > 0) {
|
||||
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,11 +48,7 @@ export function useStep6Cover({
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
|
||||
/** 最终成片:取第一个已完成视频 */
|
||||
const finalVideo =
|
||||
generatedVideos.find(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
) || generatedVideos[0]
|
||||
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
|
||||
|
||||
/** 从后端加载封面模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
@@ -175,18 +171,7 @@ export function useStep6Cover({
|
||||
}, [])
|
||||
|
||||
const handleEditTemplate = useCallback((tpl: CoverTemplate) => {
|
||||
// 系统模板不可修改:复制为新模板草稿,走"另存为"流程
|
||||
if (tpl.is_system) {
|
||||
setEditingTemplate({
|
||||
...tpl,
|
||||
id: "",
|
||||
name: tpl.name + " 副本",
|
||||
is_system: false,
|
||||
created_at: "",
|
||||
})
|
||||
} else {
|
||||
setEditingTemplate(tpl)
|
||||
}
|
||||
setEditingTemplate(tpl)
|
||||
setShowCoverEditor(true)
|
||||
}, [])
|
||||
|
||||
@@ -194,25 +179,20 @@ export function useStep6Cover({
|
||||
const handleSaveTemplate = useCallback(
|
||||
async (tpl: CoverTemplate) => {
|
||||
try {
|
||||
// 系统模板或无 id(新建/副本)→ 走创建分支;否则走更新
|
||||
const isSystem = coverTemplates.find((t) => t.id === tpl.id)?.is_system === true
|
||||
const shouldCreate = !tpl.id || isSystem
|
||||
if (shouldCreate) {
|
||||
const created = await createCoverTemplate({
|
||||
name: tpl.name || "我的封面模板",
|
||||
config: tpl.config,
|
||||
})
|
||||
setCoverTemplates((prev) => [...prev, created])
|
||||
setSelectedTemplateId(created.id || tpl.id)
|
||||
} else {
|
||||
if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) {
|
||||
const updated = await updateCoverTemplate(tpl.id, {
|
||||
name: tpl.name,
|
||||
config: tpl.config,
|
||||
})
|
||||
setCoverTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t)))
|
||||
} else {
|
||||
const created = await createCoverTemplate({
|
||||
name: tpl.name,
|
||||
config: tpl.config,
|
||||
})
|
||||
setCoverTemplates((prev) => [...prev, created])
|
||||
}
|
||||
setShowCoverEditor(false)
|
||||
setEditingTemplate(null)
|
||||
} catch (err) {
|
||||
console.error("[Step6] 保存模板失败:", err)
|
||||
}
|
||||
|
||||
@@ -105,14 +105,11 @@ export interface CoverEditorConfig {
|
||||
portraitEnabled: boolean
|
||||
portraitSize: number
|
||||
portraitPosition: TextPosition
|
||||
portraitImage?: string
|
||||
|
||||
// 背景设置
|
||||
backgroundEnabled: boolean
|
||||
backgroundSize: number
|
||||
backgroundPosition: TextPosition
|
||||
backgroundImage?: string
|
||||
backgroundColor?: string
|
||||
|
||||
// 主标题
|
||||
title: TextStyleConfig
|
||||
@@ -202,23 +199,23 @@ export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = {
|
||||
titleMaxChars: 4,
|
||||
subtitleMaxChars: 10,
|
||||
|
||||
portraitEnabled: false,
|
||||
portraitSize: 50,
|
||||
portraitPosition: { x: 50, y: 70 },
|
||||
portraitEnabled: true,
|
||||
portraitSize: 80,
|
||||
portraitPosition: { x: 50, y: 50 },
|
||||
|
||||
backgroundEnabled: true,
|
||||
backgroundSize: 100,
|
||||
backgroundSize: 90,
|
||||
backgroundPosition: { x: 50, y: 50 },
|
||||
|
||||
title: DEFAULT_TITLE_CONFIG,
|
||||
subtitle: DEFAULT_SUBTITLE_CONFIG,
|
||||
|
||||
maskEnabled: false,
|
||||
maskEnabled: true,
|
||||
maskImage: "",
|
||||
maskSize: 100,
|
||||
maskPosition: { x: 50, y: 50 },
|
||||
maskColor: "#000000",
|
||||
maskOpacity: 40,
|
||||
maskOpacity: 100,
|
||||
maskShape: "矩形",
|
||||
}
|
||||
|
||||
|
||||
@@ -2384,9 +2384,6 @@ class UnifiedRenderService:
|
||||
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
|
||||
|
||||
def _get_visual_perturbation(self) -> dict:
|
||||
# #2034:dedup_enabled=False 时跳过视觉/像素扰动(与 edge_crop、micro_transform 一致)
|
||||
if not self._dedup_enabled():
|
||||
return {}
|
||||
# 读取当前 plan 的视觉扰动参数(plan.config.visual_perturbation)
|
||||
perturbation = (self.plan.config or {}).get("visual_perturbation") or {}
|
||||
if not perturbation:
|
||||
|
||||
@@ -31,7 +31,6 @@ celery_app.conf.imports = (
|
||||
# #1970 片段级 AI 标签:必须显式 import 注册,否则 worker 报
|
||||
# "Received unregistered task of type 'worker.tag_atom_clip'"
|
||||
"worker_app.tasks.atom_clip_tagging",
|
||||
"worker_app.tasks.asset_quality_scoring_task",
|
||||
"worker_app.tasks.backfill_atom_clip_tags",
|
||||
"worker_app.tasks.classification",
|
||||
"worker_app.tasks.generation",
|
||||
|
||||
@@ -445,3 +445,22 @@ def classify_asset_real(video_path: str) -> tuple[str, float]:
|
||||
except Exception as e:
|
||||
logger.warning(f"Classification failed, using fallback: {e}")
|
||||
return AssetClassification.OTHER.value, 0.3
|
||||
|
||||
|
||||
def calculate_quality_score_real(video_path: str) -> float:
|
||||
"""
|
||||
质量评分入口函数
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
|
||||
Returns:
|
||||
质量评分 (0-100)
|
||||
"""
|
||||
try:
|
||||
analyzer = AssetAnalyzer(video_path)
|
||||
result = analyzer.calculate_quality_score()
|
||||
return result.total
|
||||
except Exception as e:
|
||||
logger.warning(f"Quality scoring failed, using fallback: {e}")
|
||||
return 50.0
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""素材质量评分 Celery 任务 — #2035.
|
||||
|
||||
视频素材 READY 入库后异步触发:下载视频到临时文件,运行 FFmpeg+NumPy 质量分析,
|
||||
将 0-100 总分写入 assets.quality_score 字段。同时复用已下载的视频,调用 AssetAnalyzer
|
||||
完成 9 类素材分类(写入 asset.metadata.classification / classification_confidence),
|
||||
供 smart_match 选片打分使用。任一环节失败均不阻断主流程(质量分兜底 50,分类降级 "other")。
|
||||
|
||||
任务名:worker.calculate_asset_quality
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.calculate_asset_quality", bind=True, max_retries=1, default_retry_delay=15)
|
||||
def calculate_asset_quality_task(self, asset_id: str) -> dict:
|
||||
"""为单个视频素材计算质量评分并写回 assets.quality_score。
|
||||
|
||||
流程:
|
||||
1. 下载视频到临时文件;
|
||||
2. 用 AssetAnalyzer(FFmpeg+NumPy) 提取分辨率/帧率/码率/清晰度/稳定性 5 维分数;
|
||||
3. 写回 assets.quality_score;
|
||||
4. 复用同一临时文件,调用 AssetAnalyzer.classify() 做 9 类素材分类,
|
||||
结果写入 asset.metadata.classification / classification_confidence;
|
||||
如已有分类结果则幂等跳过(避免重复计算)。
|
||||
|
||||
失败/非视频/无文件等情况均静默降级,返回 status=skipped/failed 不抛异常。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
tmp_dir = tempfile.mkdtemp(prefix="quality_score_")
|
||||
try:
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
asset = asset_repo.find_by_id(asset_id)
|
||||
if asset is None:
|
||||
return {"status": "skipped", "reason": "asset not found", "asset_id": asset_id}
|
||||
if not (getattr(asset, "mime_type", "") or "").startswith("video/"):
|
||||
return {"status": "skipped", "reason": "not a video", "asset_id": asset_id}
|
||||
# 已有质量分则幂等跳过(重新计算需显式置空)
|
||||
if getattr(asset, "quality_score", None) is not None:
|
||||
return {"status": "skipped", "reason": "already scored", "asset_id": asset_id}
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = getattr(asset, "storage_key", "") or ""
|
||||
if not storage_key:
|
||||
return {"status": "skipped", "reason": "no storage_key", "asset_id": asset_id}
|
||||
|
||||
# 下载到临时文件
|
||||
safe_suffix = ".mp4"
|
||||
local_path = Path(tmp_dir) / f"asset_{asset_id[:8]}{safe_suffix}"
|
||||
ok = storage.download_asset(storage_key, local_path)
|
||||
if not ok or not local_path.exists() or local_path.stat().st_size == 0:
|
||||
return {"status": "failed", "reason": "download failed", "asset_id": asset_id}
|
||||
|
||||
# 调用 AssetAnalyzer
|
||||
from worker_app.tasks.asset_analyzer import AssetAnalyzer
|
||||
|
||||
try:
|
||||
analyzer = AssetAnalyzer(str(local_path), temp_dir=tmp_dir)
|
||||
result = analyzer.calculate_quality_score()
|
||||
total = float(result.total) if result and 0 <= result.total <= 100 else 50.0
|
||||
except Exception as analyze_err: # noqa: BLE001
|
||||
logger.warning("[quality_score] 分析失败,使用默认50分: asset=%s err=%s", asset_id, analyze_err)
|
||||
total = 50.0
|
||||
|
||||
# 写回数据库(质量分)
|
||||
asset.quality_score = total
|
||||
|
||||
# #2035:自动触发 9 类分类(复用已下载的临时文件,避免重复下载)
|
||||
existing_meta = dict(asset.metadata or {})
|
||||
existing_classification = existing_meta.get("classification")
|
||||
classification = None
|
||||
confidence = None
|
||||
if not existing_classification or existing_classification == "other":
|
||||
try:
|
||||
from worker_app.tasks.asset_analyzer import AssetAnalyzer as _AA
|
||||
|
||||
# 重新构造analyzer可能会重复抽帧,但classify()会复用临时帧
|
||||
_analyzer = _AA(str(local_path), temp_dir=tmp_dir)
|
||||
_cls_result = _analyzer.classify()
|
||||
classification = getattr(_cls_result, "category", None) or "other"
|
||||
confidence = float(getattr(_cls_result, "confidence", 0.0) or 0.0)
|
||||
if confidence < 0:
|
||||
confidence = 0.0
|
||||
if confidence > 1:
|
||||
confidence = 1.0
|
||||
existing_meta["classification"] = classification
|
||||
existing_meta["classification_confidence"] = confidence
|
||||
asset.classification_status = "completed"
|
||||
asset.metadata = existing_meta
|
||||
logger.info(
|
||||
"[quality_score] asset=%s 自动分类完成: category=%s confidence=%.2f",
|
||||
asset_id,
|
||||
classification,
|
||||
confidence,
|
||||
)
|
||||
except Exception as cls_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[quality_score] asset=%s 自动分类失败(不影响质量分): %s",
|
||||
asset_id,
|
||||
cls_err,
|
||||
)
|
||||
|
||||
asset_repo.update(asset)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"[quality_score] asset=%s score=%.1f classification=%s",
|
||||
asset_id,
|
||||
total,
|
||||
classification or existing_classification,
|
||||
)
|
||||
return {
|
||||
"status": "completed",
|
||||
"asset_id": asset_id,
|
||||
"quality_score": total,
|
||||
"classification": classification or existing_classification or "other",
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
db.rollback()
|
||||
logger.exception("[quality_score] asset=%s 失败: %s", asset_id, exc)
|
||||
if self.request.retries < self.max_retries:
|
||||
raise self.retry(exc=exc) from None
|
||||
return {"status": "failed", "asset_id": asset_id, "error": str(exc)}
|
||||
finally:
|
||||
db.close()
|
||||
# 清理临时文件
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,8 +1,7 @@
|
||||
"""片段级 AI 标签 Celery 任务 — #1970 智能剪辑流程重构 P2.
|
||||
|
||||
为单个 atom_clip 调用视觉 AI 生成结构化标签(含 caption),再调用
|
||||
豆包 embedding 接口为 caption 生成向量,一并写入数据库。
|
||||
失败不阻断流程(降级为仅继承素材标签 / caption 留空 / embedding 留空)。
|
||||
为单个 atom_clip 调用视觉 AI 生成结构化标签,并更新到 ai_tags 字段。
|
||||
失败不阻断流程(降级为仅继承素材标签)。
|
||||
|
||||
任务名:worker.tag_atom_clip
|
||||
"""
|
||||
@@ -27,16 +26,16 @@ logger = get_task_logger(__name__)
|
||||
|
||||
@celery_app.task(name="worker.tag_atom_clip", bind=True, max_retries=2, default_retry_delay=10)
|
||||
def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
"""为单个原子片段生成 AI 标签 + caption + embedding.
|
||||
"""为单个原子片段生成 AI 标签.
|
||||
|
||||
Args:
|
||||
atom_clip_id: 原子片段 ID。
|
||||
force: True 时允许覆盖只有 inherited_tags 的降级记录
|
||||
(视觉 API 曾失败写入的占位标签,#1970)。
|
||||
已有完整标签且有 caption 始终跳过,保证幂等。
|
||||
已有完整标签(含 has_text)始终跳过,保证幂等。
|
||||
|
||||
Returns:
|
||||
任务结果 dict:status / clip_id / has_ai_tags / caption / embedding_dim。
|
||||
任务结果 dict:status / clip_id / ai_tags(部分字段)。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -47,27 +46,11 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
if clip is None:
|
||||
return {"status": "skipped", "reason": "clip not found", "clip_id": atom_clip_id}
|
||||
|
||||
# 幂等:已有任意 ai_tags(含降级占位)则按 force 策略跳过;person_count/text_content 为附加字段不单独触发重跑
|
||||
# - 无 force:只要 ai_tags 非 None 就跳过(与旧逻辑一致)
|
||||
# - force=True 且 ai_tags 是完整标签(含 has_text)且 caption 已存在才跳过
|
||||
existing_tags = clip.ai_tags
|
||||
has_real_tags = isinstance(existing_tags, dict) and "has_text" in existing_tags
|
||||
bool(getattr(clip, "caption", None))
|
||||
if existing_tags is not None:
|
||||
if not force:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "already tagged",
|
||||
"clip_id": atom_clip_id,
|
||||
}
|
||||
# force=True:有完整标签(has_text)就跳过;caption 是 #2035 新增的
|
||||
# 字段,对已有完整标签的历史数据不强制重跑
|
||||
if has_real_tags:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "already tagged",
|
||||
"clip_id": atom_clip_id,
|
||||
}
|
||||
# 已有完整标签则跳过(幂等);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)
|
||||
@@ -82,7 +65,7 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
doubao_client = get_doubao_client()
|
||||
mediakit_client = get_mediakit_client()
|
||||
|
||||
# 调用 tagger(视觉 API → ai_tags + caption)
|
||||
# 调用 tagger
|
||||
ai_tags = tag_atom_clip(
|
||||
clip=clip,
|
||||
video_url=video_url,
|
||||
@@ -91,54 +74,18 @@ def tag_atom_clip_task(self, atom_clip_id: str, force: bool = False) -> dict:
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
# 先写入 AI 标签(含 caption 字段在 ai_tags 字典里)
|
||||
# 更新数据库
|
||||
atom_repo.update_ai_tags(atom_clip_id, ai_tags)
|
||||
|
||||
# 提取 caption 并生成 embedding(失败降级,不阻断主流程)
|
||||
caption = (ai_tags or {}).get("caption", "") or ""
|
||||
embedding = None
|
||||
try:
|
||||
if caption.strip() and doubao_client.is_available:
|
||||
embedding = doubao_client.embed_text(caption)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[atom_clip_tagging] clip_id=%s embedding 生成失败,降级为空: %s",
|
||||
atom_clip_id,
|
||||
exc,
|
||||
)
|
||||
embedding = None
|
||||
|
||||
# 写入 caption + embedding(caption 冗余写一次到独立列,便于查询)
|
||||
try:
|
||||
atom_repo.update_caption_embedding(atom_clip_id, caption, embedding)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[atom_clip_tagging] clip_id=%s caption/embedding 写入失败: %s",
|
||||
atom_clip_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
person_count = (ai_tags or {}).get("person_count", 0)
|
||||
(ai_tags or {}).get("text_content", "") or ""
|
||||
logger.info(
|
||||
"[atom_clip_tagging] clip_id=%s ai_tags=%s caption=%r person_count=%s has_text=%s embedding_dim=%s",
|
||||
"[atom_clip_tagging] clip_id=%s ai_tags=%s",
|
||||
atom_clip_id,
|
||||
{k: v for k, v in ai_tags.items() if k not in ("inherited_tags", "caption", "text_content")},
|
||||
caption,
|
||||
person_count,
|
||||
bool((ai_tags or {}).get("has_text")),
|
||||
len(embedding) if embedding else 0,
|
||||
{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 not in ("inherited_tags", "caption", "text_content") and v
|
||||
),
|
||||
"caption": caption,
|
||||
"person_count": (ai_tags or {}).get("person_count", 0),
|
||||
"text_content": (ai_tags or {}).get("text_content", "") or "",
|
||||
"embedding_dim": len(embedding) if embedding else 0,
|
||||
"has_ai_tags": any(v for k, v in ai_tags.items() if k != "inherited_tags" and v),
|
||||
}
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
|
||||
@@ -19,7 +19,6 @@ from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import (
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.domain.atom_clip_service import compute_atom_clips
|
||||
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -57,35 +56,6 @@ def generate_atom_clips(asset_id: str) -> dict:
|
||||
}
|
||||
|
||||
scene_points = extract_scene_points_from_metadata(asset.metadata)
|
||||
# #2035:metadata 中没有 scene_change_points 时,按需调用 MediaKit 检测
|
||||
# (templates_editor 路由会主动写 metadata,ingest 流程此前未触发检测导致切点无法对齐)
|
||||
if not scene_points:
|
||||
try:
|
||||
mk = get_mediakit_client()
|
||||
video_url = getattr(asset, "file_url", "") or ""
|
||||
if mk.is_available and video_url:
|
||||
timestamps = mk.detect_scene_changes(video_url)
|
||||
if timestamps:
|
||||
scene_points = timestamps
|
||||
# 持久化到 metadata,避免下次重复检测
|
||||
new_meta = dict(asset.metadata or {})
|
||||
new_meta["scene_change_points"] = list(timestamps)
|
||||
asset.metadata = new_meta
|
||||
asset_repo.update(asset)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[atom_clips] asset_id=%s 自动检测到 %d 个场景切换点并写回metadata",
|
||||
asset_id,
|
||||
len(timestamps),
|
||||
)
|
||||
except Exception as detect_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"[atom_clips] asset_id=%s scene_change自动检测失败,降级为均匀切片: %s",
|
||||
asset_id,
|
||||
detect_err,
|
||||
)
|
||||
db.rollback() # 回滚metadata写失败,不影响后续切片
|
||||
|
||||
# P1 阶段继承素材的标签 ID;片段级语义标签是 P2 功能
|
||||
tags = list(getattr(asset, "tag_ids", []) or [])
|
||||
|
||||
|
||||
@@ -808,23 +808,17 @@ def ingest_asset(job_id: str) -> dict:
|
||||
|
||||
db.commit()
|
||||
|
||||
# ── #1970 素材原子切片 + #2035 质量评分:视频 READY 后异步触发,失败不阻断入库 ──
|
||||
# ── #1970 素材原子切片:视频 READY 后异步触发,失败不阻断入库 ──
|
||||
# atom_clips 未就绪时选片逻辑有内存兜底(compute_fallback_clips)。
|
||||
# quality_score 未计算时选片按 50 分兜底。
|
||||
try:
|
||||
if media_type == "video" and float(asset.duration or 0) > 0:
|
||||
celery_app.send_task(
|
||||
"worker.generate_atom_clips",
|
||||
args=[asset.id],
|
||||
)
|
||||
# #2035: 异步质量评分(不与 atom_clips 链式耦合,独立任务)
|
||||
celery_app.send_task(
|
||||
"worker.calculate_asset_quality",
|
||||
args=[asset.id],
|
||||
)
|
||||
except Exception as atom_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"触发原子切片/质量评分任务失败(不影响入库): asset_id=%s err=%s",
|
||||
"触发原子切片任务失败(不影响入库): asset_id=%s err=%s",
|
||||
asset.id,
|
||||
atom_err,
|
||||
)
|
||||
|
||||
@@ -12,12 +12,6 @@ server {
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
|
||||
@@ -12,13 +12,6 @@ server {
|
||||
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# SPA routing - all routes to index.html
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
|
||||
@@ -83,19 +83,6 @@ class SQLAlchemyAssetAtomClipRepository:
|
||||
models = query.all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def update_caption_embedding(self, clip_id: str, caption: str | None, embedding: list[float] | None = None) -> bool:
|
||||
"""更新片段的 caption 和 embedding 字段。"""
|
||||
upd: dict = {}
|
||||
if caption is not None:
|
||||
upd["caption"] = caption
|
||||
if embedding is not None:
|
||||
upd["embedding"] = embedding
|
||||
if not upd:
|
||||
return False
|
||||
count = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).update(upd)
|
||||
self.session.commit()
|
||||
return count > 0
|
||||
|
||||
def update_ai_tags(self, clip_id: str, ai_tags: dict) -> bool:
|
||||
"""更新指定片段的 ai_tags 字段."""
|
||||
count = (
|
||||
@@ -131,8 +118,6 @@ class SQLAlchemyAssetAtomClipRepository:
|
||||
clip_index=clip.clip_index,
|
||||
tags=clip.tags,
|
||||
ai_tags=clip.ai_tags,
|
||||
caption=clip.caption,
|
||||
embedding=clip.embedding,
|
||||
scene_change_at=clip.scene_change_at,
|
||||
is_fallback=clip.is_fallback,
|
||||
created_at=clip.created_at or datetime.now(UTC),
|
||||
@@ -147,9 +132,6 @@ class SQLAlchemyAssetAtomClipRepository:
|
||||
duration=model.duration,
|
||||
clip_index=model.clip_index,
|
||||
tags=model.tags or [],
|
||||
ai_tags=getattr(model, "ai_tags", None),
|
||||
caption=getattr(model, "caption", None),
|
||||
embedding=getattr(model, "embedding", None),
|
||||
scene_change_at=model.scene_change_at,
|
||||
is_fallback=model.is_fallback,
|
||||
created_at=model.created_at,
|
||||
|
||||
@@ -841,8 +841,6 @@ class AssetAtomClipModel(Base):
|
||||
clip_index = Column(Integer, nullable=False)
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
ai_tags = Column(JSON, nullable=True, default=None)
|
||||
caption = Column(Text, nullable=True, default=None)
|
||||
embedding = Column(JSON, nullable=True, default=None)
|
||||
scene_change_at = Column(Float, nullable=True)
|
||||
is_fallback = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
@@ -99,7 +99,6 @@ def finalize_generated_video(
|
||||
task,
|
||||
session: Session,
|
||||
effective_cover_url: str = "",
|
||||
custom_name: str | None = None,
|
||||
) -> dict:
|
||||
"""将 awaiting_cover 的任务正式入库。
|
||||
|
||||
@@ -123,8 +122,7 @@ def finalize_generated_video(
|
||||
raise ValueError(f"task {task.id} rendered_output.file_url 为空,无法 finalize")
|
||||
|
||||
video_id = uuid4().hex
|
||||
_custom = (custom_name or "").strip() if custom_name else ""
|
||||
video_name = _custom or (rendered.name.strip() or f"generated-{task.id[:8]}.mp4")
|
||||
video_name = rendered.name.strip() or f"generated-{task.id[:8]}.mp4"
|
||||
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
|
||||
@@ -70,7 +70,6 @@ class SharedSettings(BaseSettings):
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
doubao_vision_model: str = "doubao-1-5-vision-pro-250915"
|
||||
doubao_embedding_model: str = "doubao-embedding-large-text-240915"
|
||||
|
||||
# ── MediaKit (火山引擎 AI 媒体工具) ──────────────────────────────────
|
||||
mediakit_api_key: str = ""
|
||||
|
||||
@@ -37,8 +37,6 @@ class AssetAtomClip:
|
||||
clip_index: int
|
||||
tags: list[str] = field(default_factory=list)
|
||||
ai_tags: dict | None = None
|
||||
caption: str | None = None
|
||||
embedding: list[float] | None = None
|
||||
scene_change_at: float | None = None
|
||||
is_fallback: bool = False
|
||||
created_at: datetime | None = None
|
||||
@@ -69,8 +67,6 @@ class AssetAtomClip:
|
||||
tags: list[str] | None = None,
|
||||
scene_change_at: float | None = None,
|
||||
is_fallback: bool = False,
|
||||
caption: str | None = None,
|
||||
embedding: list[float] | None = None,
|
||||
) -> AssetAtomClip:
|
||||
"""工厂方法:创建一个新的原子片段。"""
|
||||
return cls(
|
||||
@@ -83,6 +79,4 @@ class AssetAtomClip:
|
||||
tags=tags or [],
|
||||
scene_change_at=scene_change_at,
|
||||
is_fallback=is_fallback,
|
||||
caption=caption,
|
||||
embedding=embedding,
|
||||
)
|
||||
|
||||
@@ -23,53 +23,36 @@ from typing import Any, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# AI 标签结构的键
|
||||
AI_TAG_KEYS = ("scene", "objects", "action", "shot", "has_text", "person_count", "text_content", "caption")
|
||||
AI_TAG_KEYS = ("scene", "objects", "action", "shot", "has_text")
|
||||
|
||||
|
||||
def build_vision_prompt() -> str:
|
||||
"""返回结构化标签提取 prompt.
|
||||
|
||||
要求 AI 以 JSON 格式返回片段内容标签,包含:
|
||||
- scene: 场景类型列表(如 "工厂", "办公室", "户外", "家庭", "商店")
|
||||
- objects: 画面中出现的主要物体/人物/动物类别,详细列出,常见类别包括:
|
||||
人物类:"人物"/"男性"/"女性"/"儿童"
|
||||
食物类:"食物"/"水果"/"饮料"/"菜肴"
|
||||
电子设备类:"手机"/"电脑"/"笔记本"/"平板"/"电视"/"相机"
|
||||
交通类:"汽车"/"自行车"/"公交车"/"飞机"
|
||||
建筑类:"建筑"/"房屋"/"桥梁"/"道路"
|
||||
动物类:"狗"/"猫"/"鸟"/"马"
|
||||
其他常见:"桌子"/"椅子"/"书本"/"花草"/"产品"等
|
||||
尽可能列出所有可识别的主要物体,3-8个
|
||||
- action: 动作类型列表(如 "演示", "说话", "操作", "行走", "奔跑", "进食")
|
||||
- scene: 场景类型列表(如 "工厂", "办公室", "户外")
|
||||
- objects: 出现的物体列表(如 "产品", "手机", "电脑")
|
||||
- action: 动作类型列表(如 "演示", "说话", "操作")
|
||||
- shot: 景别("特写" / "中景" / "远景" 之一)
|
||||
- has_text: 画面中是否有显著文字(标题/字幕/标语/海报文字)
|
||||
- person_count: 画面中可见的人数,0/1/2/3(3代表3人及以上)
|
||||
- text_content: 若 has_text=true,提取画面中最显著的文字内容(不超过30字,概括即可);否则为空字符串
|
||||
- caption: 一句中文画面描述(15-30字),简洁概括这段视频的人物、动作、场景和主体内容
|
||||
- has_text: 画面中是否有显著文字(true/false)
|
||||
"""
|
||||
return """请分析这段视频片段的关键帧,识别内容并返回 JSON 格式标签。
|
||||
|
||||
要求返回以下 JSON 结构(严格 JSON,不要添加其他文字):
|
||||
{
|
||||
"scene": ["场景1", "场景2"],
|
||||
"objects": ["物体1", "物体2", "物体3"],
|
||||
"objects": ["物体1", "物体2"],
|
||||
"action": ["动作1"],
|
||||
"shot": "特写|中景|远景",
|
||||
"has_text": true/false,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "一句中文描述"
|
||||
"has_text": true/false
|
||||
}
|
||||
|
||||
规则:
|
||||
- scene: 场景类型,如"工厂"、"办公室"、"户外"、"商店"、"家庭"、"街道"等,1-3个
|
||||
- objects: 画面中可见的所有主要物体/人物/动物/食物/设备等,详细列出(3-8个)。人物算作"人物",不要写具体人名。
|
||||
- action: 人物或物体正在进行的动作,如"演示"、"说话"、"操作"、"展示"、"行走"等,1-3个
|
||||
- scene: 场景类型,如"工厂"、"办公室"、"户外"、"商店"、"家庭"等,1-3个
|
||||
- objects: 画面中可见的主要物体,如"产品"、"手机"、"电脑"、"食品"等,1-5个
|
||||
- action: 人物或物体正在进行的动作,如"演示"、"说话"、"操作"、"展示"等,1-3个
|
||||
- shot: 景别判断,只能是"特写"、"中景"或"远景"之一
|
||||
- has_text: 画面中是否有显著可读文字(标题、字幕、标语、海报文字等)
|
||||
- person_count: 画面中可见的清晰人物数量,0=无人/远景人物不计数,1=1人,2=2人,3=3人及以上
|
||||
- text_content: 仅当 has_text=true 时填写,提取画面中最显眼的文字内容(不要超过30字);has_text=false 时填空字符串
|
||||
- caption: 一句简洁的中文画面描述(15-30字),概括主体人物、动作、场景和物体,例如"一名女性在办公室中讲解产品展示,桌上放有笔记本电脑"
|
||||
- has_text: 画面中是否有显著可读文字(标题、字幕、标语等)
|
||||
|
||||
请只返回 JSON,不要有其他说明文字。"""
|
||||
|
||||
@@ -82,7 +65,7 @@ def parse_vision_response(text: str) -> dict:
|
||||
|
||||
Returns:
|
||||
结构化标签 dict,格式如:
|
||||
{"scene": [...], "objects": [...], "action": [...], "shot": "...", "has_text": bool, "person_count": int, "text_content": str, "caption": "..."}
|
||||
{"scene": [...], "objects": [...], "action": [...], "shot": "...", "has_text": bool}
|
||||
|
||||
解析失败时返回空 dict。
|
||||
"""
|
||||
@@ -144,33 +127,6 @@ def parse_vision_response(text: str) -> dict:
|
||||
else:
|
||||
result["has_text"] = False
|
||||
|
||||
cap_val = data.get("caption", "")
|
||||
if isinstance(cap_val, str):
|
||||
cap_val = cap_val.strip()
|
||||
if len(cap_val) > 80:
|
||||
cap_val = cap_val[:80]
|
||||
else:
|
||||
cap_val = ""
|
||||
result["caption"] = cap_val
|
||||
|
||||
# person_count: 0/1/2/3
|
||||
pc_val = data.get("person_count", 0)
|
||||
try:
|
||||
pc = int(pc_val)
|
||||
result["person_count"] = max(0, min(3, pc))
|
||||
except (TypeError, ValueError):
|
||||
result["person_count"] = 0
|
||||
|
||||
# text_content: OCR 文字
|
||||
tc_val = data.get("text_content", "")
|
||||
if isinstance(tc_val, str):
|
||||
tc_val = tc_val.strip()
|
||||
if len(tc_val) > 100:
|
||||
tc_val = tc_val[:100]
|
||||
else:
|
||||
tc_val = ""
|
||||
result["text_content"] = tc_val
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -281,24 +237,14 @@ def tag_atom_clip(
|
||||
Returns:
|
||||
结构化标签 dict,格式如:
|
||||
{"scene": [...], "objects": [...], "action": [...], "shot": "...",
|
||||
"has_text": bool, "caption": "...", "inherited_tags": [...]}
|
||||
"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 {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
# 提取帧图片
|
||||
frame_urls: Optional[list[str]] = None
|
||||
@@ -315,17 +261,7 @@ def tag_atom_clip(
|
||||
|
||||
if not frame_urls:
|
||||
logger.warning("帧提取失败,跳过 AI 标签: clip_id=%s", getattr(clip, "id", ""))
|
||||
return {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
# 调用视觉 API
|
||||
prompt = build_vision_prompt()
|
||||
@@ -339,47 +275,17 @@ def tag_atom_clip(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("视觉 API 调用异常: clip_id=%s error=%s", getattr(clip, "id", ""), e)
|
||||
return {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
if not response_text:
|
||||
logger.warning("视觉 API 返回空: clip_id=%s", getattr(clip, "id", ""))
|
||||
return {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
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 {
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": "",
|
||||
"inherited_tags": inherited,
|
||||
}
|
||||
return {"inherited_tags": inherited}
|
||||
|
||||
# 合并 inherited_tags
|
||||
ai_tags["inherited_tags"] = inherited
|
||||
|
||||
@@ -245,39 +245,16 @@ def pick_narrative_assets(
|
||||
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
|
||||
)
|
||||
|
||||
# #2035:把文案标签与聚合的素材级 ai_tags 透传给 smart_select_assets,
|
||||
# 让 smart 评分维度(ai_semantic)在叙事模式内部兜底/补位时同样生效。
|
||||
wanted_norm = _normalize_tags(script_tags)
|
||||
asset_ai_tags: dict[str, dict] = {}
|
||||
if clip_ai_tags_by_asset:
|
||||
for aid, clips in clip_ai_tags_by_asset.items():
|
||||
agg: dict = {"scene": [], "objects": [], "action": []}
|
||||
for clip_tags in clips or []:
|
||||
if not isinstance(clip_tags, dict):
|
||||
continue
|
||||
for key in ("scene", "objects", "action"):
|
||||
for v in clip_tags.get(key) or []:
|
||||
v = str(v).strip()
|
||||
if v and v not in agg[key]:
|
||||
agg[key].append(v)
|
||||
asset_ai_tags[aid] = agg
|
||||
|
||||
smart_kwargs = dict(
|
||||
kind="video",
|
||||
rng=rng,
|
||||
script_tags=wanted_norm if wanted_norm else None,
|
||||
ai_tags_by_asset=asset_ai_tags if asset_ai_tags else None,
|
||||
)
|
||||
|
||||
need = limit if (limit is not None and limit > 0) else None
|
||||
|
||||
if not matched:
|
||||
return [r.asset for r in smart_select_assets(assets, limit=need, **smart_kwargs)]
|
||||
# 完全降级:与改造前随机混剪同一逻辑
|
||||
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, limit=need, **smart_kwargs)]
|
||||
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, limit=rest_need, **smart_kwargs))
|
||||
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, **smart_kwargs))
|
||||
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", rng=rng))
|
||||
return picked
|
||||
|
||||
@@ -40,14 +40,6 @@ def _get_enum_value(obj: Any, attr: str) -> str:
|
||||
return val.value if hasattr(val, "value") else str(val)
|
||||
|
||||
|
||||
def normalize_tag(tag) -> str:
|
||||
"""标准化标签:去两端空白、小写;非 str 转 str。返回空串表示应丢弃。"""
|
||||
if tag is None:
|
||||
return ""
|
||||
s = str(tag).strip().lower()
|
||||
return s
|
||||
|
||||
|
||||
def _duration_bucket(duration: float | None) -> str:
|
||||
"""将素材时长分为 3 档:short(<10s) / medium(10-30s) / long(>30s)。"""
|
||||
if duration is None or duration <= 0:
|
||||
@@ -62,24 +54,14 @@ def _duration_bucket(duration: float | None) -> str:
|
||||
def score_asset(
|
||||
asset: Any,
|
||||
now: datetime | None = None,
|
||||
script_tags: set | None = None,
|
||||
ai_tags_by_asset: dict | None = None,
|
||||
expected_categories: set[str] | None = None,
|
||||
) -> tuple[float, dict[str, float]]:
|
||||
"""为单个素材计算综合得分(0-100)。
|
||||
|
||||
维度权重(#2035 加入 AI 语义匹配 + 素材分类维度):
|
||||
- quality_score (28%):素材质量分(0-100),无质量分按 50 计
|
||||
- duration_fitness (22%):时长适配度,5-30s 为最优区间
|
||||
- recency (12%):新鲜度,30 天内衰减
|
||||
- unused (8%):未被/少被使用过的素材加分
|
||||
- ai_semantic (20%):AI 标签(scene/objects/action)与文案标签重合度;无数据给 50 中性分
|
||||
- category_match (10%):FFmpeg 自动分类结果(scenic/product/person/animal/food/tech/sport/music)
|
||||
与期望类别重合度;无分类或无期望类别时给 60 中性分
|
||||
|
||||
Args:
|
||||
script_tags: 标准化后的文案标签集合,用于 AI 语义匹配维度打分。
|
||||
ai_tags_by_asset: asset_id → ai_tags dict 映射,ai_tags 含 scene/objects/action 字段。
|
||||
维度权重:
|
||||
- quality_score (40%):素材质量分(0-100),无质量分按 50 计
|
||||
- duration_fitness (30%):时长适配度,5-30s 为最优区间
|
||||
- recency (20%):新鲜度,30 天内衰减
|
||||
- unused_bonus (10%):未被使用过的素材加分
|
||||
|
||||
Returns:
|
||||
(total_score, breakdown_dict)
|
||||
@@ -91,7 +73,7 @@ def score_asset(
|
||||
|
||||
# 1. 质量分 (0-100) → 权重 40%
|
||||
raw_quality = asset.quality_score if asset.quality_score is not None else 50.0
|
||||
quality_component = raw_quality * 0.28
|
||||
quality_component = raw_quality * 0.4
|
||||
breakdown["quality"] = round(quality_component, 2)
|
||||
|
||||
# 2. 时长适配度 (0-100) → 权重 30%
|
||||
@@ -108,7 +90,7 @@ def score_asset(
|
||||
# >30s: 指数衰减,60s 时约 50 分
|
||||
duration_fitness = 100.0 * math.exp(-0.02 * (duration - 30))
|
||||
duration_fitness = max(duration_fitness, 10.0)
|
||||
duration_component = duration_fitness * 0.22
|
||||
duration_component = duration_fitness * 0.3
|
||||
breakdown["duration"] = round(duration_component, 2)
|
||||
|
||||
# 3. 新鲜度 (0-100) → 权重 20%
|
||||
@@ -121,7 +103,7 @@ def score_asset(
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
age_days = max(0, (now - created_at).total_seconds() / 86400)
|
||||
recency = 100.0 * math.exp(-0.05 * age_days) # ~14天半衰期
|
||||
recency_component = recency * 0.12
|
||||
recency_component = recency * 0.2
|
||||
breakdown["recency"] = round(recency_component, 2)
|
||||
|
||||
# 4. 未使用偏好 (0-100) → 权重 10%
|
||||
@@ -136,55 +118,10 @@ def score_asset(
|
||||
unused_score = 70.0
|
||||
else:
|
||||
unused_score = 30.0
|
||||
unused_component = unused_score * 0.08
|
||||
unused_component = unused_score * 0.1
|
||||
breakdown["unused"] = round(unused_component, 2)
|
||||
|
||||
# 5. AI 语义匹配 (0-100) → 权重 20%
|
||||
if script_tags and ai_tags_by_asset:
|
||||
asset_ai = ai_tags_by_asset.get(getattr(asset, "id", "")) or {}
|
||||
ai_terms: set = set()
|
||||
for key in ("scene", "objects", "action"):
|
||||
vals = asset_ai.get(key) or []
|
||||
if isinstance(vals, list):
|
||||
for v in vals:
|
||||
norm = normalize_tag(v)
|
||||
if norm:
|
||||
ai_terms.add(norm)
|
||||
if ai_terms:
|
||||
norm_script = {normalize_tag(t) for t in script_tags if normalize_tag(t)}
|
||||
overlap = ai_terms & norm_script
|
||||
union = ai_terms | norm_script
|
||||
ratio = (len(overlap) / len(union)) if union else 0.0
|
||||
if overlap:
|
||||
ai_score = 50.0 + 50.0 * ratio
|
||||
else:
|
||||
ai_score = 20.0
|
||||
else:
|
||||
ai_score = 50.0
|
||||
else:
|
||||
ai_score = 50.0
|
||||
ai_component = ai_score * 0.20
|
||||
breakdown["ai_semantic"] = round(ai_component, 2)
|
||||
|
||||
# 6. 分类匹配 (0-100) → 权重 10%
|
||||
asset_meta = getattr(asset, "metadata", None) or {}
|
||||
asset_cat = (asset_meta.get("classification") or "").strip().lower()
|
||||
if expected_categories and asset_cat:
|
||||
norm_expected = {c.strip().lower() for c in expected_categories if c and c.strip()}
|
||||
if asset_cat == "other":
|
||||
cat_score = 50.0 # other 类不给额外加分也不扣分
|
||||
elif asset_cat in norm_expected:
|
||||
cat_score = 100.0
|
||||
else:
|
||||
cat_score = 30.0 # 分类明确但不匹配,略扣分
|
||||
elif expected_categories:
|
||||
cat_score = 60.0 # 无分类结果,中性
|
||||
else:
|
||||
cat_score = 60.0 # 无期望类别,中性
|
||||
cat_component = cat_score * 0.10
|
||||
breakdown["category_match"] = round(cat_component, 2)
|
||||
|
||||
total = quality_component + duration_component + recency_component + unused_component + ai_component + cat_component
|
||||
total = quality_component + duration_component + recency_component + unused_component
|
||||
return round(total, 2), breakdown
|
||||
|
||||
|
||||
@@ -195,9 +132,6 @@ def smart_select_assets(
|
||||
kind: str | None = None,
|
||||
now: datetime | None = None,
|
||||
rng: random.Random | None = None,
|
||||
script_tags: set | None = None,
|
||||
ai_tags_by_asset: dict | None = None,
|
||||
expected_categories: set[str] | None = None,
|
||||
) -> list[SmartMatchResult]:
|
||||
"""从素材列表中智能选取素材。
|
||||
|
||||
@@ -225,13 +159,7 @@ def smart_select_assets(
|
||||
# Step 3: 评分
|
||||
scored: list[SmartMatchResult] = []
|
||||
for a in ready_assets:
|
||||
total, breakdown = score_asset(
|
||||
a,
|
||||
now=now,
|
||||
script_tags=script_tags,
|
||||
ai_tags_by_asset=ai_tags_by_asset,
|
||||
expected_categories=expected_categories,
|
||||
)
|
||||
total, breakdown = score_asset(a, now=now)
|
||||
scored.append(SmartMatchResult(asset=a, score=total, breakdown=breakdown))
|
||||
|
||||
# Step 4: 按「得分 + 随机噪声」降序排序
|
||||
|
||||
@@ -39,47 +39,6 @@ class DoubaoClient:
|
||||
self.max_retries: int = settings.doubao_max_retries
|
||||
self.vision_model: str = settings.doubao_vision_model
|
||||
|
||||
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
|
||||
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
|
||||
if not self.is_available or not text or not text.strip():
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/embeddings"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": getattr(self, "embedding_model", None) or "doubao-embedding-large-text-240915",
|
||||
"input": text.strip(),
|
||||
"encoding_format": "float",
|
||||
}
|
||||
|
||||
req_timeout = timeout or self.timeout
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = httpx.post(url, headers=headers, json=payload, timeout=req_timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
emb_list = data.get("data") or []
|
||||
if emb_list and isinstance(emb_list, list):
|
||||
vec = emb_list[0].get("embedding")
|
||||
if isinstance(vec, list) and vec:
|
||||
return [float(x) for x in vec]
|
||||
logger.warning("embedding 返回结构异常: %s", str(data)[:200])
|
||||
return None
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包 Embedding 调用失败,%.1fs 后重试 (%d/%d): %s", wait, attempt + 1, self.max_retries + 1, e
|
||||
)
|
||||
time.sleep(wait)
|
||||
logger.error("豆包 Embedding 调用最终失败: %s", last_error)
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否可用(配置了 API Key)."""
|
||||
|
||||
@@ -210,12 +210,7 @@ class TestTagAtomClip:
|
||||
doubao_client=fake_doubao,
|
||||
)
|
||||
|
||||
assert result["inherited_tags"] == ["tag1", "tag2"]
|
||||
assert result.get("caption", "") == ""
|
||||
assert result.get("scene", []) == []
|
||||
assert result.get("objects", []) == []
|
||||
assert result.get("action", []) == []
|
||||
assert "inherited_tags" in result
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
assert len(fake_doubao.vision_calls) == 0
|
||||
|
||||
def test_mediakit_unavailable_no_ffmpeg(self):
|
||||
@@ -232,12 +227,7 @@ class TestTagAtomClip:
|
||||
)
|
||||
|
||||
# 没有 ffmpeg 的情况下,帧提取失败
|
||||
assert result["inherited_tags"] == ["tag1", "tag2"]
|
||||
assert result.get("caption", "") == ""
|
||||
assert result.get("scene", []) == []
|
||||
assert result.get("objects", []) == []
|
||||
assert result.get("action", []) == []
|
||||
assert "inherited_tags" in result
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
|
||||
def test_vision_api_error_returns_inherited(self):
|
||||
"""视觉 API 抛异常 → 降级 inherited_tags."""
|
||||
@@ -252,12 +242,7 @@ class TestTagAtomClip:
|
||||
mediakit_client=fake_mediakit,
|
||||
)
|
||||
|
||||
assert result["inherited_tags"] == ["tag1", "tag2"]
|
||||
assert result.get("caption", "") == ""
|
||||
assert result.get("scene", []) == []
|
||||
assert result.get("objects", []) == []
|
||||
assert result.get("action", []) == []
|
||||
assert "inherited_tags" in result
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
|
||||
def test_vision_api_empty_response(self):
|
||||
"""视觉 API 返回空 → 降级 inherited_tags."""
|
||||
@@ -272,12 +257,7 @@ class TestTagAtomClip:
|
||||
mediakit_client=fake_mediakit,
|
||||
)
|
||||
|
||||
assert result["inherited_tags"] == ["tag1", "tag2"]
|
||||
assert result.get("caption", "") == ""
|
||||
assert result.get("scene", []) == []
|
||||
assert result.get("objects", []) == []
|
||||
assert result.get("action", []) == []
|
||||
assert "inherited_tags" in result
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
|
||||
def test_vision_api_invalid_json_response(self):
|
||||
"""视觉 API 返回无效 JSON → 降级 inherited_tags."""
|
||||
@@ -292,12 +272,7 @@ class TestTagAtomClip:
|
||||
mediakit_client=fake_mediakit,
|
||||
)
|
||||
|
||||
assert result["inherited_tags"] == ["tag1", "tag2"]
|
||||
assert result.get("caption", "") == ""
|
||||
assert result.get("scene", []) == []
|
||||
assert result.get("objects", []) == []
|
||||
assert result.get("action", []) == []
|
||||
assert "inherited_tags" in result
|
||||
assert result == {"inherited_tags": ["tag1", "tag2"]}
|
||||
|
||||
def test_clip_with_empty_tags(self):
|
||||
"""空素材标签 → inherited_tags 为空列表."""
|
||||
@@ -310,8 +285,7 @@ class TestTagAtomClip:
|
||||
doubao_client=fake_doubao,
|
||||
)
|
||||
|
||||
assert result["inherited_tags"] == []
|
||||
assert result.get("caption", "") == ""
|
||||
assert result == {"inherited_tags": []}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
"""Additional unit tests to hit uncovered lines for diff-coverage >=60%."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.shared.ai_client import DoubaoClient
|
||||
|
||||
|
||||
class _FakeSettings:
|
||||
doubao_api_key = "test-key"
|
||||
doubao_model = "test-model"
|
||||
doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout = 10
|
||||
doubao_max_retries = 0
|
||||
doubao_vision_model = "test-vision"
|
||||
doubao_embedding_model = "test-embedding"
|
||||
|
||||
|
||||
def _make_client(api_key: str = "test-key") -> DoubaoClient:
|
||||
with patch("packages.shared.ai_client.get_shared_settings", return_value=_FakeSettings()):
|
||||
c = DoubaoClient()
|
||||
c.api_key = api_key
|
||||
c.max_retries = 0
|
||||
return c
|
||||
|
||||
|
||||
class TestDoubaoClientEmbedText:
|
||||
def test_no_api_key_returns_none(self):
|
||||
c = _make_client(api_key="")
|
||||
assert c.embed_text("hello") is None
|
||||
|
||||
def test_empty_text_returns_none(self):
|
||||
c = _make_client()
|
||||
assert c.embed_text("") is None
|
||||
assert c.embed_text(" ") is None
|
||||
|
||||
def test_none_text_returns_none(self):
|
||||
c = _make_client()
|
||||
assert c.embed_text(None) is None
|
||||
|
||||
@patch("packages.shared.ai_client.httpx.post")
|
||||
def test_successful_embedding(self, mock_post):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"embedding": [0.1, 0.2, 0.3]}]}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
c = _make_client()
|
||||
result = c.embed_text("hello world")
|
||||
assert result == [0.1, 0.2, 0.3]
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch("packages.shared.ai_client.httpx.post")
|
||||
def test_malformed_response_returns_none(self, mock_post):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"data": []}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_resp
|
||||
c = _make_client()
|
||||
assert c.embed_text("hello") is None
|
||||
|
||||
@patch("packages.shared.ai_client.httpx.post", side_effect=Exception("network error"))
|
||||
def test_network_error_returns_none(self, mock_post):
|
||||
c = _make_client()
|
||||
assert c.embed_text("hello") is None
|
||||
|
||||
def test_is_available_with_key(self):
|
||||
c = _make_client(api_key="sk-xxx")
|
||||
assert c.is_available is True
|
||||
|
||||
def test_is_available_without_key(self):
|
||||
c = _make_client(api_key="")
|
||||
assert c.is_available is False
|
||||
|
||||
|
||||
# --- 2. _infer_expected_categories ---
|
||||
_GEN_TASKS_PATH = Path(__file__).resolve().parents[2] / "apps/api/app/api/routes/generation_tasks.py"
|
||||
|
||||
|
||||
def _load_infer_func():
|
||||
src = _GEN_TASKS_PATH.read_text()
|
||||
start = src.index("# #2035:文案关键词")
|
||||
end = src.index("from packages.middleware")
|
||||
code = src[start:end]
|
||||
ns: dict = {}
|
||||
exec(code, ns)
|
||||
return ns["_infer_expected_categories"]
|
||||
|
||||
|
||||
_infer_expected_categories = _load_infer_func()
|
||||
|
||||
|
||||
class TestInferExpectedCategories:
|
||||
def test_none_returns_none(self):
|
||||
assert _infer_expected_categories(None) is None
|
||||
assert _infer_expected_categories(set()) is None
|
||||
|
||||
def test_product_keyword_matches(self):
|
||||
cats = _infer_expected_categories({"产品展示"})
|
||||
assert cats is not None
|
||||
assert "product" in cats
|
||||
|
||||
def test_scenic_keyword_matches(self):
|
||||
cats = _infer_expected_categories({"户外风景"})
|
||||
assert cats is not None
|
||||
assert "scenic" in cats
|
||||
|
||||
def test_food_keyword_matches(self):
|
||||
cats = _infer_expected_categories({"美食制作"})
|
||||
assert cats is not None
|
||||
assert "food" in cats
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
assert _infer_expected_categories({"抽象概念xyz"}) is None
|
||||
|
||||
|
||||
# --- 3. parse_vision_response edge cases ---
|
||||
from packages.domain.atom_clip_tagger import parse_vision_response
|
||||
|
||||
|
||||
class TestParseVisionResponseEdgeCases:
|
||||
def test_person_count_type_error_defaults_zero(self):
|
||||
text = json.dumps({
|
||||
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
||||
"person_count": "not-an-int", "text_content": "", "caption": "x",
|
||||
})
|
||||
r = parse_vision_response(text)
|
||||
assert r["person_count"] == 0
|
||||
|
||||
def test_person_count_out_of_range_clamped(self):
|
||||
text = json.dumps({
|
||||
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
||||
"person_count": 10, "text_content": "", "caption": "x",
|
||||
})
|
||||
r = parse_vision_response(text)
|
||||
assert r["person_count"] == 3
|
||||
|
||||
def test_person_count_negative_clamped(self):
|
||||
text = json.dumps({
|
||||
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
||||
"person_count": -5, "text_content": "", "caption": "x",
|
||||
})
|
||||
r = parse_vision_response(text)
|
||||
assert r["person_count"] == 0
|
||||
|
||||
def test_text_content_non_string_defaults_empty(self):
|
||||
text = '{"scene":[],"objects":[],"action":[],"shot":"","has_text":true,"person_count":0,"text_content":123,"caption":"x"}'
|
||||
r = parse_vision_response(text)
|
||||
assert r["text_content"] == ""
|
||||
|
||||
def test_caption_truncation_at_80(self):
|
||||
long_caption = "描" * 100
|
||||
text = json.dumps({
|
||||
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
||||
"person_count": 0, "text_content": "", "caption": long_caption,
|
||||
})
|
||||
r = parse_vision_response(text)
|
||||
assert len(r["caption"]) == 80
|
||||
|
||||
|
||||
# --- 4. smart_match normalize_tag ---
|
||||
from packages.domain.smart_match import normalize_tag
|
||||
|
||||
|
||||
class TestNormalizeTagEdge:
|
||||
def test_none_returns_empty(self):
|
||||
assert normalize_tag(None) == ""
|
||||
|
||||
def test_non_string_converted(self):
|
||||
assert normalize_tag(123) == "123"
|
||||
|
||||
def test_strip_and_lower(self):
|
||||
assert normalize_tag(" FOO Bar ") == "foo bar"
|
||||
|
||||
|
||||
# --- 5. narrative_match non-dict clip_tags skip ---
|
||||
from packages.domain.narrative_match import match_assets_by_script_tags
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FA:
|
||||
id: str
|
||||
tags: list
|
||||
|
||||
|
||||
class TestNarrativeMatchNonDictClipTags:
|
||||
def test_non_dict_clip_tags_are_skipped(self):
|
||||
a1 = _FA("a1", tags=[])
|
||||
clip_map = {"a1": [None, "bad", {"scene": ["工厂"], "objects": [], "action": []}, 123]}
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
[a1], script_tags=["工厂"], clip_ai_tags_by_asset=clip_map
|
||||
)
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
|
||||
|
||||
# --- 6. update_caption_embedding ---
|
||||
class _FakeSession:
|
||||
def __init__(self, rows_found: int = 1):
|
||||
self.rows_found = rows_found
|
||||
self.commits = 0
|
||||
self.updates = []
|
||||
|
||||
def query(self, model):
|
||||
return _FQuery(self)
|
||||
|
||||
def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
class _FQuery:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def filter(self, *a, **kw):
|
||||
return self
|
||||
|
||||
def update(self, upd):
|
||||
self.session.updates.append(upd)
|
||||
return self.session.rows_found
|
||||
|
||||
|
||||
class TestUpdateCaptionEmbedding:
|
||||
def _make_repo(self, session):
|
||||
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import SQLAlchemyAssetAtomClipRepository
|
||||
repo = SQLAlchemyAssetAtomClipRepository.__new__(SQLAlchemyAssetAtomClipRepository)
|
||||
repo.session = session
|
||||
return repo
|
||||
|
||||
def test_updates_both_caption_and_embedding(self):
|
||||
s = _FakeSession(rows_found=1)
|
||||
repo = self._make_repo(s)
|
||||
ok = repo.update_caption_embedding("c1", "new caption", [0.1, 0.2])
|
||||
assert ok is True
|
||||
assert s.commits == 1
|
||||
assert s.updates[0]["caption"] == "new caption"
|
||||
assert s.updates[0]["embedding"] == [0.1, 0.2]
|
||||
|
||||
def test_only_caption_update(self):
|
||||
s = _FakeSession(rows_found=1)
|
||||
repo = self._make_repo(s)
|
||||
ok = repo.update_caption_embedding("c1", "cap", None)
|
||||
assert ok is True
|
||||
assert "embedding" not in s.updates[0]
|
||||
assert s.updates[0]["caption"] == "cap"
|
||||
|
||||
def test_no_update_when_both_none(self):
|
||||
s = _FakeSession()
|
||||
repo = self._make_repo(s)
|
||||
ok = repo.update_caption_embedding("c1", None, None)
|
||||
assert ok is False
|
||||
assert s.commits == 0
|
||||
assert s.updates == []
|
||||
|
||||
def test_returns_false_when_row_not_found(self):
|
||||
s = _FakeSession(rows_found=0)
|
||||
repo = self._make_repo(s)
|
||||
ok = repo.update_caption_embedding("c1", "x", [0.1])
|
||||
assert ok is False
|
||||
@@ -1,352 +0,0 @@
|
||||
"""#2035 语义标签增强 / 质量评分 / AI选片 单测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||||
from packages.domain.atom_clip_tagger import parse_vision_response
|
||||
from packages.domain.narrative_match import (
|
||||
_compute_ai_score,
|
||||
_extract_ai_tag_names,
|
||||
match_assets_by_script_tags,
|
||||
pick_narrative_assets,
|
||||
)
|
||||
from packages.domain.smart_match import score_asset, smart_select_assets
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAsset:
|
||||
id: str
|
||||
tag_ids: list[str] = field(default_factory=list)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
status: object = None
|
||||
file_type: str = "video"
|
||||
duration: float = 10.0
|
||||
quality_score: float | None = 50.0
|
||||
created_at: datetime | None = None
|
||||
metadata: dict = field(default_factory=dict)
|
||||
usage_count: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.status is None:
|
||||
|
||||
class _S:
|
||||
value = "ready"
|
||||
|
||||
self.status = _S()
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now(UTC) - timedelta(days=1)
|
||||
|
||||
|
||||
# ── parse_vision_response: caption 提取 ─────────────────────────
|
||||
|
||||
|
||||
class TestParseVisionResponseCaption:
|
||||
def test_extracts_caption(self):
|
||||
text = '{"scene":["办公室"],"objects":["电脑","人"],"action":["说话"],"shot":"中景","has_text":false,"caption":"职场女性在办公室讲解产品功能"}'
|
||||
result = parse_vision_response(text)
|
||||
assert result["caption"] == "职场女性在办公室讲解产品功能"
|
||||
assert result["has_text"] is False
|
||||
assert result["scene"] == ["办公室"]
|
||||
|
||||
def test_caption_truncated_at_80(self):
|
||||
long = "A" * 100
|
||||
text = '{"scene":[],"objects":[],"action":[],"shot":"中景","has_text":false,"caption":"' + long + '"}'
|
||||
result = parse_vision_response(text)
|
||||
assert len(result["caption"]) == 80
|
||||
|
||||
def test_missing_caption_defaults_empty(self):
|
||||
text = '{"scene":[],"objects":[],"action":[],"shot":"特写","has_text":true}'
|
||||
result = parse_vision_response(text)
|
||||
assert result["caption"] == ""
|
||||
|
||||
def test_empty_input_returns_empty_dict(self):
|
||||
assert parse_vision_response("") == {}
|
||||
assert parse_vision_response(None) == {}
|
||||
|
||||
|
||||
# ── AI 标签提取 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractAiTagNames:
|
||||
def test_extracts_scene_objects_action(self):
|
||||
tags = {"scene": ["办公室"], "objects": ["电脑", "杯子"], "action": ["说话"], "shot": "中景", "has_text": False}
|
||||
names = _extract_ai_tag_names(tags)
|
||||
assert "办公室" in names
|
||||
assert "电脑" in names
|
||||
assert "杯子" in names
|
||||
assert "说话" in names
|
||||
assert "中景" not in names # shot 不参与匹配
|
||||
|
||||
def test_empty_tags(self):
|
||||
assert _extract_ai_tag_names({}) == set()
|
||||
assert _extract_ai_tag_names({"scene": []}) == set()
|
||||
|
||||
|
||||
# ── _compute_ai_score ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestComputeAiScore:
|
||||
def test_basic_hit(self):
|
||||
clip_map = {"a1": [{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}]}
|
||||
score = _compute_ai_score("a1", {"工厂", "演示"}, clip_map)
|
||||
# 2 hits * weight 2.0 = 4.0
|
||||
assert score == 4.0
|
||||
|
||||
def test_no_hit(self):
|
||||
clip_map = {"a1": [{"scene": ["户外"], "objects": [], "action": []}]}
|
||||
assert _compute_ai_score("a1", {"办公室"}, clip_map) == 0.0
|
||||
|
||||
def test_no_clip_map(self):
|
||||
assert _compute_ai_score("a1", {"工厂"}, None) == 0.0
|
||||
assert _compute_ai_score("a1", set(), {"a1": [{"scene": ["x"]}]}) == 0.0
|
||||
|
||||
def test_best_clip_score_not_sum(self):
|
||||
"""多片段取最高得分,不是累加。"""
|
||||
clip_map = {
|
||||
"a1": [
|
||||
{"scene": ["工厂"], "objects": [], "action": []}, # 1 hit
|
||||
{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}, # 3 hits
|
||||
{"scene": ["户外"], "objects": [], "action": []}, # 0
|
||||
]
|
||||
}
|
||||
score = _compute_ai_score("a1", {"工厂", "产品", "演示"}, clip_map)
|
||||
assert score == 3 * 2.0 # best = 6.0, not (1+3+0)*2 = 8.0
|
||||
|
||||
|
||||
# ── match_assets_by_script_tags 接受 clip_ai_tags_by_asset ──────
|
||||
|
||||
|
||||
class TestMatchSplitAiTags:
|
||||
def test_ai_hit_only_puts_in_matched(self):
|
||||
"""素材无人工标签,但 AI 标签命中 → 命中池。"""
|
||||
assets = [FakeAsset("a1", tags=[]), FakeAsset("a2", tags=["旅游"])]
|
||||
clip_map = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||||
matched, unmatched = match_assets_by_script_tags(assets, script_tags=["工厂"], clip_ai_tags_by_asset=clip_map)
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
assert [a.id for a in unmatched] == ["a2"]
|
||||
|
||||
def test_ai_and_manual_both_hit(self):
|
||||
assets = [FakeAsset("a1", tags=["工厂"]), FakeAsset("a2", tags=[])]
|
||||
clip_map = {"a1": [{"objects": ["产品"]}]}
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
assets, script_tags=["工厂", "产品"], clip_ai_tags_by_asset=clip_map
|
||||
)
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
# a2 无人标签也无AI命中 → unmatched
|
||||
assert [a.id for a in unmatched] == ["a2"]
|
||||
|
||||
|
||||
# ── score_asset AI 语义维度 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreAssetAiSemantic:
|
||||
def test_no_ai_data_gives_neutral_ai_component(self):
|
||||
a = FakeAsset("a1", quality_score=80)
|
||||
total, breakdown = score_asset(a, now=datetime.now(UTC))
|
||||
# ai_semantic 中性分 50 * 0.20 = 10;category 中性分 60 * 0.10 = 6
|
||||
assert breakdown["ai_semantic"] == 10.0
|
||||
assert breakdown["category_match"] == 6.0
|
||||
|
||||
def test_ai_hit_boosts_score(self):
|
||||
a = FakeAsset("a1", quality_score=50)
|
||||
ai_map = {"a1": {"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}}
|
||||
total_hit, _ = score_asset(a, now=datetime.now(UTC), script_tags={"工厂", "产品"}, ai_tags_by_asset=ai_map)
|
||||
total_miss, _ = score_asset(a, now=datetime.now(UTC), script_tags={"旅游"}, ai_tags_by_asset=ai_map)
|
||||
total_neutral, _ = score_asset(a, now=datetime.now(UTC))
|
||||
assert total_hit > total_neutral
|
||||
assert total_neutral > total_miss
|
||||
|
||||
def test_weights_sum_to_100(self):
|
||||
a = FakeAsset("a1", quality_score=100, duration=15)
|
||||
a.created_at = datetime.now(UTC)
|
||||
a.metadata = {"generation_use_count": 0}
|
||||
_, bd = score_asset(a, now=datetime.now(UTC))
|
||||
# 满分素材:quality=28, duration=22, recency=~12 (new), unused=8, ai=10(neutral), cat=6(neutral)
|
||||
# 总和应该 ~86
|
||||
assert 80 <= sum(bd.values()) <= 100.5
|
||||
|
||||
|
||||
# ── smart_select_assets 接受 script_tags/ai_tags_by_asset ──────
|
||||
|
||||
|
||||
class TestSmartSelectAi:
|
||||
def test_ai_hit_ranks_higher(self):
|
||||
a1 = FakeAsset("a1", quality_score=50, duration=15)
|
||||
a2 = FakeAsset("a2", quality_score=50, duration=15)
|
||||
a3 = FakeAsset("a3", quality_score=50, duration=15)
|
||||
ai_map = {
|
||||
"a1": {"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]},
|
||||
"a2": {"scene": ["户外"], "objects": [], "action": []},
|
||||
"a3": {},
|
||||
}
|
||||
rng = random.Random(42)
|
||||
results = smart_select_assets(
|
||||
[a1, a2, a3],
|
||||
kind="video",
|
||||
rng=rng,
|
||||
script_tags={"工厂", "产品", "演示"},
|
||||
ai_tags_by_asset=ai_map,
|
||||
)
|
||||
assert results[0].asset.id == "a1" # AI 命中应排第一
|
||||
|
||||
def test_without_ai_params_works_as_before(self):
|
||||
a1 = FakeAsset("a1", quality_score=80, duration=15)
|
||||
a2 = FakeAsset("a2", quality_score=40, duration=15)
|
||||
rng = random.Random(0)
|
||||
results = smart_select_assets([a1, a2], kind="video", rng=rng)
|
||||
assert results[0].asset.id == "a1"
|
||||
|
||||
|
||||
# ── pick_narrative_assets 接受 clip_ai_tags_by_asset ────────────
|
||||
|
||||
|
||||
class TestPickNarrativeAi:
|
||||
def test_ai_tagged_assets_selected_first(self):
|
||||
a1 = FakeAsset("a1", tags=[])
|
||||
a2 = FakeAsset("a2", tags=[])
|
||||
a3 = FakeAsset("a3", tags=["无关"])
|
||||
clip_map = {
|
||||
"a1": [{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}],
|
||||
"a2": [{"scene": ["户外"], "objects": [], "action": []}],
|
||||
}
|
||||
rng = random.Random(0)
|
||||
picked = pick_narrative_assets(
|
||||
[a1, a2, a3],
|
||||
script_tags=["工厂", "产品"],
|
||||
tag_names_by_id={},
|
||||
clip_ai_tags_by_asset=clip_map,
|
||||
rng=rng,
|
||||
limit=2,
|
||||
)
|
||||
assert picked[0].id == "a1" # a1 命中 AI 标签应在首位
|
||||
assert {a.id for a in picked} == {"a1", "a3"} or {a.id for a in picked} == {"a1", "a2"}
|
||||
|
||||
|
||||
# ── AssetAtomClip 字段扩展 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestAssetAtomClipNewFields:
|
||||
def test_caption_embedding_fields(self):
|
||||
clip = AssetAtomClip.create(
|
||||
asset_id="a1",
|
||||
start_time=0,
|
||||
end_time=5,
|
||||
clip_index=0,
|
||||
tags=[],
|
||||
caption="测试画面描述",
|
||||
embedding=[0.1, 0.2, 0.3],
|
||||
)
|
||||
assert clip.caption == "测试画面描述"
|
||||
assert clip.embedding == [0.1, 0.2, 0.3]
|
||||
assert clip.ai_tags is None
|
||||
|
||||
def test_default_fields_none(self):
|
||||
clip = AssetAtomClip.create("a1", 0, 5, 0)
|
||||
assert clip.caption is None
|
||||
assert clip.embedding is None
|
||||
|
||||
|
||||
# ── parse_vision_response: person_count / text_content ───────────
|
||||
|
||||
|
||||
class TestParseVisionResponseEnhanced:
|
||||
def test_person_count_parsed(self):
|
||||
text = '{"scene":["办公室"],"objects":["人物","电脑"],"action":["说话"],"shot":"中景","has_text":false,"person_count":1,"text_content":"","caption":"职场女性在办公室讲解产品功能,桌上有笔记本电脑"}'
|
||||
result = parse_vision_response(text)
|
||||
assert result["person_count"] == 1
|
||||
assert result["text_content"] == ""
|
||||
|
||||
def test_person_count_multi_people(self):
|
||||
text = '{"scene":["会议室"],"objects":["人物","桌子","椅子"],"action":["开会"],"shot":"中景","has_text":false,"person_count":3,"text_content":"","caption":"多人在会议室开会讨论项目方案"}'
|
||||
result = parse_vision_response(text)
|
||||
assert result["person_count"] == 3 # 3人及以上
|
||||
|
||||
def test_person_count_non_int_defaults_zero(self):
|
||||
text = '{"scene":[],"objects":[],"action":[],"shot":"特写","has_text":false,"person_count":"abc","text_content":"","caption":""}'
|
||||
result = parse_vision_response(text)
|
||||
assert result["person_count"] == 0
|
||||
|
||||
def test_text_content_extracted_when_has_text(self):
|
||||
text = '{"scene":["街道"],"objects":["招牌","建筑"],"action":[],"shot":"远景","has_text":true,"person_count":0,"text_content":"欢迎光临","caption":"街道上有一家店铺招牌写着欢迎光临"}'
|
||||
result = parse_vision_response(text)
|
||||
assert result["text_content"] == "欢迎光临"
|
||||
assert result["has_text"] is True
|
||||
|
||||
def test_text_content_truncated_at_100(self):
|
||||
long_text = "X" * 200
|
||||
text = (
|
||||
'{"scene":[],"objects":[],"action":[],"shot":"特写","has_text":true,"person_count":0,"text_content":"'
|
||||
+ long_text
|
||||
+ '","caption":""}'
|
||||
)
|
||||
result = parse_vision_response(text)
|
||||
assert len(result["text_content"]) == 100
|
||||
|
||||
def test_missing_person_count_defaults_zero(self):
|
||||
text = '{"scene":[],"objects":[],"action":[],"shot":"特写","has_text":false,"caption":"一个苹果"}'
|
||||
result = parse_vision_response(text)
|
||||
assert result["person_count"] == 0
|
||||
assert result["text_content"] == ""
|
||||
|
||||
def test_fallback_returns_person_count_zero(self):
|
||||
"""非 JSON 输入应返回空 dict(不是 fallback tags)。"""
|
||||
result = parse_vision_response("not json at all")
|
||||
assert result == {}
|
||||
|
||||
def test_objects_list_merged(self):
|
||||
"""objects 应该被保留并转为列表。"""
|
||||
text = '{"scene":["厨房"],"objects":["食物","锅","蔬菜","刀具"],"action":["烹饪"],"shot":"中景","has_text":false,"person_count":1,"text_content":"","caption":"厨师在厨房烹饪食物,食材摆放整齐"}'
|
||||
result = parse_vision_response(text)
|
||||
assert "食物" in result["objects"]
|
||||
assert "锅" in result["objects"]
|
||||
assert "蔬菜" in result["objects"]
|
||||
assert len(result["objects"]) >= 3
|
||||
|
||||
|
||||
# ── score_asset category_match 维度 ────────────────────────────
|
||||
|
||||
|
||||
class TestScoreAssetCategoryMatch:
|
||||
def test_no_category_gives_neutral(self):
|
||||
a = FakeAsset("a1", quality_score=50, metadata={})
|
||||
_, bd = score_asset(a, now=datetime.now(UTC))
|
||||
assert bd["category_match"] == 6.0 # 60 * 0.10 = 6
|
||||
|
||||
def test_category_hit_gives_10(self):
|
||||
a = FakeAsset("a1", quality_score=50, metadata={"classification": "product"})
|
||||
_, bd = score_asset(a, now=datetime.now(UTC), expected_categories={"product", "person"})
|
||||
assert bd["category_match"] == 10.0 # 100 * 0.10 = 10
|
||||
|
||||
def test_category_miss_gives_low(self):
|
||||
a = FakeAsset("a1", quality_score=50, metadata={"classification": "scenic"})
|
||||
_, bd_hit = score_asset(a, now=datetime.now(UTC), expected_categories={"product"})
|
||||
_, bd_neutral = score_asset(a, now=datetime.now(UTC))
|
||||
assert bd_hit["category_match"] == 3.0 # 30 * 0.10 = 3
|
||||
assert bd_neutral["category_match"] == 6.0
|
||||
|
||||
def test_other_category_neutral(self):
|
||||
"""other 类不给额外加分。"""
|
||||
a = FakeAsset("a1", quality_score=50, metadata={"classification": "other"})
|
||||
_, bd = score_asset(a, now=datetime.now(UTC), expected_categories={"product"})
|
||||
assert bd["category_match"] == 5.0 # 50 * 0.10 = 5
|
||||
|
||||
def test_category_affects_ranking(self):
|
||||
a_product = FakeAsset("a_product", quality_score=50, duration=15, metadata={"classification": "product"})
|
||||
a_scenic = FakeAsset("a_scenic", quality_score=50, duration=15, metadata={"classification": "scenic"})
|
||||
a_none = FakeAsset("a_none", quality_score=50, duration=15, metadata={})
|
||||
rng = random.Random(42)
|
||||
results = smart_select_assets(
|
||||
[a_product, a_scenic, a_none],
|
||||
kind="video",
|
||||
rng=rng,
|
||||
expected_categories={"product"},
|
||||
)
|
||||
assert results[0].asset.id == "a_product"
|
||||
@@ -1,167 +0,0 @@
|
||||
"""#2028: generation_cover._get_task_video_url 兜底逻辑测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestGetTaskVideoUrlAwaitingCoverFallback:
|
||||
def test_returns_url_from_rendered_output_when_awaiting_cover(self):
|
||||
from app.api.routes import generation_cover as cover_mod
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = []
|
||||
|
||||
mock_task = MagicMock()
|
||||
mock_task.status.value = "awaiting_cover"
|
||||
mock_task.extra_meta = {"rendered_output": {"file_url": "oss://generated/awaiting.mp4"}}
|
||||
|
||||
mock_task_repo_instance = MagicMock()
|
||||
mock_task_repo_instance.get.return_value = mock_task
|
||||
mock_db = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
|
||||
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
|
||||
):
|
||||
url = cover_mod._get_task_video_url("task-aw1", mock_db)
|
||||
assert url == "oss://generated/awaiting.mp4"
|
||||
|
||||
def test_returns_none_when_status_not_awaiting_cover(self):
|
||||
from app.api.routes import generation_cover as cover_mod
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = []
|
||||
mock_task = MagicMock()
|
||||
mock_task.status.value = "running"
|
||||
mock_task.extra_meta = {"rendered_output": {"file_url": "oss://x.mp4"}}
|
||||
mock_task_repo_instance = MagicMock()
|
||||
mock_task_repo_instance.get.return_value = mock_task
|
||||
mock_db = MagicMock()
|
||||
with (
|
||||
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
|
||||
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
|
||||
):
|
||||
url = cover_mod._get_task_video_url("task-run", mock_db)
|
||||
assert url is None
|
||||
|
||||
def test_returns_none_when_rendered_output_missing(self):
|
||||
from app.api.routes import generation_cover as cover_mod
|
||||
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = []
|
||||
mock_task = MagicMock()
|
||||
mock_task.status.value = "awaiting_cover"
|
||||
mock_task.extra_meta = {}
|
||||
mock_task_repo_instance = MagicMock()
|
||||
mock_task_repo_instance.get.return_value = mock_task
|
||||
mock_db = MagicMock()
|
||||
with (
|
||||
patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
|
||||
patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance),
|
||||
):
|
||||
url = cover_mod._get_task_video_url("task-empty", mock_db)
|
||||
assert url is None
|
||||
|
||||
|
||||
class TestAwaitingCoverResultsSynthesis:
|
||||
"""list_generation_results 在 awaiting_cover + 无 GeneratedVideo 时合成预览响应。"""
|
||||
|
||||
def _invoke(self, task, storage_service):
|
||||
from app.api.routes import generation_tasks as gt_mod
|
||||
|
||||
mock_auth = MagicMock()
|
||||
mock_auth.user.id = "u1"
|
||||
mock_task_repo = MagicMock()
|
||||
mock_task_repo.get.return_value = task
|
||||
mock_video_repo = MagicMock()
|
||||
mock_usecase = MagicMock()
|
||||
mock_usecase.execute.return_value = []
|
||||
mock_project_repo = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(gt_mod, "check_project_access", return_value=None),
|
||||
patch.object(gt_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase),
|
||||
):
|
||||
return gt_mod.list_generation_results(
|
||||
task_id=task.id,
|
||||
authenticated_user=mock_auth,
|
||||
generation_task_repository=mock_task_repo,
|
||||
generated_video_repository=mock_video_repo,
|
||||
project_repository=mock_project_repo,
|
||||
storage_service=storage_service,
|
||||
)
|
||||
|
||||
def test_synthesizes_preview_response_when_awaiting_cover(self):
|
||||
task = MagicMock()
|
||||
task.id = "task-syn-1"
|
||||
task.project_id = "proj1"
|
||||
task.status.value = "awaiting_cover"
|
||||
task.cover_url = ""
|
||||
task.extra_meta = {
|
||||
"rendered_output": {
|
||||
"file_url": "oss://bucket/v.mp4",
|
||||
"file_size": 2048,
|
||||
"duration": 10.5,
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"fps": 30.0,
|
||||
"name": "合成预览",
|
||||
"mode": "random",
|
||||
"thumbnail_url": "https://cdn/t.jpg",
|
||||
}
|
||||
}
|
||||
task.updated_at = None
|
||||
task.created_at = None
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://signed/v.mp4"
|
||||
resp = self._invoke(task, storage)
|
||||
assert len(resp.items) == 1
|
||||
item = resp.items[0]
|
||||
assert item.id == "preview-task-syn-1"
|
||||
assert item.name == "合成预览"
|
||||
assert item.file_size == 2048
|
||||
assert item.duration == 10.5
|
||||
assert item.width == 1080
|
||||
assert item.height == 1920
|
||||
assert item.fps == 30.0
|
||||
assert item.download_url == "https://signed/v.mp4"
|
||||
|
||||
def test_http_file_url_used_directly(self):
|
||||
task = MagicMock()
|
||||
task.id = "task-httpx"
|
||||
task.project_id = "p"
|
||||
task.status.value = "awaiting_cover"
|
||||
task.cover_url = ""
|
||||
task.extra_meta = {"rendered_output": {"file_url": "https://cdn.example.com/v.mp4", "name": ""}}
|
||||
task.updated_at = None
|
||||
task.created_at = None
|
||||
storage = MagicMock()
|
||||
resp = self._invoke(task, storage)
|
||||
assert resp.items[0].download_url == "https://cdn.example.com/v.mp4"
|
||||
storage.get_download_url.assert_not_called()
|
||||
assert resp.items[0].name.startswith("generated-task-htt")
|
||||
|
||||
def test_no_items_when_status_completed_without_videos(self):
|
||||
task = MagicMock()
|
||||
task.id = "task-done"
|
||||
task.project_id = "p"
|
||||
task.status.value = "completed"
|
||||
task.extra_meta = {"rendered_output": {"file_url": "oss://x.mp4"}}
|
||||
storage = MagicMock()
|
||||
resp = self._invoke(task, storage)
|
||||
assert resp.items == []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -367,13 +367,7 @@ class TestEditorClipsDurationAndStartTime:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
# 时长为0的素材被跳过,全部无效时返回「素材尚未完成分析,请稍后重试」
|
||||
assert "素材" in exc_info.value.detail and (
|
||||
"未完成" in exc_info.value.detail
|
||||
or "无效" in exc_info.value.detail
|
||||
or "分析" in exc_info.value.detail
|
||||
or "稍后" in exc_info.value.detail
|
||||
)
|
||||
assert "素材可切区间不足" in exc_info.value.detail
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_zero_duration_asset_skipped_in_mixed_pool(self, mock_storage):
|
||||
@@ -902,75 +896,3 @@ class TestClipsFromAssetsInvalidIds:
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
class TestClipsFromAssetsExceptionTolerance:
|
||||
"""#2028: score_asset / scene_points 抛异常时不应阻断整个请求,应兜底跳过。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_score_asset_exception_sets_score_zero(self, mock_storage):
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
|
||||
with (
|
||||
_patch_segments(_segments(2)),
|
||||
patch("app.api.routes.templates_editor.clips.score_asset", side_effect=RuntimeError("boom")),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
side_effect=[2.0, 8.0],
|
||||
),
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 2
|
||||
assert all(c["asset_id"] == "a1" for c in clips_data)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_scene_points_exception_safely_ignored(self, mock_storage):
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(replace_return_count=1)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
with (
|
||||
_patch_segments(_segments(1)),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.extract_scene_points_from_metadata",
|
||||
side_effect=RuntimeError("meta corrupt"),
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
||||
return_value=3.0,
|
||||
),
|
||||
):
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 1
|
||||
|
||||
@@ -204,79 +204,3 @@ class TestRenderedOutputDataclass:
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
|
||||
|
||||
class TestFinalizeCustomName:
|
||||
"""#2028: finalize_generated_video 支持 custom_name 参数。"""
|
||||
|
||||
def _make_task(self, extra_meta=None):
|
||||
task = GenerationTask.create(project_id="proj1", asset_library_id="lib1", asset_ids=["a1"])
|
||||
task.id = "task-custom"
|
||||
task.mark_processing()
|
||||
task.mark_awaiting_cover()
|
||||
task.project_id = "proj1"
|
||||
task.created_by_user_id = "user1"
|
||||
task.extra_meta = extra_meta or {
|
||||
"rendered_output": {
|
||||
"file_url": "oss://bucket/v.mp4",
|
||||
"file_size": 1024,
|
||||
"duration": 12.5,
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"fps": 30.0,
|
||||
"name": "default-name.mp4",
|
||||
"mode": "narrative",
|
||||
"batch_id": "",
|
||||
"is_duplicate": False,
|
||||
"fingerprint_dict": {"md5": "abc"},
|
||||
}
|
||||
}
|
||||
return task
|
||||
|
||||
def test_custom_name_used_in_generated_video(self):
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task()
|
||||
session = MagicMock()
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
result = finalize_generated_video(
|
||||
task=task,
|
||||
session=session,
|
||||
effective_cover_url="https://cdn/cover.jpg",
|
||||
custom_name="我的旅行vlog",
|
||||
)
|
||||
assert result["video_id"]
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.name == "我的旅行vlog"
|
||||
|
||||
def test_custom_name_falls_back_to_rendered_name(self):
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task()
|
||||
session = MagicMock()
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
finalize_generated_video(task=task, session=session, effective_cover_url="")
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.name == "default-name.mp4"
|
||||
|
||||
def test_custom_name_empty_uses_generated_id(self):
|
||||
from packages.application.generated_video_finalize import finalize_generated_video
|
||||
|
||||
task = self._make_task(extra_meta={"rendered_output": {"file_url": "oss://bucket/v.mp4", "name": ""}})
|
||||
session = MagicMock()
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
finalize_generated_video(task=task, session=session, effective_cover_url="", custom_name=" ")
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.name.startswith("generated-task-cus")
|
||||
|
||||
@@ -93,57 +93,57 @@ class TestScoreAsset:
|
||||
def test_no_quality_score_defaults_to_50(self):
|
||||
asset = FakeAsset(id="a1", quality_score=None, duration=15)
|
||||
score, breakdown = score_asset(asset, now=NOW)
|
||||
# quality component should be 50 * 0.30 = 15
|
||||
assert breakdown["quality"] == pytest.approx(14.0, abs=0.1)
|
||||
# quality component should be 50 * 0.4 = 20
|
||||
assert breakdown["quality"] == pytest.approx(20.0, abs=0.1)
|
||||
|
||||
def test_optimal_duration_5_to_30_gets_full_score(self):
|
||||
for dur in [5, 10, 20, 30]:
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=dur)
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
# duration component should be 100 * 0.25 = 25
|
||||
assert breakdown["duration"] == pytest.approx(22.0, abs=0.1)
|
||||
# duration component should be 100 * 0.3 = 30
|
||||
assert breakdown["duration"] == pytest.approx(30.0, abs=0.1)
|
||||
|
||||
def test_short_duration_below_5s_penalized(self):
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=2)
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
assert breakdown["duration"] < 22.0 # below max duration score
|
||||
assert breakdown["duration"] < 30.0
|
||||
|
||||
def test_long_duration_above_30s_penalized(self):
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=120)
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
assert breakdown["duration"] < 22.0 # below max duration score
|
||||
assert breakdown["duration"] < 30.0
|
||||
|
||||
def test_zero_duration_gives_moderate_score(self):
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=0)
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
# duration_fitness = 30.0, component = 30 * 0.3 = 9
|
||||
assert breakdown["duration"] == pytest.approx(6.6, abs=0.1)
|
||||
assert breakdown["duration"] == pytest.approx(9.0, abs=0.1)
|
||||
|
||||
def test_unused_asset_gets_full_bonus(self):
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={})
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
assert breakdown["unused"] == pytest.approx(8.0, abs=0.1)
|
||||
assert breakdown["unused"] == pytest.approx(10.0, abs=0.1)
|
||||
|
||||
def test_used_asset_gets_reduced_bonus(self):
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={"generation_use_count": 5})
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
assert breakdown["unused"] == pytest.approx(2.4, abs=0.1)
|
||||
assert breakdown["unused"] == pytest.approx(3.0, abs=0.1)
|
||||
|
||||
def test_dirty_metadata_use_count_string_does_not_crash(self):
|
||||
"""int() conversion of non-numeric metadata should not raise, should default to 0."""
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={"generation_use_count": "high"})
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
assert breakdown["unused"] == pytest.approx(8.0, abs=0.1) # use_count=0 → unused_score=100 → 100*0.08=8
|
||||
assert breakdown["unused"] == pytest.approx(10.0, abs=0.1) # use_count=0 → unused_score=100 → 100*0.1=10
|
||||
|
||||
def test_recent_asset_scores_higher_recency(self):
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=15, created_at=NOW - timedelta(days=1))
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
assert breakdown["recency"] > 8.5 # > 75% of max 15
|
||||
assert breakdown["recency"] > 15 # > 75% of max 20
|
||||
|
||||
def test_old_asset_scores_lower_recency(self):
|
||||
asset = FakeAsset(id="a1", quality_score=50, duration=15, created_at=NOW - timedelta(days=60))
|
||||
_, breakdown = score_asset(asset, now=NOW)
|
||||
assert breakdown["recency"] < 3.5 # heavily decayed
|
||||
assert breakdown["recency"] < 5 # heavily decayed
|
||||
|
||||
|
||||
# ── _duration_bucket tests ───────────────────────────────────────────────────
|
||||
@@ -245,7 +245,7 @@ class TestSmartSelectAssets:
|
||||
assert len(results) == 1
|
||||
r = results[0]
|
||||
assert r.score > 0
|
||||
assert set(r.breakdown.keys()) >= {"quality", "duration", "recency", "unused", "ai_semantic"}
|
||||
assert set(r.breakdown.keys()) == {"quality", "duration", "recency", "unused"}
|
||||
|
||||
def test_image_assets_can_be_selected(self):
|
||||
assets = [
|
||||
|
||||
@@ -155,10 +155,10 @@ class TestSmartMatchAvailabilityFallback:
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("top-exhausted.mp4", quality=100, used_ranges=_exhausted_ranges(15)),
|
||||
# second 质量分显著高于 third(质量项差 (95-20)*0.28=21 > 噪声上限 20),
|
||||
# second 质量分显著高于 third(质量项差 (90-30)*0.4=24 > 噪声上限 20),
|
||||
# 排除耗尽素材后 second 稳定排首位回补(噪声不影响大分差排名)
|
||||
_video_asset("second-fresh.mp4", quality=95, used_ranges=None),
|
||||
_video_asset("third-fresh.mp4", quality=20, used_ranges=None),
|
||||
_video_asset("second-fresh.mp4", quality=90, used_ranges=None),
|
||||
_video_asset("third-fresh.mp4", quality=30, used_ranges=None),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
|
||||
@@ -97,12 +97,12 @@ class TestScoreAssetUnusedDiminsh:
|
||||
_, low_bd = score_asset(low)
|
||||
_, high_bd = score_asset(high)
|
||||
|
||||
# use_count=0 → unused_score=100 → component=8.0 (weight 0.08)
|
||||
assert fresh_bd["unused"] == pytest.approx(8.0, abs=0.01)
|
||||
# use_count=2 → unused_score=70 → component=5.6
|
||||
assert low_bd["unused"] == pytest.approx(5.6, abs=0.01)
|
||||
# use_count=10 → unused_score=30 → component=2.4
|
||||
assert high_bd["unused"] == pytest.approx(2.4, abs=0.01)
|
||||
# use_count=0 → unused_score=100 → component=10.0
|
||||
assert fresh_bd["unused"] == 10.0
|
||||
# use_count=2 → unused_score=70 → component=7.0
|
||||
assert low_bd["unused"] == 7.0
|
||||
# use_count=10 → unused_score=30 → component=3.0
|
||||
assert high_bd["unused"] == 3.0
|
||||
|
||||
def test_monotonically_decreasing_scores(self):
|
||||
"""使用次数递增时,总评分单调不增。"""
|
||||
|
||||
Reference in New Issue
Block a user