优化脚本

This commit is contained in:
Azmat@qq.com
2026-08-28 14:45:38 +08:00
parent 849b14a0f6
commit c0a0b9603b
37 changed files with 541 additions and 1893 deletions
+3 -91
View File
@@ -15,10 +15,6 @@ from .models import (
ScriptSegment,
ScriptTemplate,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
@@ -140,84 +136,6 @@ class BaseAssetGroupSerializer(serializers.ModelSerializer):
return {str(asset.id): _asset_preview_url(asset) for asset in obj.candidate_assets.all()}
class StoryboardFrameSerializer(serializers.ModelSerializer):
asset_url = serializers.SerializerMethodField()
# 分镜图含人脸,后端自动送火山人像审核;暴露审核态让前端挂盾牌(与角色/场景卡同款)
review_status = serializers.SerializerMethodField()
review_error = serializers.SerializerMethodField()
class Meta:
model = StoryboardFrame
fields = ["id", "script_segment", "asset", "asset_url", "sort_order", "prompt", "review_status", "review_error"]
read_only_fields = fields
def get_asset_url(self, obj) -> str:
return _asset_preview_url(obj.asset)
def get_review_status(self, obj) -> str:
return obj.asset.review_status if obj.asset_id else ""
def get_review_error(self, obj) -> str:
return (obj.asset.review_error or "") if obj.asset_id else ""
class StoryboardVersionSerializer(serializers.ModelSerializer):
frames = StoryboardFrameSerializer(many=True, read_only=True)
class Meta:
model = StoryboardVersion
fields = ["id", "prompt", "is_adopted", "frames", "created_at", "updated_at"]
read_only_fields = fields
# ── 故事板分镜制(对标 VideoSegment/Version):每镜一个 shot,采用版缩略图 + 各自历史版本 ──
class StoryboardShotSerializer(serializers.ModelSerializer):
adopted_asset = serializers.SerializerMethodField()
adopted_asset_url = serializers.SerializerMethodField()
# 分镜图含人脸,后端自动送火山人像审核;暴露采用版审核态让前端挂盾牌
review_status = serializers.SerializerMethodField()
review_error = serializers.SerializerMethodField()
versions = serializers.SerializerMethodField()
class Meta:
model = StoryboardShot
fields = ["id", "script_segment", "sort_order", "status", "error_message", "prompt",
"adopted_version", "adopted_asset", "adopted_asset_url", "review_status", "review_error", "versions"]
read_only_fields = fields
def get_adopted_asset(self, obj):
v = obj.adopted_version
return str(v.asset_id) if v and v.asset_id else None
def get_adopted_asset_url(self, obj) -> str:
v = obj.adopted_version
return _asset_preview_url(v.asset) if v is not None else ""
def get_review_status(self, obj) -> str:
v = obj.adopted_version
return (getattr(v.asset, "review_status", "") or "") if (v and v.asset_id) else ""
def get_review_error(self, obj) -> str:
v = obj.adopted_version
return (getattr(v.asset, "review_error", "") or "") if (v and v.asset_id) else ""
def get_versions(self, obj):
versions = sorted(obj.versions.all(), key=lambda v: (v.created_at is None, v.created_at), reverse=True)
return [
{
"id": str(v.id),
"asset": str(v.asset_id) if v.asset_id else None,
"asset_url": _asset_preview_url(v.asset) if v.asset_id else "",
"prompt": v.prompt,
"is_adopted": v.is_adopted,
"review_status": (getattr(v.asset, "review_status", "") or "") if v.asset_id else "",
"review_error": (getattr(v.asset, "review_error", "") or "") if v.asset_id else "",
"created_at": v.created_at.isoformat() if v.created_at else "",
}
for v in versions
]
class VideoSegmentVersionSerializer(serializers.ModelSerializer):
class Meta:
model = VideoSegmentVersion
@@ -389,15 +307,13 @@ class QuickCreateJobSerializer(serializers.ModelSerializer):
]
def get_phase_index(self, obj) -> int:
# 前端四步:脚本 / 资产 / 故事板 / 视频。当前步未完成,不算 done。
if obj.phase == QuickCreateJob.Phase.PRODUCTION:
message = obj.message or ""
return 3 if "视频" in message else 2
# 前端三步:脚本 / 资产 / 视频(故事板已下线)。当前步未完成,不算 done。
return {
QuickCreateJob.Phase.PRODUCT: 0,
QuickCreateJob.Phase.SCRIPT: 0,
QuickCreateJob.Phase.ASSETS: 1,
QuickCreateJob.Phase.COMPLETE: 3,
QuickCreateJob.Phase.PRODUCTION: 2,
QuickCreateJob.Phase.COMPLETE: 2,
}.get(obj.phase, 0)
def get_settings(self, obj) -> dict:
@@ -554,8 +470,6 @@ class ProjectSerializer(serializers.ModelSerializer):
video_segments = VideoSegmentSerializer(many=True, read_only=True)
script_versions = ScriptVersionSerializer(many=True, read_only=True)
base_asset_groups = BaseAssetGroupSerializer(many=True, read_only=True)
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True) # 过渡期保留(旧整版,新生成不再写)
storyboard_shots = StoryboardShotSerializer(many=True, read_only=True)
timeline = TimelineSerializer(read_only=True)
# 合成成片地址(最新一次成功拼接):视频阶段的「播放成片 / 下载成片」直接用它
final_video_url = serializers.SerializerMethodField()
@@ -578,8 +492,6 @@ class ProjectSerializer(serializers.ModelSerializer):
"stages",
"script_versions",
"base_asset_groups",
"storyboard_versions",
"storyboard_shots",
"video_segments",
"timeline",
"final_video_url",
+19 -12
View File
@@ -5,14 +5,23 @@ 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.STORYBOARD,
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:
@@ -22,24 +31,22 @@ class StageTransition:
reason: str = ""
def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard: bool = True) -> StageTransition:
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_index = STAGE_ORDER.index(current_stage) if current_stage in STAGE_ORDER else -1
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)
if allow_skip_storyboard and current_stage == ProjectStage.Stage.BASE_ASSETS and target_stage == ProjectStage.Stage.VIDEO:
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):
@@ -94,7 +101,7 @@ def sync_video_segments_to_script(project: Project, script: ScriptVersion) -> No
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
@@ -111,9 +118,9 @@ def adopt_script_version(project: Project, script: ScriptVersion) -> None:
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
def finish_storyboard_stage(project: Project) -> None:
"""故事板全部成功后推进到视频阶段,并再次校准片段数量。"""
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
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 = ""
@@ -127,7 +134,7 @@ def finish_storyboard_stage(project: Project) -> None:
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
@@ -1,7 +1,10 @@
"""“极速成片”自动编排。
"""“一键成片”自动编排。
这不是另一套生成实现:脚本、基础资产、三视图、故事板、视频和合成全部调用专业创作现有服务。
这不是另一套生成实现:脚本、基础资产、三视图、视频和合成全部调用专业创作现有服务。
本模块只负责按状态推进,并把子任务 ID 持久化到 QuickCreateJob,方便刷新后恢复进度。
★ 故事板已从流程中去掉:资产齐备后直接提交视频,导演信息全部由脚本的秒级分镜
经 build_video_segment_prompt 直达出片模型。
"""
from __future__ import annotations
@@ -27,8 +30,6 @@ from apps.ai.services import (
create_export_job,
generate_base_asset,
generate_person_triview,
poll_storyboard,
submit_storyboard,
submit_video_segment,
video_segment_has_inflight_task,
)
@@ -48,7 +49,7 @@ from apps.projects.models import (
from apps.projects.services.pipeline import (
STAGE_ORDER,
adopt_script_version,
finish_storyboard_stage,
finish_base_assets_stage,
finish_video_stage,
sync_video_segments_to_script,
)
@@ -305,9 +306,7 @@ def _restore_project_after_orchestrator_timeout(job: QuickCreateJob) -> None:
project = job.project
if project.status != Project.Status.FAILED or _videos_have_started(job):
return
shots = list(project.storyboard_shots.all())
storyboard_ready = bool(shots) and all(shot.status == "succeeded" and shot.adopted_version_id for shot in shots)
if project.current_stage != ProjectStage.Stage.VIDEO and not storyboard_ready:
if project.current_stage != ProjectStage.Stage.VIDEO:
return
project.status = Project.Status.VIDEOING
project.failure_reason = ""
@@ -804,7 +803,7 @@ def _all_portraits_have_triview(project: Project) -> bool:
return all(_portrait_has_triview(project, group.adopted_asset_id) for group in portraits)
def _finish_assets_and_enter_storyboard(job: QuickCreateJob) -> int:
def _finish_assets_and_enter_video(job: QuickCreateJob) -> int:
for group in job.project.base_asset_groups.filter(adopted_asset__isnull=False):
if group.kind != BaseAssetGroup.Kind.PRODUCT:
group_meta = dict(group.metadata or {})
@@ -816,14 +815,14 @@ def _finish_assets_and_enter_storyboard(job: QuickCreateJob) -> int:
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
job.project.current_stage = ProjectStage.Stage.STORYBOARD
job.project.status = Project.Status.STORYBOARDING
job.project.current_stage = ProjectStage.Stage.VIDEO
job.project.status = Project.Status.VIDEOING
job.project.save(update_fields=["current_stage", "status", "updated_at"])
_save_job(
job,
phase=QuickCreateJob.Phase.PRODUCTION,
progress=72,
message="正在生成故事板与镜头画面",
message="素材已就绪,正在准备生成视频",
)
return 1
@@ -874,7 +873,7 @@ def _advance_assets(job: QuickCreateJob) -> int | None:
metadata.pop("triview_task_ids", None)
metadata.pop("triview_ready", None)
_save_job(job, metadata=metadata)
return _finish_assets_and_enter_storyboard(job)
return _finish_assets_and_enter_video(job)
def _reviews_ready(job: QuickCreateJob) -> bool | None:
@@ -896,7 +895,7 @@ def _reviews_ready(job: QuickCreateJob) -> bool | None:
metadata["reviews_skipped"] = True
_save_job(job, metadata=metadata, message="质量检查暂时不可用,继续生成视频")
return True
_save_job(job, metadata=metadata, progress=82, message="故事板已完成,正在进行视频素材质量检查")
_save_job(job, metadata=metadata, progress=82, message="素材已就绪,正在进行视频素材质量检查")
return False
blockers = collect_video_review_blockers(job.project)
if not blockers:
@@ -925,7 +924,7 @@ def _reviews_ready(job: QuickCreateJob) -> bool | None:
asset = Asset.objects.filter(team=job.team, id=item["asset_id"]).first()
if asset is not None:
submit_asset_for_review(asset)
_save_job(job, progress=82, message="故事板已完成,正在进行视频素材质量检查")
_save_job(job, progress=82, message="素材已就绪,正在进行视频素材质量检查")
return False
@@ -963,7 +962,7 @@ def _start_videos(job: QuickCreateJob) -> None:
submit_video_segment(
video_segment=segment,
user=job.created_by or job.project.created_by,
prompt="一键成片自动生成,严格遵循本镜故事板与脚本。",
prompt="一键成片自动生成,严格遵循本镜脚本的秒级分镜。",
model_config_id=settings["video_model_config_id"] or None,
aspect_ratio=settings["aspect_ratio"],
resolution=settings["resolution"],
@@ -1075,64 +1074,10 @@ def _complete(job: QuickCreateJob) -> None:
def _advance_production(job: QuickCreateJob) -> int | None:
metadata = dict(job.metadata or {})
if (
metadata.get("storyboard_started")
and job.project.current_stage != ProjectStage.Stage.VIDEO
and not job.project.storyboard_shots.exists()
):
metadata["storyboard_started"] = False
_save_job(job, metadata=metadata, message="正在生成故事板与镜头画面")
if not (job.metadata or {}).get("storyboard_started"):
with transaction.atomic():
job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id)
metadata = dict(job.metadata or {})
if not metadata.get("storyboard_started"):
metadata["storyboard_started"] = True
_save_job(job, metadata=metadata, progress=74, message="正在生成故事板与镜头画面")
should_submit = True
else:
should_submit = False
if should_submit:
try:
submit_storyboard(project=job.project, user=job.created_by or job.project.created_by, prompt="")
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
except Exception as exc:
metadata = dict(job.metadata or {})
metadata.pop("storyboard_started", None)
metadata["internal_error"] = str(exc)[:2000]
hard = any(
token in str(exc).lower()
for token in ("insufficient credit", "no active", "not configured")
)
retries = int(metadata.get("storyboard_submit_retries") or 0)
if not hard and retries < 3:
metadata["storyboard_submit_retries"] = retries + 1
_save_job(job, metadata=metadata, message="正在继续生成故事板…")
return POLL_DELAY_SECONDS
_save_job(job, metadata=metadata)
raise
"""出片阶段:过审闸 → 提交各镜视频 → 合成导出。
故事板已下线,资产齐备后就直接提交视频,不再有中间出图环节。"""
if job.project.current_stage != ProjectStage.Stage.VIDEO:
result = poll_storyboard(project=job.project, user=job.created_by or job.project.created_by)
if result.get("status") == "failed":
retries = int((job.metadata or {}).get("storyboard_fail_retries") or 0)
if retries >= 2:
fail_quick_create(job, _public_error(str(result.get("error") or "故事板生成失败")))
return None
metadata = dict(job.metadata or {})
metadata["storyboard_fail_retries"] = retries + 1
metadata["storyboard_started"] = False
_save_job(job, metadata=metadata, message="故事板未成功,正在重试…")
return 1
if result.get("status") != "succeeded":
total = max(1, int(result.get("total") or 1))
done = int(result.get("done") or 0)
_save_job(job, progress=min(80, 74 + round(done / total * 6)), message=f"正在生成故事板({done}/{total})")
return POLL_DELAY_SECONDS
finish_storyboard_stage(job.project)
finish_base_assets_stage(job.project)
review_state = _reviews_ready(job)
if review_state is None:
@@ -1142,7 +1087,23 @@ def _advance_production(job: QuickCreateJob) -> int | None:
job.refresh_from_db(fields=["metadata"])
if not (job.metadata or {}).get("video_started"):
_start_videos(job)
try:
_start_videos(job)
except Exception as exc: # noqa: BLE001
# 提交视频失败:硬错(余额不足 / 没有可用模型 / 未配置)直接抛给上层落可恢复终态;
# 其余(网络抖动、素材还没写完)给 3 次机会,别让一次失败把整单打死。
metadata = dict(job.metadata or {})
metadata["internal_error"] = str(exc)[:2000]
hard = any(
token in str(exc).lower()
for token in ("insufficient credit", "no active", "not configured")
)
retries = int(metadata.get("video_submit_retries") or 0)
if hard or retries >= 3:
_save_job(job, metadata=metadata)
raise
metadata["video_submit_retries"] = retries + 1
_save_job(job, metadata=metadata, message="正在继续申请生成视频…")
return POLL_DELAY_SECONDS
segments = list(job.project.video_segments.order_by("sort_order"))
@@ -1480,7 +1441,7 @@ def _advance_without_quick_queue(job_id: str) -> None:
def resume_quick_create(job: QuickCreateJob) -> QuickCreateJob:
"""从失败处接着跑:已完成的脚本/资产/故事板保留,只补没做完的步骤。"""
"""从失败处接着跑:已完成的脚本 / 资产 / 视频保留,只补没做完的步骤。"""
job.refresh_from_db()
if job.status == QuickCreateJob.Status.SUCCEEDED:
return job
@@ -1498,12 +1459,11 @@ def resume_quick_create(job: QuickCreateJob) -> QuickCreateJob:
metadata.pop("review_poll_retries", None)
metadata.pop("next_advance_at", None)
metadata.pop("video_fail_retries", None)
metadata.pop("video_submit_retries", None)
metadata.pop("asset_fail_retries", None)
metadata.pop("asset_fail_retries_by_key", None)
metadata.pop("storyboard_fail_retries", None)
metadata.pop("triview_fail_retries", None)
metadata.pop("triview_fail_retries_by_portrait", None)
metadata.pop("storyboard_submit_retries", None)
if metadata.pop("triview_skipped", None):
metadata.pop("triview_task_ids", None)
metadata.pop("triview_ready", None)
@@ -1633,7 +1593,7 @@ def recover_quick_create(job: QuickCreateJob) -> None:
def advance_quick_create(job_id: str) -> int | None:
"""推进一个状态并返回下次轮询秒数;返回 None 表示终态。
一次只做一段:识别商品 → 脚本(独立任务) → 资产 → 故事板/视频/合成。
一次只做一段:识别商品 → 脚本(独立任务) → 资产 → 视频/合成。
脚本不再在编排任务里同步吃完整条 SSE,避免页面一直停在「推荐脚本方向」。
"""
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
+17 -14
View File
@@ -94,7 +94,7 @@ class QuickCreateApiTests(TestCase):
self.assertEqual(project.metadata["wizard"]["persona"], "bestie")
self.assertEqual(project.metadata["wizard"]["resolution"], "720p")
self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id))
self.assertEqual(project.stages.count(), 5)
self.assertEqual(project.stages.count(), 4)
self.assertEqual(project.video_segments.count(), 2)
self.assertTrue(product.selling_points.exists())
job = QuickCreateJob.objects.get(project=project)
@@ -794,7 +794,7 @@ class QuickCreateCoordinatorTests(TestCase):
self.project.save(update_fields=["status", "current_stage", "updated_at"])
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.metadata = {"storyboard_started": True}
self.job.metadata = {}
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
fail_quick_create(self.job, "一键成片暂未完成,请稍后重试或进入专业模式查看", internal_error="Timeout reading from socket")
@@ -825,7 +825,7 @@ class QuickCreateCoordinatorTests(TestCase):
self.project.save(update_fields=["status", "current_stage", "failure_reason", "updated_at"])
self.job.status = QuickCreateJob.Status.FAILED
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.metadata = {"storyboard_started": True}
self.job.metadata = {}
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
recover_quick_create(self.job)
@@ -841,7 +841,7 @@ class QuickCreateCoordinatorTests(TestCase):
self.job.status = QuickCreateJob.Status.FAILED
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.error_message = "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续"
self.job.metadata = {"storyboard_started": True, "internal_error": "Timeout reading from socket"}
self.job.metadata = {"internal_error": "Timeout reading from socket"}
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
recover_quick_create(self.job)
@@ -850,12 +850,14 @@ class QuickCreateCoordinatorTests(TestCase):
self.assertEqual(self.job.metadata.get("transient_retries"), 1)
enqueue.assert_called_once()
# 出片阶段的提交本身在别处测;这里只验「从失败处继续」的编排语义,故把提交打桩掉
@patch("apps.projects.services.quick_create._start_videos")
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
def test_resume_failed_production_job_keeps_progress(self, enqueue):
def test_resume_failed_production_job_keeps_progress(self, enqueue, _start_videos):
self.job.status = QuickCreateJob.Status.FAILED
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.error_message = "一键成片暂未完成,请稍后重试或进入专业模式查看"
self.job.metadata = {"storyboard_started": True, "transient_retries": 8, "video_fail_retries": 2}
self.job.metadata = {"transient_retries": 8, "video_fail_retries": 2}
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
resume_quick_create(self.job)
@@ -1075,7 +1077,7 @@ class QuickCreateCoordinatorTests(TestCase):
self.assertEqual(self.job.phase, QuickCreateJob.Phase.PRODUCTION)
self.assertIsNone(group.task_id)
def test_failed_triview_does_not_block_storyboard(self):
def test_failed_triview_does_not_block_video(self):
base_ids = self._ready_asset_tasks()
_group, portrait = self._portrait_group()
failed = self._script_task(AITask.Status.FAILED, key="triview-failed", error_message="image_edit timeout")
@@ -1188,7 +1190,7 @@ class QuickCreateCoordinatorTests(TestCase):
self.assertEqual(self.job.phase, QuickCreateJob.Phase.PRODUCTION)
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
def test_resume_failed_assets_continues_into_storyboard(self, enqueue):
def test_resume_failed_assets_continues_into_video(self, enqueue):
base_ids = self._ready_asset_tasks()
self.job.status = QuickCreateJob.Status.FAILED
self.job.phase = QuickCreateJob.Phase.ASSETS
@@ -1227,7 +1229,7 @@ class QuickCreateCoordinatorTests(TestCase):
segment.save(update_fields=["adopted_version", "status", "updated_at"])
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.metadata = {"storyboard_started": True, "video_started": True}
self.job.metadata = {"video_started": True}
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
self.project.current_stage = ProjectStage.Stage.VIDEO
self.project.save(update_fields=["current_stage", "updated_at"])
@@ -1266,19 +1268,20 @@ class QuickCreateCoordinatorTests(TestCase):
self.job.save(update_fields=["status", "phase", "progress", "updated_at"])
data = QuickCreateJobSerializer(self.job).data
self.assertEqual(data["phase_index"], 3)
self.assertEqual(data["phase_index"], 2)
self.assertEqual(data["result"]["video_url"], "https://cdn.example/quick.mp4")
self.assertEqual(data["result"]["final_video_url"], "")
self.assertEqual(data["result"]["duration_seconds"], 15)
def test_phase_index_matches_four_step_ui(self):
def test_phase_index_matches_three_step_ui(self):
"""前端进度条三步:脚本 / 资产 / 视频(故事板已下线)。"""
cases = [
(QuickCreateJob.Phase.PRODUCT, "正在识别商品", 0),
(QuickCreateJob.Phase.SCRIPT, "正在生成分镜脚本…", 0),
(QuickCreateJob.Phase.ASSETS, "正在生成商品、模特与场景资产", 1),
(QuickCreateJob.Phase.PRODUCTION, "正在生成故事板与镜头画面", 2),
(QuickCreateJob.Phase.PRODUCTION, "正在生成视频(1/2)", 3),
(QuickCreateJob.Phase.COMPLETE, "视频已生成", 3),
(QuickCreateJob.Phase.PRODUCTION, "素材已就绪,正在准备生成视频", 2),
(QuickCreateJob.Phase.PRODUCTION, "正在生成视频(1/2)", 2),
(QuickCreateJob.Phase.COMPLETE, "视频已生成", 2),
]
for phase, message, expected in cases:
self.job.phase = phase
@@ -202,7 +202,7 @@ class ScriptTemplateTests(TestCase):
self.assertEqual(wizard["total_duration"], 30)
self.assertIn("第 4 镜 · 5s · CTA", wizard["template_outline"])
# 新项目仍走完整流水线初始化,不是「复制旧项目」
self.assertEqual(project.stages.count(), 5)
self.assertEqual(project.stages.count(), 4)
self.assertEqual(project.script_versions.count(), 0)
self.assertEqual(ScriptTemplate.objects.get(id=template_id).usage_count, 1)
+27 -80
View File
@@ -14,10 +14,6 @@ from apps.projects.models import (
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
@@ -67,14 +63,13 @@ class ProjectApiTests(TestCase):
project = Project.objects.get(id=response.data["id"])
self.assertEqual(project.team, self.team)
self.assertEqual(project.created_by, self.user)
self.assertEqual(project.stages.count(), 5)
self.assertEqual(project.stages.count(), 4)
self.assertEqual(project.video_segments.count(), 4)
self.assertEqual(
list(project.stages.values_list("stage", flat=True)),
[
ProjectStage.Stage.SCRIPT,
ProjectStage.Stage.BASE_ASSETS,
ProjectStage.Stage.STORYBOARD,
ProjectStage.Stage.VIDEO,
ProjectStage.Stage.EXPORT,
],
@@ -574,76 +569,29 @@ class ProjectApiTests(TestCase):
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
)
def _mk_shot(self, project, script, order, *, review_status="active"):
"""建一个已出片的 StoryboardShot(+ 已采用版本,资产审核态可控),供过审/采用测试。"""
def _mk_person(self, project, script, order, *, review_status="active"):
"""给某镜挂一个「已采用的角色立绘」(审核态可控),供过审闸测试。
故事板下线后,含真人脸的待审资产只剩人物立绘。"""
from apps.projects.models import BaseAssetGroup
seg = script.segments.filter(sort_order=order).first() or ScriptSegment.objects.create(
script_version=script, sort_order=order, narration=f"n{order}", visual_prompt=f"v{order}")
a = Asset.objects.create(team=self.team, created_by=self.user, name=f"sb{order}", asset_type="image",
source="ai_generated", category=Asset.Category.STORYBOARD, review_status=review_status)
shot = StoryboardShot.objects.create(project=project, script_segment=seg, sort_order=order,
status=StoryboardShot.Status.SUCCEEDED)
ver = StoryboardShotVersion.objects.create(shot=shot, asset=a, is_adopted=True)
shot.adopted_version = ver
shot.save(update_fields=["adopted_version"])
return shot, ver, a
seg.entity_refs = ["c1"]
seg.save(update_fields=["entity_refs"])
meta = dict(project.metadata or {})
meta["script_entities"] = [{"id": "c1", "type": "character", "name": "女主"}]
project.metadata = meta
project.save(update_fields=["metadata"])
asset = Asset.objects.create(team=self.team, created_by=self.user, name=f"person{order}", asset_type="image",
source="ai_generated", category=Asset.Category.PERSON, review_status=review_status)
AssetFile.objects.create(asset=asset, object_key=f"p{order}.png", bucket="b",
content_type="image/png", preview_url=f"http://x/p{order}.png", is_primary=True)
BaseAssetGroup.objects.create(project=project, kind=BaseAssetGroup.Kind.PERSON,
adopted_asset=asset, metadata={"label": "女主", "adopt": "adopted"})
return asset
def test_submit_storyboard_creates_one_shot_per_segment_all_queued(self):
"""开始生成故事板:确保每镜一个 shot、全部置 QUEUED(真正出图交给 poll)。"""
from apps.ai.services import submit_storyboard
self._mk_image_model()
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
script = ScriptVersion.objects.create(project=project, is_adopted=True)
for i in range(3):
ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}")
targets = submit_storyboard(project=project, user=self.user, prompt="风格")
self.assertEqual(project.storyboard_shots.count(), 3)
self.assertEqual(len(targets), 3)
self.assertTrue(all(s.status == StoryboardShot.Status.QUEUED for s in project.storyboard_shots.all()))
project.refresh_from_db()
self.assertEqual((project.metadata or {}).get("storyboard_prompt"), "风格") # 整张风格存项目级
def test_submit_storyboard_single_shot_only_targets_that_shot(self):
"""单场重跑:只把指定 shot 置 QUEUED,其余已出片的场不动。"""
from apps.ai.services import submit_storyboard
self._mk_image_model()
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
script = ScriptVersion.objects.create(project=project, is_adopted=True)
for i in range(2):
ScriptSegment.objects.create(script_version=script, sort_order=i, narration=f"n{i}", visual_prompt=f"v{i}")
shot0, _, _ = self._mk_shot(project, script, 0)
shot1, _, _ = self._mk_shot(project, script, 1)
submit_storyboard(project=project, user=self.user, shot_ids=[str(shot1.id)])
shot0.refresh_from_db(); shot1.refresh_from_db()
self.assertEqual(shot0.status, StoryboardShot.Status.SUCCEEDED) # 没动
self.assertEqual(shot1.status, StoryboardShot.Status.QUEUED) # 只重跑这场
def test_adopt_storyboard_shot_version_switches_adopted(self):
"""采用某场的历史版本:切换该场采用的分镜图。"""
self._mk_image_model()
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
script = ScriptVersion.objects.create(project=project, is_adopted=True)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
shot, v1, _ = self._mk_shot(project, script, 0)
a2 = Asset.objects.create(team=self.team, created_by=self.user, name="sb-v2", asset_type="image",
source="ai_generated", category=Asset.Category.STORYBOARD)
v2 = StoryboardShotVersion.objects.create(shot=shot, asset=a2, is_adopted=False)
res = self.client.post(
f"/api/projects/{project.id}/adopt-storyboard-shot-version/",
{"shot_id": str(shot.id), "version_id": str(v2.id)}, format="json",
)
self.assertEqual(res.status_code, 200)
shot.refresh_from_db(); v1.refresh_from_db(); v2.refresh_from_db()
self.assertEqual(shot.adopted_version_id, v2.id)
self.assertTrue(v2.is_adopted)
self.assertFalse(v1.is_adopted)
def test_video_review_precheck_flags_unreviewed_storyboard_then_clears(self):
"""生成视频前的过审闸:某镜故事板分镜未过审(非 active)→ precheck 列为 blocker;
def test_video_review_precheck_flags_unreviewed_person_then_clears(self):
"""生成视频前的过审闸:某镜的人物立绘未过审(非 active)→ precheck 列为 blocker;
过审(active)后 → blockers 清空,可放行生成。"""
project = Project.objects.create(
team=self.team, created_by=self.user, product=self.product, name="P",
@@ -652,21 +600,21 @@ class ProjectApiTests(TestCase):
VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15)
script = ScriptVersion.objects.create(project=project, is_adopted=True)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
_, _, frame_asset = self._mk_shot(project, script, 0, review_status="")
person_asset = self._mk_person(project, script, 0, review_status="")
res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
self.assertEqual(res.status_code, 200)
blockers = res.json()["blockers"]
self.assertTrue(any(b["kind"] == "storyboard" and b["scene_no"] == 1 for b in blockers))
self.assertTrue(any(b["kind"] == "person" and b["scene_no"] == 1 for b in blockers))
# 过审后再查 → 不再拦
frame_asset.review_status = "active"
frame_asset.save(update_fields=["review_status"])
person_asset.review_status = "active"
person_asset.save(update_fields=["review_status"])
res2 = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
self.assertEqual(res2.json()["blockers"], [])
def test_video_review_precheck_skips_succeeded_segments_in_batch(self):
"""整批校验(不传 segment_id)只看未出片的段:已 succeeded 的段即便分镜未过审也不拦。"""
"""整批校验(不传 segment_id)只看未出片的段:已 succeeded 的段即便人物未过审也不拦。"""
project = Project.objects.create(
team=self.team, created_by=self.user, product=self.product, name="P",
current_stage=ProjectStage.Stage.VIDEO,
@@ -674,7 +622,7 @@ class ProjectApiTests(TestCase):
seg = VideoSegment.objects.create(project=project, sort_order=0, status=VideoSegment.Status.SUCCEEDED)
script = ScriptVersion.objects.create(project=project, is_adopted=True)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="n", visual_prompt="v")
self._mk_shot(project, script, 0, review_status="")
self._mk_person(project, script, 0, review_status="")
# 整批:succeeded 段跳过 → 不拦
res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
@@ -936,7 +884,6 @@ class WorkerGateTests(TestCase):
cases = [
(f"/api/projects/{self.project.id}/generate-base-asset/", {"kind": "person"}),
(f"/api/projects/{self.project.id}/generate-triview/", {"portrait_asset_id": "x"}),
(f"/api/projects/{self.project.id}/generate-storyboard/", {}),
(f"/api/projects/{self.project.id}/submit-video-segment/", {"video_segment_id": "x"}),
("/api/ai/generate-image/", {"prompt": "测试"}),
]
+9 -114
View File
@@ -27,17 +27,14 @@ from apps.ai.script_agent import (
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
adopt_storyboard_shot_version,
create_export_job,
generate_base_asset,
generate_person_triview,
get_default_model,
get_inflight_extraction,
poll_storyboard,
poll_video_segment,
regenerate_script_segment,
submit_extract_entities,
submit_storyboard,
submit_video_segment,
synthesize_project_voiceover,
)
@@ -60,10 +57,6 @@ from .models import (
ScriptSegment,
ScriptTemplate,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
@@ -78,14 +71,12 @@ from .serializers import (
QuickCreateJobSerializer,
ScriptTemplateSerializer,
ScriptVersionSerializer,
StoryboardVersionSerializer,
VideoSegmentVersionSerializer,
is_playable_video,
)
from .services.export import run_export_job_in_thread
from .services.pipeline import (
adopt_script_version,
finish_storyboard_stage,
initialize_project_pipeline,
sync_video_segments_to_script,
)
@@ -182,8 +173,8 @@ def promote_base_asset_stage_if_ready(project: Project) -> bool:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.STORYBOARD
project.status = Project.Status.STORYBOARDING
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
return True
@@ -197,7 +188,7 @@ VIDEO_IS_FINAL_STAGE = True
def _ensure_video_stage(project: Project) -> None:
"""幂等地把项目推进到「视频」阶段(生成视频片段时调用)。current_stage 没到 VIDEO 时,
settle_video_completion 会直接返回 → 全段出片也不收口、卡在故事板格(ZWQ#15)。"""
settle_video_completion 会直接返回 → 全段出片也不收口、卡在上一格(ZWQ#15)。"""
if project.current_stage != ProjectStage.Stage.VIDEO:
project.current_stage = ProjectStage.Stage.VIDEO
if project.status != Project.Status.COMPLETED:
@@ -252,10 +243,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"base_asset_groups",
"base_asset_groups__adopted_asset__files",
"base_asset_groups__candidate_assets__files",
"storyboard_versions",
"storyboard_versions__frames__asset__files",
"storyboard_shots__adopted_version__asset__files",
"storyboard_shots__versions__asset__files",
"timeline__clips__asset__files",
).all()
serializer_class = ProjectSerializer
@@ -304,7 +291,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
def retrieve(self, request, *args, **kwargs):
# 详情加载(=进入流水线页)时自愈视频片段数:历史项目在「采用前增删分镜」未同步,
# 或项目创建固定铺的 4 段从未被收口,会让视频步骤的片段数与故事板/采用版分镜对不上。
# 或项目创建固定铺的 4 段从未被收口,会让视频步骤的片段数与采用版分镜对不上。
# 按采用版分镜数收口一次(幂等;只裁从未生成过的尾段,生成中/已出片的段绝不动)。
# 用轻量查询判定 + 同步,再交给 super 做重 prefetch 序列化(避免重查询跑两遍)。
proj = (
@@ -326,7 +313,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if adopted_script is not None:
self._sync_video_segments_to_script(proj, adopted_script)
# 自愈卡阶段:历史项目「视频片段全部出片并采用」却因 current_stage 没推到 VIDEO 而永远停在
# 「3/4 故事板」、不收口(ZWQ#15)。进详情时若检测到全段已出片,就补推到视频阶段再收口。
# 停在上一格、不收口(ZWQ#15)。进详情时若检测到全段已出片,就补推到视频阶段再收口。
segs = list(VideoSegment.objects.filter(project=proj).values_list("status", "adopted_version_id"))
if segs and all(s == VideoSegment.Status.SUCCEEDED and a is not None for s, a in segs):
_ensure_video_stage(proj)
@@ -343,7 +330,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
.order_by("-updated_at")
)
# 列表:轻查询(只 select_related product + 计数注解),不做详情那串 12 个重 prefetch
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
# ——原列表把每个项目的 阶段/片段/时间线/资产文件全拉出,20 个项目实测 ~2s。
if self.action == "list":
qs = (
Project.objects.select_related("product", "product__cover_asset", "timeline", "quick_create_job")
@@ -725,7 +712,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"project__product__cover_asset__files",
"project__script_versions",
"project__base_asset_groups",
"project__storyboard_shots__adopted_version__asset__files",
"project__video_segments__adopted_version__asset__files",
"project__timeline__export_jobs__output_asset__files",
)
@@ -1256,21 +1242,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
已出片的段不改时长——改了会跟已渲染的成片对不上。"""
sync_video_segments_to_script(project, script)
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。"""
if not script.is_adopted:
return
target = script.segments.count()
shots = list(project.storyboard_shots.order_by("sort_order"))
while len(shots) > target:
tail = shots[-1]
if tail.status == StoryboardShot.Status.NOT_STARTED and not tail.versions.exists():
tail.delete()
shots.pop()
else:
break
@action(detail=True, methods=["post"], url_path="update-script-segment")
def update_script_segment(self, request, pk=None):
project = self.get_object()
@@ -1341,7 +1312,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
seg.sort_order = index
seg.save(update_fields=["sort_order", "updated_at"])
self._sync_video_segments_to_script(project, script)
self._sync_storyboard_shots_to_script(project, script)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="delete-script-segment")
@@ -1353,15 +1323,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
script = segment.script_version
# 允许删空:用户可以把镜头全删了(再整体重新生成或手动加),不再强制「至少保留一个分镜」。
# 删本场脚本时,它对应的故事板分镜帧也一起删 —— frame.script_segment 是 SET_NULL,
# 不主动删会留下孤儿帧,故事板里仍显示这一(已删的)场。各版本受影响的帧统一收口后重排 sort_order。
affected_storyboards = list(StoryboardVersion.objects.filter(frames__script_segment=segment).distinct())
StoryboardFrame.objects.filter(script_segment=segment).delete()
for sb in affected_storyboards:
for index, frame in enumerate(sb.frames.order_by("sort_order")):
if frame.sort_order != index:
frame.sort_order = index
frame.save(update_fields=["sort_order", "updated_at"])
segment.delete()
# 删后重排 sort_order:收集变动的镜一次性 bulk_update,替代逐条 save(远程库少跑 N 个往返 → 删除快很多)
resort = []
@@ -1372,7 +1333,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if resort:
ScriptSegment.objects.bulk_update(resort, ["sort_order"])
self._sync_video_segments_to_script(project, script)
self._sync_storyboard_shots_to_script(project, script)
return Response(ScriptVersionSerializer(script).data)
# ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ──
@@ -1402,74 +1362,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
settle_video_completion(project)
return Response(ProjectSerializer(project).data)
@action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None):
"""开始生成故事板(全部场):确保每镜一个 shot 并标 QUEUED;逐场出图交给 poll-storyboard 起线程。
全部已出片时再点 = 整批重跑(全部场重出)。不推进阶段。"""
require_worker()
project = self.get_object()
submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="rerun-storyboard-shot")
def rerun_storyboard_shot_action(self, request, pk=None):
"""单场重跑:只重出该 shot 的图 —— 新增一条历史版本并采用,其余场不动。出图交给 poll-storyboard。"""
require_worker()
project = self.get_object()
shot_id = request.data.get("shot_id")
if not project.storyboard_shots.filter(id=shot_id).exists():
return Response({"detail": "shot not found"}, status=status.HTTP_404_NOT_FOUND)
submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""), shot_ids=[shot_id])
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="adopt-storyboard-shot-version")
def adopt_storyboard_shot_version_action(self, request, pk=None):
"""采用某场的某个历史版本(对标 adopt-video-version):切换该场采用的分镜图。"""
project = self.get_object()
shot = StoryboardShot.objects.filter(project=project, id=request.data.get("shot_id")).first()
if shot is None:
return Response({"detail": "shot not found"}, status=status.HTTP_404_NOT_FOUND)
version = StoryboardShotVersion.objects.filter(shot=shot, id=request.data.get("version_id")).first()
if version is None:
return Response({"detail": "version not found"}, status=status.HTTP_404_NOT_FOUND)
adopt_storyboard_shot_version(shot=shot, version=version)
return Response(ProjectSerializer(self.get_object()).data)
@action(detail=True, methods=["post"], url_path="poll-storyboard")
def poll_storyboard_action(self, request, pk=None):
"""异步故事板·轮询:为在制场起线程出图(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
project = self.get_object()
result = poll_storyboard(project=project, user=request.user)
if result.get("status") == "succeeded":
finish_storyboard_stage(project)
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
return Response(result, status=http_status)
@action(detail=True, methods=["post"], url_path="skip-storyboard")
@transaction.atomic
def skip_storyboard(self, request, pk=None):
project = self.get_object()
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SKIPPED
stage.save(update_fields=["status", "updated_at"])
# 跳过故事板直接进视频时,同样把视频片段数对齐到采用版分镜数(已生成的段不动)
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if adopted_script is not None:
self._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"])
return Response(ProjectSerializer(project).data)
@action(detail=True, methods=["post"], url_path="video-review-precheck")
def video_review_precheck(self, request, pk=None):
"""点「生成视频」前的过审校验:返回未过审(非 active)的人物立绘 / 故事板分镜清单。
"""点「生成视频」前的过审校验:返回未过审(非 active)的人物立绘清单。
body 可选 video_segment_id:只校验该段(单段重跑);不传 = 校验全部未出片段。
blockers 空 = 全部已过审,前端放行生成。"""
from apps.ai.services import collect_video_review_blockers
@@ -1511,9 +1406,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
except Exception: # noqa: BLE001
logger.error("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True)
# 生成视频即代表已进入视频阶段:某些路径(跳过故事板/异常)可能没把 current_stage 推到 VIDEO,
# 生成视频即代表已进入视频阶段:某些路径(异常/老数据)可能没把 current_stage 推到 VIDEO,
# 而 settle_video_completion 只在 current_stage==VIDEO 时收口 → 不补推的话,全段出片后项目会永远停在
# 「3/4 故事板」、永不 completed(ZWQ#15)。这里幂等补推。
# 停在上一格、永不 completed(ZWQ#15)。这里幂等补推。
_ensure_video_stage(project)
# 重跑某段 → 该段回到在制;若项目此前已 completed,退回 videoing
settle_video_completion(project)