/api/projects/ 列表此前与详情共用重序列化器(嵌套 阶段/片段/故事板/时间线 + 每资产算 url), 20 个项目把全套关联拉出 → 实测 ~2-3s,正是每次刷新慢的主因。 - 新增 ProjectListSerializer(轻:名/状态/阶段/product/封面/脚本数/镜数); ProjectViewSet.get_serializer_class 列表用轻的、详情用全量; get_queryset 列表走轻查询(select_related product + annotate 计数,不做那串 12 个重 prefetch)。 实测 /api/projects/ 2-3s → 0.48s。 - 新增 /api/projects/summary/(total + by_status,只跑 COUNT,不拉项目对象)。 - 计数显示(侧栏徽标 / 仪表盘 总项目·SKU·N 个)改用分页响应的真实 count(productTotal/projectTotal), 不再用「已加载 20 条」的长度;projects 列表页脚本状态/镜数改读 *_count 字段。 闸:9 页全绿、无瀑布、API 全过;projects 端点提速 ~5x。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
298 lines
11 KiB
Python
298 lines
11 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,
|
|
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()
|
|
|
|
class Meta:
|
|
model = BaseAssetGroup
|
|
fields = ["id", "kind", "prompt", "adopted_asset", "adopted_asset_url", "candidate_assets", "candidate_asset_urls", "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_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()
|
|
|
|
class Meta:
|
|
model = StoryboardFrame
|
|
fields = ["id", "script_segment", "asset", "asset_url", "sort_order", "prompt"]
|
|
read_only_fields = fields
|
|
|
|
def get_asset_url(self, obj) -> str:
|
|
return _asset_preview_url(obj.asset)
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
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",
|
|
"video_segments",
|
|
"timeline",
|
|
"created_at",
|
|
"updated_at",
|
|
]
|
|
read_only_fields = ["id", "status", "current_stage", "failure_reason", "created_at", "updated_at"]
|