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:
zyc
2026-06-09 14:46:16 +08:00
co-authored by Claude Opus 4.8
parent 8959946241
commit 92826dec14
13 changed files with 1229 additions and 97 deletions
+69 -6
View File
@@ -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)
+227 -33
View File
@@ -1,15 +1,39 @@
from pathlib import Path
import subprocess
import tempfile
import threading
import requests
from django.db import transaction
from django.db import connections, transaction
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.projects.models import ExportJob
# 字幕样式(对齐 stage5 四个 swatch)。本机 ffmpeg 无 libass/drawtext,改用 Pillow 渲染 PNG 再 overlay 烧入。
# RGBA 颜色;box 为半透明黑底(影视),stroke 为描边色。
SUBTITLE_STYLES: dict[str, dict] = {
"plain": {"size": 58, "fill": (255, 255, 255, 255), "stroke": (0, 0, 0, 255), "stroke_w": 4, "box": None}, # 朴素白底
"cinema": {"size": 56, "fill": (255, 255, 255, 255), "stroke": (0, 0, 0, 0), "stroke_w": 0, "box": (0, 0, 0, 165)}, # 影视黑底
"handwrite": {"size": 60, "fill": (255, 255, 255, 255), "stroke": (250, 93, 25, 255), "stroke_w": 7, "box": None}, # 手写描边(主橙 #fa5d19)
"variety": {"size": 60, "fill": (255, 220, 60, 255), "stroke": (0, 0, 0, 255), "stroke_w": 6, "box": None}, # 综艺暖黄
}
# 候选 CJK 字体(mac 优先,Linux 兜底)
_FONT_CANDIDATES = [
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
]
# 转场(UI 选项)→ ffmpeg xfade transition 名。"none" 表示纯拼接。
XFADE_MAP: dict[str, str] = {
"fade": "fade", "dissolve": "dissolve", "slide": "slideleft", "slideleft": "slideleft",
"slideright": "slideright", "wipe": "wiperight", "wiperight": "wiperight", "circle": "circleopen", "smooth": "smoothleft",
}
def _download_asset_primary_file(asset, target_path: Path) -> None:
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
if primary is None:
@@ -20,6 +44,174 @@ def _download_asset_primary_file(asset, target_path: Path) -> None:
target_path.write_bytes(response.content)
def _load_font(size: int):
from PIL import ImageFont
for path in _FONT_CANDIDATES:
try:
return ImageFont.truetype(path, size, index=0)
except Exception: # noqa: BLE001
continue
return ImageFont.load_default()
def _wrap_cjk(draw, text: str, font, max_width: int) -> list[str]:
"""按像素宽折行(中文逐字、英文整体不强拆)。"""
lines: list[str] = []
line = ""
for ch in text:
trial = line + ch
if draw.textlength(trial, font=font) <= max_width or not line:
line = trial
else:
lines.append(line)
line = ch
if line:
lines.append(line)
return lines[:3] # 最多 3 行,够长截断
def _render_subtitle_png(text: str, style_key: str, path: Path) -> tuple[int, int]:
"""把一条字幕渲染成 1080 宽的透明 PNG(居中,带描边/底框),返回 (w,h)。"""
from PIL import Image, ImageDraw
st = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
canvas_w = 1080
margin_x = 90
font = _load_font(st["size"])
probe = ImageDraw.Draw(Image.new("RGBA", (10, 10)))
lines = _wrap_cjk(probe, (text or "").strip().replace("\n", " "), font, canvas_w - 2 * margin_x)
line_h = st["size"] + 16
pad = 22
text_h = line_h * len(lines)
canvas_h = text_h + 2 * pad
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
if st["box"]:
widest = max((draw.textlength(ln, font=font) for ln in lines), default=0)
box_w = int(widest) + 2 * pad + 24
x0 = (canvas_w - box_w) // 2
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=16, fill=st["box"])
y = pad
for ln in lines:
w = draw.textlength(ln, font=font)
x = (canvas_w - w) / 2
draw.text((x, y), ln, font=font, fill=st["fill"],
stroke_width=st["stroke_w"], stroke_fill=st["stroke"])
y += line_h
img.save(path)
return canvas_w, canvas_h
def _clip_specs(clips) -> list[dict]:
"""每个 clip 的入点/出点/时长(秒),考虑 trim。"""
specs = []
for clip in clips:
ts = (clip.trim_start_ms or 0) / 1000.0
te = (clip.trim_end_ms / 1000.0) if clip.trim_end_ms else ts + (clip.duration_ms or 15000) / 1000.0
specs.append({"ts": ts, "te": te, "dur": max(0.1, te - ts)})
return specs
def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]:
"""每个 clip 在输出时间轴上的起点 + 输出总时长(xfade 会压缩总长)。"""
starts, cum = [], 0.0
for i, s in enumerate(specs):
starts.append(0.0 if i == 0 else max(0.0, cum - i * xfade))
cum += s["dur"]
total = sum(s["dur"] for s in specs) - (len(specs) - 1) * xfade if xfade > 0 else sum(s["dur"] for s in specs)
return starts, max(0.1, total)
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
transition: str, sub_overlays: list[tuple[str, float, float]],
bgm_name: str | None, bgm_volume: float) -> list[str]:
parts: list[str] = []
for i, s in enumerate(specs):
parts.append(
f"[{i}:v]trim=start={s['ts']:.3f}:end={s['te']:.3f},setpts=PTS-STARTPTS,"
"scale=1080:1920:force_original_aspect_ratio=decrease,"
"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p[v" + str(i) + "]"
)
xname = XFADE_MAP.get(transition or "none")
if xname and n > 1:
prev = "v0"
for i in range(1, n):
out = "vbase" if i == n - 1 else f"x{i}"
parts.append(f"[{prev}][v{i}]xfade=transition={xname}:duration=0.5:offset={starts[i]:.3f}[{out}]")
prev = out
else:
parts.append("".join(f"[v{i}]" for i in range(n)) + f"concat=n={n}:v=1:a=0[vbase]")
# 字幕:每条一张 PNG,按时间窗 overlay 到底部居中(本机 ffmpeg 无 libass,用图片烧入)
sub_base = n + (1 if bgm_name else 0)
vlabel = "vbase"
for j, (_png, start, end) in enumerate(sub_overlays):
idx = sub_base + j
out = "vout" if j == len(sub_overlays) - 1 else f"ov{j}"
parts.append(
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
)
vlabel = out
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]")
cmd = ["ffmpeg", "-y"]
for i in range(n):
cmd += ["-i", f"clip{i}.mp4"]
if bgm_name:
cmd += ["-stream_loop", "-1", "-i", bgm_name]
for png, _s, _e in sub_overlays:
cmd += ["-loop", "1", "-i", png]
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
if bgm_name:
cmd += ["-map", "[aout]"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
if bgm_name:
cmd += ["-c:a", "aac", "-b:a", "192k"]
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
return cmd
def run_export_job_in_thread(export_job_id: str) -> None:
"""后台线程跑拼接导出。本机无 Celery worker(dev),故事板/视频已用线程模式,导出沿用同一打法:
HTTP 秒回,真实 ffmpeg 拼接在线程里跑,前端轮询 poll-export 看进度 / 取成片。失败落库供轮询上报。"""
def _worker() -> None:
try:
run_export_job(export_job_id)
except Exception as exc: # noqa: BLE001 — 失败落库,poll-export 据此上报
job = ExportJob.objects.filter(id=export_job_id).first()
if job is not None:
job.status = ExportJob.Status.FAILED
job.error_message = str(exc)
job.save(update_fields=["status", "error_message", "updated_at"])
finally:
connections.close_all()
threading.Thread(target=_worker, daemon=True).start()
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
"""字幕条目:文本取 SubtitleTrack.content,空则回退脚本旁白;时间按输出布局(对 xfade 也对齐)。"""
track = timeline.subtitle_tracks.filter(enabled=True).first() or timeline.subtitle_tracks.first()
if track is None or track.enabled is False:
return []
texts: list[str] = [str((c or {}).get("text", "")) for c in (track.content or [])]
if not any(t.strip() for t in texts):
script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if script is not None:
texts = [seg.narration for seg in script.segments.all().order_by("sort_order")]
cues: list[tuple[float, float, str]] = []
for i in range(len(specs)):
text = texts[i] if i < len(texts) else ""
start = starts[i]
end = starts[i + 1] if i + 1 < len(starts) else total
if text and text.strip():
cues.append((start, max(start + 0.5, end), text))
return cues
def run_export_job(export_job_id: str) -> ExportJob:
export_job = ExportJob.objects.select_related("timeline", "timeline__project").get(id=export_job_id)
timeline = export_job.timeline
@@ -32,43 +224,45 @@ def run_export_job(export_job_id: str) -> ExportJob:
export_job.progress = 10
export_job.save(update_fields=["status", "progress", "updated_at"])
transition = str((timeline.metadata or {}).get("transition", {}).get("type", "none"))
bgm_track = timeline.bgm_tracks.select_related("asset").first()
subtitle_track = timeline.subtitle_tracks.filter(enabled=True).first()
style_key = str((subtitle_track.style or {}).get("key", "plain")) if subtitle_track else "plain"
specs = _clip_specs(clips)
xfade = 0.5 if XFADE_MAP.get(transition) and len(clips) > 1 else 0.0
starts, total = _output_starts(specs, xfade)
with tempfile.TemporaryDirectory(prefix="airshelf-export-") as tmp_dir:
tmp = Path(tmp_dir)
concat_file = tmp / "concat.txt"
downloaded_files: list[Path] = []
for index, clip in enumerate(clips):
clip_path = tmp / f"clip-{index}.mp4"
_download_asset_primary_file(clip.asset, clip_path)
downloaded_files.append(clip_path)
concat_file.write_text(
"\n".join(f"file '{path.as_posix()}'" for path in downloaded_files),
encoding="utf-8",
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
bgm_name = None
if bgm_track is not None and bgm_track.asset_id:
primary = bgm_track.asset.files.filter(is_primary=True).first() or bgm_track.asset.files.first()
suffix = Path(primary.object_key).suffix or ".mp3" if primary else ".mp3"
bgm_name = f"bgm{suffix}"
_download_asset_primary_file(bgm_track.asset, tmp / bgm_name)
cues = _subtitle_cues(timeline, project, specs, starts, total)
sub_overlays: list[tuple[str, float, float]] = []
for i, (start, end, text) in enumerate(cues):
png = f"sub{i}.png"
_render_subtitle_png(text, style_key, tmp / png)
sub_overlays.append((png, start, end))
export_job.progress = 35
export_job.save(update_fields=["progress", "updated_at"])
command = _build_export_command(
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
)
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg export failed: {proc.stderr.decode('utf-8', 'ignore')[-1200:]}")
output_path = tmp / "output.mp4"
command = [
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_file),
"-vf",
"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
"-r",
"30",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"+faststart",
str(output_path),
]
subprocess.run(command, check=True, capture_output=True)
export_job.progress = 85
export_job.save(update_fields=["progress", "updated_at"])
+224 -15
View File
@@ -1,6 +1,11 @@
import logging
from pathlib import Path
import uuid
from django.db import transaction
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
@@ -8,13 +13,29 @@ from apps.ai.services import (
create_export_job,
generate_base_asset,
generate_project_script,
generate_storyboard,
generate_storyboard_frame,
poll_video_segment,
submit_storyboard,
submit_video_segment,
)
from apps.assets.models import Asset, AssetFile
from apps.assets.serializers import AssetFileSerializer
from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin
from .models import BaseAssetGroup, Project, ProjectStage, ScriptVersion, Timeline, TimelineClip, VideoSegment
from .models import (
BaseAssetGroup,
BgmTrack,
ExportJob,
Project,
ProjectStage,
ScriptVersion,
SubtitleTrack,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
from .serializers import (
BaseAssetGroupSerializer,
ExportJobSerializer,
@@ -23,8 +44,32 @@ from .serializers import (
StoryboardVersionSerializer,
VideoSegmentVersionSerializer,
)
from .services.export import run_export_job_in_thread
from .services.pipeline import STAGE_ORDER
from .tasks import poll_video_segment_task, run_export_job_task
from .tasks import poll_video_segment_task
logger = logging.getLogger(__name__)
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "application/octet-stream",
)
asset = Asset.objects.create(
id=asset_id, team=team, created_by=user, name=name,
asset_type=asset_type, source=Asset.Source.UPLOAD, category=category,
)
AssetFile.objects.create(
asset=asset, object_key=stored.object_key, bucket=stored.bucket,
content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True,
)
return asset
def promote_base_asset_stage_if_ready(project: Project) -> bool:
@@ -46,13 +91,15 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Project.objects.select_related("product", "timeline").prefetch_related(
"stages",
"video_segments",
"video_segments__adopted_version__asset__files",
"script_versions",
"script_versions__segments",
"base_asset_groups",
"base_asset_groups__candidate_assets",
"base_asset_groups__adopted_asset__files",
"base_asset_groups__candidate_assets__files",
"storyboard_versions",
"storyboard_versions__frames",
"timeline__clips",
"storyboard_versions__frames__asset__files",
"timeline__clips__asset__files",
).all()
serializer_class = ProjectSerializer
search_fields = ["name", "product__title"]
@@ -122,15 +169,28 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None):
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。"""
project = self.get_object()
storyboard = generate_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
storyboard = submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_201_CREATED)
return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="poll-storyboard")
def poll_storyboard_action(self, request, pk=None):
"""异步故事板·轮询:每次生成下一帧(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
project = self.get_object()
result = generate_storyboard_frame(project=project, user=request.user)
if result.get("status") == "succeeded":
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
return Response(result, status=http_status)
@action(detail=True, methods=["post"], url_path="skip-storyboard")
@transaction.atomic
@@ -149,7 +209,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = self.get_object()
segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id"))
submit_video_segment(video_segment=segment, user=request.user, prompt=request.data.get("prompt", ""))
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
# 有 Celery worker 时由它自动轮询;无 worker(本机 dev)则前端驱动 poll-video-segment。
# 队列不可用不应让提交 500——已提交到 ARK,轮询是次要路径。
try:
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
except Exception: # noqa: BLE001
logger.warning("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True)
return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="poll-video-segment")
@@ -188,11 +253,155 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
start_ms += segment.target_duration_seconds * 1000
export_job = create_export_job(timeline=timeline, user=request.user)
run_export_job_task.delay(str(export_job.id))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
stage.status = ProjectStage.Status.QUEUED
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.EXPORT
project.status = Project.Status.EXPORTING
project.save(update_fields=["current_stage", "status", "updated_at"])
# 后台线程跑真实 ffmpeg 拼接(无需 Celery worker);前端轮询 poll-export 取进度/成片。
transaction.on_commit(lambda: run_export_job_in_thread(str(export_job.id)))
return Response(ExportJobSerializer(export_job).data, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post", "get"], url_path="poll-export")
def poll_export_action(self, request, pk=None):
"""拼接导出·轮询:回最新导出任务的状态/进度/成片 URL。成片就绪时把 EXPORT 阶段标记成功。"""
project = self.get_object()
timeline = getattr(project, "timeline", None)
export_job = timeline.export_jobs.order_by("-created_at").first() if timeline is not None else None
if export_job is None:
return Response({"status": "not_started", "progress": 0, "output_url": ""})
output_url = ""
output = export_job.output_asset
if output is not None:
primary = output.files.filter(is_primary=True).first() or output.files.first()
if primary is not None:
output_url = AssetFileSerializer().get_preview_url(primary)
if export_job.status == ExportJob.Status.SUCCEEDED:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
if stage.status != ProjectStage.Status.SUCCEEDED:
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
elif export_job.status == ExportJob.Status.FAILED:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
if stage.status != ProjectStage.Status.FAILED:
stage.status = ProjectStage.Status.FAILED
stage.error_message = export_job.error_message
stage.save(update_fields=["status", "error_message", "updated_at"])
return Response({
"status": export_job.status,
"progress": export_job.progress,
"output_asset": str(output.id) if output is not None else None,
"output_url": output_url,
"error_message": export_job.error_message,
})
@action(detail=True, methods=["post"], url_path="upload-video-segment", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic
def upload_video_segment_action(self, request, pk=None):
"""上传自带视频替换某段:落 TOS → Asset(video) → VideoSegmentVersion → 采用并标记完成。"""
project = self.get_object()
upload = request.data.get("file")
if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
segment = VideoSegment.objects.filter(project=project, id=request.data.get("video_segment_id")).first()
if segment is None:
return Response({"detail": "video segment not found"}, status=status.HTTP_404_NOT_FOUND)
asset = _store_uploaded_asset(
team=project.team, user=request.user, upload=upload,
asset_type=Asset.Type.VIDEO, category=Asset.Category.VIDEO_CLIP,
name=f"{project.name}-上传-{segment.sort_order + 1}",
)
version = VideoSegmentVersion.objects.create(
video_segment=segment, asset=asset, prompt="用户上传", is_adopted=True,
metadata={"source": "upload"},
)
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.error_message = ""
segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
return Response(ProjectSerializer(project).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic
def upload_bgm_action(self, request, pk=None):
"""上传 BGM 音频:落 TOS → Asset(audio) → 设为 timeline 的(唯一)BGM 轨。"""
project = self.get_object()
upload = request.data.get("file")
if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
timeline, _ = Timeline.objects.get_or_create(
project=project, defaults={"name": f"{project.name} Timeline", "duration_seconds": 60}
)
asset = _store_uploaded_asset(
team=project.team, user=request.user, upload=upload,
asset_type=Asset.Type.AUDIO, category=Asset.Category.UPLOAD, name=f"{project.name}-BGM",
)
volume = int(request.data.get("volume") or 60)
timeline.bgm_tracks.all().delete()
BgmTrack.objects.create(timeline=timeline, asset=asset, volume=max(0, min(100, volume)), start_ms=0)
return Response(ProjectSerializer(self.get_object()).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post", "put"], url_path="save-timeline")
@transaction.atomic
def save_timeline_action(self, request, pk=None):
"""保存草稿:整体持久化时间轴编辑态(片段顺序/裁剪、字幕样式与内容、BGM 音量、转场、草稿元数据)。"""
project = self.get_object()
timeline, _ = Timeline.objects.get_or_create(
project=project, defaults={"name": f"{project.name} Timeline", "duration_seconds": 60}
)
data = request.data
clips = data.get("clips")
if isinstance(clips, list):
valid_asset_ids = set(
Asset.objects.filter(team=project.team, id__in=[c.get("asset") for c in clips if c.get("asset")])
.values_list("id", flat=True)
)
timeline.clips.all().delete()
start_ms = 0
for index, clip in enumerate(clips):
asset_id = clip.get("asset")
if not asset_id or str(asset_id) not in {str(a) for a in valid_asset_ids}:
continue
duration = int(clip.get("duration_ms") or 15000)
TimelineClip.objects.create(
timeline=timeline, asset_id=asset_id, sort_order=index, start_ms=start_ms,
duration_ms=duration, trim_start_ms=int(clip.get("trim_start_ms") or 0),
trim_end_ms=clip.get("trim_end_ms"),
)
start_ms += duration
timeline.duration_seconds = max(1, round(start_ms / 1000))
subtitle = data.get("subtitle")
if isinstance(subtitle, dict):
track = timeline.subtitle_tracks.first() or SubtitleTrack(timeline=timeline)
track.enabled = bool(subtitle.get("enabled", True))
style = dict(track.style or {})
if subtitle.get("style_key"):
style["key"] = subtitle["style_key"]
track.style = style
if isinstance(subtitle.get("content"), list):
track.content = subtitle["content"]
track.save()
bgm = data.get("bgm")
if isinstance(bgm, dict):
track = timeline.bgm_tracks.first()
if bgm.get("clear"):
timeline.bgm_tracks.all().delete()
elif track is not None and bgm.get("volume") is not None:
track.volume = max(0, min(100, int(bgm["volume"])))
track.save(update_fields=["volume", "updated_at"])
metadata = dict(timeline.metadata or {})
if isinstance(data.get("transition"), dict):
metadata["transition"] = {"type": str(data["transition"].get("type", "none"))}
if isinstance(data.get("draft"), dict):
metadata["draft"] = data["draft"]
timeline.metadata = metadata
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
return Response(ProjectSerializer(self.get_object()).data)