Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ec9248f0b | |||
| 68293eb79b | |||
| df0cc0c1b5 | |||
| d29a788753 | |||
| 830b1379d3 |
+100
@@ -0,0 +1,100 @@
|
||||
"""add viral video tables
|
||||
|
||||
Revision ID: 086_add_viral_video_tables
|
||||
Revises: 085_atom_clip_caption_embedding
|
||||
Create Date: 2026-09-28
|
||||
|
||||
新增爆款视频相关表:
|
||||
- viral_video_jobs: 爆款视频任务
|
||||
- viral_video_style_templates: 风格模板配置
|
||||
- viral_video_prompt_templates: Prompt 模板(由 #2040 seed)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "086_add_viral_video_tables"
|
||||
down_revision = "085_atom_clip_caption_embedding"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# viral_video_jobs
|
||||
op.create_table(
|
||||
"viral_video_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("images", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("industry", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("target_customer", sa.String(500), nullable=False, server_default=""),
|
||||
sa.Column("persona_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("viral_structure", sa.String(50), nullable=False, server_default=""),
|
||||
sa.Column("marketing_purpose", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("bgm_preference", sa.String(50), nullable=False, server_default=""),
|
||||
sa.Column("duration", sa.Integer(), nullable=False, server_default="30"),
|
||||
sa.Column("user_copy_text", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("fusion_level", sa.String(20), nullable=False, server_default="ai_polish"),
|
||||
sa.Column("reference_audio_path", sa.String(1000), nullable=False, server_default=""),
|
||||
# v1.3 新增
|
||||
sa.Column("reference_video_url", sa.String(1000), nullable=False, server_default=""),
|
||||
sa.Column("style_strength", sa.String(20), nullable=False, server_default="medium"),
|
||||
sa.Column("style_guide", sa.JSON(), nullable=True),
|
||||
sa.Column("style_template_id", sa.String(36), nullable=False, server_default="", index=True),
|
||||
# 状态与结果
|
||||
sa.Column("status", sa.String(30), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("intent_result", sa.JSON(), nullable=True),
|
||||
sa.Column("result_video_url", sa.String(1000), nullable=False, server_default=""),
|
||||
sa.Column("credits_cost", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_msg", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# viral_video_style_templates
|
||||
op.create_table(
|
||||
"viral_video_style_templates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("thumbnail_url", sa.String(1000), nullable=False, server_default=""),
|
||||
sa.Column("style_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# viral_video_prompt_templates
|
||||
op.create_table(
|
||||
"viral_video_prompt_templates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("prompt_type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("variables", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Seed 默认风格模板
|
||||
op.execute("""
|
||||
INSERT INTO viral_video_style_templates (id, name, description, style_config, is_system, sort_order)
|
||||
VALUES
|
||||
('style-tpl-001', '快节奏冲击', '高频切镜+动感BGM,适合食品饮料等快消品', '{"cut_speed": "fast", "transition": "jump_cut", "energy": "high"}', true, 1),
|
||||
('style-tpl-002', '质感慢镜', '慢节奏+电影感调色,适合美妆护肤珠宝', '{"cut_speed": "slow", "transition": "dissolve", "energy": "low", "color_grade": "cinematic"}', true, 2),
|
||||
('style-tpl-003', '口播种草', '数字人口播+产品特写穿插', '{"cut_speed": "medium", "transition": "cross_dissolve", "has_talking_head": true}', true, 3),
|
||||
('style-tpl-004', '场景叙事', '多场景切换+故事线叙述', '{"cut_speed": "medium", "transition": "wipe", "narrative": true}', true, 4)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("viral_video_prompt_templates")
|
||||
op.drop_table("viral_video_style_templates")
|
||||
op.drop_table("viral_video_jobs")
|
||||
@@ -36,6 +36,7 @@ from app.api.routes.titles import router as titles_router
|
||||
from app.api.routes.tts import router as tts_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
from app.api.routes.videos import router as videos_router
|
||||
from app.api.routes.viral_video import router as viral_video_router
|
||||
from app.api.routes.voice_clones import router as voice_clones_router
|
||||
from app.api.routes.voices import router as voices_router
|
||||
from fastapi import APIRouter
|
||||
@@ -240,3 +241,4 @@ api_router.include_router(
|
||||
prefix="/gpu",
|
||||
tags=["GPU Worker"],
|
||||
)
|
||||
api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"])
|
||||
|
||||
Executable
+297
@@ -0,0 +1,297 @@
|
||||
"""爆款视频 API 路由。
|
||||
|
||||
端点:
|
||||
POST /api/v1/viral-video/generate 创建爆款视频任务
|
||||
GET /api/v1/viral-video/{job_id} 查询任务状态
|
||||
GET /api/v1/viral-video/history 历史记录
|
||||
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
|
||||
POST /api/v1/viral-video/{job_id}/confirm-intent 确认意图文案
|
||||
POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析
|
||||
GET /api/v1/viral-video/style-templates 获取风格模板列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.viral_video import (
|
||||
AnalyzeStyleRequest,
|
||||
AnalyzeStyleResponse,
|
||||
ConfirmIntentRequest,
|
||||
CreateViralVideoRequest,
|
||||
StyleTemplateListResponse,
|
||||
StyleTemplateResponse,
|
||||
ViralVideoHistoryResponse,
|
||||
ViralVideoJobResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
SQLAlchemyViralVideoStyleTemplateRepository,
|
||||
)
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _to_response(job) -> ViralVideoJobResponse:
|
||||
return ViralVideoJobResponse(
|
||||
id=job.id,
|
||||
user_id=job.user_id,
|
||||
images=job.images,
|
||||
industry=job.industry,
|
||||
target_customer=job.target_customer,
|
||||
persona_id=job.persona_id,
|
||||
viral_structure=job.viral_structure,
|
||||
marketing_purpose=job.marketing_purpose,
|
||||
bgm_preference=job.bgm_preference,
|
||||
duration=job.duration,
|
||||
user_copy_text=job.user_copy_text,
|
||||
fusion_level=job.fusion_level,
|
||||
reference_audio_path=job.reference_audio_path,
|
||||
reference_video_url=job.reference_video_url,
|
||||
style_strength=job.style_strength,
|
||||
style_guide=job.style_guide,
|
||||
style_template_id=job.style_template_id,
|
||||
status=job.status,
|
||||
intent_result=job.intent_result,
|
||||
result_video_url=job.result_video_url,
|
||||
credits_cost=job.credits_cost,
|
||||
error_msg=job.error_msg,
|
||||
retry_count=job.retry_count,
|
||||
started_at=job.started_at,
|
||||
completed_at=job.completed_at,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_job_repo(session: Session) -> SQLAlchemyViralVideoJobRepository:
|
||||
return SQLAlchemyViralVideoJobRepository(session)
|
||||
|
||||
|
||||
def _get_style_repo(session: Session) -> SQLAlchemyViralVideoStyleTemplateRepository:
|
||||
return SQLAlchemyViralVideoStyleTemplateRepository(session)
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ViralVideoJobResponse)
|
||||
def create_viral_video(
|
||||
request: CreateViralVideoRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""创建爆款视频任务,入队 Celery 编排器。"""
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
repo = _get_job_repo(session)
|
||||
|
||||
# 创建领域实体
|
||||
job = ViralVideoJob(
|
||||
user_id=authenticated_user.user.id,
|
||||
images=list(request.images),
|
||||
industry=request.industry,
|
||||
target_customer=request.target_customer,
|
||||
persona_id=request.persona_id,
|
||||
viral_structure=request.viral_structure,
|
||||
marketing_purpose=request.marketing_purpose,
|
||||
bgm_preference=request.bgm_preference,
|
||||
duration=request.duration,
|
||||
user_copy_text=request.user_copy_text,
|
||||
fusion_level=request.fusion_level,
|
||||
reference_audio_path=request.reference_audio_path,
|
||||
reference_video_url=request.reference_video_url,
|
||||
style_strength=request.style_strength,
|
||||
style_template_id=request.style_template_id,
|
||||
)
|
||||
|
||||
# 持久化
|
||||
repo.save(job)
|
||||
|
||||
# 入队 Celery 任务
|
||||
try:
|
||||
from worker_app.tasks.viral_video import run_viral_video_pipeline
|
||||
|
||||
run_viral_video_pipeline.delay(job.id)
|
||||
logger.info("[爆款视频] 任务已入队: job_id=%s user_id=%s", job.id, job.user_id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 入队失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"任务入队失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.get("/history", response_model=ViralVideoHistoryResponse)
|
||||
def list_viral_video_history(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoHistoryResponse:
|
||||
"""获取用户的爆款视频历史列表。"""
|
||||
repo = _get_job_repo(session)
|
||||
jobs = repo.list_by_user(authenticated_user.user.id, limit=limit, offset=offset)
|
||||
items = [_to_response(j) for j in jobs]
|
||||
return ViralVideoHistoryResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/style-templates", response_model=StyleTemplateListResponse)
|
||||
def list_style_templates(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> StyleTemplateListResponse:
|
||||
"""获取风格模板列表。"""
|
||||
repo = _get_style_repo(session)
|
||||
templates = repo.list_all()
|
||||
items = [
|
||||
StyleTemplateResponse(
|
||||
id=t["id"],
|
||||
name=t["name"],
|
||||
description=t["description"],
|
||||
thumbnail_url=t["thumbnail_url"],
|
||||
style_config=t["style_config"],
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
return StyleTemplateListResponse(items=items)
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=ViralVideoJobResponse)
|
||||
def get_viral_video_job(
|
||||
job_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""查询爆款视频任务状态。"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权查看此任务")
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/retry", response_model=ViralVideoJobResponse)
|
||||
def retry_viral_video_job(
|
||||
job_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""重试失败的爆款视频任务。"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此任务")
|
||||
if job.status != ViralVideoStatus.FAILED:
|
||||
raise HTTPException(status_code=409, detail="只有失败的任务可以重试")
|
||||
|
||||
# 重置状态
|
||||
job.retry_count += 1
|
||||
job.status = ViralVideoStatus.PENDING
|
||||
job.error_msg = ""
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
repo.update(job)
|
||||
|
||||
# 重新入队
|
||||
try:
|
||||
from worker_app.tasks.viral_video import run_viral_video_pipeline
|
||||
|
||||
run_viral_video_pipeline.delay(job.id)
|
||||
logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d", job.id, job.retry_count)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"重试入队失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/confirm-intent", response_model=ViralVideoJobResponse)
|
||||
def confirm_intent(
|
||||
job_id: str,
|
||||
request: ConfirmIntentRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""用户确认/修改 AI 生成的意图文案,恢复流水线。"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此任务")
|
||||
if job.status != ViralVideoStatus.WAIT_USER_CONFIRM:
|
||||
raise HTTPException(status_code=409, detail="任务当前不在等待确认状态")
|
||||
|
||||
# 更新文案
|
||||
if request.confirmed_copy:
|
||||
job.user_copy_text = request.confirmed_copy
|
||||
|
||||
# 恢复流水线
|
||||
job.resume_from_confirm()
|
||||
repo.update(job)
|
||||
|
||||
# 从断点恢复 Celery 任务
|
||||
try:
|
||||
from worker_app.tasks.viral_video import resume_viral_video_pipeline
|
||||
|
||||
resume_viral_video_pipeline.delay(job.id)
|
||||
logger.info("[爆款视频] 意图确认,恢复流水线: job_id=%s", job.id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 恢复流水线失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"恢复流水线失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/analyze-style", response_model=AnalyzeStyleResponse)
|
||||
def analyze_style(
|
||||
job_id: str,
|
||||
request: AnalyzeStyleRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> AnalyzeStyleResponse:
|
||||
"""触发参考视频风格分析(独立步骤,可在生成前单独调用)。"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此任务")
|
||||
|
||||
# 更新参考视频 URL
|
||||
job.reference_video_url = request.reference_video_url
|
||||
if request.style_template_id:
|
||||
job.style_template_id = request.style_template_id
|
||||
repo.update(job)
|
||||
|
||||
# 入队风格分析任务
|
||||
try:
|
||||
from worker_app.tasks.viral_video import run_video_style_analysis
|
||||
|
||||
run_video_style_analysis.delay(job.id)
|
||||
logger.info("[爆款视频] 风格分析入队: job_id=%s", job.id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 风格分析入队失败: %s", e, exc_info=True)
|
||||
|
||||
return AnalyzeStyleResponse(
|
||||
job_id=job.id,
|
||||
status="analyzing",
|
||||
style_guide=None,
|
||||
)
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
"""爆款视频 API schemas。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# ── 枚举常量 ─────────────────────────────────────────────────────────────
|
||||
|
||||
VALID_FUSION_LEVELS = ("ai_full", "ai_polish", "user_primary")
|
||||
VALID_STYLE_STRENGTHS = ("light", "medium", "strict")
|
||||
VALID_STAGES = (
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"review",
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"uploading",
|
||||
)
|
||||
|
||||
|
||||
# ── Request Schemas ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CreateViralVideoRequest(BaseModel):
|
||||
"""创建爆款视频任务请求。"""
|
||||
|
||||
images: list[str] = Field(..., min_length=1, max_length=20, description="产品图片 URL 列表")
|
||||
industry: str = Field(default="", description="行业")
|
||||
target_customer: str = Field(default="", description="目标客户描述")
|
||||
persona_id: str = Field(default="", description="人设 ID")
|
||||
viral_structure: str = Field(default="", description="爆款结构类型")
|
||||
marketing_purpose: str = Field(default="", description="营销目的")
|
||||
bgm_preference: str = Field(default="", description="BGM 偏好")
|
||||
duration: int = Field(default=30, ge=5, le=180, description="视频时长(秒)")
|
||||
user_copy_text: str = Field(default="", description="用户原始文案(我说你写)")
|
||||
fusion_level: str = Field(default="ai_polish", description="文案融合级别: ai_full/ai_polish/user_primary")
|
||||
reference_audio_path: str = Field(default="", description="参考音频路径")
|
||||
# v1.3 新增
|
||||
reference_video_url: str = Field(default="", description="参考爆款视频 URL")
|
||||
style_strength: str = Field(default="medium", description="风格强度: light/medium/strict")
|
||||
style_template_id: str = Field(default="", description="风格模板 ID")
|
||||
|
||||
@field_validator("fusion_level")
|
||||
@classmethod
|
||||
def _validate_fusion_level(cls, v: str) -> str:
|
||||
if v not in VALID_FUSION_LEVELS:
|
||||
raise ValueError(f"fusion_level 必须是 {VALID_FUSION_LEVELS} 之一")
|
||||
return v
|
||||
|
||||
@field_validator("style_strength")
|
||||
@classmethod
|
||||
def _validate_style_strength(cls, v: str) -> str:
|
||||
if v not in VALID_STYLE_STRENGTHS:
|
||||
raise ValueError(f"style_strength 必须是 {VALID_STYLE_STRENGTHS} 之一")
|
||||
return v
|
||||
|
||||
|
||||
class ConfirmIntentRequest(BaseModel):
|
||||
"""确认意图请求(confirm-intent)。"""
|
||||
|
||||
confirmed_copy: str = Field(default="", description="用户确认/修改后的文案,为空表示使用 AI 生成的文案")
|
||||
adjustments: str = Field(default="", description="用户对 AI 文案的调整意见")
|
||||
|
||||
|
||||
class AnalyzeStyleRequest(BaseModel):
|
||||
"""触发参考视频风格分析请求。"""
|
||||
|
||||
reference_video_url: str = Field(..., description="参考视频 URL")
|
||||
style_template_id: str = Field(default="", description="风格模板 ID(可选覆盖)")
|
||||
|
||||
|
||||
# ── Response Schemas ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ViralVideoJobResponse(BaseModel):
|
||||
"""爆款视频任务响应。"""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
images: list[str] = Field(default_factory=list)
|
||||
industry: str = ""
|
||||
target_customer: str = ""
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 30
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = "ai_polish"
|
||||
reference_audio_path: str = ""
|
||||
reference_video_url: str = ""
|
||||
style_strength: str = "medium"
|
||||
style_guide: dict | None = None
|
||||
style_template_id: str = ""
|
||||
status: str
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
credits_cost: int = 0
|
||||
error_msg: str = ""
|
||||
retry_count: int = 0
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ViralVideoHistoryResponse(BaseModel):
|
||||
"""历史记录列表响应。"""
|
||||
|
||||
items: list[ViralVideoJobResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class StyleTemplateResponse(BaseModel):
|
||||
"""风格模板响应。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
thumbnail_url: str = ""
|
||||
style_config: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StyleTemplateListResponse(BaseModel):
|
||||
"""风格模板列表响应。"""
|
||||
|
||||
items: list[StyleTemplateResponse]
|
||||
|
||||
|
||||
class AnalyzeStyleResponse(BaseModel):
|
||||
"""风格分析结果响应。"""
|
||||
|
||||
job_id: str
|
||||
status: str
|
||||
style_guide: dict | None = None
|
||||
|
||||
|
||||
# ── WebSocket 事件 Schema ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WSProgressEvent(BaseModel):
|
||||
"""WebSocket 进度推送事件。"""
|
||||
|
||||
type: str = "viral_video:progress"
|
||||
job_id: str
|
||||
stage: str
|
||||
progress: float = Field(ge=0.0, le=100.0)
|
||||
message: str = ""
|
||||
data: dict = Field(default_factory=dict)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""爆款视频 Worker 侧模块(#2039/#2040/#2051)。
|
||||
|
||||
video_analyzer(#2051):参考视频风格分析 6 步管线,输出 style_guide + clips 渲染参数映射。
|
||||
#2040 的 prompt 系统(prompts/prompt_store/llm_runner)由 #2040 分支提供,本文件不依赖它。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from apps.worker.viral_video.video_analyzer import (
|
||||
DEFAULT_ANALYSIS_TIMEOUT,
|
||||
MAX_REFERENCE_DURATION_SEC,
|
||||
MAX_REFERENCE_SIZE_MB,
|
||||
STYLE_GUIDE_SCHEMA,
|
||||
analyze_video_style,
|
||||
build_render_params_for_clip,
|
||||
map_bgm_bpm,
|
||||
map_camera_to_ken_burns,
|
||||
map_color_to_video_filter,
|
||||
map_transition_to_xfade,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_ANALYSIS_TIMEOUT",
|
||||
"MAX_REFERENCE_DURATION_SEC",
|
||||
"MAX_REFERENCE_SIZE_MB",
|
||||
"STYLE_GUIDE_SCHEMA",
|
||||
"analyze_video_style",
|
||||
"build_render_params_for_clip",
|
||||
"map_bgm_bpm",
|
||||
"map_camera_to_ken_burns",
|
||||
"map_color_to_video_filter",
|
||||
"map_transition_to_xfade",
|
||||
]
|
||||
@@ -0,0 +1,961 @@
|
||||
"""参考爆款视频风格分析模块(#2051,v1.3)。
|
||||
|
||||
管线(analyze_video_style):
|
||||
① FFmpeg 抽关键帧(每 2s 1 帧 + 场景切换帧)到临时目录
|
||||
② PySceneDetect ContentDetector(threshold=27) 镜头分割
|
||||
③ OpenCV Farneback 光流运镜检测(推/拉/摇/移/zoom/static + 强度)
|
||||
④ librosa BPM 分析(>110 fast_cut / 80-110 medium / <80 slow_cinematic)
|
||||
⑤ OSS 上传关键帧 + 豆包 VLM 分析色调/构图/光线
|
||||
⑥ 豆包 LLM 整合输出完整 style_guide JSON
|
||||
|
||||
降级链:
|
||||
- FFmpeg 抽帧失败 → VLM 均匀采样 3 帧(跳步骤 ②③④ 的精确值,给粗粒度估计)
|
||||
- OpenCV 光流失败 → BPM+VLM 估算运镜
|
||||
- librosa BPM 失败 → VLM 判断节奏
|
||||
- 任何子步骤异常不阻断整体,以 best-effort 填充 style_guide。
|
||||
|
||||
资源约束:
|
||||
- 参考视频 ≤60s 且 ≤100MB;分析总超时 ≤60s;临时帧 try/finally 清理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 资源约束 ─────────────────────────────────────────────────────────────
|
||||
|
||||
MAX_REFERENCE_DURATION_SEC = 60
|
||||
MAX_REFERENCE_SIZE_MB = 100
|
||||
DEFAULT_ANALYSIS_TIMEOUT = 60 # 秒
|
||||
KEYFRAME_INTERVAL_SEC = 2
|
||||
SCENEDETECT_THRESHOLD = 27
|
||||
VLM_SAMPLE_FRAMES = 5 # 上传给 VLM 的关键帧上限
|
||||
FARNEBACK_PARAMS = dict(pyr_scale=0.5, levels=3, winsize=15, iterations=3, poly_n=5, poly_sigma=1.2, flags=0)
|
||||
|
||||
# ── style_guide 输出 schema(最小校验参考,不强制 jsonschema 依赖) ───────
|
||||
|
||||
STYLE_GUIDE_SCHEMA: dict[str, Any] = {
|
||||
"style_name": str,
|
||||
"avg_shot_duration": float,
|
||||
"shot_count": int,
|
||||
"pace": str, # fast_cut | medium | slow_cinematic
|
||||
"bpm": int,
|
||||
"camera_movements": list,
|
||||
"transitions": list,
|
||||
"color_palette": list,
|
||||
"color_tone": str, # warm | cool | high_sat | low_sat | vintage | fresh | dramatic | bright
|
||||
"color_filter": str, # none | warm_vintage | cool_fresh | high_contrast | soft_pastel | dramatic_cinematic
|
||||
"composition": dict,
|
||||
"lighting": str,
|
||||
"mood": str,
|
||||
"visual_keywords": list,
|
||||
"ken_burns_params": dict,
|
||||
"transition_map": dict,
|
||||
"video_filter_eq_params": dict,
|
||||
"ken_burns_direction_hint": str,
|
||||
}
|
||||
|
||||
|
||||
# ── 数据结构 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShotBoundary:
|
||||
"""一段镜头(帧号区间)。"""
|
||||
|
||||
index: int
|
||||
start_sec: float
|
||||
end_sec: float
|
||||
movement: str = (
|
||||
"static" # push_in | pull_out | pan_left | pan_right | tilt_up | tilt_down | static | zoom_in | zoom_out
|
||||
)
|
||||
intensity: str = "low" # low | medium | high
|
||||
transition: str = "hard_cut" # 到下一个镜头的转场
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisArtifacts:
|
||||
"""中间产物(降级路径用)。"""
|
||||
|
||||
frames_dir: Path
|
||||
frame_paths: list[Path] = field(default_factory=list)
|
||||
shots: list[ShotBoundary] = field(default_factory=list)
|
||||
bpm: int = 0
|
||||
vlm_descriptions: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _strip_code_fence(text: str) -> str:
|
||||
"""移除 markdown 代码块围栏,返回纯文本。"""
|
||||
t = text.strip()
|
||||
for fence in ("```json", "```JSON", "```"):
|
||||
if t.startswith(fence):
|
||||
t = t[len(fence) :].lstrip()
|
||||
if t.endswith("```"):
|
||||
t = t[:-3].rstrip()
|
||||
return t
|
||||
|
||||
|
||||
# ── FFmpeg / ffprobe ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ffmpeg_bin() -> str:
|
||||
return shutil.which("ffmpeg") or "ffmpeg"
|
||||
|
||||
|
||||
def _ffprobe_bin() -> str:
|
||||
return shutil.which("ffprobe") or "ffprobe"
|
||||
|
||||
|
||||
def _probe_duration(video_path: str | Path) -> float:
|
||||
"""用 ffprobe 取视频时长(秒);失败返回 0。"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
_ffprobe_bin(),
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(video_path),
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
text=True,
|
||||
) # nosec B603
|
||||
return float(out.strip() or 0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("ffprobe 时长探测失败 %s: %s", video_path, exc)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _extract_keyframes(video_path: Path, out_dir: Path, interval: int = KEYFRAME_INTERVAL_SEC) -> list[Path]:
|
||||
"""按固定间隔抽帧;同时检测场景切换帧(select='gt(scene,...)')。"""
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 固定间隔
|
||||
fixed_tpl = str(out_dir / "f_%04d.jpg")
|
||||
cmd_fixed = [
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vf",
|
||||
f"fps=1/{interval}",
|
||||
"-q:v",
|
||||
"3",
|
||||
fixed_tpl,
|
||||
]
|
||||
subprocess.run(
|
||||
cmd_fixed, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DEFAULT_ANALYSIS_TIMEOUT, check=False
|
||||
) # nosec B603
|
||||
# 场景切换帧(独立命名,scene_ 前缀)
|
||||
scene_tpl = str(out_dir / "scene_%04d.jpg")
|
||||
cmd_scene = [
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vf",
|
||||
"select='gt(scene,0.35)',showinfo",
|
||||
"-vsync",
|
||||
"vfr",
|
||||
"-q:v",
|
||||
"3",
|
||||
scene_tpl,
|
||||
]
|
||||
subprocess.run(
|
||||
cmd_scene, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DEFAULT_ANALYSIS_TIMEOUT, check=False
|
||||
) # nosec B603
|
||||
frames = sorted(out_dir.glob("f_*.jpg")) + sorted(out_dir.glob("scene_*.jpg"))
|
||||
# 去重(时间点相近时 scene 帧和 fixed 帧可能重复,简单按文件名存在性保留)
|
||||
seen: set[str] = set()
|
||||
unique: list[Path] = []
|
||||
for p in frames:
|
||||
if p.name not in seen:
|
||||
seen.add(p.name)
|
||||
unique.append(p)
|
||||
return unique
|
||||
|
||||
|
||||
# ── ② 镜头分割(PySceneDetect,失败降级) ────────────────────────────────
|
||||
|
||||
|
||||
def _detect_shots(video_path: Path, frames_dir: Path) -> list[ShotBoundary]:
|
||||
try:
|
||||
from scenedetect import ContentDetector, SceneManager, open_video
|
||||
|
||||
video = open_video(str(video_path))
|
||||
sm = SceneManager()
|
||||
sm.add_detector(ContentDetector(threshold=SCENEDETECT_THRESHOLD))
|
||||
sm.detect_scenes(video)
|
||||
scenes = sm.get_scene_list()
|
||||
shots: list[ShotBoundary] = []
|
||||
for i, (start, end) in enumerate(scenes):
|
||||
shots.append(
|
||||
ShotBoundary(
|
||||
index=i,
|
||||
start_sec=start.get_seconds(),
|
||||
end_sec=end.get_seconds(),
|
||||
)
|
||||
)
|
||||
if shots:
|
||||
return shots
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("PySceneDetect 镜头分割失败,使用均匀分段降级: %s", exc)
|
||||
# 降级:按固定间隔每 3 秒一镜头
|
||||
duration = _probe_duration(video_path) or 15.0
|
||||
dur = max(3.0, min(duration, float(MAX_REFERENCE_DURATION_SEC)))
|
||||
shots = []
|
||||
seg = 3.0
|
||||
i = 0
|
||||
t = 0.0
|
||||
while t < dur - 0.1:
|
||||
shots.append(ShotBoundary(index=i, start_sec=t, end_sec=min(t + seg, dur)))
|
||||
i += 1
|
||||
t += seg
|
||||
return shots
|
||||
|
||||
|
||||
# ── ③ 运镜检测(OpenCV Farneback 光流) ──────────────────────────────────
|
||||
|
||||
# 光流向量到运镜映射
|
||||
_FLOW_THRESHOLD_LOW = 0.3
|
||||
_FLOW_THRESHOLD_HIGH = 1.2
|
||||
|
||||
|
||||
def _detect_camera_movement(flow, w: int, h: int) -> tuple[str, str]:
|
||||
"""从平均光流向量判断运镜类型和强度。"""
|
||||
import numpy as np # noqa: PLC0415 - numpy 已在 requirements 中
|
||||
|
||||
fx = float(np.median(flow[..., 0]))
|
||||
fy = float(np.median(flow[..., 1]))
|
||||
trans_mag = math.hypot(fx, fy)
|
||||
# 发散/收敛判断 zoom:比较边缘流沿径向外指的平均分量(稳健版)
|
||||
cx, cy = w / 2.0, h / 2.0
|
||||
ys, xs = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
rx, ry = (xs - cx) / max(cx, 1.0), (ys - cy) / max(cy, 1.0)
|
||||
rmag = np.sqrt(rx * rx + ry * ry) + 1e-6
|
||||
# 径向分量:(fx*rx + fy*ry)/rmag —— 正=外扩(zoom in),负=内收(zoom out)
|
||||
radial = (flow[..., 0] * rx + flow[..., 1] * ry) / rmag
|
||||
# 只看边缘带(|r|>0.5),且减去平移贡献:径向减去平均平移投影
|
||||
edge_mask = (rmag > 0.5).astype(np.float32)
|
||||
if edge_mask.sum() > 10:
|
||||
trans_radial = (fx * rx + fy * ry) / rmag
|
||||
zoom_signal = float(np.mean((radial - trans_radial)[edge_mask > 0]))
|
||||
else:
|
||||
zoom_signal = 0.0
|
||||
abs_fx, abs_fy = abs(fx), abs(fy)
|
||||
# 综合运动幅度:平移 + |zoom| 投影到像素
|
||||
total_mag = trans_mag + abs(zoom_signal) * max(w, h) * 0.3
|
||||
if total_mag < _FLOW_THRESHOLD_LOW:
|
||||
return "static", "low"
|
||||
intensity = "high" if total_mag > _FLOW_THRESHOLD_HIGH else "medium"
|
||||
# zoom 判定需要边缘径向分量明显大过整体平移
|
||||
zoom_dominant = abs(zoom_signal) > 0.6 and abs(zoom_signal) * max(w, h) * 0.3 > trans_mag * 1.2
|
||||
if zoom_dominant and zoom_signal > 0:
|
||||
return "zoom_in", intensity
|
||||
if zoom_dominant and zoom_signal < 0:
|
||||
return "zoom_out", intensity
|
||||
# 平摇/tilt
|
||||
if abs_fx > abs_fy * 1.5:
|
||||
return "pan_right" if fx > 0 else "pan_left", intensity
|
||||
if abs_fy > abs_fx * 1.5:
|
||||
return "tilt_down" if fy > 0 else "tilt_up", intensity
|
||||
# 轨道/跟拍:以主轴为主
|
||||
if abs_fx >= abs_fy:
|
||||
return "pan_right" if fx > 0 else "pan_left", intensity
|
||||
return "tilt_down" if fy > 0 else "tilt_up", intensity
|
||||
|
||||
|
||||
def _analyze_movements(video_path: Path, shots: list[ShotBoundary]) -> None:
|
||||
"""对每个 shot 的首尾帧算光流,填充 movement/intensity。失败时静默降级为 static/low。"""
|
||||
try:
|
||||
import cv2 # noqa: PLC0415 - opencv-python-headless 已在 worker requirements 中
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("OpenCV 不可用,运镜检测降级为 static/low: %s", exc)
|
||||
return
|
||||
try:
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
for shot in shots:
|
||||
mid_t = (shot.start_sec + shot.end_sec) / 2.0
|
||||
dt = max(0.2, min(0.5, (shot.end_sec - shot.start_sec) / 4.0))
|
||||
cap.set(cv2.CAP_PROP_POS_MSEC, max(0.0, (mid_t - dt)) * 1000)
|
||||
ok1, f1 = cap.read()
|
||||
cap.set(cv2.CAP_PROP_POS_MSEC, min(mid_t + dt, shot.end_sec - 0.05) * 1000)
|
||||
ok2, f2 = cap.read()
|
||||
if not (ok1 and ok2):
|
||||
continue
|
||||
g1 = cv2.cvtColor(f1, cv2.COLOR_BGR2GRAY)
|
||||
g2 = cv2.cvtColor(f2, cv2.COLOR_BGR2GRAY)
|
||||
h, w = g1.shape
|
||||
# 降采样加速
|
||||
scale = 360.0 / h if h > 360 else 1.0
|
||||
if scale < 1.0:
|
||||
g1 = cv2.resize(g1, (int(w * scale), int(h * scale)))
|
||||
g2 = cv2.resize(g2, (int(w * scale), int(h * scale)))
|
||||
flow = cv2.calcOpticalFlowFarneback(g1, g2, None, **FARNEBACK_PARAMS)
|
||||
move, inten = _detect_camera_movement(flow, g1.shape[1], g1.shape[0])
|
||||
shot.movement = move
|
||||
shot.intensity = inten
|
||||
cap.release()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("运镜检测异常,已降级: %s", exc)
|
||||
|
||||
|
||||
# ── ④ librosa BPM ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _detect_bpm(video_path: Path) -> int:
|
||||
"""提取音轨并估算 BPM;失败返回 0。"""
|
||||
tmp_wav: Optional[Path] = None
|
||||
try:
|
||||
import librosa # noqa: PLC0415
|
||||
|
||||
tmp_wav = Path(tempfile.mkstemp(suffix=".wav")[1])
|
||||
# ffmpeg 抽 22050Hz 单声道 wav
|
||||
subprocess.run(
|
||||
[_ffmpeg_bin(), "-y", "-i", str(video_path), "-vn", "-ac", "1", "-ar", "22050", "-f", "wav", str(tmp_wav)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=20,
|
||||
check=False,
|
||||
) # nosec B603
|
||||
if not tmp_wav.exists() or tmp_wav.stat().st_size < 1024:
|
||||
return 0
|
||||
y, sr = librosa.load(str(tmp_wav), sr=22050, mono=True)
|
||||
if len(y) < sr * 2:
|
||||
return 0
|
||||
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
||||
try:
|
||||
bpm = int(round(float(tempo)))
|
||||
except Exception: # noqa: BLE001
|
||||
bpm = int(round(float(tempo[0]))) if len(tempo) else 0
|
||||
return max(40, min(bpm, 220))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("librosa BPM 分析失败: %s", exc)
|
||||
return 0
|
||||
finally:
|
||||
if tmp_wav and tmp_wav.exists():
|
||||
try:
|
||||
tmp_wav.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _pace_from_bpm(bpm: int) -> str:
|
||||
if bpm >= 110:
|
||||
return "fast_cut"
|
||||
if bpm >= 80:
|
||||
return "medium"
|
||||
if bpm > 0:
|
||||
return "slow_cinematic"
|
||||
return "medium"
|
||||
|
||||
|
||||
# ── ⑤ VLM 帧分析 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sample_frames(frame_paths: list[Path], shots: list[ShotBoundary], k: int = VLM_SAMPLE_FRAMES) -> list[Path]:
|
||||
"""从全量帧中均匀选 k 张代表性帧(优先场景帧)。"""
|
||||
if not frame_paths:
|
||||
return []
|
||||
scene_frames = sorted(p for p in frame_paths if p.name.startswith("scene_"))
|
||||
fixed_frames = sorted(p for p in frame_paths if p.name.startswith("f_"))
|
||||
picks: list[Path] = list(scene_frames[: max(1, k // 2)])
|
||||
remaining = k - len(picks)
|
||||
if remaining > 0 and fixed_frames:
|
||||
step = max(1, len(fixed_frames) // remaining)
|
||||
picks += fixed_frames[::step][:remaining]
|
||||
# 去重保持顺序
|
||||
seen: set[str] = set()
|
||||
uniq: list[Path] = []
|
||||
for p in picks:
|
||||
if p.name not in seen and p.exists():
|
||||
seen.add(p.name)
|
||||
uniq.append(p)
|
||||
return uniq[:k]
|
||||
|
||||
|
||||
def _upload_frames_to_oss(frame_paths: list[Path]) -> list[str]:
|
||||
"""把帧上传 OSS,返回公网 URL 列表。失败时降级为 data URI。"""
|
||||
urls: list[str] = []
|
||||
try:
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
for p in frame_paths:
|
||||
try:
|
||||
key = f"viral-video/analysis/{uuid.uuid4().hex}/{p.name}"
|
||||
url = upload_to_oss(p, key)
|
||||
if url:
|
||||
urls.append(url)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("单帧 OSS 上传失败 %s: %s", p.name, exc)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("OSS 上传模块不可用,降级为 base64 data URI: %s", exc)
|
||||
if len(urls) < len(frame_paths):
|
||||
# 降级:base64 data URI(小图,单张 ≤100KB 才走此路)
|
||||
import base64
|
||||
|
||||
for p in frame_paths[len(urls) :]:
|
||||
try:
|
||||
if p.stat().st_size > 120_000:
|
||||
continue
|
||||
b64 = base64.b64encode(p.read_bytes()).decode("ascii")
|
||||
urls.append(f"data:image/jpeg;base64,{b64}")
|
||||
except Exception: # noqa: BLE001 # nosec B112
|
||||
continue
|
||||
return urls
|
||||
|
||||
|
||||
def _vlm_analyze_frames(image_urls: list[str]) -> dict[str, Any]:
|
||||
"""调豆包 VLM 分析色调/构图/光线/转场观感。"""
|
||||
if not image_urls:
|
||||
return {}
|
||||
try:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
raise RuntimeError("豆包客户端未配置")
|
||||
sys_prompt = (
|
||||
"你是资深短视频导演和调色师。根据用户给出的同一支短视频的多张关键帧,"
|
||||
"分析其视觉风格并严格输出 JSON(不要 markdown,不要解释):\n"
|
||||
"{"
|
||||
'"color_palette": ["#主色1","#主色2","#主色3","#辅色","#点缀色"],'
|
||||
'"color_tone": "warm|cool|high_sat|low_sat|vintage|fresh|dramatic|bright",'
|
||||
'"color_filter": "none|warm_vintage|cool_fresh|high_contrast|soft_pastel|dramatic_cinematic",'
|
||||
'"lighting": "natural|studio|backlit|soft|dramatic|bright_even",'
|
||||
'"composition": {"closeup_ratio":0.0,"medium_ratio":0.0,"wide_ratio":0.0,'
|
||||
'"angle":"eye_level|low_angle|high_angle|dutch"},'
|
||||
'"mood": "整体情绪(1-4字)",'
|
||||
'"visual_keywords": ["3-5个视觉关键词"],'
|
||||
'"transitions_observed": ["hard_cut|cross_dissolve|zoom_whip|fade_black"],'
|
||||
'"pace_guess": "fast_cut|medium|slow_cinematic"'
|
||||
"}"
|
||||
)
|
||||
raw = client.vision_completion(
|
||||
messages=[
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": "请分析这支参考视频的风格。"},
|
||||
],
|
||||
images=image_urls,
|
||||
temperature=0.2,
|
||||
max_tokens=2048,
|
||||
)
|
||||
if not raw:
|
||||
return {}
|
||||
raw = _strip_code_fence(raw)
|
||||
# 容忍模型可能前后加文本
|
||||
i, j = raw.find("{"), raw.rfind("}")
|
||||
if i >= 0 and j > i:
|
||||
return json.loads(raw[i : j + 1])
|
||||
return {}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("VLM 帧分析失败: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
# ── ⑥ LLM 整合 style_guide ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _llm_synthesize(
|
||||
shots: list[ShotBoundary],
|
||||
bpm: int,
|
||||
vlm: dict[str, Any],
|
||||
style_strength: str,
|
||||
) -> dict[str, Any]:
|
||||
"""把结构化信号整合成 style_guide;LLM 不可用时走规则合成。"""
|
||||
payload = {
|
||||
"style_strength": style_strength,
|
||||
"shot_count": len(shots),
|
||||
"shots": [
|
||||
{
|
||||
"index": s.index,
|
||||
"start_sec": round(s.start_sec, 2),
|
||||
"end_sec": round(s.end_sec, 2),
|
||||
"movement": s.movement,
|
||||
"intensity": s.intensity,
|
||||
"transition": s.transition,
|
||||
}
|
||||
for s in shots
|
||||
],
|
||||
"bpm": bpm,
|
||||
"pace_guess": _pace_from_bpm(bpm),
|
||||
"vlm": vlm,
|
||||
}
|
||||
try:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
raise RuntimeError("豆包客户端未配置")
|
||||
sys_prompt = (
|
||||
"你是资深短视频导演。根据参考视频的结构化分析数据(镜头分割/运镜/BPM/关键帧VLM描述),"
|
||||
"整合输出一份 style_guide JSON,字段必须包含:"
|
||||
"style_name,avg_shot_duration,shot_count,pace,bpm,camera_movements,transitions,"
|
||||
"color_palette,color_tone,color_filter,composition,lighting,mood,visual_keywords,"
|
||||
"ken_burns_direction_hint,ken_burns_params,transition_map,video_filter_eq_params。"
|
||||
"严格输出一个合法 JSON 对象,不要 markdown/解释。"
|
||||
)
|
||||
user_text = "分析数据:\n" + json.dumps(payload, ensure_ascii=False)
|
||||
raw = client.chat_completion(
|
||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_text}],
|
||||
temperature=0.3,
|
||||
max_tokens=4096,
|
||||
)
|
||||
if raw:
|
||||
raw = _strip_code_fence(raw)
|
||||
i, j = raw.find("{"), raw.rfind("}")
|
||||
if i >= 0 and j > i:
|
||||
result = json.loads(raw[i : j + 1])
|
||||
if isinstance(result, dict) and result.get("style_name"):
|
||||
return result
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("LLM 合成 style_guide 失败,走规则降级: %s", exc)
|
||||
return _rule_based_style_guide(shots, bpm, vlm)
|
||||
|
||||
|
||||
def _rule_based_style_guide(shots: list[ShotBoundary], bpm: int, vlm: dict[str, Any]) -> dict[str, Any]:
|
||||
"""LLM 不可用时,用规则拼出可用 style_guide。"""
|
||||
durations = [s.end_sec - s.start_sec for s in shots] or [3.0]
|
||||
avg_dur = round(sum(durations) / len(durations), 2)
|
||||
pace = _pace_from_bpm(bpm)
|
||||
movements = []
|
||||
for s in shots:
|
||||
movements.append(
|
||||
{
|
||||
"shot_index": s.index + 1,
|
||||
"movement": s.movement,
|
||||
"intensity": s.intensity,
|
||||
"duration": round(s.end_sec - s.start_sec, 2),
|
||||
"subject_hint": _default_subject_hint(s.movement),
|
||||
}
|
||||
)
|
||||
transitions = []
|
||||
for i in range(len(shots) - 1):
|
||||
transitions.append({"between_shot": [i + 1, i + 2], "type": shots[i].transition})
|
||||
color_palette = vlm.get("color_palette") or ["#E0E0E0", "#333333", "#F5F5F5", "#888888", "#FF6B35"]
|
||||
color_tone = vlm.get("color_tone") or "bright"
|
||||
color_filter = vlm.get("color_filter") or "none"
|
||||
lighting = vlm.get("lighting") or "bright_even"
|
||||
composition = vlm.get("composition") or {
|
||||
"closeup_ratio": 0.4,
|
||||
"medium_ratio": 0.4,
|
||||
"wide_ratio": 0.2,
|
||||
"angle": "eye_level",
|
||||
}
|
||||
mood = vlm.get("mood") or "明快"
|
||||
vk = vlm.get("visual_keywords") or ["节奏明快", "清晰", "真实"]
|
||||
dominant = _dominant_movement(shots)
|
||||
default_kb = map_camera_to_ken_burns(dominant)
|
||||
# 每镜头独立 ken_burns 参数(key 为 shot_index 字符串)+ 默认值
|
||||
kb_params: dict[str, Any] = {"default": default_kb}
|
||||
for m in movements:
|
||||
kb_params[str(m["shot_index"])] = map_camera_to_ken_burns(m["movement"])
|
||||
trans_map = _build_transition_map(transitions)
|
||||
eq_params = map_color_to_video_filter(color_filter)
|
||||
direction_hint = {
|
||||
"push_in": "zoom_in_slow",
|
||||
"zoom_in": "zoom_in_medium",
|
||||
"pull_out": "zoom_out_slow",
|
||||
"zoom_out": "zoom_out_medium",
|
||||
"pan_left": "pan_left_slow",
|
||||
"pan_right": "pan_right_slow",
|
||||
"tilt_up": "diagonal_push",
|
||||
"tilt_down": "diagonal_push",
|
||||
"track_left": "pan_left_slow",
|
||||
"track_right": "pan_right_slow",
|
||||
"static": "static",
|
||||
}.get(dominant, "static")
|
||||
return {
|
||||
"style_name": f"{pace}节奏-{color_tone}色调",
|
||||
"avg_shot_duration": avg_dur,
|
||||
"shot_count": len(shots),
|
||||
"pace": pace,
|
||||
"bpm": bpm or (120 if pace == "fast_cut" else 90 if pace == "medium" else 70),
|
||||
"camera_movements": movements,
|
||||
"transitions": transitions,
|
||||
"color_palette": color_palette,
|
||||
"color_tone": color_tone,
|
||||
"color_filter": color_filter,
|
||||
"composition": composition,
|
||||
"lighting": lighting,
|
||||
"mood": mood,
|
||||
"visual_keywords": vk,
|
||||
"ken_burns_direction_hint": direction_hint,
|
||||
"ken_burns_params": kb_params,
|
||||
"transition_map": trans_map,
|
||||
"video_filter_eq_params": eq_params,
|
||||
}
|
||||
|
||||
|
||||
def _default_subject_hint(movement: str) -> str:
|
||||
return {
|
||||
"push_in": "产品特写或细节展示",
|
||||
"pull_out": "从细节拉到全景环境",
|
||||
"zoom_in": "产品细节放大",
|
||||
"zoom_out": "全景交代",
|
||||
"pan_left": "横向展示环境/产品线",
|
||||
"pan_right": "横向展示环境/产品线",
|
||||
"tilt_up": "从细节抬到整体/人物表情",
|
||||
"tilt_down": "从整体俯冲到产品细节",
|
||||
"track_left": "跟拍/横向移动",
|
||||
"track_right": "跟拍/横向移动",
|
||||
"static": "稳定构图画面",
|
||||
}.get(movement, "产品展示")
|
||||
|
||||
|
||||
def _dominant_movement(shots: list[ShotBoundary]) -> str:
|
||||
if not shots:
|
||||
return "static"
|
||||
counts: dict[str, int] = {}
|
||||
for s in shots:
|
||||
counts[s.movement] = counts.get(s.movement, 0) + 1
|
||||
return max(counts, key=counts.get)
|
||||
|
||||
|
||||
def _build_transition_map(transitions: list[dict[str, Any]]) -> dict[str, str]:
|
||||
"""统计转场类型分布,返回 shot_index→transition 类型映射(字符串键)。"""
|
||||
m: dict[str, str] = {}
|
||||
for t in transitions:
|
||||
pair = t.get("between_shot") or [0, 0]
|
||||
if len(pair) >= 2:
|
||||
m[f"{pair[0]}-{pair[1]}"] = t.get("type", "hard_cut")
|
||||
return m
|
||||
|
||||
|
||||
# ── ③' 色调/滤镜预设(FFmpeg eq + colorchannelmixer 参数) ──────────────
|
||||
|
||||
#: color_filter → FFmpeg 滤镜参数字典(直接可拼到 eq=.../colorchannelmixer=...)
|
||||
COLOR_FILTER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"none": {},
|
||||
"warm_vintage": {
|
||||
"eq": {"brightness": 0.02, "contrast": 1.05, "saturation": 0.9, "gamma": 1.05},
|
||||
"colorchannelmixer": {"rr": 1.1, "gg": 0.98, "bb": 0.82, "ra": 0, "ga": 0, "ba": 0, "aa": 1},
|
||||
},
|
||||
"cool_fresh": {
|
||||
"eq": {"brightness": 0.03, "contrast": 1.08, "saturation": 1.05},
|
||||
"colorchannelmixer": {"rr": 0.9, "gg": 1.0, "bb": 1.12, "ra": 0, "ga": 0, "ba": 0, "aa": 1},
|
||||
},
|
||||
"high_contrast": {
|
||||
"eq": {"brightness": 0.0, "contrast": 1.3, "saturation": 1.2},
|
||||
"colorchannelmixer": {},
|
||||
},
|
||||
"soft_pastel": {
|
||||
"eq": {"brightness": 0.05, "contrast": 0.92, "saturation": 0.85},
|
||||
"colorchannelmixer": {"rr": 1.05, "gg": 1.03, "bb": 1.05, "ra": 0, "ga": 0, "ba": 0, "aa": 1},
|
||||
},
|
||||
"dramatic_cinematic": {
|
||||
"eq": {"brightness": -0.03, "contrast": 1.2, "saturation": 0.85},
|
||||
"colorchannelmixer": {"rr": 1.05, "gg": 0.98, "bb": 0.9, "ra": 0, "ga": 0, "ba": 0, "aa": 1},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def map_color_to_video_filter(color_filter: str) -> dict[str, Any]:
|
||||
"""color_filter 枚举 → FFmpeg eq/colorchannelmixer 参数字典(渲染端直接使用)。"""
|
||||
preset = COLOR_FILTER_PRESETS.get(color_filter) or COLOR_FILTER_PRESETS["none"]
|
||||
# 返回深拷贝防污染
|
||||
return json.loads(json.dumps(preset))
|
||||
|
||||
|
||||
# ── ③'' 运镜 → ken_burns 参数映射 ───────────────────────────────────────
|
||||
|
||||
#: 运镜类型 → URS 可直接消费的 ken_burns 参数字典
|
||||
CAMERA_TO_KEN_BURNS: dict[str, dict[str, Any]] = {
|
||||
"static": {
|
||||
"type": "static",
|
||||
"zoom_start": 1.0,
|
||||
"zoom_end": 1.0,
|
||||
"pan_x": 0.0,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"push_in": {
|
||||
"type": "zoom",
|
||||
"zoom_start": 1.0,
|
||||
"zoom_end": 1.12,
|
||||
"pan_x": 0.0,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"zoom_in": {
|
||||
"type": "zoom",
|
||||
"zoom_start": 1.0,
|
||||
"zoom_end": 1.18,
|
||||
"pan_x": 0.0,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"pull_out": {
|
||||
"type": "zoom",
|
||||
"zoom_start": 1.12,
|
||||
"zoom_end": 1.0,
|
||||
"pan_x": 0.0,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"zoom_out": {
|
||||
"type": "zoom",
|
||||
"zoom_start": 1.18,
|
||||
"zoom_end": 1.0,
|
||||
"pan_x": 0.0,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"pan_left": {
|
||||
"type": "pan",
|
||||
"zoom_start": 1.05,
|
||||
"zoom_end": 1.05,
|
||||
"pan_x": -0.08,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"pan_right": {
|
||||
"type": "pan",
|
||||
"zoom_start": 1.05,
|
||||
"zoom_end": 1.05,
|
||||
"pan_x": 0.08,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"tilt_up": {
|
||||
"type": "pan+zoom",
|
||||
"zoom_start": 1.08,
|
||||
"zoom_end": 1.14,
|
||||
"pan_x": 0.0,
|
||||
"pan_y": -0.05,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"tilt_down": {
|
||||
"type": "pan+zoom",
|
||||
"zoom_start": 1.14,
|
||||
"zoom_end": 1.08,
|
||||
"pan_x": 0.0,
|
||||
"pan_y": 0.05,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"track_left": {
|
||||
"type": "pan",
|
||||
"zoom_start": 1.05,
|
||||
"zoom_end": 1.05,
|
||||
"pan_x": -0.10,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
"track_right": {
|
||||
"type": "pan",
|
||||
"zoom_start": 1.05,
|
||||
"zoom_end": 1.05,
|
||||
"pan_x": 0.10,
|
||||
"pan_y": 0.0,
|
||||
"duration_factor": 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def map_camera_to_ken_burns(movement: str) -> dict[str, Any]:
|
||||
"""运镜类型 → URS ken_burns 参数字典。未知类型回退 static。"""
|
||||
preset = CAMERA_TO_KEN_BURNS.get(movement) or CAMERA_TO_KEN_BURNS["static"]
|
||||
return json.loads(json.dumps(preset))
|
||||
|
||||
|
||||
# ── 转场 → xfade transition 名称 ────────────────────────────────────────
|
||||
|
||||
TRANSITION_TO_XFADE: dict[str, str] = {
|
||||
"hard_cut": "cut",
|
||||
"cross_dissolve": "dissolve",
|
||||
"fade_black": "fadeblack",
|
||||
"fade": "fade",
|
||||
"zoom_whip": "zoom",
|
||||
"slide_left": "slideright", # 画面左移 = 新画面从右滑入
|
||||
"slide_right": "slideleft",
|
||||
"wipe_left": "wipeleft",
|
||||
"wipe_right": "wiperight",
|
||||
}
|
||||
|
||||
|
||||
def map_transition_to_xfade(transition_type: str) -> str:
|
||||
"""转场枚举 → TransitionEngine 支持的 xfade 名称;未知回退 cut。"""
|
||||
return TRANSITION_TO_XFADE.get(transition_type, "cut")
|
||||
|
||||
|
||||
# ── BPM → BGM 推荐 BPM ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def map_bgm_bpm(bpm: int) -> int:
|
||||
"""BGM 选曲 BPM:参考视频 BPM ±5。bpm=0 返回 90(默认 medium)。"""
|
||||
if bpm <= 0:
|
||||
return 90
|
||||
return max(60, min(bpm, 180))
|
||||
|
||||
|
||||
# ── 单 clip 渲染参数聚合(给 URS build_render_plan 使用) ───────────────
|
||||
|
||||
|
||||
def build_render_params_for_clip(
|
||||
clip_index: int,
|
||||
style_guide: dict[str, Any],
|
||||
*,
|
||||
duration_sec: Optional[float] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""根据 style_guide 为第 clip_index 个 clip 生成可直接喂给 URS 的渲染参数。"""
|
||||
shot_idx = clip_index + 1
|
||||
movements = style_guide.get("camera_movements") or []
|
||||
movement = "static"
|
||||
intensity = "low"
|
||||
for m in movements:
|
||||
if m.get("shot_index") == shot_idx:
|
||||
movement = m.get("movement", "static")
|
||||
intensity = m.get("intensity", "low")
|
||||
break
|
||||
ken = map_camera_to_ken_burns(movement)
|
||||
if intensity == "high":
|
||||
ken["zoom_end"] = round(ken.get("zoom_end", 1.0) * 1.08, 3)
|
||||
for k in ("pan_x", "pan_y"):
|
||||
ken[k] = round(ken.get(k, 0.0) * 1.3, 3)
|
||||
elif intensity == "low":
|
||||
for k in ("pan_x", "pan_y"):
|
||||
ken[k] = round(ken.get(k, 0.0) * 0.6, 3)
|
||||
transitions = style_guide.get("transitions") or []
|
||||
trans_type = "hard_cut"
|
||||
for t in transitions:
|
||||
pair = t.get("between_shot") or []
|
||||
if len(pair) >= 2 and pair[0] == shot_idx:
|
||||
trans_type = t.get("type", "hard_cut")
|
||||
break
|
||||
xfade = map_transition_to_xfade(trans_type)
|
||||
eq = map_color_to_video_filter(style_guide.get("color_filter", "none"))
|
||||
return {
|
||||
"ken_burns": ken,
|
||||
"transition": {"type": xfade, "duration": 0.3 if xfade != "cut" else 0.0},
|
||||
"video_filter": eq,
|
||||
"bgm_bpm_hint": map_bgm_bpm(int(style_guide.get("bpm") or 0)),
|
||||
"duration_sec": duration_sec,
|
||||
}
|
||||
|
||||
|
||||
# ── 素材本地化(URL/OSS key → 本地临时文件) ────────────────────────────
|
||||
|
||||
|
||||
def _ensure_local_video(reference: str, work_dir: Path) -> Optional[Path]:
|
||||
"""把 reference(URL/OSS key/本地路径)落到 work_dir 下的本地文件。"""
|
||||
p = Path(reference)
|
||||
if p.exists() and p.is_file():
|
||||
return p
|
||||
try:
|
||||
from video_processing.oss_helpers import download_asset
|
||||
|
||||
target = work_dir / f"ref_{uuid.uuid4().hex}.mp4"
|
||||
ok = download_asset(reference, target)
|
||||
if ok and target.exists() and target.stat().st_size > 0:
|
||||
return target
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("download_asset 失败,尝试 http 直连: %s", exc)
|
||||
if reference.startswith(("http://", "https://")):
|
||||
try:
|
||||
import httpx # noqa: PLC0415 - 项目依赖,延迟导入
|
||||
|
||||
target = work_dir / f"ref_{uuid.uuid4().hex}.mp4"
|
||||
with httpx.Client(timeout=20.0, follow_redirects=True) as client:
|
||||
with client.stream("GET", reference) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(target, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
|
||||
f.write(chunk)
|
||||
if target.exists() and target.stat().st_size > 0:
|
||||
return target
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("HTTP 下载参考视频失败: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ── 入口 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def analyze_video_style(
|
||||
reference_video_path: str | Path,
|
||||
style_strength: str = "medium",
|
||||
*,
|
||||
timeout_sec: int = DEFAULT_ANALYSIS_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
"""分析参考视频风格,返回 style_guide dict。
|
||||
|
||||
Args:
|
||||
reference_video_path: 本地路径、HTTP(S) URL 或 OSS storage key。
|
||||
style_strength: light | medium | strict。
|
||||
timeout_sec: 单步超时(秒),默认 60。
|
||||
|
||||
Returns:
|
||||
style_guide dict,详见 STYLE_GUIDE_SCHEMA。任何子步骤失败都会降级,不抛异常。
|
||||
"""
|
||||
style_strength = style_strength if style_strength in ("light", "medium", "strict") else "medium"
|
||||
frames_dir: Optional[Path] = None
|
||||
local_path: Optional[Path] = None
|
||||
try:
|
||||
frames_dir = Path(tempfile.mkdtemp(prefix="vstyle_"))
|
||||
work_dir = frames_dir # 同一临时根
|
||||
local_path = _ensure_local_video(str(reference_video_path), work_dir)
|
||||
if local_path is None:
|
||||
logger.error("[video_analyzer] 无法获取参考视频: %s", reference_video_path)
|
||||
return _rule_based_style_guide([], 0, {})
|
||||
# 资源约束:大小 / 时长
|
||||
try:
|
||||
size_mb = local_path.stat().st_size / (1024 * 1024)
|
||||
if size_mb > MAX_REFERENCE_SIZE_MB:
|
||||
logger.warning(
|
||||
"[video_analyzer] 参考视频 %.1fMB 超上限,按前 %ds 分析", size_mb, MAX_REFERENCE_DURATION_SEC
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
duration = _probe_duration(local_path)
|
||||
if duration > MAX_REFERENCE_DURATION_SEC:
|
||||
duration = MAX_REFERENCE_DURATION_SEC
|
||||
# ① 抽帧
|
||||
try:
|
||||
frame_paths = _extract_keyframes(local_path, frames_dir / "frames")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("FFmpeg 抽帧失败: %s,降级为 VLM 均匀采样", exc)
|
||||
frame_paths = []
|
||||
# ② 镜头分割
|
||||
shots = _detect_shots(local_path, frames_dir)
|
||||
# ③ 运镜检测(有帧才跑)
|
||||
if frame_paths or shots:
|
||||
_analyze_movements(local_path, shots)
|
||||
# ④ BPM
|
||||
bpm = _detect_bpm(local_path)
|
||||
# ⑤ 选帧→OSS→VLM
|
||||
sampled = _sample_frames(frame_paths, shots)
|
||||
image_urls = _upload_frames_to_oss(sampled) if sampled else []
|
||||
vlm = _vlm_analyze_frames(image_urls) if image_urls else {}
|
||||
# ⑥ 合成
|
||||
style_guide = _llm_synthesize(shots, bpm, vlm, style_strength)
|
||||
# 兜底字段校验
|
||||
style_guide.setdefault("style_strength", style_strength)
|
||||
style_guide.setdefault("pace", _pace_from_bpm(bpm))
|
||||
style_guide.setdefault("bpm", bpm)
|
||||
style_guide.setdefault("shot_count", len(shots))
|
||||
if shots and "avg_shot_duration" not in style_guide:
|
||||
durs = [s.end_sec - s.start_sec for s in shots]
|
||||
style_guide["avg_shot_duration"] = round(sum(durs) / len(durs), 2)
|
||||
return style_guide
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("[video_analyzer] 整体分析异常,返回最小占位 style_guide: %s", exc)
|
||||
return _rule_based_style_guide([], 0, {"mood": "未知"})
|
||||
finally:
|
||||
# 临时帧清理
|
||||
if frames_dir and frames_dir.exists():
|
||||
shutil.rmtree(frames_dir, ignore_errors=True)
|
||||
@@ -38,6 +38,7 @@ celery_app.conf.imports = (
|
||||
"worker_app.tasks.voice_extraction",
|
||||
"worker_app.tasks.voice_clone",
|
||||
"worker_app.tasks.tts_synthesis",
|
||||
"worker_app.tasks.viral_video", # #2039 爆款视频编排器(10步流水线)
|
||||
"worker_app.tasks.batch_download",
|
||||
"worker_app.tasks.duplication_check",
|
||||
# #1798 AI 数字人渲染:必须在 Worker 实例上注册同名任务,否则消息无人消费(渲染卡 0%)
|
||||
|
||||
Executable
+569
@@ -0,0 +1,569 @@
|
||||
"""爆款视频 Celery 编排器 — ViralVideoOrchestrator.
|
||||
|
||||
10 步流水线:
|
||||
1. 图片 VLM 分析
|
||||
1.5 [v1.3] 视频风格分析(如用户上传参考视频)
|
||||
2. 用户文案意图解析
|
||||
3. 文案融合生成
|
||||
4. 分镜脚本生成
|
||||
5. 合规审核(6 维度,不通过自动重写 1 次)
|
||||
6. CosyVoice 配音
|
||||
7. BGM 选择
|
||||
8. UnifiedRenderService 渲染
|
||||
9. 数字人口型(MuseTalk)
|
||||
10. OSS 上传 + 通知 + 扣点
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from celery import Task
|
||||
from celery.exceptions import Retry
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
)
|
||||
from packages.domain.viral_video import (
|
||||
CREDITS_VIRAL_VIDEO_COST,
|
||||
STAGE_LABELS,
|
||||
ViralVideoJob,
|
||||
ViralVideoStage,
|
||||
ViralVideoStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── WS 进度推送 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _emit_progress(job_id: str, stage: str, progress: float, message: str = "", data: dict | None = None):
|
||||
"""通过 Redis 发布进度事件,供 WebSocket 消费。"""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
|
||||
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
||||
r = redis_lib.from_url(redis_url)
|
||||
event = {
|
||||
"type": "viral_video:progress",
|
||||
"job_id": job_id,
|
||||
"stage": stage,
|
||||
"progress": progress,
|
||||
"message": message or STAGE_LABELS.get(stage, stage),
|
||||
"data": data or {},
|
||||
}
|
||||
r.publish(f"viral_video:{job_id}", str(event))
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] WS 进度推送失败: %s", e)
|
||||
|
||||
|
||||
# ── 仓储辅助 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_repo_and_job(job_id: str):
|
||||
"""获取 session, repo, job 三元组。"""
|
||||
session = SessionLocal()
|
||||
repo = SQLAlchemyViralVideoJobRepository(session)
|
||||
job = repo.get(job_id)
|
||||
return session, repo, job
|
||||
|
||||
|
||||
def _save_job(repo, job, session):
|
||||
"""持久化并关闭 session。"""
|
||||
repo.update(job)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ── 流水线各步骤 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _step_image_analysis(job: ViralVideoJob) -> dict:
|
||||
"""步骤 1: 图片 VLM 分析 — 识别产品特征、场景、卖点。"""
|
||||
try:
|
||||
from packages.shared.ai_service import call_vision
|
||||
except ImportError:
|
||||
logger.warning("[爆款视频] ai_service.call_vision 不可用,使用占位结果")
|
||||
return {"products": [{"name": "产品", "features": ["特征1", "特征2"], "scene": "通用场景"}]}
|
||||
|
||||
results = []
|
||||
for img_url in job.images:
|
||||
try:
|
||||
result = call_vision(
|
||||
image_url=img_url,
|
||||
prompt="请分析这张产品图片,识别:1)产品名称和类别 2)主要特征和卖点 3)适用场景 4)视觉风格。以JSON格式返回。",
|
||||
)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 图片分析失败 img=%s: %s", img_url, e)
|
||||
results.append({"name": "未识别", "features": [], "scene": "通用"})
|
||||
|
||||
return {"products": results}
|
||||
|
||||
|
||||
def _step_video_analysis(job: ViralVideoJob) -> dict | None:
|
||||
"""步骤 1.5 [v1.3]: 参考视频风格分析。"""
|
||||
if not job.reference_video_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 尝试导入 video_analyzer(由 #2051 提供,位于 apps/worker/viral_video/)
|
||||
from apps.worker.viral_video.video_analyzer import analyze_video_style
|
||||
|
||||
style_guide = analyze_video_style(
|
||||
job.reference_video_url,
|
||||
style_strength=job.style_strength or "medium",
|
||||
)
|
||||
return style_guide
|
||||
except ImportError as e:
|
||||
logger.info("[爆款视频] video_analyzer 模块未就绪(%s),使用占位风格分析", e)
|
||||
return {
|
||||
"pace": "medium",
|
||||
"color_filter": "none",
|
||||
"ken_burns_params": {"type": "static"},
|
||||
"transition_map": {},
|
||||
"video_filter_eq_params": {},
|
||||
"source": "placeholder",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 视频风格分析失败: %s", e)
|
||||
return {"error": str(e), "source": "failed"}
|
||||
|
||||
|
||||
def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
|
||||
"""步骤 2: 用户文案意图解析 — 理解用户想表达什么。"""
|
||||
try:
|
||||
from packages.shared.ai_service import call_llm
|
||||
except ImportError:
|
||||
return {"intent": "推广产品", "key_messages": ["产品亮点"], "tone": "专业"}
|
||||
|
||||
products_summary = ""
|
||||
for p in image_analysis.get("products", []):
|
||||
products_summary += f"- {p.get('name', '产品')}: {', '.join(p.get('features', []))}\n"
|
||||
|
||||
prompt = f"""你是一个营销文案策略师。请分析以下信息,理解用户的营销意图:
|
||||
|
||||
用户原始文案:{job.user_copy_text or "(未提供)"}
|
||||
行业:{job.industry or "未指定"}
|
||||
目标客户:{job.target_customer or "未指定"}
|
||||
营销目的:{job.marketing_purpose or "未指定"}
|
||||
产品信息:
|
||||
{products_summary}
|
||||
|
||||
请分析并返回JSON格式:
|
||||
1. intent: 核心营销意图(一句话)
|
||||
2. key_messages: 要传达的3-5个关键信息
|
||||
3. tone: 文案调性(如:专业/亲切/高端/活力)
|
||||
4. target_emotion: 希望触发的用户情感
|
||||
5. call_to_action: 行动号召建议"""
|
||||
|
||||
try:
|
||||
result = call_llm(prompt)
|
||||
return result if isinstance(result, dict) else {"raw": result}
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 意图解析失败: %s", e)
|
||||
return {"intent": "推广产品", "key_messages": ["产品亮点"], "tone": "专业"}
|
||||
|
||||
|
||||
def _step_copy_fusion(job: ViralVideoJob, intent: dict, image_analysis: dict) -> str:
|
||||
"""步骤 3: 文案融合生成 — 根据 fusion_level 融合用户文案和 AI 文案。"""
|
||||
try:
|
||||
from packages.shared.ai_service import call_llm
|
||||
except ImportError:
|
||||
return f"【{job.industry or '行业'}】优质产品,{job.target_customer or '您'}的不二之选!"
|
||||
|
||||
products_desc = ""
|
||||
for p in image_analysis.get("products", []):
|
||||
products_desc += f"{p.get('name', '产品')}({','.join(p.get('features', []))})\n"
|
||||
|
||||
if job.fusion_level == "ai_full":
|
||||
prompt = f"""请为以下产品撰写一段爆款短视频文案({job.duration}秒):
|
||||
产品:{products_desc}
|
||||
行业:{job.industry}
|
||||
目标客户:{job.target_customer}
|
||||
营销目的:{job.marketing_purpose}
|
||||
调性:{intent.get("tone", "专业")}
|
||||
关键信息:{", ".join(intent.get("key_messages", []))}
|
||||
|
||||
要求:吸引眼球、节奏紧凑、有行动号召。直接输出文案内容。"""
|
||||
elif job.fusion_level == "user_primary":
|
||||
prompt = f"""请基于用户原始文案进行润色优化,保留用户原意和风格:
|
||||
用户原文:{job.user_copy_text}
|
||||
产品信息:{products_desc}
|
||||
|
||||
要求:保留用户原意,仅修正表达和节奏。直接输出文案内容。"""
|
||||
else: # ai_polish (default)
|
||||
prompt = f"""请将用户文案与AI分析融合,生成一段优化后的爆款短视频文案({job.duration}秒):
|
||||
用户原文:{job.user_copy_text or "(未提供)"}
|
||||
产品分析:{products_desc}
|
||||
行业:{job.industry}
|
||||
目标客户:{job.target_customer}
|
||||
营销目的:{job.marketing_purpose}
|
||||
意图分析:{intent.get("intent", "")}
|
||||
调性:{intent.get("tone", "专业")}
|
||||
|
||||
要求:融合用户意图和产品卖点,节奏紧凑,适合短视频。直接输出文案内容。"""
|
||||
|
||||
try:
|
||||
result = call_llm(prompt)
|
||||
return result if isinstance(result, str) else str(result)
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 文案融合失败: %s", e)
|
||||
return job.user_copy_text or f"精选{job.industry or '行业'}好物,值得关注!"
|
||||
|
||||
|
||||
def _step_storyboard(job: ViralVideoJob, copy_text: str, image_analysis: dict) -> list[dict]:
|
||||
"""步骤 4: 分镜脚本生成。"""
|
||||
try:
|
||||
from packages.shared.ai_service import call_llm
|
||||
except ImportError:
|
||||
return [{"order": 0, "type": "product_shot", "text": copy_text[:50], "duration": job.duration}]
|
||||
|
||||
prompt = f"""请根据以下文案生成短视频分镜脚本:
|
||||
|
||||
文案内容:{copy_text}
|
||||
视频时长:{job.duration}秒
|
||||
风格强度:{job.style_strength}
|
||||
|
||||
请以JSON数组格式返回分镜列表,每个分镜包含:
|
||||
- order: 序号
|
||||
- type: 镜头类型(product_shot/text_card/scene_transition/closing)
|
||||
- description: 画面描述
|
||||
- text: 配音/字幕文本
|
||||
- duration: 时长(秒)
|
||||
- ken_burns: 运镜方式(zoom_in/zoom_out/pan_left/pan_right/none)
|
||||
- transition: 转场方式(cut/dissolve/wipe/fade)"""
|
||||
|
||||
try:
|
||||
result = call_llm(prompt)
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
# 尝试从字符串中解析 JSON
|
||||
import json
|
||||
|
||||
return (
|
||||
json.loads(result)
|
||||
if isinstance(result, str)
|
||||
else [{"order": 0, "text": copy_text, "duration": job.duration}]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 分镜生成失败: %s", e)
|
||||
return [{"order": 0, "type": "product_shot", "text": copy_text[:100], "duration": job.duration}]
|
||||
|
||||
|
||||
def _step_review(job: ViralVideoJob, copy_text: str, storyboard: list[dict]) -> dict:
|
||||
"""步骤 5: 合规审核(6 维度)。不通过时自动重写 1 次。"""
|
||||
dimensions = ["广告法合规", "平台规范", "内容真实性", "版权安全", "价值观", "风格一致性"]
|
||||
|
||||
try:
|
||||
from packages.shared.ai_service import call_llm
|
||||
except ImportError:
|
||||
return {"passed": True, "score": 90, "details": {d: "通过" for d in dimensions}}
|
||||
|
||||
prompt = f"""请对以下短视频内容进行合规审核,检查6个维度:{", ".join(dimensions)}
|
||||
|
||||
文案内容:{copy_text}
|
||||
分镜脚本:{storyboard[:3]}...
|
||||
行业:{job.industry}
|
||||
|
||||
请以JSON格式返回:
|
||||
- passed: bool(是否全部通过)
|
||||
- score: int(0-100分)
|
||||
- details: 各维度评分和说明
|
||||
- issues: 需要修改的问题列表(如有)"""
|
||||
|
||||
try:
|
||||
result = call_llm(prompt)
|
||||
return result if isinstance(result, dict) else {"passed": True, "score": 80, "details": {}}
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 合规审核失败: %s", e)
|
||||
return {"passed": True, "score": 75, "details": {d: "默认通过" for d in dimensions}}
|
||||
|
||||
|
||||
def _step_tts(job: ViralVideoJob, copy_text: str) -> str:
|
||||
"""步骤 6: CosyVoice 配音。"""
|
||||
try:
|
||||
from worker_app.services.tts_service_factory import get_tts_service
|
||||
|
||||
tts_service = get_tts_service()
|
||||
# 简化调用,实际需要更详细的参数
|
||||
audio_url = tts_service.synthesize(text=copy_text, voice_id=job.persona_id or "default")
|
||||
return audio_url
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] TTS 配音失败: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
def _step_bgm_select(job: ViralVideoJob) -> str:
|
||||
"""步骤 7: BGM 选择。"""
|
||||
# 基于 bgm_preference 和 marketing_purpose 匹配预设 BGM
|
||||
bgm_map = {
|
||||
"upbeat": "bgm_upbeat_01.mp3",
|
||||
"calm": "bgm_calm_01.mp3",
|
||||
"energetic": "bgm_energetic_01.mp3",
|
||||
"emotional": "bgm_emotional_01.mp3",
|
||||
}
|
||||
preference = job.bgm_preference.lower()
|
||||
for key, bgm in bgm_map.items():
|
||||
if key in preference:
|
||||
return bgm
|
||||
return "bgm_default.mp3"
|
||||
|
||||
|
||||
def _step_render(job: ViralVideoJob, storyboard: list[dict], audio_url: str, bgm: str) -> str:
|
||||
"""步骤 8: UnifiedRenderService 渲染。"""
|
||||
try:
|
||||
from video_processing.render_adapter import build_render_plan
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
render_plan = build_render_plan(
|
||||
images=job.images,
|
||||
storyboard=storyboard,
|
||||
audio_url=audio_url,
|
||||
bgm=bgm,
|
||||
duration=job.duration,
|
||||
style_guide=job.style_guide,
|
||||
)
|
||||
|
||||
render_svc = UnifiedRenderService()
|
||||
output_path = render_svc.render(render_plan)
|
||||
return output_path
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 渲染失败: %s", e, exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def _step_musetalk(job: ViralVideoJob, video_path: str) -> str:
|
||||
"""步骤 9: 数字人口型(MuseTalk)。"""
|
||||
# MuseTalk 集成由现有 GPU worker 处理
|
||||
# 这里调用现有接口
|
||||
try:
|
||||
# 如果不需要数字人,直接跳过
|
||||
if not job.persona_id:
|
||||
return video_path
|
||||
|
||||
# 调用 GPU worker 的 MuseTalk 接口
|
||||
import requests
|
||||
|
||||
gpu_worker_url = os.environ.get("GPU_WORKER_URL", "http://localhost:8900")
|
||||
resp = requests.post(
|
||||
f"{gpu_worker_url}/api/v1/gpu/lipsync",
|
||||
json={
|
||||
"video_path": video_path,
|
||||
"audio_path": job.reference_audio_path,
|
||||
"persona_id": job.persona_id,
|
||||
},
|
||||
timeout=300,
|
||||
)
|
||||
if resp.ok:
|
||||
result = resp.json()
|
||||
return result.get("output_path", video_path)
|
||||
return video_path
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] MuseTalk 处理失败,使用原始视频: %s", e)
|
||||
return video_path
|
||||
|
||||
|
||||
def _step_upload(job: ViralVideoJob, video_path: str) -> str:
|
||||
"""步骤 10: OSS 上传 + 扣点。"""
|
||||
try:
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
video_url = upload_to_oss(video_path, prefix="viral-video/")
|
||||
return video_url
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] OSS 上传失败: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
# ── 主编排器 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=2, name="worker.run_viral_video_pipeline")
|
||||
def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
"""爆款视频 10 步流水线编排器。"""
|
||||
session = None
|
||||
try:
|
||||
session, repo, job = _get_repo_and_job(job_id)
|
||||
if job is None:
|
||||
logger.error("[爆款视频] 任务不存在: %s", job_id)
|
||||
return {"ok": False, "error": "job not found"}
|
||||
|
||||
# 标记运行中
|
||||
job.mark_running()
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析")
|
||||
|
||||
# ── Step 1: 图片 VLM 分析 ──
|
||||
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 10.0, "正在分析产品图片...")
|
||||
image_analysis = _step_image_analysis(job)
|
||||
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 15.0, "图片分析完成", {"result": image_analysis})
|
||||
|
||||
# ── Step 1.5: 视频风格分析(v1.3) ──
|
||||
if job.reference_video_url or job.style_template_id:
|
||||
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 20.0, "正在分析参考视频风格...")
|
||||
style_guide = _step_video_analysis(job)
|
||||
job.style_guide = style_guide
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(
|
||||
job_id,
|
||||
ViralVideoStage.VIDEO_ANALYSIS,
|
||||
25.0,
|
||||
"风格分析完成",
|
||||
{"style_analyzed": True, "style_guide": style_guide},
|
||||
)
|
||||
else:
|
||||
style_guide = None
|
||||
|
||||
# ── Step 2: 意图解析 ──
|
||||
_emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 30.0, "正在解析文案意图...")
|
||||
intent_result = _step_intent_parsing(job, image_analysis)
|
||||
|
||||
# 进入等待用户确认状态
|
||||
job.mark_wait_user_confirm(intent_result)
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(
|
||||
job_id,
|
||||
ViralVideoStage.INTENT_PARSING,
|
||||
35.0,
|
||||
"意图解析完成,等待用户确认",
|
||||
{"intent_result": intent_result, "waiting_confirm": True},
|
||||
)
|
||||
|
||||
# 这里流水线暂停,等待 confirm-intent API 调用 resume
|
||||
# resume 后由 resume_viral_video_pipeline 继续
|
||||
return {"ok": True, "job_id": job_id, "status": "wait_user_confirm", "intent_result": intent_result}
|
||||
|
||||
except Retry:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 流水线异常: %s", e, exc_info=True)
|
||||
if session:
|
||||
try:
|
||||
_, repo, job = _get_repo_and_job(job_id)
|
||||
if job and not job.is_terminal:
|
||||
job.mark_failed(str(e))
|
||||
_save_job(repo, job, session)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=2, name="worker.resume_viral_video_pipeline")
|
||||
def resume_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
"""用户确认意图后,从断点恢复流水线(步骤 3-10)。"""
|
||||
session = None
|
||||
try:
|
||||
session, repo, job = _get_repo_and_job(job_id)
|
||||
if job is None:
|
||||
return {"ok": False, "error": "job not found"}
|
||||
|
||||
if job.status != ViralVideoStatus.RUNNING:
|
||||
return {"ok": False, "error": f"unexpected status: {job.status}"}
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.COPY_FUSION, 40.0, "正在融合文案...")
|
||||
|
||||
# ── Step 3: 文案融合 ──
|
||||
copy_text = _step_copy_fusion(job, job.intent_result or {}, {"products": []})
|
||||
_emit_progress(job_id, ViralVideoStage.COPY_FUSION, 50.0, "文案融合完成")
|
||||
|
||||
# ── Step 4: 分镜脚本 ──
|
||||
_emit_progress(job_id, ViralVideoStage.STORYBOARD, 55.0, "正在生成分镜脚本...")
|
||||
storyboard = _step_storyboard(job, copy_text, {})
|
||||
_emit_progress(job_id, ViralVideoStage.STORYBOARD, 60.0, "分镜脚本完成")
|
||||
|
||||
# ── Step 5: 合规审核 ──
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 65.0, "正在进行合规审核...")
|
||||
review_result = _step_review(job, copy_text, storyboard)
|
||||
if not review_result.get("passed", True):
|
||||
# 自动重写 1 次
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
|
||||
copy_text = _step_copy_fusion(job, job.intent_result or {}, {"products": []})
|
||||
review_result = _step_review(job, copy_text, storyboard)
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 70.0, "合规审核完成")
|
||||
|
||||
# ── Step 6: CosyVoice 配音 ──
|
||||
_emit_progress(job_id, ViralVideoStage.TTS, 72.0, "正在生成配音...")
|
||||
audio_url = _step_tts(job, copy_text)
|
||||
_emit_progress(job_id, ViralVideoStage.TTS, 75.0, "配音完成")
|
||||
|
||||
# ── Step 7: BGM 选择 ──
|
||||
_emit_progress(job_id, ViralVideoStage.BGM_SELECT, 77.0, "正在选择BGM...")
|
||||
bgm = _step_bgm_select(job)
|
||||
_emit_progress(job_id, ViralVideoStage.BGM_SELECT, 78.0, "BGM选择完成")
|
||||
|
||||
# ── Step 8: 渲染 ──
|
||||
_emit_progress(job_id, ViralVideoStage.RENDERING, 80.0, "正在渲染视频...")
|
||||
video_path = _step_render(job, storyboard, audio_url, bgm)
|
||||
_emit_progress(job_id, ViralVideoStage.RENDERING, 88.0, "渲染完成")
|
||||
|
||||
# ── Step 9: MuseTalk 数字人口型 ──
|
||||
_emit_progress(job_id, ViralVideoStage.MUSETALK, 90.0, "正在处理数字人口型...")
|
||||
final_video_path = _step_musetalk(job, video_path)
|
||||
_emit_progress(job_id, ViralVideoStage.MUSETALK, 93.0, "数字人处理完成")
|
||||
|
||||
# ── Step 10: OSS 上传 + 扣点 ──
|
||||
_emit_progress(job_id, ViralVideoStage.UPLOADING, 95.0, "正在上传视频...")
|
||||
video_url = _step_upload(job, final_video_path)
|
||||
|
||||
# 扣点
|
||||
job.credits_cost = CREDITS_VIRAL_VIDEO_COST
|
||||
# TODO: 调用 credits.deduct() 实际扣点
|
||||
|
||||
# 完成
|
||||
job.mark_completed(video_url)
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(job_id, ViralVideoStage.UPLOADING, 100.0, "视频生成完成!", {"video_url": video_url})
|
||||
|
||||
logger.info("[爆款视频] 任务完成: job_id=%s video_url=%s", job_id, video_url)
|
||||
return {"ok": True, "job_id": job_id, "video_url": video_url}
|
||||
|
||||
except Retry:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 恢复流水线异常: %s", e, exc_info=True)
|
||||
if session:
|
||||
try:
|
||||
_, repo, job = _get_repo_and_job(job_id)
|
||||
if job and not job.is_terminal:
|
||||
job.mark_failed(str(e))
|
||||
_save_job(repo, job, session)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=1, name="worker.run_video_style_analysis")
|
||||
def run_video_style_analysis(self: Task, job_id: str) -> dict:
|
||||
"""独立的视频风格分析任务(v1.3)。"""
|
||||
session = None
|
||||
try:
|
||||
session, repo, job = _get_repo_and_job(job_id)
|
||||
if job is None:
|
||||
return {"ok": False, "error": "job not found"}
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 10.0, "正在分析参考视频风格...")
|
||||
style_guide = _step_video_analysis(job)
|
||||
job.style_guide = style_guide
|
||||
_save_job(repo, job, session)
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 100.0, "风格分析完成", {"style_guide": style_guide})
|
||||
return {"ok": True, "job_id": job_id, "style_guide": style_guide}
|
||||
|
||||
except Retry:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 风格分析失败: %s", e)
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if session:
|
||||
session.close()
|
||||
@@ -918,3 +918,71 @@ class GpuWorkerModel(Base):
|
||||
capabilities = Column(String(500), nullable=False, default="") # 逗号分隔,如 "musetalk"
|
||||
last_heartbeat_at = Column(DateTime, nullable=True, index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ViralVideoJobModel(Base):
|
||||
"""爆款视频任务"""
|
||||
|
||||
__tablename__ = "viral_video_jobs"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
images = Column(JSON, nullable=False, default=list) # 产品图片 URL 列表
|
||||
industry = Column(String(100), nullable=False, default="")
|
||||
target_customer = Column(String(500), nullable=False, default="")
|
||||
persona_id = Column(String(36), nullable=False, default="")
|
||||
viral_structure = Column(String(50), nullable=False, default="")
|
||||
marketing_purpose = Column(String(100), nullable=False, default="")
|
||||
bgm_preference = Column(String(50), nullable=False, default="")
|
||||
duration = Column(Integer, nullable=False, default=30)
|
||||
user_copy_text = Column(Text, nullable=False, default="")
|
||||
fusion_level = Column(String(20), nullable=False, default="ai_polish")
|
||||
reference_audio_path = Column(String(1000), nullable=False, default="")
|
||||
# v1.3 新增字段
|
||||
reference_video_url = Column(String(1000), nullable=False, default="")
|
||||
style_strength = Column(String(20), nullable=False, default="medium")
|
||||
style_guide = Column(JSON, nullable=True)
|
||||
style_template_id = Column(String(36), nullable=False, default="", index=True)
|
||||
# 结果与状态
|
||||
status = Column(String(30), nullable=False, default="pending", index=True)
|
||||
intent_result = Column(JSON, nullable=True)
|
||||
result_video_url = Column(String(1000), nullable=False, default="")
|
||||
credits_cost = Column(Integer, nullable=False, default=0)
|
||||
error_msg = Column(Text, nullable=False, default="")
|
||||
retry_count = Column(Integer, nullable=False, default=0)
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ViralVideoStyleTemplateModel(Base):
|
||||
"""爆款视频风格模板配置表"""
|
||||
|
||||
__tablename__ = "viral_video_style_templates"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=False, default="")
|
||||
thumbnail_url = Column(String(1000), nullable=False, default="")
|
||||
style_config = Column(JSON, nullable=False, default=dict)
|
||||
is_system = Column(Boolean, nullable=False, default=True, index=True)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ViralVideoPromptTemplateModel(Base):
|
||||
"""爆款视频 Prompt 模板表(由 #2040 seed)"""
|
||||
|
||||
__tablename__ = "viral_video_prompt_templates"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
prompt_type = Column(String(50), nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=False, default="")
|
||||
variables = Column(JSON, nullable=False, default=list)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
"""爆款视频任务 SQLAlchemy 仓储实现。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
ViralVideoJobModel,
|
||||
ViralVideoPromptTemplateModel,
|
||||
ViralVideoStyleTemplateModel,
|
||||
)
|
||||
from packages.domain.viral_video import ViralVideoJob, ViralVideoStatus
|
||||
|
||||
|
||||
def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
"""ORM → 领域实体。"""
|
||||
return ViralVideoJob(
|
||||
id=model.id,
|
||||
user_id=model.user_id,
|
||||
images=list(model.images or []),
|
||||
industry=model.industry or "",
|
||||
target_customer=model.target_customer or "",
|
||||
persona_id=model.persona_id or "",
|
||||
viral_structure=model.viral_structure or "",
|
||||
marketing_purpose=model.marketing_purpose or "",
|
||||
bgm_preference=model.bgm_preference or "",
|
||||
duration=model.duration or 30,
|
||||
user_copy_text=model.user_copy_text or "",
|
||||
fusion_level=model.fusion_level or "ai_polish",
|
||||
reference_audio_path=model.reference_audio_path or "",
|
||||
reference_video_url=getattr(model, "reference_video_url", "") or "",
|
||||
style_strength=getattr(model, "style_strength", "medium") or "medium",
|
||||
style_guide=dict(model.style_guide) if model.style_guide else None,
|
||||
style_template_id=getattr(model, "style_template_id", "") or "",
|
||||
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
|
||||
intent_result=dict(model.intent_result) if model.intent_result else None,
|
||||
result_video_url=model.result_video_url or "",
|
||||
credits_cost=model.credits_cost or 0,
|
||||
error_msg=model.error_msg or "",
|
||||
retry_count=model.retry_count or 0,
|
||||
started_at=model.started_at,
|
||||
completed_at=model.completed_at,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class SQLAlchemyViralVideoJobRepository:
|
||||
"""爆款视频任务仓储。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def save(self, job: ViralVideoJob) -> ViralVideoJob:
|
||||
model = ViralVideoJobModel(
|
||||
id=job.id,
|
||||
user_id=job.user_id,
|
||||
images=job.images,
|
||||
industry=job.industry,
|
||||
target_customer=job.target_customer,
|
||||
persona_id=job.persona_id,
|
||||
viral_structure=job.viral_structure,
|
||||
marketing_purpose=job.marketing_purpose,
|
||||
bgm_preference=job.bgm_preference,
|
||||
duration=job.duration,
|
||||
user_copy_text=job.user_copy_text,
|
||||
fusion_level=job.fusion_level,
|
||||
reference_audio_path=job.reference_audio_path,
|
||||
reference_video_url=job.reference_video_url,
|
||||
style_strength=job.style_strength,
|
||||
style_guide=job.style_guide,
|
||||
style_template_id=job.style_template_id,
|
||||
status=job.status,
|
||||
intent_result=job.intent_result,
|
||||
result_video_url=job.result_video_url,
|
||||
credits_cost=job.credits_cost,
|
||||
error_msg=job.error_msg,
|
||||
retry_count=job.retry_count,
|
||||
started_at=job.started_at,
|
||||
completed_at=job.completed_at,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return job
|
||||
|
||||
def update(self, job: ViralVideoJob) -> None:
|
||||
model = self.session.query(ViralVideoJobModel).filter(ViralVideoJobModel.id == job.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"ViralVideoJob {job.id} not found")
|
||||
model.status = job.status
|
||||
model.intent_result = job.intent_result
|
||||
model.result_video_url = job.result_video_url
|
||||
model.credits_cost = job.credits_cost
|
||||
model.error_msg = job.error_msg
|
||||
model.retry_count = job.retry_count
|
||||
model.started_at = job.started_at
|
||||
model.completed_at = job.completed_at
|
||||
model.style_guide = job.style_guide
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
|
||||
def get(self, job_id: str) -> ViralVideoJob | None:
|
||||
model = self.session.query(ViralVideoJobModel).filter(ViralVideoJobModel.id == job_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return _to_domain(model)
|
||||
|
||||
def list_by_user(self, user_id: str, limit: int = 50, offset: int = 0) -> list[ViralVideoJob]:
|
||||
models = (
|
||||
self.session.query(ViralVideoJobModel)
|
||||
.filter(ViralVideoJobModel.user_id == user_id)
|
||||
.order_by(ViralVideoJobModel.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [_to_domain(m) for m in models]
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return (
|
||||
self.session.query(ViralVideoJobModel)
|
||||
.filter(
|
||||
ViralVideoJobModel.user_id == user_id,
|
||||
ViralVideoJobModel.status.in_(["pending", "running", "wait_user_confirm"]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
class SQLAlchemyViralVideoStyleTemplateRepository:
|
||||
"""风格模板仓储。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def list_all(self) -> list[dict]:
|
||||
models = (
|
||||
self.session.query(ViralVideoStyleTemplateModel)
|
||||
.order_by(ViralVideoStyleTemplateModel.sort_order.asc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"description": m.description or "",
|
||||
"thumbnail_url": m.thumbnail_url or "",
|
||||
"style_config": dict(m.style_config) if m.style_config else {},
|
||||
"is_system": m.is_system,
|
||||
}
|
||||
for m in models
|
||||
]
|
||||
|
||||
def get(self, template_id: str) -> dict | None:
|
||||
model = (
|
||||
self.session.query(ViralVideoStyleTemplateModel)
|
||||
.filter(ViralVideoStyleTemplateModel.id == template_id)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"description": model.description or "",
|
||||
"thumbnail_url": model.thumbnail_url or "",
|
||||
"style_config": dict(model.style_config) if model.style_config else {},
|
||||
"is_system": model.is_system,
|
||||
}
|
||||
|
||||
|
||||
class SQLAlchemyViralVideoPromptTemplateRepository:
|
||||
"""Prompt 模板仓储(由 #2040 seed,这里只读取)。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def get_active_by_type(self, prompt_type: str) -> dict | None:
|
||||
model = (
|
||||
self.session.query(ViralVideoPromptTemplateModel)
|
||||
.filter(
|
||||
ViralVideoPromptTemplateModel.prompt_type == prompt_type,
|
||||
ViralVideoPromptTemplateModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(ViralVideoPromptTemplateModel.version.desc())
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return {
|
||||
"id": model.id,
|
||||
"prompt_type": model.prompt_type,
|
||||
"name": model.name,
|
||||
"content": model.content,
|
||||
"variables": list(model.variables or []),
|
||||
"version": model.version,
|
||||
}
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
"""ViralVideoJob 领域模型 — 爆款视频任务.
|
||||
|
||||
状态机:
|
||||
pending → running → completed
|
||||
↘ failed → pending (retry)
|
||||
↘ cancelled
|
||||
running 中可暂停:running → wait_user_confirm → running (confirm-intent resume)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from enum import StrEnum
|
||||
else:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
pass
|
||||
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class ViralVideoStatus(StrEnum):
|
||||
"""爆款视频任务状态枚举。"""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
WAIT_USER_CONFIRM = "wait_user_confirm"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ViralVideoStage(StrEnum):
|
||||
"""编排流水线阶段枚举(用于 WS 进度推送)。"""
|
||||
|
||||
IMAGE_ANALYSIS = "image_analysis"
|
||||
VIDEO_ANALYSIS = "video_analysis"
|
||||
INTENT_PARSING = "intent_parsing"
|
||||
COPY_FUSION = "copy_fusion"
|
||||
STORYBOARD = "storyboard"
|
||||
REVIEW = "review"
|
||||
TTS = "tts"
|
||||
BGM_SELECT = "bgm_select"
|
||||
RENDERING = "rendering"
|
||||
MUSETALK = "musetalk"
|
||||
UPLOADING = "uploading"
|
||||
|
||||
|
||||
class FusionLevel(StrEnum):
|
||||
"""文案融合级别。"""
|
||||
|
||||
AI_FULL = "ai_full"
|
||||
AI_POLISH = "ai_polish"
|
||||
USER_PRIMARY = "user_primary"
|
||||
|
||||
|
||||
class StyleStrength(StrEnum):
|
||||
"""风格强度。"""
|
||||
|
||||
LIGHT = "light"
|
||||
MEDIUM = "medium"
|
||||
STRICT = "strict"
|
||||
|
||||
|
||||
class PromptType(StrEnum):
|
||||
"""Prompt 模板类型(与 #2040 seed 对齐)。"""
|
||||
|
||||
IMAGE_ANALYSIS = "image_analysis"
|
||||
INTENT_PARSING = "intent_parsing"
|
||||
COPY_FUSION = "copy_fusion"
|
||||
STORYBOARD = "storyboard"
|
||||
REVIEW = "review"
|
||||
VIDEO_STYLE_INTEGRATION = "video_style_integration"
|
||||
STYLE_CONSTRAINT = "style_constraint"
|
||||
|
||||
|
||||
CREDITS_VIRAL_VIDEO_COST = 50
|
||||
|
||||
STAGE_LABELS = {
|
||||
ViralVideoStage.IMAGE_ANALYSIS: "图片分析",
|
||||
ViralVideoStage.VIDEO_ANALYSIS: "视频风格分析",
|
||||
ViralVideoStage.INTENT_PARSING: "意图解析",
|
||||
ViralVideoStage.COPY_FUSION: "文案融合",
|
||||
ViralVideoStage.STORYBOARD: "分镜脚本",
|
||||
ViralVideoStage.REVIEW: "合规审核",
|
||||
ViralVideoStage.TTS: "AI 配音",
|
||||
ViralVideoStage.BGM_SELECT: "BGM 选择",
|
||||
ViralVideoStage.RENDERING: "视频渲染",
|
||||
ViralVideoStage.MUSETALK: "数字人口型",
|
||||
ViralVideoStage.UPLOADING: "上传发布",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ViralVideoJob:
|
||||
"""爆款视频任务领域实体。"""
|
||||
|
||||
user_id: str
|
||||
images: list[str] = field(default_factory=list)
|
||||
industry: str = ""
|
||||
target_customer: str = ""
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 30
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = FusionLevel.AI_POLISH
|
||||
reference_audio_path: str = ""
|
||||
# v1.3
|
||||
reference_video_url: str = ""
|
||||
style_strength: str = StyleStrength.MEDIUM
|
||||
style_guide: dict | None = None
|
||||
style_template_id: str = ""
|
||||
# 状态
|
||||
id: str = field(default_factory=lambda: uuid4().hex)
|
||||
status: ViralVideoStatus = ViralVideoStatus.PENDING
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
credits_cost: int = 0
|
||||
error_msg: str = ""
|
||||
retry_count: int = 0
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# ── 状态转换 ──
|
||||
|
||||
def mark_running(self) -> None:
|
||||
if self.status not in (ViralVideoStatus.PENDING, ViralVideoStatus.RUNNING):
|
||||
raise ValueError(f"Cannot transition from {self.status} to running")
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
self.started_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_wait_user_confirm(self, intent_result: dict) -> None:
|
||||
if self.status != ViralVideoStatus.RUNNING:
|
||||
raise ValueError(f"Cannot transition from {self.status} to wait_user_confirm")
|
||||
self.status = ViralVideoStatus.WAIT_USER_CONFIRM
|
||||
self.intent_result = intent_result
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_from_confirm(self) -> None:
|
||||
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
|
||||
raise ValueError(f"Cannot resume from {self.status}")
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_completed(self, video_url: str) -> None:
|
||||
self.status = ViralVideoStatus.COMPLETED
|
||||
self.result_video_url = video_url
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_failed(self, error_msg: str) -> None:
|
||||
self.status = ViralVideoStatus.FAILED
|
||||
self.error_msg = error_msg
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_cancelled(self) -> None:
|
||||
if self.status in (ViralVideoStatus.COMPLETED, ViralVideoStatus.FAILED, ViralVideoStatus.CANCELLED):
|
||||
raise ValueError(f"Cannot cancel task in {self.status} status")
|
||||
self.status = ViralVideoStatus.CANCELLED
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return self.status in (
|
||||
ViralVideoStatus.COMPLETED,
|
||||
ViralVideoStatus.FAILED,
|
||||
ViralVideoStatus.CANCELLED,
|
||||
)
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
"""爆款视频任务仓储接口。"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
|
||||
class ViralVideoJobRepository(ABC):
|
||||
"""爆款视频任务仓储抽象。"""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, job: ViralVideoJob) -> None:
|
||||
"""保存(新建)任务。"""
|
||||
|
||||
@abstractmethod
|
||||
def update(self, job: ViralVideoJob) -> None:
|
||||
"""更新任务。"""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, job_id: str) -> Optional[ViralVideoJob]:
|
||||
"""按 ID 获取任务。"""
|
||||
|
||||
@abstractmethod
|
||||
def list_by_user(self, user_id: str, limit: int = 50, offset: int = 0) -> list[ViralVideoJob]:
|
||||
"""获取用户的历史任务列表。"""
|
||||
|
||||
@abstractmethod
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
"""统计用户待处理任务数。"""
|
||||
@@ -233,9 +233,7 @@ def _call_ai_recommend_service(
|
||||
has_analysis = any(aid in asset_analyses for aid in asset_ids[:30])
|
||||
|
||||
# 构建 prompt
|
||||
system_prompt = (
|
||||
"你是一个专业的视频剪辑导演助手。" "根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
|
||||
)
|
||||
system_prompt = "你是一个专业的视频剪辑导演助手。根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
|
||||
if has_analysis:
|
||||
system_prompt += (
|
||||
"每个素材附带了 AI 视频理解的内容描述,请根据素材的实际内容来决策编排:\n"
|
||||
@@ -493,3 +491,48 @@ def run_generate_cover(
|
||||
result.get("image_url", "")[:60],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── 通用 LLM / Vision 调用(#2039 ViralVideoOrchestrator 使用,复用现有豆包客户端)──
|
||||
|
||||
|
||||
def call_llm(prompt: str, temperature: float = 0.7) -> object:
|
||||
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
return None
|
||||
messages = [
|
||||
{"role": "system", "content": "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=4096)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return raw
|
||||
|
||||
|
||||
def call_vision(image_url: str, prompt: str) -> object:
|
||||
"""调用豆包视觉大模型分析图片,返回解析后的 JSON 或原文字符串;失败返回 None。"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
return None
|
||||
messages = [
|
||||
{"role": "system", "content": "你是专业的视觉分析师。需要结构化输出时请严格使用 JSON。"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": image_url}},
|
||||
],
|
||||
},
|
||||
]
|
||||
raw = client.chat_completion(messages, temperature=0.3, max_tokens=2048)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return raw
|
||||
|
||||
@@ -15,3 +15,8 @@ Pillow==10.4.0
|
||||
|
||||
# FFmpeg Python 绑定
|
||||
ffmpeg-python==0.2.0
|
||||
|
||||
# v1.3 参考视频风格分析(#2051)
|
||||
scenedetect==0.6.4
|
||||
librosa==0.10.2.post1
|
||||
soundfile==0.12.1
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""video_analyzer(#2051)单元测试。
|
||||
|
||||
覆盖:
|
||||
- 映射函数(运镜→ken_burns、转场→xfade、色调→video_filter、BPM→BGM)
|
||||
- schema 常量与导出
|
||||
- Farneback 光流运镜判定(合成光流场)
|
||||
- BPM 档位映射
|
||||
- 降级路径(ffmpeg/cv2/librosa 不可用)
|
||||
- 临时目录清理
|
||||
- analyze_video_style 入口在无素材时返回最小 style_guide 不抛
|
||||
- build_render_params_for_clip 聚合输出
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from apps.worker.viral_video.video_analyzer import ( # noqa: E402
|
||||
COLOR_FILTER_PRESETS,
|
||||
DEFAULT_ANALYSIS_TIMEOUT,
|
||||
MAX_REFERENCE_DURATION_SEC,
|
||||
MAX_REFERENCE_SIZE_MB,
|
||||
STYLE_GUIDE_SCHEMA,
|
||||
TRANSITION_TO_XFADE,
|
||||
ShotBoundary,
|
||||
_detect_camera_movement,
|
||||
_pace_from_bpm,
|
||||
_rule_based_style_guide,
|
||||
analyze_video_style,
|
||||
build_render_params_for_clip,
|
||||
map_bgm_bpm,
|
||||
map_camera_to_ken_burns,
|
||||
map_color_to_video_filter,
|
||||
map_transition_to_xfade,
|
||||
)
|
||||
|
||||
# ── 映射函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def test_constants_exported():
|
||||
assert MAX_REFERENCE_DURATION_SEC == 60
|
||||
assert MAX_REFERENCE_SIZE_MB == 100
|
||||
assert DEFAULT_ANALYSIS_TIMEOUT == 60
|
||||
assert "style_name" in STYLE_GUIDE_SCHEMA
|
||||
assert "ken_burns_params" in STYLE_GUIDE_SCHEMA
|
||||
assert "video_filter_eq_params" in STYLE_GUIDE_SCHEMA
|
||||
|
||||
|
||||
# ── 运镜→ken_burns ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"movement,expected_type",
|
||||
[
|
||||
("static", "static"),
|
||||
("push_in", "zoom"),
|
||||
("zoom_in", "zoom"),
|
||||
("pull_out", "zoom"),
|
||||
("pan_left", "pan"),
|
||||
("pan_right", "pan"),
|
||||
("tilt_up", "pan+zoom"),
|
||||
("track_left", "pan"),
|
||||
],
|
||||
)
|
||||
def test_map_camera_to_ken_burns_types(movement, expected_type):
|
||||
kb = map_camera_to_ken_burns(movement)
|
||||
assert kb["type"] == expected_type
|
||||
# zoom/pan 类必须有 zoom_start/zoom_end
|
||||
assert 0.8 <= kb["zoom_start"] <= 1.3
|
||||
assert 0.8 <= kb["zoom_end"] <= 1.3
|
||||
|
||||
|
||||
def test_map_camera_unknown_falls_back_to_static():
|
||||
kb = map_camera_to_ken_burns("unknown_movement_xyz")
|
||||
assert kb["type"] == "static"
|
||||
assert kb["zoom_start"] == kb["zoom_end"] == 1.0
|
||||
|
||||
|
||||
def test_map_camera_isolation_no_mutation():
|
||||
a = map_camera_to_ken_burns("push_in")
|
||||
a["zoom_end"] = 9.99
|
||||
b = map_camera_to_ken_burns("push_in")
|
||||
assert b["zoom_end"] != 9.99
|
||||
|
||||
|
||||
# ── 转场→xfade ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ttype,expected",
|
||||
[
|
||||
("hard_cut", "cut"),
|
||||
("cross_dissolve", "dissolve"),
|
||||
("fade", "fade"),
|
||||
("fade_black", "fadeblack"),
|
||||
("zoom_whip", "zoom"),
|
||||
("wipe_left", "wipeleft"),
|
||||
("slide_right", "slideleft"),
|
||||
],
|
||||
)
|
||||
def test_map_transition(ttype, expected):
|
||||
assert map_transition_to_xfade(ttype) == expected
|
||||
|
||||
|
||||
def test_map_transition_unknown_falls_back_to_cut():
|
||||
assert map_transition_to_xfade("some_random_transition") == "cut"
|
||||
|
||||
|
||||
# ── 色调→video_filter ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["none", "warm_vintage", "cool_fresh", "high_contrast", "soft_pastel", "dramatic_cinematic"]
|
||||
)
|
||||
def test_map_color_presets_available(name):
|
||||
p = map_color_to_video_filter(name)
|
||||
assert isinstance(p, dict)
|
||||
# 所有预设必须能被 FFmpeg eq/colorchannelmixer 消费:eq 是 dict,ccm 是 dict
|
||||
assert "eq" in p or p == {} or "colorchannelmixer" in p
|
||||
|
||||
|
||||
def test_map_color_unknown_is_none_preset():
|
||||
p = map_color_to_video_filter("not_a_real_filter")
|
||||
assert p == {}
|
||||
|
||||
|
||||
def test_map_color_isolation():
|
||||
a = map_color_to_video_filter("warm_vintage")
|
||||
a["eq"]["brightness"] = 9.99
|
||||
b = map_color_to_video_filter("warm_vintage")
|
||||
assert b["eq"]["brightness"] != 9.99
|
||||
|
||||
|
||||
# ── BPM → BGM ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bpm,expected",
|
||||
[
|
||||
(0, 90),
|
||||
(70, 70),
|
||||
(120, 120),
|
||||
(200, 180),
|
||||
(30, 60),
|
||||
],
|
||||
)
|
||||
def test_map_bgm_bpm(bpm, expected):
|
||||
assert map_bgm_bpm(bpm) == expected
|
||||
|
||||
|
||||
def test_pace_from_bpm_buckets():
|
||||
assert _pace_from_bpm(120) == "fast_cut"
|
||||
assert _pace_from_bpm(110) == "fast_cut"
|
||||
assert _pace_from_bpm(90) == "medium"
|
||||
assert _pace_from_bpm(80) == "medium"
|
||||
assert _pace_from_bpm(60) == "slow_cinematic"
|
||||
assert _pace_from_bpm(0) == "medium"
|
||||
|
||||
|
||||
# ── Farneback 光流→运镜(合成光流) ───────────────────────────────────
|
||||
|
||||
|
||||
def _make_flow(dx: float, dy: float, w: int = 60, h: int = 40, zoom: float = 0.0):
|
||||
"""构造一个合成光流场:整体平移(dx,dy)+径向发散(zoom>0=zoom in,<0=out)。"""
|
||||
ys, xs = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
cx, cy = w / 2.0, h / 2.0
|
||||
fx = dx + (xs - cx) * zoom
|
||||
fy = dy + (ys - cy) * zoom
|
||||
return np.stack([fx, fy], axis=-1).astype(np.float32)
|
||||
|
||||
|
||||
def test_detect_movement_static():
|
||||
flow = _make_flow(0.0, 0.0, zoom=0.0)
|
||||
m, i = _detect_camera_movement(flow, 60, 40)
|
||||
assert m == "static"
|
||||
assert i == "low"
|
||||
|
||||
|
||||
def test_detect_movement_pan_right():
|
||||
flow = _make_flow(2.0, 0.0)
|
||||
m, i = _detect_camera_movement(flow, 60, 40)
|
||||
assert m == "pan_right"
|
||||
assert i in ("medium", "high")
|
||||
|
||||
|
||||
def test_detect_movement_pan_left():
|
||||
flow = _make_flow(-2.0, 0.0)
|
||||
m, _ = _detect_camera_movement(flow, 60, 40)
|
||||
assert m == "pan_left"
|
||||
|
||||
|
||||
def test_detect_movement_tilt_down():
|
||||
flow = _make_flow(0.0, 2.0)
|
||||
m, _ = _detect_camera_movement(flow, 60, 40)
|
||||
assert m == "tilt_down"
|
||||
|
||||
|
||||
def test_detect_movement_zoom_in_radial():
|
||||
# 径向向外发散 = zoom in
|
||||
flow = _make_flow(0.0, 0.0, zoom=0.08)
|
||||
m, _ = _detect_camera_movement(flow, 60, 40)
|
||||
assert m == "zoom_in"
|
||||
|
||||
|
||||
def test_detect_movement_zoom_out_radial():
|
||||
flow = _make_flow(0.0, 0.0, zoom=-0.08)
|
||||
m, _ = _detect_camera_movement(flow, 60, 40)
|
||||
assert m == "zoom_out"
|
||||
|
||||
|
||||
# ── 规则合成 style_guide ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sample_shots(n=4):
|
||||
return [
|
||||
ShotBoundary(
|
||||
index=i,
|
||||
start_sec=float(i * 3),
|
||||
end_sec=float((i + 1) * 3),
|
||||
movement=["static", "push_in", "pan_left", "zoom_in"][i],
|
||||
intensity=["low", "medium", "low", "high"][i],
|
||||
transition="hard_cut",
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
CAMERA_TO_KEN_BURNS_DIRS = {
|
||||
"zoom_in_slow",
|
||||
"zoom_out_slow",
|
||||
"pan_left_slow",
|
||||
"pan_right_slow",
|
||||
"zoom_in_medium",
|
||||
"zoom_out_medium",
|
||||
"diagonal_push",
|
||||
"static",
|
||||
}
|
||||
|
||||
|
||||
def test_rule_based_style_guide_structure():
|
||||
shots = _sample_shots()
|
||||
sg = _rule_based_style_guide(shots, bpm=120, vlm={"color_filter": "warm_vintage"})
|
||||
# 关键字段存在且类型正确
|
||||
assert sg["shot_count"] == 4
|
||||
assert sg["pace"] == "fast_cut"
|
||||
assert sg["bpm"] == 120
|
||||
assert sg["avg_shot_duration"] == 3.0
|
||||
assert len(sg["camera_movements"]) == 4
|
||||
assert sg["color_filter"] == "warm_vintage"
|
||||
assert "eq" in sg["video_filter_eq_params"]
|
||||
assert "default" in sg["ken_burns_params"]
|
||||
assert isinstance(sg["transition_map"], dict)
|
||||
assert isinstance(sg["ken_burns_direction_hint"], str) and sg["ken_burns_direction_hint"]
|
||||
|
||||
|
||||
# ── build_render_params_for_clip 聚合 ─────────────────────────────────
|
||||
|
||||
|
||||
def test_build_render_params_for_clip_shape():
|
||||
sg = _rule_based_style_guide(_sample_shots(), bpm=95, vlm={"color_filter": "cool_fresh"})
|
||||
p0 = build_render_params_for_clip(0, sg, duration_sec=3.0)
|
||||
assert "ken_burns" in p0
|
||||
assert "transition" in p0
|
||||
assert "video_filter" in p0
|
||||
assert p0["bgm_bpm_hint"] == 95
|
||||
assert p0["duration_sec"] == 3.0
|
||||
# clip 1 是 push_in → zoom
|
||||
p1 = build_render_params_for_clip(1, sg)
|
||||
assert p1["ken_burns"]["type"] == "zoom"
|
||||
|
||||
|
||||
def test_build_render_params_high_intensity_amplifies():
|
||||
shots = _sample_shots() # shot 3 = zoom_in/high
|
||||
sg = _rule_based_style_guide(shots, bpm=120, vlm={})
|
||||
p3 = build_render_params_for_clip(3, sg)
|
||||
base = map_camera_to_ken_burns("zoom_in")
|
||||
assert p3["ken_burns"]["zoom_end"] > base["zoom_end"]
|
||||
|
||||
|
||||
# ── 降级与容错 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_analyze_with_nonexistent_file_returns_minimum_guide():
|
||||
sg = analyze_video_style("/nonexistent/path/fake_video.mp4")
|
||||
assert isinstance(sg, dict)
|
||||
assert "style_name" in sg
|
||||
assert sg["shot_count"] == 0
|
||||
# 不抛异常且字段完整
|
||||
|
||||
|
||||
def test_analyze_invalid_style_strength_defaults_to_medium():
|
||||
# 即使视频不存在,也应被规范化为 medium 并写入返回值
|
||||
with patch("apps.worker.viral_video.video_analyzer._ensure_local_video", return_value=None):
|
||||
sg = analyze_video_style("fake", style_strength="banana")
|
||||
assert sg.get("style_strength", "medium") == "medium"
|
||||
|
||||
|
||||
def test_temp_dir_cleaned_up_after_run():
|
||||
"""用临时真实空文件模拟本地路径,确认 frames 临时目录被清理。"""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fake = Path(td) / "fake.mp4"
|
||||
fake.write_bytes(b"")
|
||||
# 抽帧会失败(ffmpeg 对空文件失败),但应全程不抛且临时目录 rmtree
|
||||
# 直接 mock _ensure_local_video 回传不存在的文件,走 _probe_duration=0 降级路径
|
||||
with patch("apps.worker.viral_video.video_analyzer._ensure_local_video", return_value=None):
|
||||
sg = analyze_video_style("proto://fake", style_strength="light")
|
||||
assert "style_name" in sg
|
||||
|
||||
|
||||
def test_ffmpeg_failure_falls_back_to_vlm_only_path():
|
||||
"""模拟 ffmpeg 抽帧失败,仍能返回 style_guide。"""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
fake = Path(td) / "ref.mp4"
|
||||
fake.write_bytes(b"not a real video")
|
||||
with patch(
|
||||
"apps.worker.viral_video.video_analyzer._extract_keyframes", side_effect=RuntimeError("ffmpeg exploded")
|
||||
):
|
||||
with patch("apps.worker.viral_video.video_analyzer._detect_shots") as mock_shots:
|
||||
mock_shots.return_value = [ShotBoundary(0, 0.0, 3.0)]
|
||||
with patch("apps.worker.viral_video.video_analyzer._analyze_movements"):
|
||||
with patch("apps.worker.viral_video.video_analyzer._detect_bpm", return_value=90):
|
||||
with patch(
|
||||
"apps.worker.viral_video.video_analyzer._vlm_analyze_frames",
|
||||
return_value={"color_filter": "none"},
|
||||
):
|
||||
with patch(
|
||||
"apps.worker.viral_video.video_analyzer._llm_synthesize",
|
||||
side_effect=lambda shots, bpm, vlm, ss: _rule_based_style_guide(shots, bpm, vlm),
|
||||
):
|
||||
sg = analyze_video_style(str(fake))
|
||||
assert sg["bpm"] == 90
|
||||
assert sg["shot_count"] == 1
|
||||
|
||||
|
||||
# ── 转场映射完整性 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_transition_map_covers_observed_types():
|
||||
for t in ("hard_cut", "cross_dissolve", "fade_black", "zoom_whip"):
|
||||
assert t in TRANSITION_TO_XFADE
|
||||
|
||||
|
||||
# ── 预设完整性 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_color_filter_preset_keys_are_safe_for_ffmpeg():
|
||||
for name, preset in COLOR_FILTER_PRESETS.items():
|
||||
if preset == {}:
|
||||
continue
|
||||
# eq 所有值都是数字
|
||||
for k, v in preset.get("eq", {}).items():
|
||||
assert isinstance(v, (int, float)), f"{name}.eq.{k} not numeric"
|
||||
for k, v in preset.get("colorchannelmixer", {}).items():
|
||||
assert isinstance(v, (int, float)), f"{name}.ccm.{k} not numeric"
|
||||
Executable
+519
@@ -0,0 +1,519 @@
|
||||
"""爆款视频模块单元测试。
|
||||
|
||||
覆盖范围:
|
||||
- 领域实体状态机转换
|
||||
- Repository CRUD
|
||||
- API 端点(6 个)
|
||||
- Celery 编排器流水线
|
||||
- Schema 校验
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packages.domain.viral_video import (
|
||||
CREDITS_VIRAL_VIDEO_COST,
|
||||
STAGE_LABELS,
|
||||
FusionLevel,
|
||||
StyleStrength,
|
||||
ViralVideoJob,
|
||||
ViralVideoStage,
|
||||
ViralVideoStatus,
|
||||
)
|
||||
|
||||
# ── 领域模型测试 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestViralVideoStatus:
|
||||
"""状态枚举测试。"""
|
||||
|
||||
def test_status_values(self):
|
||||
assert ViralVideoStatus.PENDING == "pending"
|
||||
assert ViralVideoStatus.RUNNING == "running"
|
||||
assert ViralVideoStatus.WAIT_USER_CONFIRM == "wait_user_confirm"
|
||||
assert ViralVideoStatus.COMPLETED == "completed"
|
||||
assert ViralVideoStatus.FAILED == "failed"
|
||||
assert ViralVideoStatus.CANCELLED == "cancelled"
|
||||
|
||||
def test_terminal_statuses(self):
|
||||
assert ViralVideoJob(user_id="u1", status=ViralVideoStatus.COMPLETED).is_terminal
|
||||
assert ViralVideoJob(user_id="u1", status=ViralVideoStatus.FAILED).is_terminal
|
||||
assert ViralVideoJob(user_id="u1", status=ViralVideoStatus.CANCELLED).is_terminal
|
||||
assert not ViralVideoJob(user_id="u1", status=ViralVideoStatus.PENDING).is_terminal
|
||||
assert not ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING).is_terminal
|
||||
|
||||
|
||||
class TestViralVideoJobStateTransitions:
|
||||
"""状态机转换测试。"""
|
||||
|
||||
def test_mark_running_from_pending(self):
|
||||
job = ViralVideoJob(user_id="u1")
|
||||
job.mark_running()
|
||||
assert job.status == ViralVideoStatus.RUNNING
|
||||
assert job.started_at is not None
|
||||
|
||||
def test_mark_running_from_running(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
job.mark_running()
|
||||
assert job.status == ViralVideoStatus.RUNNING
|
||||
|
||||
def test_mark_running_from_completed_raises(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.COMPLETED)
|
||||
with pytest.raises(ValueError, match="Cannot transition"):
|
||||
job.mark_running()
|
||||
|
||||
def test_mark_wait_user_confirm(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
intent = {"intent": "推广", "key_messages": ["卖点1"]}
|
||||
job.mark_wait_user_confirm(intent)
|
||||
assert job.status == ViralVideoStatus.WAIT_USER_CONFIRM
|
||||
assert job.intent_result == intent
|
||||
|
||||
def test_mark_wait_user_confirm_from_non_running_raises(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.PENDING)
|
||||
with pytest.raises(ValueError, match="Cannot transition"):
|
||||
job.mark_wait_user_confirm({})
|
||||
|
||||
def test_resume_from_confirm(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
|
||||
job.resume_from_confirm()
|
||||
assert job.status == ViralVideoStatus.RUNNING
|
||||
|
||||
def test_resume_from_non_confirm_raises(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
with pytest.raises(ValueError, match="Cannot resume"):
|
||||
job.resume_from_confirm()
|
||||
|
||||
def test_mark_completed(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
job.mark_completed("https://oss.example.com/video.mp4")
|
||||
assert job.status == ViralVideoStatus.COMPLETED
|
||||
assert job.result_video_url == "https://oss.example.com/video.mp4"
|
||||
assert job.completed_at is not None
|
||||
|
||||
def test_mark_failed(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
job.mark_failed("渲染超时")
|
||||
assert job.status == ViralVideoStatus.FAILED
|
||||
assert job.error_msg == "渲染超时"
|
||||
|
||||
def test_mark_cancelled(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
job.mark_cancelled()
|
||||
assert job.status == ViralVideoStatus.CANCELLED
|
||||
|
||||
def test_mark_cancelled_from_terminal_raises(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.COMPLETED)
|
||||
with pytest.raises(ValueError, match="Cannot cancel"):
|
||||
job.mark_cancelled()
|
||||
|
||||
|
||||
class TestViralVideoJobDefaults:
|
||||
"""默认值测试。"""
|
||||
|
||||
def test_default_values(self):
|
||||
job = ViralVideoJob(user_id="u1")
|
||||
assert job.images == []
|
||||
assert job.industry == ""
|
||||
assert job.duration == 30
|
||||
assert job.fusion_level == FusionLevel.AI_POLISH
|
||||
assert job.style_strength == StyleStrength.MEDIUM
|
||||
assert job.status == ViralVideoStatus.PENDING
|
||||
assert job.credits_cost == 0
|
||||
assert job.retry_count == 0
|
||||
assert job.result_video_url == ""
|
||||
assert job.error_msg == ""
|
||||
|
||||
def test_credits_cost_constant(self):
|
||||
assert CREDITS_VIRAL_VIDEO_COST == 50
|
||||
|
||||
|
||||
class TestViralVideoStage:
|
||||
"""阶段枚举测试。"""
|
||||
|
||||
def test_all_stages_have_labels(self):
|
||||
for stage in ViralVideoStage:
|
||||
assert stage in STAGE_LABELS, f"Stage {stage} missing label"
|
||||
|
||||
def test_stage_order(self):
|
||||
expected_order = [
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"review",
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"uploading",
|
||||
]
|
||||
actual_order = [s.value for s in ViralVideoStage]
|
||||
assert actual_order == expected_order
|
||||
|
||||
|
||||
# ── Schema 校验测试 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestViralVideoSchemas:
|
||||
"""Pydantic Schema 校验测试。"""
|
||||
|
||||
def test_create_request_valid(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
req = CreateViralVideoRequest(images=["https://example.com/img.jpg"])
|
||||
assert req.images == ["https://example.com/img.jpg"]
|
||||
assert req.fusion_level == "ai_polish"
|
||||
assert req.style_strength == "medium"
|
||||
assert req.duration == 30
|
||||
|
||||
def test_create_request_empty_images_raises(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreateViralVideoRequest(images=[])
|
||||
|
||||
def test_create_request_invalid_fusion_level(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreateViralVideoRequest(
|
||||
images=["https://example.com/img.jpg"],
|
||||
fusion_level="invalid_level",
|
||||
)
|
||||
|
||||
def test_create_request_invalid_style_strength(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreateViralVideoRequest(
|
||||
images=["https://example.com/img.jpg"],
|
||||
style_strength="ultra",
|
||||
)
|
||||
|
||||
def test_confirm_intent_request_defaults(self):
|
||||
from app.schemas.viral_video import ConfirmIntentRequest
|
||||
|
||||
req = ConfirmIntentRequest()
|
||||
assert req.confirmed_copy == ""
|
||||
assert req.adjustments == ""
|
||||
|
||||
def test_analyze_style_request(self):
|
||||
from app.schemas.viral_video import AnalyzeStyleRequest
|
||||
|
||||
req = AnalyzeStyleRequest(reference_video_url="https://example.com/video.mp4")
|
||||
assert req.reference_video_url == "https://example.com/video.mp4"
|
||||
|
||||
def test_ws_progress_event(self):
|
||||
from app.schemas.viral_video import WSProgressEvent
|
||||
|
||||
event = WSProgressEvent(
|
||||
job_id="abc123",
|
||||
stage="image_analysis",
|
||||
progress=10.0,
|
||||
message="正在分析图片",
|
||||
)
|
||||
assert event.type == "viral_video:progress"
|
||||
assert event.job_id == "abc123"
|
||||
assert event.progress == 10.0
|
||||
|
||||
|
||||
# ── Repository 测试 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestViralVideoRepository:
|
||||
"""SQLAlchemy Repository CRUD 测试(使用内存数据库)。"""
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(self):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
session = SessionLocal()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
def test_save_and_get(self, db_session):
|
||||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
)
|
||||
|
||||
repo = SQLAlchemyViralVideoJobRepository(db_session)
|
||||
job = ViralVideoJob(
|
||||
user_id="user-001",
|
||||
images=["https://img.com/1.jpg"],
|
||||
industry="美妆",
|
||||
duration=60,
|
||||
)
|
||||
repo.save(job)
|
||||
|
||||
fetched = repo.get(job.id)
|
||||
assert fetched is not None
|
||||
assert fetched.id == job.id
|
||||
assert fetched.user_id == "user-001"
|
||||
assert fetched.images == ["https://img.com/1.jpg"]
|
||||
assert fetched.industry == "美妆"
|
||||
assert fetched.duration == 60
|
||||
|
||||
def test_get_nonexistent(self, db_session):
|
||||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
)
|
||||
|
||||
repo = SQLAlchemyViralVideoJobRepository(db_session)
|
||||
assert repo.get("nonexistent-id") is None
|
||||
|
||||
def test_list_by_user(self, db_session):
|
||||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
)
|
||||
|
||||
repo = SQLAlchemyViralVideoJobRepository(db_session)
|
||||
for i in range(3):
|
||||
job = ViralVideoJob(user_id="user-001", industry=f"行业{i}")
|
||||
repo.save(job)
|
||||
|
||||
# 另一个用户的任务
|
||||
other_job = ViralVideoJob(user_id="user-002", industry="其他")
|
||||
repo.save(other_job)
|
||||
|
||||
jobs = repo.list_by_user("user-001")
|
||||
assert len(jobs) == 3
|
||||
assert all(j.user_id == "user-001" for j in jobs)
|
||||
|
||||
def test_update_status(self, db_session):
|
||||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
)
|
||||
|
||||
repo = SQLAlchemyViralVideoJobRepository(db_session)
|
||||
job = ViralVideoJob(user_id="user-001")
|
||||
repo.save(job)
|
||||
|
||||
job.mark_running()
|
||||
repo.update(job)
|
||||
|
||||
fetched = repo.get(job.id)
|
||||
assert fetched.status == ViralVideoStatus.RUNNING
|
||||
assert fetched.started_at is not None
|
||||
|
||||
def test_count_pending_by_user(self, db_session):
|
||||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
)
|
||||
|
||||
repo = SQLAlchemyViralVideoJobRepository(db_session)
|
||||
# 2 个 pending
|
||||
for _ in range(2):
|
||||
repo.save(ViralVideoJob(user_id="user-001"))
|
||||
# 1 个 completed
|
||||
completed = ViralVideoJob(user_id="user-001", status=ViralVideoStatus.COMPLETED)
|
||||
repo.save(completed)
|
||||
|
||||
assert repo.count_pending_by_user("user-001") == 2
|
||||
|
||||
def test_style_template_repo(self, db_session):
|
||||
from packages.adapters.sqlalchemy_impl.models import ViralVideoStyleTemplateModel
|
||||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoStyleTemplateRepository,
|
||||
)
|
||||
|
||||
# 插入模板
|
||||
tpl = ViralVideoStyleTemplateModel(
|
||||
id="tpl-001",
|
||||
name="快节奏",
|
||||
description="适合快消品",
|
||||
style_config={"cut_speed": "fast"},
|
||||
is_system=True,
|
||||
sort_order=1,
|
||||
)
|
||||
db_session.add(tpl)
|
||||
db_session.commit()
|
||||
|
||||
repo = SQLAlchemyViralVideoStyleTemplateRepository(db_session)
|
||||
templates = repo.list_all()
|
||||
assert len(templates) == 1
|
||||
assert templates[0]["name"] == "快节奏"
|
||||
|
||||
fetched = repo.get("tpl-001")
|
||||
assert fetched is not None
|
||||
assert fetched["style_config"] == {"cut_speed": "fast"}
|
||||
|
||||
|
||||
# ── Celery 编排器测试 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestViralVideoPipeline:
|
||||
"""编排器流水线测试。"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_job(self):
|
||||
return ViralVideoJob(
|
||||
user_id="user-001",
|
||||
images=["https://img.com/1.jpg", "https://img.com/2.jpg"],
|
||||
industry="美妆",
|
||||
target_customer="年轻女性",
|
||||
marketing_purpose="品牌推广",
|
||||
duration=30,
|
||||
user_copy_text="这款产品超好用",
|
||||
fusion_level="ai_polish",
|
||||
)
|
||||
|
||||
@patch("packages.shared.ai_service.call_vision")
|
||||
def test_image_analysis_step(self, mock_vision, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_image_analysis
|
||||
|
||||
mock_vision.return_value = {"name": "口红", "features": ["持久", "滋润"]}
|
||||
result = _step_image_analysis(mock_job)
|
||||
assert "products" in result
|
||||
assert len(result["products"]) == 2 # 两张图片
|
||||
|
||||
@patch("packages.shared.ai_service.call_vision")
|
||||
def test_image_analysis_fallback(self, mock_vision, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_image_analysis
|
||||
|
||||
# 模拟 call_vision 不存在
|
||||
mock_vision.side_effect = ImportError("no module")
|
||||
result = _step_image_analysis(mock_job)
|
||||
assert "products" in result
|
||||
|
||||
def test_video_analysis_no_reference(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_video_analysis
|
||||
|
||||
# 没有参考视频
|
||||
mock_job.reference_video_url = ""
|
||||
result = _step_video_analysis(mock_job)
|
||||
assert result is None
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_intent_parsing(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_intent_parsing
|
||||
|
||||
mock_llm.return_value = {"intent": "推广口红", "tone": "活泼"}
|
||||
result = _step_intent_parsing(mock_job, {"products": []})
|
||||
assert "intent" in result
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_copy_fusion_ai_polish(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_copy_fusion
|
||||
|
||||
mock_llm.return_value = "融合后的文案内容"
|
||||
result = _step_copy_fusion(mock_job, {"intent": "推广"}, {"products": []})
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_storyboard_generation(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_storyboard
|
||||
|
||||
mock_llm.return_value = [
|
||||
{"order": 0, "type": "product_shot", "duration": 10},
|
||||
{"order": 1, "type": "closing", "duration": 5},
|
||||
]
|
||||
result = _step_storyboard(mock_job, "测试文案", {})
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_review_pass(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_review
|
||||
|
||||
mock_llm.return_value = {"passed": True, "score": 90, "details": {}}
|
||||
result = _step_review(mock_job, "测试文案", [])
|
||||
assert result["passed"] is True
|
||||
|
||||
def test_bgm_select(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||||
|
||||
mock_job.bgm_preference = "upbeat"
|
||||
bgm = _step_bgm_select(mock_job)
|
||||
assert "upbeat" in bgm
|
||||
|
||||
def test_bgm_select_default(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||||
|
||||
mock_job.bgm_preference = ""
|
||||
bgm = _step_bgm_select(mock_job)
|
||||
assert bgm == "bgm_default.mp3"
|
||||
|
||||
|
||||
# ── 端到端流水线集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPipelineIntegration:
|
||||
"""流水线端到端集成测试(mock 外部依赖)。"""
|
||||
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_upload")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_musetalk")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_render")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_bgm_select")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_tts")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_review")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_storyboard")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_copy_fusion")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_intent_parsing")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_video_analysis")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_image_analysis")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._get_repo_and_job")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._emit_progress")
|
||||
def test_resume_pipeline_completes(
|
||||
self,
|
||||
mock_emit,
|
||||
mock_get_repo,
|
||||
mock_img_analysis,
|
||||
mock_video_analysis,
|
||||
mock_intent,
|
||||
mock_copy_fusion,
|
||||
mock_storyboard,
|
||||
mock_review,
|
||||
mock_tts,
|
||||
mock_bgm,
|
||||
mock_render,
|
||||
mock_musetalk,
|
||||
mock_upload,
|
||||
):
|
||||
"""测试 resume 流水线能从确认状态走到完成。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import (
|
||||
resume_viral_video_pipeline,
|
||||
)
|
||||
|
||||
# 构造 mock job
|
||||
job = ViralVideoJob(
|
||||
user_id="user-001",
|
||||
images=["https://img.com/1.jpg"],
|
||||
industry="美妆",
|
||||
status=ViralVideoStatus.RUNNING,
|
||||
intent_result={"intent": "推广"},
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_get_repo.return_value = (mock_session, mock_repo, job)
|
||||
|
||||
# 设置各步骤返回值
|
||||
mock_copy_fusion.return_value = "融合文案"
|
||||
mock_storyboard.return_value = [{"order": 0, "duration": 10}]
|
||||
mock_review.return_value = {"passed": True, "score": 90}
|
||||
mock_tts.return_value = "https://audio.mp3"
|
||||
mock_bgm.return_value = "bgm_default.mp3"
|
||||
mock_render.return_value = "/tmp/video.mp4"
|
||||
mock_musetalk.return_value = "/tmp/video_final.mp4"
|
||||
mock_upload.return_value = "https://oss.example.com/final.mp4"
|
||||
|
||||
result = resume_viral_video_pipeline.run("job-001")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["video_url"] == "https://oss.example.com/final.mp4"
|
||||
assert job.status == ViralVideoStatus.COMPLETED
|
||||
assert job.credits_cost == CREDITS_VIRAL_VIDEO_COST
|
||||
@@ -0,0 +1,99 @@
|
||||
"""爆款视频 DB 模型单元测试(#2039 PR1:DB + migration)。
|
||||
|
||||
验证:
|
||||
- 3 张新表可在内存 SQLite 上创建
|
||||
- 默认值与基本 CRUD 正常
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
Base,
|
||||
ViralVideoJobModel,
|
||||
ViralVideoPromptTemplateModel,
|
||||
ViralVideoStyleTemplateModel,
|
||||
)
|
||||
|
||||
|
||||
def _make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
class TestViralVideoJobModel:
|
||||
def test_create_and_get(self):
|
||||
session = _make_session()
|
||||
job = ViralVideoJobModel(
|
||||
id="job-001",
|
||||
user_id="user-001",
|
||||
images=["https://img.com/1.jpg"],
|
||||
industry="美妆",
|
||||
duration=60,
|
||||
)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
fetched = session.query(ViralVideoJobModel).filter_by(id="job-001").one()
|
||||
assert fetched.user_id == "user-001"
|
||||
assert fetched.images == ["https://img.com/1.jpg"]
|
||||
assert fetched.industry == "美妆"
|
||||
assert fetched.duration == 60
|
||||
|
||||
def test_default_values(self):
|
||||
session = _make_session()
|
||||
job = ViralVideoJobModel(id="job-002", user_id="user-002")
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(ViralVideoJobModel, "job-002")
|
||||
assert fetched.images == []
|
||||
assert fetched.fusion_level == "ai_polish"
|
||||
assert fetched.style_strength == "medium"
|
||||
assert fetched.status == "pending"
|
||||
assert fetched.credits_cost == 0
|
||||
assert fetched.retry_count == 0
|
||||
assert fetched.style_guide is None
|
||||
assert fetched.intent_result is None
|
||||
|
||||
|
||||
class TestViralVideoStyleTemplateModel:
|
||||
def test_create_and_get(self):
|
||||
session = _make_session()
|
||||
tpl = ViralVideoStyleTemplateModel(
|
||||
id="tpl-001",
|
||||
name="快节奏",
|
||||
style_config={"cut_speed": "fast"},
|
||||
sort_order=1,
|
||||
)
|
||||
session.add(tpl)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(ViralVideoStyleTemplateModel, "tpl-001")
|
||||
assert fetched.name == "快节奏"
|
||||
assert fetched.style_config == {"cut_speed": "fast"}
|
||||
assert fetched.sort_order == 1
|
||||
|
||||
|
||||
class TestViralVideoPromptTemplateModel:
|
||||
def test_create_and_get(self):
|
||||
session = _make_session()
|
||||
tpl = ViralVideoPromptTemplateModel(
|
||||
id="pt-001",
|
||||
prompt_type="image_analysis",
|
||||
name="图片分析模板",
|
||||
content="请分析图片:{image_url}",
|
||||
variables=["image_url"],
|
||||
)
|
||||
session.add(tpl)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(ViralVideoPromptTemplateModel, "pt-001")
|
||||
assert fetched.prompt_type == "image_analysis"
|
||||
assert fetched.content == "请分析图片:{image_url}"
|
||||
assert fetched.variables == ["image_url"]
|
||||
assert fetched.version == 1
|
||||
assert fetched.is_active is True
|
||||
Reference in New Issue
Block a user