feat(core/backend): pipeline continuity + threaded ffmpeg burn-in export + upload/save-timeline
Video pipeline (script→assets→storyboard→video→stitch): - robust split_script_into_segments (4 non-empty scenes), scene-aware storyboard/video prompts - link VideoSegment→ScriptSegment + storyboard-frame reference image (graceful text fallback) - idempotent poll_video_segment (no double-charge on repeated polling) - threaded export (no Celery worker needed) + poll-export endpoint - run_export_job rewritten to filter_complex: per-clip trim, xfade transitions, subtitle burn-in (Pillow PNG overlay; this ffmpeg lacks libass), BGM mix - upload-video-segment / upload-bgm / save-timeline endpoints - serializers embed asset preview URLs (beat assets pagination); Pillow added to requirements Also includes prior uncommitted backend work: account preferences/sessions, billing trend, product/asset endpoints, accounts 0002 migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.assets.serializers import AssetFileSerializer
|
||||
|
||||
from .models import (
|
||||
BaseAssetGroup,
|
||||
BgmTrack,
|
||||
@@ -18,6 +20,16 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -27,10 +39,11 @@ class ProjectStageSerializer(serializers.ModelSerializer):
|
||||
|
||||
class VideoSegmentSerializer(serializers.ModelSerializer):
|
||||
adopted_asset = serializers.SerializerMethodField()
|
||||
adopted_asset_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = VideoSegment
|
||||
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset"]
|
||||
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset", "adopted_asset_url"]
|
||||
read_only_fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
|
||||
|
||||
def get_adopted_asset(self, obj):
|
||||
@@ -38,22 +51,39 @@ class VideoSegmentSerializer(serializers.ModelSerializer):
|
||||
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 ""
|
||||
|
||||
|
||||
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", "candidate_assets", "version", "metadata", "created_at"]
|
||||
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", "sort_order", "prompt"]
|
||||
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)
|
||||
@@ -72,10 +102,34 @@ class VideoSegmentVersionSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class TimelineClipSerializer(serializers.ModelSerializer):
|
||||
# 直接内嵌片段资产的可播放 URL + 是否视频,前端播放器无需再依赖(分页的)团队 assets 列表解析
|
||||
asset_url = serializers.SerializerMethodField()
|
||||
asset_is_video = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TimelineClip
|
||||
fields = ["id", "asset", "sort_order", "start_ms", "duration_ms", "trim_start_ms", "trim_end_ms"]
|
||||
read_only_fields = ["id"]
|
||||
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):
|
||||
@@ -93,11 +147,20 @@ class SubtitleTrackSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class BgmTrackSerializer(serializers.ModelSerializer):
|
||||
asset_url = serializers.SerializerMethodField()
|
||||
asset_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = BgmTrack
|
||||
fields = ["id", "asset", "volume", "start_ms"]
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user