605 lines
24 KiB
Python
605 lines
24 KiB
Python
import uuid
|
|
|
|
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()
|
|
|
|
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_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)
|
|
video_url = _final_video_url(project)
|
|
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
|
|
first_shot = next(
|
|
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id),
|
|
None,
|
|
)
|
|
poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot 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,
|
|
"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
|
|
|
|
|
|
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()
|
|
|
|
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",
|
|
"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"))
|
|
|
|
|
|
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()
|
|
|
|
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",
|
|
"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)
|
|
|
|
|
|
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 [])
|