优化脚本
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": "测试"}),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user