@starreel/mcp 0.1.40 → 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 CHANGED
@@ -207,6 +207,39 @@ with `update_character` `profile_locked: 1` so later extractions can't
207
207
  overwrite appearance (appearance anchors the portrait and face lock — an
208
208
  overwrite means face drift across the whole drama).
209
209
 
210
+ ## After the first rewrite — 点改优先 (point-edit, don't re-run)
211
+
212
+ **`rewrite_script` is "start over from the source", not "give me another
213
+ revision."** Once a draft exists, re-running it overwrites the current draft
214
+ together with every fix already made — and the new version does **not**
215
+ inherit what the previous one got right. Measured across three production
216
+ runs of the same episode: a long VO block correctly split in v2 was merged
217
+ back in v3, and era-correct wardrobe in v2 drifted a decade in v3. The
218
+ kickoff response says so explicitly via `overwrites_existing_script: true`.
219
+
220
+ So after the first successful rewrite, **every** change goes through
221
+ `edit_rewritten_script` — free, seconds, deterministic:
222
+
223
+ 1. `get_script` → take the **full** `rewritten_script`.
224
+ 2. Change **only the scenes that need changing** — split an over-long scene,
225
+ fix a line, drop a `motif` that has no matching visual, add a missing
226
+ `[SFX]`, move a hand-held object out of the wardrobe segment into the
227
+ scene's `[道具]` line. Every other scene is copied back **verbatim**,
228
+ punctuation included.
229
+ 3. Submit the **whole** script to `edit_rewritten_script` (it is a full-text
230
+ write, so untouched parts must come back unchanged — never send a fragment,
231
+ and never let the model "tidy up" scenes it wasn't asked to touch).
232
+
233
+ Re-run `rewrite_script` only when the user genuinely wants a different take on
234
+ the whole episode. If a re-run (or an edit) went wrong,
235
+ `get_script` with `include_previous: true` returns the previous draft snapshot.
236
+
237
+ Point-editable by definition — anything the platform can already name: scene
238
+ length, cross-shot dialogue splits, wrong time-of-day or era, orphan motifs,
239
+ props in the wardrobe segment, missing `[SFX]`/`[BGM]`, group-shot head counts.
240
+ Storyboard-level issues do **not** need a script edit at all: use
241
+ `update_shot` / `replace_shot_dialogue`.
242
+
210
243
  ## Failure playbook
211
244
 
212
245
  Failures surface as a human-readable `message` (MCP throws `Error(message)`;
@@ -250,6 +283,18 @@ refund on failure); you never compensate manually.
250
283
  `generate_bgm`, `replace_shot_dialogue`
251
284
  - **Finish**: `compose_episode`, `get_final_cut`, `get_export`,
252
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.
253
298
  - **Edit**: `edit_video_shot`, `regenerate_shot_video`, `split_shot`,
254
299
  `trim_shot`, `rerender_episode`
255
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()
@@ -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
+ }
@@ -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
  }
@@ -34,6 +55,10 @@ const WORKFLOW_HINT = '★三档执行策略(别把三档混着问客户):' +
34
55
  '把自己写好的剧本直接贴进 edit_rewritten_script 绕过改写会被 400 拒(没有改写稿就没有可改的对象),extract_assets 同样要求基于改写稿。' +
35
56
  '改写后的所有修改按 AI 产物的结构化格式做:改稿 edit_rewritten_script(润色/纠正)、人物档案 update_character、分镜 update_shot/replace_shot_dialogue——' +
36
57
  '别回头整篇替换剧本或在设定字段里另写一套,两套真相源打架是一致性事故的头号根源。' +
58
+ '★★**改写成功一次后就别再重跑 rewrite_script**:它是从原稿整篇重来,当前稿的所有修正全丢,' +
59
+ '且新版不保证保留旧版已改好的地方(三版实测会来回摆)。要修就 edit_rewritten_script 点改' +
60
+ '(get_script 取全文 → 只改那几场、其余逐字照抄 → 提交整篇),免费秒级、结果确定;' +
61
+ '误重跑用 get_script(include_previous=1) 回捞上一版。' +
37
62
  'generate_portraits_and_sheets(定妆图+设定图·分镜后建只给出场角色出图更省)→assign_voices(分配音色)→frames→videos→generate_tts→compose;' +
38
63
  '★别先建角色形象/道具设定图/动作模板再分镜——分镜是纯文本步、不依赖任何图;资产在分镜后建更省更准(动作模板本就必须分镜后)。收费步照现有 quote 报价确认流程。' +
39
64
  '广告另需 add_product+generate_product_sheet;MV 走 set_mv_lyrics→generate_mv_story→generate_mv_script。' +
@@ -173,10 +198,29 @@ export function registerProduceTools(server, client) {
173
198
  '(广告走 ad 改写;MV 不走标准改写会被拦)。后台异步(分钟级),文本步按 token 后付、不欠费,无需报价。' +
174
199
  '完成后用 get_script 审阅、edit_rewritten_script 改稿。' +
175
200
  '★典型耗时 2~4 分钟(生产实测 ≈169 秒)。**60 秒内查不到结果是正常的,不是失败**——' +
176
- '用 get_run_status 判断还在不在跑,别急着重发。' + WORKFLOW_HINT, { episode_id: z.number().int().positive() }, async ({ episode_id }) => jsonResult(await client.producePost(`/episodes/${episode_id}/rewrite`)));
177
- server.tool('get_script', '读某一集的原始内容 + AI 改写后的可拍稿 + 改写状态 + dramaturgy_suggestions(两步保真模式下 AI 识别到但未自动补的剧作缺口——钩子/情感锚点;把它转述给客户决定采纳与否,忽略不影响产线)。免费。', { episode_id: z.number().int().positive() }, async ({ episode_id }) => jsonResult(await client.produceGet(`/episodes/${episode_id}/script`)));
178
- server.tool('edit_rewritten_script', '客户改 AI 改写后的可拍稿(写 script_content)。只用于修改 AI 改写产出的稿(人工润色/纠正);' +
179
- '本集还没跑过 rewrite_script 时会被 400 拒——这不是绕过改写的通道,别把自己写好的剧本直接贴进来。免费。', {
201
+ '用 get_run_status 判断还在不在跑,别急着重发。' +
202
+ '★★**本工具是"从原稿整篇重来",不是"再改一版"**:已有可拍稿时重跑会把当前稿连同已做的所有修正' +
203
+ '一起覆盖,而且新一版**不保证保留旧版已经改好的地方**(生产三版实测:上一版拆好的长旁白段下一版又' +
204
+ '合回去、上一版正确的年代服装下一版漂走)。响应里的 overwrites_existing_script=true 就是这个意思。' +
205
+ '所以**首次改写成功之后,后续所有修改一律用 edit_rewritten_script 点改**——免费、秒级、只动指定的' +
206
+ '那几场,其余逐字不变,结果确定不抽卡;只有"要一个完全不同的版本"才重跑本工具。' +
207
+ '误重跑后用 get_script(include_previous=1) 取回上一版。' + WORKFLOW_HINT, { episode_id: z.number().int().positive() }, async ({ episode_id }) => jsonResult(await client.producePost(`/episodes/${episode_id}/rewrite`)));
208
+ server.tool('get_script', '读某一集的原始内容 + AI 改写后的可拍稿 + 改写状态 + dramaturgy_suggestions(两步保真模式下 AI 识别到但未自动补的剧作缺口——钩子/情感锚点;把它转述给客户决定采纳与否,忽略不影响产线)。免费。' +
209
+ '★改稿工作流:先用本工具取回 rewritten_script 全文 → 只改客户要改的那几场 → 用 edit_rewritten_script 提交(不要重跑改写)。' +
210
+ 'previous_script_available=true 表示有上一版快照;传 include_previous=1 可取回上一版全文(误重跑/改坏了的回捞路径,默认不带以免响应翻倍)。', {
211
+ episode_id: z.number().int().positive(),
212
+ include_previous: z.boolean().optional().describe('取回上一版可拍稿全文(previous_script 快照)。默认 false;改坏了或误重跑时用'),
213
+ }, async ({ episode_id, include_previous }) => jsonResult(await client.produceGet(`/episodes/${episode_id}/script${include_previous ? '?include_previous=1' : ''}`)));
214
+ server.tool('edit_rewritten_script', '★改稿的**首选通道**:改 AI 改写后的可拍稿(写 script_content)。免费、秒级、结果确定不抽卡——' +
215
+ '**改写成功一次之后,所有修改都走这里,不要重跑 rewrite_script**(那是从原稿整篇重来,当前稿的修正全丢、' +
216
+ '已改好的地方会退回去)。' +
217
+ '★怎么点改(三步,别跳):① get_script 取回 rewritten_script **全文**;② **只改要改的那几场**——' +
218
+ '目标场次内改动词句/拆场/删挂错的 motif/补 [SFX]/移器物到 [道具] 行,其余场次连标点都逐字照抄;' +
219
+ '③ 把改完的**整篇**提交本工具(本工具是全文覆盖,所以未改动部分必须原样带回,不能只发片段、也不要让模型' +
220
+ '"顺手重写"没让它改的场)。典型可点改的问题:场次太长要拆、台词跨镜断句、时段/年代写错、' +
221
+ 'motif 挂在没有该元素的场、[角色档案] 服装段混进手持器物、缺 [SFX]/[BGM] 标注。' +
222
+ '只用于修改 AI 改写产出的稿;本集还没跑过 rewrite_script 时会被 400 拒——这不是绕过改写的通道,' +
223
+ '别把自己写好的剧本直接贴进来。改坏了可用 get_script(include_previous=1) 取回上一版。免费。', {
180
224
  episode_id: z.number().int().positive(),
181
225
  script: z.string().min(1).describe('改好的可拍剧本(覆盖 AI 改写稿)'),
182
226
  }, async ({ episode_id, script }) => jsonResult(await client.producePut(`/episodes/${episode_id}/rewritten-script`, { script_content: script })));
@@ -569,13 +613,19 @@ export function registerProduceTools(server, client) {
569
613
  physical_size_hint: z.string().optional().describe('物理尺寸提示'),
570
614
  episode_id: z.number().int().positive().optional(),
571
615
  }, async ({ drama_id, ...fields }) => jsonResult(await client.producePost(`/props`, { drama_id, ...fields })));
572
- server.tool('update_prop', '改道具(名称/类型/描述/prompt/尺寸)。免费。', {
616
+ server.tool('update_prop', '改道具(名称/类型/描述/prompt/尺寸/多视角参考图)。免费。', {
573
617
  prop_id: z.number().int().positive(),
574
618
  name: z.string().optional(),
575
619
  type: z.string().optional(),
576
620
  description: z.string().optional(),
577
621
  prompt: z.string().optional(),
578
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 数组,整份覆盖;建议正视/侧视/端面各一张'),
579
629
  }, async ({ prop_id, ...fields }) => jsonResult(await client.producePut(`/props/${prop_id}`, fields)));
580
630
  server.tool('delete_prop', '删一个道具。免费。', { prop_id: z.number().int().positive() }, async ({ prop_id }) => jsonResult(await client.produceDelete(`/props/${prop_id}`)));
581
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 })));
@@ -647,4 +697,91 @@ export function registerProduceTools(server, client) {
647
697
  character_id: z.number().int().positive(),
648
698
  voice_id: z.string().describe('来自 clone_voice / list_voices 的 voice_id(形如 lib:12)'),
649
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
+ });
650
787
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starreel/mcp",
3
- "version": "0.1.40",
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.40",
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.40",
11
+ "version": "0.1.42",
12
12
  "transport": { "type": "stdio" },
13
13
  "environmentVariables": [
14
14
  {