后端生成闸+多项修复;前端全站更新;QA 审计与报告

后端:
- 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口
  一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失
- 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复
- 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令
- 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令)

前端:全站页面更新(pipeline/library/products/projects/team/account 等),
新增共享 pager 分页组件

QA:刷新 function-audit 全量输出,新增 full-qa 报告
文档:BP 产品介绍资料、design/CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-15 10:06:15 +08:00
co-authored by Claude Fable 5
parent 890cb9ab67
commit 216a711291
92 changed files with 5114 additions and 2027 deletions
+47 -3
View File
@@ -1,3 +1,5 @@
import uuid
from rest_framework import serializers
from apps.assets.serializers import AssetFileSerializer
@@ -40,10 +42,11 @@ class ProjectStageSerializer(serializers.ModelSerializer):
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"]
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):
@@ -55,6 +58,21 @@ class VideoSegmentSerializer(serializers.ModelSerializer):
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)
@@ -167,11 +185,37 @@ class TimelineSerializer(serializers.ModelSerializer):
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", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
read_only_fields = ["id", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
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):
+180 -17
View File
@@ -139,17 +139,49 @@ def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]
_AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
def _probe_fps(path: Path) -> float:
"""探测视频帧率(avg_frame_rate)。失败返回 0 由调用方兜底。"""
try:
proc = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=avg_frame_rate", "-of", "csv=p=0", str(path)],
capture_output=True, timeout=60,
)
raw = proc.stdout.decode("utf-8", "ignore").strip().splitlines()[0] if proc.stdout else ""
num, _, den = raw.partition("/")
fps = float(num) / float(den or 1)
return fps if 1.0 <= fps <= 120.0 else 0.0
except Exception: # noqa: BLE001
return 0.0
def _pick_output_fps(fps_list: list[float]) -> float:
"""输出帧率取各片段帧率的众数(并列取更高)。
源 24fps 被硬转 30fps 会按 4:5 不均匀复帧——成片肉眼可见一卡一顿;帧率跟随源才是帧帧对应。"""
valid = [round(f, 3) for f in fps_list if f > 0]
if not valid:
return 30.0
counts: dict[float, int] = {}
for f in valid:
counts[f] = counts.get(f, 0) + 1
best = max(counts.items(), key=lambda kv: (kv[1], kv[0]))
return best[0]
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,
has_audio: list[bool] | None = None) -> list[str]:
has_audio: list[bool] | None = None, fps: float = 30.0,
voice_overlays: list[tuple[str, float, float]] | None = None) -> list[str]:
has_audio = has_audio or [False] * n
voice_overlays = voice_overlays or []
fps_expr = f"{fps:.6g}"
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) + "]"
f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
)
xname = XFADE_MAP.get(transition or "none")
if xname and n > 1:
@@ -175,7 +207,7 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
# 音频:片段自带的人声/原声必须保留(有声片段取原音轨,无声片段补等长静音,否则 concat 会缺流);
# 若另挂了 BGM,则把 BGM 混到原声之上(amix,normalize=0 不自动衰减原声音量)。
# 三种片段全无声且无 BGM 时,保持旧行为=纯视频不带音轨。
want_audio = any(has_audio) or bool(bgm_name)
want_audio = any(has_audio) or bool(bgm_name) or bool(voice_overlays)
audio_label: str | None = None
if want_audio:
for i, s in enumerate(specs):
@@ -191,9 +223,23 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[avoice0]")
parts.append(f"[avoice0]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[avoice]")
audio_label = "avoice"
# 旁白配音(TTS):每段延迟到所属片段在输出时间轴的起点,裁到片段时长,叠在基础音轨之上
if voice_overlays:
vo_base = n + (1 if bgm_name else 0) + len(sub_overlays)
for j, (_name, vstart, vdur) in enumerate(voice_overlays):
delay_ms = max(0, int(round(vstart * 1000)))
parts.append(
f"[{vo_base + j}:a]atrim=0:{vdur:.3f},asetpts=PTS-STARTPTS,{_AFMT},"
f"adelay={delay_ms}|{delay_ms}[vo{j}]"
)
vo_inputs = "".join(f"[vo{j}]" for j in range(len(voice_overlays)))
parts.append(
f"[avoice]{vo_inputs}amix=inputs={len(voice_overlays) + 1}:duration=first:dropout_transition=0:normalize=0[anarr]"
)
audio_label = "anarr"
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS,{_AFMT}[abgm]")
parts.append("[avoice][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
parts.append(f"[{audio_label}][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
audio_label = "aout"
cmd = ["ffmpeg", "-y"]
@@ -203,10 +249,12 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
cmd += ["-stream_loop", "-1", "-i", bgm_name]
for png, _s, _e in sub_overlays:
cmd += ["-loop", "1", "-i", png]
for name, _vs, _vd in voice_overlays:
cmd += ["-i", name]
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
if audio_label:
cmd += ["-map", f"[{audio_label}]"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", fps_expr, "-preset", "veryfast"]
if audio_label:
cmd += ["-c:a", "aac", "-b:a", "192k"]
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
@@ -232,23 +280,113 @@ def run_export_job_in_thread(export_job_id: str) -> None:
threading.Thread(target=_worker, daemon=True).start()
def _split_subtitle_text(text: str) -> list[str]:
"""整段旁白 → 短句列表(与前端 splitSubtitleCues 同规则):
硬标点(。!?;…)必切;长句(≥12 字)在逗号处再切;过短碎句(<5 字)并入前句;去尾部逗号句号。"""
clean = " ".join(str(text or "").split())
if not clean:
return []
hard = "\u3002\uff01\uff1f!?\uff1b;\u2026" # 。!?!?;… 全角+半角
soft = "\uff0c,\u3001" # ,、
parts: list[str] = []
cur = ""
for ch in clean:
cur += ch
if ch in hard or (ch in soft and len(cur) >= 12):
parts.append(cur)
cur = ""
if cur.strip():
parts.append(cur)
merged: list[str] = []
for raw in parts:
p = raw.strip()
if not p:
continue
core = sum(1 for c in p if c not in hard and c not in soft)
if merged and core < 5:
merged[-1] += p
else:
merged.append(p)
out: list[str] = []
for p in merged:
p = p.rstrip("\uff0c\u3002\uff1b,;\u3001")
if p:
out.append(p)
return out
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
"""字幕条目:文本取 SubtitleTrack.content,空则回退脚本旁白;时间按输出布局(对 xfade 也对齐)。"""
"""字幕条目(逐句):优先用 SubtitleTrack.content 里每条 cue 自带的 start_ms——
先定位到所属片段(输入时间轴=各片段时长累计),再重映射到输出时间轴(xfade 会压缩起点);
无保存字幕时回退脚本旁白,逐段按句切分铺满片段时长。旧行为是整段旁白糊满 15s(切割错误)。"""
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")]
in_starts: list[float] = []
acc = 0.0
for s in specs:
in_starts.append(acc)
acc += s["dur"]
def clip_index(t_in: float) -> int:
for k in range(len(specs)):
if t_in < in_starts[k] + specs[k]["dur"]:
return k
return len(specs) - 1
# 旁白配音时长(秒)按片段索引取:有配音时字幕窗口必须跟语音走,而不是铺满片段
vo_meta = (timeline.metadata or {}).get("voiceover") or {}
vo_durs: dict[int, float] = {}
if vo_meta.get("enabled"):
for item in vo_meta.get("items") or []:
try:
vo_idx = int(item.get("index", -1))
vo_dur = float(item.get("duration_ms") or 0) / 1000.0
except (TypeError, ValueError):
continue
if vo_idx >= 0 and vo_dur > 0:
vo_durs[vo_idx] = vo_dur
cues: list[tuple[float, float, str]] = []
content = [c for c in (track.content or []) if str((c or {}).get("text", "")).strip()]
if content:
entries = sorted(content, key=lambda c: int((c or {}).get("start_ms", 0) or 0))
outs: list[tuple[float, int, str, float | None]] = []
for c in entries:
t_in = int(c.get("start_ms", 0) or 0) / 1000.0
i = clip_index(t_in)
offset = max(0.0, min(specs[i]["dur"], t_in - in_starts[i]))
# 新格式 cue 自带 end_ms(已对齐配音语速),同样重映射到输出时间轴;旧草稿无 end_ms 走相邻推断
out_end: float | None = None
if c.get("end_ms"):
e_off = max(0.0, min(specs[i]["dur"], int(c["end_ms"]) / 1000.0 - in_starts[i]))
out_end = starts[i] + e_off
outs.append((starts[i] + offset, i, str(c.get("text", "")).strip(), out_end))
for j, (start, i, text, out_end) in enumerate(outs):
if out_end is None:
if j + 1 < len(outs) and outs[j + 1][1] == i:
out_end = outs[j + 1][0]
else:
out_end = min(total, starts[i] + specs[i]["dur"])
cues.append((start, max(start + 0.5, min(total, out_end)), text))
return cues
script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
texts = [seg.narration for seg in script.segments.all().order_by("sort_order")] if script else []
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))
pieces = _split_subtitle_text(texts[i] if i < len(texts) else "")
if not pieces:
continue
span = (starts[i + 1] if i + 1 < len(starts) else total) - starts[i]
if i in vo_durs:
span = min(span, vo_durs[i])
total_chars = sum(len(p) for p in pieces) or 1
acc_chars = 0
for p in pieces:
start = starts[i] + (acc_chars / total_chars) * span
acc_chars += len(p)
end = starts[i] + (acc_chars / total_chars) * span
cues.append((start, max(start + 0.5, end), p))
return cues
@@ -279,6 +417,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
# 逐片段探测是否自带音轨:有声→保留原声,无声→补静音(见 _build_export_command)
has_audio = [_has_audio_stream(tmp / f"clip{index}.mp4") for index in range(len(clips))]
# 输出帧率跟随源帧率众数(Seedance 出 24fps,硬转 30fps 会不均匀复帧=成片一卡一顿)
output_fps = _pick_output_fps([_probe_fps(tmp / f"clip{index}.mp4") for index in range(len(clips))])
bgm_name = None
if bgm_track is not None and bgm_track.asset_id:
@@ -294,13 +434,36 @@ def run_export_job(export_job_id: str) -> ExportJob:
_render_subtitle_png(text, style_key, tmp / png)
sub_overlays.append((png, start, end))
# 旁白配音(TTS 资产):按 timeline.metadata.voiceover 映射下载,人声轨混在 BGM 之上;
# 逐句条目带 offset_ms(句内起点,可被拖动调整),输出位置 = 片段输出起点 + 句内偏移
vo_meta = (timeline.metadata or {}).get("voiceover") or {}
voice_overlays: list[tuple[str, float, float]] = []
if vo_meta.get("enabled") and isinstance(vo_meta.get("items"), list):
for j, item in enumerate(vo_meta["items"]):
try:
seg_index = int(item.get("index", -1))
offset_s = max(0.0, float(item.get("offset_ms") or 0) / 1000.0)
except (TypeError, ValueError):
continue
if seg_index < 0 or seg_index >= len(clips) or not item.get("asset"):
continue
remain = specs[seg_index]["dur"] - offset_s
if remain <= 0.05:
continue # 句子被拖到片段尾外,导出时丢弃(预览同样不响)
vo_asset = Asset.objects.filter(team=project.team, id=item["asset"]).first()
if vo_asset is None:
continue
vo_name = f"vo{j}.mp3"
_download_asset_primary_file(vo_asset, tmp / vo_name)
voice_overlays.append((vo_name, starts[seg_index] + offset_s, remain))
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,
has_audio=has_audio,
has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays,
)
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
if proc.returncode != 0:
+215 -1
View File
@@ -5,9 +5,19 @@ from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import ModelConfig, ModelProvider
from apps.assets.models import Asset
from apps.billing.models import CreditAccount, CreditLedger
from apps.products.models import Product
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
from apps.projects.models import (
Project,
ProjectStage,
ScriptVersion,
SubtitleTrack,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
class ProjectApiTests(TestCase):
@@ -100,3 +110,207 @@ class ProjectApiTests(TestCase):
self.assertEqual(script.segments.count(), 4)
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
def test_adopt_video_version_remaps_timeline_draft(self):
"""切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
make_asset = lambda name: Asset.objects.create(
team=self.team, created_by=self.user, name=name,
asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP,
)
old_asset, new_asset = make_asset("v1"), make_asset("v2")
segment = VideoSegment.objects.create(project=project, sort_order=0)
old_ver = VideoSegmentVersion.objects.create(video_segment=segment, asset=old_asset, is_adopted=True)
new_ver = VideoSegmentVersion.objects.create(video_segment=segment, asset=new_asset)
segment.adopted_version = old_ver
segment.save(update_fields=["adopted_version"])
timeline = Timeline.objects.create(project=project)
clip = TimelineClip.objects.create(
timeline=timeline, asset=old_asset, sort_order=0,
duration_ms=15000, trim_start_ms=1000, trim_end_ms=9000,
)
response = self.client.post(
f"/api/projects/{project.id}/adopt-video-version/",
{"video_segment_id": str(segment.id), "version_id": str(new_ver.id)},
format="json",
)
self.assertEqual(response.status_code, 200)
clip.refresh_from_db()
self.assertEqual(clip.asset_id, new_asset.id)
# 旧素材上的裁剪点对新素材无意义,必须复位
self.assertEqual(clip.trim_start_ms, 0)
self.assertIsNone(clip.trim_end_ms)
def _make_audio_model(self):
return ModelConfig.objects.create(
provider=self.provider, name="doubao-voice-bigtts", display_name="豆包语音合成",
capability="audio", endpoint="api/v1/tts", unit_price="2.0000",
)
@patch("apps.ai.services.TosStorage")
@patch("apps.ai.services.VolcanoTtsProvider")
def test_generate_voiceover_creates_assets_and_charges_once(self, provider_cls, storage_cls):
"""每镜旁白 TTS:产出音频资产、timeline.metadata 落映射、一次调用只计一次费。"""
self._make_audio_model()
provider = provider_cls.return_value
provider.configured = True
provider.synthesize.return_value = (b"fake-mp3-bytes", 4200)
stored = storage_cls.return_value.upload_fileobj.return_value
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "k.mp3", "b", "audio/mpeg", 14
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO")
response = self.client.post(
f"/api/projects/{project.id}/generate-voiceover/",
{"items": [{"index": 0, "text": "第一镜旁白"}, {"index": 1, "text": "第二镜旁白"}]},
format="json",
)
self.assertEqual(response.status_code, 201)
voiceover = response.json()["voiceover"]
self.assertTrue(voiceover["enabled"])
self.assertEqual(len(voiceover["items"]), 2)
self.assertEqual(provider.synthesize.call_count, 2)
from apps.assets.models import Asset as AssetModel
self.assertEqual(AssetModel.objects.filter(team=self.team, asset_type="audio").count(), 2)
self.assertEqual(
CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1
)
project.refresh_from_db()
self.assertEqual(len(project.timeline.metadata["voiceover"]["items"]), 2)
@patch("apps.ai.services.VolcanoTtsProvider")
def test_generate_voiceover_unconfigured_returns_400_and_no_charge(self, provider_cls):
"""语音凭证未配置:400 + 人话提示,不建任务不扣费。"""
self._make_audio_model()
provider_cls.return_value.configured = False
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO2")
response = self.client.post(
f"/api/projects/{project.id}/generate-voiceover/",
{"items": [{"index": 0, "text": "旁白"}]},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("语音合成未配置", response.json()["detail"])
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 0)
def test_export_command_mixes_voiceover_above_bgm(self):
"""导出命令:人声按片段起点 adelay 延迟、先与原声 amix、再与 BGM amix。"""
from apps.projects.services.export import _build_export_command
specs = [{"ts": 0.0, "te": 15.0, "dur": 15.0}, {"ts": 0.0, "te": 15.0, "dur": 15.0}]
cmd = _build_export_command(
n=2, specs=specs, starts=[0.0, 15.0], total=30.0, transition="none",
sub_overlays=[], bgm_name="bgm.mp3", bgm_volume=0.6, has_audio=[False, False],
voice_overlays=[("vo0.mp3", 0.0, 15.0), ("vo1.mp3", 15.0, 15.0)],
)
self.assertIn("vo0.mp3", cmd)
self.assertIn("vo1.mp3", cmd)
graph = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("adelay=15000|15000", graph)
self.assertIn("[avoice][vo0][vo1]amix=inputs=3", graph)
self.assertIn("[anarr][abgm]amix=inputs=2", graph)
def test_save_timeline_updates_voiceover_offsets(self):
"""拖动字幕块后保存:按 asset 回写句内起点 offset_ms,未拖动的句不受影响。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VOFF")
Timeline.objects.create(project=project, metadata={"voiceover": {"enabled": True, "items": [
{"index": 0, "cue": 0, "asset": "a1", "text": "x", "duration_ms": 2000, "offset_ms": 0},
{"index": 0, "cue": 1, "asset": "a2", "text": "y", "duration_ms": 2000, "offset_ms": 2000},
]}})
response = self.client.post(
f"/api/projects/{project.id}/save-timeline/",
{"voiceover": {"items": [{"asset": "a2", "offset_ms": 9000}]}},
format="json",
)
self.assertEqual(response.status_code, 200)
project.timeline.refresh_from_db()
items = project.timeline.metadata["voiceover"]["items"]
self.assertEqual(items[0]["offset_ms"], 0)
self.assertEqual(items[1]["offset_ms"], 9000)
def test_subtitle_cues_use_end_ms_and_follow_voiceover(self):
"""字幕烧入时序:cue 自带 end_ms 时按它收尾(不再拖到片段尾)——这是配音对齐的关键;
无保存字幕的回退路径,有配音时逐句窗口也要压进语音真实时长。"""
from apps.projects.services.export import _subtitle_cues
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="SUB")
timeline = Timeline.objects.create(
project=project,
metadata={"voiceover": {"enabled": True, "items": [{"index": 0, "asset": "x", "text": "t", "duration_ms": 4000}]}},
)
SubtitleTrack.objects.create(timeline=timeline, enabled=True, content=[
{"start_ms": 0, "end_ms": 2000, "text": "第一句"},
{"start_ms": 2000, "end_ms": 4000, "text": "第二句"},
])
specs = [{"ts": 0.0, "te": 15.0, "dur": 15.0}]
cues = _subtitle_cues(timeline, project, specs, [0.0], 15.0)
self.assertEqual(len(cues), 2)
self.assertEqual((cues[0][0], cues[0][1]), (0.0, 2.0))
self.assertEqual(cues[1][1], 4.0) # 最后一句在语音念完处收尾,而非拖到 15s
def test_save_timeline_voiceover_toggle_and_clear(self):
"""保存草稿可关/开配音、可移除映射(不动已生成的音频资产)。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO3")
Timeline.objects.create(
project=project,
metadata={"voiceover": {"enabled": True, "voice_type": "v", "items": [{"index": 0, "asset": "a", "text": "t"}]}},
)
response = self.client.post(
f"/api/projects/{project.id}/save-timeline/", {"voiceover": {"enabled": False}}, format="json"
)
self.assertEqual(response.status_code, 200)
project.timeline.refresh_from_db()
self.assertFalse(project.timeline.metadata["voiceover"]["enabled"])
response = self.client.post(
f"/api/projects/{project.id}/save-timeline/", {"voiceover": {"clear": True}}, format="json"
)
self.assertEqual(response.status_code, 200)
project.timeline.refresh_from_db()
self.assertNotIn("voiceover", project.timeline.metadata)
class WorkerGateTests(TestCase):
"""无 Celery worker 时,图片/视频生成入口必须 503 拒绝(防结果无人取回=数据丢失)。"""
def setUp(self):
self.user = User.objects.create_user(username="gate", password="pass")
self.team = Team.objects.create(name="Gate Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance="100.0000")
self.product = Product.objects.create(team=self.team, created_by=self.user, title="Gate Product")
self.project = Project.objects.create(team=self.team, created_by=self.user, name="Gate", product=self.product)
self.client = APIClient()
self.client.force_authenticate(self.user)
def _blocked(self):
return patch("apps.common.celery_health.celery_worker_available", return_value=False)
def test_generation_endpoints_blocked_without_worker(self):
cases = [
(f"/api/projects/{self.project.id}/generate-base-asset/", {"kind": "person"}),
(f"/api/projects/{self.project.id}/generate-storyboard/", {}),
(f"/api/projects/{self.project.id}/submit-video-segment/", {"video_segment_id": "x"}),
("/api/ai/generate-image/", {"prompt": "测试"}),
]
with self._blocked():
for url, payload in cases:
response = self.client.post(url, payload, format="json")
self.assertEqual(response.status_code, 503, msg=f"{url} 应被 worker 闸拦截")
self.assertIn("worker", response.json()["detail"].lower() if isinstance(response.json().get("detail"), str) else "")
def test_eager_mode_bypasses_gate(self):
from apps.common.celery_health import celery_worker_available
# 测试配置 CELERY_TASK_ALWAYS_EAGER=True:任务就地执行,无需 worker,闸放行
self.assertTrue(celery_worker_available())
def test_ping_failure_means_unavailable(self):
from apps.common import celery_health
celery_health._cache["expires"] = 0.0
with self.settings(CELERY_TASK_ALWAYS_EAGER=False):
with patch.object(celery_health, "_ping_workers", side_effect=ConnectionError("broker down")):
self.assertFalse(celery_health.celery_worker_available())
celery_health._cache["expires"] = 0.0
+161 -3
View File
@@ -9,7 +9,10 @@ from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.ai.providers import TtsNotConfigured
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
create_export_job,
generate_base_asset,
generate_project_script,
@@ -17,11 +20,13 @@ from apps.ai.services import (
poll_video_segment,
submit_storyboard,
submit_video_segment,
synthesize_project_voiceover,
)
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 apps.common.celery_health import require_worker
from .models import (
BaseAssetGroup,
@@ -29,6 +34,7 @@ from .models import (
ExportJob,
Project,
ProjectStage,
ScriptSegment,
ScriptVersion,
SubtitleTrack,
Timeline,
@@ -92,6 +98,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"stages",
"video_segments",
"video_segments__adopted_version__asset__files",
"video_segments__versions__asset__files",
"script_versions",
"script_versions__segments",
"base_asset_groups",
@@ -121,6 +128,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
user=request.user,
user_prompt=request.data.get("prompt", ""),
selling_point_ids=request.data.get("selling_point_ids") or [],
source=request.data.get("source") or "ai",
)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@@ -143,6 +151,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="generate-base-asset")
def generate_base_asset_action(self, request, pk=None):
require_worker()
project = self.get_object()
kind = request.data.get("kind")
if kind not in BaseAssetGroup.Kind.values:
@@ -167,9 +176,117 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
promote_base_asset_stage_if_ready(project)
return Response(BaseAssetGroupSerializer(group).data)
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 VideoSegment 数量:不足则尾部补 NOT_STARTED,
多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。"""
if not script.is_adopted:
return
target = script.segments.count()
segments = list(project.video_segments.order_by("sort_order"))
while len(segments) > target:
tail = segments[-1]
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
tail.delete()
segments.pop()
else:
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
next_order += 1
@action(detail=True, methods=["post"], url_path="update-script-segment")
def update_script_segment(self, request, pk=None):
project = self.get_object()
segment = ScriptSegment.objects.get(id=request.data.get("segment_id"), script_version__project=project)
changed = []
for field in ("narration", "visual_prompt"):
if field in request.data:
setattr(segment, field, str(request.data.get(field) or "").strip())
changed.append(field)
if "duration_seconds" in request.data:
try:
segment.duration_seconds = max(1, min(60, int(request.data["duration_seconds"])))
changed.append("duration_seconds")
except (TypeError, ValueError):
pass
if changed:
segment.save(update_fields=[*changed, "updated_at"])
return Response(ScriptVersionSerializer(segment.script_version).data)
@action(detail=True, methods=["post"], url_path="add-script-segment")
@transaction.atomic
def add_script_segment(self, request, pk=None):
project = self.get_object()
after = ScriptSegment.objects.select_related("script_version").get(
id=request.data.get("after_segment_id"), script_version__project=project
)
script = after.script_version
segments = list(script.segments.order_by("sort_order"))
insert_at = next(i for i, s in enumerate(segments) if s.id == after.id) + 1
created = ScriptSegment.objects.create(
script_version=script,
sort_order=insert_at,
duration_seconds=int(request.data.get("duration_seconds") or 15),
narration=str(request.data.get("narration") or "").strip(),
visual_prompt=str(request.data.get("visual_prompt") or "").strip(),
)
segments.insert(insert_at, created)
for index, seg in enumerate(segments):
if seg.sort_order != index:
seg.sort_order = index
seg.save(update_fields=["sort_order", "updated_at"])
self._sync_video_segments_to_script(project, script)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="delete-script-segment")
@transaction.atomic
def delete_script_segment(self, request, pk=None):
project = self.get_object()
segment = ScriptSegment.objects.select_related("script_version").get(
id=request.data.get("segment_id"), script_version__project=project
)
script = segment.script_version
if script.segments.count() <= 1:
return Response({"detail": "至少保留一个分镜"}, status=status.HTTP_400_BAD_REQUEST)
segment.delete()
for index, seg in enumerate(script.segments.order_by("sort_order")):
if seg.sort_order != index:
seg.sort_order = index
seg.save(update_fields=["sort_order", "updated_at"])
self._sync_video_segments_to_script(project, script)
return Response(ScriptVersionSerializer(script).data)
# ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ──
@action(detail=True, methods=["post"], url_path="adopt-video-version")
@transaction.atomic
def adopt_video_version(self, request, pk=None):
project = self.get_object()
segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id"))
version = VideoSegmentVersion.objects.get(video_segment=segment, id=request.data.get("version_id"))
segment.versions.update(is_adopted=False)
version.is_adopted = True
version.save(update_fields=["is_adopted", "updated_at"])
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.error_message = ""
segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
# 时间线草稿(剪辑台/拼接导出都读它)若还引用本场旧版本的资产,必须跟随切换,
# 否则采用新版本后预览和导出仍是旧片段;裁剪点是对旧素材设的,一并复位
timeline = Timeline.objects.filter(project=project).first()
if timeline is not None and version.asset_id:
segment_asset_ids = list(segment.versions.values_list("asset_id", flat=True))
timeline.clips.filter(asset_id__in=segment_asset_ids).exclude(asset_id=version.asset_id).update(
asset_id=version.asset_id, trim_start_ms=0, trim_end_ms=None
)
return Response(ProjectSerializer(project).data)
@action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None):
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。"""
require_worker()
project = self.get_object()
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)
@@ -206,15 +323,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="submit-video-segment")
def submit_video_segment_action(self, request, pk=None):
# 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。
require_worker()
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", ""))
# 有 Celery worker 时由它自动轮询;无 worker(本机 dev)则前端驱动 poll-video-segment。
# 队列不可用不应让提交 500——已提交到 ARK,轮询是次要路径
# worker 在线由上面的闸保证;此处入队失败只剩极小窗口(刚提交完 broker 闪断),
# 任务已在 ARK,前端轮询仍可兜底取回,故不让提交 500
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)
logger.error("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")
@@ -345,6 +464,24 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
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)
# ── Stage 5 · 旁白配音(TTS):每镜旁白合成语音资产,导出时作为人声轨混在 BGM 之上 ──
@action(detail=True, methods=["get", "post"], url_path="generate-voiceover")
def generate_voiceover_action(self, request, pk=None):
project = self.get_object()
if request.method == "GET":
return Response({"voices": VOICEOVER_VOICES, "default_voice": DEFAULT_VOICEOVER_VOICE})
try:
voiceover = synthesize_project_voiceover(
project=project,
user=request.user,
items=request.data.get("items") or [],
voice_type=request.data.get("voice_type") or DEFAULT_VOICEOVER_VOICE,
speed_ratio=float(request.data.get("speed_ratio") or 1.0),
)
except (TtsNotConfigured, ValueError) as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"voiceover": voiceover}, 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):
@@ -402,6 +539,27 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
metadata["transition"] = {"type": str(data["transition"].get("type", "none"))}
if isinstance(data.get("draft"), dict):
metadata["draft"] = data["draft"]
if isinstance(data.get("voiceover"), dict):
vo_patch = data["voiceover"]
existing = metadata.get("voiceover")
if vo_patch.get("clear"):
metadata.pop("voiceover", None)
elif isinstance(existing, dict):
if vo_patch.get("enabled") is not None:
existing["enabled"] = bool(vo_patch["enabled"])
# 拖动字幕块后回写每句语音的句内起点(按 asset id 对位,只动 offset_ms)
if isinstance(vo_patch.get("items"), list):
offsets = {}
for it in vo_patch["items"]:
if isinstance(it, dict) and it.get("asset") is not None and it.get("offset_ms") is not None:
try:
offsets[str(it["asset"])] = max(0, int(it["offset_ms"]))
except (TypeError, ValueError):
continue
for it in existing.get("items") or []:
if str(it.get("asset")) in offsets:
it["offset_ms"] = offsets[str(it.get("asset"))]
metadata["voiceover"] = existing
timeline.metadata = metadata
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
return Response(ProjectSerializer(self.get_object()).data)