@starreel/mcp 0.1.41 → 0.1.42
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.
- package/SKILL.md +12 -0
- package/assets/handoff/assemble.sh +340 -0
- package/assets/handoff/compile_timeline.py +246 -0
- package/assets/handoff/fetch_pack.py +178 -0
- package/dist/path-guard.js +39 -0
- package/dist/tools/produce.js +115 -1
- package/package.json +4 -3
- package/server.json +2 -2
package/SKILL.md
CHANGED
|
@@ -283,6 +283,18 @@ refund on failure); you never compensate manually.
|
|
|
283
283
|
`generate_bgm`, `replace_shot_dialogue`
|
|
284
284
|
- **Finish**: `compose_episode`, `get_final_cut`, `get_export`,
|
|
285
285
|
`generate_episode_poster`, `generate_cover`
|
|
286
|
+
- **Assemble it yourself**: `export_handoff_pack`, `get_handoff_toolchain` —
|
|
287
|
+
download the per-shot raw clips, dialogue tracks, SFX, BGM and subtitles, then
|
|
288
|
+
decide transitions and assemble the cut on your own side. Use this instead of
|
|
289
|
+
`compose_episode` when you want to judge each seam yourself; use
|
|
290
|
+
`compose_episode` when you want the platform's finishing pipeline (pre-flight
|
|
291
|
+
checks, A/V duration parity, loudness mastering). Three facts that will bite you:
|
|
292
|
+
`audio_contract.mode="tts"` means the raw clips have **no voice** (dialogue ships
|
|
293
|
+
separately — skip it and the episode is silent); every shot must be cut to
|
|
294
|
+
`trim_head_ms`/`duration_ms` or you splice in frames the platform already QC'd out;
|
|
295
|
+
and all subtitle/dialogue/SFX offsets are relative to **each shot's own trimmed
|
|
296
|
+
start**, not to the final timeline — add whatever overlapping transitions you like
|
|
297
|
+
and let `compile_timeline.py` expand them, never hand-compute the shift.
|
|
286
298
|
- **Edit**: `edit_video_shot`, `regenerate_shot_video`, `split_shot`,
|
|
287
299
|
`trim_shot`, `rerender_episode`
|
|
288
300
|
- **Read back (all free)**: `list_dramas`, `get_drama`, `get_characters`,
|
|
@@ -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)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
fetch_pack.py — 把 export_handoff_pack 返回的 manifest 拉成一个本地素材包目录。
|
|
4
|
+
|
|
5
|
+
平台返回的 manifest 是「URL + 内联字幕」,而 compile_timeline.py / assemble.sh 要的是
|
|
6
|
+
「本地文件」。本脚本负责这一步适配:下载全部素材、把内联 cues 落成逐镜 SRT、
|
|
7
|
+
把 manifest 里的 url 字段改写成包内相对路径。
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
# manifest 存成文件后
|
|
11
|
+
python3 fetch_pack.py manifest.json -o ./pack
|
|
12
|
+
# 或直接从 stdin
|
|
13
|
+
cat manifest.json | python3 fetch_pack.py - -o ./pack
|
|
14
|
+
|
|
15
|
+
产物:
|
|
16
|
+
pack/manifest.json # 已改写成本地相对路径,可直接喂给 compile_timeline.py
|
|
17
|
+
pack/clips/shot_001.mp4 …
|
|
18
|
+
pack/audio/dialogue/… pack/audio/sfx/… pack/audio/bgm/…
|
|
19
|
+
pack/subs/shot_001.srt …
|
|
20
|
+
pack/lut/haldclut.png # 有调色时才有
|
|
21
|
+
|
|
22
|
+
之后:
|
|
23
|
+
python3 compile_timeline.py ./pack [--transitions plan.json]
|
|
24
|
+
./assemble.sh ./pack out.mp4 [plan.json]
|
|
25
|
+
|
|
26
|
+
只用标准库,无第三方依赖。
|
|
27
|
+
"""
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import urllib.request
|
|
33
|
+
import urllib.error
|
|
34
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
35
|
+
|
|
36
|
+
DOWNLOAD_TIMEOUT_SEC = 120
|
|
37
|
+
RETRIES = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fetch(url: str, dest: str) -> str:
|
|
41
|
+
"""下载到 dest。已存在且非空则跳过(断点续跑友好)。返回 dest。"""
|
|
42
|
+
if os.path.exists(dest) and os.path.getsize(dest) > 0:
|
|
43
|
+
return dest
|
|
44
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
45
|
+
tmp = dest + ".part"
|
|
46
|
+
last = None
|
|
47
|
+
for attempt in range(1, RETRIES + 1):
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT_SEC) as r, open(tmp, "wb") as f:
|
|
50
|
+
while True:
|
|
51
|
+
chunk = r.read(1 << 20)
|
|
52
|
+
if not chunk:
|
|
53
|
+
break
|
|
54
|
+
f.write(chunk)
|
|
55
|
+
os.replace(tmp, dest) # 原子落位,中断不会留下半个文件冒充成品
|
|
56
|
+
return dest
|
|
57
|
+
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
|
|
58
|
+
last = e
|
|
59
|
+
if os.path.exists(tmp):
|
|
60
|
+
os.unlink(tmp)
|
|
61
|
+
raise RuntimeError(f"下载失败({RETRIES} 次): {url}\n {last}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fmt_ms(ms: int) -> str:
|
|
65
|
+
ms = max(0, int(ms))
|
|
66
|
+
h, ms = divmod(ms, 3600000)
|
|
67
|
+
m, ms = divmod(ms, 60000)
|
|
68
|
+
s, ms = divmod(ms, 1000)
|
|
69
|
+
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_srt(path: str, cues) -> int:
|
|
73
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
74
|
+
n = 0
|
|
75
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
76
|
+
for c in cues:
|
|
77
|
+
text = str(c.get("text", "")).strip()
|
|
78
|
+
if not text:
|
|
79
|
+
continue
|
|
80
|
+
n += 1
|
|
81
|
+
f.write(f"{n}\n{fmt_ms(c['start_ms'])} --> {fmt_ms(c['end_ms'])}\n{text}\n\n")
|
|
82
|
+
return n
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
ap = argparse.ArgumentParser()
|
|
87
|
+
ap.add_argument("manifest", help="manifest.json 路径,或 - 表示从 stdin 读")
|
|
88
|
+
ap.add_argument("-o", "--outdir", default="./pack")
|
|
89
|
+
ap.add_argument("-j", "--jobs", type=int, default=4, help="并发下载数(默认 4)")
|
|
90
|
+
a = ap.parse_args()
|
|
91
|
+
|
|
92
|
+
raw = sys.stdin.read() if a.manifest == "-" else open(a.manifest, encoding="utf-8").read()
|
|
93
|
+
m = json.loads(raw)
|
|
94
|
+
# MCP 工具可能把 manifest 包在 data 里返回,两种都认
|
|
95
|
+
m = m.get("data", m) if isinstance(m.get("data"), dict) else m
|
|
96
|
+
|
|
97
|
+
if m.get("manifest_version") != "0.1":
|
|
98
|
+
sys.exit(f"不认识的 manifest_version: {m.get('manifest_version')!r},拒绝猜测")
|
|
99
|
+
|
|
100
|
+
out = a.outdir
|
|
101
|
+
os.makedirs(out, exist_ok=True)
|
|
102
|
+
jobs = [] # (url, 包内相对路径)
|
|
103
|
+
|
|
104
|
+
for i, sh in enumerate(m.get("shots", [])):
|
|
105
|
+
n = int(sh["shot_number"])
|
|
106
|
+
tag = f"{n:03d}"
|
|
107
|
+
clip = sh.get("clip") or {}
|
|
108
|
+
if clip.get("url"):
|
|
109
|
+
rel = f"clips/shot_{tag}.mp4"
|
|
110
|
+
jobs.append((clip.pop("url"), rel))
|
|
111
|
+
clip["file"] = rel
|
|
112
|
+
|
|
113
|
+
dlg = sh.get("dialogue_audio")
|
|
114
|
+
if dlg and dlg.get("url"):
|
|
115
|
+
rel = f"audio/dialogue/shot_{tag}.wav"
|
|
116
|
+
jobs.append((dlg.pop("url"), rel))
|
|
117
|
+
dlg["file"] = rel
|
|
118
|
+
|
|
119
|
+
for k, fx in enumerate(sh.get("sfx") or []):
|
|
120
|
+
if not fx.get("url"):
|
|
121
|
+
continue
|
|
122
|
+
rel = f"audio/sfx/shot_{tag}_{k}.wav"
|
|
123
|
+
jobs.append((fx.pop("url"), rel))
|
|
124
|
+
fx["file"] = rel
|
|
125
|
+
|
|
126
|
+
# 内联 cues → 逐镜 SRT(时间码基准仍是「该镜 trim 后的第 0 毫秒」,不做任何平移)
|
|
127
|
+
cues = ((sh.get("subtitle") or {}).get("cues")) or []
|
|
128
|
+
if cues:
|
|
129
|
+
rel = f"subs/shot_{tag}.srt"
|
|
130
|
+
cnt = write_srt(os.path.join(out, rel), cues)
|
|
131
|
+
sh["subtitle"] = {"file": rel, "cue_count": cnt}
|
|
132
|
+
else:
|
|
133
|
+
sh["subtitle"] = {"file": None, "cue_count": 0}
|
|
134
|
+
|
|
135
|
+
for k, b in enumerate(m.get("bgm") or []):
|
|
136
|
+
if not b.get("url"):
|
|
137
|
+
continue
|
|
138
|
+
rel = f"audio/bgm/track_{k}.wav"
|
|
139
|
+
jobs.append((b.pop("url"), rel))
|
|
140
|
+
b["file"] = rel
|
|
141
|
+
|
|
142
|
+
lut = (m.get("render_target") or {}).get("color_lut")
|
|
143
|
+
if isinstance(lut, dict) and lut.get("haldclut_url"):
|
|
144
|
+
rel = "lut/haldclut.png"
|
|
145
|
+
jobs.append((lut.pop("haldclut_url"), rel))
|
|
146
|
+
lut["file"] = rel
|
|
147
|
+
|
|
148
|
+
if not jobs:
|
|
149
|
+
sys.exit("manifest 里没有任何可下载资源(URL 可能已过期,重新调 export_handoff_pack)")
|
|
150
|
+
|
|
151
|
+
print(f"下载 {len(jobs)} 个文件 → {out}")
|
|
152
|
+
errors = []
|
|
153
|
+
|
|
154
|
+
def one(job):
|
|
155
|
+
url, rel = job
|
|
156
|
+
try:
|
|
157
|
+
fetch(url, os.path.join(out, rel))
|
|
158
|
+
except Exception as e: # 单个失败不中断整批,最后一起报
|
|
159
|
+
errors.append(f"{rel}: {e}")
|
|
160
|
+
|
|
161
|
+
with ThreadPoolExecutor(max_workers=max(1, a.jobs)) as ex:
|
|
162
|
+
list(ex.map(one, jobs))
|
|
163
|
+
|
|
164
|
+
with open(os.path.join(out, "manifest.json"), "w", encoding="utf-8") as f:
|
|
165
|
+
json.dump(m, f, ensure_ascii=False, indent=2)
|
|
166
|
+
|
|
167
|
+
if errors:
|
|
168
|
+
print(f"\n{len(errors)} 个文件下载失败:", file=sys.stderr)
|
|
169
|
+
for e in errors[:20]:
|
|
170
|
+
print(" " + e, file=sys.stderr)
|
|
171
|
+
print("URL 有有效期,过期就重新调 export_handoff_pack 拿新的 manifest。", file=sys.stderr)
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
|
|
174
|
+
print(f"完成。下一步:\n python3 compile_timeline.py {out}\n ./assemble.sh {out} out.mp4")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
main()
|
package/dist/path-guard.js
CHANGED
|
@@ -43,3 +43,42 @@ export function assertSafeLocalMediaPath(filePath, kind, resolve = realpathSync)
|
|
|
43
43
|
}
|
|
44
44
|
return real;
|
|
45
45
|
}
|
|
46
|
+
/** 允许落盘的工具链文件名白名单——目标文件名由我们定,不接受调用方指定。 */
|
|
47
|
+
export const TOOLCHAIN_FILENAMES = ['fetch_pack.py', 'compile_timeline.py', 'assemble.sh'];
|
|
48
|
+
/** 写文件比读文件更危险:这些前缀下一律不落盘(覆盖系统/可执行文件路径的后果不可逆)。 */
|
|
49
|
+
const WRITE_DENY_PREFIXES = [
|
|
50
|
+
'/etc/', '/private/etc/', '/proc/', '/sys/', '/bin/', '/sbin/',
|
|
51
|
+
'/usr/', '/System/', '/Library/', '/boot/', '/dev/',
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* 校验「把工具链脚本写到哪个目录」。
|
|
55
|
+
*
|
|
56
|
+
* 与上传守卫同源但更严:上传只是读别人的文件,写盘则可能覆盖掉目标位置已有的东西。
|
|
57
|
+
* 三条规则:
|
|
58
|
+
* ① 必须是绝对路径 —— 相对路径在 MCP 进程里解析基准不明(进程 cwd 未必是调用方以为的目录);
|
|
59
|
+
* ② realpath 后不得含以 '.' 开头的路径段(~/.ssh、~/.config、.git 全部命中);
|
|
60
|
+
* ③ 不得落在系统/可执行目录前缀下。
|
|
61
|
+
* 文件名不由调用方决定(见 TOOLCHAIN_FILENAMES),所以不存在 ../ 穿越写任意文件的路径。
|
|
62
|
+
*/
|
|
63
|
+
export function assertSafeToolchainDir(dirPath, resolve = realpathSync) {
|
|
64
|
+
if (!dirPath || !dirPath.startsWith('/')) {
|
|
65
|
+
throw new Error(`dir 必须是绝对路径(收到 "${dirPath}")。MCP 进程的工作目录与你的不一定相同,`
|
|
66
|
+
+ '相对路径会写到意料之外的地方。');
|
|
67
|
+
}
|
|
68
|
+
let real;
|
|
69
|
+
try {
|
|
70
|
+
real = resolve(dirPath);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new Error(`dir 不存在:${dirPath}。请先创建该目录再重试(本工具不递归建目录)。`);
|
|
74
|
+
}
|
|
75
|
+
const segments = real.split(sep);
|
|
76
|
+
if (segments.some(s => s.startsWith('.') && s.length > 1)) {
|
|
77
|
+
throw new Error(`dir 位于隐藏目录(${real}),已拒绝——不向 ~/.ssh、~/.config、.git 这类目录写文件。`
|
|
78
|
+
+ '换一个普通工作目录。');
|
|
79
|
+
}
|
|
80
|
+
if (WRITE_DENY_PREFIXES.some(p => real.startsWith(p))) {
|
|
81
|
+
throw new Error(`dir 位于系统目录(${real}),已拒绝写入。换一个你自己的工作目录。`);
|
|
82
|
+
}
|
|
83
|
+
return real;
|
|
84
|
+
}
|
package/dist/tools/produce.js
CHANGED
|
@@ -11,6 +11,27 @@
|
|
|
11
11
|
* 用 get_storyboards / get_episode_status 轮询到完成。下载链接只发我方 COS 链接。
|
|
12
12
|
*/
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
+
import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { assertSafeToolchainDir, TOOLCHAIN_FILENAMES } from '../path-guard.js';
|
|
18
|
+
const TOOLCHAIN_USAGE = [
|
|
19
|
+
'1. 三个脚本放同一目录(assemble.sh 需可执行位)。',
|
|
20
|
+
'2. 把 export_handoff_pack 的返回体存成 manifest.json。',
|
|
21
|
+
'3. python3 fetch_pack.py manifest.json -o ./pack # 下载素材 + 内联字幕落成逐镜 SRT + 改写成本地路径',
|
|
22
|
+
'4. python3 compile_timeline.py ./pack [--transitions plan.json] # 展开时间轴',
|
|
23
|
+
'5. ./assemble.sh ./pack out.mp4 [plan.json] # 装配成片',
|
|
24
|
+
];
|
|
25
|
+
const TOOLCHAIN_NOTE = 'assemble.sh 会先自检 ffmpeg 是否带 libass;不带则字幕以软字幕轨输出而非烧录(竖屏发布必须烧录)。' +
|
|
26
|
+
'包里若带 render_target.color_lut,fetch_pack.py 会把调色查找表一并下载,assemble.sh 自动施加——' +
|
|
27
|
+
'不施加的话你的成片与平台成片会有色差。';
|
|
28
|
+
/**
|
|
29
|
+
* 随包发布的装配工具链目录。本文件编译后在 dist/tools/produce.js,
|
|
30
|
+
* assets/ 在包根,所以要上两级 —— 少一级会静默解析到 dist/assets(不存在),
|
|
31
|
+
* 而 get_handoff_toolchain 里 catch 掉读取异常,坏结果不会崩、只会悄悄发给第三方。
|
|
32
|
+
* release-check 里有一条闸钉住这个路径。
|
|
33
|
+
*/
|
|
34
|
+
export const HANDOFF_ASSETS = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'handoff');
|
|
14
35
|
function jsonResult(data) {
|
|
15
36
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
16
37
|
}
|
|
@@ -592,13 +613,19 @@ export function registerProduceTools(server, client) {
|
|
|
592
613
|
physical_size_hint: z.string().optional().describe('物理尺寸提示'),
|
|
593
614
|
episode_id: z.number().int().positive().optional(),
|
|
594
615
|
}, async ({ drama_id, ...fields }) => jsonResult(await client.producePost(`/props`, { drama_id, ...fields })));
|
|
595
|
-
server.tool('update_prop', '改道具(名称/类型/描述/prompt
|
|
616
|
+
server.tool('update_prop', '改道具(名称/类型/描述/prompt/尺寸/多视角参考图)。免费。', {
|
|
596
617
|
prop_id: z.number().int().positive(),
|
|
597
618
|
name: z.string().optional(),
|
|
598
619
|
type: z.string().optional(),
|
|
599
620
|
description: z.string().optional(),
|
|
600
621
|
prompt: z.string().optional(),
|
|
601
622
|
physical_size_hint: z.string().optional(),
|
|
623
|
+
// v0.9.976 — 多视角/细节参考图(整份覆盖;传 [] 清空,最多 6 张 http(s) 图)。
|
|
624
|
+
// 与主图分工:主图锁「这是什么」,多视角锁「截面与部件怎么装配」——单张白底图
|
|
625
|
+
// 和文字都表达不了这个(生产实证:形制文本已送达,锤头仍画成被禁的形状)。
|
|
626
|
+
// 正视 / 侧视 / 端面各一张最有效,端面是截面形状唯一能说清的方式。
|
|
627
|
+
reference_images: z.array(z.string().url()).max(6).optional()
|
|
628
|
+
.describe('多视角参考图 URL 数组,整份覆盖;建议正视/侧视/端面各一张'),
|
|
602
629
|
}, async ({ prop_id, ...fields }) => jsonResult(await client.producePut(`/props/${prop_id}`, fields)));
|
|
603
630
|
server.tool('delete_prop', '删一个道具。免费。', { prop_id: z.number().int().positive() }, async ({ prop_id }) => jsonResult(await client.produceDelete(`/props/${prop_id}`)));
|
|
604
631
|
server.tool('mark_signature_prop', '标记/取消「招牌道具」(会在多镜复现的关键道具,加强一致性追踪)。signature=false 取消。免费。', { prop_id: z.number().int().positive(), signature: z.boolean().optional().describe('默认 true 标记;false 取消') }, async ({ prop_id, signature }) => jsonResult(await client.producePost(`/props/${prop_id}/signature`, { signature: signature !== false })));
|
|
@@ -670,4 +697,91 @@ export function registerProduceTools(server, client) {
|
|
|
670
697
|
character_id: z.number().int().positive(),
|
|
671
698
|
voice_id: z.string().describe('来自 clone_voice / list_voices 的 voice_id(形如 lib:12)'),
|
|
672
699
|
}, async ({ character_id, voice_id }) => jsonResult(await client.producePost(`/characters/${character_id}/voice`, { voice_id })));
|
|
700
|
+
// ── 素材交接包:客户自己拼片的通道 ──────────────────────────────────────
|
|
701
|
+
// 与 compose_episode 的分工:compose_episode = 「平台替你拼」;本组工具 = 「素材给你,
|
|
702
|
+
// 你自己决定转场、自己拼」。两条路都不丢客户的对白/音效/配乐/字幕成果。
|
|
703
|
+
server.tool('export_handoff_pack', '导出本集「素材交接包」清单:逐镜裸片 + 对白音轨 + 音效 + 配乐 + 字幕的可下载 URL,' +
|
|
704
|
+
'交给你在**自己那边**完成转场决策、拼接、混音、烧字幕——平台不参与终拼。免费,零扣费。\n' +
|
|
705
|
+
'\n【推荐流程】① 调本工具拿 manifest;② 按 clips[].url 把裸片下载到本地;' +
|
|
706
|
+
'③ 你自己看片判断每个接缝该用什么转场(manifest 给了 scene_boundary 场景边界作判据),' +
|
|
707
|
+
'写一份 plan.json;④ 用 get_handoff_toolchain 拿到 compile_timeline.py 展开时间轴、' +
|
|
708
|
+
'assemble.sh 装配出成片。工具链已经把「加了重叠转场之后字幕/对白/音效怎么跟着位移」算好了。\n' +
|
|
709
|
+
'\n【三个不看就会翻车的事实】\n' +
|
|
710
|
+
'① audio_contract.mode="tts" 时**裸片里没有人声**,对白在 dialogue_audio 里;不铺就是整集没台词。' +
|
|
711
|
+
'mode="clip" 时人声已烤在裸片音轨里,反过来**不要**再叠。\n' +
|
|
712
|
+
'② 每镜必须按 trim_head_ms / duration_ms 裁剪再用;直接拼整条裸片会把平台已经 QC 掉的' +
|
|
713
|
+
'首尾形变帧一起拼进去。\n' +
|
|
714
|
+
'③ 字幕 cue、dialogue_audio.offset_ms、sfx[].offset_ms 的基准都是「该镜 trim 之后的第 0 毫秒」,' +
|
|
715
|
+
'不是成片绝对时间。你加多少重叠转场都不用改它们——交给 compile_timeline.py 展开,别手算累加。\n' +
|
|
716
|
+
'\n想让平台代拼、要平台级质量闸(终拼预检/音画等长/响度母带),改用 compose_episode。', { episode_id: z.number().int().positive() }, async ({ episode_id }) => {
|
|
717
|
+
const manifest = await client.produceGet(`/episodes/${episode_id}/handoff-pack`);
|
|
718
|
+
return jsonResult({
|
|
719
|
+
...manifest,
|
|
720
|
+
assembly_guide: {
|
|
721
|
+
step_1_download: '按 shots[].clip.url / dialogue_audio.url / sfx[].url / bgm[].url 下载素材。' +
|
|
722
|
+
'URL 到 expires_at 失效,过期重新调本工具。',
|
|
723
|
+
step_2_decide_transitions: '自己分析画面决定每个接缝的转场。scene_boundary="start" 是换场(适合给转场),' +
|
|
724
|
+
'"continue" 是同场景(平台默认硬切——同场景逐镜叠化是"幻灯片拼凑感"的主因)。' +
|
|
725
|
+
'transition_hint 是平台建议,你可以覆盖。',
|
|
726
|
+
step_3_plan_json: '写 plan.json:{"transitions":[{"before_shot":3,"type":"fade","overlap_ms":300}]}。' +
|
|
727
|
+
'type 接受预设 id(fade/flash_white/whip_pan…)、导演语义键' +
|
|
728
|
+
'(match_cut/smash_cut/cross_dissolve…)、或直接给 ffmpeg xfade 名。' +
|
|
729
|
+
'overlap_ms 不填就用该预设的规范时长。',
|
|
730
|
+
step_4_assemble: 'save_handoff_toolchain 把三个脚本落到本地目录,然后:\n' +
|
|
731
|
+
' python3 fetch_pack.py manifest.json -o ./pack # 下载素材 + 内联字幕落成 SRT\n' +
|
|
732
|
+
' python3 compile_timeline.py ./pack --transitions plan.json\n' +
|
|
733
|
+
' ./assemble.sh ./pack out.mp4 plan.json',
|
|
734
|
+
gotchas: [
|
|
735
|
+
'clip.duration_source="authored" 的镜是 probe 失败退回声明时长的,请自行 ffprobe 校正,否则拼接有累积误差。',
|
|
736
|
+
'render_target.color_lut 非 null 时,裸片是**未调色**的:必须施加随包的 haldclut 查找表,' +
|
|
737
|
+
'否则你的成片与平台成片有色差。fetch_pack.py 会下载它、assemble.sh 会自动施加。',
|
|
738
|
+
'重叠转场会让其后所有镜整体前移且误差累积——用 compile_timeline.py 算,不要手算。',
|
|
739
|
+
'闪白/闪黑(flash_white/flash_black)是硬切+片头闪光,不能走 xfade,否则双重闪且破坏口型。工具链已处理。',
|
|
740
|
+
'响度必须两 pass 线性母带;单 pass 动态 loudnorm 会把对白间隙的 BGM 上提,让配乐增益调了等于没调。',
|
|
741
|
+
'侧链避让的 key 必须是纯人声轨,混进音效会让打击音也把 BGM 压下去,听感是配乐一惊一乍。',
|
|
742
|
+
],
|
|
743
|
+
toolchain: 'save_handoff_toolchain 直接把工具链写到你的工作目录(推荐);或 get_handoff_toolchain 拿源码自己保存。',
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
server.tool('get_handoff_toolchain', '拿到素材交接包的装配工具链源码(compile_timeline.py 时间轴编译器 / assemble.sh ffmpeg 装配脚本)。' +
|
|
748
|
+
'免费。本工具只返回源码文本,**不会**在你机器上写文件——请自行保存到工作目录再执行。\n' +
|
|
749
|
+
'compile_timeline.py 负责把「镜相对」的字幕/对白/音效锚点展开成你自己时间轴上的绝对时间码,' +
|
|
750
|
+
'并处理重叠转场引起的整体位移;assemble.sh 是从裸片到成片的完整 ffmpeg 装配基线' +
|
|
751
|
+
'(裁剪→规范化→拼接/xfade→配乐侧链→字幕→两 pass 母带)。' +
|
|
752
|
+
'依赖 ffmpeg(烧字幕需带 libass)、ffprobe、jq、python3。', {
|
|
753
|
+
script: z.enum([...TOOLCHAIN_FILENAMES, 'all']).optional().describe('要哪个;缺省 all'),
|
|
754
|
+
}, async ({ script }) => {
|
|
755
|
+
const want = script && script !== 'all' ? [script] : [...TOOLCHAIN_FILENAMES];
|
|
756
|
+
const files = {};
|
|
757
|
+
for (const f of want) {
|
|
758
|
+
try {
|
|
759
|
+
files[f] = readFileSync(join(HANDOFF_ASSETS, f), 'utf8');
|
|
760
|
+
}
|
|
761
|
+
catch (e) {
|
|
762
|
+
files[f] = `// 读取失败: ${e?.message || e}`;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return jsonResult({ files, usage: TOOLCHAIN_USAGE, note: TOOLCHAIN_NOTE });
|
|
766
|
+
});
|
|
767
|
+
server.tool('save_handoff_toolchain', '把装配工具链(fetch_pack.py / compile_timeline.py / assemble.sh)直接写到你本地的一个目录,' +
|
|
768
|
+
'省掉自己复制粘贴。免费。目录必须**已存在**且是绝对路径;文件名固定,不接受自定义。\n' +
|
|
769
|
+
'拒绝写入隐藏目录(~/.ssh、~/.config、.git…)与系统目录——这是写盘不是读盘,覆盖错地方不可逆。\n' +
|
|
770
|
+
'落盘后完整流程:\n' +
|
|
771
|
+
' python3 fetch_pack.py manifest.json -o ./pack # 下载素材、内联字幕落成 SRT\n' +
|
|
772
|
+
' python3 compile_timeline.py ./pack [--transitions plan.json]\n' +
|
|
773
|
+
' ./assemble.sh ./pack out.mp4 [plan.json]', { dir: z.string().describe('已存在的绝对路径目录,如 /Users/me/work/starreel-pack') }, async ({ dir }) => {
|
|
774
|
+
const safe = assertSafeToolchainDir(dir);
|
|
775
|
+
const written = [];
|
|
776
|
+
for (const f of TOOLCHAIN_FILENAMES) {
|
|
777
|
+
const body = readFileSync(join(HANDOFF_ASSETS, f), 'utf8');
|
|
778
|
+
const dest = join(safe, f);
|
|
779
|
+
writeFileSync(dest, body, 'utf8');
|
|
780
|
+
// .sh 要可执行,否则第三方还得自己 chmod 一次才跑得起来
|
|
781
|
+
if (f.endsWith('.sh'))
|
|
782
|
+
chmodSync(dest, 0o755);
|
|
783
|
+
written.push({ file: dest, bytes: Buffer.byteLength(body), mode: f.endsWith('.sh') ? '0755' : '0644' });
|
|
784
|
+
}
|
|
785
|
+
return jsonResult({ written, usage: TOOLCHAIN_USAGE, note: TOOLCHAIN_NOTE });
|
|
786
|
+
});
|
|
673
787
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starreel/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"mcpName": "ai.starreel/starreel",
|
|
5
5
|
"description": "StarReel MCP server — drive the AI short-drama production pipeline (script → storyboards → frames → video → final cut) from Claude, Cursor, or any MCP client",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"dist",
|
|
13
13
|
"README.md",
|
|
14
14
|
"SKILL.md",
|
|
15
|
-
"server.json"
|
|
15
|
+
"server.json",
|
|
16
|
+
"assets"
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
18
19
|
"build": "tsc",
|
|
@@ -40,4 +41,4 @@
|
|
|
40
41
|
"localization",
|
|
41
42
|
"short-drama"
|
|
42
43
|
]
|
|
43
|
-
}
|
|
44
|
+
}
|
package/server.json
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
|
3
3
|
"name": "ai.starreel/starreel",
|
|
4
4
|
"description": "Turn a script into a finished, downloadable short-drama episode — via MCP or REST.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.42",
|
|
6
6
|
"websiteUrl": "https://starreel.ai",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"identifier": "@starreel/mcp",
|
|
11
|
-
"version": "0.1.
|
|
11
|
+
"version": "0.1.42",
|
|
12
12
|
"transport": { "type": "stdio" },
|
|
13
13
|
"environmentVariables": [
|
|
14
14
|
{
|