后端生成闸+多项修复;前端全站更新;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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user