""" composer/align.py — 画面跟随音频对齐("剪画面"策略) 用户洞察(2026-10-03): 音画不同步时,**不该动声音**(变速会变调、裁剪会丢字), 而应该**动画面** —— 画面变速几乎没有质量损失(atempo 保音高、setpts 改时长)。 适用范围(严格遵守): · 有对口型的镜头 **不动**(口型已与音频绑定,变速会破坏) · 无音频的镜头不动 · 变速比超出安全范围(默认 0.67~1.5 倍)→ 不动并告警(避免明显快进/慢放感) 用法: from composer.align import align_clips_to_audio new_clips = align_clips_to_audio(work_dir, sb, clips, lipsync_indexes={2,3}) """ from pathlib import Path from lib.logger import get_logger log = get_logger("composer.align") def _dur(p: str) -> float: try: from lib.ffmpeg import probe_duration return float(probe_duration(str(p)) or 0) except Exception: return 0.0 def _has_audio(p: str) -> bool: try: from lib.ffmpeg import has_audio_stream return bool(has_audio_stream(str(p))) except Exception: return False def align_clips_to_audio(work_dir, sb, clips: list, lipsync_indexes: set | None = None, max_speed: float = 1.5, min_delta: float = 0.15, progress_cb=None) -> list: """把「有台词音频、但没做对口型」的镜头**变速**到音频长度 work_dir : 工作目录(音频在 {work_dir}/pre_audio/{shot_id}.mp3) clips : 当前镜头片段路径列表(与 sb.shots 同序) lipsync_indexes: 已做对口型的镜头序号(1-based)——这些不动 max_speed : 允许的最大变速倍率(默认 1.5,atempo 安全区 0.5~2.0) min_delta : 小于该时长差(秒)视为已对齐,不动 返回:新的片段路径列表(失败的镜头保持原样) """ import subprocess from lib.ffmpeg import FFMPEG work = Path(work_dir) pre_dir = work / "pre_audio" skip = set(lipsync_indexes or ()) out = list(clips) done = 0 for idx, shot in enumerate(list(getattr(sb, "shots", []) or []), 1): if idx - 1 >= len(out): break if idx in skip: continue # 对口型镜头不动 sid = getattr(shot, "shot_id", f"shot_{idx:02d}") audio = pre_dir / f"{sid}.mp3" if not audio.exists(): continue # 无音频 → 不动 adur = _dur(str(audio)) clip = Path(str(out[idx - 1])) if not clip.exists() or adur <= 0: continue vdur = _dur(str(clip)) if vdur <= 0: continue delta = vdur - adur if abs(delta) < min_delta: continue # 已对齐 ratio = vdur / adur # >1 = 画面偏长,需加速 # 超出安全区**不再跳过**(跳过 = 音画依然不同步)—— # 改为:变速到安全极限 + 裁剪剩余(画面比音频长时裁掉尾部,内容损失最小) if ratio > max_speed: applied = max_speed need_trim = True elif ratio < 1.0 / max_speed: applied = 1.0 / max_speed need_trim = False # 画面太短:变速到极限即可(不要补黑) else: applied = ratio need_trim = abs(delta) > min_delta dst = work / f"aligned_{idx:02d}.mp4" if progress_cb: progress_cb("align", sid, "running", f"画面变速 {applied:.2f}x") # 视频变速用 setpts;音频(若存在)用 atempo 同步变速 vf = f"[0:v]setpts=PTS/{applied:.4f}[v]" if _has_audio(str(clip)): ap = min(max(applied, 0.5), 2.0) # atempo 安全区 0.5~2.0 fc = f"{vf};[0:a]atempo={ap:.4f}[a]" maps = ["-map", "[v]", "-map", "[a]", "-c:a", "aac", "-b:a", "192k"] else: fc = vf maps = ["-map", "[v]"] cmd = [FFMPEG, "-y", "-i", str(clip), "-filter_complex", fc, *maps, "-c:v", "libx264", "-preset", "fast", "-crf", "20", "-pix_fmt", "yuv420p"] if need_trim and applied > 1.0: # 变速后仍偏长 → 裁到音频长度(保留开头,丢尾部多余部分) cmd += ["-t", f"{adur:.3f}"] cmd.append(str(dst)) r = subprocess.run(cmd, capture_output=True, text=True, timeout=600) if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 1_000: log.warning(" ⚠️ %s 变速失败,保留原片段: %s", sid, (r.stderr or "")[-160:]) continue new_dur = _dur(str(dst)) out[idx - 1] = str(dst) done += 1 _note = f"{applied:.2f}x" + ("+裁尾" if (need_trim and applied > 1.0) else "") log.info(" ✂️ %s 画面对齐:%.2fs → %.2fs(%s,音频 %.2fs)", sid, vdur, new_dur, _note, adur) if progress_cb: progress_cb("align", sid, "done", f"{vdur:.1f}s → {new_dur:.1f}s({_note})") if done: log.info(" ✂️ 画面跟随音频对齐完成:%d 个镜头", done) return out