Files
yingqing/core/backend/apps/projects/services/pipeline.py
T

160 lines
6.9 KiB
Python

from dataclasses import dataclass
from django.utils import timezone
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
# 故事板已从流程中去掉:脚本 → 基础资产 → 视频 → 导出。
# ProjectStage.Stage.STORYBOARD 枚举保留(老项目的历史行还引用它),但不再出现在流水线里。
STAGE_ORDER = [
ProjectStage.Stage.SCRIPT,
ProjectStage.Stage.BASE_ASSETS,
ProjectStage.Stage.VIDEO,
ProjectStage.Stage.EXPORT,
]
# 老项目可能正停在已下线的 storyboard 阶段;按「基础资产之后」对待,让它们能正常进视频。
LEGACY_STAGE_FALLBACK = {ProjectStage.Stage.STORYBOARD: ProjectStage.Stage.BASE_ASSETS}
def normalize_stage(stage: str) -> str:
"""把已下线的阶段名归一到现役阶段,供索引/比较使用。"""
return LEGACY_STAGE_FALLBACK.get(stage, stage)
@dataclass(frozen=True)
class StageTransition:
current: str
target: str
allowed: bool
reason: str = ""
def can_enter_stage(current_stage: str, target_stage: str) -> StageTransition:
if target_stage not in STAGE_ORDER:
return StageTransition(current_stage, target_stage, False, "unknown target stage")
current = normalize_stage(current_stage)
current_index = STAGE_ORDER.index(current) if current in STAGE_ORDER else -1
target_index = STAGE_ORDER.index(target_stage)
if target_index <= current_index + 1:
return StageTransition(current_stage, target_stage, True)
return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied")
def initialize_project_pipeline(project: Project, *, placeholder_segments: int = 4) -> None:
"""建立专业创作和一键成片共用的阶段、视频占位数据。幂等,可安全重试。"""
for stage_name in STAGE_ORDER:
ProjectStage.objects.get_or_create(project=project, stage=stage_name)
for index in range(placeholder_segments):
VideoSegment.objects.get_or_create(
project=project,
sort_order=index,
defaults={"target_duration_seconds": 15},
)
def sync_video_segments_to_script(project: Project, script: ScriptVersion) -> None:
"""把视频段数量和时长收口到采用脚本;已生成过的段绝不裁剪或改时长。"""
if not script.is_adopted:
return
script_segments = list(script.segments.order_by("sort_order"))
target = len(script_segments)
segments = list(project.video_segments.order_by("sort_order"))
while len(segments) > target:
tail = segments[-1]
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
tail.delete()
segments.pop()
else:
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
index = len(segments)
seconds = script_segments[index].duration_seconds if index < target else 15
segments.append(
VideoSegment.objects.create(
project=project,
sort_order=next_order,
target_duration_seconds=seconds,
)
)
next_order += 1
stale: list[VideoSegment] = []
for index, video_segment in enumerate(segments):
if index >= target:
break
seconds = script_segments[index].duration_seconds
if not seconds or video_segment.target_duration_seconds == seconds:
continue
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
continue
video_segment.target_duration_seconds = seconds
stale.append(video_segment)
if stale:
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
def adopt_script_version(project: Project, script: ScriptVersion) -> None:
"""采用脚本并推进到资产阶段,供专业创作按钮和一键成片编排共同调用。"""
ScriptVersion.objects.filter(project=project).exclude(id=script.id).update(is_adopted=False)
if not script.is_adopted:
script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"])
# 定稿即本地拆角色/场景(不调模型、不扣费),资产页无需再付费「提取」
try:
from apps.ai.entity_local import materialize_script_entities
materialize_script_entities(project=project, script=script)
except Exception: # noqa: BLE001 — 拆实体失败不挡定稿进资产;用户仍可点「提取」重试
import logging
logging.getLogger(__name__).exception("local entity materialize on adopt failed for project %s", project.id)
sync_video_segments_to_script(project, script)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
project.current_stage = ProjectStage.Stage.BASE_ASSETS
project.status = Project.Status.ASSETING
project.failure_reason = ""
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
def finish_base_assets_stage(project: Project) -> None:
"""基础资产齐备后直接推进到视频阶段(故事板已下线),并再次校准片段数量。"""
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if adopted_script is not None:
sync_video_segments_to_script(project, adopted_script)
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
def finish_video_stage(project: Project) -> bool:
"""全部片段成功即完成视频阶段。"""
segments = list(project.video_segments.values_list("status", "adopted_version_id"))
if not segments or not all(status == VideoSegment.Status.SUCCEEDED and adopted for status, adopted in segments):
return False
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.VIDEO)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.COMPLETED
project.failure_reason = ""
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
return True