Files
yingqing/core/backend/apps/projects/serializers.py
T

654 lines
26 KiB
Python

import uuid
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from apps.assets.serializers import AssetFileSerializer
from .models import (
BaseAssetGroup,
BgmTrack,
ExportJob,
Project,
ProjectStage,
QuickCreateJob,
ScriptSegment,
ScriptTemplate,
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 ""
_VIDEO_SUFFIXES = (".mp4", ".mov", ".webm", ".m4v")
def is_playable_video(asset) -> bool:
"""主文件是不是真能塞进 <video> 播的视频。演示种子数据里有「导出任务标成功、挂的却是一张 PNG 海报」
的成片,直接下发会让播放按钮打开一个放不出来的图。"""
if asset is None:
return False
files = list(asset.files.all())
primary = next((f for f in files if f.is_primary), files[0] if files else None)
if primary is None:
return False
if (primary.content_type or "").startswith("video/"):
return True
return (primary.object_key or "").lower().endswith(_VIDEO_SUFFIXES)
def _final_video_url(project) -> str:
"""合成成片(最新一次成功的拼接导出)的可播放 URL;没合成过返回空串。
在 Python 侧筛选而不再查库:列表页用 Prefetch 预取(见 ProjectViewSet.get_queryset),
详情页 timeline 只有一条、export_jobs 数量极少,直接遍历不会放大查询。"""
timeline = getattr(project, "timeline", None)
if timeline is None:
return ""
succeeded = [
job for job in timeline.export_jobs.all()
if job.status == ExportJob.Status.SUCCEEDED and is_playable_video(job.output_asset)
]
if not succeeded:
return ""
dated = [job for job in succeeded if job.created_at is not None]
latest = max(dated, key=lambda job: job.created_at) if dated else succeeded[-1]
return _asset_preview_url(latest.output_asset)
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 QuickCreateJobSerializer(serializers.ModelSerializer):
"""极速成片轮询响应:只下发用户需要的四阶段进度和最终可播结果。"""
project_id = serializers.UUIDField(source="project.id", read_only=True)
product_id = serializers.UUIDField(source="project.product_id", read_only=True)
product_name = serializers.CharField(source="project.product.title", read_only=True)
title = serializers.CharField(source="project.name", read_only=True)
product_images = serializers.SerializerMethodField()
phase_index = serializers.SerializerMethodField()
settings = serializers.SerializerMethodField()
result = serializers.SerializerMethodField()
error_message = serializers.SerializerMethodField()
class Meta:
model = QuickCreateJob
fields = [
"id",
"project_id",
"product_id",
"product_name",
"title",
"product_images",
"status",
"phase",
"phase_index",
"settings",
"progress",
"message",
"error_message",
"result",
"created_at",
"updated_at",
"completed_at",
]
read_only_fields = fields
def get_error_message(self, obj) -> str:
from apps.projects.services.quick_create import _public_error
stored = obj.error_message or ""
if obj.status != QuickCreateJob.Status.FAILED:
return stored
hidden = str((obj.metadata or {}).get("internal_error") or "")
return _public_error(" ".join(part for part in (stored, hidden) if part)) or stored
def get_product_images(self, obj) -> list[dict]:
product = getattr(obj.project, "product", None)
if product is None:
return []
return [
{
"asset_id": str(image.asset_id),
"url": _asset_preview_url(image.asset),
}
for image in product.images.all()
]
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
return {
QuickCreateJob.Phase.PRODUCT: 0,
QuickCreateJob.Phase.SCRIPT: 0,
QuickCreateJob.Phase.ASSETS: 1,
QuickCreateJob.Phase.COMPLETE: 3,
}.get(obj.phase, 0)
def get_settings(self, obj) -> dict:
wizard = dict((obj.project.metadata or {}).get("wizard") or {})
return {
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"),
"resolution": str(wizard.get("resolution") or "720p").lower(),
"total_duration": int(wizard.get("total_duration") or 15),
"video_model_config_id": str(wizard.get("video_model_config_id") or ""),
"video_model_name": str(wizard.get("video_model_name") or ""),
"video_model_label": str(wizard.get("video_model_label") or ""),
}
def get_result(self, obj) -> dict | None:
project = obj.project
settings = self.get_settings(obj)
final_video_url = _final_video_url(project)
video_url = final_video_url
segments = list(project.video_segments.all())
if not video_url:
for segment in sorted(segments, key=lambda item: item.sort_order):
version = segment.adopted_version
if version is not None and is_playable_video(version.asset):
video_url = _asset_preview_url(version.asset)
break
product = getattr(project, "product", None)
cover = getattr(product, "cover_asset", None) if product is not None else None
poster_url = _asset_preview_url(cover) if cover else ""
if not poster_url and product is not None:
first_image = next(iter(product.images.all()), None)
poster_url = _asset_preview_url(first_image.asset) if first_image else ""
script = next((version for version in project.script_versions.all() if version.is_adopted), None)
script_meta = (script.metadata or {}) if script is not None else {}
format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get(
script_meta.get("presentation_format"), "智能推荐"
)
structure_label = {
"pain": "痛点解决",
"contrast": "前后对比",
"review": "测评验证",
"scene": "场景种草",
}.get(script_meta.get("video_structure"), "智能推荐")
groups = list(project.base_asset_groups.all())
person = next((group for group in groups if group.kind == BaseAssetGroup.Kind.PERSON and group.adopted_asset_id), None)
scene = next((group for group in groups if group.kind == BaseAssetGroup.Kind.SCENE and group.adopted_asset_id), None)
duration = sum(segment.target_duration_seconds for segment in segments)
video_segments = []
for segment in sorted(segments, key=lambda item: item.sort_order):
version = segment.adopted_version
clip_url = _asset_preview_url(version.asset) if version is not None and is_playable_video(version.asset) else ""
video_segments.append(
{
"id": str(segment.id),
"sort_order": segment.sort_order,
"duration_seconds": segment.target_duration_seconds or 15,
"video_url": clip_url,
"poster_url": poster_url,
}
)
return {
"video_url": video_url,
"final_video_url": final_video_url,
"poster_url": poster_url,
"duration_seconds": duration or 15,
"aspect_ratio": settings["aspect_ratio"],
"resolution": settings["resolution"],
"video_model": settings["video_model_label"] or settings["video_model_name"],
"structure": structure_label,
"presentation": format_label,
"person": ((person.metadata or {}).get("label") or "自动匹配") if person else "自动匹配",
"scene": ((scene.metadata or {}).get("label") or "自动匹配") if scene else "自动匹配",
"video_segments": video_segments,
}
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
def _quick_create_job(obj: Project):
try:
return obj.quick_create_job
except ObjectDoesNotExist:
return None
def _quick_create_status(obj: Project) -> str:
job = _quick_create_job(obj)
return job.status if job else ""
def _quick_create_job_id(obj: Project) -> str:
job = _quick_create_job(obj)
return str(job.id) if job else ""
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)
# 合成成片地址:项目列表的播放按钮据此直接播成片(没合成过为空 → 退回进流水线)
final_video_url = serializers.SerializerMethodField()
quick_create = serializers.SerializerMethodField()
quick_create_status = serializers.SerializerMethodField()
quick_create_job_id = serializers.SerializerMethodField()
class Meta:
model = Project
fields = [
"id", "name", "product", "product_title", "cover_preview_url",
"status", "current_stage", "script_version_count", "video_segment_count",
"final_video_url", "quick_create", "quick_create_status", "quick_create_job_id",
"is_deleted", "purged_at", "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 ""
def get_final_video_url(self, obj) -> str:
return _final_video_url(obj)
def get_quick_create(self, obj) -> bool:
return bool((obj.metadata or {}).get("quick_create"))
def get_quick_create_status(self, obj) -> str:
return _quick_create_status(obj)
def get_quick_create_job_id(self, obj) -> str:
return _quick_create_job_id(obj)
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)
# 合成成片地址(最新一次成功拼接):视频阶段的「播放成片 / 下载成片」直接用它
final_video_url = serializers.SerializerMethodField()
quick_create_status = serializers.SerializerMethodField()
quick_create_job_id = serializers.SerializerMethodField()
class Meta:
model = Project
fields = [
"id",
"name",
"product",
"status",
"current_stage",
"budget_limit",
"failure_reason",
"metadata",
"is_deleted",
"purged_at",
"stages",
"script_versions",
"base_asset_groups",
"storyboard_versions",
"storyboard_shots",
"video_segments",
"timeline",
"final_video_url",
"quick_create_status",
"quick_create_job_id",
"created_at",
"updated_at",
]
read_only_fields = ["id", "status", "current_stage", "failure_reason", "is_deleted", "purged_at", "created_at", "updated_at"]
def get_final_video_url(self, obj) -> str:
return _final_video_url(obj)
def get_quick_create_status(self, obj) -> str:
return _quick_create_status(obj)
def get_quick_create_job_id(self, obj) -> str:
return _quick_create_job_id(obj)
class ScriptTemplateSerializer(serializers.ModelSerializer):
"""套路模板 · 列表与详情共用。写入只开放 name(其余字段由存模板端点从脚本抽)。"""
outline_text = serializers.SerializerMethodField()
source_project_name = serializers.CharField(source="source_project.name", default="", read_only=True)
shot_count = serializers.SerializerMethodField()
class Meta:
model = ScriptTemplate
fields = [
"id",
"name",
"presentation_format",
"video_structure",
"persona",
"total_duration",
"outline",
"outline_text",
"shot_count",
"cta",
"scenes",
"source_project",
"source_project_name",
"usage_count",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"presentation_format",
"video_structure",
"persona",
"total_duration",
"outline",
"cta",
"scenes",
"source_project",
"usage_count",
"created_at",
"updated_at",
]
def get_outline_text(self, obj) -> str:
from .services.templates import render_outline_text
return render_outline_text(
{"outline": obj.outline, "cta": obj.cta}
)
def get_shot_count(self, obj) -> int:
return len(obj.outline or [])