feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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:
@@ -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
|
||||
Reference in New Issue
Block a user