feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,136 @@
"""
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
@@ -0,0 +1,678 @@
"""
AVE 06_composer/beat_sync — BGM 节拍驱动的卡点视频合成
流程:
1. BGM 节拍检测 (librosa beat_track)
2. 按 N拍 分组 (默认 4拍/组)
3. 每组分配素材 (Pexels搜索 或 用户提供)
4. 精确裁剪到组时长
5. xfade 过渡拼接 (≤8组避免 FFmpeg 崩溃)
6. 文字/字幕叠加
用法:
from composer.beat_sync import compose_beat_sync
compose_beat_sync(bgm_path="bgm.wav", output="beat_sync.mp4")
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("beat_sync")
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache")))
BEAT_CACHE = CACHE_DIR / "beat_sync"
BEAT_CACHE.mkdir(parents=True, exist_ok=True)
def detect_beats(
audio_path: str,
group_size: int = 4,
enable_energy: bool = False,
detect_structure: bool = False,
) -> dict:
"""
检测 BGM 节拍
参数:
audio_path: BGM 音频路径
group_size: 每组节拍数 (默认4拍/组)
enable_energy: 是否提取 RMS/频谱能量特征 (用于变速卡点)
detect_structure: 是否检测歌曲结构 (Intro/Verse/Chorus/Bridge/Outro)
返回:
{
"bpm": 120.0,
"beat_times": [0.0, 0.5, 1.0, ...],
"segments": [{"start": 0.0, "end": 2.0, "beats": 4, "energy": 0.8, ...}, ...],
"structure": [{"label": "Intro", "start": 0.0, "end": 8.0, ...}],
"total_duration": 30.0,
}
"""
import librosa
import numpy as np
y, sr = librosa.load(audio_path, sr=22050, mono=True)
duration = librosa.get_duration(y=y, sr=sr)
# 节拍检测
tempo_arr, beats = librosa.beat.beat_track(y=y, sr=sr)
tempo_val = float(tempo_arr.item() if hasattr(tempo_arr, 'item') else tempo_arr)
beat_times = librosa.frames_to_time(beats, sr=sr).tolist()
# 如果没检测到拍点, 按 BPM 估算
if len(beat_times) < 2:
bpm = tempo_val if tempo_val > 0 else 120.0
interval = 60.0 / bpm
beat_times = [i * interval for i in range(int(duration / interval))]
logger.info(f" 无拍点, 按 BPM={bpm:.0f} 估算 {len(beat_times)} 拍")
else:
logger.info(f" 检测到 BPM={tempo_val:.1f}, {len(beat_times)} 拍")
# 提取 RMS 和频谱能量
rms_energy = None
spectral_features = None
if enable_energy:
# 用短时傅立叶提取帧级能量
hop_length = 512
frame_dur = hop_length / sr
rms = librosa.feature.rms(y=y, hop_length=hop_length)[0]
rms_energy = rms.tolist()
logger.debug(f" 能量帧: {len(rms_energy)} 帧, 帧时长={frame_dur:.3f}s")
# 频谱 flux
spec = np.abs(librosa.stft(y, hop_length=hop_length))
flux = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
spectral_features = {
"rms": rms_energy,
"flux": flux.tolist() if hasattr(flux, 'tolist') else [],
"hop_length": hop_length,
"frame_duration": frame_dur,
}
# 歌曲结构检测
song_structure = None
if detect_structure:
song_structure = _detect_song_structure(y, sr, duration)
if song_structure:
logger.info(f" 歌曲结构: {len(song_structure)} 段")
for s in song_structure:
logger.info(f" {s['label']:8s} {s['start']:.0f}s→{s['end']:.0f}s")
# 按 N拍 分组
segments = []
max_beats_per_seg = 0
for i in range(0, len(beat_times), group_size):
start = beat_times[i]
end = beat_times[min(i + group_size, len(beat_times) - 1)]
actual_beats = min(group_size, len(beat_times) - i)
if end - start > 0.3:
seg = {
"start": round(start, 2),
"end": round(end, 2),
"duration": round(end - start, 2),
"beats": actual_beats,
}
# 计算该段平均能量
if enable_energy and rms_energy is not None and spectral_features:
seg["energy"] = _segment_energy(start, end, spectral_features)
segments.append(seg)
max_beats_per_seg = max(max_beats_per_seg, actual_beats)
# 最后一段: 最后一个拍点到音频结束
if segments and segments[-1]["end"] < duration - 0.3:
seg = {
"start": segments[-1]["end"],
"end": round(duration, 2),
"duration": round(duration - segments[-1]["end"], 2),
"beats": 0,
}
if enable_energy and spectral_features:
seg["energy"] = _segment_energy(seg["start"], seg["end"], spectral_features)
segments.append(seg)
# 限制 ≤8 组 (xfade 链限制)
if len(segments) > 8:
keep = segments[:7]
remaining = segments[7:]
merged = {
"start": keep[-1]["end"],
"end": round(duration, 2),
"duration": round(duration - keep[-1]["end"], 2),
"beats": sum(s["beats"] for s in remaining),
}
if enable_energy and "energy" in remaining[0]:
merged["energy"] = sum(
s.get("energy", 0) * s["duration"] for s in remaining
) / max(merged["duration"], 0.1)
keep.append(merged)
segments = keep
logger.info(f" 合并为 {len(segments)} 组 (≤8)")
result = {
"bpm": tempo_val,
"beat_count": len(beat_times),
"beat_times": [round(float(t), 3) for t in beat_times], # 文档承诺的字段(此前漏返回)
"segments": segments,
"total_duration": round(duration, 2),
}
if song_structure:
result["structure"] = song_structure
if spectral_features:
result["spectral"] = spectral_features
return result
def _segment_energy(start_sec: float, end_sec: float,
spectral: dict) -> float:
"""计算某时间段的平均归一化能量 (基于 RMS + Flux)"""
frame_dur = spectral["frame_duration"]
rms = spectral.get("rms", [])
flux = spectral.get("flux", [])
if not rms:
return 0.5
start_frame = int(start_sec / max(frame_dur, 0.001))
end_frame = int(end_sec / max(frame_dur, 0.001))
start_frame = max(0, min(start_frame, len(rms) - 1))
end_frame = max(start_frame + 1, min(end_frame, len(rms)))
seg_rms = rms[start_frame:end_frame]
if not seg_rms:
return 0.5
avg_rms = sum(seg_rms) / len(seg_rms) if seg_rms else 0
avg_flux = sum(flux[start_frame:end_frame]) / len(seg_rms) if flux and len(seg_rms) > 0 else 0
# 归一化: 用全局均值和 std
rms_mean = sum(rms) / len(rms) if rms else 0.1
rms_std = (sum((v - rms_mean)**2 for v in rms) / len(rms)) ** 0.5 if rms and len(rms) > 1 else rms_mean
rms_std = max(rms_std, rms_mean * 0.01)
energy = (avg_rms - rms_mean) / rms_std * 0.3 + 0.5
return max(0.0, min(1.0, energy))
def _detect_song_structure(y, sr, duration) -> list[dict]:
"""
检测歌曲结构段落
基于 chroma 特征 + 自相似矩阵 + 时序聚类,
输出: Intro / Verse / Chorus / Bridge / Outro
"""
import numpy as np
import librosa
try:
# 提取 chroma 特征
hop_length = 2048
chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=hop_length)
frame_dur = hop_length / sr
# 自相似矩阵 + 递归分割
ssm = librosa.segment.recurrence_matrix(chroma, axis=0)
bounds = librosa.segment.agglomerative(ssm, k=min(8, ssm.shape[0]))
bounds_frames = librosa.segment.subsegment(ssm, bounds, n_segments=2)
# 转换为时间
boundaries = [0.0]
for bf in bounds_frames:
t = float(bf) * frame_dur
if t < duration - 1:
boundaries.append(round(t, 1))
boundaries.append(round(duration, 1))
# 按时间比例分配标签
labels = _label_structure(boundaries, duration)
result = []
for i in range(len(boundaries) - 1):
result.append({
"label": labels[i] if i < len(labels) else "Section",
"start": boundaries[i],
"end": boundaries[i + 1],
"duration": round(boundaries[i + 1] - boundaries[i], 1),
})
return result
except Exception as e:
logger.warning(f" 歌曲结构检测失败: {e}")
return []
def _label_structure(boundaries: list[float], total_duration: float) -> list[str]:
"""根据段落在歌曲中的位置分配典型结构标签"""
n = len(boundaries) - 1
labels = []
# 按位置比例分配
positions = []
for i in range(n):
mid = (boundaries[i] + boundaries[i + 1]) / 2 / max(total_duration, 1)
positions.append(mid)
for i, pos in enumerate(positions):
if i == 0 and pos < 0.05:
labels.append("Intro")
elif i >= n - 2 and pos > 0.85:
labels.append("Outro")
elif 0.15 <= pos <= 0.65:
# 很可能在 Chorus 区
labels.append("Chorus" if i % 2 == 1 else "Verse")
elif pos < 0.15:
labels.append("Verse")
elif pos < 0.4:
labels.append("Pre-Chorus" if i % 2 == 0 else "Chorus")
else:
labels.append("Bridge" if pos < 0.75 else "Outro")
return labels
def compose_beat_sync(
bgm_path: str,
output_path: str = "beat_sync.mp4",
material_clips: list[str] | None = None,
group_size: int = 4,
resolution: str = "1080x1920",
texts: list[str] | None = None,
pexels_api_key: str = "",
pexels_search: str = "",
enable_speed_ramp: bool = False,
frame_locked: bool = True,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
target_bpm: float = 0.0,
) -> str:
"""
BGM 节拍驱动的卡点视频合成 (V2: 能量感知 + 帧锁定)
参数:
bgm_path: BGM 音频路径
output_path: 输出视频路径
material_clips: 素材视频/图片列表 (可选, 不提供则 Pexels 搜索)
group_size: 每组节拍数
resolution: 输出分辨率
texts: 每段叠加的文字 (可选)
pexels_api_key: Pexels API Key
pexels_search: Pexels 搜索关键词
enable_speed_ramp: 是否启用能量驱动变速 (高能加速, 低能减速)
frame_locked: 是否启用帧锁定时间线 (消除 xfade 漂移)
base_speed: 基础速度 (默认 1.0)
high_speed: 高能段速度 (默认 1.5)
low_speed: 低能段速度 (默认 0.7)
target_bpm: 目标 BPM (>0 时用 AudioLine StemAligner 预处理 BGM)
返回: output_path
"""
if not os.path.exists(bgm_path):
raise FileNotFoundError(f"BGM 文件不存在: {bgm_path}")
# 0. BPM 标准化预处理 (使用 AudioLine StemAligner)
if target_bpm > 0:
try:
from audio_line.layer4_alignment.stem_aligner import StemAligner
import librosa
y_detect, sr_detect = librosa.load(bgm_path, sr=None, mono=True)
tempo_arr, _ = librosa.beat.beat_track(y=y_detect, sr=sr_detect)
source_bpm = float(tempo_arr.item() if hasattr(tempo_arr, 'item') else tempo_arr)
logger.info(f" BPM 标准化: {source_bpm:.0f} → {target_bpm:.0f}")
if source_bpm > 0 and abs(source_bpm - target_bpm) > 1.0:
aligner = StemAligner(method="phase_vocoder")
result = aligner.align_to_bpm(
stems={"bgm": bgm_path},
source_bpm=source_bpm,
target_bpm=target_bpm,
output_dir=str(BEAT_CACHE / "aligned"),
)
if result.aligned_paths:
bgm_path = result.aligned_paths["bgm"]
logger.info(f" ✅ 标准化完成: {bgm_path}")
else:
logger.info(f" ⏭️ 无需标准化 (相近 BPM)")
except ImportError:
logger.info(f" ⏭️ AudioLine 不可用, 跳过 BPM 标准化")
except Exception as e:
logger.warning(f" ⚠ BPM 标准化失败: {e} (继续使用原 BGM)")
# 1. 节拍检测 (启用能量)
logger.info("=== Beat-Sync 卡点模式 ===")
logger.info(f"BGM: {bgm_path}")
beat_info = detect_beats(bgm_path, group_size, enable_energy=enable_speed_ramp)
segments = beat_info["segments"]
total_dur = beat_info["total_duration"]
logger.info(f"BPM: {beat_info['bpm']}, {beat_info['beat_count']}拍, {len(segments)}组")
if enable_speed_ramp and "energy" in segments[0]:
energies = [s.get("energy", 0.5) for s in segments]
avg_e = sum(energies) / len(energies) if energies else 0.5
logger.info(f" 能量感知变速: ON (均值 {avg_e:.2f}, 范围 {min(energies):.2f}~{max(energies):.2f})")
# 2. 准备素材
clips = material_clips or []
if len(clips) < len(segments) and pexels_api_key:
from material_producer.pexels.search import search_videos
needed = len(segments) - len(clips)
logger.info(f"素材不足, 从 Pexels 搜索 {needed} 个...")
kw = pexels_search or "background video"
found = search_videos(kw, count=needed, api_key=pexels_api_key, orientation="portrait")
for f in found:
clips.append(f["path"])
logger.info(f" 共 {len(clips)} 个素材")
if not clips:
raise ValueError("无可用素材")
# 3. 为每组分配素材 (循环使用)
seg_materials = [] # [(clip_path, adjusted_start, adjusted_end, adjusted_duration, speed), ...]
for i, seg in enumerate(segments):
clip = clips[i % len(clips)]
# 计算本段速度 (能量驱动变速)
speed = base_speed
if enable_speed_ramp:
energy = seg.get("energy", 0.5)
if energy > 0.6:
speed = base_speed + (high_speed - base_speed) * min((energy - 0.6) / 0.4, 1.0)
elif energy < 0.3:
speed = low_speed + (base_speed - low_speed) * (energy / 0.3)
speed = max(0.5, min(2.0, speed))
# 调整时长 (变速后实际画面时长)
orig_dur = seg["duration"]
if frame_locked:
# 帧锁定: 以 BGM 节拍为准, 用 fps 对齐到整帧
fps = 30
orig_frames = round(orig_dur * fps)
adjusted_dur = orig_frames / fps # 帧对齐后的时长
else:
adjusted_dur = orig_dur
# 变速后的素材播放时长 = adjusted_dur / speed
# 即: 如果加速 (speed>1), 需要更短的素材
material_time_needed = adjusted_dur / speed if speed > 0 else adjusted_dur
seg_materials.append((clip, seg["start"], seg["end"],
adjusted_dur, speed, material_time_needed))
logger.info(f"分配素材: {len(seg_materials)} 段, {len(clips)} 素材循环"
+ (f", 变速范围 {low_speed:.1f}~{high_speed:.1f}x" if enable_speed_ramp else ""))
# 4. 帧锁定: 预计算精确的 xfade 偏移
if frame_locked:
fps = 30
# 精确计算每段的累计偏移
cumulative_frames = 0
for idx, (clip, start, end, dur, speed, mt) in enumerate(seg_materials):
seg_frames = round(dur * fps)
seg_materials[idx] = (clip, start, dur, speed, mt, seg_frames)
logger.info(f" 帧锁定: ON (30fps)")
# 5. 渲染每组为独立视频片段 (带变速)
temp_dir = tempfile.mkdtemp(prefix="ave_beat_")
seg_files = []
for idx in range(len(seg_materials)):
clip, start_orig, dur, speed, mt, *rest = seg_materials[idx]
if frame_locked:
seg_frames = rest[0] if rest else round(dur * 30)
# 帧对齐时长
dur_aligned = seg_frames / 30.0
else:
dur_aligned = dur
seg_frames = 0
seg_file = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
# 从素材中取一段 (考虑变速需要)
clip_dur = _get_duration(clip)
offset = (start_orig * 17) % max(clip_dur - mt, 0.1) if clip_dur > mt else 0
# 先截取素材, 再变速
raw_seg = os.path.join(temp_dir, f"seg_{idx:03d}_raw.mp4")
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset),
"-i", clip,
"-t", str(mt),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps=30",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-an", raw_seg,
], capture_output=True, timeout=120, check=True)
if speed != 1.0 and speed >= 0.5 and speed <= 2.0:
# 变速处理
subprocess.run([
FFMPEG, "-y", "-i", raw_seg,
"-filter_complex",
f"setpts={1/speed}*PTS[v]",
"-map", "[v]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p",
seg_file,
], capture_output=True, timeout=120, check=True)
else:
os.replace(raw_seg, seg_file)
seg_files.append(seg_file)
# 6. 使用帧锁定时间线拼接
if len(seg_files) == 1:
subprocess.run([FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", output_path],
capture_output=True, check=True, timeout=60)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
if frame_locked:
return _concat_frame_locked(seg_files, bgm_path, seg_materials,
output_path, texts, resolution, temp_dir)
else:
return _concat_xfade(seg_files, bgm_path, seg_materials,
output_path, texts, resolution, temp_dir)
def _concat_frame_locked(
seg_files: list[str],
bgm_path: str,
seg_materials: list,
output_path: str,
texts: list[str] | None,
resolution: str,
temp_dir: str,
) -> str:
"""帧锁定拼接: 用 concat demuxer 精确拼接, 无 xfade 漂移"""
if len(seg_files) == 1:
# 只有一段, 直接合并 BGM
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
# 用 concat demuxer 拼接 (帧精确, 无过渡)
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
# 先拼视频
video_only = output_path.replace(".mp4", "_novideo.mp4")
cmd = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", "-an", video_only]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
# 叠加 BGM
cmd2 = [FFMPEG, "-y", "-i", video_only, "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(concat_file)
os.unlink(video_only)
logger.info(f" ✅ 帧锁定拼接完成: {len(seg_files)} 段")
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
def _concat_xfade(
seg_files: list[str],
bgm_path: str,
seg_materials: list,
output_path: str,
texts: list[str] | None,
resolution: str,
temp_dir: str,
) -> str:
"""xfade 过渡拼接: ≤8 段, 逐段交叉淡入淡出"""
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
# 构建 xfade 链
# xfade=transition=fade:duration=0.5:offset=offset1
# 每段重叠 0.5s, 累计偏移
filter_parts = []
inputs = []
offset = 0.0
prev_label = "v0"
for i in range(len(seg_files)):
inputs += ["-i", seg_files[i]]
if i == 0:
continue
# 前一段的时长
seg_dur = seg_materials[i - 1][2] if i - 1 < len(seg_materials) else 1.0
offset += float(seg_dur) - 0.5 # 减去过渡重叠
label = f"v{i}"
filter_parts.append(
f"[{prev_label}][{label}]xfade=transition=fade:duration=0.5:offset={offset:.2f}[v{i}]"
)
prev_label = f"v{i}"
# 最终输出标签是 v{len(seg_files)-1}
last_label = f"v{len(seg_files) - 1}"
full_filter = ";".join(filter_parts)
video_xfade = output_path.replace(".mp4", "_xfade.mp4")
cmd = [FFMPEG, "-y"] + inputs + [
"-filter_complex", full_filter,
"-map", f"[{last_label}]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p",
video_xfade,
]
try:
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
except subprocess.CalledProcessError as e:
logger.warning(f" xfade 失败, 回落 concat: {e.stderr[:200]}")
# 回落: concat demuxer
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
cmd_fb = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", video_xfade]
subprocess.run(cmd_fb, capture_output=True, timeout=300, check=True)
os.unlink(concat_file)
# 叠加 BGM
cmd2 = [FFMPEG, "-y", "-i", video_xfade, "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(video_xfade)
logger.info(f" ✅ xfade 拼接完成: {len(seg_files)} 段")
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
def _finalize(
output_path: str,
texts: list[str] | None,
seg_materials: list,
resolution: str,
temp_dir: str,
) -> str:
"""最终处理: 叠加文字字幕 + 清理临时目录"""
import shutil
# 叠加文字
if texts and len(texts) > 0 and seg_materials:
_add_text_subtitles(output_path, texts, seg_materials, resolution)
logger.info(f" ✅ 文字叠加: {len(texts)} 段")
# 清理临时目录
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
logger.debug(f" 临时目录已清理: {temp_dir}")
return output_path
def _get_duration(path: str) -> float:
"""获取媒体文件时长(秒)
用 lib.ffmpeg.probe_duration(ffmpeg -i 解析)——
本机 ffprobe 实为 ffmpeg 的符号链接,不支持 -show_entries(会静默失败)
"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return d if d > 0 else 30.0
except Exception:
return 30.0
def _add_text_subtitles(video_path: str, texts: list[str],
seg_materials: list, resolution: str):
"""为每段叠加文字 (ASS 字幕)"""
width, height = (int(x) for x in resolution.split("x"))
font_size = max(48, int(height * 0.05))
margin_v = int(height * 0.3)
lines = [
"[Script Info]", "ScriptType: v4.00+",
f"PlayResX: {width}", f"PlayResY: {height}",
"ScaledBorderAndShadow: yes", "",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
f"Style: Default,Noto Sans SC,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,100,100,{margin_v},1",
"", "[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
]
for i, text in enumerate(texts):
if i >= len(seg_materials):
break
start = seg_materials[i][1]
end = seg_materials[i][2]
h = int(start); m = int((start - h) * 60); s = int(start - h * 3600 - m * 60)
he = int(end); me = int((end - he) * 60); se = int(end - he * 3600 - me * 60)
lines.append(
f"Dialogue: 0,{h}:{m:02d}:{s:02d}.00,{he}:{me:02d}:{se:02d}.00,Default,,0,0,0,,{text}"
)
ass_path = video_path.replace(".mp4", "_text.ass")
with open(ass_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
# 叠加字幕
tmp = video_path.replace(".mp4", "_tmp.mp4")
subprocess.run([
FFMPEG, "-y", "-i", video_path,
"-vf", f"subtitles={ass_path}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "copy", "-pix_fmt", "yuv420p", tmp,
], capture_output=True, timeout=120, check=True)
os.replace(tmp, video_path)
os.remove(ass_path)
@@ -0,0 +1,68 @@
"""
AVE character_locker — 角色描述块锁定器
职责:
在全片 Prompt 链中自动注入固定角色描述块,
确保 Kling 文生视频时角色一致性。
用法:
from composer.character_locker import inject_character_block
prompt = inject_character_block(segments, character_desc)
"""
import json
import os
from pathlib import Path
# ── 角色库路径 ──
CHARACTER_DB = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/characters")))
CHARACTER_DB.mkdir(parents=True, exist_ok=True)
def load_character_block(char_name: str) -> str:
"""从角色库加载描述块"""
safe = char_name.replace(" ", "_").replace("/", "_")
path = CHARACTER_DB / f"{safe}.json"
if not path.exists():
raise FileNotFoundError(f"角色 '{char_name}' 不存在于角色库: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
return data["description"]
def inject_character_block(segments: list[dict], character_desc: str) -> list[dict]:
"""
在每段素材描述前注入角色描述块
参数:
segments: 导演脚本的 segments 列表
character_desc: 角色描述块(从定妆照生成)
返回:
注入后的 segments(直接修改原列表)
"""
for seg in segments:
mat = seg.get("material", {})
search = mat.get("search", "")
# 如果已包含角色描述, 跳过
if character_desc in search:
continue
# 注入: 角色描述 + 场景描述
if search:
mat["search_locked"] = search # 保留原搜索词
mat["search"] = f"{character_desc}, {search}"
else:
mat["search"] = character_desc
return segments
def lock_prompt(prompt: str, character_desc: str) -> str:
"""锁定单个 Prompt: 注入角色描述块(如果尚未包含)"""
if character_desc in prompt:
return prompt
return f"{character_desc}, {prompt}"
def batch_lock_prompts(prompts: list[str], character_desc: str) -> list[str]:
"""批量锁定多个 Prompt"""
return [lock_prompt(p, character_desc) for p in prompts]
@@ -0,0 +1,181 @@
"""
composer.de_ai — 去 AI 化处理链(DCS 第八节)
================================================
目的:消除 AI 生成视频的"塑料感/锐利边缘/过饱和",做出接近真人实拍(伪纪录片)的质感。
处理链(按 DCS 文档顺序):
① gblur 轻微模糊 —— 消除 AI 边缘过度锐利
② noise 胶片颗粒 —— 添加真实传感器噪点
③ eq saturation 降饱和 —— 消 AI 味的艳丽
④ curves S 曲线 —— 拉反差,更像实拍
⑤ colortemperature 色温 —— 偏移到 5300K(日光偏暖)
默认参数(DCS 默认,可被分镜的 post_process / color 覆盖):
模糊 sigma 0.4 | 颗粒 12 | 饱和 -0.12 | 色温 5300K
用法:
from composer.de_ai import DeAIProcessor
DeAIProcessor().process("in.mp4", "out.mp4") # 默认参数
DeAIProcessor().process("in.mp4", "out.mp4", blur_sigma=0.6, grain=20)
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Optional
log = logging.getLogger("composer.de_ai")
# FFmpeg 绝对路径(本机装在 ~/.local/bin,不在默认 PATH)
_FFMPEG_CANDIDATES = [
os.path.expanduser("~/.local/bin/ffmpeg"),
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg",
"ffmpeg",
]
def find_ffmpeg() -> str:
for c in _FFMPEG_CANDIDATES:
if c == "ffmpeg" or Path(c).exists():
if c == "ffmpeg" and not shutil.which("ffmpeg"):
continue
return c
raise FileNotFoundError("未找到 ffmpeg(请在 ~/.local/bin 或 PATH 中安装)")
class DeAIProcessor:
"""去 AI 化处理器"""
# DCS 默认参数
DEFAULT_BLUR_SIGMA = 0.4
DEFAULT_GRAIN = 12
DEFAULT_SATURATION = -0.12 # 偏移量(实际 saturation = 1 + offset = 0.88)
DEFAULT_TEMPERATURE = 5300 # 色温 K
DEFAULT_CRF = 23 # 画质(未显式指定时按**源码率自适应**,见 _adaptive_crf)
DEFAULT_PRESET = "medium"
def __init__(self, ffmpeg: str | None = None):
self.ffmpeg = ffmpeg or find_ffmpeg()
def _adaptive_crf(self, video_path: str) -> int:
"""按**源码率**自适应 CRF —— 避免对已压缩素材重编码后体积膨胀
实测:规范化后的素材(约 1.5 Mbps)用 CRF 23 重编码 → 体积膨胀到 2.7 倍;
改用 CRF 27 后回到合理区间(视觉几乎无差)。
"""
import re
import subprocess
kbps = 0.0
try:
# 用 `ffmpeg -i` 解析(本机 ffprobe 不支持 -select_streams / -show_entries,
# 用它取码率会静默失败 → 一律落兜底值)
r = subprocess.run([self.ffmpeg, "-i", str(video_path)],
capture_output=True, text=True, timeout=20)
info = (r.stderr or "") + (r.stdout or "")
m = re.search(r"bitrate:\s*(\d+)\s*kb/s", info)
if m:
kbps = float(m.group(1))
if not kbps:
# 兜底:用「文件大小 / 时长」估算
dm = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", info)
if dm:
dur = int(dm.group(1)) * 3600 + int(dm.group(2)) * 60 + float(dm.group(3))
if dur > 0:
kbps = Path(video_path).stat().st_size * 8 / dur / 1000
except Exception:
kbps = 0.0
if 0 < kbps < 3000:
return 27 # 已压缩素材(含规范化后的)→ 不再加码率
if kbps and kbps < 6000:
return 25
return self.DEFAULT_CRF
def build_filter(self, blur_sigma: float = DEFAULT_BLUR_SIGMA,
grain: int = DEFAULT_GRAIN,
saturation_offset: float = DEFAULT_SATURATION,
temperature: int = DEFAULT_TEMPERATURE,
enable_curves: bool = True) -> str:
"""构造 FFmpeg 滤镜链(可单独测试)"""
parts = []
if blur_sigma and blur_sigma > 0:
parts.append(f"gblur=sigma={blur_sigma:.2f}")
if grain and grain > 0:
parts.append(f"noise=alls={int(grain)}:allf=t+u")
sat = max(0.0, 1.0 + float(saturation_offset))
if abs(saturation_offset) > 1e-6:
parts.append(f"eq=saturation={sat:.3f}")
if enable_curves:
parts.append("curves=all='0/0 0.5/0.55 1/1'")
if temperature and int(temperature) != 6500:
parts.append(f"colortemperature=temperature={int(temperature)}")
return ",".join(parts)
def process(self, input_path: str, output_path: str,
blur_sigma: float | None = None,
grain: int | None = None,
saturation_offset: float | None = None,
temperature: int | None = None,
crf: int | None = None,
preset: str | None = None,
keep_audio: bool = True) -> str:
"""执行去 AI 化处理 → 返回输出路径(失败抛异常)"""
inp = Path(input_path)
if not inp.exists():
raise FileNotFoundError(f"输入视频不存在: {input_path}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
vf = self.build_filter(
blur_sigma=self.DEFAULT_BLUR_SIGMA if blur_sigma is None else blur_sigma,
grain=self.DEFAULT_GRAIN if grain is None else grain,
saturation_offset=self.DEFAULT_SATURATION if saturation_offset is None else saturation_offset,
temperature=self.DEFAULT_TEMPERATURE if temperature is None else temperature,
)
cmd = [
self.ffmpeg, "-y", "-i", str(inp),
"-vf", vf,
"-c:v", "libx264",
"-preset", preset or self.DEFAULT_PRESET,
"-crf", str(crf if crf is not None else self._adaptive_crf(input_path)),
# ⚠️ 必须显式压回 yuv420p:滤镜链中的 curves / colortemperature 在 RGB 域
# 运算后会把像素格式提升为 yuv444p,libx264 无此参数时会保持 444p 输出
# (High 4:4:4 Predictive profile)→ 成片在微信/抖音/iOS/多数安卓上无法播放。
"-pix_fmt", "yuv420p",
]
cmd += ["-c:a", "copy"] if keep_audio else ["-an"]
cmd.append(str(out))
log.info(" 🎞️ 去 AI 化: %s → %s", inp.name, out.name)
log.debug(" 滤镜: %s", vf)
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"ffmpeg 失败: {(r.stderr or '')[-300:]}")
if not out.exists() or out.stat().st_size == 0:
raise RuntimeError("ffmpeg 输出为空")
log.info(" ✅ 去 AI 化完成: %.1f MB", out.stat().st_size / 1024 / 1024)
return str(out)
def apply_to_storyboard(self, video_path: str, output_path: str,
storyboard=None) -> str:
"""按分镜的 post_process / color 配置处理(第一期分镜的设置)"""
kw: dict = {}
if storyboard is not None and getattr(storyboard, "shots", None):
pp = storyboard.shots[0].post_process
cc = storyboard.shots[0].color
kw = {
"blur_sigma": pp.blur_sigma,
"grain": pp.grain_intensity,
"saturation_offset": cc.saturation_offset,
"temperature": 6500 + int(cc.temperature_shift * 1000), # 偏移 → 色温
}
return self.process(video_path, output_path, **kw)
def deai_filter_preview() -> str:
"""给前端展示用的默认滤镜链"""
return DeAIProcessor().build_filter()
@@ -0,0 +1,233 @@
"""
composer/director_styles.py — 导演风格库(20 位)
来源:开源项目 DirectorSKILL(wuwangzhang1216/DirectorSKILL)的
references/director_styles/*.md,经结构化提取(一句话镜头 / 关键词 /
叙事方法 / 镜头语言 / 灯光色彩),保留可执行要点。
用途:用户选择某位导演 → 注入导演提示词 → 分镜产出该导演的镜头语言。
⚠️ 使用原则(原仓库要求):**用于原创创作**,不复制其具体镜头/台词/角色。
"""
DIRECTOR_STYLES: dict = {
"spielberg": {
"name": "斯皮尔伯格 · 奇观与亲密",
"one_line": "先拍看见的人,再拍被看见的东西;奇观永远排在一张脸后面。",
"keywords": "情感动员、经典覆盖、奇观与亲密并置、儿童视线高度、家庭裂痕、三层纵深、先反应后揭示、代价之后的修复。",
"narrative": "外部目标必须一句话说清,并在前 90 秒说清:救人、找东西、逃出去、回家、揭真相。;- 情感缺口先于奇观:第一次奇观出现前,观众已经知道主角缺什么(一句对白 + 一件道具即可)。;- 高潮由 3–5 个小目标串成,每完成一个立刻出现新障碍;不要用一个长障碍撑满。;- 亚类型可换(战争、科幻、传记),情感核心不换:亲情、失落、恐惧、勇气。",
"camera": "主力 35–50mm 建立可信的日常;反应镜头换到 135mm,机位不必逼近人物——135mm 在 3 米处就给到约 0.8 米宽的画幅(胸口以上),景别跳一大级而人不受打扰,背景同时被压平成一片色。;- 三层纵深调度:前景道具或次要人物 / 中景主角 / 背景事件或出口。观众任何一帧都能指认危险与出口。;- 运动必须有目的:推轨速度与人物步速一致(约 1.2–1.4 m/s);升降镜头只在需要交",
"light": "基础是柔和环境底光 + 一束可见硬光(窗、门缝、车灯、探照灯、投影机),硬光必须穿过薄雾或尘才看得见。;- 温暖场景比 3:1;威胁场景拉到 8:1,做法是撤掉补光让暗面自己掉下去,不是关小主光——整体照度不变,观众始终看得清空间。饱和度是另一条轴,属于调色,不参与比值。;- 每个光源在剧作里都有名字:不是\"神秘光芒\",是\"走廊尽头那盏没关的顶灯\"。写进提示",
"scene": "强情绪牵引的商业叙事:冒险、灾难、家庭创伤、童年视角、人道主义题材。;- 需要观众在开场 30 秒内认同主角的短片、预告片情绪段、品牌与公益片。;- 需要\"大事件 + 小人物\"结构,并且观众必须始终知道危险与出口在哪的动作段落。;- 不适用:反英雄叙事、道德暧昧的结局、要求观众保",
},
"hitchcock": {
"name": "希区柯克 · 悬念与信息差",
"one_line": "把秘密先交给观众,再让角色慢慢走进去;等待本身就是段落。",
"keywords": "悬念优先于惊吓、信息不对称、窥视与共谋、日常空间变危险、关键物件、延迟、几何遮挡、优雅的反派、黑色幽默。",
"narrative": "段落开始前先写一行\"观众知道 X,角色不知道 X\"。这一行写不出来,就不要用这个透镜。;- 危险的抵达时间可视化:把 20–60 秒的等待切成 3–5 个可数节拍(脚步近了、门把动了、影子长了)。;- 普通人被卷入犯罪或误认,让安全的日常生活不再可靠;主角越想解释,处境越糟。;- 另一种变体:角色知道真相却无法证明。此时观众与角色同盟,压力来自\"没有人相信\"",
"camera": "三段式主观:客观镜头看人(50mm)→ 主观所见(100–135mm 压缩,模拟凝视只挑一样东西看)→ 反应特写(50mm,比第一镜近两级——不是比中间那镜近,中间那镜拍的根本不是他)。三镜必须共享同一条视线轴。;- 广角 28mm 只用来一次性交代\"这个房间是一张地图\":门在哪、楼梯在哪、可藏身处在哪。之后不再重复给。;- 遮挡构图是默认:前景占画面 20–35%(门框、栏杆、栏杆间隙、窗帘、肩",
"light": "主光硬、单侧、有明确来源,比 4:1;阴影是几何形状(百叶、楼梯扶手、门缝),不是氛围雾。;- 明亮空间里的恐怖尤其有效:把照度维持在日常水平,让威胁没有黑暗可以躲,观众只能靠自己找。;- 服装与道具的颜色承担身份与欲望的转变:一个人物在段落里只允许换一次色彩状态,这次换色必须与信息转折同步。;- 主色中性冷灰与米白,重点色饱和度高(正红、深绿、金);对比中",
"scene": "悬疑、惊悚、犯罪、心理恐怖;跟踪戏、误认与身份错置、密室段落。;- 需要在日常空间(楼梯、厨房、办公室、车厢、洗手间)里制造威胁的段落。;- 短片与广告里需要用 20–60 秒把观众按在座位上的单场戏。;- 不适用:需要观众与角色同步无知的鬼屋类恐怖,或需要开放式意象的作者电影(",
},
"kubrick": {
"name": "库布里克 · 几何与秩序",
"one_line": "让建筑比人更有权力:广角、单点透视、匀速推进,人被压进几何里。",
"keywords": "单点透视、匀速推轨、几何秩序、高照度恐惧、黑色讽刺、理性失控、仪式化重复、分析距离、开放冷结局。",
"narrative": "先确定场景背后的制度、机器或哲学命题,再写人物;人物是命题的样本。;- 不解释心理,用重复行为展示异化:同一动作在段落里出现 2–3 次,每次只改变一个细节。;- 结构可章节化、可循环、可对称(开场与结尾用同一构图,只换内容)。;- 反讽的机制是\"形式的庄严 + 内容的荒谬\"同时在场,两者都不能软化。",
"camera": "主力 18–25mm。透视变形由机位距离决定,不由焦段决定:18mm 退到 1.5–3 米只是把人拍小,脸仍是正常比例;要拿到脸部膨胀感必须逼到 0.6–0.8 米——广角的用处正是让你贴到这个距离还能把整条走廊装进画面。两种用法都保留,不要用 35mm 去\"修正\"。;- 构图对称到像素级:消失点落在画幅正中(水平 50%,垂直 45–55%),人物骑在中轴上或严格镜像分布。;- 运动只有两种:完",
"light": "高照度、均匀;比 2:1 甚至 1.5:1。手段是成排的硬光源对称排布:投出的短硬阴影方向互相抵消——不是没有阴影,而是没有一块可以藏东西的暗部。恐惧来自\"没有黑暗可以解释这件事\"。;- 光源可以是极端自然(只用实景顶灯、窗光、蜡烛级别的低照度)或极端人工(成排灯管、投射的色块),但两者都必须在画面里可见并对称排布。;- 色彩系统化:一个空间给一个色,冷白 ",
"scene": "心理恐怖、科幻寓言、战争讽刺、制度与权力机制、失控的理性、封闭空间。;- 需要观众保持分析距离而非情感认同的段落;需要荒诞感与庄严感同时成立的仪式场面。;- 有长走廊、大厅、舱体、机房、营房这类具备强消失点的实景或生成场景。;- 不适用:需要观众共情主角、需要手持临场感、需要柔和",
},
"kurosawa": {
"name": "黑泽明 · 群像与天气",
"one_line": "用长焦把队列压成横向的带,让风雨作用在身体上,等待比爆发更长。",
"keywords": "人道主义、群像功能分工、横向运动、长焦压缩、天气作为角色、身体姿态、道德选择、悲剧尊严、代价化胜利。",
"narrative": "每个角色先定\"社会位置 + 一个道德选择\",再定性格。群像里 5–8 人,每人一句话能说清用处。;- 结构常是:混乱 → 组织 → 训练与等待 → 决战 → 空虚。等待段是全片的心脏,不是过场。;- 人物不是纯粹英雄,他们在责任、荣誉、生存与道德之间挣扎,并且会输。;- 群像叙事必须有可数的位置关系:谁在圈内、谁在圈外、谁在高处、谁背对着谁。",
"camera": "主力 100–250mm。长焦把远近人物压到同一平面上,让\"人群\"变成一堵可读的墙。方向要分清:朝镜头跑的人在 250mm 下几乎看不出在推进(纵深位移被压掉),所以冲锋要设计成\"跑了很久还没到\";横穿画面的人反而更快,因为视野窄——想让横向运动在画面里停留久一点,只能退远机位或者加长队列,不能靠焦段。;- 运动以横向为主:横移跟拍、摇摄扫过队列。纵深突入只在一次决定性的冲锋时用一次。;- 三机位",
"light": "光的作用是让天气可见:逆光让雨丝、尘埃、呼出的白气变成亮线,比 6:1 以上。;- 黑白思维优先——先确认画面在去色后依然靠明暗分区成立,再考虑颜色。;- 彩色时使用浓重的戏剧化色块表现权力与阵营(正红、靛蓝、土黄、墨黑),色块贴在旗帜、盔甲、外衣上,而不是打在空气里。;- 环境的质感高于美感:泥的反光、湿布的深色、干尘的粉白,这些是画面的主要信息来源。",
"scene": "武士片、史诗、群像、战争与社会冲突、阶级压迫、英雄牺牲、权力崩塌。;- 需要在恶劣自然环境(雨、风、雪、尘、泥)中完成情绪的段落。;- 需要 5 人以上同框且每人有明确功能与位置的调度;需要动作方向绝对清楚的战斗。;- 不适用:室内心理剧、亲密两人对话、需要浅景深与柔光的当代都市",
},
"scorsese": {
"name": "斯科塞斯 · 都市与旁白",
"one_line": "让摄影机被欲望拖着走,用音乐把上升拍得好看,再用静默让代价落地。",
"keywords": "都市脉搏、不可靠旁白、圈层规则、流行乐蒙太奇、稳定器长镜头、急推变焦、突发暴力、罪感与自辩、上升到崩塌。",
"narrative": "结构固定为四段:进入 → 上升 → 沉迷 → 坠落。四段的音乐密度依次为高、最高、中、零。;- 用第一人称旁白搭建人物的神话,再用画面证伪:旁白说\"我们都是朋友\",画面里有人被隔在门外。;- 亚文化世界必须有可被列举的规则(谁先动手、钱怎么分、谁能坐哪张桌子)。规则通过一次违规被观众理解。;- 人物不理解自己的罪,只会不断辩护;因此他不设置转折点,转折由后果",
"camera": "主力 18–27mm 用于稳定器长镜头(跟随人物穿过空间,与背景保持接触感);40–75mm 用于人物在自辩时的中近景。;- 长镜头的功能是\"入场仪式\":从公共空间连续走到私密空间,路上经过至少 3 个需要被许可的关卡。生成端把它拆成 2–3 段,接点放在有人或有物完全挡住画面的那 4–6 帧(一个横过的背影、一扇被推开的门),观众读不出接缝;工具若提供续写/延长入口,直接用它接。;- 急推变焦(",
"light": "夜景为主:霓虹、酒吧红、后厨绿、金色包厢、街灯钠黄。实用光源直接入画并且允许过曝。;- 主光可以很硬且方向混乱(多个实用光源互相打架),比 5:1;这种\"脏\"的照明就是都市的质感。;- 上升段饱和度拉高(红金为主),坠落段把饱和度降低 30–40%、把红换成灰绿,同一空间照明不改而观感改变。;- 烟雾常驻:让每个光源都有可见的光锥,画面才有厚度。",
"scene": "犯罪片、都市人物传记、道德坠落、亚文化圈层生态、男性友谊与暴力关系、宗教罪感。;- 需要\"进入一个世界\"的段落:从任何人都能进的公共入口,连续走到只有内部人能进的那个房间。;- 需要用 60–120 秒压缩数年命运的音乐蒙太奇。;- 不适用:需要观众保持冷静分析的题材、极简室内心",
},
"fellini": {
"name": "费里尼 · 狂欢与记忆",
"one_line": "怪异全部长在真人身上:脸、身体、服装、姿态;摄影机在人群里游动,不停下。",
"keywords": "游行式调度、人脸奇观、马戏团与圆舞曲、记忆逻辑、欲望与羞耻、滑稽与悲伤并置、心理舞台、意象回旋、开放结尾。",
"narrative": "用记忆与欲望做结构,不用因果。一个段落的连接理由可以是\"同一首曲子\"或\"同一种羞耻\"。;- 主角常是迷惘的创作者、浪子或观察者;他的功能是被环绕,不是推动。;- 现实滑入幻想没有边界线:让观众在事后才意识到已经越界。;- 全片选 3 个意象反复出现,每次改变一个属性(白天变夜晚、活人变照片、乐队变一个人)。",
"camera": "主力 18–32mm。正面近景用 25mm 贴到 0.5–0.7 米拍——夸张来自这个距离而不是这支镜头(同样是 25mm,退到 2 米脸就只是变小)。鼻子略大、耳朵后缩是漫画式效果,不要用长焦修正。;- 摄影机持续游动:跟移 + 摇摄组合,速度 0.4–0.8 m/s,很少完全静止。它像一个在派对里找人的宾客。;- 群体调度像游行:人物从画面左右边缘陆续入画,前景不断有人穿过(前景遮挡占 10–",
"light": "光像舞台照明:硬边光区打出看得见的边界,雾再把边界柔化半米左右;人物走进走出光区,明暗切换本身是表演。;- 雾与逆光常驻:让人群有轮廓、有层次,也让边界变得不可靠。比 4:1,但阴影里保留细节。;- 彩色时倾向华丽、人工、肉感:粉、金、酒红、天蓝,饱和度高;服装的颜色比环境更响。;- 黑白思维时靠强反差 + 雾 + 白衣黑衣的对撞分区,不靠灰阶细腻。",
"scene": "梦境段落、自传性叙事、艺术家困境、荒诞喜剧、回忆结构、城市漫游、群像仪式(游行、派对、集会、婚礼、葬礼)。;- 需要喜剧与悲伤同时在场的段落;需要主角被一群人包围但更孤独的段落。;- 时尚片、音乐录影、品牌短片里需要\"生命剧场\"感的群像调度。;- 不适用:需要严密因果的类型片情节",
},
"bergman": {
"name": "伯格曼 · 凝视与沉默",
"one_line": "把摄影机放在视线正前方,用长焦让整张脸清晰,然后不切。",
"keywords": "正面凝视、双脸并置、审讯式侧光、存在主义对白、沉默作为台词、室内即牢笼、剥皮式揭露、不治愈的结尾。",
"narrative": "人物 2–3 个,空间 1–2 个,时间跨度不超过一夜。外部事件越简单越好。;- 真正的戏剧来自关系里的谎言、依赖、怨恨与羞耻,不来自情节推进。;- 对白像剥皮:每一轮交换都比上一轮更接近人物最不愿说的那句话。写对白时给每一轮标一个\"被剥掉的第几层\"。;- 全场设一次\"权力翻转\":谁在问、谁在答,必须在中段互换一次。",
"camera": "主力 75–150mm 拍脸。75mm 给中近景,100–150mm 给大特写;长焦不放大鼻子也不缩耳朵,脸被拍成它本来的比例,观众找不到一处变形可以拿来替他解释。;- 正面凝视是核心手段:摄影机在视线轴线上(偏差 <10 度),脸占画幅高度 60–80%。这是这个透镜最贵也最有效的一招,一场戏用 2–4 次就够。;- 双脸构图:一张脸正面在中景平面,另一张脸侧面在近景平面,两张脸的边缘接触或轻微",
"light": "单侧硬主光,比 6:1,暗面不补光但保留可辨认的轮廓。光源方向决定谁在审问谁。;- 光源常是窗、台灯、火;把光位设在演员正侧方(90 度)而不是 45 度,这样鼻影会把脸分成两半。;- 黑白思维:先确认画面在去色后靠脸的明暗雕塑成立。彩色时也保持低饱和、室内化、冷偏(灰蓝、灰绿),只留皮肤是暖的。;- 背景压到近乎全黑或全白的单色平面,让脸没有可依靠的环境信",
"scene": "室内剧、婚姻与亲密关系冲突、信仰危机、死亡焦虑、家庭创伤、忏悔与审问、心理崩溃。;- 两人对话场景;单人独白;需要在 3–5 分钟内不换景完成情绪全程的段落。;- 需要用最少资产(一个房间、两张脸、一扇窗)做出高强度戏剧的低成本项目。;- 不适用:动作、群像、需要空间奇观或类型快",
},
"tarkovsky": {
"name": "塔可夫斯基 · 雕刻时间",
"one_line": "镜头站着不动,让水、火和时间在画面里替人物完成表演。",
"keywords": "雕刻时间、长镜头、记忆、信仰、牺牲、水、火、风、泥、废墟、梦境、精神流亡、质地、垂直压力。",
"narrative": "先定一个精神问题(我能否被原谅/信仰是否值得代价),再设计事件;不要先设计反转。;- 外部旅程与内部旅程一一对应:走向一个具体地点=走向一次内在清算。;- 梦、回忆、现实、预感之间不加标记地切换,观众靠情绪而非逻辑跟随。;- 一场戏只推进一个精神刻度,允许\"什么都没发生\"的整场戏存在。",
"camera": "主力焦段 28–35mm,f/5.6–f/8 深焦:前景的水面、中景的人、远处的门要同时清晰,用景深而不是切镜建立层次。;- 深焦是有代价的:35mm f/8 的超焦距约 5 米,把焦点放在那里,清晰范围是 2.5 米到无限远。所以前景反光面要摆在 2.5 米以外;贴到 1 米内的前景在这个光圈下一定糊,那是另一种镜头(质地特写),不能和深焦分层混在同一个镜头里。;- 移动只有两种:恒速极慢横移(",
"light": "反差比 2:1–3:1,柔阴天窗光为主,几乎不出现硬边阴影。;- 唯一允许的明亮点光源是火:烛、油灯、篝火、燃烧的木头。火只做点光和运动源,永远不做主光——主光始终是柔的阴天窗光,所以即使画面里有明火,也不该出现硬边阴影。;- 主色调是泥棕、湿石灰、苔绿、褐调;高光偏乳白,重点色只有火橙和铁锈红。;- 低饱和低对比,但不是灰蒙蒙——湿表面自己反光,质地承担对",
"scene": "诗电影、精神与信仰危机、科幻寓言、记忆叙事、废墟与被自然侵入的空间、慢电影、梦与现实无缝交叠的段落。也适合极低预算但可控环境的项目:一个房间、一堵湿墙、一盆水就能成段。;不适合:需要在 30 秒内交代三条信息的商业片、动作片、任何以剪辑节奏为卖点的内容。",
},
"wong_kar_wai": {
"name": "王家卫 · 都市孤独",
"one_line": "摄影机贴得太近、挡得太多、又晚了一步,正好是这段关系的形状。",
"keywords": "错过、暧昧、时间标记、记忆重写、孤独、霓虹污染、步进印片、手持、前景遮挡、旁白、都市迷宫、怀旧流行乐。",
"narrative": "先设计\"差一点发生\":两个人共享同一个空间但错开 30 秒,或共享同一个物件但不共享时间。;- 用具体数字承载情感:日期、门牌号、班次、第几次、几分钟、还剩几个。抽象情绪一律换成可数的东西——自己发明一个可数物,不要借用别人已经用过的那一个。;- 旁白是第一人称,真诚但不可靠——它在为自己重写事实,观众要能看出画面与旁白的差额。;- 结构碎片化:同一个动作在",
"camera": "焦段两极:18–25mm 贴到 40cm 内拍狭窄空间和变形的近身;85mm 隔街压缩人群,中间焦段少用。;- 手持是默认,但幅度小:像人在犹豫时的微调,不是奔跑时的晃动。;- 步进印片感(step-print):低帧率拍摄(8–12 fps)配长快门(约 1/8 秒),再把每一帧重复打印 2–3 次填回正常时长。拖影来自那个长快门,不是来自抽帧——只抽帧不开慢门只会得到卡顿。一段只用一次。;- ",
"light": "反差比 6:1–10:1,光比全靠实用光(霓虹管、路灯、走廊灯泡、冰箱灯、电视机),不带补光。;- 分清哪些实用光是硬的:裸灯泡、射灯、窗外招牌的直射是硬源,负责硬边和高光比;霓虹管是细长的线光源,本身偏柔,它的活是染色和勾轮廓,别指望它给你硬边阴影。;- 一个空间只用两三种颜色,且互补:绿与品红、金与青、红与墨绿。颜色是情绪滤镜,不必符合物理。;- 高饱和",
"scene": "都市爱情与错过、暧昧未成形的关系、记忆叙事、夜景与狭窄室内、人物独白、怀旧年代空间、情绪向短片与音乐段落。空间越小越好:走廊、楼梯、单间、面摊、后座。;不适合:需要清楚交代地理关系的场景、群戏调度、任何观众必须看懂\"谁在哪里\"的动作段落。",
},
"nolan": {
"name": "诺兰 · 高概念谜题",
"one_line": "先把规则讲清楚,再让三条时间线同时向同一个倒计时收拢。",
"keywords": "高概念规则、倒计时、交叉剪辑、实拍质感、谜题结构、理性人物、执念、牺牲、物理重量、情感压抑。",
"narrative": "第一步:用一句话写出规则(不超过 25 字),且这句话必须在前 90 秒内以戏剧方式说出或演示出。;- 规则必须同时是情感陷阱:遵守它会让主角失去他最在乎的东西。;- 拼图式给信息:观众先困惑 20%,再补 60%,最后 20% 留到高潮才补齐;每次补齐都要伴随一次代价。;- 多线并行:2–4 条线各有独立倒计时,且倒计时长度不同(例如 A 线 60 分钟、",
"camera": "焦段偏广:14–21mm 拍尺度与空间关系,35mm 拍贴身跟随,50mm 是常规上限。更长的焦段全片只留给一种用途——把分处两地的两条线压进相似的画面形状里做匹配剪辑——用完立刻退回广角。;- 广角+近距离=物理存在感:让镜头离演员 60–90cm,用 21mm 拍,观众能感到重量和体积。;- 手持与稳定器混用,但手持是\"跟得上\"的手持,不是失控;动作段落每个镜头只表达一个机制事实。;- 大远景",
"light": "反差比 3:1–4:1,自然主义动机;奇观也要像是真实发生并被摄影机偶然拍到。;- 主色调冷:混凝土灰、钢青、夜间钠灯的暖橙作唯一暖点;饱和度中低。;- 不同时间线用可辨识但不夸张的差异区分:色温差 500–800K、颗粒差、天气差、镜头畸变差,而不是换成完全不同的滤镜。;- 室内多用可见的实用光源(工作灯、仪表、车灯);室外用真实天光和阴天散射。",
"scene": "高概念科幻惊悚、时间与记忆结构、心理谜题、身份困境、任务片、多线并行高潮、需要\"观众边看边学规则\"的段落。也适合预算有限但结构可以炫技的短片:一个规则、两个空间、三条线。;不适合:氛围向诗电影、纯人物关系剧、没有可陈述规则的情绪散文。",
},
"villeneuve": {
"name": "维伦纽瓦 · 宏大克制",
"one_line": "把人缩到画面高度的百分之五,然后给他二十秒,什么也不解释。",
"keywords": "宏大尺度、几何对称、单色相、雾与沙、低频轰鸣、寂静、缓慢丈量、纪念碑式建筑、人对系统、克制悬念、形而上恐惧。",
"narrative": "主角是专业人士(调查员、士兵、技术员、研究者),带着一套方法论进入一个方法论失效的地方。;- 不解释:把揭示延迟到观众已经形成错误假设之后,让理解本身成为情节事件。;- 事件规模远超主角的处置能力,所以主角的动作永远是\"观察、记录、承受\",而不是\"解决\"。;- 哲学问题先行(时间、记忆、宿命、暴力的代价),奇观是它的物证。",
"camera": "焦段:21mm 拍纪念碑尺度,40mm 拍程序化中景,75mm 压缩疲惫的脸;人物在广角镜头里占画面高度的 5%–15%。;- 运动只有三种:锁死、恒速横移(像在丈量墙面)、恒速推进(正对灭点)。禁止手持、禁止环绕、禁止变速。;- 强制留出\"空镜时间\":镜头在人物进入前 2 秒开始,在人物离开后 3 秒结束。;- 构图靠中心对称或单一水平线;把地平线放在画面的 1/8 或 7/8,不要放中间。",
"light": "一个段落一个主导色相,绝不混:赭橙沙漠、青雾、铜金室内、墨绿林地、灰白冰面。换色相=换地点或换时间层。;- 逆光为主,光比 5:1–8:1——这个比值指的是逆光面与正面补光之比,不是常规的主光对补光,所以人物是剪影或半剪影,正面只留到轮廓可读为止。;- 柔散射,几乎不出现硬边阴影;光源常是一整面墙、一整扇窗、一整片天。;- 低饱和但高色相纯度:颜色很淡,但整",
"scene": "科幻寓言、第一接触、缓慢恐惧、犯罪程序剧、巨型建筑与荒漠地貌、人在系统性力量前的渺小感、需要\"看不清也听不清\"作为主题的段落。低成本也可成立:一堵大墙、一台雾机、一个逆光窗。;不适合:需要密集信息交付的段落、喜剧、群戏对白、任何要求观众跟上快速因果链的内容。",
},
"fincher": {
"name": "芬奇 · 外科精确",
"one_line": "用长焦把人从背景里切下来,再从他不知道的位置上看着他。",
"keywords": "外科精确、信息密度、监视视角、青绿-琥珀、低饱和低对比、长焦样本化、实用光唯一、机位可复现、程序节奏、反高潮。",
"narrative": "结构由信息流动决定:先画出\"谁在第几分钟知道什么\"的表,再写场景。;- 节拍来自工作行为:数据、笔录、屏幕、会议、走访、归档;人物通过职业动作暴露内心。;- 观众的知情度略高于主角约一个证据单位——不是完全同步,也不是全知。;- 暴力不被浪漫化:冷静、突然、无配乐前摇,且后果延迟到下一场才显现。",
"camera": "焦段偏长:85mm 与 135mm 是主力,35mm 只用于一次性交代几何;长焦压缩把背景压成色块,人物被\"抠\"出来。;- 机位可复现:写清高度、距离、角度,同一场戏的同一机位必须能精确回到原位;这是与\"差不多的稳定\"最大的区别。;- 推进以百分比计量:一个 8 秒镜头推进画幅宽度的 3%–6%,慢到观众意识不到,但结束时构图已经变紧。;- 监视机位是签名:从玻璃后、门缝、走廊尽头、货架之间、天花",
"light": "青绿阴影与琥珀实用光的两级对位;不是整体偏色,是阴影偏青、光源偏黄。;- 光比 4:1–6:1,但对比度整体压低:黑位抬起,阴影里始终保留可读纹理,绝不压死黑。;- 实用光源常是唯一光源:台灯、显示器、街灯、走廊应急灯、车灯;写提示词时点名光源实体,不写氛围。;- 饱和度低但不去色:皮肤保留一点血色,其余颜色被抽走。",
"scene": "犯罪悬疑与连环案、调查与程序叙事、技术行业与媒体操纵、心理冷酷的家庭暴力、职业流程剧、阴冷都市夜景。特别适合\"信息不对称\"驱动的段落:谁知道什么、何时知道、怎么被揭穿。;不适合:需要观众与人物共情到失控的段落、手持纪实、温暖家庭剧、任何靠镜头晃动制造紧张的内容。",
},
"refn": {
"name": "雷弗恩 · 霓虹停滞",
"one_line": "机位锁死、人在正中、什么都不动,让颜色替所有人做完这场仪式。",
"keywords": "霓虹色块、正面对称、催眠停滞、合成器音墙、仪式动作、缓慢凝视、突发暴力、身体物化、平面化构图、无解释寓言。",
"narrative": "情节压到寓言级:欲望、复仇、嫉妒、自毁,四选一,且不加动机说明。;- 用仪式动作推进而不是因果链:穿衣、洗手、走向房间中央、跪下、把一样东西放在正中。;- 人物动机藏在沉默里,只通过行为暗示;观众允许猜错。;- 暴力是审美事件,不是问题的解决方案;它不改变人物,只完成一个形式。",
"camera": "焦段 25mm(把房间拍成舞台)/40mm(仪式中景)/75mm(锁死的脸部肖像)。25mm 只用来正面平行地拍整堵墙,且人物离镜头 3 米以上——这样广角的变形不会显形;一旦贴到 1.5 米内拍人,脸就会鼓出来,对称的仪式感立刻变成怪诞,那是另一套语言。;- 机位胸高、与墙面成 90 度、绝对居中;人物的中轴与画面中轴误差为零。;- 运动预算是零:整段不动。若必须动,只允许一次恒速正面推进,速度",
"light": "极端色块:纯品红、电光蓝、血红、月光紫;一个镜头最多两种颜色,各占约一半画面,边界硬。分界线可以偏离画幅中线 ±10%,但必须落在一条画面里本来就有的竖直边上(门框边、墙角、柱子),否则观众会读成打光失误而不是设计。;- 光比 8:1–12:1,光源像舞台灯:硬边、有明确的落点、可以直接看见颜色的分界线。;- 单一色温支配整段;换颜色=换心理状态或换叙事阶段",
"scene": "风格化犯罪、寓言式短片、时尚惊悚、缓慢恐怖、宗教或仪式感空间、沉默角色与极端美学并存的段落。这套语言对生成模型格外友好:无位移、无复杂调度、单一光源、单一平面,能出错的地方比其他风格少一个数量级——你几乎只需要守住构图不塌。;不适合:需要交代因果的叙事段落、群戏对白、任何依赖观众",
},
"bi_gan": {
"name": "毕赣 · 梦境长镜",
"one_line": "摄影机像一个没睡醒的人,沿一条能画在地图上的路,一直走进梦里。",
"keywords": "雾镇、超长长镜头、路径、穿越式转场、梦境拼接、诗歌旁白、时间循环、潮湿反光、霓虹与乡土并置、磁带与老歌、记忆寻人。",
"narrative": "故事围绕\"寻找一个人或一段记忆\",但寻找的过程就是全部叙事,找到与否不重要。;- 现实、梦境、回忆、预感被有意混淆,且不加任何标记;观众跟随情绪而非逻辑。;- 时间可循环、倒流、并置:同一空间在段落后段再次出现,但物件或人已经不同。;- 诗歌旁白承担象征任务,与当下画面错位;它谈论的对象不在画面里。",
"camera": "焦段 18–25mm 为主,f/2.8–f/4;广角让狭窄空间可通行,同时把湿反光铺满画面。贴到 1.5 米跟拍时,25mm f/3.5 的景深不到一米,背后的霓虹和灯泡自然化成光斑——这正是要的,不要收小光圈去救背景,收了整条街就变成资料片。注意这层虚化只在近端成立:退到 3 米,同样的焦段光圈景深就回到两米以上,背景重新变清楚,所以\"贴着人走\"不是风格偏好,是拿到这个背景的技术前提。;- 摄影",
"light": "主光源是潮湿表面的反射:积水、湿砖、玻璃、雨衣、瓷砖;先设计水,再设计灯。;- 混合色温是常态且不校正:卡拉 OK 招牌的霓虹、台球桌上的荧光管、廉价白炽灯泡、电视机的青光同时存在。;- 色调湿润青绿、暗紫、淡橙,低饱和但层次厚;对比低,暗部有雾散射的抬升。;- 雾天自然光形成弥散柔光,几乎无硬阴影;光比 2.5:1–3.5:1。",
"scene": "华语小镇叙事、诗电影、梦与现实交叠、寻人与寻记忆、潮湿西南地貌、低成本高氛围段落、长镜头实验、魔幻现实主义。适合有真实场地但没有预算做复杂分镜的项目:一条路线就是一场戏。;不适合:需要多机位覆盖的对白戏、剪辑驱动的类型片、任何需要观众看懂因果的段落。",
},
"zhang_yimou": {
"name": "张艺谋 · 单色仪式",
"one_line": "先决定这一段是什么颜色,再决定谁站在里面;颜色说话,人只是破格点。",
"keywords": "单色场、图案与破格、群体几何、民俗仪式、院落对称、材料色、顶俯图形、集体规训、命运的可见形状、色彩宣告。",
"narrative": "先写\"颜色分段表\":全片 2–4 个色段,每段一句话写清这个颜色代表什么;换色必须落在一次剪辑点上,且对应一次不可逆的处境变化。;- 冲突结构是\"个体 vs 规矩\":一个人想要一件很小的东西,一套集体制度不允许。制度必须有可见形式——队列、称量、报数、开门关门的时刻表。;- 仪式承担说明功能:不解释规矩,演一次规矩,观众自己看懂。一场仪式只演一次完整流程,第",
"camera": "主力 35mm 做天井与长廊的单点透视对称;85–135mm 把重复单元(列队、屋檐、灯笼、门洞、梯田)压成平面图案。24mm 以下慎用——广角会破坏单元的等距,图案立刻散掉。;- 机位只用两档高度:眼平 1.6m 锁定,用于正面对称;顶俯 90° 用于把人群变成图形。中间高度会同时失去对称和图形,尽量不用。;- 运动只有三种,每种都恒速无加减速:与一排单元平行的横移(0.3–0.5 m/s)、对",
"light": "单场单主色:一个段落只允许一个主色占据 60%–80% 画幅。换色即换情绪刻度,且换色必须发生在剪辑点上,不做渐变。;- 外景用高照度硬光,比 3:1,让材料的织纹和颗粒被看见。3:1 是靠地面和同色墙面的大面积反射把暗部托起来的,不是靠加灯;没有这层反射的空旷外景,硬太阳自然会跑到 6:1 以上,那就不是这个风格了——所以选景时先看有没有能反光的大面积同色",
"scene": "民俗与仪式题材:婚丧、节庆、宗族规矩、集体劳作、大型团体操演段落(方阵、齐舞、集体仪典)。;- 需要用视觉宣告情绪的段落:情绪不靠台词、不靠特写,靠整块画面换色完成。;- 院落、天井、长廊、梯田、旷野、城墙等有强几何秩序的空间。;- 群体资源可控的项目:只要有 30 个以上可重复",
},
"hou_hsiao_hsien": {
"name": "侯孝贤 · 固定远观",
"one_line": "摄影机提前到场,坐在门外,不走近也不离开,让一屋子人把时间过完。",
"keywords": "固定长镜、远观、门框纵深、实时、省略、环境声、日常行为、非表演、家族时间、画外事件。",
"narrative": "结构靠省略:把最有戏的事件放在两颗镜头之间。观众看见事前的一顿饭和事后的一顿饭,中间那件事永远不拍。;- 一场戏=一个连续时间块,不做时间压缩。如果吃饭要 4 分钟,就拍 4 分钟里的一段,不剪成 40 秒。;- 信息由行为携带:谁坐在哪、谁给谁盛饭、谁不说话、谁先离席。人物关系全部靠座次和动作分配交代。;- 大历史只从缝隙里进来——广播、一句转述、一个不再",
"camera": "主力 50–100mm,机位距被摄体 6–12 米。用中长焦从远处拍全景,而不是用广角贴近拍——这一条决定了整个风格:观众离得远,但看得清。;- 机位高度是坐姿眼高 1.0–1.2 米(板凳、榻榻米、饭桌的高度),不是站姿眼高。整场不变。;- 三脚架锁死。唯一允许的运动是\"补构图\":人物快要出画时,10–20 度的极慢摇,速度慢到观众意识不到,摇完就停。禁止推、拉、升降、跟。;- 门框、窗框、廊柱",
"light": "只用现场光。主光是门口或窗口的白天户外光,室内比它低 1–2 档;人脸经常处在下半档的暗部,允许看不清。;- 光比 2:1–4:1,柔,没有硬边阴影;夜戏由一盏真实的顶灯或日光灯管完成,比 4:1,灯本身可以入画。;- 不给演员补光。人走到窗边就亮,走进屋里就暗——曝光变化本身是场面调度的一部分。;- 色调来自实景材料:旧木头的褐、灰泥墙的白、门外的植物绿、",
"scene": "家庭史、时代变迁下的日常生活、代际沉默、离开与返乡、小城与街屋空间。;- 需要\"事件发生在画外\"的叙事:变故已经发生,我们只看见之后的一顿饭。;- 极低成本高完成度的项目:一个真实房间、一扇门、一个下午的自然光就能拍一场。;- 需要观众自己在长时间里发现信息的段落——他必须扫描画",
},
"park_chan_wook": {
"name": "朴赞郁 · 巴洛克反讽",
"one_line": "把画面塞满花纹,让摄影机做一件物理上不可能的事,然后礼貌地伤害一个人。",
"keywords": "巴洛克、纹样密度、反讽之美、礼貌残酷、不可能的运动、对称正面、道德账目、形式高潮、精确暴力、后果的重量。",
"narrative": "结构是账本:谁欠谁什么,写成一张可核对的表。每一次暴力都必须记账,并在后面某处被结算。;- 复仇者与被复仇者共享同一套礼节。他们互相称呼得体、请对方坐下、倒茶、道歉——恐怖来自礼节没有中断。;- 用形式而不是台词交代秘密:一次镜像、一件重复出现的织物、两个不同时间点在同一位置的相同构图。;- 允许一次结构性的反转,但反转必须让此前所有礼貌行为的含义反过来,而",
"camera": "主力 40–50mm 做正面对称;28mm 只在那一次不可能运动里用(需要把整个装饰空间收进来);85mm 给装饰性插入镜头(手、餐具、绣线、纽扣)。;- 每场戏只允许一次\"不可能运动\",且写成一条有起点和终点的路径:例如从 90 度顶俯的物件开始,下降并旋转 90 度,落在正面对称的人物中景上;或沿一面纹样墙横移,穿过墙体,继续在隔壁房间横移。多做一次,风格就变成噪音。;- 写这条路径时把后退量",
"light": "主光是暖实用光(台灯、壁灯、廊下灯笼、窗户的斜射日光),必须以掠射角打在有纹样的表面上,让图案获得浮雕感——正打光会把花纹拍平,等于白做。;- 常规光比 3:1;暴力发生的那一颗拉到 8:1,方法是关掉补光而不是加硬光,让阴影自己吃掉一半的花纹。;- 冷色只从窗外进来,作为暖室内的对位,占画幅不超过 20%。;- 主色:酒红、橄榄绿、芥末金、胡桃木褐;重点色",
"scene": "复仇、家族秘密、阶级伪装、囚禁与交换、道德账目型犯罪叙事。;- 需要\"美\"与\"残酷\"同时在场的段落:一次伤害发生在一个非常好看的房间里。;- 有美术预算或有真实纹样环境的项目:壁纸、地砖、刺绣、木雕、庭院植栽、旧式旅馆。;- 需要一次让观众\"看见摄影机\"的形式高潮:一个镜头做完一",
},
"malick": {
"name": "马力克 · 自然光诗意",
"one_line": "镜头贴着一只手,太阳在她背后压着地平线,摄影机以步速漂移,不停。",
"keywords": "自然光、魔术时刻、广角贴近、步速漂移、手势、自然插入、耳语旁白、联想蒙太奇、无情节、风景中的身体。",
"narrative": "不写情节点,写\"记忆序列\":一串可以任意排序的片段,每段一句话(谁、在哪、做一个什么手势)。;- 时间是主观的:现在、回忆、想象之间不加标记地切换,观众靠旁白的人称和光的方向分辨。;- 旁白是内心的第二人称或对缺席者的呼告,8–15 个字一句,不描述画面,也不解释情节;它提问,画面不回答。;- 人物之间的关系由身体距离与朝向交代:谁走在谁前面、谁被落下、谁转",
"camera": "主力 14–28mm,且必须贴近:拍手时镜头离手 30–50 厘米,拍脸时 60–90 厘米。广角加贴近=前景巨大、地平线在后面,这是这个风格的全部秘密。35mm 只留给贴身跟走的中近景,再长就跟不上人也压不出前景。;- 摄影机永不锁定。手持或轻稳定器,以人的步速漂移(0.8–1.2 m/s),低频晃动,没有一次急停急起。禁止三脚架,禁止对称正面。;- 机位高度持续变化,0.3–1.8 米之间来回",
"light": "只用现有光。允许的器材是一块白卡或一面反光板,不允许打灯。做不到就不拍这场。;- 拍摄窗口:日出后 20–35 分钟或日落前 20–35 分钟,太阳高度 2–10 度。太阳每小时最多爬升约 15 度(纬度越高越慢),所以这个高度带只能对应半小时上下的窗口,别按\"一整个金色小时\"排通告。阴天全天可用,晴天正午不可用。;- 太阳放在人物后方 150–210 度范",
"scene": "回忆、成长、失去、信仰、家庭裂痕这类没有外部目标的抒情叙事。;- 品牌与公益片中需要\"感受先于信息\"的段落;婚礼、纪念、季节性影像。;- 有真实外景与可等待的日程的项目:能守到日出后 30 分钟或日落前 30 分钟。;- 素材式拍法:先拍大量碎片,在剪辑台上找出顺序——适合没有完",
},
"michael_mann": {
"name": "迈克尔·曼 · 夜色与专业",
"one_line": "夜里的城市是一块发着冷光的平原,一个懂行的人站在它前面,谁也不认识他。",
"keywords": "都市夜、市政光源、混色温、地平线、专业能力、程序精确、高感噪点、玻璃反射、职业孤独、城市噪声即配乐。",
"narrative": "人物由能力定义:出场前 60 秒内必须让观众看见他把一件专业的事做对,不靠台词说明身份。;- 双主角结构:对立的两方共享同一套职业伦理和作息,用平行剪辑并置两条日常线(同样的准备、同样的等待、同样的收尾),让观众先认同能力,再被迫选立场。;- 私人生活以碎片方式插入,永远被工作打断,而且从不解决;感情戏的功能是标出这个人不能拥有什么。;- 流程即情节:把一次",
"camera": "主力 18–25mm 做\"室内也有地平线\":机位退到房间深处,让整面窗和天际线成为人物背后的背景;35mm 做双人;50mm 只给流程插入(手、工具、接口、时间显示)。;- 机位眼平 1.6m,光轴保持水平。仰俯角一律不用,除非从车里拍或从高处看城市。人被压小是靠距离和构图,不靠角度。;- 人物在画幅中的高度目标是 8%–15%——按高度算,不要按宽度:25mm 镜头下 22 米外的人只占画幅宽度",
"light": "不打电影灯。每一个光源都要说得出它在城里的名字:钠灯路灯、汞灯停车场、便利店的荧光管、LED 广告、车头灯、写字楼层的顶灯、施工照明。;- 同一帧里必须有至少两种不同色温对抗(例如左侧钠灯橙、右侧汞灯青绿),不要统一白平衡把它们调成一个颜色——那正是最常见的失败。;- 曝光按实用光源给,不按脸给:脸可以低于主光 1.5–2 档,允许看不清,允许出现可见的高感",
"scene": "犯罪、盗窃、监视、执法、走私、网络安全这类\"专业者对专业者\"的题材。;- 需要把流程拍成戏的段落:装配、勘察、等待、交接、撤离——每一步都有自己的镜头。;- 都市夜外景:停车楼、高架下、机场外围、码头、深夜写字楼、住宅塔楼的外挂走廊。;- 需要\"孤独=距离\"的情绪:人物之间永远隔",
},
"coen_brothers": {
"name": "科恩兄弟 · 冷峻荒诞",
"one_line": "把画面摆得一丝不苟,让演员绝对认真地演,然后比该切的时候多留一秒。",
"keywords": "冷面、几何、地域具体性、广角低机位、命运是个平板笑话、能力反噬、荒诞记账、多留一秒、正面死中、难看的顶光。",
"narrative": "先写\"计划书\":主角的方案分 5–8 步,每步都合理。然后找出其中恰恰因为执行正确才引发灾难的那一步——那是全片的引擎。;- 引入一个不理解这套计划的外部力量(天气、一个较真的职员、一条法规、一只动物),它不邪恶,它只是不参与。;- 暴力必须记账:每一次伤害之后立刻拍善后——擦、搬、填表、解释、赔钱。观众的不适来自后勤,不来自血。;- 至少一个角色是道德常识",
"camera": "主力 14–21mm 做低机位运动与怪诞近景;27mm 做冷面正面对称;40mm 只留给全片唯一一次不开玩笑的单人镜头。;- 机位是二元的:要么完全锁定正面死中(眼平 1.6m),要么一次硬性机械运动——0.4–0.8 米高的低机位推轨跑在行走者前方或后方,恒速直线。没有\"飘\",没有微调构图。;- 广角低机位跑动镜头的规格:镜头高度 0.4–0.6 米,上仰 10–15 度,速度与被摄体一致;14",
"light": "光要难看得有道理:顶部荧光灯管、一只裸灯泡、雪地反光、汽车旅馆的床头灯。顶光在每个人眼窝里留下阴影,让称职的人看起来像傻子——这是这个风格的核心照明策略。;- 室内比 3:1,夜外景 8:1;不用柔光箱美化人脸,不用发丝逆光。;- 每个空间有一个统一的、廉价的色偏:办公室是荧光管的绿白,汽车旅馆是钨丝的橙棕,雪地是无色的高亮,警局是日光灯的青白。;- 主色:",
"scene": "黑色喜剧、犯罪失控、绑架与勒索、小人物执行大计划、官僚与流程荒诞。;- 强地域性题材:某个具体的县、某种口音、某种天气、某种连锁店——具体到能说出品类名称。;- 需要观众\"笑得不确定\"的段落:同一颗镜头里既有滑稽也有真实的伤害。;- 低成本可行:一条走廊、一间办公室、一片空地加一",
},
}
def get_director_style(key: str) -> dict:
"""按 key 取导演风格(未知 key 返回空 dict)"""
return DIRECTOR_STYLES.get((key or "").strip()) or {}
def director_style_block(key: str) -> str:
"""导演风格 → 提示词块(供导演系统注入)
只注入**可执行要点**(约 150-250 字),保持提示词简洁:
核心视角一句 + 关键词 + 镜头语言前两条 + 灯光首要点
"""
st = get_director_style(key)
if not st:
return ""
def _first(s: str, n: int = 2) -> str:
parts = [x.strip(" -") for x in str(s or "").split(";") if x.strip(" -")]
return ";".join(parts[:n])[:220]
out = [f"【导演风格:{st['name']}】"]
if st.get("one_line"):
out.append(f"· 核心视角:{st['one_line'][:90]}")
if st.get("keywords"):
out.append(f"· 风格关键词:{st['keywords'][:80]}")
cam = _first(st.get("camera"), 2)
if cam:
out.append(f"· 镜头语言:{cam}")
lit = _first(st.get("light"), 1)
if lit:
out.append(f"· 灯光:{lit}")
out.append("→ 用以上镜头语言与光影逻辑设计分镜(原创内容,不照搬具体镜头/台词)。")
return "\n".join(out)
def director_style_options() -> dict:
"""供前端下拉:{key: 中文名}"""
return {k: v.get("name", k) for k, v in DIRECTOR_STYLES.items()}
@@ -0,0 +1,607 @@
"""
composer/dubbing.py — 配音合成(TTS 人声 + 环境音铺底 + 时间轴对齐)
背景(2026-10-02 实测确认):
· 可灵生成的镜头**没有音轨**(payload 无音频参数)
· 分镜的 Shot.text(台词)此前只拼进提示词,不产生任何声音
· 导演台出片链路(pipeline.render)没有音频环节 → 成片无声
本模块补齐「声音」:
按镜头时间轴,把台词逐句合成为一条完整音轨;每个镜头一段,段长严格等于
镜头时长(短则补静音、长则加速),因此天然对齐画面。
音色来源(优先级):
1. 演员表指定的 voice_id(CastMember.voice_id)
2. 按角色代号稳定分配的路人音色池(同一角色全片同一音色,不会听起来换人)
环境音来源:
分镜的 Shot.audio_cue(如"远处车流声")→ 与环境音库资产做关键词匹配 →
该镜头期间低音量循环铺底。
输出:一条 44.1kHz 立体声 wav(可直接交给 StoryboardPipeline.mux_final 的 voice_path)
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Callable, Optional
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
from lib.ffmpeg import FFMPEG # noqa: E402 # 统一 ffmpeg 定位(绝对路径)
log = get_logger("dubbing")
API_BASE_SR = 44100
DEFAULT_SFX_VOLUME = 0.85 # 音效默认音量(非语言发声为主,比环境音高)
DEFAULT_AMBIENCE_VOLUME = 0.15
DEFAULT_VOICE_VOLUME = 1.0
ProgressCB = Optional[Callable[[str, str, str, str], None]]
# ════════════════════════════════════════════════════════════
# 音色分配
# ════════════════════════════════════════════════════════════
# 路人音色池(未指定音色的角色按代号稳定分配;来自 config/cosyvoice_voices.yaml 的 pools)
FALLBACK_MALE = ["longze_v3", "longzhe_v3", "longanlang_v3"]
FALLBACK_FEMALE = ["longxing_v3", "longmiao_v3", "longanqin_v3"]
NARRATOR_VOICE = "longxiu_v3"
def _is_female(slot: str, note: str = "") -> bool:
"""粗判角色性别(用于选路人音色池):代号/备注里出现女/她/妈/姐/妹/婆 → 女声"""
t = f"{slot} {note}"
return bool(re.search(r"女|她|妈|姐|妹|婆|娘|太太|女士|girl|woman", t))
def resolve_voice_map(sb, cast_voice_map: dict | None = None) -> dict:
"""角色代号 → voice_id(确定性分配)
cast_voice_map: 演员表里显式指定的 {slot: voice_id}
未指定的角色 → 按代号 hash 从路人池稳定取一个(同名永远同音色)
"""
cast_voice_map = cast_voice_map or {}
out: dict[str, str] = {}
# ⚠️ 字段名是 global_anchors(不是 anchors)—— 写错会导致演员表里的 voice_id 读不到,
# 角色音色全部退化为"路人池按 hash 分配"(用户实测发现)
_ga = getattr(sb, "global_anchors", None)
_cast = []
if _ga is not None and hasattr(_ga, "effective_cast"):
try:
_cast = _ga.effective_cast() or []
except Exception:
_cast = []
for m in _cast:
slot = getattr(m, "slot", "") or ""
if not slot:
continue
vid = (cast_voice_map.get(slot) or getattr(m, "voice_id", "") or "").strip()
if not vid:
pool = FALLBACK_FEMALE if _is_female(slot, getattr(m, "note", "")) else FALLBACK_MALE
vid = pool[sum(ord(c) for c in slot) % len(pool)]
out[slot] = vid
# 演员表未覆盖的出场代号(导演写的)也要有音色
for shot in getattr(sb, "shots", []) or []:
for slot in (getattr(shot, "cast_slots", None) or []):
if slot and slot not in out:
pool = FALLBACK_FEMALE if _is_female(slot) else FALLBACK_MALE
out[slot] = pool[sum(ord(c) for c in slot) % len(pool)]
return out
def pick_voice_for_shot(shot, voice_map: dict, narrator: str = NARRATOR_VOICE) -> str:
"""该镜头台词用谁的音色:出场人物第一顺位;无出场 → 旁白音色"""
slots = getattr(shot, "cast_slots", None) or []
for s in slots:
if s in voice_map:
return voice_map[s]
if slots:
# 演员表里没有的代号 → 用其自身 hash 分配
pool = FALLBACK_FEMALE if _is_female(slots[0]) else FALLBACK_MALE
return pool[sum(ord(c) for c in slots[0]) % len(pool)]
return narrator
# ════════════════════════════════════════════════════════════
# 音频工具
# ════════════════════════════════════════════════════════════
def _run(cmd: list[str], timeout: int = 600) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def probe_audio_duration(path: str) -> float:
"""音频/视频时长(秒);用 ffmpeg -i 解析(本机无真 ffprobe,见 lib/ffmpeg.py)"""
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return float(d or 0.0)
def synthesize_line(text: str, voice_id: str, out_path: str,
api_key: str = "", speed: float = 1.0) -> str:
"""单句 TTS(阿里 CosyVoice)"""
from voice_synthesizer.aliyun import synthesize
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
synthesize(text, out_path, api_key=api_key, voice_id=voice_id, speed=speed)
return out_path
def make_silence(duration_sec: float, dst: str) -> str:
"""生成指定时长的静音段(44.1k 立体声 wav)"""
r = _run([FFMPEG, "-y", "-f", "lavfi",
"-i", f"anullsrc=channel_layout=stereo:sample_rate={API_BASE_SR}",
"-t", f"{max(0.05, duration_sec):.3f}",
"-c:a", "pcm_s16le", dst], timeout=120)
if r.returncode != 0:
raise RuntimeError(f"生成静音失败: {r.stderr[-300:]}")
return dst
def fit_audio_to_duration(src: str, dst: str, target_sec: float,
max_speedup: float = 1.6) -> dict:
"""把音频适配到目标时长
· 短于目标 → 补静音(apad),保持原速
· 长于目标 → 加速(atempo,最多 max_speedup 倍);仍超长则截断
返回 {out, src_dur, target_dur, action}
"""
src_dur = probe_audio_duration(src)
target = max(0.3, float(target_sec))
action = "keep"
afilters: list[str] = []
if src_dur <= 0:
# 探不到时长 → 直接按目标截断 + 补静音,交给 ffmpeg
afilters = ["apad"]
action = "unknown-duration"
elif src_dur > target * 1.02:
ratio = src_dur / target
if ratio <= max_speedup:
afilters = [f"atempo={ratio:.4f}"]
action = f"加速×{ratio:.2f}"
else:
afilters = [f"atempo={max_speedup:.4f}", "apad"]
action = f"加速×{max_speedup:.2f}+截断"
else:
afilters = ["apad"]
action = "补静音"
af = ",".join(afilters) if afilters else "anull"
r = _run([FFMPEG, "-y", "-i", src, "-af", af,
"-t", f"{target:.3f}", "-ar", str(API_BASE_SR), "-ac", "2",
"-c:a", "pcm_s16le", dst], timeout=300)
if r.returncode != 0:
raise RuntimeError(f"音频适配失败: {r.stderr[-300:]}")
return {"out": dst, "src_dur": round(src_dur, 2), "target_dur": round(target, 2), "action": action}
def mix_segment(voice_path: str, ambience_path: str, duration_sec: float,
dst: str, ambience_volume: float = DEFAULT_AMBIENCE_VOLUME,
sfx_items: list | None = None) -> str:
"""把「人声 + 环境音(循环)+ 音效(按时点插入)」混成一个镜头段,严格等于目标时长
sfx_items: [(音效文件路径, 插入秒数, 音量)] —— 非语言发声(惨叫/闷哼/关门声…)
"""
dur = max(0.3, float(duration_sec))
sfx_items = [x for x in (sfx_items or [])
if x and x[0] and Path(x[0]).exists()]
has_v = bool(voice_path and Path(voice_path).exists())
has_a = bool(ambience_path and Path(ambience_path).exists())
if not (has_v or has_a or sfx_items):
return make_silence(dur, dst)
inputs: list[str] = []
if has_v:
inputs += ["-i", voice_path]
if has_a:
inputs += ["-stream_loop", "-1", "-i", ambience_path]
for f, _at, _vol in sfx_items:
inputs += ["-i", f]
parts: list[str] = []
labels: list[str] = []
idx = 0
if has_v:
parts.append(f"[{idx}:a]volume={DEFAULT_VOICE_VOLUME},apad[v]")
labels.append("[v]"); idx += 1
if has_a:
parts.append(f"[{idx}:a]volume={ambience_volume},atrim=0:{dur:.3f},asetpts=N/SR/TB[a]")
labels.append("[a]"); idx += 1
for k, (f, at, vol) in enumerate(sfx_items):
ms = int(max(0.0, min(float(at), dur - 0.05)) * 1000)
parts.append(f"[{idx}:a]volume={vol},adelay={ms}|{ms}[s{k}]")
labels.append(f"[s{k}]"); idx += 1
if len(labels) == 1:
parts.append(f"{labels[0]}anull[out]")
else:
parts.append(f"{''.join(labels)}amix=inputs={len(labels)}:duration=first:normalize=0[out]")
r = _run([FFMPEG, "-y", *inputs, "-filter_complex", ";".join(parts),
"-map", "[out]", "-t", f"{dur:.3f}",
"-ar", str(API_BASE_SR), "-ac", "2", "-c:a", "pcm_s16le", dst], timeout=300)
if r.returncode != 0:
raise RuntimeError(f"段混音失败: {r.stderr[-300:]}")
return dst
def concat_audio(seg_paths: list[str], out_path: str) -> str:
"""按顺序拼接音频段(同规格 wav → concat demuxer)"""
if not seg_paths:
raise ValueError("没有音频段可拼接")
lst = Path(out_path).with_suffix(".txt")
lst.write_text("".join(f"file '{Path(p).resolve()}'\n" for p in seg_paths), encoding="utf-8")
r = _run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", str(lst),
"-c:a", "pcm_s16le", "-ar", str(API_BASE_SR), "-ac", "2", out_path], timeout=600)
lst.unlink(missing_ok=True)
if r.returncode != 0:
raise RuntimeError(f"音频拼接失败: {r.stderr[-300:]}")
return out_path
# ════════════════════════════════════════════════════════════
# 环境音匹配(分镜 audio_cue ↔ 环境音库)
# ════════════════════════════════════════════════════════════
def load_ambience_assets() -> list[dict]:
"""读取环境音库资产(含本地文件路径)"""
try:
from assets_manager import AssetStore
st = AssetStore("ambience")
out = []
for a in st.list():
f = ""
for slot in ("audio", "file", "sample"):
v = a.get(slot) or ""
if v:
try:
f = str(Path(st.kind_dir(a["id"])) / Path(v).name)
except Exception:
f = ""
break
if not f or not Path(f).exists():
# 退一步:目录下第一个音频文件
try:
d = Path(st.kind_dir(a["id"]))
cands = [p for p in d.glob("*") if p.suffix.lower() in (".mp3", ".wav", ".m4a", ".aac", ".ogg")]
f = str(cands[0]) if cands else ""
except Exception:
f = ""
a["_file"] = f
out.append(a)
return out
except Exception as e:
log.warning("环境音库读取失败: %s", e)
return []
def load_sfx_assets() -> list[dict]:
"""读取音效库资产(含本地文件路径)—— 结构同环境音库"""
try:
from assets_manager import AssetStore
st = AssetStore("sfx")
out = []
for a in st.list():
f = ""
for slot in ("audio", "file", "sample"):
v = a.get(slot) or ""
if v:
try:
f = str(Path(st.kind_dir(a["id"])) / Path(v).name)
except Exception:
f = ""
break
if not f or not Path(f).exists():
try:
d = Path(st.kind_dir(a["id"]))
cands = [p for p in d.glob("*")
if p.suffix.lower() in (".mp3", ".wav", ".m4a", ".aac", ".ogg")]
f = str(cands[0]) if cands else ""
except Exception:
f = ""
a["_file"] = f
out.append(a)
return out
except Exception as e:
log.warning("音效库读取失败: %s", e)
return []
def match_sfx(desc: str, assets: list[dict]) -> str:
"""用音效描述匹配音效资产(名称/标签最长子串命中)→ 文件路径
例:desc「痛苦的闷哼」→ 命中音效「闷哼」/「痛呼」
"""
if not desc or not assets:
return ""
d = str(desc)
best, best_len = "", 0
for a in assets:
f = a.get("_file") or ""
if not f:
continue
for key in [a.get("name", "")] + list(a.get("tags") or []):
k = str(key or "").strip()
if len(k) >= 2 and (k in d or d in k) and len(k) > best_len:
best, best_len = f, len(k)
return best
def match_ambience(cue: str, assets: list[dict]) -> str:
"""用 audio_cue 文本匹配环境音资产(名称/标签最长子串命中)→ 文件路径"""
if not cue or not assets:
return ""
best, best_len = "", 0
for a in assets:
f = a.get("_file") or ""
if not f:
continue
for key in [a.get("name", "")] + list(a.get("tags") or []):
k = str(key or "").strip()
if len(k) >= 2 and k in cue and len(k) > best_len:
best, best_len = f, len(k)
return best
# ════════════════════════════════════════════════════════════
# 主入口
# ════════════════════════════════════════════════════════════
def pre_synthesize(sb, work_dir: str, api_key: str = "",
cast_voice_map: dict | None = None,
min_dur: float = 2.0, max_dur: float = 10.0,
pad: float = 0.4, progress_cb: ProgressCB = None) -> dict:
"""【音频先行】逐镜合成台词 → 按音频**真实时长**更新镜头时长
为什么:画面时长若拍脑袋定,台词长了就得加速/截断 → 口型节奏必然对不上。
音频先行 = 让画面跟着声音走。
· 有台词的镜头:TTS → 探测时长 → shot.duration = clamp(音频时长 + 气口, min, max)
· 无台词镜头:保持原时长
· 音频存到 {work_dir}/{shot_id}.mp3 → 后续混流**直接复用,不重复 TTS**
返回 {updated, shots:{shot_id:{audio,audio_dur,old_dur,new_dur,voice_id}}, notes}
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
from lib.config import load_config
cfg = load_config()
api_key = api_key or (cfg.get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise RuntimeError("音频先行需要百炼 API Key(TTS)")
voice_map = resolve_voice_map(sb, cast_voice_map)
out: dict = {}
notes: list = []
updated = 0
shots = list(getattr(sb, "shots", []) or [])
for idx, shot in enumerate(shots, 1):
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
text = (getattr(shot, "text", "") or "").strip()
if not text:
continue
vid = pick_voice_for_shot(shot, voice_map)
mp3 = work / f"{sid}.mp3"
if not (mp3.exists() and mp3.stat().st_size > 1000): # 已有则复用
synthesize_line(text, vid, str(mp3), api_key=api_key)
adur = probe_audio_duration(str(mp3))
if adur <= 0:
notes.append(f"{sid}: 音频时长探测失败,保持原时长")
continue
old = float(getattr(shot, "duration", 5) or 5)
new = max(min_dur, min(max_dur, round(adur + pad, 2)))
shot.duration = new
if abs(new - old) > 0.01:
updated += 1
notes.append(f"{sid}: {old}s → {new}s(音频 {adur:.2f}s · {vid})")
out[sid] = {"audio": str(mp3), "audio_dur": round(adur, 2),
"old_dur": old, "new_dur": new, "voice_id": vid,
"has_cast": bool([s for s in (getattr(shot, "cast_slots", None) or []) if s])}
if progress_cb:
progress_cb("tts", sid, "done", f"{adur:.1f}s → 镜头 {new}s")
log.info(" 🎙 音频先行:%d/%d 个镜头时长按台词音频调整", updated, len(shots))
return {"updated": updated, "shots": out, "notes": notes, "voice_map": voice_map}
def build_voice_track(sb, out_path: str, work_dir: str,
api_key: str = "",
cast_voice_map: dict | None = None,
use_ambience: bool = True,
ambience_volume: float = DEFAULT_AMBIENCE_VOLUME,
progress_cb: ProgressCB = None,
reuse_dir: str = "") -> dict:
"""按分镜时间轴合成完整音轨
返回 {output, segments, voice_seconds, ambience_hits, voices:[...], notes:[...]}
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.config import load_config
cfg = load_config()
api_key = api_key or (cfg.get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise RuntimeError("未配置百炼 API Key(TTS 需要)")
voice_map = resolve_voice_map(sb, cast_voice_map)
amb_assets = load_ambience_assets() if use_ambience else []
sfx_assets = load_sfx_assets() # 音效库(非语言发声:惨叫/闷哼/关门…)
log.info("配音: 音色映射 %s | 环境音库 %d 条 | 音效库 %d 条",
voice_map, len(amb_assets), len(sfx_assets))
segs: list[str] = []
notes: list[str] = []
voice_seconds = 0.0
amb_hits = 0
sfx_hits = 0
# ★ 分轨(用户 2026-10-03 提出的声音模型):
# · 台词轨(dialogue):有出场人物的镜头 → 与画面强绑定(还要用于对口型)
# · 旁白轨(narration):无出场人物 → 独立一条声轨,允许与画面有轻微差异
dialogue_segs: list[str] = []
narration_segs: list[str] = []
shots = list(getattr(sb, "shots", []) or [])
for idx, shot in enumerate(shots, 1):
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
dur = float(getattr(shot, "duration", 3) or 3)
text = (getattr(shot, "text", "") or "").strip()
if progress_cb:
progress_cb("dub", sid, "running", f"{idx}/{len(shots)}")
voice_path = ""
if text:
vid = pick_voice_for_shot(shot, voice_map)
# ★ 优先复用「音频先行」已合成的音频(不重复 TTS = 不重复计费)
_pre = Path(reuse_dir) / f"{sid}.mp3" if reuse_dir else None
if _pre and _pre.exists() and _pre.stat().st_size > 1000:
raw = _pre
notes.append(f"{sid}: 复用先行音频")
else:
raw = work / f"tts_{idx:02d}.mp3"
synthesize_line(text, vid, str(raw), api_key=api_key)
try:
fitted = work / f"voice_{idx:02d}.wav"
info = fit_audio_to_duration(str(raw), str(fitted), dur)
voice_path = str(fitted)
voice_seconds += min(info["src_dur"], dur)
notes.append(f"{sid}: {vid} · {info['action']}({len(text)}字)")
except Exception as e:
log.warning(" ⚠️ %s 台词合成失败: %s", sid, e)
notes.append(f"{sid}: 台词合成失败({str(e)[:40]})")
voice_path = ""
# 环境音(该镜头的 audio_cue)
amb_path = ""
if amb_assets:
_a = getattr(shot, "audio", None)
cue = ""
if _a is not None:
cue = (_a.get("audio_cue", "") if isinstance(_a, dict)
else getattr(_a, "audio_cue", "")) or ""
cue = str(cue).strip()
if cue:
amb_path = match_ambience(cue, amb_assets)
if amb_path:
amb_hits += 1
notes.append(f"{sid}: 环境音「{cue}」匹配 ✓")
# 音效(非语言发声):按 desc 匹配素材,按 at_second 插入该镜头内
sfx_items: list = []
if sfx_assets:
_a = getattr(shot, "audio", None)
raw_sfx = []
if _a is not None:
raw_sfx = ((_a.get("sfx", []) if isinstance(_a, dict)
else getattr(_a, "sfx", [])) or [])
if isinstance(raw_sfx, (str, dict)):
raw_sfx = [raw_sfx]
for s in raw_sfx:
if isinstance(s, dict):
desc = str(s.get("desc") or s.get("name") or "").strip()
at = float(s.get("at_second") or 0)
vol = float(s.get("volume") or DEFAULT_SFX_VOLUME)
else:
desc, at, vol = str(s or "").strip(), 0.0, DEFAULT_SFX_VOLUME
if not desc:
continue
f = match_sfx(desc, sfx_assets)
if f:
sfx_items.append((f, at, vol))
sfx_hits += 1
notes.append(f"{sid}: 音效「{desc}」@{at}s ✓")
else:
notes.append(f"{sid}: 音效「{desc}」未匹配到素材(音效库为空?)")
# ★ 分轨:判断这一段是「角色台词」还是「旁白」
# 台词(有出场人物)→ 主轨;旁白(无出场人物)→ 单独轨(不重复放进主轨)
_slots = [x for x in (getattr(shot, "cast_slots", None) or []) if x]
is_narration = bool(text) and not _slots
voice_for_main = "" if is_narration else voice_path
seg = work / f"seg_{idx:02d}.wav"
mix_segment(voice_for_main, amb_path, dur, str(seg),
ambience_volume=ambience_volume, sfx_items=sfx_items)
segs.append(str(seg))
# 旁白轨:该镜头是旁白 → 只放人声;否则留空(拼接时补等长静音)
if is_narration:
narr_seg = work / f"narr_{idx:02d}.wav"
try:
mix_segment(voice_path, "", dur, str(narr_seg))
narration_segs.append(str(narr_seg))
notes.append(f"{sid}: 旁白(单独轨)")
except Exception as e:
log.warning(" ⚠️ %s 旁白轨生成失败: %s", sid, e)
narration_segs.append("")
else:
narration_segs.append("")
if progress_cb:
progress_cb("dub", sid, "done",
"旁白" if is_narration else ("角色台词" if text else "无台词"))
concat_audio(segs, out_path)
# ★ 旁白轨(独立输出):无旁白的镜头补等长静音,保证与主轨时间轴对齐
narr_out = ""
if any(narration_segs):
narr_parts = []
for i, (n, shot) in enumerate(zip(narration_segs, shots), 1):
if n:
narr_parts.append(n)
else:
dur_i = float(getattr(shot, "duration", 3) or 3)
sil = work / f"narr_sil_{i:02d}.wav"
try:
if not sil.exists():
make_silence(dur_i, str(sil))
narr_parts.append(str(sil))
except Exception:
pass
if narr_parts:
narr_out = str(Path(out_path).with_name("narration_track.wav"))
try:
concat_audio(narr_parts, narr_out)
log.info(" 🎙 旁白轨单独输出: %s(%d 段)",
Path(narr_out).name, len([x for x in narration_segs if x]))
except Exception as e:
log.warning(" ⚠️ 旁白轨拼接失败: %s", e)
narr_out = ""
total = sum(float(getattr(s, "duration", 3) or 3) for s in shots)
log.info(" 🎙 配音完成: %s(%d 段 / %.1fs 音轨)", Path(out_path).name, len(segs), total)
return {"output": out_path, "narration": narr_out, "segments": len(segs),
"voice_seconds": round(voice_seconds, 2), "ambience_hits": amb_hits,
"sfx_hits": sfx_hits,
"narration_segments": len([x for x in narration_segs if x]),
"total_seconds": round(total, 2), "voices": voice_map, "notes": notes}
# ── CLI 自测:从已保存分镜生成音轨 ──
if __name__ == "__main__":
import argparse, json
ap = argparse.ArgumentParser(description="从分镜生成配音音轨(有 TTS 费用)")
ap.add_argument("--storyboard", required=True, help="分镜 JSON 路径或 project_id")
ap.add_argument("--output", default="", help="输出 wav 路径")
ap.add_argument("--no-ambience", action="store_true", help="不使用环境音")
args = ap.parse_args()
p = Path(args.storyboard)
if not p.exists():
p = Path.home() / "workbuddy-agent-os/agent-local/runtime/ave/storyboards" / f"{args.storyboard}.json"
data = json.loads(p.read_text(encoding="utf-8"))
from schemas import Storyboard
sb = Storyboard.from_dict(data)
out = args.output or str(Path.home() / "workbuddy-agent-os/agent-local/runtime/ave/dub_test.wav")
r = build_voice_track(sb, out, str(Path(out).parent / "dub_work"),
use_ambience=not args.no_ambience)
print(json.dumps({k: v for k, v in r.items() if k != "notes"}, ensure_ascii=False, indent=1))
for n in r["notes"]:
print(" ·", n)
@@ -0,0 +1,699 @@
"""
AVE 06_composer — FFmpeg 合成模块
职责:
1. 音频混音: 人声 + BGM → 混合音频
2. 视频合成: 素材片段拼接 + 画面过渡
3. 字幕叠加: ASS 格式中文字幕(带重音高亮)
4. 最终编码: 输出 final.mp4
5. 分段渲染: 3分钟以上视频自动分段
依赖:
- ffmpeg (需安装)
- Python: ffmpeg-python
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import asyncio
import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("composer")
# ── 常量 ──────────────────────────────────────────────────
DEFAULT_RESOLUTION = "1080x1920" # 竖屏
DEFAULT_FPS = 30
DEFAULT_BITRATE = "8M"
SEGMENT_DURATION_MAX = 180 # 3分钟分段
# ── 公共 API ──────────────────────────────────────────────
def mix_audio(
voice_path: str,
bgm_path: Optional[str] = None,
output_path: str = "mixed_audio.wav",
voice_volume: float = 1.0,
bgm_volume: float = 0.3,
) -> str:
"""
混音: 人声 + BGM → 混合音频
- voice_path: 人声 WAV 路径
- bgm_path: BGM 路径 (None 则只输出人声)
- voice_volume: 人声音量倍率
- bgm_volume: BGM 音量倍率
- 返回: 混合音频路径
"""
if not os.path.exists(voice_path):
raise FileNotFoundError(f"人声文件不存在: {voice_path}")
if bgm_path and not os.path.exists(bgm_path):
logger.warning(f"BGM 文件不存在,跳过背景音乐: {bgm_path}")
bgm_path = None
if not bgm_path:
# 无人声时,直接复制人声
subprocess.run(
[FFMPEG, "-y", "-i", voice_path, "-acodec", "pcm_s16le", output_path],
capture_output=True, check=True
)
logger.info(f"音频已输出(无BGM): {output_path}")
return output_path
# 双轨混音
cmd = [
FFMPEG, "-y",
"-i", voice_path,
"-i", bgm_path,
"-filter_complex",
f"[0:a]volume={voice_volume}[v];[1:a]volume={bgm_volume}[b];[v][b]amix=inputs=2:duration=first[a]",
"-map", "[a]",
"-acodec", "pcm_s16le",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
logger.info(f"混音完成: {output_path}")
return output_path
def duck_bgm(
voice_path: str,
bgm_path: str,
output_path: str = "ducked_bgm.wav",
low_volume: float = 0.15,
high_volume: float = 0.50,
fade_ms: int = 300,
word_timestamps: list | None = None,
) -> str:
"""
BGM 音量避让 (Audio Ducking), 基于字级时间戳
说话时 BGM 压低 (low_volume), 间隙时恢复 (high_volume)。
使用 word_timestamps 精确控制每个字的避让区间,
比 silencedetect 更准确 (CosyVoice 连续语音无停顿).
参数:
word_timestamps: [{begin_time, end_time}, ...] 单位毫秒
来自 synthesize_with_timestamps()
"""
if not os.path.exists(voice_path):
raise FileNotFoundError(f"人声文件不存在: {voice_path}")
if not os.path.exists(bgm_path):
raise FileNotFoundError(f"BGM 文件不存在: {bgm_path}")
# 如果没有时间戳, 回退到固定音量
if not word_timestamps:
logger.info(" 无时间戳, 使用固定音量 0.25")
subprocess.run([
FFMPEG, "-y", "-i", bgm_path,
"-af", f"volume={high_volume}",
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=30)
return output_path
duration = _get_media_duration(voice_path)
total_ms = duration * 1000
fade_ms_f = fade_ms / 1000.0
logger.info(f"BGM 避让: {low_volume}→{high_volume}, 基于 {len(word_timestamps)} 个字")
# 构建音量区间: [(start_sec, end_sec, volume), ...]
# 每个字的区间: 字开始前 fade_ms 开始压低, 字结束后 fade_ms 恢复
segments = []
# 先确定所有活跃区间 (字的时间范围 + 过渡)
active_zones = [] # [(start, end), ...] 单位为秒
for w in word_timestamps:
w_start = max(0, (w["begin_time"] - 50) / 1000.0) # 字前 50ms
w_end = min(duration, (w["end_time"] + 50) / 1000.0) # 字后 50ms
active_zones.append((w_start, w_end))
# 合并重叠的活跃区间
active_zones.sort()
merged = []
for start, end in active_zones:
if merged and start <= merged[-1][1] + 0.05: # 50ms 内视为连续
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
# 构建音量分段: 活跃期低音量, 间隙期高音量
prev_end = 0.0
for a_start, a_end in merged:
# 间隙: prev_end → a_start (高音量)
if a_start > prev_end + 0.05:
gap_mid = (prev_end + a_start) / 2
segments.append((prev_end, gap_mid, high_volume))
segments.append((gap_mid, a_start, high_volume))
# 说话: a_start → a_end (低音量, 带过渡)
segments.append((a_start, a_end, low_volume))
prev_end = a_end
# 最后间隙
if duration > prev_end + 0.05:
segments.append((prev_end, duration, high_volume))
if not segments:
logger.info(" 无分段, 使用固定音量")
subprocess.run([
FFMPEG, "-y", "-i", bgm_path,
"-af", f"volume={high_volume}",
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=30)
return output_path
# 渲染每段再拼接
import tempfile, shutil
seg_dir = tempfile.mkdtemp(prefix="ave_duck_")
seg_wavs = []
for idx, (st, en, vol) in enumerate(segments):
dur = en - st
if dur < 0.05:
continue
seg_out = os.path.join(seg_dir, f"seg_{idx:03d}.wav")
fade_dur = min(fade_ms_f, dur / 3)
subprocess.run([
FFMPEG, "-y",
"-ss", str(st), "-i", bgm_path,
"-t", str(dur),
"-af", f"volume={vol},afade=t=in:d={fade_dur},afade=t=out:st={dur-fade_dur}:d={fade_dur}",
"-acodec", "pcm_s16le", seg_out,
], capture_output=True, timeout=60, check=True)
seg_wavs.append(seg_out)
# 拼接
concat_file = _create_concat_list(seg_wavs)
if len(seg_wavs) == 1:
subprocess.run([FFMPEG, "-y", "-i", seg_wavs[0], "-acodec", "pcm_s16le", output_path],
capture_output=True, check=True)
else:
subprocess.run([
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=60)
shutil.rmtree(seg_dir, ignore_errors=True)
logger.info(f"BGM 避让完成: {output_path} ({len(seg_wavs)} 段)")
return output_path
def image_to_clip(
input_path: str,
output_path: str,
duration: float = 5.0,
resolution: str = DEFAULT_RESOLUTION,
ken_burns: bool = True,
) -> str:
"""
将静态图片转换为视频分镜(带 Ken Burns 动效)
处理横图→竖屏转换(如 1248×832→1080×1920),用于 AVE 流水线中
图片生成阶段→合成阶段之间的转换步骤。
策略:先按目标高度等比缩放填满画面,然后 crop 到目标宽度,
再添加 Ken Burns 式平滑平移动效。
"""
target_w, target_h = [int(x) for x in resolution.split("x")]
if ken_burns:
# 使用 cos 实现平滑左右平移: 从右到左的平滑摆动
filter_str = (
f"scale='max({target_w},iw*{target_h}/ih)':{target_h}:flags=lanczos,"
f"crop={target_w}:{target_h}:"
f"'(iw-{target_w})*(0.5-0.45*cos(PI*t/{duration}))':'0',"
f"setsar=1,fps={DEFAULT_FPS}"
)
else:
# 无动效:居中裁剪
filter_str = (
f"scale='max({target_w},iw*{target_h}/ih)':{target_h}:flags=lanczos,"
f"crop={target_w}:{target_h}:'(iw-{target_w})/2':'(ih-{target_h})/2',"
f"setsar=1,fps={DEFAULT_FPS}"
)
cmd = [
FFMPEG, "-y",
"-loop", "1", "-t", str(duration),
"-i", input_path,
"-vf", filter_str,
"-c:v", "libx264", "-preset", "medium", "-crf", "23",
"-pix_fmt", "yuv420p", "-an",
output_path,
]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
logger.info(f"图片→视频分镜: {os.path.basename(input_path)} → {os.path.basename(output_path)} ({duration}s)")
return output_path
def _apply_ken_burns(input_path: str, output_path: str, resolution: str = DEFAULT_RESOLUTION) -> str:
"""
对视频素材施加随机 Ken Burns 效果(缩放+平移),让画面动起来。
策略:先获取原始尺寸,判断横竖屏,选择合适的缩放+裁切方案。
竖屏素材(720x1280等):放大后以宽度为基准裁切到1080。
"""
import random
duration = _get_media_duration(input_path)
if duration < 1.0:
duration = 5.0
target_w, target_h = [int(x) for x in resolution.split("x")]
# 获取原视频尺寸
try:
from lib.ffmpeg import probe_info
_info = probe_info(input_path)
src_w, src_h = _info.get("width") or target_w, _info.get("height") or target_h
except Exception:
src_w, src_h = target_w, target_h
# 判断方向
is_portrait = src_h > src_w # 竖屏
if is_portrait and src_w >= target_w:
# 竖屏且宽度够 → 只用缩放覆盖高度,平移只在竖直方向
zoom = random.uniform(1.10, 1.25)
d = f"max(t,0.001)/{max(duration,1)}"
trajectory = random.choice(["center_zoom", "pan_up", "pan_down"])
if trajectory == "center_zoom":
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})/2"
elif trajectory == "pan_up":
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})*(1-{d})"
else: # pan_down
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})*{d}"
kb_type = trajectory
else:
# 横屏或小尺寸 → 仅缩放填充,不做平移(避免负值)
zoom = max(target_w / max(src_w, 1), target_h / max(src_h, 1)) * 1.05
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})/2"
kb_type = "center"
filter_str = (
f"scale=iw*{zoom}:ih*{zoom}:flags=lanczos,"
f"crop={target_w}:{target_h}:{x}:{y},"
f"setsar=1,fps=30"
)
cmd = [
FFMPEG, "-y", "-i", input_path,
"-vf", filter_str,
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "25",
"-an", output_path,
]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
logger.debug(f"Ken Burns [{kb_type} @ {zoom:.2f}x] → {output_path}")
return output_path
def compose_video(
material_clips: list[str],
audio_path: str,
output_path: str = "final.mp4",
resolution: str = DEFAULT_RESOLUTION,
fps: int = DEFAULT_FPS,
transitions: list[str] | None = None,
subtitles_path: str | None = None,
total_duration: float | None = None,
ken_burns: bool = True,
) -> str:
"""
视频合成: 素材片段 + 音频 + 字幕 → final.mp4
- material_clips: 素材视频文件路径列表
- audio_path: 混音音频路径
- output_path: 输出文件路径
- transitions: 每段之间的过渡效果 (fade/cut/dissolve),长度=clips数-1
- subtitles_path: ASS/SRT 字幕文件路径 (可选)
- total_duration: 视频总时长(秒),用于裁剪素材匹配音频
- ken_burns: 是否对每个素材施加随机缩放/平移动效(默认开启)
**素材时长自动匹配策略**:
如果所有素材总时长 < 音频时长 → 末尾慢放/循环最后一段
如果素材总时长 > 音频时长 → 裁剪素材到恰好匹配
"""
if not material_clips:
raise ValueError("素材列表为空")
if not os.path.exists(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
# 1. 可选择对素材施加 Ken Burns 动效(逐素材预处理)
final_clips = material_clips
if ken_burns:
import tempfile, shutil
ken_dir = tempfile.mkdtemp(prefix="ave_kenburns_")
processed = []
for idx, clip in enumerate(material_clips):
out = os.path.join(ken_dir, f"kb_{idx:03d}.mp4")
_apply_ken_burns(clip, out, resolution)
processed.append(out)
final_clips = processed
logger.info(f"Ken Burns 已应用于 {len(final_clips)} 个素材")
# 2. 创建素材拼接文件列表
concat_file = _create_concat_list(final_clips)
# 3. 获取音频总时长
audio_duration = _get_media_duration(audio_path)
# 4. 构建 FFmpeg 命令
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-i", audio_path,
]
filters = []
# 5. 如果素材总时长 > 音频时长,裁剪
materials_duration = sum(_get_media_duration(c) for c in final_clips)
output_duration = audio_duration
# 6. 视频流处理(Ken Burns 已预处理,这里只做字幕叠加)
filters.append(f"[0:v]setpts=PTS-STARTPTS,fps={fps}[v]")
filters.append("[1:a]volume=1.0[a]")
# 7. 字幕叠加
if subtitles_path and os.path.exists(subtitles_path):
filters.append(f"[v]subtitles={subtitles_path}[vo]")
map_v = "[vo]"
else:
map_v = "[v]"
filter_complex = ";".join(filters)
cmd.extend(["-filter_complex", filter_complex])
cmd.extend(["-map", map_v, "-map", "[a]"])
# 8. 时长匹配
logger.info(f" 素材总时长: {materials_duration:.1f}s, 音频时长: {audio_duration:.1f}s")
if materials_duration < audio_duration:
logger.warning(f"素材不足,尾部会黑屏补齐")
cmd.extend(["-shortest"])
# 9. 编码参数
cmd.extend([
"-c:v", "libx264",
"-preset", "medium",
"-b:v", DEFAULT_BITRATE,
"-c:a", "aac",
"-b:a", "192k",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
output_path,
])
try:
subprocess.run(cmd, capture_output=True, timeout=600)
logger.info(f"视频合成完成: {output_path}")
except subprocess.TimeoutExpired:
logger.error("渲染超时(>600s),建议分段渲染")
raise
except subprocess.CalledProcessError as e:
logger.error(f"FFmpeg 执行失败: {e.stderr.decode(errors='replace')[:500]}")
raise
# 清理临时文件
if ken_burns:
shutil.rmtree(ken_dir, ignore_errors=True)
return output_path
def create_subtitles(
segments: list[dict],
output_path: str = "subtitles.ass",
resolution: tuple = (1080, 1920),
) -> str:
"""
从分段信息生成 ASS 格式字幕
segments 格式:
[{"text": "...", "start_sec": 0.0, "end_sec": 8.0, "emotion": "..."}]
"""
width, height = resolution
# 字幕位置: 由下往上 20% (竖屏 1080x1920)
margin_v = int(height * 0.2)
font_size = max(48, int(height * 0.044)) # 1920 → ~84px
lines = [
"[Script Info]",
"ScriptType: v4.00+",
f"PlayResX: {width}",
f"PlayResY: {height}",
"ScaledBorderAndShadow: yes",
"",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
f"Style: Default,Noto Sans SC,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,100,100,{margin_v},1",
"",
"[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
]
for seg in segments:
start = _sec_to_ass_time(seg.get("start_sec", 0))
end = _sec_to_ass_time(seg.get("end_sec", 8))
text = seg.get("text", "").replace("\n", "\\N")
lines.append(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}")
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
logger.info(f"字幕生成: {output_path} ({len(segments)} 段)")
return output_path
def segment_render(
material_clips: list[str],
audio_path: str,
output_dir: str = ".",
segment_duration: int = SEGMENT_DURATION_MAX,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
) -> list[str]:
"""
分段渲染: 长视频自动分段 → 分别渲染 → 返回分段文件列表
后续由 FFmpeg concat 拼接
"""
audio_duration = _get_media_duration(audio_path)
if audio_duration <= segment_duration:
# 无需分段
out = os.path.join(output_dir, "segment_000.mp4")
compose_video(material_clips, audio_path, out,
subtitles_path=subtitles_path,
resolution=resolution)
return [out]
# 需要分段
segments = []
for i in range(0, int(audio_duration), segment_duration):
start = i
end = min(i + segment_duration, audio_duration)
out = os.path.join(output_dir, f"segment_{i:03d}.mp4")
_render_segment(material_clips, audio_path, start, end, out,
subtitles_path=subtitles_path,
resolution=resolution)
segments.append(out)
logger.info(f"分段渲染完成: {len(segments)} 段")
return segments
def compose_with_anchors(
material_clips: list[str],
audio_path: str,
output_path: str,
silence_periods: list[dict],
total_duration: float,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
) -> str:
"""
锚点驱动的画面切换合成
在每段静音处切换素材片段,添加交叉淡变过渡。
素材按顺序循环使用,避免单一素材贯穿全片。
参数:
material_clips: 素材视频列表
audio_path: 混合音频路径
silence_periods: [{"start": s, "end": e, "duration": d}, ...]
total_duration: 音频总时长(秒)
"""
num_clips = len(material_clips)
if num_clips == 0:
raise ValueError("素材列表为空")
if not silence_periods:
logger.info("无锚点,回落普通合成")
return compose_video(material_clips, audio_path, output_path,
subtitles_path=subtitles_path, resolution=resolution)
import tempfile
# 1. 在静音中点附近切割素材段落
transitions = [0.0] # 每个切换点的时间(秒)
for s in silence_periods:
mid = (s["start"] + s["end"]) / 2
if mid > transitions[-1] + 0.5: # 至少间隔0.5s
transitions.append(mid)
transitions.append(total_duration)
# 2. 为每段分配素材(循环使用)
seg_info = [] # [(clip_path, start, end), ...]
clip_idx = 0
for i in range(len(transitions) - 1):
seg_start = transitions[i]
seg_end = transitions[i + 1]
dur = seg_end - seg_start
if dur < 0.3:
continue # 跳过过短段落
seg_info.append((material_clips[clip_idx % num_clips], seg_start, seg_end))
clip_idx += 1
logger.info(f"锚点分段: {len(seg_info)} 段, 使用 {min(clip_idx, num_clips)}/{num_clips} 个素材")
# 3. 渲染每段为独立视频
temp_dir = tempfile.mkdtemp(prefix="ave_anchor_")
seg_files = []
for idx, (clip, seg_s, seg_e) in enumerate(seg_info):
out = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
dur = seg_e - seg_s
# 从该素材中截取一段 (用素材的一部分,避免只用开头)
clip_dur = _get_media_duration(clip)
offset_in_clip = (seg_s * 15) % max(clip_dur, 1) # 取素材中不同位置
offset_in_clip = min(offset_in_clip, max(clip_dur - dur, 0))
# 用 FFmpeg 截取该素材的一段
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset_in_clip),
"-i", clip,
"-t", str(dur),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps=30",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
out,
], capture_output=True, timeout=120, check=True)
seg_files.append(out)
# 4. 简单 concat 拼接 (含交叉淡变的 xfade 链 >10段容易 FFmpeg 崩溃)
# 用 concat demuxer 直接拼接,素材切换本身已提供视觉变化
concat_list = _create_concat_list(seg_files)
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_list,
"-i", audio_path,
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=1".format(max(0, total_duration - 1)),
]
if subtitles_path and os.path.exists(subtitles_path):
cmd += ["-vf", f"subtitles={subtitles_path}"]
cmd += ["-shortest", "-c:v", "libx264", "-preset", "medium", "-b:v", DEFAULT_BITRATE,
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", output_path]
subprocess.run(cmd, capture_output=True, check=True, timeout=600)
# 清理
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info(f"锚点驱动合成完成: {output_path}")
return output_path
def concat_segments(segment_files: list[str], output_path: str = "final.mp4") -> str:
"""拼接分段视频"""
concat_file = _create_concat_list(segment_files)
subprocess.run([
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-c", "copy",
output_path,
], capture_output=True, check=True)
logger.info(f"分段拼接完成: {output_path}")
return output_path
# ── 内部工具 ──────────────────────────────────────────────
def _create_concat_list(file_paths: list[str]) -> str:
"""创建 FFmpeg concat 文件列表"""
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
)
for fp in file_paths:
abs_path = os.path.abspath(fp)
tmp.write(f"file '{abs_path}'\n")
tmp.close()
return tmp.name
def _get_media_duration(path: str) -> float:
"""获取媒体文件时长(秒) —— 用 lib.ffmpeg(本机 ffprobe 实为 ffmpeg 符号链接)"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
if d > 0:
return d
except Exception:
pass
logger.warning(f"无法获取时长,默认 30s: {path}")
return 30.0
def _sec_to_ass_time(seconds: float) -> str:
"""秒 → ASS 时间格式 H:MM:SS.cc"""
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
cs = int((seconds - int(seconds)) * 100)
return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
def _render_segment(
material_clips: list[str],
audio_path: str,
start_sec: float,
end_sec: float,
output_path: str,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
):
"""渲染单个分段(裁剪音频+对齐素材)"""
duration = end_sec - start_sec
# 用 concat 合并所有素材作为视频源
concat_file = _create_concat_list(material_clips)
filters = [
f"[0:v]setpts=PTS-STARTPTS,scale={resolution.replace('x',':')},fps=30[v]",
f"[1:a]atrim=start={start_sec}:duration={duration},asetpts=PTS-STARTPTS[a]",
]
if subtitles_path and os.path.exists(subtitles_path):
filters.append(f"[v]subtitles={subtitles_path}[vo]")
map_v = "[vo]"
else:
map_v = "[v]"
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-i", audio_path,
"-filter_complex", ";".join(filters),
"-map", map_v, "-map", "[a]",
"-shortest",
"-c:v", "libx264", "-preset", "medium", "-b:v", DEFAULT_BITRATE,
"-c:a", "aac", "-b:a", "192k",
"-pix_fmt", "yuv420p",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=600)
@@ -0,0 +1,62 @@
"""
composer/frames.py — 视频抽帧工具(首尾帧衔接用)
用途:`chain_from_prev` 的镜头需要「上一镜的尾帧」作为生成参考,
这里提供 ffmpeg 抽帧(零成本、本地处理)。
"""
import subprocess
import sys
from pathlib import Path
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
if str(_SCRIPT_ROOT) not in sys.path:
sys.path.insert(0, str(_SCRIPT_ROOT))
from lib.logger import get_logger
log = get_logger("composer.frames")
def _ffmpeg() -> str:
try:
from lib.ffmpeg import FFMPEG
return FFMPEG
except Exception:
return "ffmpeg"
def extract_tail_frame(video_path: str, out_path: str, offset: float = 0.1) -> str:
"""抽取视频**最后一帧**(默认取结束前 0.1 秒处,避开黑帧/抖动)
用于首尾帧衔接:本镜的起手画面 = 上一镜的收尾画面 → 视觉连续。
"""
src = Path(video_path)
if not src.exists():
raise FileNotFoundError(f"视频不存在: {video_path}")
dst = Path(out_path)
dst.parent.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
[_ffmpeg(), "-y", "-sseof", f"-{offset}", "-i", str(src),
"-frames:v", "1", "-q:v", "2", str(dst)],
capture_output=True, text=True, timeout=90)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 500:
raise RuntimeError(f"抽尾帧失败: {r.stderr.strip()[-160:]}")
log.debug(" 抽尾帧: %s → %s", src.name, dst.name)
return str(dst)
def extract_head_frame(video_path: str, out_path: str, offset: float = 0.0) -> str:
"""抽取视频**第一帧**(用于把"上一镜的收尾"作为本镜的起手参考)"""
src = Path(video_path)
if not src.exists():
raise FileNotFoundError(f"视频不存在: {video_path}")
dst = Path(out_path)
dst.parent.mkdir(parents=True, exist_ok=True)
cmd = [_ffmpeg(), "-y"]
if offset:
cmd += ["-ss", str(offset)]
cmd += ["-i", str(src), "-frames:v", "1", "-q:v", "2", str(dst)]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 500:
raise RuntimeError(f"抽首帧失败: {r.stderr.strip()[-160:]}")
return str(dst)
@@ -0,0 +1,457 @@
"""
AVE hybrid — 口播+卡点融合管线
功能:
将口播的语音锚点驱动画面切换 + 卡点的BGM能量驱动变速结合起来。
人声决定"什么时候切画面", BGM决定"画面怎么动"。
流程:
1. 检测人声锚点 (静音段 → 段边界)
2. 检测 BGM 节拍 + 能量特征
3. 混音 (人声+BGM, BGM 音量避让)
4. 为每段分配素材 + 能量驱动变速
5. 渲染每段 + 字幕叠加
6. 帧锁定拼接
用法:
from composer.hybrid import compose_hybrid
compose_hybrid(
voice_path="narration.wav",
bgm_path="bgm.wav",
output_path="hybrid.mp4",
texts=["第一段文案", "第二段文案", ...],
)
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("hybrid")
def compose_hybrid(
voice_path: str,
bgm_path: str,
output_path: str = "hybrid.mp4",
material_clips: list[str] | None = None,
resolution: str = "1080x1920",
texts: list[str] | None = None,
pexels_api_key: str = "",
pexels_search: str = "",
# 口播锚点参数
min_silence_sec: float = 0.15,
voice_volume: float = 1.0,
bgm_volume: float = 0.35,
# 卡点速度参数
enable_speed_ramp: bool = True,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
# 节拍分组
group_size: int = 4,
) -> str:
"""
口播+卡点融合管线
参数:
voice_path: 人声 WAV 路径
bgm_path: BGM 音频路径
output_path: 输出视频路径
material_clips: 素材视频/图片列表 (可选)
resolution: 输出分辨率
texts: 每段文字 (用于字幕, 可选)
pexels_api_key: Pexels API Key (素材不够时补充)
pexels_search: Pexels 搜索关键词
min_silence_sec: 最小静音时长 (秒), 用于分割段
voice_volume: 人声音量 (默认 1.0)
bgm_volume: BGM 音量 (默认 0.35)
enable_speed_ramp: 是否启用能量驱动变速
base_speed: 基础速度
high_speed: 高能段速度
low_speed: 低能段速度
group_size: BGM 节拍分组数
返回: output_path
"""
logger.info("=== 口播+卡点融合模式 ===")
logger.info(f"人声: {voice_path}")
logger.info(f"BGM: {bgm_path}")
# ── 1. 检测人声锚点 ──
from anchor_extractor.extractor import get_silence_periods
silences = get_silence_periods(voice_path, min_silence_sec=min_silence_sec)
# 获取人声总时长
voice_duration = _get_duration(voice_path)
# 从静音段反推活跃段 (说话段)
voice_segments = _silences_to_segments(silences, voice_duration)
logger.info(f" 锚点检测: {len(silences)} 个静音 → {len(voice_segments)} 个说话段")
# ── 2. 检测 BGM 节拍 (含能量) ──
from composer.beat_sync import detect_beats
beat_info = detect_beats(bgm_path, group_size=group_size, enable_energy=enable_speed_ramp)
bgm_segments = beat_info["segments"]
logger.info(f" BPM: {beat_info['bpm']:.0f}, {beat_info['beat_count']}拍, {len(bgm_segments)}组")
# ── 3. 混音 (人声+BGM, 避让) ──
temp_dir = tempfile.mkdtemp(prefix="ave_hybrid_")
mixed_audio = os.path.join(temp_dir, "mixed.wav")
# BGM 避让: 说话时压低BGM
mixed_audio = _duck_and_mix(
voice_path, bgm_path, voice_segments, mixed_audio,
voice_volume=voice_volume, bgm_volume=bgm_volume,
)
# ── 4. 准备素材 ──
clips = list(material_clips) if material_clips else []
if len(clips) < len(voice_segments) and pexels_api_key:
from material_producer.pexels.search import search_videos
needed = len(voice_segments) - len(clips)
logger.info(f" 素材不足, 从 Pexels 搜索 {needed} 个...")
kw = pexels_search or "background video"
found = search_videos(kw, count=needed, api_key=pexels_api_key, orientation="portrait")
for f in found:
clips.append(f["path"])
logger.info(f" 共 {len(clips)} 个素材")
if not clips:
raise ValueError("无可用素材")
# ── 5. 为每段分配素材 + 计算速度 ──
# 将 BGM 节拍能量映射到每个说话段上
fps = 30
seg_plans = [] # [(clip_path, adjusted_dur, speed), ...]
for idx, vseg in enumerate(voice_segments):
clip = clips[idx % len(clips)]
v_start = vseg["start"]
v_end = vseg["end"]
v_dur = v_end - v_start
if v_dur < 0.5:
continue
# 在该时间段内计算 BGM 平均能量
avg_energy = _average_energy_in_range(bgm_segments, v_start, v_end)
speed = base_speed
if enable_speed_ramp:
if avg_energy > 0.6:
speed = base_speed + (high_speed - base_speed) * min((avg_energy - 0.6) / 0.4, 1.0)
elif avg_energy < 0.3:
speed = low_speed + (base_speed - low_speed) * (avg_energy / 0.3)
speed = max(0.5, min(2.0, speed))
# 帧锁定时长
frames = round(v_dur * fps)
dur_aligned = frames / fps
seg_plans.append({
"clip": clip,
"start": v_start,
"end": v_end,
"duration": dur_aligned,
"speed": speed,
"energy": avg_energy,
})
logger.info(f" 分配 {len(seg_plans)} 段素材"
+ (f", 变速范围 {low_speed:.1f}~{high_speed:.1f}x" if enable_speed_ramp else ""))
# ── 6. 渲染每段 ──
seg_files = []
for idx, plan in enumerate(seg_plans):
clip = plan["clip"]
dur = plan["duration"]
speed = plan["speed"]
mt = dur / max(speed, 0.01) # 素材需要的时间长度
seg_file = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
# 从素材中截取一段 (考虑变速)
clip_dur = _get_duration(clip)
offset = (plan["start"] * 17) % max(clip_dur - mt, 0.1) if clip_dur > mt else 0
raw_seg = os.path.join(temp_dir, f"seg_{idx:03d}_raw.mp4")
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset),
"-i", clip,
"-t", str(mt),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps={fps}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-an", raw_seg,
], capture_output=True, timeout=120, check=True)
if speed != 1.0 and 0.5 <= speed <= 2.0:
subprocess.run([
FFMPEG, "-y", "-i", raw_seg,
"-filter_complex", f"setpts={1/speed}*PTS[v]",
"-map", "[v]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p", seg_file,
], capture_output=True, timeout=120, check=True)
else:
os.replace(raw_seg, seg_file)
seg_files.append(seg_file)
if not seg_files:
raise RuntimeError("渲染失败: 无有效段")
# ── 7. 拼接 ──
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
else:
_concat_hybrid_segments(seg_files, mixed_audio, output_path, seg_plans, fps)
# ── 8. 字幕叠加 ──
if texts and len(texts) > 0 and seg_plans:
_add_text_subtitles(output_path, texts, seg_plans, resolution)
# ── 9. 清理 ──
shutil.rmtree(temp_dir, ignore_errors=True)
final_size = os.path.getsize(output_path) / 1024 / 1024
logger.info(f"✅ 融合完成: {output_path} ({final_size:.1f}MB, "
f"{len(seg_files)} 段{', 能量变速' if enable_speed_ramp else ''})")
return output_path
# ── 内部辅助函数 ──────────────────────────────────────────
def _silences_to_segments(silences: list[dict], total_duration: float) -> list[dict]:
"""从静音段反推人声活跃段"""
if not silences:
return [{"start": 0.0, "end": total_duration}]
segments = []
prev_end = 0.0
for s in silences:
s_start = s.get("start", 0)
s_end = s.get("end", 0)
if s_start > prev_end + 0.1:
segments.append({"start": prev_end, "end": s_start})
prev_end = s_end
if prev_end < total_duration - 0.3:
segments.append({"start": prev_end, "end": total_duration})
if not segments:
return [{"start": 0.0, "end": total_duration}]
return segments
def _average_energy_in_range(bgm_segments: list[dict], start: float, end: float) -> float:
"""计算时间范围 [start, end] 内 BGM 的平均能量"""
energies = []
for seg in bgm_segments:
seg_start = seg["start"]
seg_end = seg["end"]
# 检查重叠
if seg_end < start or seg_start > end:
continue
energy = seg.get("energy", 0.5)
overlap = min(seg_end, end) - max(seg_start, start)
if overlap > 0:
energies.append((energy, overlap))
if not energies:
return 0.5
# 加权平均 (按重叠时长)
total_weight = sum(w for _, w in energies) or 1.0
avg = sum(e * w for e, w in energies) / total_weight
return max(0.0, min(1.0, avg))
def _duck_and_mix(
voice_path: str,
bgm_path: str,
voice_segments: list[dict],
output_path: str,
voice_volume: float = 1.0,
bgm_volume: float = 0.35,
) -> str:
"""
BGM 音量避让混音:
- 说话时: BGM 音量降到 bgm_volume * 0.4
- 间隙时: BGM 恢复到 bgm_volume
"""
import numpy as np
import soundfile as sf
# 加载音频
voice, sr_v = sf.read(voice_path)
bgm, sr_b = sf.read(bgm_path)
# 重采样 BGM 到相同 sr
if sr_b != sr_v:
import librosa
bgm = librosa.resample(bgm, orig_sr=sr_b, target_sr=sr_v)
# 补齐 BGM 到人声长度
if len(bgm) < len(voice):
repeats = int(np.ceil(len(voice) / len(bgm)))
bgm = np.tile(bgm, repeats)
bgm = bgm[:len(voice)]
# 创建音量包络: 说话段压低 BGM
bgm_envelope = np.ones(len(voice))
for seg in voice_segments:
s = int(seg["start"] * sr_v)
e = int(seg["end"] * sr_v)
bgm_envelope[s:e] = 0.4 # 说话时 BGM 降到 40%
# 平滑过渡 (0.05s fade)
fade_samples = int(0.05 * sr_v)
for i in range(1, len(bgm_envelope) - fade_samples * 2):
if bgm_envelope[i] != bgm_envelope[i - 1]:
# 在变化点做淡变
start = max(0, i - fade_samples)
end = min(len(bgm_envelope), i + fade_samples)
for j in range(start, end):
frac = (j - start) / max(end - start, 1)
bgm_envelope[j] = bgm_envelope[start] + frac * (bgm_envelope[end - 1] - bgm_envelope[start])
# 混音
voice_float = voice.astype(np.float32)
bgm_float = bgm.astype(np.float32)
# 归一化到 [-1, 1]
voice_float /= max(np.max(np.abs(voice_float)), 0.001)
bgm_float /= max(np.max(np.abs(bgm_float)), 0.001)
mixed = voice_float * voice_volume + bgm_float * bgm_volume * bgm_envelope
# 限制防止爆音
peak = np.max(np.abs(mixed))
if peak > 0.99:
mixed /= peak * 1.02
sf.write(output_path, mixed, sr_v)
logger.debug(f" 混音完成: {output_path} ({sr_v}Hz, {len(voice)/sr_v:.1f}s)")
return output_path
def _concat_hybrid_segments(
seg_files: list[str],
mixed_audio: str,
output_path: str,
seg_plans: list[dict],
fps: int = 30,
) -> str:
"""帧锁定拼接混合音频"""
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return output_path
# concat demuxer 拼接视频
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
video_only = output_path.replace(".mp4", "_novideo.mp4")
cmd = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", "-an", video_only]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
# 叠加混合音频
cmd2 = [FFMPEG, "-y", "-i", video_only, "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(concat_file)
if os.path.exists(video_only):
os.unlink(video_only)
logger.info(f" ✅ 拼接完成: {len(seg_files)} 段")
return output_path
def _add_text_subtitles(
video_path: str,
texts: list[str],
seg_plans: list[dict],
resolution: str,
):
"""为每段叠加文字字幕 (ASS 格式)"""
width, height = (int(x) for x in resolution.split("x"))
font_size = max(48, int(height * 0.05))
margin_v = int(height * 0.3)
lines = [
"[Script Info]", "ScriptType: v4.00+",
f"PlayResX: {width}", f"PlayResY: {height}",
"ScaledBorderAndShadow: yes", "",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
f"Style: Default,Noto Sans SC,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,100,100,{margin_v},1",
"", "[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
]
for i, text in enumerate(texts):
if i >= len(seg_plans):
break
start = seg_plans[i]["start"]
dur = seg_plans[i]["duration"]
end = start + dur
def _fmt_ts(sec: float) -> str:
h = int(sec // 3600)
m = int((sec % 3600) // 60)
s = sec % 60
return f"{h}:{m:02d}:{s:05.2f}"
lines.append(
f"Dialogue: 0,{_fmt_ts(start)},{_fmt_ts(end)},Default,,0,0,0,,{text}"
)
ass_path = video_path.replace(".mp4", "_hybrid_text.ass")
with open(ass_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
tmp = video_path.replace(".mp4", "_tmp.mp4")
subprocess.run([
FFMPEG, "-y", "-i", video_path,
"-vf", f"subtitles={ass_path}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "copy", "-pix_fmt", "yuv420p", tmp,
], capture_output=True, timeout=120, check=True)
os.replace(tmp, video_path)
os.remove(ass_path)
logger.info(f" ✅ 字幕叠加: {len(texts)} 段")
def _get_duration(path: str) -> float:
"""获取媒体文件时长(秒)
用 lib.ffmpeg.probe_duration(ffmpeg -i 解析)——
本机 ffprobe 实为 ffmpeg 的符号链接,不支持 -show_entries(会静默失败)
"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return d if d > 0 else 30.0
except Exception:
return 30.0
@@ -0,0 +1,508 @@
#!/usr/bin/env python3
"""
AVE lipsync — Kling LipSync 集成
功能:
Audio→Video: 输入视频 + 语音音频 → 输出口型对齐的视频
Text→Video: 输入视频 + 文本 → 输出口型对齐的视频 (TTS自动)
API:
fal.ai endpoint (Kling LipSync 托管在 fal.ai)
成本: $0.014/5s ≈ ¥0.10/秒 ≈ ¥0.50/5秒镜头
用法:
python lipsync.py --video clip.mp4 --audio voice.wav --output synced.mp4
python lipsync.py --video clip.mp4 --text "你好世界" --output synced.mp4
集成:
作为口播策略后处理钩子, 在 video_factory.py 中调用
"""
import sys
import os
import json
import hashlib
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.config import load_config
from lib.cost_tracker import get_tracker
from lib.logger import get_logger
logger = get_logger("lipsync")
# ── 缓存 ──
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/lipsync")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
# ── fal.ai 端点 ──
# Kling LipSync 通过 fal.ai 提供
FAL_BASE = "https://fal.run"
ENDPOINTS = {
"audio_to_video": f"{FAL_BASE}/fal-ai/kling-video/lipsync/audio-to-video",
"text_to_video": f"{FAL_BASE}/fal-ai/kling-video/lipsync/text-to-video",
}
# ═══════════════════════════════════════════════════════════
# 核心 API
# ═══════════════════════════════════════════════════════════
def lipsync_audio_to_video(
video_path: str,
audio_path: str,
output_path: str = "",
force: bool = False,
api_key: str = "",
) -> str:
"""
Audio→Video 唇形同步
参数:
video_path: 输入视频路径 (2-10s, 720p/1080p, ≤100MB)
audio_path: 输入音频路径 (.mp3/.wav/.m4a, ≤5MB)
output_path: 输出路径
force: 强制重新生成
api_key: fal.ai API Key
返回:
本地输出视频路径
"""
if not output_path:
joint = hashlib.md5(
(video_path + audio_path).encode()
).hexdigest()[:16]
output_path = str(CACHE_DIR / f"lipsync_{joint}.mp4")
# 缓存
if os.path.exists(output_path) and not force:
logger.info(f"缓存命中: {output_path}")
return output_path
cfg = load_config()
api_key = api_key or cfg.get("fal", {}).get("api_key", "")
if not api_key:
raise ValueError("缺少 fal.ai API Key (config fal.api_key)")
logger.info(f"LipSync Audio→Video:")
logger.info(f" video: {video_path}")
logger.info(f" audio: {audio_path}")
# 上传文件或传URL (fal.ai 支持直接传base64或文件URL)
# 方案: 使用 fal.ai 的 upload 端点
video_url = _upload_to_fal(video_path, api_key)
audio_url = _upload_to_fal(audio_path, api_key)
if not video_url or not audio_url:
raise RuntimeError("上传文件到 fal.ai 失败")
logger.info(f" video_url: {video_url[:60]}...")
logger.info(f" audio_url: {audio_url[:60]}...")
# 提交任务
result = _submit_and_poll(
endpoint=ENDPOINTS["audio_to_video"],
payload={
"video_url": video_url,
"audio_url": audio_url,
},
api_key=api_key,
timeout_sec=120,
)
# 下载结果
video_url_res = result.get("video", {}).get("url", "") or result.get("output", "")
if not video_url_res:
raise RuntimeError(f"fal.ai 返回无视频URL: {json.dumps(result, ensure_ascii=False)[:200]}")
logger.info(f" 下载结果中...")
_download(video_url_res, output_path)
# 费用
tracker = get_tracker()
try:
dur = _get_media_duration(output_path)
except Exception:
dur = 5 # 默认5秒
tracker.log("KlingLipSync", duration=dur, note=f"audio-to-video ¥0.014×{dur}s")
sz_mb = os.path.getsize(output_path) / 1024 / 1024
logger.info(f" ✅ LipSync完成: {output_path} ({sz_mb:.1f}MB, {dur:.1f}s)")
return output_path
def lipsync_text_to_video(
video_path: str,
text: str,
voice_id: str = "default",
output_path: str = "",
force: bool = False,
api_key: str = "",
) -> str:
"""
Text→Video 唇形同步 (自动 TTS + 口型对齐, ≤120字)
参数:
video_path: 输入视频路径
text: 口播文本 (≤120字)
voice_id: 音色ID
output_path: 输出路径
force: 强制重新生成
返回:
本地输出视频路径
"""
if len(text) > 120:
logger.warning(f"文本过长 ({len(text)}字), 截断至120字")
text = text[:120]
if not output_path:
joint = hashlib.md5(
(video_path + text).encode()
).hexdigest()[:16]
output_path = str(CACHE_DIR / f"lipsync_txt_{joint}.mp4")
if os.path.exists(output_path) and not force:
logger.info(f"缓存命中: {output_path}")
return output_path
cfg = load_config()
api_key = api_key or cfg.get("fal", {}).get("api_key", "")
if not api_key:
raise ValueError("缺少 fal.ai API Key")
logger.info(f"LipSync Text→Video: text={text[:40]}...")
video_url = _upload_to_fal(video_path, api_key)
if not video_url:
raise RuntimeError("上传视频到 fal.ai 失败")
result = _submit_and_poll(
endpoint=ENDPOINTS["text_to_video"],
payload={
"video_url": video_url,
"text": text,
"voice_id": voice_id,
},
api_key=api_key,
timeout_sec=180,
)
video_url_res = result.get("video", {}).get("url", "") or result.get("output", "")
if not video_url_res:
raise RuntimeError(f"fal.ai 返回无视频URL")
_download(video_url_res, output_path)
sz_mb = os.path.getsize(output_path) / 1024 / 1024
logger.info(f" ✅ LipSync Text→Video完成: {output_path} ({sz_mb:.1f}MB)")
return output_path
# ═══════════════════════════════════════════════════════════
# 内部工具
# ═══════════════════════════════════════════════════════════
def _upload_to_fal(file_path: str, api_key: str) -> str:
"""上传文件到 fal.ai, 返回可访问的 URL"""
import httpx
if not os.path.exists(file_path):
raise FileNotFoundError(f"文件不存在: {file_path}")
with open(file_path, "rb") as f:
resp = httpx.post(
f"{FAL_BASE}/fal-ai/upload",
headers={
"Authorization": f"Key {api_key}",
},
files={"file": f},
timeout=120,
)
resp.raise_for_status()
data = resp.json()
return data.get("url", "")
def _submit_and_poll(
endpoint: str,
payload: dict,
api_key: str,
timeout_sec: int = 120,
) -> dict:
"""提交 fal.ai 任务并轮询, 返回结果"""
import httpx
headers = {
"Authorization": f"Key {api_key}",
"Content-Type": "application/json",
}
# 提交
resp = httpx.post(
endpoint.replace("/run", ""), # 提交到非 /run 端点
headers=headers,
json=payload,
timeout=30,
)
# fal.ai 的 submit API
submit_url = endpoint
resp = httpx.post(submit_url, headers=headers, json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
# 检查是否直接返回结果(fal.ai 同步模式)
if data.get("status") == "completed" or data.get("video"):
return data
# 获取请求ID进行轮询
request_id = data.get("request_id", "")
if not request_id:
# 有的接口直接返回结果
if data.get("video", {}).get("url"):
return data
raise RuntimeError(f"fal.ai 无 request_id: {json.dumps(data, ensure_ascii=False)[:200]}")
status_url = f"{endpoint}/requests/{request_id}"
logger.info(f" 轮询: {request_id}")
start = time.time()
for i in range(60):
# 指数退避
if i < 5:
time.sleep(3)
elif i < 15:
time.sleep(5)
else:
time.sleep(10)
elapsed = time.time() - start
if elapsed > timeout_sec:
raise TimeoutError(f"fal.ai 超时 ({timeout_sec}s)")
try:
q = httpx.get(status_url, headers=headers, timeout=15)
q.raise_for_status()
status_data = q.json()
status = status_data.get("status", "")
if status == "COMPLETED":
return status_data
elif status in ("FAILED", "ERROR"):
raise RuntimeError(f"fal.ai 失败: {json.dumps(status_data, ensure_ascii=False)[:200]}")
logger.debug(f" [{i}] status={status}")
except httpx.HTTPError as e:
logger.debug(f" [{i}] 查询失败: {e}")
time.sleep(5)
raise TimeoutError("fal.ai 轮询超时")
def _download(url: str, path: str):
"""下载文件"""
import httpx
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with httpx.stream("GET", url, timeout=600) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_bytes(8192):
f.write(chunk)
def _get_media_duration(path: str) -> float:
"""获取媒体时长 (秒) —— 用 lib.ffmpeg(本机 ffprobe 实为 ffmpeg 符号链接)"""
from lib.ffmpeg import probe_duration
return probe_duration(path)
# ═══════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════
def cli(args: list[str] | None = None):
import argparse
parser = argparse.ArgumentParser(description="Kling LipSync 唇形同步")
sub = parser.add_subparsers(dest="mode", required=True)
# audio → video
p_av = sub.add_parser("audio-to-video", help="音频驱动唇形同步")
p_av.add_argument("--video", required=True, help="输入视频")
p_av.add_argument("--audio", required=True, help="输入音频")
p_av.add_argument("--output", default="", help="输出路径")
p_av.add_argument("--force", action="store_true", help="强制重新生成")
# text → video
p_tv = sub.add_parser("text-to-video", help="文本驱动唇形同步")
p_tv.add_argument("--video", required=True, help="输入视频")
p_tv.add_argument("--text", required=True, help="口播文本 (≤120字)")
p_tv.add_argument("--voice-id", default="default", help="音色ID")
p_tv.add_argument("--output", default="", help="输出路径")
p_tv.add_argument("--force", action="store_true", help="强制重新生成")
parsed = parser.parse_args(args)
if parsed.mode == "audio-to-video":
out = lipsync_audio_to_video(
video_path=parsed.video,
audio_path=parsed.audio,
output_path=parsed.output,
force=parsed.force,
)
print(f"\n✅ LipSync 完成: {out}")
elif parsed.mode == "text-to-video":
out = lipsync_text_to_video(
video_path=parsed.video,
text=parsed.text,
voice_id=parsed.voice_id,
output_path=parsed.output,
force=parsed.force,
)
print(f"\n✅ LipSync 完成: {out}")
if __name__ == "__main__":
cli()
# ════════════════════════════════════════════════════════════
# ★ 百炼 VideoRetalk(推荐实现,2026-10-03 实测探明)
# 文档:https://help.aliyun.com/zh/model-studio/videoretalk-api
# 优势:用现有百炼 key(无需 fal.ai)· ¥0.08/秒 · 1800 秒免费额度
# 上传复用 AVE 已有的两步式(getPolicy → OSS 直传 → oss://)
# ════════════════════════════════════════════════════════════
DASHSCOPE_BASE = "https://dashscope.aliyuncs.com"
VR_SUBMIT_URL = f"{DASHSCOPE_BASE}/api/v1/services/aigc/image2video/video-synthesis"
VR_TASK_URL = f"{DASHSCOPE_BASE}/api/v1/tasks/{{tid}}"
VIDEORETALK_MODEL = "videoretalk"
VR_PRICE_PER_SEC = 0.08 # 元/秒(官方后付费单价)
VR_FREE_QUOTA_SEC = 1800 # 官方免费额度(秒)
def vr_estimate_cost(duration_sec: float) -> float:
"""对口型成本估算(元)—— 实际优先消耗 1800 秒免费额度"""
return round(max(0.0, float(duration_sec)) * VR_PRICE_PER_SEC, 2)
def _vr_upload(path: str, api_key: str) -> str:
"""两步式上传 → oss:// URL(与 person_swap 同一套;oss:// 需配 Resolve header)"""
import httpx
p = Path(path)
r = httpx.get(f"{DASHSCOPE_BASE}/api/v1/uploads?action=getPolicy&model={VIDEORETALK_MODEL}",
headers={"Authorization": f"Bearer {api_key}"}, timeout=30)
if r.status_code != 200:
raise RuntimeError(f"获取上传凭证失败 HTTP {r.status_code}: {r.text[:200]}")
d = r.json().get("data") or {}
key = f"{d.get('upload_dir', '')}/{p.name}"
form = {
"OSSAccessKeyId": d.get("oss_access_key_id", ""),
"policy": d.get("policy", ""),
"signature": d.get("signature", ""),
"key": key,
"x-oss-object-acl": d.get("x_oss_object_acl", "private"),
"x-oss-forbid-overwrite": "true",
}
with open(p, "rb") as f:
r2 = httpx.post(d["upload_host"], data=form,
files={"file": (p.name, f, "application/octet-stream")}, timeout=300,
trust_env=False)
if r2.status_code not in (200, 201, 204):
raise RuntimeError(f"OSS 直传失败 HTTP {r2.status_code}: {r2.text[:200]}")
return f"oss://{key}"
def lipsync_videoretalk(video_path: str, audio_path: str, output_path: str,
api_key: str = "", ref_image_path: str = "",
video_extension: bool = False,
timeout: int = 1800, progress_cb=None) -> str:
"""人物视频 + 人声 → **口型匹配**的新视频(百炼 VideoRetalk)
· 成本 ¥0.08/秒(有 1800 秒免费额度)
· 视频:正面近景、2~120s、mp4/mov、15~60fps、边长 640~2048
· 音频:干净人声、2~120s(TTS 产物天然满足)
· ref_image_path:角色参考图(可选,提升人脸匹配;我们正好有)
返回输出视频本地路径。
"""
import httpx
api_key = api_key or (load_config().get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise ValueError("VideoRetalk 需要百炼 API Key")
_cb = progress_cb or (lambda p, n: None)
vdur = _get_media_duration(video_path)
if vdur and not (2.0 < vdur < 120.0):
raise ValueError(f"视频时长 {vdur:.1f}s 超出 VideoRetalk 要求(2~120 秒)")
_cb(5, "上传视频")
vurl = _vr_upload(video_path, api_key)
_cb(15, "上传音频")
aurl = _vr_upload(audio_path, api_key)
rurl = ""
if ref_image_path and Path(ref_image_path).exists():
try:
_cb(20, "上传人脸参考图")
rurl = _vr_upload(ref_image_path, api_key)
except Exception as e:
logger.warning("参考图上传失败(继续):%s", e)
inp = {"video_url": vurl, "audio_url": aurl}
if rurl:
inp["ref_image_url"] = rurl
payload = {
"model": VIDEORETALK_MODEL,
"input": inp,
"parameters": {"video_extension": bool(video_extension)},
}
hdr = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
"X-DashScope-OssResourceResolve": "enable", # ★ 用 oss:// 时必须
}
_cb(25, "提交对口型任务")
r = httpx.post(VR_SUBMIT_URL, headers=hdr, json=payload, timeout=60, trust_env=False)
if r.status_code != 200:
raise RuntimeError(f"提交失败 HTTP {r.status_code}: {r.text[:300]}")
tid = (r.json().get("output") or {}).get("task_id", "")
if not tid:
raise RuntimeError(f"提交返回无 task_id: {r.text[:300]}")
logger.info(" VideoRetalk 任务已提交: %s(预估 ¥%.2f)", tid, vr_estimate_cost(vdur))
# 轮询
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
rr = httpx.get(VR_TASK_URL.format(tid=tid),
headers={"Authorization": f"Bearer {api_key}"}, timeout=30, trust_env=False)
out = (rr.json() or {}).get("output") or {}
st = out.get("task_status", "")
if st == "SUCCEEDED":
url = out.get("video_url") or ""
if not url:
results = out.get("results") or []
url = (results[0].get("video_url") or results[0].get("url")) if results else ""
if not url:
raise RuntimeError(f"成功但无视频 URL: {str(out)[:200]}")
_cb(95, "下载结果")
_download(url, output_path)
_cb(100, "完成")
logger.info(" ✅ 对口型完成: %s", Path(output_path).name)
return output_path
if st in ("FAILED", "CANCELED", "UNKNOWN"):
raise RuntimeError(f"任务 {st}: {(out.get('message') or out.get('code') or '')[:200]}")
if st != last:
last = st
logger.info(" [%s] 对口型任务状态: %s", tid[:8], st)
_cb(30, f"处理中({st})")
time.sleep(10)
raise RuntimeError(f"对口型超时(>{timeout}s)")
# ── CLI 扩展:--provider videoretalk ──
def cli_videoretalk(video: str, audio: str, output: str,
ref_image: str = "", video_extension: bool = False):
def _p(p, n):
print(f" {p}% {n}")
out = lipsync_videoretalk(video, audio, output, ref_image_path=ref_image,
video_extension=video_extension, progress_cb=_p)
print(f"\n✅ 输出: {out}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
"""
composer/qa.py — 出片质检辅助(接触印相表)
用途:出片后自动抽若干关键帧拼成一张「接触印相表」(contact sheet),
让人**一眼**核对:
· 口型是否与台词对上(有台词的镜头)
· 跨镜人物是否同一张脸(一致性)
· 表情/构图/光线是否连贯
纯本地 ffmpeg 处理,零成本。
"""
import subprocess
import sys
from pathlib import Path
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
if str(_SCRIPT_ROOT) not in sys.path:
sys.path.insert(0, str(_SCRIPT_ROOT))
from lib.logger import get_logger
log = get_logger("composer.qa")
def _ffmpeg() -> str:
try:
from lib.ffmpeg import FFMPEG
return FFMPEG
except Exception:
return "ffmpeg"
def _duration(video_path: str) -> float:
try:
from lib.ffmpeg import probe_duration
return float(probe_duration(video_path) or 0)
except Exception:
return 0.0
def make_contact_sheet(video_path: str, out_path: str,
cols: int = 4, rows: int = 2, tile_w: int = 360) -> str:
"""抽 `cols*rows` 帧均匀分布拼成一张图(默认 4x2 = 8 帧)
· 均匀采样(按视频时长算间隔),覆盖整个片段
· 输出 jpg,宽 ≈ cols * tile_w
"""
src = Path(video_path)
if not src.exists():
raise FileNotFoundError(f"视频不存在: {video_path}")
n = max(1, cols * rows)
dur = _duration(str(src))
interval = max(0.2, (dur / n)) if dur > 0 else 1.0
dst = Path(out_path)
dst.parent.mkdir(parents=True, exist_ok=True)
vf = f"fps=1/{interval:.3f},scale={tile_w}:-2,tile={cols}x{rows}"
r = subprocess.run(
[_ffmpeg(), "-y", "-i", str(src), "-vf", vf, "-frames:v", "1", "-q:v", "3", str(dst)],
capture_output=True, text=True, timeout=180)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 1000:
raise RuntimeError(f"生成接触印相表失败: {r.stderr.strip()[-200:]}")
log.info(" 🖼 质检图: %s(%d 帧 · %dx%d)", dst.name, n, cols, rows)
return str(dst)
def make_shot_qa(job_dir: str, sb=None) -> dict:
"""为一次出片生成质检图(成片整体 + 每个镜头)
返回 {"final": path, "shots": [paths...]};失败不抛(质检是辅助,不应阻塞交付)
"""
out: dict = {"final": "", "shots": []}
d = Path(job_dir)
if not d.exists():
return out
# ① 成片整体
final = d / "final_muxed.mp4"
if not final.exists():
final = d / "final.mp4"
if final.exists():
try:
out["final"] = make_contact_sheet(str(final), str(d / "qa" / "final_sheet.jpg"))
except Exception as e:
log.warning(" ⚠️ 成片质检图失败: %s", e)
# ② 逐镜头(对口型后的片段优先,核对口型最准)
qa_dir = d / "qa"
shots = []
try:
cands = sorted(d.glob("lipsync_*.mp4")) or sorted(d.glob("shot_*.mp4"))
for i, v in enumerate(cands, 1):
try:
shots.append(make_contact_sheet(
str(v), str(qa_dir / f"{v.stem}_sheet.jpg"), cols=3, rows=2, tile_w=280))
except Exception as e:
log.debug(" 镜头质检图失败 %s: %s", v.name, e)
except Exception as e:
log.warning(" ⚠️ 逐镜质检图失败: %s", e)
out["shots"] = shots
return out
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="出片质检辅助(接触印相表)")
ap.add_argument("job_dir", help="出片目录(含 final.mp4 或 final_muxed.mp4)")
args = ap.parse_args()
r = make_shot_qa(args.job_dir)
print(f"成片质检图: {r['final'] or '(无)'}")
print(f"镜头质检图: {len(r['shots'])} 张")
for p in r["shots"][:8]:
print(f" {p}")
@@ -0,0 +1,801 @@
"""
composer/realism.py — 写实感增强(去 AI 感)
来源:外部方法论学习(详见 PLANS/REALISM_GUIDE.md + 09_ave/docs/realism/)
作用:为提示词编译提供三件东西——
① EMOTION_ACTIONS 情绪外化表(抽象情绪 → 具体身体动作)
② REALISM_ANCHORS 写实锚点包(按拍摄风格给"反 AI 感"的具体锚点)
③ check_conflicts 矛盾约束自检(写了互相抵消的组合)
"""
import re
# ════════════════════════════════════════════════════════════
# ① 情绪外化表
# 原则:视频模型看不懂抽象形容词,必须外化为身体细节;
# 每个镜头只写 1-2 个关键动作(多了稀释焦点)
# ════════════════════════════════════════════════════════════
EMOTION_ACTIONS: dict = {
"悲伤": "低头、肩膀微微颤抖、眼眶泛红,手指无意识地攥紧衣角",
"难过": "低头、肩膀微微颤抖、眼眶泛红,手指无意识地攥紧衣角",
"喜悦": "嘴角抑制不住地上扬、眉眼舒展,说话时眼睛弯起来",
"开心": "嘴角抑制不住地上扬、眉眼舒展,说话时眼睛弯起来",
"高兴": "嘴角抑制不住地上扬、眉眼舒展,说话时眼睛弯起来",
"紧张": "手指无意识地攥紧衣角、眼神闪躲、呼吸略急",
"焦虑": "频繁看表、手指轻敲、眼神游移、下意识抿嘴",
"愤怒": "双拳紧握、下颌线绷紧、胸口起伏,从牙缝里挤话",
"生气": "双拳紧握、下颌线绷紧、胸口起伏,从牙缝里挤话",
"释然": "长舒一口气、紧绷的肩膀放松下来,浮起淡淡的微笑",
"放松": "长舒一口气、紧绷的肩膀放松下来,浮起淡淡的微笑",
"恐惧": "瞳孔放大、后退半步、手不自觉抓住身边物体、嘴唇发抖",
"害怕": "瞳孔放大、后退半步、手不自觉抓住身边物体、嘴唇发抖",
"疲惫": "揉太阳穴、眼皮下沉、长呼气、动作迟缓",
"疲惫不堪": "揉太阳穴、眼皮下沉、长呼气、动作迟缓",
"专注": "眉头微皱、眼神锁定一点、嘴唇抿紧、身体前倾",
"认真": "眉头微皱、眼神锁定一点、嘴唇抿紧、身体前倾",
"犹豫": "张嘴又闭上、视线游移、手悬在半空、欲言又止",
"纠结": "张嘴又闭上、视线游移、手悬在半空、欲言又止",
"惊喜": "眼睛瞬间睁大、嘴巴微张、身体向前倾,笑容缓慢绽放",
"期待": "身体微微前倾、目光停在一点、呼吸放轻",
"尴尬": "视线移向别处、嘴角抽动一下、手指摸了摸鼻尖",
"无奈": "轻轻摇头、嘴角扯出一个笑、肩膀垮下来",
"委屈": "嘴唇抿成一条线、眼眶发热、目光低垂看向地面",
"好奇": "微微歪头、眼睛亮起来、视线跟着对象移动",
"警觉": "下颌收紧、眼神快速扫向声源、身体下意识绷住",
"疏离": "目光越过对方看向远处、表情平静没有起伏、动作缓慢",
"冷漠": "目光越过对方看向远处、表情平静没有起伏、动作缓慢",
}
def externalize_emotion(sentiment: str) -> str:
"""抽象情绪 → 具体身体动作;未命中则原样返回(调用方可自行处理)"""
s = (sentiment or "").strip()
if not s:
return ""
# 直接命中
if s in EMOTION_ACTIONS:
return EMOTION_ACTIONS[s]
# 含"紧张/好奇"这类复合写法(用 / 、 分隔)→ 取第一个能命中的
for sep in ("/", "、", ",", ",", " "):
if sep in s:
for piece in s.split(sep):
piece = piece.strip()
if piece in EMOTION_ACTIONS:
return EMOTION_ACTIONS[piece]
# 部分包含
for k, v in EMOTION_ACTIONS.items():
if k in s:
return v
return s
# ════════════════════════════════════════════════════════════
# ② 写实锚点包(按拍摄风格)
# 核心:用"设备型号 / 真实光源 / 微瑕疵 / 皮肤细节"替换抽象形容词
# ════════════════════════════════════════════════════════════
REALISM_ANCHORS: dict = {
# 📱 自拍 Vlog(用户核心诉求)
"selfie_pov": {
"name": "手机自拍 · 第一人称",
"device": "iPhone 前置摄像头拍摄,竖屏 9:16,一臂距离(鼻梁与脸颊有轻微广角透视变形)",
"hand": "手持自拍,画面随呼吸与手臂疲劳轻微起伏;偶尔抬手微调构图",
"light": "窗光从侧面打在脸上、室内顶灯偏暖;背景比人脸暗一档",
"skin": "真实皮肤纹理、毛孔与细小绒毛可见、不过度磨皮、没有塑料质感",
"flaws": ("前置镜头为**定焦**(不会有对焦搜索);近距离透视变形;"
"动态范围有限——窗外或灯下的高光容易过曝、暗部有可见噪点;"
"头发有几缕没梳顺"),
"motion": "说话时自然眨眼、吞咽、鼻翼微微起伏,视线偶尔离开镜头再回来",
"sound": "房间轻微混响、远处环境声、衣服摩擦声(手机麦克风拾音,人声比正常对话稍远)",
"_no": ("不要三脚架式的绝对稳定(自然的手持呼吸感要保留)、不要影棚布光、"
"不要滤镜磨皮、不要对焦搜索(手机前置是定焦)"),
},
# 伪纪录片 · 真实自然(现有默认)
"documentary_realism": {
"device": "手持摄影机质感,轻微呼吸起伏",
"hand": "镜头随拍摄者呼吸轻微移动,构图不是完美的",
"light": "现场实际光源(窗光、路灯、顶灯),明暗过渡自然",
"skin": "真实皮肤纹理、毛孔可见、不过度磨皮",
"flaws": "轻微噪点、暗部细节不完全干净、对焦有呼吸感",
"motion": "被拍摄者的动作不追求完美,有停顿与迟疑",
"_no": "不要运镜特效痕迹、不要 AI 塑料质感、不要字幕水印",
},
# 电影感
"cinematic": {
"device": "35mm 电影镜头,浅景深",
"hand": "三脚架或稳定器,运动平滑",
"light": "动机光(画面内有光源依据)+ 逆光轮廓",
"skin": "电影级肤色还原、保留皮肤质感",
"flaws": "轻微的镜头光晕、真实的光斑",
"motion": "调度从容,动作有起势与收势",
"_no": "不要手持晃动、不要纪实粗糙感",
},
# 棚拍干净
"studio_clean": {
"device": "专业摄影机,稳定机位",
"hand": "固定机位 / 滑轨",
"light": "柔光箱主光 + 轮廓光",
"skin": "干净但保留皮肤纹理(不要过度磨皮)",
"flaws": "极少的噪点,干净背景",
"motion": "表演可预期、动作清晰",
"_no": "不要手持晃动、不要环境杂光",
},
# 手持 Vlog · 第一视角(边走边拍,镜头=拍摄者的眼睛)
"vlog_handheld": {
"device": "手机或轻便相机手持拍摄,竖屏 9:16",
"hand": "第一视角手持,画面随走路上下起伏,转向时略有回摆",
"light": "自然光为主( daylight / 室内灯),明暗随走动变化",
"skin": "真实皮肤纹理、毛孔可见、不过度磨皮",
"flaws": "走动时的轻微抖动、暗部噪点、偶尔的手部入画",
"motion": "被拍对象自然走动、走动时说话有些喘",
"sound": "环境音随移动变化、脚步声、风噪",
"_no": "不要稳定器般平滑、不要影棚布光",
},
# 访谈纪实 · 半身对镜
"interview_doc": {
"device": "中焦镜头,半身构图,略浅景深",
"hand": "机位基本固定,允许极轻微的手持呼吸感",
"light": "柔和侧光或窗户光,面部立体但不过曝",
"skin": "真实皮肤纹理、皱纹与毛孔保留、不过度磨皮",
"flaws": "背景略暗、轻微噪点、被访者偶尔的停顿与眼神移动",
"motion": "被访者自然对镜说话,有停顿、思考、手势",
"sound": "安静室内混响、衣料摩擦声",
"_no": "不要花哨运镜、不要强风格化调色",
},
}
def get_anchors(shooting_style: str) -> dict:
"""按拍摄风格取锚点包;未知名回退 documentary_realism"""
s = (shooting_style or "").strip().lower()
if s in REALISM_ANCHORS:
return REALISM_ANCHORS[s]
# 常见别名
alias = {
"selfie": "selfie_pov", "vlog": "selfie_pov", "自拍": "selfie_pov",
"手机自拍": "selfie_pov", "第一人称": "selfie_pov",
"documentary": "documentary_realism", "纪实": "documentary_realism",
"film": "cinematic", "电影感": "cinematic", "movie": "cinematic",
"studio": "studio_clean", "棚拍": "studio_clean",
}
for k, v in alias.items():
if k in s:
return REALISM_ANCHORS[v]
return REALISM_ANCHORS["documentary_realism"]
# ════════════════════════════════════════════════════════════
# ③ 矛盾约束自检(写了等于没写)
# ════════════════════════════════════════════════════════════
CONFLICT_PAIRS: list = [
(("画面稳定", "稳定机位", "三脚架"), ("手持", "晃动", "呼吸起伏"),
"「画面稳定」与「手持晃动」冲突(稳定=三脚架语言)"),
(("电影感", "cinematic"), ("纪实", "documentary", "手持"),
"「电影感」与「纪实手持」冲突(风格相互拉扯)"),
(("胶片", "film grain", "颗粒感"), ("4K", "超清", "ultra sharp", "高清锐利"),
"「胶片感」与「超清」冲突(胶片感本身是模糊+颗粒)"),
(("慢动作", "slow motion"), ("高速", "快切", "high speed"),
"「慢动作」与「高速动作」冲突"),
]
def check_conflicts(text: str) -> list:
"""扫描提示词里的矛盾组合,返回告警列表(空 = 无冲突)
⚠️ 先剔除负向语境:负向约束里写「不要画面稳定」是在**否定**该项,
不能被当成"要求画面稳定"(否则与"手持晃动"误报冲突)。
"""
t = (text or "")
# 剔除"不要X / 避免X / 禁止X / 无X"片段(X 最多 12 字),再做正向检测
t_positive = re.sub(r"(不要|避免|禁止|无)[^、;。\n]{0,12}", " ", t)
warns = []
for group_a, group_b, msg in CONFLICT_PAIRS:
hit_a = next((a for a in group_a if a.lower() in t_positive.lower()), "")
hit_b = next((b for b in group_b if b.lower() in t_positive.lower()), "")
if hit_a and hit_b:
warns.append(f"{msg}(同时出现「{hit_a}」与「{hit_b}」)")
return warns
# ════════════════════════════════════════════════════════════
# 必加约束(护栏)
# ════════════════════════════════════════════════════════════
BASE_CONSTRAINTS = "不要水印、不要 Logo、不要字幕"
FACE_CONSTRAINTS = "人物面部稳定不变形、动作连贯自然不僵硬、无穿模无卡顿无闪烁"
ANTI_TWIN = "同一画面中不出现外形着装完全一致的人物(防止分身/双胞胎)"
def compose_constraints(extra_no: str = "", with_anti_twin: bool = True) -> str:
"""组装约束串(负向 + 正向要求,自动去重)
注意:两类语义不同——
· 负向约束("不要 X")用「负向约束:」引导
· 正向要求("人物面部稳定不变形"这类陈述句)用「画面要求:」引导
⚠️ 不能给正向要求硬加"不要"前缀(会把要求变成禁止,语义反转)
"""
negatives: list = [] # 形如 "不要水印"
positives: list = [] # 形如 "人物面部稳定不变形"
for x in BASE_CONSTRAINTS.split("、"):
x = x.strip()
if x:
negatives.append(x if x.startswith("不要") else "不要" + x)
for x in FACE_CONSTRAINTS.split("、"):
x = x.strip()
if x:
positives.append(x)
if with_anti_twin:
positives.append(ANTI_TWIN)
if extra_no:
e = extra_no.strip()
for x in e.split("、"):
x = x.strip()
# ⚠️ 每项单独去前缀(锚点包每项通常自带"不要",若只去整体前缀会出现"不要不要X")
while x.startswith("不要"):
x = x[len("不要"):].strip()
if x:
negatives.append("不要" + x)
def _dedup(seq):
"""**完全去重**(相同项只留一条)
⚠️ 我曾经想在这里做"子串合并"("不要字幕水印" 吞掉 "不要字幕"),
但实测证明那是**错的**:
· `不要字幕` 与 `不要字幕水印` 是两条**不同**的约束(后者更具体),都该保留
· 而且按子串匹配会把 `不要水印` 误判成 `字幕水印` 的子集 → 错误替换
结论:**只做完全去重,不做子串合并。**
"""
seen, out = set(), []
for it in seq:
k = it.strip()
if not k or k in seen:
continue
seen.add(k)
out.append(it)
return out
parts = []
if negatives:
parts.append("负向约束:" + "、".join(_dedup(negatives)))
if positives:
parts.append("画面要求:" + "、".join(_dedup(positives)))
return ";".join(parts)
# ════════════════════════════════════════════════════════════
# ④ 风格包(Style Pack)—— 把"导演提示词知识"变成可选参数
# 用户 2026-10-03 要求:把学到的提示词技巧整理成参数选项,
# 选一个风格 = 自动带上一整套「设备/光/机位/瑕疵/情绪/负面词」设定。
# ════════════════════════════════════════════════════════════
STYLE_PACKS: dict = {
"selfie_pov": {
"label": "📱 手机自拍 · 第一人称",
"device": "iPhone 前置摄像头,手持自拍,手臂伸出一臂距离",
"lighting": "室内自然光或窗光为主,光源不均匀,脸上有明显明暗过渡",
"camera": "轻微手持晃动,构图略微歪斜不居中,对焦偶尔漂移",
"flaws": "轻微手机噪点、真实肤质(可见毛孔与细纹)、有一缕碎发没整理好",
"emotion": "外化", "realism_default": "mild",
"negatives": ["影棚均匀布光", "三脚架固定机位", "过度磨皮", "商业修图感"],
},
"vlog_handheld": {
"label": "🚶 手持 Vlog · 第一视角",
"device": "手持相机或手机,走动跟拍,第一视角",
"lighting": "户外环境光/店内混合光源,色温不统一",
"camera": "明显手持晃动(模拟步行节奏),偶有快速转向,构图随意",
"flaws": "轻微运动模糊、曝光偶尔跳动、背景杂乱真实",
"emotion": "外化", "realism_default": "mild",
"negatives": ["稳定器丝滑运镜", "影棚打光", "摆拍感"],
},
"documentary_realism": {
"label": "🎥 伪纪录片 · 真实自然",
"device": "手持摄像机或长焦,像随手记录",
"lighting": "以现场自然光为主,不补光,允许暗部欠曝",
"camera": "长镜头、轻微呼吸感晃动、不稳定构图",
"flaws": "轻微胶片颗粒、真实环境杂乱、画面有生活痕迹",
"emotion": "外化", "realism_default": "mild",
"negatives": ["影棚布光", "摆拍摆姿", "商业感"],
},
"interview_doc": {
"label": "📺 访谈纪实 · 半身对镜",
"device": "固定机位,中焦镜头,半身构图",
"lighting": "柔和主光 + 环境光,背景稍暗",
"camera": "机位固定,极轻微晃动,人物可略偏离画面中心",
"flaws": "背景真实但不抢眼、肤色自然不修饰",
"emotion": "外化", "realism_default": "off",
"negatives": ["夸张运镜", "过度磨皮"],
},
"cinematic": {
"label": "🎞️ 电影感 · 考究构图",
"device": "专业电影机,变焦或定焦,景深控制",
"lighting": "有设计感的光比,侧逆光/轮廓光,暗部有层次",
"camera": "稳定运镜或精心设计的推拉摇移,构图讲究",
"flaws": "轻微胶片颗粒(不要数码锐化的塑料感)",
"emotion": "外化", "realism_default": "off",
"negatives": ["手机感", "随手拍", "画面杂乱"],
},
"studio_clean": {
"label": "💡 商业棚拍 · 高清干净",
"device": "专业摄影机,三脚架固定,中长焦",
"lighting": "专业布光:主光 + 辅光 + 轮廓光,光比均匀",
"camera": "稳定固定机位,构图工整居中",
"flaws": "(此风格刻意干净 —— 但保留真实肤质,避免塑料感)",
"emotion": "外化", "realism_default": "off",
"negatives": ["画面杂乱", "手持晃动", "环境噪音"],
},
"anime_2d": {
"label": "🎨 2D 动画 · 漫剧",
"device": "动画分镜,2D 手绘或动漫渲染",
"lighting": "动画式平光或风格化光影(非物理真实)",
"camera": "动画运镜:推拉摇移、定格、分格",
"flaws": "(动画无需真实瑕疵;改为保持线条稳定、色块不跳变)",
"emotion": "夸张外化(动漫式表情与动作幅度)", "realism_default": "off",
"negatives": ["写实人脸照片感", "真实摄影质感", "胶片颗粒", "真人实拍"],
},
"anime_3d": {
"label": "🧸 3D 动画 · 卡通渲染",
"device": "3D 渲染,卡通/风格化着色",
"lighting": "风格化打光,柔和阴影或卡通描边",
"camera": "3D 运镜(环绕/位移/景深)",
"flaws": "(保持模型一致,避免穿模与闪烁)",
"emotion": "夸张外化", "realism_default": "off",
"negatives": ["写实照片", "真人实拍", "皮肤毛孔", "胶片噪点"],
},
"product_ad": {
"label": "🧊 产品广告 · 精致特写",
"device": "微距或长焦,产品特写,浅景深",
"lighting": "精细打光:轮廓光 + 反光板,突出材质",
"camera": "稳定运镜(滑轨/环绕),构图讲究",
"flaws": "材质细节清晰(不是缩小版瑕疵)",
"emotion": "(产品无情绪;如有模特出镜则外化)", "realism_default": "off",
"negatives": ["画面杂乱", "廉价感", "低清晰度"],
},
}
# ════════════════════════════════════════════════════════════
# ⑤ 反真实词库(★ 研究核心发现)
# "AI 感"的头号来源:提示词里堆砌「4K/8K/超清/无瑕/大师作品」——
# 模型会理解成"要无瑕",于是过度磨皮、抹平纹理、统一光影 → 塑料感。
# 真实感增强开启时,自动剔除这些词。
# ════════════════════════════════════════════════════════════
ANTI_REALISM_WORDS: list = [
"4k", "8k", "4K", "8K", "超清", "超高清", "高清", "极致画质", "顶级画质",
"电影级", "电影感构图", "大片感", "杂志级",
"无瑕", "无瑕疵", "完美", "大师作品", "大师级", "杰作",
"masterpiece", "best quality", "ultra detailed", "ultra realistic",
"hyperrealistic", "photorealistic", "8k uhd", "highly detailed",
"过度锐化", "塑料感", "CG感", "3D渲染感",
]
# 反真实词在"写实类"风格下才剔除(动画类风格里这些词无害甚至有用)
_REALISM_STYLES = {"selfie_pov", "vlog_handheld", "documentary_realism",
"interview_doc", "cinematic", "studio_clean"}
def strip_anti_realism(text: str) -> tuple:
"""剔除会导致「塑料感」的反真实词(写实风格下)
返回 (清洗后文本, 被剔除的词列表)
"""
if not text:
return "", []
out = text
removed = []
for w in ANTI_REALISM_WORDS:
if w in out:
out = out.replace(w, "")
removed.append(w)
# 清理残留下的多余标点/空格
out = re.sub(r"[、,,]\s*(?=[、,,])", "", out)
out = re.sub(r"\s{2,}", " ", out).strip(" 、,,")
return out, removed
def apply_style_pack(prompt: str, shooting_style: str,
realism_boost: str = "mild") -> tuple:
"""把「风格包」应用到提示词上(去 AI 感的核心动作)
realism_boost: off(不动)/ mild(加瑕疵锚点)/ strong(瑕疵 + 剔除反真实词)
返回 (增强后的提示词, 说明列表)
"""
notes: list = []
if not prompt:
return prompt, notes
pack = STYLE_PACKS.get(shooting_style) or {}
boost = (realism_boost or "off").lower()
out = prompt
if boost == "off" or not pack:
return out, notes
# ① strong:剔除反真实词(研究里最大的坑)
if boost == "strong" and shooting_style in _REALISM_STYLES:
out, removed = strip_anti_realism(out)
if removed:
notes.append(f"已剔除反真实词:{'、'.join(removed[:6])}")
# ② 追加瑕疵锚点(如果提示词里还没有类似描述)
flaws = pack.get("flaws") or ""
if flaws and not any(k in out for k in ("噪点", "颗粒", "毛孔", "瑕疵", "纹理")):
out = out.rstrip("。. ") + f"。{flaws}。"
notes.append("已追加真实质感锚点")
# ③ strong:额外补一句"反完美"总纲
if boost == "strong":
tail = "真实拍摄的不完美感:光线不均匀、构图不刻意、画面有生活痕迹"
if "不完美" not in out and "不均匀" not in out:
out = out.rstrip("。. ") + f"。{tail}。"
notes.append("已追加反完美总纲")
return out, notes
def style_pack_options() -> dict:
"""供前端生成「拍摄风格」下拉(含中文标签)"""
return {k: v.get("label", k) for k, v in STYLE_PACKS.items()}
# ════════════════════════════════════════════════════════════
# ★ 去 AI 感扩展库(2026-10-04 研究落地)
# 来源:zhouwei713/seedance-prompt 的 anti-ai-checklist.md
# (原文存 09_ave/docs/realism/anti-ai-checklist.md)
#
# 核心洞察:**"AI 感"主要来自模型的默认审美**(电影化运镜/稳定画面/
# 商业调色/完美构图/磨皮皮肤)—— 必须用负面约束显式关闭,
# 并主动注入「非完美事件」才像真实拍摄。
# ════════════════════════════════════════════════════════════
# ① AI 默认行为 → 负面约束句(10 项,含"造成的问题")
AI_DEFAULT_NEGATIVES = [
("电影化平滑运镜(推轨/摇臂感)", "没有电影化的摄像机移动"),
("画面稳定如三脚架", "不要三脚架式的绝对稳定(自然手持呼吸感是允许的)"),
("现代商业调色(青橙色调、高对比)", "没有现代色彩分级"),
("完美居中构图", "不完美的构图,构图偏离中心"),
("场景自动加商店/咖啡馆/人群/广告牌", "没有商店、广告、咖啡馆、人群或商业活动"),
("自动配背景音乐", "没有音乐。没有音效设计。没有旁白"),
("表演感强、直视镜头摆姿势", "真实的即兴行为,无剧本的日常生活片段感"),
("皮肤过度平滑/磨皮感", "逼真的皮肤纹理"),
("角色跨镜头漂移", "在整个视频中保持一致的身份、服装、发型和外貌"),
("灯光过于均匀完美", "自然光,在阳光和阴影间移动时的曝光波动"),
]
# ② 非完美事件库(21 个 · 四类)—— 每镜头选一个,全片不重复
IMPERFECT_EVENTS = {
"photographer": [
"自动对焦在主体和前景/背景之间反复搜索",
"构图偏离中心(操作者试图跟上主体)",
"摄像机稍晚才捕捉到关键瞬间(人已转身才对准)",
"变焦生硬,推过了又拉回来",
"走动跟拍时的强烈晃动和运动模糊",
"手指或背带短暂遮挡镜头一角",
"镜头努力对焦但短暂失败,画面呼吸",
],
"environment": [
"一阵微风吹动散落的发丝或衣角",
"云影掠过,曝光随之波动",
"小动物(猫/狗/鸽子)闯入画面,主体自然回应",
"场外有人打招呼,主体转头回应",
"阳光透过树叶在脸上闪烁明灭",
"路人从镜头前无意穿过",
],
"subject": [
"注意到镜头,露出小而真诚的微笑后移开视线",
"说话被环境音打断,笑场",
"整理头发、拨发到耳后等无意识小动作",
"动作做到一半改变主意(伸手拿杯子又先擦了下桌子)",
],
"ending": [
"录制在中途突然切到黑屏,仿佛摄像机关闭",
"主体走出画面,镜头停留在空景两秒",
"镜头垂下拍到地面/脚,然后切断",
"有人喊拍摄者的名字,镜头晃动着转向声源时切断",
],
}
# ③ 特殊字符规范(信息类型标注)—— 来自 07-特殊字符规范.md
SYMBOL_SPEC = {
"台词": "{你好,世界}(外语需标语种:用日语说道{こんにちは})",
"音效": "<远处传来狗叫声>",
"音乐": "(背景中播放着快节奏的摇滚乐)",
"字幕": "【第一章:启程】",
}
def ai_negative_block(hard: bool = True, extra: list = ()) -> str:
"""组装「关闭 AI 默认审美」负面约束段
hard=False 时只取前 6 条(精简版,用于短提示词)
"""
items = [neg for _, neg in (AI_DEFAULT_NEGATIVES if hard else AI_DEFAULT_NEGATIVES[:6])]
items.extend([str(x) for x in (extra or []) if x])
return "【负面约束(关闭 AI 默认审美)】" + ";".join(items) + "。"
def pick_imperfect_events(n_shots: int, categories: tuple = None) -> list:
"""为 N 个镜头分配「非完美事件」(不重复;最后一条自动用收束方式)"""
import random
cats = list(categories or ("photographer", "environment", "subject"))
pool = []
for c in cats:
pool.extend(IMPERFECT_EVENTS.get(c, []))
random.shuffle(pool)
out = []
for i in range(max(0, n_shots)):
if i == n_shots - 1 and n_shots > 1:
out.append(random.choice(IMPERFECT_EVENTS["ending"]))
else:
out.append(pool[i % len(pool)] if pool else "")
return out
def imperfect_events_prompt() -> str:
"""给导演的「非完美事件」纪律(逐镜头分配、不重复、结尾收束、2 秒节奏)"""
return (
"【真实感纪律:非完美事件(每个镜头分配一个,全片不重复)】\n"
"真实拍摄必然有瑕疵。请为每个镜头挑 1 个(**一镜一个,不要堆**):\n"
"· 摄影师失误:" + ";".join(IMPERFECT_EVENTS["photographer"][:4]) + "\n"
"· 环境介入:" + ";".join(IMPERFECT_EVENTS["environment"][:4]) + "\n"
"· 主体自然行为:" + ";".join(IMPERFECT_EVENTS["subject"][:3]) + "\n"
"· **结尾必须用一种收束方式**:" + ";".join(IMPERFECT_EVENTS["ending"]) + "\n"
"另外:镜头约 2 秒一个(超过 3 秒会滑向「精心拍摄」的假感);"
"除非明确设定为手机慢动作,否则不要写慢动作。"
)
def check_abstract_words(text: str) -> list:
"""自检:扫出会引发 AI 感的抽象词(返回命中列表)"""
BAD = ["美丽", "唯美", "高级感", "大片感", "史诗", "震撼", "精致", "梦幻", "氛围感"]
return [w for w in BAD if w in str(text or "")]
# ════════════════════════════════════════════════════════════
# ★ 设备美学缺陷参数包(9 个)—— 2026-10-04 研究落地
# 来源:camera-aesthetics.md(原文存 docs/realism/)
#
# 核心原则:**想要某种年代质感,就完整描述那个设备的物理缺陷,缺陷即真实。**
# 与 STYLE_PACKS 的关系:
# · STYLE_PACKS = 抽象风格取向(自拍/纪录片/电影感…)
# · DEVICE_PACKS = **具体设备的物理缺陷**(DV/VHS/胶片/手机/GoPro…)
# 两者互补,可叠加。
# ════════════════════════════════════════════════════════════
DEVICE_PACKS: dict = {
"dv_2000s": {
"name": "2000年代初消费级DV摄像机",
"fit": "家庭录像、朋友随拍、千禧年怀旧、韩日老社区题材",
"visual": ["中等数字压缩伪影", "褪色的色彩,柔和的对比度", "轻微的传感器噪点",
"轻微的滚动快门", "480p 到轻微高清之间的分辨率感", "高光容易过曝,暗部发灰"],
"operation": ["强烈的手持抖动,不完美的构图", "频繁的自动对焦搜索",
"镜头呼吸(对焦时视角轻微变化)", "在阳光和阴影间移动时的曝光波动",
"偶尔的运动模糊", "变焦生硬,速度不均匀", "录制中途突然开始或结束"],
"negative": "不要三脚架式的绝对稳定。不要电影化的摄像机移动。不要现代色彩分级。",
},
"vhs_8090s": {
"name": "80至90年代VHS家用摄像机",
"fit": "童年录像、生日聚会、更旧的怀旧感、复古恐怖",
"visual": ["明显的磁带噪点和扫描线", "色彩溢出(红色尤其容易晕开)",
"底部时间码或日期戳", "画面边缘轻微的几何畸变",
"低分辨率,细节模糊", "磁带损伤造成的偶尔画面撕裂或跳帧"],
"operation": ["沉重机身带来的缓慢笨重移动", "手动变焦的顿挫感",
"白平衡偏移(室内偏橙,室外偏蓝)", "逆光时人物完全剪影化"],
"negative": "不要数字锐利感。不要三脚架式的绝对稳定。不要高动态范围。",
},
"film_8mm": {
"name": "8mm/Super 8 胶片",
"fit": "更久远的家庭记忆、梦境感、婚礼老片、艺术短片",
"visual": ["明显的胶片颗粒", "温暖偏黄或偏品红的色彩偏移", "画面边缘的暗角",
"灰尘、毛发、划痕偶尔闪过", "18fps 带来的轻微卡顿感",
"过曝的高光呈现柔和的光晕"],
"operation": ["每段镜头很短(胶片昂贵,3到5秒就切)", "硬切,没有转场",
"曝光靠估计,时对时错", "无声(胶片本身不录音)或后期配的环境音"],
"negative": "没有同期声对白。没有平滑运镜。没有数字清晰度。",
},
"smartphone_vertical": {
"name": "智能手机竖屏(2015年后)",
"fit": "Vlog、社交媒体感、街头偶遇、当代日常",
"visual": ["9:16 竖构图", "过度锐化和 HDR 处理感",
"电子防抖造成的果冻效应", "暗光下的涂抹感降噪",
"前置镜头的轻微广角畸变"],
"operation": ["单手持机的轻微倾斜", "走路时的上下浮动(电子防抖压制后仍残留)",
"对着镜头说话的自拍视角切换", "手指偶尔擦过镜头边缘",
"突然抬起或放下手机的开始与结束"],
"negative": "没有横屏。没有三脚架稳定。没有专业布光。",
},
"gopro": {
"name": "GoPro / 运动相机",
"fit": "第一人称视角、运动、旅行、冒险感",
"visual": ["超广角鱼眼畸变,边缘拉伸", "高饱和的默认色彩",
"强光下的镜头眩光", "水滴或灰尘沾在镜头上"],
"operation": ["胸挂或头戴的第一人称视角", "剧烈但被电子增稳压制的震动",
"呼吸和身体晃动传导到画面", "视角随头部转动突然甩动"],
"negative": "没有第三人称跟拍。没有电影构图。没有慢动作(除非明确要求)。",
},
"cctv": {
"name": "监控摄像头 / CCTV",
"fit": "悬疑、都市传说、客观记录感、伪纪录片",
"visual": ["固定高位俯视角", "低帧率(每秒约10到15帧的顿挫感)",
"单色或低饱和色彩,夜间红外模式呈灰白", "角落的时间戳持续跳动",
"低分辨率,人脸模糊难辨"],
"operation": ["完全静止,或极缓慢的预设巡航转动", "无声",
"人物不知道被拍摄,行为完全自然"],
"negative": "没有摄影师存在。没有构图意识。没有声音。没有变焦跟随主体。",
},
"doc_handheld": {
"name": "专业纪录片(手持电影机)",
"fit": "高质感纪实、人物特稿、社会观察",
"visual": ["浅景深,主体清晰背景柔和", "自然光为主,宽容度高",
"克制的调色,接近肉眼所见"],
"operation": ["有经验的手持,微晃但不失控", "跟焦偶尔慢半拍(保留这个不完美)",
"长镜头耐心观察,不急于切换", "被摄者偶尔瞥向镜头又移开"],
"negative": "没有摆拍感。没有采访灯光。没有戏剧化运镜。没有配乐驱动的剪辑节奏。",
},
"webcam": {
"name": "网络摄像头 / 视频通话",
"fit": "疫情记忆、远程对话、屏幕录制感、亲密而低保真的交流",
"visual": ["720p 以下的低清晰度", "压缩造成的块状伪影,网络波动时画面糊成马赛克",
"顶光或屏幕光打在脸上的青白色", "固定机位,仰视或平视的笔记本视角"],
"operation": ["音画偶尔不同步", "人物偶尔卡顿定格再恢复", "背景是真实的房间一角"],
"negative": "没有专业收音。没有补光灯质感。没有运镜。",
},
"flip_phone_2003": {
"name": "2003年翻盖手机摄像(CMOS低保真)",
"fit": "极度怀旧、千禧年初学生时代、赛博朋克低保真、暗角情绪短片",
"visual": ["极低分辨率(176×144 或 320×240)", "严重 JPEG 压缩块状伪影,色块化严重",
"色彩饱和度极端且偏色严重(通常偏蓝绿或偏品红)", "暗部几乎完全噪点糊死",
"画面中央清晰度尚可,边缘急剧劣化", "固定数字变焦(裁切放大)",
"超窄动态范围,高光纯白一片"],
"operation": ["单手持机,手臂伸直自拍或随意抓拍", "翻盖打开即拍,几乎没有延迟补偿",
"快门时滞极长(按下到实际拍摄有0.5到1秒延迟)",
"录像按一下开始,再按一下结束,没有预览确认",
"对焦基本靠固定焦段,前后移动自身来「对焦」",
"录制过程中镜头容易被手指或翻盖边缘遮挡"],
"negative": "没有清晰度。没有平滑运动。没有色彩准确度。没有变焦操作。",
},
}
# ════════════════════════════════════════════════════════════
# ★ 氛围词典(抽象氛围 → 具体可拍现象)
# 来源:atmosphere-dictionary.md(原文存 docs/realism/)
#
# 原则:**禁止直接使用抽象氛围词**,必须翻译成具体视觉/听觉现象,
# 每种氛围至少选 3~5 个现象组合使用。
# ════════════════════════════════════════════════════════════
ATMOSPHERE_DICT: dict = {
"温暖": {
"visual": ["午后阳光透过树叶在地面投下移动的斑驳光影", "晾衣绳上的织物在微风中缓慢摇曳",
"陶瓷杯口冒出的细微热气", "猫或狗慵懒地靠近人",
"盆栽植物摆在窗台和门口台阶上", "人物把头发拨到耳后的小动作",
"不经意的真诚微笑,随即移开视线"],
"audio": ["晨间鸟鸣", "远处小孩嬉闹声", "织物摩擦声", "猫叫", "勺子碰杯壁"],
},
"怀旧": {
"visual": ["褪色的色彩与柔和的对比度", "老式电线杆和架空电线切割天空",
"墙面斑驳的低层住宅", "停在巷口的旧自行车", "画面上的日期戳",
"人物穿着宽松水洗牛仔裤等年代服饰"],
"audio": ["远处摩托车驶过", "老风扇转动声", "模拟信号的底噪"],
},
"孤独": {
"visual": ["大面积负空间构图,人物偏居画面一角", "空旷街道上唯一移动的身影",
"荧光灯的青白色调", "便利店或地铁站的深夜灯光",
"人物长时间静止,只有环境在动(车流、雨、霓虹闪烁)", "玻璃反光中模糊的人影"],
"audio": ["空调外机嗡鸣", "脚步回声", "远处地铁进站声", "雨点打在遮阳棚上"],
},
"紧张": {
"visual": ["画面边缘偶尔闯入不明物体或人影", "光源不稳定,灯管闪烁",
"门缝、走廊尽头等纵深阴影", "手持镜头呼吸感加重,微微颤抖",
"人物频繁回头或停顿倾听"],
"audio": ["突然中断的环境音", "远处不明的金属碰撞声", "自己的呼吸声被收进麦克风"],
},
"平静": {
"visual": ["极缓慢的光线变化(云影掠过整个画面)", "水面或窗帘的规律性微动",
"长镜头,机位几乎不动", "人物做重复性动作(浇水、叠衣、剥豆)"],
"audio": ["风穿过树叶的持续沙沙声", "水滴声", "远处若有若无的生活声"],
},
"热闹": {
"visual": ["前景不断有人或车穿过镜头", "摊贩的动作细节(翻炒、找零、擦桌子)",
"蒸汽、油烟、烤炉的热浪扭曲空气",
"多层次的动态:近景手部动作、中景人流、远景车流"],
"audio": ["叫卖声", "锅铲碰撞", "多人交谈混叠", "自行车铃"],
},
"梦境": {
"visual": ["过曝的窗户白成一片", "胶片颗粒与光晕", "镜头短,硬切,时间线跳跃",
"人物对镜头挥手或大笑但无声", "逆光轮廓,面部细节丢失"],
"audio": ["无声,或只有一层模糊的环境底噪"],
},
"田园": {
"visual": ["大片稻田或麦田在风中如波浪起伏", "人物赤脚踩在湿润泥土或田埂上",
"荷叶、野花、藤蔓等植物特写", "阳光透过树叶在人物脸上流动的光斑",
"远处农舍的炊烟和黑瓦白墙", "蜻蜓点水、蝴蝶停在花上",
"人物背影渐渐走远,融入田野尽头"],
"audio": ["连续的蛙鸣和虫鸣", "风吹稻田的沙沙声", "远处公鸡打鸣", "溪流声",
"脚踏湿泥的柔软声音", "竹叶碰撞声"],
},
"夏日": {
"visual": ["树影在地面剧烈晃动,光斑密集而明亮", "人物额头和鬓角有细微汗珠",
"手中冰饮或西瓜表面凝结水珠", "风扇叶片或蒲扇的转动",
"强烈阳光造成的高光过曝和镜头眩光", "热空气扭曲远处景物",
"牵牛花、向日葵、荷叶等盛夏植物", "偶尔的云影掠过造成短暂阴凉"],
"audio": ["比任何季节都响亮的蝉鸣", "空调外机或风扇的低沉嗡鸣",
"冰块碰杯的清脆声", "远处孩童的嬉笑和泼水声"],
},
"通勤": {
"visual": ["密集的人流与单个主体形成对比", "从室内到室外/地上到地下的曝光剧烈变化",
"呼出的白气(秋冬)或额头的汗(盛夏)", "低头看手机、翻包找卡等碎片化动作",
"车窗玻璃上反射的模糊面容", "通勤者独特的身体语言(缩脖子、拉围巾)",
"地铁隧道中快速掠过的灯光管"],
"audio": ["广播模糊的播报声", "列车进站的轰鸣和刹车尖叫", "闸机蜂鸣声",
"脚步匆忙的节奏", "车门气动声", "隧道风噪声"],
},
"青春": {
"visual": ["漫无目的的闲逛或长时间坐着发呆", "手部小动作(拨弄衣角、卷头发、转笔)",
"靠在墙边、栏杆上,重心随意切换", "低头笑、抬头看天、把脸埋进手臂",
"旧电扇转头吹动刘海和衣领", "地面上的影子被拉长,时间缓慢推移",
"零星对话,说话间隙被环境填满"],
"audio": ["远处收音机或电视的模糊声音", "老旧风扇的转动声", "冰棍棒折断声",
"偶尔的笑声然后继续安静"],
},
}
def device_pack_block(key: str, include_negative: bool = True) -> str:
"""设备缺陷参数包 → 提示词片段(可直接粘进「摄像风格」段)"""
p = DEVICE_PACKS.get((key or "").strip())
if not p:
return ""
out = [f"【设备美学:{p['name']}】"]
out.append("画面特征:" + ";".join(p["visual"]))
out.append("操作特征:" + ";".join(p["operation"]))
if include_negative and p.get("negative"):
out.append("负面约束:" + p["negative"])
return "\n".join(out)
def atmosphere_block(keys, n_each: int = 4) -> str:
"""氛围 → 具体现象片段(每种氛围取 n 个视觉 + 全部听觉的前若干个)
keys 可以是 str 或 list;未命中则原样返回空串(调用方可自行翻译)。
用法原则:**不要直接写抽象氛围词,用这些具体现象替代**。
"""
if isinstance(keys, str):
keys = [keys]
out = []
for k in (keys or []):
a = ATMOSPHERE_DICT.get(str(k).strip())
if not a:
continue
vis = a["visual"][:max(1, n_each)]
aud = a["audio"][:max(1, n_each // 2)]
out.append(f"【{k}(用具体现象替代)】视觉:" + ";".join(vis)
+ ("。听觉:" + ";".join(aud) if aud else ""))
return "\n".join(out)
def assign_device_defects(n_shots: int) -> list:
"""按「缺陷不重复原则」给每个镜头分配**不同类型**的摄像机缺陷
来源:camera-aesthetics.md 进阶用法 —— 同一提示词里每镜的缺陷类型不重复:
对焦 → 构图 → 抖动 → 曝光 → 反应延迟 → 呼吸感 → 录制中断
"""
seq = [
"自动对焦在前景和背景之间搜索", # 对焦缺陷
"构图偏离中心,操作者跟不上主体", # 构图缺陷
"跟拍晃动 + 运动模糊", # 抖动缺陷
"进入阴影时曝光波动", # 曝光缺陷
"镜头慢了半拍才跟上动作", # 反应延迟
"自然的镜头呼吸和焦距漂移", # 呼吸感
]
out = []
for i in range(max(0, n_shots)):
if i == n_shots - 1 and n_shots > 1:
out.append("画面突然切到黑屏(录制中断)") # 结尾固定收束
else:
out.append(seq[i % len(seq)])
return out
def focus_path_hint() -> str:
"""「自动对焦路径指定法」提示(效果翻倍的关键技巧)
不要笼统写"自动对焦搜索",要给出**对焦起点和终点**。
"""
return ("【对焦路径指定(关键技巧)】不要笼统写「自动对焦搜索」——"
"要明确写出对焦的起点和终点,模型才知道对焦该在哪两个物体之间走:\n"
" ❌ 自动对焦反复搜索\n"
" ✅ 自动对焦在她脸部和她手中的西瓜之间来回切换\n"
" ✅ 自动对焦从远处山影漂移到她后背再拉回远处")
@@ -0,0 +1,376 @@
"""
AVE speed_ramp — 变速卡点引擎
功能:
1. 对视频/音频施加匀速变速
2. 缓变速度曲线: 将视频分段, 每段不同速度, 组合成渐变效果
3. 卡点策略集成: 根据能量/节拍密度生成动态速度曲线
核心: FFmpeg setpts(视频) + atempo(音频) 滤波器链
用法:
from composer.speed_ramp import speed_ramp, speed_curve
# 匀速 0.8x
speed_ramp("input.mp4", "slow.mp4", speed=0.8)
# 缓变 0.7→1.0→1.3 (6段渐变)
speed_ramp("input.mp4", "ramp.mp4", curve=[0.7, 1.0, 1.3], segments=6)
# 节拍驱动的变速 (高能段加速, 低能段减速)
speed_by_energy("input.mp4", beat_info={"segments": [...]}, output="energy_ramp.mp4")
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("speed_ramp")
# ── 常量 ──────────────────────────────────────────────────
# atempo 支持的范围: 0.5~2.0 (单次)
# 需要更极端的变速时, 级联多个 atempo
ATEMPO_MIN = 0.5
ATEMPO_MAX = 2.0
# ── 公共 API ──────────────────────────────────────────────
def speed_ramp(
input_path: str,
output_path: str = "speed_ramp.mp4",
speed: float = 1.0,
curve: list[float] | None = None,
segments: int = 6,
keep_audio: bool = True,
) -> str:
"""
变速处理
参数:
input_path: 输入视频路径
output_path: 输出视频路径
speed: 匀速倍率 (curve 为空时使用)
curve: 缓变速度曲线 [起点倍率, ..., 终点倍率] (优先于 speed)
例: [0.7, 1.0, 1.3] 表示从 0.7x 渐变到 1.3x
segments: 缓变分段数 (越多越平滑, 默认 6)
keep_audio: 是否保留音频 (默认 True)
返回: output_path
"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
duration = _get_duration(input_path)
if curve and len(curve) >= 2:
return _apply_curve(input_path, output_path, curve, segments, duration, keep_audio)
else:
return _apply_constant(input_path, output_path, speed, duration, keep_audio)
def speed_by_energy(
input_path: str,
output_path: str = "energy_ramp.mp4",
beat_segments: list[dict] | None = None,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
keep_audio: bool = True,
) -> str:
"""
按节拍能量生成变速曲线
beat_segments 格式: [{"start": 0.0, "end": 2.0, "beats": 4, "energy": 0.8}, ...]
高能量(>0.6) → 加速, 低能量(<0.3) → 减速, 中间 → 基础速度
返回: output_path
"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
if not beat_segments:
logger.warning("无节拍段落, 回落匀速")
return _apply_constant(input_path, output_path, base_speed, _get_duration(input_path), keep_audio)
# 按能量生成每段速度
temp_dir = tempfile.mkdtemp(prefix="ave_spd_")
seg_files = []
total_duration = _get_duration(input_path)
for idx, seg in enumerate(beat_segments):
start = seg["start"]
end = seg["end"]
dur = end - start
if dur < 0.3:
continue
# 根据能量决定速度 (线性插值)
energy = seg.get("energy", 0.5)
if energy > 0.6:
spd = base_speed + (high_speed - base_speed) * min((energy - 0.6) / 0.4, 1.0)
elif energy < 0.3:
spd = low_speed + (base_speed - low_speed) * (energy / 0.3)
else:
spd = base_speed
spd = max(ATEMPO_MIN, min(ATEMPO_MAX, spd))
seg_out = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
_apply_constant(input_path, seg_out, spd, dur, keep_audio, ss=start, t=dur)
seg_files.append(seg_out)
if not seg_files:
shutil.rmtree(temp_dir, ignore_errors=True)
return _apply_constant(input_path, output_path, base_speed, total_duration, keep_audio)
# 拼接
_concat_segments(seg_files, output_path, keep_audio)
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info(f"✅ 能量变速完成: {output_path} ({len(seg_files)} 段)")
return output_path
def make_energy_curve(
beat_info: dict,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
) -> list[tuple[float, float, float]]:
"""
从节拍检测结果生成能量驱动速度曲线
返回: [(start_sec, end_sec, speed), ...]
"""
segments = beat_info.get("segments", [])
if not segments:
return [(0, beat_info.get("total_duration", 30), base_speed)]
# 计算每段的归一化能量 (基于拍点密度)
max_beats = max(s.get("beats", 0) for s in segments) or 1
curves = []
for s in segments:
dur = s["end"] - s["start"]
if dur <= 0:
continue
# 拍点密度 → 能量
density = s.get("beats", 0) / max(max_beats, 1)
energy = max(0.0, min(1.0, density))
if energy > 0.6:
spd = base_speed + (high_speed - base_speed) * min((energy - 0.6) / 0.4, 1.0)
elif energy < 0.3:
spd = low_speed + (base_speed - low_speed) * (energy / 0.3)
else:
spd = base_speed
spd = max(ATEMPO_MIN, min(ATEMPO_MAX, spd))
curves.append((s["start"], s["end"], spd))
return curves
# ── 内部实现 ──────────────────────────────────────────────
def _apply_constant(
input_path: str,
output_path: str,
speed: float,
duration: float | None = None,
keep_audio: bool = True,
ss: float = 0.0,
t: float | None = None,
) -> str:
"""匀速变速"""
speed = max(ATEMPO_MIN, min(ATEMPO_MAX, speed))
vf = f"setpts={1/speed}*PTS"
cmd = [FFMPEG, "-y"]
# 如果指定了裁剪区间
if ss > 0:
cmd += ["-ss", str(ss)]
cmd += ["-i", input_path]
if t is not None:
cmd += ["-t", str(t)]
if keep_audio:
# atempo 不支持超出 [0.5, 2.0], 级联处理
af = _build_atempo_filter(speed)
# ⚠️ af 自带输入/输出标签(`[0:a]atempo=…[a]`),不能再拼一个 [a]
# (否则 filter 图非法 → 音频编码 0 字节,输出只有画面)
cmd += ["-filter_complex", f"{vf}[v];{af}"]
cmd += ["-map", "[v]", "-map", "[a]"]
else:
cmd += ["-filter_complex", f"{vf}[v]", "-map", "[v]", "-an"]
cmd += ["-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", output_path]
try:
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
logger.debug(f" 匀速 {speed:.2f}x → {output_path}")
except subprocess.CalledProcessError as e:
logger.warning(f" 变速失败: {e.stderr[:200]}")
# 回落: 不变速
subprocess.run([FFMPEG, "-y", "-i", input_path,
"-c", "copy", output_path], capture_output=True, timeout=60, check=True)
return output_path
def _apply_curve(
input_path: str,
output_path: str,
curve: list[float],
num_segments: int,
total_duration: float,
keep_audio: bool,
) -> str:
"""缓变速度曲线: 将输入切成 N 段, 每段按曲线插值变速, 再拼接"""
# 生成每段的速度
speeds = _interpolate_curve(curve, num_segments)
seg_duration = total_duration / num_segments
temp_dir = tempfile.mkdtemp(prefix="ave_curve_")
seg_files = []
for idx, spd in enumerate(speeds):
seg_start = idx * seg_duration
seg_out = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
_apply_constant(input_path, seg_out, spd, seg_duration,
keep_audio, ss=seg_start, t=seg_duration)
seg_files.append(seg_out)
# 拼接
_concat_segments(seg_files, output_path, keep_audio)
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info(f"✅ 曲线变速完成: {output_path} ({len(speeds)} 段, "
f"速度 {speeds[0]:.2f}→{speeds[-1]:.2f})")
return output_path
def _interpolate_curve(curve: list[float], num_segments: int) -> list[float]:
"""在曲线控制点之间线性插值"""
if len(curve) >= num_segments:
return curve[:num_segments]
# 在 curve 点之间均匀插值到 num_segments 个点
result = []
n_controls = len(curve) - 1
for i in range(num_segments):
# 映射到 [0, n_controls] 区间
pos = i / (num_segments - 1) * n_controls if num_segments > 1 else 0
idx_low = int(pos)
idx_high = min(idx_low + 1, n_controls)
frac = pos - idx_low
# 线性插值
val = curve[idx_low] * (1 - frac) + curve[idx_high] * frac
result.append(max(ATEMPO_MIN, min(ATEMPO_MAX, val)))
return result
def _build_atempo_filter(speed: float) -> str:
"""构建 atempo 滤波器链 (支持 [0.5, 2.0], 超范围级联)
atempo 单次只能 [0.5, 2.0].
例如 speed=3.0 → atempo=2.0, atempo=1.5
例如 speed=0.4 → atempo=0.5, atempo=0.8
"""
if ATEMPO_MIN <= speed <= ATEMPO_MAX:
return f"[0:a]atempo={speed}[a]"
# 级联: 分解为多个在范围内的 atempo
factors = []
remaining = speed
if speed > ATEMPO_MAX:
while remaining > ATEMPO_MAX:
factors.append(ATEMPO_MAX)
remaining /= ATEMPO_MAX
if remaining >= ATEMPO_MIN:
factors.append(remaining)
elif speed < ATEMPO_MIN:
while remaining < ATEMPO_MIN:
factors.append(ATEMPO_MIN)
remaining /= ATEMPO_MIN
if remaining <= ATEMPO_MAX:
factors.append(remaining)
if not factors:
factors = [speed]
# 构建级联链: [0:a]atempo=f0[a0];[a0]atempo=f1[a1];...
chain = []
prev = "0:a"
for i, f in enumerate(factors):
label = f"a{i}"
chain.append(f"[{prev}]atempo={f:.4f}[{label}]")
prev = label
return ";".join(chain)
def _concat_segments(seg_files: list[str], output_path: str, keep_audio: bool):
"""拼接变速后的视频段"""
if len(seg_files) == 1:
subprocess.run([FFMPEG, "-y", "-i", seg_files[0],
"-c", "copy", output_path], capture_output=True, timeout=60, check=True)
return
# 用 concat demuxer
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
cmd = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file]
if keep_audio:
cmd += ["-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p"]
else:
cmd += ["-c:v", "libx264", "-preset", "fast", "-crf", "23", "-an"]
cmd += [output_path]
try:
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
except subprocess.CalledProcessError as e:
logger.warning(f" 拼接失败, 回落 concat: {e.stderr[:200]}")
# 回落: filter concat
concat_filter = "".join(f"[{i}:v]" for i in range(len(seg_files)))
cmd2 = [FFMPEG, "-y"]
for sf in seg_files:
cmd2 += ["-i", sf]
cmd2 += ["-filter_complex",
f"{concat_filter}concat=n={len(seg_files)}:v=1{'a=1' if keep_audio else '':1}[v]"
+ (f";{concat_filter.replace(':v',':a')}concat=n={len(seg_files)}:v=0:a=1[a]"
if keep_audio else ""),
"-map", "[v]"]
if keep_audio:
cmd2 += ["-map", "[a]"]
cmd2 += ["-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", output_path]
subprocess.run(cmd2, capture_output=True, timeout=300, check=True)
os.unlink(concat_file)
def _get_duration(path: str) -> float:
"""获取媒体文件时长(秒)
用 lib.ffmpeg.probe_duration(ffmpeg -i 解析)——
本机 ffprobe 实为 ffmpeg 的符号链接,不支持 -show_entries(会静默失败)
"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return d if d > 0 else 30.0
except Exception:
return 30.0