Files
yingqing/core/backend/apps/projects/serializers.py
T
zycandClaude Opus 4.8 658aaa222f 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>
2026-06-24 17:11:55 +08:00

368 lines
15 KiB
Python

import uuid
from rest_framework import serializers
from apps.assets.serializers import AssetFileSerializer
from .models import (
BaseAssetGroup,
BgmTrack,
ExportJob,
Project,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
StoryboardShotVersion,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
def _asset_preview_url(asset) -> str:
"""资产主文件的可播放/可显示 URL(主图优先,其次首张),内嵌进各阶段序列化,
让前端缩略图不再依赖(分页 20 条的)团队 assets 列表解析——团队资产 >20 时新生成的图本会丢。"""
if asset is None:
return ""
files = list(asset.files.all())
primary = next((f for f in files if f.is_primary), files[0] if files else None)
return AssetFileSerializer().get_preview_url(primary) if primary else ""
class ProjectStageSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectStage
fields = ["id", "stage", "status", "started_at", "completed_at", "error_message", "metadata"]
read_only_fields = fields
class VideoSegmentSerializer(serializers.ModelSerializer):
adopted_asset = serializers.SerializerMethodField()
adopted_asset_url = serializers.SerializerMethodField()
versions = serializers.SerializerMethodField()
class Meta:
model = VideoSegment
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset", "adopted_asset_url", "versions"]
read_only_fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
def get_adopted_asset(self, obj):
# pipeline stage4 缩略图:暴露已采用版本对应的资产 id(供前端在 assets 里解析 preview_url)
version = obj.adopted_version
return str(version.asset_id) if version and version.asset_id else None
def get_adopted_asset_url(self, obj) -> str:
version = obj.adopted_version
return _asset_preview_url(version.asset) if version is not None else ""
def get_versions(self, obj):
# 视频详情弹窗:历史版本(倒序,带可播放 URL)。模型无 Meta.ordering,这里按 created_at 排
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,
"created_at": v.created_at.isoformat() if v.created_at else "",
}
for v in versions
]
class BaseAssetGroupSerializer(serializers.ModelSerializer):
candidate_assets = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
adopted_asset_url = serializers.SerializerMethodField()
candidate_asset_urls = serializers.SerializerMethodField()
# 采用资产的火山审核态随项目详情下发,刷新后仍能回显绿/红/审核中徽章(前端不再依赖那次性的轮询)
adopted_asset_review = serializers.SerializerMethodField()
adopted_asset_review_error = serializers.SerializerMethodField()
class Meta:
model = BaseAssetGroup
fields = ["id", "kind", "prompt", "adopted_asset", "adopted_asset_url", "candidate_assets", "candidate_asset_urls", "adopted_asset_review", "adopted_asset_review_error", "version", "metadata", "created_at"]
read_only_fields = fields
def get_adopted_asset_url(self, obj) -> str:
return _asset_preview_url(obj.adopted_asset)
def get_adopted_asset_review(self, obj) -> str:
return getattr(obj.adopted_asset, "review_status", "") or ""
def get_adopted_asset_review_error(self, obj) -> str:
return getattr(obj.adopted_asset, "review_error", "") or ""
def get_candidate_asset_urls(self, obj) -> dict:
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
fields = ["id", "video_segment", "asset", "prompt", "is_adopted", "metadata", "created_at"]
read_only_fields = fields
class TimelineClipSerializer(serializers.ModelSerializer):
# 直接内嵌片段资产的可播放 URL + 是否视频,前端播放器无需再依赖(分页的)团队 assets 列表解析
asset_url = serializers.SerializerMethodField()
asset_is_video = serializers.SerializerMethodField()
class Meta:
model = TimelineClip
fields = ["id", "asset", "asset_url", "asset_is_video", "sort_order", "start_ms", "duration_ms", "trim_start_ms", "trim_end_ms"]
read_only_fields = ["id", "asset_url", "asset_is_video"]
def _primary_file(self, obj):
asset = obj.asset
if asset is None:
return None
files = list(asset.files.all())
return next((f for f in files if f.is_primary), files[0] if files else None)
def get_asset_url(self, obj) -> str:
f = self._primary_file(obj)
return AssetFileSerializer().get_preview_url(f) if f else ""
def get_asset_is_video(self, obj) -> bool:
asset = obj.asset
if asset is None:
return False
if asset.asset_type == "video":
return True
f = self._primary_file(obj)
return bool(f and "video/" in (f.content_type or ""))
class TimelineExportJobSerializer(serializers.ModelSerializer):
class Meta:
model = ExportJob
fields = ["id", "status", "output_asset", "progress", "error_message", "created_at", "updated_at"]
read_only_fields = fields
class SubtitleTrackSerializer(serializers.ModelSerializer):
class Meta:
model = SubtitleTrack
fields = ["id", "content", "style", "enabled"]
read_only_fields = fields
class BgmTrackSerializer(serializers.ModelSerializer):
asset_url = serializers.SerializerMethodField()
asset_name = serializers.SerializerMethodField()
class Meta:
model = BgmTrack
fields = ["id", "asset", "asset_url", "asset_name", "volume", "start_ms"]
read_only_fields = fields
def get_asset_url(self, obj) -> str:
return _asset_preview_url(obj.asset)
def get_asset_name(self, obj) -> str:
return obj.asset.name if obj.asset_id else ""
class TimelineSerializer(serializers.ModelSerializer):
clips = TimelineClipSerializer(many=True, read_only=True)
export_jobs = TimelineExportJobSerializer(many=True, read_only=True)
subtitle_tracks = SubtitleTrackSerializer(many=True, read_only=True)
bgm_tracks = BgmTrackSerializer(many=True, read_only=True)
voiceover = serializers.SerializerMethodField()
class Meta:
model = Timeline
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "voiceover", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
read_only_fields = ["id", "voiceover", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
def get_voiceover(self, obj):
# 旁白配音映射(metadata.voiceover)+ 每段新鲜的可播放 URL(TOS 签名 URL 会过期,不能存死)
vo = (obj.metadata or {}).get("voiceover")
if not isinstance(vo, dict) or not vo.get("items"):
return None
from apps.assets.models import Asset
ids = []
for item in vo["items"]:
try:
ids.append(uuid.UUID(str(item.get("asset"))))
except (TypeError, ValueError):
continue
assets = {str(a.id): a for a in Asset.objects.filter(id__in=ids).prefetch_related("files")}
items = []
for item in vo["items"]:
asset = assets.get(str(item.get("asset")))
items.append({**item, "asset_url": _asset_preview_url(asset)})
return {
"enabled": bool(vo.get("enabled")),
"voice_type": str(vo.get("voice_type") or ""),
"speed_ratio": vo.get("speed_ratio", 1.0),
"items": items,
}
class ExportJobSerializer(serializers.ModelSerializer):
class Meta:
model = ExportJob
fields = ["id", "status", "output_asset", "progress", "error_message", "metadata", "created_at", "updated_at"]
read_only_fields = fields
class ScriptSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptSegment
fields = [
"id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points",
"role", "speaker", "product_exposure", "entity_refs", "dialogue",
]
read_only_fields = fields
class ScriptVersionSerializer(serializers.ModelSerializer):
segments = ScriptSegmentSerializer(many=True, read_only=True)
class Meta:
model = ScriptVersion
# metadata 携带 ScriptDraft 的 hook/tone/entities,供前端结构化渲染与下游故事板 @图N
fields = ["id", "title", "content", "source", "is_adopted", "segments", "metadata", "created_at", "updated_at"]
read_only_fields = fields
class ProjectListSerializer(serializers.ModelSerializer):
"""列表/仪表盘/侧栏用的轻量项目序列化:不嵌套 阶段/片段/故事板/时间线(那些只详情页要)。
脚本数/镜数走 annotate 计数(见 ProjectViewSet.get_queryset),避免逐项目拉全套关联(原列表 2-3s)。"""
product_title = serializers.CharField(source="product.title", read_only=True, default="")
cover_preview_url = serializers.SerializerMethodField()
script_version_count = serializers.IntegerField(read_only=True, default=0)
video_segment_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Project
fields = [
"id", "name", "product", "product_title", "cover_preview_url",
"status", "current_stage", "script_version_count", "video_segment_count",
"created_at", "updated_at",
]
def get_cover_preview_url(self, obj) -> str:
return _asset_preview_url(getattr(obj.product, "cover_asset", None)) if obj.product_id else ""
class ProjectSerializer(serializers.ModelSerializer):
stages = ProjectStageSerializer(many=True, read_only=True)
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)
class Meta:
model = Project
fields = [
"id",
"name",
"product",
"status",
"current_stage",
"budget_limit",
"failure_reason",
"metadata",
"stages",
"script_versions",
"base_asset_groups",
"storyboard_versions",
"storyboard_shots",
"video_segments",
"timeline",
"created_at",
"updated_at",
]
read_only_fields = ["id", "status", "current_stage", "failure_reason", "created_at", "updated_at"]