后端生成闸+多项修复;前端全站更新;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
+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: