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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user