完善二期清单

This commit is contained in:
Azmat@qq.com
2026-08-18 14:11:05 +08:00
parent d1ecb52125
commit dfe94a923e
55 changed files with 1754 additions and 137 deletions
+101
View File
@@ -11,6 +11,7 @@ from .models import (
Project,
ProjectStage,
ScriptSegment,
ScriptTemplate,
ScriptVersion,
StoryboardFrame,
StoryboardShot,
@@ -34,6 +35,41 @@ def _asset_preview_url(asset) -> str:
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
@@ -321,18 +357,24 @@ class ProjectListSerializer(serializers.ModelSerializer):
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()
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",
"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)
class ProjectSerializer(serializers.ModelSerializer):
stages = ProjectStageSerializer(many=True, read_only=True)
@@ -342,6 +384,8 @@ class ProjectSerializer(serializers.ModelSerializer):
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
@@ -363,7 +407,64 @@ class ProjectSerializer(serializers.ModelSerializer):
"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 [])