5d6a4675fb
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 15s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 54s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m23s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m47s
CI/CD Pipeline / Validate - Style (push) Successful in 5m10s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
252 lines
9.7 KiB
Python
Executable File
252 lines
9.7 KiB
Python
Executable File
"""ViralVideoJob 领域模型 — 爆款视频任务.
|
||
|
||
v1.6 重大简化:Seedance 2.5 单次最长30秒,单次调用直接出片,不再分段/拼接/ffmpeg concat。
|
||
状态机(三步分步):
|
||
pending -> running -> image_analyzed -> running -> copy_generated -> running -> completed
|
||
wait_user_confirm -> running -> completed (旧路径兼容)
|
||
任意阶段 fail; 任意非终态 cancel.
|
||
failed -> pending (retry 重置后重跑)。
|
||
"""
|
||
|
||
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"
|
||
IMAGE_ANALYZED = "image_analyzed"
|
||
COPY_GENERATED = "copy_generated"
|
||
WAIT_USER_CONFIRM = "wait_user_confirm"
|
||
COMPLETED = "completed"
|
||
FAILED = "failed"
|
||
CANCELLED = "cancelled"
|
||
|
||
|
||
class ViralVideoStage(StrEnum):
|
||
IMAGE_ANALYSIS = "image_analysis"
|
||
VIDEO_ANALYSIS = "video_analysis"
|
||
INTENT_PARSING = "intent_parsing"
|
||
SCRIPT_GENERATION = "script_generation" # v1.6: 编导分镜脚本(融合原 copy_fusion+storyboard+review)
|
||
REVIEW = "review"
|
||
TTS = "tts"
|
||
RENDERING = "rendering" # v1.6: 单次 Seedance 生成(BGM/音效/画面一次出片)
|
||
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):
|
||
IMAGE_ANALYSIS = "image_analysis"
|
||
INTENT_PARSING = "intent_parsing"
|
||
SCRIPT_GENERATION = "script_generation"
|
||
REVIEW = "review"
|
||
VIDEO_STYLE_INTEGRATION = "video_style_integration"
|
||
STYLE_CONSTRAINT = "style_constraint"
|
||
|
||
|
||
STAGE_LABELS = {
|
||
ViralVideoStage.IMAGE_ANALYSIS: "图片分析",
|
||
ViralVideoStage.VIDEO_ANALYSIS: "视频风格分析",
|
||
ViralVideoStage.INTENT_PARSING: "意图解析",
|
||
ViralVideoStage.SCRIPT_GENERATION: "编导脚本生成",
|
||
ViralVideoStage.REVIEW: "合规审核",
|
||
ViralVideoStage.TTS: "AI 配音",
|
||
ViralVideoStage.RENDERING: "视频生成",
|
||
ViralVideoStage.UPLOADING: "上传发布",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class ViralVideoJob:
|
||
"""爆款视频任务领域实体(v1.6 单次 Seedance 出片版)。"""
|
||
|
||
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 = 15 # v1.6: 默认15秒,上限30秒(Seedance 2.5 单次最大30s)
|
||
user_copy_text: str = ""
|
||
fusion_level: str = FusionLevel.AI_POLISH
|
||
reference_audio_path: str = ""
|
||
reference_video_url: str = ""
|
||
style_strength: str = StyleStrength.MEDIUM
|
||
style_guide: dict | None = None
|
||
style_template_id: str = ""
|
||
# v1.5.1 音频/视频参数
|
||
voice_id: str = ""
|
||
voice_source: str = ""
|
||
video_ratio: str = "9:16"
|
||
video_model: str = ""
|
||
# v1.4+ 产物
|
||
image_analysis: dict | None = None
|
||
intent_result: dict | None = None
|
||
generated_copy_text: str = "" # v1.6: 存 voiceover_script(纯口播对白),字段名兼容
|
||
storyboard: list | None = None # v1.6: 存 copy_result.shots,字段名兼容
|
||
copy_result: dict | None = None # v1.6: 完整编导脚本结构
|
||
# 状态
|
||
id: str = field(default_factory=lambda: uuid4().hex)
|
||
status: ViralVideoStatus = ViralVideoStatus.PENDING
|
||
current_stage: str = "" # 细粒度阶段(ViralVideoStage.value,snake_case)
|
||
phase_message: str = "" # 阶段中文提示文案,前端轮询直接展示
|
||
heartbeat_at: datetime | None = None # worker 心跳时间,用于超时僵尸任务检测
|
||
result_video_url: str = ""
|
||
video_resolution: str = "720p"
|
||
credits_prepaid: float = 0.0
|
||
credits_transaction_id: str = ""
|
||
credits_cost: float = 0.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.IMAGE_ANALYZED,
|
||
ViralVideoStatus.COPY_GENERATED,
|
||
ViralVideoStatus.WAIT_USER_CONFIRM,
|
||
ViralVideoStatus.RUNNING,
|
||
):
|
||
raise ValueError(f"Cannot transition from {self.status} to running")
|
||
self.status = ViralVideoStatus.RUNNING
|
||
now = datetime.now(timezone.utc)
|
||
if self.started_at is None:
|
||
self.started_at = now
|
||
self.heartbeat_at = now
|
||
self.updated_at = now
|
||
|
||
def touch_heartbeat(self) -> None:
|
||
"""更新心跳时间(worker 在长任务中周期性调用,用于超时检测)。"""
|
||
now = datetime.now(timezone.utc)
|
||
if self.started_at is None:
|
||
self.started_at = now
|
||
self.heartbeat_at = now
|
||
self.updated_at = now
|
||
|
||
def mark_image_analyzed(self) -> None:
|
||
if self.status not in (ViralVideoStatus.PENDING, ViralVideoStatus.RUNNING):
|
||
raise ValueError(f"Cannot transition from {self.status} to image_analyzed")
|
||
self.status = ViralVideoStatus.IMAGE_ANALYZED
|
||
if self.started_at is None:
|
||
self.started_at = datetime.now(timezone.utc)
|
||
self.updated_at = datetime.now(timezone.utc)
|
||
|
||
def mark_copy_generated(self, copy_result: dict) -> None:
|
||
"""v1.6 阶段2完成:编导脚本(含 voiceover_script/shots/硬约束/负面词)已生成。"""
|
||
if self.status not in (
|
||
ViralVideoStatus.IMAGE_ANALYZED,
|
||
ViralVideoStatus.RUNNING,
|
||
ViralVideoStatus.PENDING,
|
||
):
|
||
raise ValueError(f"Cannot transition from {self.status} to copy_generated")
|
||
self.status = ViralVideoStatus.COPY_GENERATED
|
||
self.copy_result = copy_result or {}
|
||
if isinstance(copy_result, dict):
|
||
self.generated_copy_text = copy_result.get("voiceover_script", "") or ""
|
||
shots = copy_result.get("shots") or []
|
||
self.storyboard = list(shots) if isinstance(shots, list) else []
|
||
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_image_analyzed(self, **kwargs) -> None:
|
||
if self.status not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING):
|
||
raise ValueError(f"Cannot resume from {self.status} to copy-gen")
|
||
for k, v in kwargs.items():
|
||
if hasattr(self, k) and v not in (None, "", []):
|
||
setattr(self, k, v)
|
||
self.status = ViralVideoStatus.RUNNING
|
||
self.updated_at = datetime.now(timezone.utc)
|
||
|
||
def resume_from_copy_generated(self, edited_copy: str | None = None) -> None:
|
||
"""阶段2->阶段3:用户确认/编辑口播文案,开始跑 TTS+单次Seedance渲染。"""
|
||
if self.status != ViralVideoStatus.COPY_GENERATED:
|
||
raise ValueError(f"Cannot resume from {self.status} to render")
|
||
if edited_copy and isinstance(self.copy_result, dict):
|
||
self.copy_result = {**self.copy_result, "voiceover_script": edited_copy}
|
||
self.generated_copy_text = edited_copy
|
||
self.status = ViralVideoStatus.RUNNING
|
||
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,
|
||
)
|
||
|
||
@property
|
||
def effective_copy_text(self) -> str:
|
||
"""TTS 用的最终口播文案:优先 copy_result.voiceover_script,兼容老字段。"""
|
||
if isinstance(self.copy_result, dict) and self.copy_result.get("voiceover_script"):
|
||
return self.copy_result["voiceover_script"]
|
||
return self.generated_copy_text or self.user_copy_text or "你好,给大家推荐一款好物"
|
||
|
||
@property
|
||
def voiceover_script(self) -> str:
|
||
return self.effective_copy_text
|