feat(storyboard): 故事板改分镜制(单场独立重跑/历史)+ 视频过审闸 + 审核前端如实化

三块改动在 services/views/pipeline/App/api 等文件按行交织、且过审闸后端依赖故事板新
模型,无法拆成各自独立可编译的提交,故合并为一次:

· 故事板分镜制:新增 StoryboardShot/StoryboardShotVersion(对标 VideoSegment/Version)
  + 数据迁移(采用版每帧转一 shot+已采用版本)。每镜一个 shot,各自历史版本,可单场
  重跑、逐场切换/采用,新增「开始生成故事板/单场重跑/采用某版」端点。重跑非阻塞(后台
  静默轮询出图,不锁其它场按钮);历史「查看」与「采用」分离(切版本不丢在制重跑)。
  视频取分镜图/删分镜清理/过审闸全部改读 storyboard_shots。
· 视频生成前过审闸:点生成校验含真人脸的人物立绘/故事板分镜是否过审,未过审弹窗列出
  哪一镜/哪个,可一键送审,挡住不生成(collect_video_review_blockers + precheck 端点)。
· 审核前端如实化:不再 `|| "processing"` 伪造审核中;送审失败经 onNotify 如实提示;
  审核轮询扩到视频阶段,送审后能看着盾变绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-24 17:11:55 +08:00
co-authored by Claude Opus 4.8
parent d6cb280018
commit 658aaa222f
12 changed files with 936 additions and 243 deletions
+4
View File
@@ -9,6 +9,8 @@ from .models import (
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
@@ -36,6 +38,8 @@ admin.site.register(ScriptSegment)
admin.site.register(BaseAssetGroup)
admin.site.register(StoryboardVersion)
admin.site.register(StoryboardFrame)
admin.site.register(StoryboardShot)
admin.site.register(StoryboardShotVersion)
admin.site.register(VideoSegment)
admin.site.register(VideoSegmentVersion)
admin.site.register(Timeline)
@@ -0,0 +1,134 @@
# Generated by Django 5.1.15 on 2026-06-24 08:01
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ai", "0016_person_triview_16_9"),
("assets", "0006_alter_asset_category_model"),
("projects", "0004_project_projects_pr_team_id_e667f8_idx"),
]
operations = [
migrations.CreateModel(
name="StoryboardShot",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
(
"status",
models.CharField(
choices=[
("not_started", "Not Started"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
],
default="not_started",
max_length=32,
),
),
("prompt", models.TextField(blank=True)),
("error_message", models.TextField(blank=True)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="storyboard_shots",
to="projects.project",
),
),
(
"script_segment",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="storyboard_shots",
to="projects.scriptsegment",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="StoryboardShotVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("prompt", models.TextField(blank=True)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="storyboard_shot_versions",
to="assets.asset",
),
),
(
"shot",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="projects.storyboardshot",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="storyboard_shot_versions",
to="ai.aitask",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddField(
model_name="storyboardshot",
name="adopted_version",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="adopted_by_shots",
to="projects.storyboardshotversion",
),
),
migrations.AlterUniqueTogether(
name="storyboardshot",
unique_together={("project", "sort_order")},
),
]
@@ -0,0 +1,50 @@
from django.db import migrations
def forwards(apps, schema_editor):
"""把旧「整版制」故事板迁到新「分镜制」:每个项目只取采用版(无采用版则取最新版)的每一帧,
建一个 StoryboardShot + 一条已采用 StoryboardShotVersion(沿用同一张图)。旧的其它整版历史丢弃。"""
Project = apps.get_model("projects", "Project")
StoryboardVersion = apps.get_model("projects", "StoryboardVersion")
StoryboardShot = apps.get_model("projects", "StoryboardShot")
StoryboardShotVersion = apps.get_model("projects", "StoryboardShotVersion")
for project in Project.objects.all().iterator():
adopted = (
StoryboardVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first()
or StoryboardVersion.objects.filter(project=project).order_by("-created_at").first()
)
if adopted is None:
continue
seen_orders = set()
for frame in adopted.frames.order_by("sort_order", "created_at"):
if frame.sort_order in seen_orders: # 同序帧只保第一张,守 (project, sort_order) 唯一约束
continue
seen_orders.add(frame.sort_order)
shot = StoryboardShot.objects.create(
project=project,
script_segment=frame.script_segment,
sort_order=frame.sort_order,
status="succeeded",
prompt=frame.prompt or "",
)
version = StoryboardShotVersion.objects.create(
shot=shot, asset=frame.asset, prompt=frame.prompt or "", is_adopted=True,
)
shot.adopted_version = version
shot.save(update_fields=["adopted_version"])
def backwards(apps, schema_editor):
StoryboardShot = apps.get_model("projects", "StoryboardShot")
StoryboardShot.objects.all().delete() # version 随 shot 级联删
class Migration(migrations.Migration):
dependencies = [
("projects", "0005_storyboardshot_storyboardshotversion_and_more"),
]
operations = [
migrations.RunPython(forwards, backwards),
]
+48
View File
@@ -151,6 +151,54 @@ class StoryboardFrame(TimeStampedModel):
ordering = ["sort_order", "created_at"]
# ── 故事板「分镜制」(对标视频 VideoSegment/VideoSegmentVersion)──
# 一镜 = 一个 StoryboardShot(有自己的采用版 + 状态);每次出图 = 一条 StoryboardShotVersion(历史版本)。
# 取代旧的「整版制」StoryboardVersion/StoryboardFrame:单场可独立重跑、单场各有历史、可逐场切换采用。
class StoryboardShot(TimeStampedModel):
class Status(models.TextChoices):
NOT_STARTED = "not_started", "Not Started"
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="storyboard_shots")
script_segment = models.ForeignKey(
ScriptSegment,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="storyboard_shots",
)
sort_order = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED)
prompt = models.TextField(blank=True)
adopted_version = models.ForeignKey(
"projects.StoryboardShotVersion",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="adopted_by_shots",
)
error_message = models.TextField(blank=True)
class Meta:
unique_together = [("project", "sort_order")]
ordering = ["sort_order", "created_at"]
class StoryboardShotVersion(TimeStampedModel):
shot = models.ForeignKey(StoryboardShot, on_delete=models.CASCADE, related_name="versions")
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="storyboard_shot_versions")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="storyboard_shot_versions")
prompt = models.TextField(blank=True)
is_adopted = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
class VideoSegment(TimeStampedModel):
class Status(models.TextChoices):
NOT_STARTED = "not_started", "Not Started"
+53 -1
View File
@@ -13,6 +13,8 @@ from .models import (
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
@@ -130,6 +132,54 @@ class StoryboardVersionSerializer(serializers.ModelSerializer):
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
@@ -289,7 +339,8 @@ 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_versions = StoryboardVersionSerializer(many=True, read_only=True) # 过渡期保留(旧整版,新生成不再写)
storyboard_shots = StoryboardShotSerializer(many=True, read_only=True)
timeline = TimelineSerializer(read_only=True)
class Meta:
@@ -307,6 +358,7 @@ class ProjectSerializer(serializers.ModelSerializer):
"script_versions",
"base_asset_groups",
"storyboard_versions",
"storyboard_shots",
"video_segments",
"timeline",
"created_at",
+122
View File
@@ -13,6 +13,10 @@ from apps.projects.models import (
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
@@ -470,6 +474,124 @@ class ProjectApiTests(TestCase):
# 已出片的尾段保留 → 不裁到 1,停在 2
self.assertEqual(project.video_segments.count(), 2)
def _mk_image_model(self):
return ModelConfig.objects.create(
provider=self.provider, name="gpt-image-2", display_name="img",
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
)
def _mk_shot(self, project, script, order, *, review_status="active"):
"""建一个已出片的 StoryboardShot(+ 已采用版本,资产审核态可控),供过审/采用测试。"""
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
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;
过审(active)后 → blockers 清空,可放行生成。"""
project = Project.objects.create(
team=self.team, created_by=self.user, product=self.product, name="P",
current_stage=ProjectStage.Stage.VIDEO,
)
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="")
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))
# 过审后再查 → 不再拦
frame_asset.review_status = "active"
frame_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 的段即便分镜未过审也不拦。"""
project = Project.objects.create(
team=self.team, created_by=self.user, product=self.product, name="P",
current_stage=ProjectStage.Stage.VIDEO,
)
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="")
# 整批:succeeded 段跳过 → 不拦
res = self.client.post(f"/api/projects/{project.id}/video-review-precheck/", format="json")
self.assertEqual(res.json()["blockers"], [])
# 指定该段(单段重跑):无论 succeeded 都校验 → 拦
res2 = self.client.post(
f"/api/projects/{project.id}/video-review-precheck/",
{"video_segment_id": str(seg.id)}, format="json",
)
self.assertTrue(len(res2.json()["blockers"]) >= 1)
def test_adopt_video_version_remaps_timeline_draft(self):
"""切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
+71 -6
View File
@@ -18,12 +18,13 @@ from apps.ai.script_agent import stream_script_agent
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
adopt_storyboard_shot_version,
create_export_job,
generate_base_asset,
generate_person_triview,
generate_storyboard_frame,
get_default_model,
get_inflight_extraction,
poll_storyboard,
poll_video_segment,
regenerate_script_segment,
submit_extract_entities,
@@ -46,6 +47,8 @@ from .models import (
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
@@ -130,6 +133,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"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
@@ -511,6 +516,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
next_order += 1
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()
@@ -575,6 +595,7 @@ 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")
@@ -605,6 +626,7 @@ 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 · 视频版本采用(详情弹窗里切历史版) ──
@@ -633,20 +655,48 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None):
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。"""
"""开始生成故事板(全部场):确保每镜一个 shot 并标 QUEUED;逐场出图交给 poll-storyboard 起线程。
全部已出片时再点 = 整批重跑(全部场重出)。不推进阶段。"""
require_worker()
project = self.get_object()
storyboard = submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
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(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_202_ACCEPTED)
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 阶段。"""
"""异步故事板·轮询:为在制场起线程出图(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
project = self.get_object()
result = generate_storyboard_frame(project=project, user=request.user)
result = poll_storyboard(project=project, user=request.user)
if result.get("status") == "succeeded":
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
@@ -677,6 +727,21 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
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)的人物立绘 / 故事板分镜清单。
body 可选 video_segment_id:只校验该段(单段重跑);不传 = 校验全部未出片段。
blockers 空 = 全部已过审,前端放行生成。"""
from apps.ai.services import collect_video_review_blockers
project = self.get_object()
seg = None
sid = request.data.get("video_segment_id")
if sid:
seg = project.video_segments.filter(id=sid).first()
blockers = collect_video_review_blockers(project, only_segment=seg)
return Response({"blockers": blockers})
@action(detail=True, methods=["post"], url_path="submit-video-segment")
def submit_video_segment_action(self, request, pk=None):
# 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。