Files
xiaoxia-saas/packages/domain/viral_video.py
T
xiaoxia 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
feat(viral-video): 动态积分定价(按tokens×单价×1.3,保留两位小数) (#2152)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-03 00:20:20 +08:00

252 lines
9.7 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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