@starreel/mcp 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env bash
2
+ # assemble.sh — 素材交接包装配参考实现 v0.1(硬切基线)
3
+ #
4
+ # 先跑通这个基线,再去改转场。它证明素材包是完整的:拼接、对白、音效、配乐、
5
+ # 字幕、响度六件事全部到位,音画同步。转场是唯一留给你改的东西。
6
+ #
7
+ # 用法:
8
+ # ./assemble.sh <包目录> [输出文件] [转场计划.json]
9
+ #
10
+ # 依赖: ffmpeg (≥4.4), ffprobe, jq, python3
11
+ set -euo pipefail
12
+
13
+ PACK="${1:?用法: assemble.sh <包目录> [输出文件] [转场计划.json]}"
14
+ OUT="${2:-episode_out.mp4}"
15
+ PLAN="${3:-}"
16
+ BUILD="${PACK}/build"
17
+ TMP="${BUILD}/tmp"
18
+ mkdir -p "${TMP}"
19
+
20
+ for bin in ffmpeg ffprobe jq python3; do
21
+ command -v "${bin}" >/dev/null || { echo "缺少依赖: ${bin}" >&2; exit 1; }
22
+ done
23
+
24
+ # 字幕烧录需要 ffmpeg 编译时启用 libass。很多发行版/homebrew 的 ffmpeg 没带,
25
+ # 且缺失时 subtitles 滤镜直接不存在 —— 必须在这里查,不能等跑完四阶段转码才崩。
26
+ if ffmpeg -hide_banner -filters 2>/dev/null | grep -qE '[[:space:]]subtitles[[:space:]]'; then
27
+ HAS_LIBASS=1
28
+ else
29
+ HAS_LIBASS=0
30
+ echo "注意: 本机 ffmpeg 未启用 libass(无 subtitles 滤镜),字幕将以软字幕轨输出而非烧录。" >&2
31
+ echo " 要烧录字幕请装带 libass 的 ffmpeg:brew install ffmpeg 或自行 --enable-libass 编译。" >&2
32
+ fi
33
+
34
+ # ── 阶段 1:编译时间轴(镜相对锚点 → 绝对时间码)────────────────────────────
35
+ echo "==> [1/6] 编译时间轴"
36
+ if [ -n "${PLAN}" ]; then
37
+ python3 "$(dirname "$0")/compile_timeline.py" "${PACK}" --transitions "${PLAN}" --outdir "${BUILD}"
38
+ else
39
+ python3 "$(dirname "$0")/compile_timeline.py" "${PACK}" --outdir "${BUILD}"
40
+ fi
41
+ OFF="${BUILD}/offsets.json"
42
+
43
+ W=$(jq -r '.render_target.width' "${OFF}")
44
+ H=$(jq -r '.render_target.height' "${OFF}")
45
+ FPS=$(jq -r '.render_target.fps' "${OFF}")
46
+ VB=$(jq -r '.render_target.video_bitrate' "${OFF}")
47
+ AB=$(jq -r '.render_target.audio_bitrate' "${OFF}")
48
+ MODE=$(jq -r '.audio_contract.mode' "${OFF}")
49
+ LN_I=$(jq -r '.audio_contract.loudnorm.I' "${OFF}")
50
+ LN_TP=$(jq -r '.audio_contract.loudnorm.TP' "${OFF}")
51
+ LN_LRA=$(jq -r '.audio_contract.loudnorm.LRA' "${OFF}")
52
+ TOTAL_MS=$(jq -r '.total_ms' "${OFF}")
53
+ # 调色查找表:平台的 LUT 在终拼时施加,裸片是未调色的。有它就在规范化这一步顺手烤进去,
54
+ # 成片色彩才与平台一致。用 filter_complex 的真实输入而不是 movie= —— 后者要对路径里的
55
+ # 空格/冒号做转义,包目录一旦带空格就会静默失败。
56
+ LUT_REL=$(jq -r '.render_target.color_lut.file // empty' "${OFF}")
57
+ LUT_FILE=""
58
+ if [ -n "${LUT_REL}" ] && [ -f "${PACK}/${LUT_REL}" ]; then
59
+ LUT_FILE="${PACK}/${LUT_REL}"
60
+ echo " 调色查找表: ${LUT_REL}"
61
+ fi
62
+ NSHOTS=$(jq -r '.shots | length' "${OFF}")
63
+
64
+ # 有无重叠决定阶段 3 走哪条路:全硬切走 concat demuxer 快路径(-c copy,秒级);
65
+ # 一旦有重叠就必须整图 filter_complex 重编码。
66
+ MAXOV=$(jq -r '[.shots[].overlap_ms] | max' "${OFF}")
67
+ [ "${MAXOV}" = "null" ] && MAXOV=0
68
+
69
+ ms2s() { awk -v m="$1" 'BEGIN{printf "%.3f", m/1000}'; }
70
+
71
+ # ── 阶段 2:逐镜规范化(trim + 统一编码 + 组装该镜音轨)─────────────────────
72
+ echo "==> [2/6] 规范化 ${NSHOTS} 个镜头"
73
+ : > "${TMP}/concat.txt"
74
+ for i in $(seq 0 $((NSHOTS - 1))); do
75
+ S=$(jq -c ".shots[${i}]" "${OFF}")
76
+ N=$(jq -r '.shot_number' <<<"${S}")
77
+ SRC="${PACK}/$(jq -r '.file' <<<"${S}")"
78
+ SS=$(ms2s "$(jq -r '.trim_head_ms' <<<"${S}")")
79
+ DUR=$(ms2s "$(jq -r '.duration_ms' <<<"${S}")")
80
+ BAKED=$(jq -r '.has_baked_audio' <<<"${S}")
81
+ DST="${TMP}/shot_$(printf '%03d' "${N}").mkv"
82
+
83
+ # 中间产物一律 mkv + pcm_s16le:aac 每段都带 ~21ms(1024 samples) 编码器 priming
84
+ # delay,走 adelay/amix 时每镜音频会整体提前约半帧,重叠转场下误差还会叠加。
85
+ # 只在阶段 4 最后一次编 aac。
86
+ # 视频:先 trim 再统一到目标画布/帧率。scale+pad 保证不同源尺寸也能拼。
87
+ VF="scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS}"
88
+
89
+ # baked-cut(闪白/闪黑):硬切 + 本镜片头快速过曝/压黑,**不重叠**。
90
+ # 平台实测:这类效果走 xfade 会变成双重闪 + 130ms 重叠 + 口型/音频瞬断,
91
+ # 所以它烤在片头、不进 xfade 链。
92
+ RENDER=$(jq -r '.transition.render // "cut"' <<<"${S}")
93
+ if [ "${RENDER}" = "baked-cut" ]; then
94
+ FLASH=$(jq -r '.transition.flash // "white"' <<<"${S}")
95
+ FMS=$(jq -r '.transition.duration_ms // 130' <<<"${S}")
96
+ VF="${VF},fade=t=in:st=0:d=$(ms2s "${FMS}"):color=${FLASH}"
97
+ fi
98
+
99
+ INPUTS=(-ss "${SS}" -t "${DUR}" -i "${SRC}")
100
+ if [ "${MODE}" = "clip" ] && [ "${BAKED}" = "true" ]; then
101
+ # 裸片自带人声:直接用它的音轨,绝不再叠对白(叠了就是双声)
102
+ if [ -n "${LUT_FILE}" ]; then
103
+ ffmpeg -hide_banner -loglevel error -y "${INPUTS[@]}" -i "${LUT_FILE}" \
104
+ -filter_complex "[0:v]${VF}[g];[g][1:v]haldclut[v]" \
105
+ -map "[v]" -map 0:a:0? \
106
+ -c:v libx264 -b:v "${VB}" -pix_fmt yuv420p -c:a pcm_s16le -ar 48000 -ac 2 \
107
+ -shortest "${DST}"
108
+ else
109
+ ffmpeg -hide_banner -loglevel error -y "${INPUTS[@]}" \
110
+ -vf "${VF}" -map 0:v:0 -map 0:a:0? \
111
+ -c:v libx264 -b:v "${VB}" -pix_fmt yuv420p -c:a pcm_s16le -ar 48000 -ac 2 \
112
+ -shortest "${DST}"
113
+ fi
114
+ else
115
+ # TTS 模式:裸片无人声。静音底 + 对白 + 音效,全部按镜内 offset 落位。
116
+ # 输入索引是固定契约:0=静音底 1=视频 2..=对白/音效(按下面的追加顺序)
117
+ INPUTS=(-f lavfi -t "${DUR}" -i "anullsrc=r=48000:cl=stereo"
118
+ -ss "${SS}" -t "${DUR}" -i "${SRC}")
119
+ FILTER=""; LABELS="[0:a]"; IDX=2
120
+
121
+ DLG=$(jq -c '.dialogue // empty' <<<"${S}")
122
+ if [ -n "${DLG}" ] && [ "${DLG}" != "null" ]; then
123
+ DOFF=$(jq -r '.offset_ms // 0' <<<"${DLG}")
124
+ DGAIN=$(jq -r '.gain_db // 0' <<<"${DLG}")
125
+ INPUTS+=(-i "${PACK}/$(jq -r '.file' <<<"${DLG}")")
126
+ FILTER+="[${IDX}:a]adelay=${DOFF}|${DOFF},volume=${DGAIN}dB[d];"
127
+ LABELS+="[d]"; IDX=$((IDX + 1))
128
+ fi
129
+
130
+ NSFX=$(jq -r '.sfx | length' <<<"${S}")
131
+ for ((j = 0; j < NSFX; j++)); do
132
+ X=$(jq -c ".sfx[${j}]" <<<"${S}")
133
+ XOFF=$(jq -r '.offset_ms // 0' <<<"${X}")
134
+ XGAIN=$(jq -r '.gain_db // 0' <<<"${X}")
135
+ INPUTS+=(-i "${PACK}/$(jq -r '.file' <<<"${X}")")
136
+ FILTER+="[${IDX}:a]adelay=${XOFF}|${XOFF},volume=${XGAIN}dB[x${j}];"
137
+ LABELS+="[x${j}]"; IDX=$((IDX + 1))
138
+ done
139
+
140
+ # 混入静音底:amix 的 inputs = 底轨 + 对白 + 音效,normalize=0 保住各自增益
141
+ NMIX=$((IDX - 1))
142
+ FILTER+="${LABELS}amix=inputs=${NMIX}:normalize=0:dropout_transition=0[aout]"
143
+
144
+ if [ -n "${LUT_FILE}" ]; then
145
+ INPUTS+=(-i "${LUT_FILE}")
146
+ ffmpeg -hide_banner -loglevel error -y "${INPUTS[@]}" \
147
+ -filter_complex "${FILTER};[1:v]${VF}[g];[g][${IDX}:v]haldclut[v]" \
148
+ -map "[v]" -map "[aout]" -t "${DUR}" \
149
+ -c:v libx264 -b:v "${VB}" -pix_fmt yuv420p -c:a pcm_s16le -ar 48000 -ac 2 \
150
+ "${DST}"
151
+ else
152
+ ffmpeg -hide_banner -loglevel error -y "${INPUTS[@]}" \
153
+ -filter_complex "${FILTER}" \
154
+ -map 1:v:0 -vf "${VF}" -map "[aout]" -t "${DUR}" \
155
+ -c:v libx264 -b:v "${VB}" -pix_fmt yuv420p -c:a pcm_s16le -ar 48000 -ac 2 \
156
+ "${DST}"
157
+ fi
158
+ fi
159
+ echo "file '$(cd "$(dirname "${DST}")" && pwd)/$(basename "${DST}")'" >> "${TMP}/concat.txt"
160
+ done
161
+
162
+ # ── 阶段 3:拼接 ───────────────────────────────────────────────────────────
163
+ if [ "${MAXOV}" = "0" ]; then
164
+ echo "==> [3/6] 拼接(全硬切,concat 快路径)"
165
+ ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "${TMP}/concat.txt" \
166
+ -c copy "${TMP}/cut.mkv"
167
+ else
168
+ echo "==> [3/6] 拼接(含重叠转场,xfade 链)"
169
+ # 视频:xfade 链。**offset 直接取编译好的 in_ms,绝不手算累加** —— 每个重叠都会
170
+ # 让其后所有镜前移,手算的累积误差是这条路最常见的翻车原因。
171
+ # 音频:不做 crossfade,每镜音轨 adelay 到自己的 in_ms 再 amix。这样音频时间轴
172
+ # 与 xfade 后的视频天然一致;重叠量若落在上一镜镜末静音内,对白完全不受影响。
173
+ VIN=(); VPRE=""; VCHAIN=""; ACHAIN=""; ALABELS=""; CUR="[p0]"
174
+ for i in $(seq 0 $((NSHOTS - 1))); do
175
+ S=$(jq -c ".shots[${i}]" "${OFF}")
176
+ N=$(jq -r '.shot_number' <<<"${S}")
177
+ VIN+=(-i "${TMP}/shot_$(printf '%03d' "${N}").mkv")
178
+ IN_MS=$(jq -r '.in_ms' <<<"${S}")
179
+ # settb=AVTB 是 xfade 的硬前提:xfade 要求两路输入 timebase 完全一致,而
180
+ # concat filter 输出 1/1000000、解码器给的常是 1/12288 —— 不统一直接报
181
+ # "timebase do not match"。★顺序不能反:fps 滤镜会把 timebase 重设成 1/fps,
182
+ # 写成 settb,fps 等于白设,必须 fps 在前、settb 收尾。
183
+ VPRE+="[${i}:v]fps=${FPS},format=yuv420p,settb=AVTB[p${i}];"
184
+ ACHAIN+="[${i}:a]adelay=${IN_MS}|${IN_MS}[a${i}];"
185
+ ALABELS+="[a${i}]"
186
+ [ "${i}" -eq 0 ] && continue
187
+ OV=$(jq -r '.overlap_ms' <<<"${S}")
188
+ if [ "${OV}" -gt 0 ]; then
189
+ XF=$(jq -r '.transition.xfade // "fade"' <<<"${S}")
190
+ VCHAIN+="${CUR}[p${i}]xfade=transition=${XF}:duration=$(ms2s "${OV}"):offset=$(ms2s "${IN_MS}")[v${i}];"
191
+ else
192
+ VCHAIN+="${CUR}[p${i}]concat=n=2:v=1:a=0[v${i}];"
193
+ fi
194
+ CUR="[v${i}]"
195
+ done
196
+ ACHAIN+="${ALABELS}amix=inputs=${NSHOTS}:normalize=0:dropout_transition=0[aout]"
197
+ ffmpeg -hide_banner -loglevel error -y "${VIN[@]}" \
198
+ -filter_complex "${VPRE}${VCHAIN}${ACHAIN}" \
199
+ -map "${CUR}" -map "[aout]" \
200
+ -c:v libx264 -b:v "${VB}" -pix_fmt yuv420p -c:a pcm_s16le -ar 48000 -ac 2 \
201
+ "${TMP}/cut.mkv"
202
+ fi
203
+
204
+ # ── 阶段 4:配乐 + 侧链避让 + 响度 ─────────────────────────────────────────
205
+ echo "==> [4/6] 配乐与混音"
206
+ # 限幅阈值 = 10^(TP/20),与母带的 TP 目标同一个数
207
+ LIMIT=$(awk -v tp="${LN_TP}" 'BEGIN{printf "%.4f", 10 ^ (tp/20)}')
208
+ NBGM=$(jq -r '.bgm | length' "${OFF}")
209
+ if [ "${NBGM}" -gt 0 ]; then
210
+ BI=(); BF=""; BL=""
211
+ for i in $(seq 0 $((NBGM - 1))); do
212
+ B=$(jq -c ".bgm[${i}]" "${OFF}")
213
+ BI+=(-i "${PACK}/$(jq -r '.file' <<<"${B}")")
214
+ BOFF=$(jq -r '.in_ms' <<<"${B}")
215
+ BGAIN=$(jq -r '.gain_db' <<<"${B}")
216
+ IDX=$((i + 1))
217
+ BF+="[${IDX}:a]adelay=${BOFF}|${BOFF},volume=${BGAIN}dB[b${i}];"
218
+ BL+="[b${i}]"
219
+ done
220
+ BF+="${BL}amix=inputs=${NBGM}:normalize=0[bgm];"
221
+ # 侧链源必须是**只有人声的总线**,不能用前景总轨。音效混在 sidechain 里会
222
+ # 让打击音/环境音也把 BGM 压下去,成片听感是配乐一惊一乍。平台侧链的 key
223
+ # 取自 voicebus(对白+旁白),这里照做:直接用包里的对白 wav 另拼一条 key 轨。
224
+ TH=$(jq -r '.audio_contract.ducking.threshold // 0.06' "${OFF}")
225
+ RT=$(jq -r '.audio_contract.ducking.ratio // 6' "${OFF}")
226
+ AT=$(jq -r '.audio_contract.ducking.attack_ms // 20' "${OFF}")
227
+ RL=$(jq -r '.audio_contract.ducking.release_ms // 350' "${OFF}")
228
+ MU=$(jq -r '.audio_contract.ducking.makeup // 1' "${OFF}")
229
+ DUCK_ARGS="threshold=${TH}:ratio=${RT}:attack=${AT}:release=${RL}:makeup=${MU}"
230
+
231
+ # 对白 key 轨:每条对白 adelay 到「本镜入点 + 镜内 offset」。
232
+ KEY_IN=(); KEYF=""; KEYL=""; KN=0
233
+ KIDX=$((NBGM + 1)) # 输入 0 是拼好的画面,1..NBGM 是配乐,对白从这里接着排
234
+ for i in $(seq 0 $((NSHOTS - 1))); do
235
+ S=$(jq -c ".shots[${i}]" "${OFF}")
236
+ DF=$(jq -r '.dialogue.file // empty' <<<"${S}")
237
+ [ -z "${DF}" ] && continue
238
+ DABS=$(( $(jq -r '.in_ms' <<<"${S}") + $(jq -r '.dialogue.offset_ms // 0' <<<"${S}") ))
239
+ KEY_IN+=(-i "${PACK}/${DF}")
240
+ KEYF+="[${KIDX}:a]adelay=${DABS}|${DABS}[k${KN}];"
241
+ KEYL+="[k${KN}]"; KN=$((KN + 1)); KIDX=$((KIDX + 1))
242
+ done
243
+
244
+ if [ "${KN}" -gt 0 ]; then
245
+ BF+="${KEYF}${KEYL}amix=inputs=${KN}:normalize=0[voicekey];"
246
+ BF+="[bgm][voicekey]sidechaincompress=${DUCK_ARGS}[duck];"
247
+ BF+="[0:a][duck]amix=inputs=2:normalize=0[mix];"
248
+ else
249
+ # 原声(clip)模式:人声烤在画面音轨里,拆不出独立 key —— 只能拿前景总轨当
250
+ # sidechain,音效会误触发避让。要精确避让就得回平台侧终拼。
251
+ BF+="[0:a]asplit=2[fg][sc];"
252
+ BF+="[bgm][sc]sidechaincompress=${DUCK_ARGS}[duck];"
253
+ BF+="[fg][duck]amix=inputs=2:normalize=0[mix];"
254
+ fi
255
+ # 这里**不做 loudnorm**。单 pass loudnorm 是动态归一化:对白间隙里 BGM 成了
256
+ # 唯一能量源,会被逐段上提到目标响度,把混音里刻意压低的 BGM"补偿"回可闻——
257
+ # 平台生产实锤过"BGM 增益 -45 和 -57 出来一样响、调几轮没变化"。响度统一交给
258
+ # 阶段 6 的两 pass 线性母带(全片恒定增益、轨间比例保持)。这里只削峰防削波。
259
+ BF+="[mix]alimiter=limit=${LIMIT}:level=0[amaster]"
260
+ ffmpeg -hide_banner -loglevel error -y -i "${TMP}/cut.mkv" "${BI[@]}" ${KEY_IN[@]+"${KEY_IN[@]}"} \
261
+ -filter_complex "${BF}" -map 0:v -map "[amaster]" \
262
+ -c:v copy -c:a aac -b:a "${AB}" -shortest "${TMP}/mixed.mp4"
263
+ else
264
+ ffmpeg -hide_banner -loglevel error -y -i "${TMP}/cut.mkv" \
265
+ -af "alimiter=limit=${LIMIT}:level=0" \
266
+ -c:v copy -c:a aac -b:a "${AB}" "${TMP}/mixed.mp4"
267
+ fi
268
+
269
+ # ── 阶段 5:字幕烧录 ───────────────────────────────────────────────────────
270
+ echo "==> [5/6] 字幕"
271
+ SRT="${BUILD}/episode.srt"
272
+ if [ ! -s "${SRT}" ]; then
273
+ cp "${TMP}/mixed.mp4" "${TMP}/subbed.mp4"
274
+ elif [ "${HAS_LIBASS}" = "1" ]; then
275
+ FONTDIR="${PACK}/fonts"
276
+ MARGIN=$(jq -r '.subtitle_contract.margin_v // 80' "${PACK}/manifest.json")
277
+ # 字体必须来自包内 fonts/ —— 你的机器上没有我方字体时会静默退回默认字体,
278
+ # 成片字幕观感与预期不符,且不会报错。
279
+ ffmpeg -hide_banner -loglevel error -y -i "${TMP}/mixed.mp4" \
280
+ -vf "subtitles=${SRT}:fontsdir=${FONTDIR}:force_style='MarginV=${MARGIN}'" \
281
+ -c:v libx264 -b:v "${VB}" -pix_fmt yuv420p -c:a copy "${TMP}/subbed.mp4"
282
+ else
283
+ # 降级:软字幕轨。播放器能开关,但不是发布用的烧录版 —— 竖屏平台一律要烧录。
284
+ ffmpeg -hide_banner -loglevel error -y -i "${TMP}/mixed.mp4" -i "${SRT}" \
285
+ -map 0 -map 1 -c copy -c:s mov_text "${TMP}/subbed.mp4"
286
+ echo " ⚠️ 字幕以软轨输出(本机无 libass),发布前需在带 libass 的机器上重跑烧录。" >&2
287
+ fi
288
+
289
+ # ── 阶段 6:两 pass 线性母带 ────────────────────────────────────────────────
290
+ echo "==> [6/6] 响度母带"
291
+ # pass1 只测量,pass2 带 measured_* + linear=true 施加**恒定**增益。
292
+ # LRA 用 7(交付母带刻意比逐镜混音的 11 收窄动态),与平台终拼一致。
293
+ # 测量失败就回退单 pass 动态模式 —— 有瑕疵但不阻塞交付,与平台行为一致。
294
+ MASTER_LRA=7
295
+ STATS=$(ffmpeg -hide_banner -nostats -i "${TMP}/subbed.mp4" -vn \
296
+ -af "loudnorm=I=${LN_I}:TP=${LN_TP}:LRA=${MASTER_LRA}:print_format=json" \
297
+ -f null /dev/null 2>&1 | sed -n '/^{/,/^}/p')
298
+
299
+ if [ -n "${STATS}" ] && jq -e '.input_i' >/dev/null 2>&1 <<<"${STATS}"; then
300
+ MI=$(jq -r '.input_i' <<<"${STATS}")
301
+ MTP=$(jq -r '.input_tp' <<<"${STATS}")
302
+ MLRA=$(jq -r '.input_lra' <<<"${STATS}")
303
+ MTH=$(jq -r '.input_thresh'<<<"${STATS}")
304
+ MOFF=$(jq -r '.target_offset' <<<"${STATS}")
305
+ MASTER_AF="loudnorm=I=${LN_I}:TP=${LN_TP}:LRA=${MASTER_LRA}:measured_I=${MI}:measured_TP=${MTP}:measured_LRA=${MLRA}:measured_thresh=${MTH}:offset=${MOFF}:linear=true"
306
+ echo " 测得 I=${MI} TP=${MTP} LRA=${MLRA} → 线性归一到 ${LN_I} LUFS"
307
+ else
308
+ MASTER_AF="loudnorm=I=${LN_I}:TP=${LN_TP}:LRA=${MASTER_LRA}"
309
+ echo " ⚠️ pass1 测量失败,回退单 pass 动态模式(BGM 相对响度可能被上提)" >&2
310
+ fi
311
+
312
+ # +faststart 必须带:本 pass 是最终写盘者,不带会把 moov 甩回文件尾,
313
+ # 浏览器流播开播慢/卡顿。
314
+ ffmpeg -hide_banner -loglevel error -y -i "${TMP}/subbed.mp4" \
315
+ -c:v copy -af "${MASTER_AF}" -c:a aac -b:a "${AB}" -ar 48000 \
316
+ -movflags +faststart "${OUT}"
317
+
318
+ # ── 自检 ───────────────────────────────────────────────────────────────────
319
+ VD=$(ffprobe -v error -select_streams v:0 -show_entries stream=duration -of csv=p=0 "${OUT}")
320
+ AD=$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of csv=p=0 "${OUT}")
321
+ echo "完成: ${OUT}"
322
+ echo " 预期 $(ms2s "${TOTAL_MS}")s · 视频 ${VD}s · 音频 ${AD}s"
323
+ awk -v v="${VD}" -v a="${AD}" 'BEGIN{ d=v-a; if(d<0)d=-d; if(d>0.5){print " ⚠️ 音画时长差 " d "s(>0.5s 视为不同步,检查 trim 与 offset 换算)"; exit 1} }'
324
+
325
+ # ═══════════════════════════════════════════════════════════════════════════
326
+ # 加转场:写一份 plan.json 作为第三参数传进来。
327
+ #
328
+ # { "transitions": [
329
+ # { "before_shot": 3, "type": "fade" }, // 用预设规范时长
330
+ # { "before_shot": 7, "type": "flash_white" }, // baked-cut,自动不重叠
331
+ # { "before_shot": 9, "type": "whip_pan", "overlap_ms": 160 } // 显式覆盖时长
332
+ # ] }
333
+ #
334
+ # type 接受三种写法:预设 id(fade / flash_white / whip_pan …)、导演语义键
335
+ # (match_cut / smash_cut / cross_dissolve …)、或直接给 ffmpeg xfade 名。
336
+ # 未知值退回 fade 而不是报错。
337
+ #
338
+ # 编译器会自动处理:其后所有镜的入点前移、字幕/音效/BGM 同步偏移、重叠超预算钳制、
339
+ # 重叠压到对白时告警、下架转场(长叠化卡壳)告警。你不需要手算任何时间。
340
+ # ═══════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ compile_timeline.py — 镜相对锚点 → 绝对时间码 编译器(参考实现 v0.1)
4
+
5
+ 素材包里的字幕/对白/音效时间全部是「相对该镜 trim 后起点」的。第三方自己拼片、
6
+ 自己加转场后,用本脚本把它们展开成自己时间轴上的绝对时间码。
7
+
8
+ 核心公式(唯一真相,改转场只改这里):
9
+ in_ms[0] = 0
10
+ in_ms[i] = in_ms[i-1] + duration_ms[i-1] - overlap_ms[i]
11
+ 其中 overlap_ms[i] = 第 i 镜与上一镜的画面重叠量(硬切=0,叠化=转场时长)。
12
+ 重叠会让其后每一镜整体提前,误差累积——所以绝不要手算,交给这个脚本。
13
+
14
+ 用法:
15
+ python3 compile_timeline.py <包目录> [--transitions plan.json] [--outdir build]
16
+
17
+ 转场计划 plan.json(可选,不给则全片硬切):
18
+ { "transitions": [ { "before_shot": 3, "type": "dissolve", "overlap_ms": 300 } ] }
19
+ before_shot = 该镜与它上一镜之间的转场;overlap_ms 只对重叠式转场 > 0。
20
+
21
+ 产物:
22
+ build/offsets.json 每镜绝对入点 + 总时长 + BGM 入点(assemble.sh 消费)
23
+ build/episode.srt 整集字幕(已按各镜入点偏移合并)
24
+ """
25
+ import argparse, json, os, re, sys
26
+
27
+ # ── 转场预设表 ─────────────────────────────────────────────────────────────
28
+ # 与平台 services/timeline/transition-presets.ts 保持一致。改平台那份时同步这里。
29
+ # xfade = ffmpeg xfade transition 名
30
+ # render = "xfade"(两镜重叠)| "baked-cut"(硬切 + 入场闪光,**不重叠**)
31
+ PRESETS = {
32
+ "cut": {"xfade": None, "ms": 0, "render": "cut"},
33
+ "fade": {"xfade": "fade", "ms": 500, "render": "xfade"},
34
+ "dissolve": {"xfade": "dissolve", "ms": 700, "render": "xfade"},
35
+ "fade_black": {"xfade": "fadeblack", "ms": 600, "render": "xfade"},
36
+ "wipe_left": {"xfade": "wipeleft", "ms": 500, "render": "xfade"},
37
+ "circle_open": {"xfade": "circleopen", "ms": 600, "render": "xfade"},
38
+ "flash_white": {"xfade": "fadewhite", "ms": 130, "render": "baked-cut", "flash": "white"},
39
+ "flash_black": {"xfade": "fadeblack", "ms": 130, "render": "baked-cut", "flash": "black"},
40
+ "zoom_punch": {"xfade": "zoomin", "ms": 350, "render": "xfade"},
41
+ "whip_pan": {"xfade": "hrwind", "ms": 130, "render": "xfade"},
42
+ "glass_shatter":{"xfade": "distance", "ms": 500, "render": "xfade"},
43
+ "swirl": {"xfade": "radial", "ms": 700, "render": "xfade"},
44
+ "invert_flash": {"xfade": "fadeblack", "ms": 320, "render": "xfade"},
45
+ "radial_open": {"xfade": "radial", "ms": 650, "render": "xfade"},
46
+ }
47
+
48
+ # Wave11 导演语义键 → 预设 id。j_cut / l_cut 是纯音轨编辑,视觉上就是硬切。
49
+ W11_TO_PRESET = {
50
+ "hard_cut": "cut", "j_cut": "cut", "l_cut": "cut",
51
+ "match_cut": "dissolve", "graphic_match": "dissolve", "cross_dissolve": "dissolve",
52
+ "whip_pan_trans": "whip_pan", "smash_cut": "flash_white",
53
+ "iris_in": "circle_open", "time_lapse_in": "dissolve",
54
+ }
55
+
56
+ # 平台 v0.9.81 下架:叠化 ≥600ms 的 xfade 类在**有对白的接缝**会做长音频交叉淡化 →
57
+ # 卡壳(客户实测确认)。这里不禁用,但会警告并给出安全等价替代。
58
+ DEPRECATED = {"dissolve", "fade_black", "circle_open", "swirl", "radial_open"}
59
+ SAFE_REPLACEMENT = {
60
+ "dissolve": "fade", "circle_open": "fade",
61
+ "swirl": "flash_black", "fade_black": "flash_black", "radial_open": "flash_black",
62
+ }
63
+
64
+
65
+ def resolve_preset(name):
66
+ """转场名(预设 id / Wave11 语义键 / 裸 xfade 名)→ 预设。未知退 fade。"""
67
+ k = str(name or "").strip().lower()
68
+ if not k:
69
+ return "cut", PRESETS["cut"]
70
+ if k in W11_TO_PRESET:
71
+ k = W11_TO_PRESET[k]
72
+ if k in PRESETS:
73
+ return k, PRESETS[k]
74
+ for pid, p in PRESETS.items(): # 允许直接给 ffmpeg xfade 名
75
+ if p["xfade"] == k:
76
+ return pid, p
77
+ return "fade", PRESETS["fade"]
78
+
79
+
80
+ # ── SRT ────────────────────────────────────────────────────────────────────
81
+ TS = re.compile(r"(\d+):(\d{2}):(\d{2})[,.](\d{3})")
82
+
83
+
84
+ def parse_srt(path):
85
+ """→ [(start_ms, end_ms, text)]。时间码基准 = 该镜 trim 后的第 0 毫秒。"""
86
+ if not path or not os.path.exists(path):
87
+ return []
88
+ blocks = re.split(r"\n\s*\n", open(path, encoding="utf-8-sig").read().strip())
89
+ cues = []
90
+ for b in blocks:
91
+ lines = [l for l in b.splitlines() if l.strip()]
92
+ if len(lines) < 2:
93
+ continue
94
+ arrow = next((l for l in lines if "-->" in l), None)
95
+ if not arrow:
96
+ continue
97
+ stamps = TS.findall(arrow)
98
+ if len(stamps) != 2:
99
+ continue
100
+ to_ms = lambda t: (int(t[0]) * 3600 + int(t[1]) * 60 + int(t[2])) * 1000 + int(t[3])
101
+ text = "\n".join(lines[lines.index(arrow) + 1:])
102
+ cues.append((to_ms(stamps[0]), to_ms(stamps[1]), text))
103
+ return cues
104
+
105
+
106
+ def fmt_ms(ms):
107
+ ms = max(0, int(ms))
108
+ h, ms = divmod(ms, 3600000)
109
+ m, ms = divmod(ms, 60000)
110
+ s, ms = divmod(ms, 1000)
111
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
112
+
113
+
114
+ def write_srt(path, cues):
115
+ with open(path, "w", encoding="utf-8") as f:
116
+ for i, (a, b, t) in enumerate(cues, 1):
117
+ f.write(f"{i}\n{fmt_ms(a)} --> {fmt_ms(b)}\n{t}\n\n")
118
+
119
+
120
+ # ── 编译 ───────────────────────────────────────────────────────────────────
121
+ def compile_timeline(pack_dir, plan_path=None, outdir=None):
122
+ manifest = json.load(open(os.path.join(pack_dir, "manifest.json"), encoding="utf-8"))
123
+ if manifest.get("manifest_version") != "0.1":
124
+ sys.exit(f"不认识的 manifest_version: {manifest.get('manifest_version')!r},拒绝猜测")
125
+
126
+ overlaps, trans = {}, {}
127
+ if plan_path:
128
+ plan = json.load(open(plan_path, encoding="utf-8"))
129
+ for t in plan.get("transitions", []):
130
+ n = int(t["before_shot"])
131
+ pid, p = resolve_preset(t.get("type"))
132
+ # overlap 缺省取预设的规范时长;baked-cut 强制 0(硬切 + 入场闪光)
133
+ ov = int(t["overlap_ms"]) if t.get("overlap_ms") is not None else p["ms"]
134
+ if p["render"] != "xfade":
135
+ ov = 0
136
+ overlaps[n] = max(0, ov)
137
+ trans[n] = {
138
+ "preset_id": pid, "xfade": p["xfade"],
139
+ "render": p["render"], "flash": p.get("flash"),
140
+ }
141
+
142
+ rules = manifest.get("assembly_rules", {})
143
+ cap = int(rules.get("transition_budget_ms_max", 400))
144
+ floor = int(rules.get("min_shot_duration_ms", 800))
145
+
146
+ shots, cursor, cues, warnings = [], 0, [], []
147
+ prev_dur = None
148
+ for sh in manifest["shots"]:
149
+ n = int(sh["shot_number"])
150
+ dur = int(sh["clip"]["duration_ms"])
151
+ ov = overlaps.get(n, 0)
152
+
153
+ if prev_dur is not None and ov:
154
+ # 重叠不得超过预算,也不得吃掉相邻任一镜的最短时长
155
+ limit = min(cap, prev_dur - floor, dur - floor)
156
+ if ov > max(0, limit):
157
+ warnings.append(f"镜 {n}: 重叠 {ov}ms 超限,钳到 {max(0, limit)}ms")
158
+ ov = max(0, limit)
159
+ # 可借冗余 = 上一镜的「镜末静音留白 + 被掐掉的尾部」。
160
+ # ★ silence_after_ms 默认是 0(authoring 字段,只有 AI 填过或项目设了
161
+ # pacing_curve 才非零),所以真正能借的通常是 trim_tail_ms 那 250ms。
162
+ # 重叠超出这个预算 = 重叠期压在对白上,声画归属模糊。
163
+ prev = shots[-1]
164
+ budget = int(prev.get("silence_after_ms", 0)) + int(prev.get("trim_tail_ms", 0))
165
+ if ov > budget:
166
+ warnings.append(
167
+ f"镜 {n}: 重叠 {ov}ms 超出上一镜可借冗余 {budget}ms"
168
+ f"(静音留白 {prev.get('silence_after_ms', 0)} + 掐掉的尾部 {prev.get('trim_tail_ms', 0)}),"
169
+ f"重叠期会压到对白,建议降到 {budget}ms 或改硬切")
170
+ cursor -= ov
171
+
172
+ in_ms = cursor if prev_dur is not None else 0
173
+ tr = dict(trans.get(n) or {"preset_id": "cut", "xfade": None, "render": "cut", "flash": None})
174
+ if tr["preset_id"] in DEPRECATED:
175
+ warnings.append(
176
+ f"镜 {n}: 转场 {tr['preset_id']} 已在平台下架(长叠化在有对白接缝会卡壳),"
177
+ f"建议改用 {SAFE_REPLACEMENT.get(tr['preset_id'], 'fade')}")
178
+ tr["duration_ms"] = ov if tr["render"] == "xfade" else (
179
+ PRESETS[tr["preset_id"]]["ms"] if tr["render"] == "baked-cut" else 0)
180
+
181
+ shots.append({
182
+ "shot_number": n,
183
+ "transition": tr,
184
+ "file": sh["clip"]["file"],
185
+ "trim_head_ms": int(sh["clip"].get("trim_head_ms", 0)),
186
+ "trim_tail_ms": int(sh["clip"].get("trim_tail_ms", 0)),
187
+ "duration_ms": dur,
188
+ "in_ms": in_ms,
189
+ "overlap_ms": ov,
190
+ "has_baked_audio": bool(sh["clip"].get("has_baked_audio")),
191
+ "silence_after_ms": int(sh.get("padding", {}).get("silence_after_ms", 0)),
192
+ "dialogue": sh.get("dialogue_audio"),
193
+ "sfx": sh.get("sfx", []),
194
+ })
195
+
196
+ # 字幕:镜内相对 → 绝对。一次加法,位移问题在此消失。
197
+ sub = (sh.get("subtitle") or {}).get("file")
198
+ for a, b, t in parse_srt(os.path.join(pack_dir, sub) if sub else None):
199
+ cues.append((in_ms + a, in_ms + b, t))
200
+
201
+ cursor = in_ms + dur
202
+ prev_dur = dur
203
+
204
+ total = cursor
205
+ by_n = {s["shot_number"]: s for s in shots}
206
+ bgm = []
207
+ for b in manifest.get("bgm", []):
208
+ anchor = by_n.get(int(b.get("anchor_shot", 1)))
209
+ if not anchor:
210
+ warnings.append(f"BGM {b.get('file')}: anchor_shot 不存在,按 0 处理")
211
+ bgm.append({
212
+ "file": b["file"],
213
+ "in_ms": (anchor["in_ms"] if anchor else 0) + int(b.get("anchor_offset_ms", 0)),
214
+ "gain_db": float(b.get("gain_db", -16)),
215
+ "fade_in_ms": int(b.get("fade_in_ms", 0)),
216
+ "fade_out_ms": int(b.get("fade_out_ms", 0)),
217
+ "loop": bool(b.get("loop", True)),
218
+ })
219
+
220
+ out = {
221
+ "total_ms": total,
222
+ "render_target": manifest["render_target"],
223
+ "audio_contract": manifest["audio_contract"],
224
+ "shots": shots,
225
+ "bgm": bgm,
226
+ "warnings": warnings,
227
+ }
228
+ outdir = outdir or os.path.join(pack_dir, "build")
229
+ os.makedirs(outdir, exist_ok=True)
230
+ json.dump(out, open(os.path.join(outdir, "offsets.json"), "w", encoding="utf-8"),
231
+ ensure_ascii=False, indent=2)
232
+ write_srt(os.path.join(outdir, "episode.srt"), sorted(cues, key=lambda c: c[0]))
233
+
234
+ print(f"总时长 {total/1000:.2f}s · {len(shots)} 镜 · {len(cues)} 条字幕 → {outdir}")
235
+ for w in warnings:
236
+ print(" ⚠️ " + w, file=sys.stderr)
237
+ return out
238
+
239
+
240
+ if __name__ == "__main__":
241
+ ap = argparse.ArgumentParser()
242
+ ap.add_argument("pack_dir")
243
+ ap.add_argument("--transitions")
244
+ ap.add_argument("--outdir")
245
+ a = ap.parse_args()
246
+ compile_timeline(a.pack_dir, a.transitions, a.outdir)