@starreel/mcp 0.1.212 → 0.1.214

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
@@ -1482,6 +1482,17 @@ to close") tells the vendor to fit that entire sequence into each 3-second shot.
1482
1482
  `voice_gain_db` for the TTS dialogue + narration track); one music section's level, fades,
1483
1483
  where in the track it starts (`track_offset_ms`) and how late after its first shot it comes in
1484
1484
  (`start_shift_ms`) via `list_bgm_cues` → `update_bgm_cue`. Don't download the cut to re-mix it locally.
1485
+ - **Theme song with vocals** (free; settings only — `compose_episode` again): `get_theme_songs` →
1486
+ `set_theme_songs` attaches an opening / ending / insert song to an episode (the customer's own
1487
+ upload via `upload-url` kind=audio, or a `track_id`). Give the song's backing track as
1488
+ `instrumental_url` and dialogue lines automatically swap the vocal for the backing track;
1489
+ without it the whole song ducks by `duck_db` (default −18 dB) while someone speaks. Background
1490
+ music yields inside the song's range. `enabled=false` = the drama isn't switched on yet.
1491
+ - **Poem / lyric text cards** (free; `compose_episode` again): `get_shot_verse_cards` →
1492
+ `set_shot_verse_cards` — up to 6 vertical or horizontal cards per shot (left / right / center,
1493
+ own fade-in/out and start time), a full-screen black closing card (`backdrop: "black"`) and
1494
+ scrolling end credits (`motion: "scroll_up"`). Cards stay out of the dialogue-subtitle band.
1495
+ Use only the text the customer supplied. Don't overlay text on a downloaded cut.
1485
1496
  - **Finish**: `compose_episode`, `get_final_cut`, `get_export`,
1486
1497
  `generate_episode_poster`, `generate_cover`
1487
1498
  - **What did this drama cost?** `get_drama_bill` — per kind `charged` / `refunded`
@@ -1561,7 +1572,18 @@ to close") tells the vendor to fit that entire sequence into each 3-second shot.
1561
1572
  gone and you should regenerate that shot rather than paper over it). Check per line
1562
1573
  with `shots[].spoken_lines[]`: subtitles are per-line but audio is per-shot, so
1563
1574
  **having subtitles never means having sound**; every shot must be cut to
1564
- `trim_head_ms`/`duration_ms` or you splice in frames the platform already QC'd out;
1575
+ `trim_head_ms`/`duration_ms` or you splice in frames the platform already QC'd out.
1576
+ Since `manifest_version` **0.2** those in/out points are projected from the
1577
+ platform's own final-cut timeline (the same numbers `build_av_timing_contract`
1578
+ reports, the same cut `compose_episode` renders) and `clip.url` is the file that
1579
+ timeline actually uses (`clip.source_kind`); 0.1 packs computed their own trim and
1580
+ did not match the platform cut. Honour `speed_factor` (retime the shot) and
1581
+ `freeze_pad_ms` (clone the last frame) —
1582
+ `duration_ms = (out_ms − in_ms) / speed_factor + freeze_pad_ms`; don't burn
1583
+ subtitles again on `subtitles_burned_in=true` shots or apply the LUT on
1584
+ `color_graded=true` shots; `trim_basis` other than `platform_timeline` means the
1585
+ shot is untrimmed and `notes` say why. A saved toolchain that only accepts 0.1
1586
+ will refuse a 0.2 manifest — run `save_handoff_toolchain` again;
1565
1587
  and all subtitle/dialogue/SFX offsets are relative to **each shot's own trimmed
1566
1588
  start**, not to the final timeline — add whatever overlapping transitions you like
1567
1589
  and let `compile_timeline.py` expand them, never hand-compute the shift.
@@ -77,14 +77,33 @@ for i in $(seq 0 $((NSHOTS - 1))); do
77
77
  SRC="${PACK}/$(jq -r '.file' <<<"${S}")"
78
78
  SS=$(ms2s "$(jq -r '.trim_head_ms' <<<"${S}")")
79
79
  DUR=$(ms2s "$(jq -r '.duration_ms' <<<"${S}")")
80
+ # 0.2:源片取 TAKE 毫秒,按 SPEED 变速,不够长的部分克隆末帧补 PAD(与平台渲染器同一做法)
81
+ TAKE=$(ms2s "$(jq -r '.src_take_ms // .duration_ms' <<<"${S}")")
82
+ SPEED=$(jq -r '.speed_factor // 1' <<<"${S}")
83
+ # 原声可能比视频流长(厂商音频尾 / 口型片按容器时长取材):输入按整段播放所需的源片时长读,
84
+ # 视频再单独截到 TAKE —— 输入 -t 会连音频一起截掉,后面 apad 补出来的只是静音
85
+ READ=$(awk -v d="$(jq -r '.duration_ms' <<<"${S}")" -v s="${SPEED}" -v t="$(jq -r '.src_take_ms // .duration_ms' <<<"${S}")" \
86
+ 'BEGIN{r=d*s; if(r<t)r=t; printf "%.3f", r/1000}')
87
+ PAD=$(jq -r '.freeze_pad_ms // 0' <<<"${S}")
80
88
  BAKED=$(jq -r '.has_baked_audio' <<<"${S}")
89
+ # 已调色的合成片不再套查找表(双重调色)
90
+ SHOT_LUT="${LUT_FILE}"
91
+ [ "$(jq -r '.color_graded // false' <<<"${S}")" = "true" ] && SHOT_LUT=""
92
+ # 人声烤在片里(逐镜判定,不看整集 mode)且片子真有音轨 → 用片子原声
93
+ HAS_A=$(ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "${SRC}" | head -1)
81
94
  DST="${TMP}/shot_$(printf '%03d' "${N}").mkv"
82
95
 
83
96
  # 中间产物一律 mkv + pcm_s16le:aac 每段都带 ~21ms(1024 samples) 编码器 priming
84
97
  # delay,走 adelay/amix 时每镜音频会整体提前约半帧,重叠转场下误差还会叠加。
85
98
  # 只在阶段 4 最后一次编 aac。
86
99
  # 视频:先 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}"
100
+ VF="trim=duration=${TAKE},setpts=PTS-STARTPTS,scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS}"
101
+ AF="anull"
102
+ if [ "${SPEED}" != "1" ]; then
103
+ VF="${VF/setpts=PTS-STARTPTS,/setpts=(PTS-STARTPTS)/${SPEED},}"
104
+ AF="atempo=${SPEED}"
105
+ fi
106
+ [ "${PAD}" != "0" ] && VF="${VF},tpad=stop_mode=clone:stop_duration=$(ms2s "${PAD}")"
88
107
 
89
108
  # baked-cut(闪白/闪黑):硬切 + 本镜片头快速过曝/压黑,**不重叠**。
90
109
  # 平台实测:这类效果走 xfade 会变成双重闪 + 130ms 重叠 + 口型/音频瞬断,
@@ -96,65 +115,53 @@ for i in $(seq 0 $((NSHOTS - 1))); do
96
115
  VF="${VF},fade=t=in:st=0:d=$(ms2s "${FMS}"):color=${FLASH}"
97
116
  fi
98
117
 
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
118
+ # 静音底 + (人声烤在片里时)片子原声 + 对白 + 音效,全部按镜内 offset 落位。
119
+ # 输入索引是固定契约:0=静音底 1=视频 2..=对白/音效(按下面的追加顺序)
120
+ INPUTS=(-f lavfi -t "${DUR}" -i "anullsrc=r=48000:cl=stereo"
121
+ -ss "${SS}" -t "${READ}" -i "${SRC}")
122
+ FILTER=""; LABELS="[0:a]"; IDX=2; NCLIP=0
120
123
 
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
124
+ # 人声烤在片里(逐镜判定,不看整集 mode):用片子原声,绝不再叠对白(叠了就是双声);补帧段补静音
125
+ if [ "${BAKED}" = "true" ] && [ -n "${HAS_A}" ]; then
126
+ FILTER+="[1:a]${AF},apad[c];"; LABELS+="[c]"; NCLIP=1
127
+ fi
129
128
 
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
129
+ DLG=$(jq -c '.dialogue // empty' <<<"${S}")
130
+ if [ "${BAKED}" != "true" ] && [ -n "${DLG}" ] && [ "${DLG}" != "null" ]; then
131
+ DOFF=$(jq -r '.offset_ms // 0' <<<"${DLG}")
132
+ DGAIN=$(jq -r '.gain_db // 0' <<<"${DLG}")
133
+ INPUTS+=(-i "${PACK}/$(jq -r '.file' <<<"${DLG}")")
134
+ FILTER+="[${IDX}:a]adelay=${DOFF}|${DOFF},volume=${DGAIN}dB[d];"
135
+ LABELS+="[d]"; IDX=$((IDX + 1))
136
+ fi
139
137
 
140
- # 混入静音底:amix 的 inputs = 底轨 + 对白 + 音效,normalize=0 保住各自增益
141
- NMIX=$((IDX - 1))
142
- FILTER+="${LABELS}amix=inputs=${NMIX}:normalize=0:dropout_transition=0[aout]"
138
+ NSFX=$(jq -r '.sfx | length' <<<"${S}")
139
+ for ((j = 0; j < NSFX; j++)); do
140
+ X=$(jq -c ".sfx[${j}]" <<<"${S}")
141
+ XOFF=$(jq -r '.offset_ms // 0' <<<"${X}")
142
+ XGAIN=$(jq -r '.gain_db // 0' <<<"${X}")
143
+ INPUTS+=(-i "${PACK}/$(jq -r '.file' <<<"${X}")")
144
+ FILTER+="[${IDX}:a]adelay=${XOFF}|${XOFF},volume=${XGAIN}dB[x${j}];"
145
+ LABELS+="[x${j}]"; IDX=$((IDX + 1))
146
+ done
143
147
 
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
148
+ # 混入静音底:amix 的 inputs = 底轨 + 片子原声 + 对白 + 音效,normalize=0 保住各自增益
149
+ NMIX=$((IDX - 1 + NCLIP))
150
+ FILTER+="${LABELS}amix=inputs=${NMIX}:normalize=0:dropout_transition=0[aout]"
151
+
152
+ if [ -n "${SHOT_LUT}" ]; then
153
+ INPUTS+=(-i "${SHOT_LUT}")
154
+ ffmpeg -hide_banner -loglevel error -y "${INPUTS[@]}" \
155
+ -filter_complex "${FILTER};[1:v]${VF}[g];[g][${IDX}:v]haldclut[v]" \
156
+ -map "[v]" -map "[aout]" -t "${DUR}" \
157
+ -c:v libx264 -b:v "${VB}" -pix_fmt yuv420p -c:a pcm_s16le -ar 48000 -ac 2 \
158
+ "${DST}"
159
+ else
160
+ ffmpeg -hide_banner -loglevel error -y "${INPUTS[@]}" \
161
+ -filter_complex "${FILTER}" \
162
+ -map 1:v:0 -vf "${VF}" -map "[aout]" -t "${DUR}" \
163
+ -c:v libx264 -b:v "${VB}" -pix_fmt yuv420p -c:a pcm_s16le -ar 48000 -ac 2 \
164
+ "${DST}"
158
165
  fi
159
166
  echo "file '$(cd "$(dirname "${DST}")" && pwd)/$(basename "${DST}")'" >> "${TMP}/concat.txt"
160
167
  done
@@ -120,8 +120,8 @@ def write_srt(path, cues):
120
120
  # ── 编译 ───────────────────────────────────────────────────────────────────
121
121
  def compile_timeline(pack_dir, plan_path=None, outdir=None):
122
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},拒绝猜测")
123
+ if manifest.get("manifest_version") not in ("0.1", "0.2"):
124
+ sys.exit(f"不认识的 manifest_version: {manifest.get('manifest_version')!r},拒绝猜测(工具链过旧请重新 save_handoff_toolchain)")
125
125
 
126
126
  overlaps, trans = {}, {}
127
127
  if plan_path:
@@ -185,6 +185,12 @@ def compile_timeline(pack_dir, plan_path=None, outdir=None):
185
185
  "trim_head_ms": int(sh["clip"].get("trim_head_ms", 0)),
186
186
  "trim_tail_ms": int(sh["clip"].get("trim_tail_ms", 0)),
187
187
  "duration_ms": dur,
188
+ # 0.2:源片实际取多少(变速 / 定格补帧时 ≠ duration_ms);0.1 没有这些字段 = 常速整取
189
+ "src_take_ms": int(sh["clip"]["out_ms"]) - int(sh["clip"]["in_ms"])
190
+ if sh["clip"].get("out_ms") is not None and sh["clip"].get("in_ms") is not None else dur,
191
+ "speed_factor": float(sh["clip"].get("speed_factor") or 1),
192
+ "freeze_pad_ms": int(sh["clip"].get("freeze_pad_ms") or 0),
193
+ "color_graded": bool(sh["clip"].get("color_graded")),
188
194
  "in_ms": in_ms,
189
195
  "overlap_ms": ov,
190
196
  "has_baked_audio": bool(sh["clip"].get("has_baked_audio")),
@@ -148,8 +148,9 @@ def main():
148
148
  # MCP 工具可能把 manifest 包在 data 里返回,两种都认
149
149
  m = m.get("data", m) if isinstance(m.get("data"), dict) else m
150
150
 
151
- if m.get("manifest_version") != "0.1":
152
- sys.exit(f"不认识的 manifest_version: {m.get('manifest_version')!r},拒绝猜测")
151
+ # 0.2 = 入出点投影自平台终拼时间轴(新增 speed_factor / freeze_pad_ms / subtitles_burned_in 等);0.1 仍认
152
+ if m.get("manifest_version") not in ("0.1", "0.2"):
153
+ sys.exit(f"不认识的 manifest_version: {m.get('manifest_version')!r},拒绝猜测(工具链过旧请重新 save_handoff_toolchain)")
153
154
 
154
155
  out = a.outdir
155
156
  local_color = (m.get("postproduction") or {}).get("workflow") == "local_color"
@@ -181,7 +182,10 @@ def main():
181
182
  clip = sh.get("clip") or {}
182
183
  if clip.get("url"):
183
184
  rel = f"clips/shot_{tag}.mp4"
184
- expected_hashes[rel] = (sh.get("provenance") or {}).get("generation_sha256")
185
+ prov = sh.get("provenance") or {}
186
+ # 0.2:按实际交付文件的摘要校验;发的是合成 / 口型 / 换轨片时没有落库摘要(null)= 不校验,
187
+ # 绝不拿原片摘要去比派生片。0.1 清单没有这个键,沿用原片摘要
188
+ expected_hashes[rel] = prov["delivered_sha256"] if "delivered_sha256" in prov else prov.get("generation_sha256")
185
189
  jobs.append((clip.pop("url"), rel))
186
190
  clip["file"] = rel
187
191
 
@@ -200,6 +204,10 @@ def main():
200
204
 
201
205
  # 内联 cues → 逐镜 SRT(时间码基准仍是「该镜 trim 后的第 0 毫秒」,不做任何平移)
202
206
  cues = ((sh.get("subtitle") or {}).get("cues")) or []
207
+ if cues and clip.get("subtitles_burned_in"):
208
+ # 旧版合成片已烧字幕:不出 SRT,否则装配时再烧一层
209
+ print(f"镜 {n}: 片里已烧字幕(subtitles_burned_in),跳过 SRT", file=sys.stderr)
210
+ cues = []
203
211
  if cues:
204
212
  rel = f"subs/shot_{tag}.srt"
205
213
  cnt = write_srt(os.path.join(out, rel), cues)
@@ -241,7 +241,7 @@
241
241
  "save_designed_voice": "Save the candidate selected by the client into the voice library and return voice_id (like lib:12). **Charged the same as voice cloning** (saving the same candidate repeatedly does not charge again). Then bind it to a character with set_character_voice (narrator is also a character) before using it for dubbing; you can also use speak_with_voice first to audition more sentences.",
242
242
  "set_character_voice": "Bind a voice to a character; later generate_tts will use it to dub that character. Free. This is only needed when producing video—cloning and auditioning do not require a character.\n★★【In native-audio dramas (use_clip_audio=true, default), this step is “voice locking”】When the bound voice is **the client’s own authorized voice** (cloned by clone_voice / uploaded by the client, not a public voice), later video generation will pass this audio directly to the video vendor as reference audio. The vendor will speak according to it, and lip sync is naturally aligned—otherwise each group/single shot samples the voice independently, so the same character’s voice may drift across shots. Public voices are only used for TTS dubbing and will not be passed to the vendor.\n★Cost: shots where this character appears will no longer feed a character-sheet video anchor during video generation (the reference video’s audio track overrides reference audio; only one can be used). Character identity must instead be carried by the character sheet/reference images—so confirm the character sheet and reference images are ready before binding.\n★Only affects videos generated **after binding**: bind first, then generate_videos. For videos already generated, the cheapest way to unify the voice is repair_episode_dialogue (only_flagged=false, re-dub full lines with the cloned voice, do not regenerate video, charged at the TTS tier); regenerate_shot_video also works but is orders of magnitude more expensive.\n★After binding, assign_voices will no longer overwrite this character (the client’s own voice has highest priority).",
243
243
  "set_voice_anchor_from_shot": "Select a character’s voice line (voice baseline) from a shot in this episode. Free. No external audio source is required from the client—use the voice this character has already spoken in the video.\n★★【In native-audio dramas (use_clip_audio=true, default), this is “voice locking”】After selection, later video generation will pass this audio to the video vendor as reference audio, so the same character’s voice across shots follows it; without locking, the vendor samples each shot independently and the voice may drift across shots.\n★Which shot to choose: a shot where the character **speaks alone, has a longer line, and has no background music or interruptions by others**; start_s/dur_s can clip only part of it (if omitted, the default window is used, and duration is automatically clamped to each engine’s limit).\n★Only affects videos generated **afterward**. Shots already generated before locking will appear in get_pipeline_status under native_voice_anchor.stale_shots; regenerate them with regenerate_shot_video to unify the voice. The cheapest workflow is: generate one shot for each speaking character first, select the voice line, then batch-generate the remaining shots.\n★Works even if the dialogue field is empty: actual audio is authoritative—the platform uses calibrated voice activity detection to confirm there is speech in the selected interval, and uses the transcription text as anchor text; if no speech is detected or no usable text can be transcribed, it rejects (a wrong anchor would pollute all later shots’ voices, so reject rather than guess). The response field text_source indicates whether text came from dialogue or transcription.\n★Preview first with preview:true: only clips and returns preview_url plus anchor text, **without writing to the character**; if it sounds right, remove preview and formally submit with the same start_s/dur_s.\n★Noise removal and acceptance check (effective when enabled by the platform for the episode; response contains hygiene): automatically removes pure environmental-noise pauses between two lines and measures sample noise floor (noise_floor_before/after, threshold threshold). In preview, preview_url is the **cleaned** sample and raw_preview_url is the original—**ask the client to compare both before/after by listening**. verdict=fail means obvious environmental noise or music is under the voice (removing pauses cannot fix it; cloning and the video vendor will learn it as part of the timbre), and formal submission will be rejected: choose a cleaner shot, or adjust start_s/dur_s to avoid background sound; only pass allow_issues:true to write it if the client has listened and explicitly insists.\n★If the client has an authorized external audio source, use clone_voice + set_character_voice instead; choose one of the two, and the later setting overrides the earlier one.",
244
- "export_handoff_pack": "After delivery traceability is enabled, an immutable export manifest is saved (assets are not modified). Keep pack_id and manifest_sha256, and reference them when returning an external final cut. Export the “asset handoff package” manifest for this episode: downloadable URLs for each-shot raw clips + dialogue tracks + sound effects + music + subtitles, for you to complete transition decisions, stitching, mixing, and subtitle burn-in **on your own side**—the platform does not participate in final assembly. Free, zero credits.\n★Local color grading / third-party AI post: purpose=local_color; when there are only images, pass media_type=images, no need to generate video. This mode preserves source files, disables the export package’s automatic LUT, and includes post-production notes and a local comparison-page flow; it does not upload to third parties, charge for generation, or backfill the platform. Image sources may already have been processed, so not all exported images can be promised as ungraded originals.\n\n【Recommended workflow】① Call this tool to get manifest; ② Download raw clips locally via clips[].url; ③ Watch the clips yourself and decide which transition to use at each seam (manifest provides scene_boundary as a scene-boundary criterion), write a plan.json; ④ Use get_handoff_toolchain to get compile_timeline.py to expand the timeline and assemble.sh to assemble the final cut. The toolchain already calculates how subtitles/dialogue/SFX shift after overlapping transitions are added.\n\n【Three facts that will break things if ignored】\n① **Where the voice is must be read per shot from `shots[].voice_track.location`, not from the episode-level audio_contract.mode**—the latter is an episode-level declaration, while in all-offscreen-narration shots the narration is mixed into the raw clip on the platform completion side, so it can differ per shot within the same episode. The four values require different handling: `separate_file`=dialogue is in dialogue_audio; if you do not lay it in, this shot has no lines; `baked_in_clip`=already in the raw clip audio track; overlaying it again causes double voice; `missing`=**platform side confirmed missing** (narration compensation failed); subtitles still exist but the sound is nowhere—regenerate this shot on the platform; extending a transition cannot hide it; `unknown`=there are lines but the source cannot be found, **you must listen to the raw clip** before deciding. ★Subtitles never imply there is sound: subtitles are per sentence, audio is per shot. For shots with `conflicts_with_contract=true`, trust measurement; notes already name them by shot number.\n② Each shot must be trimmed using trim_head_ms / duration_ms before use; directly stitching the full raw clip will include head/tail deformed frames that the platform already QC’d out.\n③ Subtitle cues, dialogue_audio.offset_ms, and sfx[].offset_ms are all based on “millisecond 0 after this shot’s trim”, not absolute final-cut time. No matter how many overlapping transitions you add, do not modify them—let compile_timeline.py expand them; do not manually accumulate times.\n\nBefore you assemble, run build_av_timing_contract → get_av_timing_contract (free): per shot the measured source length, the platform final cut's in/out points, transition overlap and freeze padding, and whether the voice is already mixed into the clip or on a separate track — check your timeline's total against it.\n\nIf you want the platform to assemble it, or need platform-level quality gates (final assembly precheck / equal audio-video duration / loudness mastering), use compose_episode.",
244
+ "export_handoff_pack": "After delivery traceability is enabled, an immutable export manifest is saved (assets are not modified). Keep pack_id and manifest_sha256, and reference them when returning an external final cut. Export the “asset handoff package” manifest for this episode: downloadable URLs for each-shot raw clips + dialogue tracks + sound effects + music + subtitles, for you to complete transition decisions, stitching, mixing, and subtitle burn-in **on your own side**—the platform does not participate in final assembly. Free, zero credits.\n★Local color grading / third-party AI post: purpose=local_color; when there are only images, pass media_type=images, no need to generate video. This mode preserves source files, disables the export package’s automatic LUT, and includes post-production notes and a local comparison-page flow; it does not upload to third parties, charge for generation, or backfill the platform. Image sources may already have been processed, so not all exported images can be promised as ungraded originals.\n\n【Recommended workflow】① Call this tool to get manifest; ② Download raw clips locally via clips[].url; ③ Watch the clips yourself and decide which transition to use at each seam (manifest provides scene_boundary as a scene-boundary criterion), write a plan.json; ④ Use get_handoff_toolchain to get compile_timeline.py to expand the timeline and assemble.sh to assemble the final cut. The toolchain already calculates how subtitles/dialogue/SFX shift after overlapping transitions are added.\n\n【Three facts that will break things if ignored】\n① **Where the voice is must be read per shot from `shots[].voice_track.location`, not from the episode-level audio_contract.mode**—the latter is an episode-level declaration, while in all-offscreen-narration shots the narration is mixed into the raw clip on the platform completion side, so it can differ per shot within the same episode. The four values require different handling: `separate_file`=dialogue is in dialogue_audio; if you do not lay it in, this shot has no lines; `baked_in_clip`=already in the raw clip audio track; overlaying it again causes double voice; `missing`=**platform side confirmed missing** (narration compensation failed); subtitles still exist but the sound is nowhere—regenerate this shot on the platform; extending a transition cannot hide it; `unknown`=there are lines but the source cannot be found, **you must listen to the raw clip** before deciding. ★Subtitles never imply there is sound: subtitles are per sentence, audio is per shot. For shots with `conflicts_with_contract=true`, trust measurement; notes already name them by shot number.\n② Each shot must be trimmed using trim_head_ms / duration_ms before use; directly stitching the full raw clip will include head/tail deformed frames that the platform already QC’d out. ★From manifest_version=0.2 the in/out points are projected from the platform's final-cut timeline (the same as build_av_timing_contract and the platform cut), and clip.url is the file that timeline actually uses (clip.source_kind); in 0.1 the pack computed its own trim, which did not match the platform cut. speed_factor≠1 must be retimed by that factor and freeze_pad_ms>0 must be filled by cloning the last frame (duration_ms=(out_ms−in_ms)/speed_factor+freeze_pad_ms), or the total length will not match; subtitles_burned_in=true means do not burn subtitles again, color_graded=true means do not apply the LUT again; shots with trim_basis≠platform_timeline are untrimmed, and notes say why. An old toolchain copy that only accepts 0.1 will reject 0.2 — run save_handoff_toolchain again.\n③ Subtitle cues, dialogue_audio.offset_ms, and sfx[].offset_ms are all based on “millisecond 0 after this shot’s trim”, not absolute final-cut time. No matter how many overlapping transitions you add, do not modify them—let compile_timeline.py expand them; do not manually accumulate times.\n\nBefore you assemble, run build_av_timing_contract → get_av_timing_contract (free): per shot the measured source length, the platform final cut's in/out points, transition overlap and freeze padding, and whether the voice is already mixed into the clip or on a separate track — check your timeline's total against it.\n\nIf you want the platform to assemble it, or need platform-level quality gates (final assembly precheck / equal audio-video duration / loudness mastering), use compose_episode.",
245
245
  "get_handoff_toolchain": "Get the source code for the asset handoff package assembly toolchain (compile_timeline.py timeline compiler / assemble.sh ffmpeg assembly script). Free. This tool only returns source text and **will not** write files on your machine—save it to your working directory yourself before running.\ncompile_timeline.py expands “shot-relative” subtitle/dialogue/SFX anchors into absolute timecodes on your own timeline and handles global offsets caused by overlapping transitions; assemble.sh is a complete ffmpeg assembly baseline from raw clips to final cut (trim → normalize → concat/xfade → music sidechain → subtitles → two-pass master). Dependencies: ffmpeg (with libass for subtitle burn-in), ffprobe, jq, python3.",
246
246
  "save_handoff_toolchain": "Write the assembly toolchain (fetch_pack.py / compile_timeline.py / assemble.sh) directly to a local directory, avoiding manual copy/paste. Free. The directory must **already exist** and be an absolute path; filenames are fixed and cannot be customized.\nRefuses to write into hidden directories (~/.ssh, ~/.config, .git…) and system directories—this is disk writing, not reading, and overwriting the wrong place is irreversible.\nFull workflow after writing to disk:\n python3 fetch_pack.py manifest.json -o ./pack # Download assets and materialize inline subtitles as SRT\n python3 compile_timeline.py ./pack [--transitions plan.json]\n ./assemble.sh ./pack out.mp4 [plan.json]",
247
247
  "get_post_production_checklist": "Read this episode's **post-production effects checklist** (free). Items marked in the script as [VFX: …] or written in shot prompts as \"post-production / composited later\" are **not composited by the platform**—image and video generation leave them out and the final cut does not add them, so the finished episode lacks these story elements unless handled. Per item: shot_number, time start_s~end_s, content, source (vfx_tag shot annotation / prompt / script_unassigned = in the rewritten script but not attached to any shot), narrative_required (story-critical; cosmetic items such as color grading are false), status (pending / done_external / waived). ★Review before composing; tell the client about narrative_required pending items and ask: do it in external post (then mark_post_production_item done_external), change the shot so the platform draws it (update_shot then regenerate that shot), or drop it (waived). review_videos / compose_episode give an advisory for unhandled items.",
@@ -258,5 +258,9 @@
258
258
  "get_local_frame_edit": "Check progress and results of a local frame edit (free). status=running → keep waiting; when completed, look at each candidate: composited_url (the result pasted back onto the original — show this to the customer), verdict (pass = usable; warn = usable but needs a human look — REGION_SEAM_WARN possible seam / EDIT_NOT_APPLIED barely changed / IDENTITY_NEEDS_REVIEW face-comparison score in the grey zone, not auto-approved / IDENTITY_NOT_JUDGEABLE side view, looking down or face too small to judge / IDENTITY_COMPARE_LOW legacy identity score low / IDENTITY_AUDIT_UNAVAILABLE identity not audited; reject = not recommended — REGION_MISALIGNED misaligned paste / IDENTITY_MISMATCH face comparison says it became a different person / IDENTITY_AUDIT_REJECTED frame audit failed, possibly a composition issue already present in the original frame), reason_text (a plain-language explanation of each reason — relay it to the customer as is), metrics, identity_audit, identity_compare (face edits: with method=face_embedding the decision uses face embeddings — similarity to the same face before the edit ≥0.75 passes, <0.60 is a mismatch, in between goes to a human; vlm_reference is recorded only. Without a method field it is the legacy advisory score). ★Still check face edits with your own eyes — the platform cannot judge side views, downward-looking or small faces. Download and look at composited_url yourself, tell the customer, then adopt_local_frame_edit the one they pick. Rejected candidates are not refunded (including those failing the frame audit because of an issue already in the original frame).",
259
259
  "adopt_local_frame_edit": "Adopt a local-edit candidate as this shot's current frame (free). Same follow-up as an uploaded frame: the shot video is marked for regeneration and adjacent seams are repaired. ★Only after the customer has looked and chosen. If the frame changed after the edit → 409 BASE_FRAME_CHANGED (start a new edit on the current frame); verdict=reject candidates are refused by default (409 CANDIDATE_REJECTED) — pass accept_rejected:true only when the customer insists after seeing it.",
260
260
  "audit_seam_directions": "Pre-video seam direction check (advisory, never blocks any step): for every pair of adjacent shots, compares \"previous shot's last frame (or first frame if none) ↔ this shot's first frame\" for the same subject jumping left/right sides of the frame (a reversed eyeline is noted with it), reversed motion direction, and shot-size jumps (same subject, extreme long ↔ close-up). Eyeline alone and key-light direction are observed but not reported: production replay showed too many false positives on those (better to miss than to nag). Shots that look fine alone but jump when cut together usually fail on these. Complements audit_seams, which checks identity/wardrobe/props/object state but not direction or position. ★When: after first frames (and needed last frames) are done and chain_frames has run, before review_frames / generate_videos. Scene changes, declared jump cuts, insert shots and title/black cards are skipped automatically. ★dry_run first (default, free): returns the seams to observe plus estimated_points (upper bound) and typical_points (usual cost); tell the client both — billed by actual text tokens. After the client agrees, pass dry_run=false + confirm_usage_billing=true. Execution is asynchronous and returns immediately; read results with get_seam_directions a minute or two later. Seams already observed with unchanged frames are not billed again.",
261
- "get_seam_directions": "(Free) Read audit_seam_directions results: each issue gives the seam shot numbers, the problem (subject jumps sides, reversed motion, shot-size jump) and fixes (chain_shot_from mode=reframe to regenerate this shot's first frame anchored on the previous last frame / update_shot to state facing then generate_shot_frame / align_prev_shot when the previous shot already has video / split_shot to insert an intermediate shot size). Results are advisory: explain them to the client and let the client pick which to fix; intentional reverse shots, turns or impact cuts can be ignored. not_audited>0 = seams not observed yet or frames changed since; running=true = still running. review_frames' seam_direction_advisory is a summary of the same results. This tool only looks at pre-video still frames; after videos are generated, check seams with preview_seams (free short-clip previews)."
261
+ "get_seam_directions": "(Free) Read audit_seam_directions results: each issue gives the seam shot numbers, the problem (subject jumps sides, reversed motion, shot-size jump) and fixes (chain_shot_from mode=reframe to regenerate this shot's first frame anchored on the previous last frame / update_shot to state facing then generate_shot_frame / align_prev_shot when the previous shot already has video / split_shot to insert an intermediate shot size). Results are advisory: explain them to the client and let the client pick which to fix; intentional reverse shots, turns or impact cuts can be ignored. not_audited>0 = seams not observed yet or frames changed since; running=true = still running. review_frames' seam_direction_advisory is a summary of the same results. This tool only looks at pre-video still frames; after videos are generated, check seams with preview_seams (free short-clip previews).",
262
+ "get_shot_verse_cards": "Read this shot's current poem/lyric text cards (free). set_shot_verse_cards is a **full overwrite**—read before editing. enabled=false means the drama is not enabled yet (saved cards will not appear in the cut).",
263
+ "set_shot_verse_cards": "Add/change/clear poem & lyric text cards on one shot (burned into the final cut; free, data only). Up to 6 cards per shot, **full overwrite** (read with get_shot_verse_cards first); cards=[] clears. Covers three staples of cinematic period-style shorts: (1) vertical or horizontal poem/lyric lines fading in at the left, right or center (a newline in text starts a new column/line, punctuation is kept; stagger several cards with start_ms); (2) a full-screen black closing card with backdrop=\"black\" (usually on the last shot, orient=horizontal, position=center); (3) end credits over the picture with motion=\"scroll_up\" (scrolls bottom to top, one name per line, blank lines separate groups). Cards automatically stay out of the dialogue-subtitle band, so they never overlap spoken subtitles; oversized text is shrunk instead of overflowing. Run compose_episode afterwards to re-cut (rerender_episode reuses the saved timeline and will not pick it up). enabled=false from get_shot_verse_cards means the drama is not enabled yet. ★Do not download the video, overlay text yourself and re-upload—that bypasses renderer fonts and subtitle avoidance. ★Only use the poem/credits text the customer supplied; do not invent it.",
264
+ "get_theme_songs": "Read an episode's theme-song settings (free). set_theme_songs is a **full overwrite**—read before editing. enabled=false means the drama is not enabled yet.",
265
+ "set_theme_songs": "Attach/change/clear **theme songs with vocals** on an episode (opening song, ending song, or an insert song over a range of shots; free, data only, full overwrite, songs=[] clears; up to 3 per episode). Song source, pick one: (1) the customer's own song—upload via upload-url (kind=audio) and pass the public_url as audio_url; (2) platform-composed or library track—pass track_id; (3) an existing https URL. placement: opening = from 0 until the song ends or shot sb_to ends / ending = pinned to the end, or starting at shot sb_from / range = shots sb_from..sb_to (both required). Vocals never fight dialogue: with instrumental_url (the same song's backing track) the default dialogue_mode=instrumental fades the vocal out and the backing track in while someone speaks, then swaps back; with only the full song, dialogue_mode=duck lowers the whole song by duck_db (default -18 dB) during speech. Background music automatically yields inside the theme-song range (bgm_yield, default true) and is unchanged elsewhere. gain_db defaults to -8; track_offset_ms skips an intro. Run compose_episode afterwards; enabled=false means the drama is not enabled yet. ★Do not download the cut, remix it yourself and re-upload."
262
266
  }
@@ -256,7 +256,11 @@
256
256
  "save_designed_voice": "把客户挑中的那条候选存进音色库,返回 voice_id(形如 lib:12)。**按声音克隆同价扣费**(同一条重复保存不重复扣)。之后用 set_character_voice 绑到角色(旁白也是一个角色)才会用它配音;也可以先 speak_with_voice 试听更多句子。",
257
257
  "set_character_voice": "把音色绑到某角色,之后 generate_tts 用它给该角色配音。免费。只有要出片时才需要这步——克隆和试听都不需要角色。\n★★【原声剧(use_clip_audio=true,默认)里这一步就是「锁声线」】绑的是**客户自己的授权音色**(clone_voice 克隆的 / 客户上传的,不是公共音色)时,之后生成的视频会把这段声音直接交给视频厂商当参考音频,厂商按它发声、口型天然同步——否则每个组/单镜各自采样嗓音,同一角色前后镜声线会漂。公共音色只用于 TTS 配音,不会交给厂商。\n★代价:该角色出场的镜头生成视频时不再喂定妆视频锚(参考视频的音轨会压过参考音频,只能二选一),人物身份改由定妆图/设定图承担——所以绑之前先确认定妆图、设定图已经到位。\n★只对**绑定之后**生成的视频生效:要先绑、再 generate_videos。已经出好的视频要统一声线,用 repair_episode_dialogue(only_flagged=false,克隆音重配整句、不重生视频、按 TTS 档计费)最省;重生视频(regenerate_shot_video)也行但贵几个数量级。\n★绑好后 assign_voices 不会再覆盖这个角色(客户自己的声音优先级最高)。",
258
258
  "set_voice_anchor_from_shot": "从本剧某一镜选定角色的声线(声音基准)。免费。不需要客户提供外部音源——就用片子里这个角色已经念出来的声音。\n★★【原声剧(use_clip_audio=true,默认)里这就是「锁声线」】选定后,之后生成的视频会把这段声音交给视频厂商当参考音频,同一角色前后镜的嗓音跟它走;不锁的话每镜由厂商各自采样,前后镜声线会漂。\n★选哪一镜:该角色**独自说话、台词较长、没有背景音乐和别人插话**的镜;start_s/dur_s 可只截其中一段(不传则取默认窗口,时长会按各引擎上限自动钳制)。\n★只对**之后**生成的视频生效。锁之前已经出好的镜会出现在 get_pipeline_status 的 native_voice_anchor.stale_shots 里,用 regenerate_shot_video 重出才会统一。所以最省的做法是:每个说话角色先出一镜、选定声线,再批量出其余镜。\n★台词栏为空也能用:以实际音频为准——平台用已标定的人声探测确认所选区间里有人声,并把转写文字当锚文本;探测不到人声、或转写不出可用文字就拒(锚选错会污染此后所有镜的嗓音,宁拒不猜)。响应里 text_source 标明文本来自台词还是转写。\n★先 preview:true 试听:只截取并返回 preview_url 与锚文本,**不写入角色**;听着对再去掉 preview、用同样的 start_s/dur_s 正式提交。\n★去环境音与验收(平台按剧开启时生效,响应里有 hygiene 字段):自动剔除两句之间的纯环境音停顿,并测样本底噪(noise_floor_before/after,阈值 threshold)。preview 时 preview_url 是**清理后**的样本、raw_preview_url 是原样本——**请让客户前后对比试听**。verdict=fail 表示人声下面垫着明显的环境声或音乐(剔停顿救不了,克隆和视频厂商会把它当音色学走),正式提交会被拒:换一镜念得更干净的、或调 start_s/dur_s 避开背景声;只有客户听过后明确坚持,才带 allow_issues:true 写入。\n★有客户授权的外部音源时改用 clone_voice + set_character_voice;两者二选一,后设的覆盖先设的。",
259
- "export_handoff_pack": "启用交付追溯后会保存不可变导出清单(不改素材)。保留 pack_id 和 manifest_sha256,回传外部成片时引用。导出本集「素材交接包」清单:逐镜裸片 + 对白音轨 + 音效 + 配乐 + 字幕的可下载 URL,交给你在**自己那边**完成转场决策、拼接、混音、烧字幕——平台不参与终拼。免费,零扣费。\n★本地调色/第三方 AI 后期:purpose=local_color;只有图片时传 media_type=images,无需生成视频。本模式保留源文件、关闭导出包的自动 LUT、附带后期说明和本地对比页流程;不上传第三方、不收费生成、不回填平台。图片来源可能已有处理,不能把所有导出图承诺为未调色原片。\n\n【推荐流程】① 调本工具拿 manifest;② 按 clips[].url 把裸片下载到本地;③ 你自己看片判断每个接缝该用什么转场(manifest 给了 scene_boundary 场景边界作判据),写一份 plan.json;④ 用 get_handoff_toolchain 拿到 compile_timeline.py 展开时间轴、assemble.sh 装配出成片。工具链已经把「加了重叠转场之后字幕/对白/音效怎么跟着位移」算好了。\n\n【三个不看就会翻车的事实】\n① **人声在哪要逐镜读 `shots[].voice_track.location`,不能读整集的 audio_contract.mode**——后者是整剧级声明,而全画外旁白镜的旁白是平台在完成侧混进裸片的,同一集里逐镜可以不同。四种取值各有各的处置:`separate_file`=对白在 dialogue_audio,不铺这镜就没台词;`baked_in_clip`=已在裸片音轨里,再叠一遍会双声;`missing`=**平台侧确认缺失**(旁白补偿失败),字幕还在但声音哪都没有——回平台重生成该镜,拉长转场掩盖不了;`unknown`=有台词却查不到来源,**必须试听裸片**再决定。★有字幕从来不等于有声音:字幕是逐句的,音频是逐镜的。`conflicts_with_contract=true` 的镜以实测为准,notes 里已按镜号点名。\n② 每镜必须按 trim_head_ms / duration_ms 裁剪再用;直接拼整条裸片会把平台已经 QC 掉的首尾形变帧一起拼进去。\n③ 字幕 cue、dialogue_audio.offset_ms、sfx[].offset_ms 的基准都是「该镜 trim 之后的第 0 毫秒」,不是成片绝对时间。你加多少重叠转场都不用改它们——交给 compile_timeline.py 展开,别手算累加。\n\n拼之前先跑 build_av_timing_contract → get_av_timing_contract(免费):逐镜实测源片时长、平台终拼的入出点/转场占用/定格补帧、人声已混入视频还是独立轨,拿它核对你的时间轴总长。\n想让平台代拼、要平台级质量闸(终拼预检/音画等长/响度母带),改用 compose_episode。",
259
+ "export_handoff_pack": "启用交付追溯后会保存不可变导出清单(不改素材)。保留 pack_id 和 manifest_sha256,回传外部成片时引用。导出本集「素材交接包」清单:逐镜裸片 + 对白音轨 + 音效 + 配乐 + 字幕的可下载 URL,交给你在**自己那边**完成转场决策、拼接、混音、烧字幕——平台不参与终拼。免费,零扣费。\n★本地调色/第三方 AI 后期:purpose=local_color;只有图片时传 media_type=images,无需生成视频。本模式保留源文件、关闭导出包的自动 LUT、附带后期说明和本地对比页流程;不上传第三方、不收费生成、不回填平台。图片来源可能已有处理,不能把所有导出图承诺为未调色原片。\n\n【推荐流程】① 调本工具拿 manifest;② 按 clips[].url 把裸片下载到本地;③ 你自己看片判断每个接缝该用什么转场(manifest 给了 scene_boundary 场景边界作判据),写一份 plan.json;④ 用 get_handoff_toolchain 拿到 compile_timeline.py 展开时间轴、assemble.sh 装配出成片。工具链已经把「加了重叠转场之后字幕/对白/音效怎么跟着位移」算好了。\n\n【三个不看就会翻车的事实】\n① **人声在哪要逐镜读 `shots[].voice_track.location`,不能读整集的 audio_contract.mode**——后者是整剧级声明,而全画外旁白镜的旁白是平台在完成侧混进裸片的,同一集里逐镜可以不同。四种取值各有各的处置:`separate_file`=对白在 dialogue_audio,不铺这镜就没台词;`baked_in_clip`=已在裸片音轨里,再叠一遍会双声;`missing`=**平台侧确认缺失**(旁白补偿失败),字幕还在但声音哪都没有——回平台重生成该镜,拉长转场掩盖不了;`unknown`=有台词却查不到来源,**必须试听裸片**再决定。★有字幕从来不等于有声音:字幕是逐句的,音频是逐镜的。`conflicts_with_contract=true` 的镜以实测为准,notes 里已按镜号点名。\n② 每镜必须按 trim_head_ms / duration_ms 裁剪再用;直接拼整条裸片会把平台已经 QC 掉的首尾形变帧一起拼进去。★manifest_version=0.2 起入出点投影自平台终拼时间轴(与 build_av_timing_contract、平台成片同一套),clip.url 是时间轴实际用的那个文件(clip.source_kind);0.1 时代交接包自算的 trim 与成片对不上。speed_factor≠1 要按倍率变速、freeze_pad_ms>0 要克隆末帧补齐(duration_ms=(out_ms−in_ms)/speed_factor+freeze_pad_ms),否则总长对不上;subtitles_burned_in=true 别再烧字幕,color_graded=true 别再套 LUT;trim_basis≠platform_timeline 的镜是整条不裁,notes 写了原因。工具链只认 0.1 的旧副本会拒收 0.2,重新 save_handoff_toolchain。\n③ 字幕 cue、dialogue_audio.offset_ms、sfx[].offset_ms 的基准都是「该镜 trim 之后的第 0 毫秒」,不是成片绝对时间。你加多少重叠转场都不用改它们——交给 compile_timeline.py 展开,别手算累加。\n\n拼之前先跑 build_av_timing_contract → get_av_timing_contract(免费):逐镜实测源片时长、平台终拼的入出点/转场占用/定格补帧、人声已混入视频还是独立轨,拿它核对你的时间轴总长。\n想让平台代拼、要平台级质量闸(终拼预检/音画等长/响度母带),改用 compose_episode。",
260
260
  "get_handoff_toolchain": "拿到素材交接包的装配工具链源码(compile_timeline.py 时间轴编译器 / assemble.sh ffmpeg 装配脚本)。免费。本工具只返回源码文本,**不会**在你机器上写文件——请自行保存到工作目录再执行。\ncompile_timeline.py 负责把「镜相对」的字幕/对白/音效锚点展开成你自己时间轴上的绝对时间码,并处理重叠转场引起的整体位移;assemble.sh 是从裸片到成片的完整 ffmpeg 装配基线(裁剪→规范化→拼接/xfade→配乐侧链→字幕→两 pass 母带)。依赖 ffmpeg(烧字幕需带 libass)、ffprobe、jq、python3。",
261
- "save_handoff_toolchain": "把装配工具链(fetch_pack.py / compile_timeline.py / assemble.sh)直接写到你本地的一个目录,省掉自己复制粘贴。免费。目录必须**已存在**且是绝对路径;文件名固定,不接受自定义。\n拒绝写入隐藏目录(~/.ssh、~/.config、.git…)与系统目录——这是写盘不是读盘,覆盖错地方不可逆。\n落盘后完整流程:\n python3 fetch_pack.py manifest.json -o ./pack # 下载素材、内联字幕落成 SRT\n python3 compile_timeline.py ./pack [--transitions plan.json]\n ./assemble.sh ./pack out.mp4 [plan.json]"
261
+ "save_handoff_toolchain": "把装配工具链(fetch_pack.py / compile_timeline.py / assemble.sh)直接写到你本地的一个目录,省掉自己复制粘贴。免费。目录必须**已存在**且是绝对路径;文件名固定,不接受自定义。\n拒绝写入隐藏目录(~/.ssh、~/.config、.git…)与系统目录——这是写盘不是读盘,覆盖错地方不可逆。\n落盘后完整流程:\n python3 fetch_pack.py manifest.json -o ./pack # 下载素材、内联字幕落成 SRT\n python3 compile_timeline.py ./pack [--transitions plan.json]\n ./assemble.sh ./pack out.mp4 [plan.json]",
262
+ "get_shot_verse_cards": "读这一镜的诗句/歌词字卡现值(免费)。set_shot_verse_cards 是**整组覆盖**,改之前先读。enabled=false 表示本剧尚未开通(存了也暂不进成片)。",
263
+ "set_shot_verse_cards": "给某一镜加/改/清「诗句·歌词字卡」(终拼时烧进成片;免费纯数据)。一镜最多 6 张,**整组覆盖**(先 get_shot_verse_cards 读现值再改),cards=[] 清空。国风电影感短片的三种标配都走它:①画面一侧/居中淡入的竖排或横排诗句、歌词(text 里换行=另起一列/一行,标点原样保留,可多张错开 start_ms 依次出现);②整屏黑底结语卡 backdrop=\"black\"(通常放末镜,orient=horizontal、position=center);③叠在画面上的片尾名单 motion=\"scroll_up\"(自下而上滚动,一行一个名字,空行分组)。字卡自动避开对白字幕所在的那条带,不会与台词重叠;字号放不下会自动缩小,不会顶出画面。改完调 compose_episode 重新终拼生效(rerender_episode 沿用已存时间轴,带不上)。get_shot_verse_cards 的 enabled=false 表示本剧尚未开通(存了也暂不进成片)。★别自己下载视频叠字再上传——那会绕开渲染机字体与字幕避让。★文字只写客户给的诗句/名单,别替客户编。",
264
+ "get_theme_songs": "读某一集的主题曲设置(免费)。set_theme_songs 是**整组覆盖**,改之前先读。enabled=false 表示本剧尚未开通(存了也暂不进成片)。",
265
+ "set_theme_songs": "给某一集挂/改/清**带人声的主题曲**(片头曲、片尾曲或某几镜的插曲;免费纯数据,整组覆盖,songs=[] 清空;一集最多 3 首)。歌来源三选一:①客户自己的歌——先 upload-url(kind=audio)上传拿 public_url 当 audio_url;②平台作曲/曲库——带 track_id(get_mv_song、list 曲库返回的 id);③已有 https 地址。placement:opening=片头(从 0 开始,放到歌完或 sb_to 那镜结束)/ending=片尾(贴片尾,或从 sb_from 那镜开始)/range=sb_from~sb_to 镜头段落(两端必填)。人声不和台词打架:给了 instrumental_url(同一首歌的伴奏轨)默认 dialogue_mode=instrumental——有台词时人声渐隐、伴奏接上,说完换回;只有原曲则 dialogue_mode=duck——有台词时整首压低 duck_db(默认 -18dB)。主题曲播放区间里配乐自动让开(bgm_yield,默认 true),区间外照常。gain_db 默认 -8,track_offset_ms 跳过前奏。改完调 compose_episode 重新终拼生效;enabled=false 表示本剧尚未开通。★别自己下载成片混音再上传。"
262
266
  }
@@ -278,9 +278,9 @@ export const PIPELINE = [
278
278
  },
279
279
  {
280
280
  step: '9 音频',
281
- tools: ['assign_voices', 'generate_tts(仅 use_clip_audio=false)', 'clone_voice', 'generate_bgm', 'get_bgm_plan', 'quote_bgm_candidates', 'select_bgm_candidate', 'get_bgm_status', 'generate_sfx', 'lipsync_shot', 'lipsync_episode', 'get_lipsync_status', 'set_shot_name_card'],
281
+ tools: ['assign_voices', 'generate_tts(仅 use_clip_audio=false)', 'clone_voice', 'generate_bgm', 'get_bgm_plan', 'quote_bgm_candidates', 'select_bgm_candidate', 'get_bgm_status', 'generate_sfx', 'lipsync_shot', 'lipsync_episode', 'get_lipsync_status', 'set_shot_name_card', 'set_shot_verse_cards(诗句/歌词字卡·黑底结语卡·滚动片尾名单)', 'set_theme_songs(带人声的片头/片尾/插曲,台词时压低或切伴奏)'],
282
282
  billing: '混合',
283
- note: '默认视频原声跳过 TTS 三步(pipeline-status 里显示 not_required,不是没做完)。',
283
+ note: '默认视频原声跳过 TTS 三步(pipeline-status 里显示 not_required,不是没做完)。国风电影感短片的诗句字卡与主题曲在平台内完成(compose_episode 时烧录/混音),别下载成片外挂后期。',
284
284
  },
285
285
  {
286
286
  step: '10 成片与交付',
@@ -2009,6 +2009,42 @@ export function registerProduceTools(server, client) {
2009
2009
  ...(start_ms !== undefined ? { start_ms } : {}),
2010
2010
  ...(position !== undefined ? { position } : {}),
2011
2011
  })));
2012
+ server.tool('get_shot_verse_cards', '读这一镜的诗句/歌词字卡现值(免费)。set_shot_verse_cards 是**整组覆盖**,改之前先读。enabled=false 表示本剧尚未开通(存了也暂不进成片)。', { storyboard_id: z.number().int().positive() }, async ({ storyboard_id }) => jsonResult(await client.produceGet(`/storyboards/${storyboard_id}/verse-cards`)));
2013
+ server.tool('set_shot_verse_cards', '给某一镜加/改/清「诗句·歌词字卡」(终拼时烧进成片;免费纯数据)。一镜最多 6 张,**整组覆盖**(先 get_shot_verse_cards 读现值再改),cards=[] 清空。国风电影感短片的三种标配都走它:①画面一侧/居中淡入的竖排或横排诗句、歌词(text 里换行=另起一列/一行,标点原样保留,可多张错开 start_ms 依次出现);②整屏黑底结语卡 backdrop=\"black\"(通常放末镜,orient=horizontal、position=center);③叠在画面上的片尾名单 motion=\"scroll_up\"(自下而上滚动,一行一个名字,空行分组)。字卡自动避开对白字幕所在的那条带,不会与台词重叠;字号放不下会自动缩小,不会顶出画面。改完调 compose_episode 重新终拼生效(rerender_episode 沿用已存时间轴,带不上)。get_shot_verse_cards 的 enabled=false 表示本剧尚未开通(存了也暂不进成片)。★别自己下载视频叠字再上传——那会绕开渲染机字体与字幕避让。★文字只写客户给的诗句/名单,别替客户编。', {
2014
+ storyboard_id: z.number().int().positive(),
2015
+ cards: z.array(z.object({
2016
+ text: z.string().min(1).describe('字卡文字;换行=另起一列(竖排)/一行(横排);≤300 字'),
2017
+ orient: z.enum(['vertical', 'horizontal']).optional().describe('默认 vertical 竖排(从右往左);滚动名单自动横排'),
2018
+ position: z.enum(['left', 'right', 'center']).optional().describe('默认 right;滚动名单默认 center'),
2019
+ start_ms: z.number().int().min(0).max(60000).optional().describe('镜内起点毫秒(相对本镜开头),默认 0'),
2020
+ duration_ms: z.number().int().min(500).max(60000).optional().describe('显示时长,默认 4000;超出本镜部分截掉'),
2021
+ fade_in_ms: z.number().int().min(0).max(5000).optional().describe('淡入毫秒,默认 600'),
2022
+ fade_out_ms: z.number().int().min(0).max(5000).optional().describe('淡出毫秒,默认 600'),
2023
+ font_size: z.number().int().min(20).max(200).optional().describe('字号上限(输出像素),默认 64;放不下自动缩小'),
2024
+ font: z.string().optional().describe('字体名,默认行书 Zhi Mang Xing;规整宋体填 Noto Serif CJK SC(渲染机已装的才生效)'),
2025
+ backdrop: z.enum(['none', 'black']).optional().describe('black = 整屏黑底结语卡'),
2026
+ motion: z.enum(['static', 'scroll_up']).optional().describe('scroll_up = 自下而上滚动的片尾名单'),
2027
+ })).max(6).describe('整组字卡(顺序即渲染顺序);[] = 清空本镜字卡'),
2028
+ }, async ({ storyboard_id, cards }) => jsonResult(await client.producePut(`/storyboards/${storyboard_id}/verse-cards`, { cards })));
2029
+ server.tool('get_theme_songs', '读某一集的主题曲设置(免费)。set_theme_songs 是**整组覆盖**,改之前先读。enabled=false 表示本剧尚未开通(存了也暂不进成片)。', { episode_id: z.number().int().positive() }, async ({ episode_id }) => jsonResult(await client.produceGet(`/episodes/${episode_id}/theme-songs`)));
2030
+ server.tool('set_theme_songs', '给某一集挂/改/清**带人声的主题曲**(片头曲、片尾曲或某几镜的插曲;免费纯数据,整组覆盖,songs=[] 清空;一集最多 3 首)。歌来源三选一:①客户自己的歌——先 upload-url(kind=audio)上传拿 public_url 当 audio_url;②平台作曲/曲库——带 track_id(get_mv_song、list 曲库返回的 id);③已有 https 地址。placement:opening=片头(从 0 开始,放到歌完或 sb_to 那镜结束)/ending=片尾(贴片尾,或从 sb_from 那镜开始)/range=sb_from~sb_to 镜头段落(两端必填)。人声不和台词打架:给了 instrumental_url(同一首歌的伴奏轨)默认 dialogue_mode=instrumental——有台词时人声渐隐、伴奏接上,说完换回;只有原曲则 dialogue_mode=duck——有台词时整首压低 duck_db(默认 -18dB)。主题曲播放区间里配乐自动让开(bgm_yield,默认 true),区间外照常。gain_db 默认 -8,track_offset_ms 跳过前奏。改完调 compose_episode 重新终拼生效;enabled=false 表示本剧尚未开通。★别自己下载成片混音再上传。', {
2031
+ episode_id: z.number().int().positive(),
2032
+ songs: z.array(z.object({
2033
+ audio_url: z.string().optional().describe('原曲(带人声)https 地址或 static/ 路径;与 track_id 二选一'),
2034
+ track_id: z.number().int().positive().optional().describe('曲库/平台作曲的曲目 id(本人上传或公开的)'),
2035
+ instrumental_url: z.string().optional().describe('同一首歌的伴奏轨(与原曲逐采样对齐);给了才能「台词段只留伴奏」'),
2036
+ placement: z.enum(['opening', 'ending', 'range']),
2037
+ sb_from: z.number().int().positive().optional().describe('起镜号(storyboard_number)'),
2038
+ sb_to: z.number().int().positive().optional().describe('止镜号(含)'),
2039
+ track_offset_ms: z.number().int().min(0).optional().describe('从曲内第几毫秒开始放(跳过前奏)'),
2040
+ gain_db: z.number().min(-30).max(6).optional().describe('主题曲音量 dB,默认 -8'),
2041
+ fade_in_ms: z.number().int().min(0).max(10000).optional().describe('默认 1000'),
2042
+ fade_out_ms: z.number().int().min(0).max(10000).optional().describe('默认 2000'),
2043
+ dialogue_mode: z.enum(['duck', 'instrumental']).optional().describe('有台词时:duck=整首压低;instrumental=切伴奏(需 instrumental_url)'),
2044
+ duck_db: z.number().min(-40).max(0).optional().describe('duck 模式压低量 dB,默认 -18'),
2045
+ bgm_yield: z.boolean().optional().describe('播放区间里配乐让开,默认 true'),
2046
+ })).max(3).describe('整组主题曲;[] = 清空'),
2047
+ }, async ({ episode_id, songs }) => jsonResult(await client.producePut(`/episodes/${episode_id}/theme-songs`, { songs })));
2012
2048
  server.tool('extract_segment_to_library', '把某一镜(或某集成片)的某个时间区间抽出来,存成可复用的素材,进我的素材库。免费(纯剪切,不走厂商)。' +
2013
2049
  '典型用法:闪回/回忆蒙太奇——画面全用已有镜头,边际成本为 0。' +
2014
2050
  '区间是相对**该来源视频**的秒数;末端超出片长会自动夹回来并在 span 里回显实际区间。' +
@@ -2637,7 +2673,11 @@ export function registerProduceTools(server, client) {
2637
2673
  '★有字幕从来不等于有声音:字幕是逐句的,音频是逐镜的。`conflicts_with_contract=true` 的镜' +
2638
2674
  '以实测为准,notes 里已按镜号点名。\n' +
2639
2675
  '② 每镜必须按 trim_head_ms / duration_ms 裁剪再用;直接拼整条裸片会把平台已经 QC 掉的' +
2640
- '首尾形变帧一起拼进去。\n' +
2676
+ '首尾形变帧一起拼进去。★manifest_version=0.2 起入出点投影自平台终拼时间轴(与 build_av_timing_contract、' +
2677
+ '平台成片同一套),clip.url 是时间轴实际用的那个文件(clip.source_kind);0.1 时代交接包自算的 trim 与成片对不上。' +
2678
+ 'speed_factor≠1 要按倍率变速、freeze_pad_ms>0 要克隆末帧补齐(duration_ms=(out_ms−in_ms)/speed_factor+freeze_pad_ms),' +
2679
+ '否则总长对不上;subtitles_burned_in=true 别再烧字幕,color_graded=true 别再套 LUT;' +
2680
+ 'trim_basis≠platform_timeline 的镜是整条不裁,notes 写了原因。工具链只认 0.1 的旧副本会拒收 0.2,重新 save_handoff_toolchain。\n' +
2641
2681
  '③ 字幕 cue、dialogue_audio.offset_ms、sfx[].offset_ms 的基准都是「该镜 trim 之后的第 0 毫秒」,' +
2642
2682
  '不是成片绝对时间。你加多少重叠转场都不用改它们——交给 compile_timeline.py 展开,别手算累加。\n' +
2643
2683
  '\n拼之前先跑 build_av_timing_contract → get_av_timing_contract(免费):逐镜实测源片时长、平台终拼的入出点/转场占用/定格补帧、人声已混入视频还是独立轨,拿它核对你的时间轴总长。' +
package/openapi.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "StarReel Production API",
5
- "version": "0.1.212",
6
- "description": "Turn a script into a finished, downloadable short-drama episode over REST.\n\nPipeline: script → AI rewrite → cast/scenes/props extraction → storyboards → portraits & sheets + scene plates → keyframes → video shots → TTS → final cut (.mp4).\n\n**Billing is prepaid and agent-safe**: big-ticket stages are quote-then-generate (`quote_*` returns a `quote_id`). For video, quote == actual charge. For images the quote is a *range* — models billed per reference image are priced before those are assembled, so `estimated_points` is the upper bound (size the balance by it and a run never dies half-way) and `typical_points` is the usual cost; fixed-price models return the two equal. Insufficient balance returns 402 — nothing half-runs and the account never goes negative.\n\nAuth: exchange your API key at `POST /v1/agent/token` for a 15-minute bearer token.\n\nGenerated from the @starreel/mcp v0.1.212 tool surface (operationIds match MCP tool names 1:1)."
5
+ "version": "0.1.214",
6
+ "description": "Turn a script into a finished, downloadable short-drama episode over REST.\n\nPipeline: script → AI rewrite → cast/scenes/props extraction → storyboards → portraits & sheets + scene plates → keyframes → video shots → TTS → final cut (.mp4).\n\n**Billing is prepaid and agent-safe**: big-ticket stages are quote-then-generate (`quote_*` returns a `quote_id`). For video, quote == actual charge. For images the quote is a *range* — models billed per reference image are priced before those are assembled, so `estimated_points` is the upper bound (size the balance by it and a run never dies half-way) and `typical_points` is the usual cost; fixed-price models return the two equal. Insufficient balance returns 402 — nothing half-runs and the account never goes negative.\n\nAuth: exchange your API key at `POST /v1/agent/token` for a 15-minute bearer token.\n\nGenerated from the @starreel/mcp v0.1.214 tool surface (operationIds match MCP tool names 1:1)."
7
7
  },
8
8
  "servers": [
9
9
  {
@@ -7302,7 +7302,7 @@
7302
7302
  "get": {
7303
7303
  "operationId": "export_handoff_pack",
7304
7304
  "summary": "启用交付追溯后会保存不可变导出清单(不改素材)",
7305
- "description": "启用交付追溯后会保存不可变导出清单(不改素材)。保留 pack_id 和 manifest_sha256,回传外部成片时引用。导出本集「素材交接包」清单:逐镜裸片 + 对白音轨 + 音效 + 配乐 + 字幕的可下载 URL,交给你在**自己那边**完成转场决策、拼接、混音、烧字幕——平台不参与终拼。免费,零扣费。\n★本地调色/第三方 AI 后期:purpose=local_color;只有图片时传 media_type=images,无需生成视频。本模式保留源文件、关闭导出包的自动 LUT、附带后期说明和本地对比页流程;不上传第三方、不收费生成、不回填平台。图片来源可能已有处理,不能把所有导出图承诺为未调色原片。\n\n【推荐流程】① 调本工具拿 manifest;② 按 clips[].url 把裸片下载到本地;③ 你自己看片判断每个接缝该用什么转场(manifest 给了 scene_boundary 场景边界作判据),写一份 plan.json;④ 用 get_handoff_toolchain 拿到 compile_timeline.py 展开时间轴、assemble.sh 装配出成片。工具链已经把「加了重叠转场之后字幕/对白/音效怎么跟着位移」算好了。\n\n【三个不看就会翻车的事实】\n① **人声在哪要逐镜读 `shots[].voice_track.location`,不能读整集的 audio_contract.mode**——后者是整剧级声明,而全画外旁白镜的旁白是平台在完成侧混进裸片的,同一集里逐镜可以不同。四种取值各有各的处置:`separate_file`=对白在 dialogue_audio,不铺这镜就没台词;`baked_in_clip`=已在裸片音轨里,再叠一遍会双声;`missing`=**平台侧确认缺失**(旁白补偿失败),字幕还在但声音哪都没有——回平台重生成该镜,拉长转场掩盖不了;`unknown`=有台词却查不到来源,**必须试听裸片**再决定。★有字幕从来不等于有声音:字幕是逐句的,音频是逐镜的。`conflicts_with_contract=true` 的镜以实测为准,notes 里已按镜号点名。\n② 每镜必须按 trim_head_ms / duration_ms 裁剪再用;直接拼整条裸片会把平台已经 QC 掉的首尾形变帧一起拼进去。\n③ 字幕 cue、dialogue_audio.offset_ms、sfx[].offset_ms 的基准都是「该镜 trim 之后的第 0 毫秒」,不是成片绝对时间。你加多少重叠转场都不用改它们——交给 compile_timeline.py 展开,别手算累加。\n\n拼之前先跑 build_av_timing_contract → get_av_timing_contract(免费):逐镜实测源片时长、平台终拼的入出点/转场占用/定格补帧、人声已混入视频还是独立轨,拿它核对你的时间轴总长。\n想让平台代拼、要平台级质量闸(终拼预检/音画等长/响度母带),改用 compose_episode。",
7305
+ "description": "启用交付追溯后会保存不可变导出清单(不改素材)。保留 pack_id 和 manifest_sha256,回传外部成片时引用。导出本集「素材交接包」清单:逐镜裸片 + 对白音轨 + 音效 + 配乐 + 字幕的可下载 URL,交给你在**自己那边**完成转场决策、拼接、混音、烧字幕——平台不参与终拼。免费,零扣费。\n★本地调色/第三方 AI 后期:purpose=local_color;只有图片时传 media_type=images,无需生成视频。本模式保留源文件、关闭导出包的自动 LUT、附带后期说明和本地对比页流程;不上传第三方、不收费生成、不回填平台。图片来源可能已有处理,不能把所有导出图承诺为未调色原片。\n\n【推荐流程】① 调本工具拿 manifest;② 按 clips[].url 把裸片下载到本地;③ 你自己看片判断每个接缝该用什么转场(manifest 给了 scene_boundary 场景边界作判据),写一份 plan.json;④ 用 get_handoff_toolchain 拿到 compile_timeline.py 展开时间轴、assemble.sh 装配出成片。工具链已经把「加了重叠转场之后字幕/对白/音效怎么跟着位移」算好了。\n\n【三个不看就会翻车的事实】\n① **人声在哪要逐镜读 `shots[].voice_track.location`,不能读整集的 audio_contract.mode**——后者是整剧级声明,而全画外旁白镜的旁白是平台在完成侧混进裸片的,同一集里逐镜可以不同。四种取值各有各的处置:`separate_file`=对白在 dialogue_audio,不铺这镜就没台词;`baked_in_clip`=已在裸片音轨里,再叠一遍会双声;`missing`=**平台侧确认缺失**(旁白补偿失败),字幕还在但声音哪都没有——回平台重生成该镜,拉长转场掩盖不了;`unknown`=有台词却查不到来源,**必须试听裸片**再决定。★有字幕从来不等于有声音:字幕是逐句的,音频是逐镜的。`conflicts_with_contract=true` 的镜以实测为准,notes 里已按镜号点名。\n② 每镜必须按 trim_head_ms / duration_ms 裁剪再用;直接拼整条裸片会把平台已经 QC 掉的首尾形变帧一起拼进去。★manifest_version=0.2 起入出点投影自平台终拼时间轴(与 build_av_timing_contract、平台成片同一套),clip.url 是时间轴实际用的那个文件(clip.source_kind);0.1 时代交接包自算的 trim 与成片对不上。speed_factor≠1 要按倍率变速、freeze_pad_ms>0 要克隆末帧补齐(duration_ms=(out_ms−in_ms)/speed_factor+freeze_pad_ms),否则总长对不上;subtitles_burned_in=true 别再烧字幕,color_graded=true 别再套 LUT;trim_basis≠platform_timeline 的镜是整条不裁,notes 写了原因。工具链只认 0.1 的旧副本会拒收 0.2,重新 save_handoff_toolchain。\n③ 字幕 cue、dialogue_audio.offset_ms、sfx[].offset_ms 的基准都是「该镜 trim 之后的第 0 毫秒」,不是成片绝对时间。你加多少重叠转场都不用改它们——交给 compile_timeline.py 展开,别手算累加。\n\n拼之前先跑 build_av_timing_contract → get_av_timing_contract(免费):逐镜实测源片时长、平台终拼的入出点/转场占用/定格补帧、人声已混入视频还是独立轨,拿它核对你的时间轴总长。\n想让平台代拼、要平台级质量闸(终拼预检/音画等长/响度母带),改用 compose_episode。",
7306
7306
  "tags": [
7307
7307
  "episodes"
7308
7308
  ],
@@ -10277,6 +10277,199 @@
10277
10277
  }
10278
10278
  }
10279
10279
  },
10280
+ "/episodes/{episode_id}/theme-songs": {
10281
+ "get": {
10282
+ "operationId": "get_theme_songs",
10283
+ "summary": "读某一集的主题曲设置(免费)",
10284
+ "description": "读某一集的主题曲设置(免费)。set_theme_songs 是**整组覆盖**,改之前先读。enabled=false 表示本剧尚未开通(存了也暂不进成片)。",
10285
+ "tags": [
10286
+ "episodes"
10287
+ ],
10288
+ "parameters": [
10289
+ {
10290
+ "name": "episode_id",
10291
+ "in": "path",
10292
+ "required": true,
10293
+ "schema": {
10294
+ "type": "integer",
10295
+ "exclusiveMinimum": 0
10296
+ }
10297
+ }
10298
+ ],
10299
+ "responses": {
10300
+ "200": {
10301
+ "description": "StarReel envelope",
10302
+ "content": {
10303
+ "application/json": {
10304
+ "schema": {
10305
+ "type": "object",
10306
+ "properties": {
10307
+ "code": {
10308
+ "type": "integer"
10309
+ },
10310
+ "message": {
10311
+ "type": "string"
10312
+ },
10313
+ "data": {
10314
+ "description": "Operation result payload"
10315
+ }
10316
+ }
10317
+ }
10318
+ }
10319
+ }
10320
+ },
10321
+ "402": {
10322
+ "description": "Insufficient prepaid balance (never overdrafts); response carries `needed` points"
10323
+ }
10324
+ }
10325
+ },
10326
+ "put": {
10327
+ "operationId": "set_theme_songs",
10328
+ "summary": "给某一集挂/改/清**带人声的主题曲**(片头曲、片尾曲或某几镜的插曲;免费纯数据,整组覆盖,songs=[] 清空;一集最多 3 首)",
10329
+ "description": "给某一集挂/改/清**带人声的主题曲**(片头曲、片尾曲或某几镜的插曲;免费纯数据,整组覆盖,songs=[] 清空;一集最多 3 首)。歌来源三选一:①客户自己的歌——先 upload-url(kind=audio)上传拿 public_url 当 audio_url;②平台作曲/曲库——带 track_id(get_mv_song、list 曲库返回的 id);③已有 https 地址。placement:opening=片头(从 0 开始,放到歌完或 sb_to 那镜结束)/ending=片尾(贴片尾,或从 sb_from 那镜开始)/range=sb_from~sb_to 镜头段落(两端必填)。人声不和台词打架:给了 instrumental_url(同一首歌的伴奏轨)默认 dialogue_mode=instrumental——有台词时人声渐隐、伴奏接上,说完换回;只有原曲则 dialogue_mode=duck——有台词时整首压低 duck_db(默认 -18dB)。主题曲播放区间里配乐自动让开(bgm_yield,默认 true),区间外照常。gain_db 默认 -8,track_offset_ms 跳过前奏。改完调 compose_episode 重新终拼生效;enabled=false 表示本剧尚未开通。★别自己下载成片混音再上传。",
10330
+ "tags": [
10331
+ "episodes"
10332
+ ],
10333
+ "parameters": [
10334
+ {
10335
+ "name": "episode_id",
10336
+ "in": "path",
10337
+ "required": true,
10338
+ "schema": {
10339
+ "type": "integer",
10340
+ "exclusiveMinimum": 0
10341
+ }
10342
+ }
10343
+ ],
10344
+ "requestBody": {
10345
+ "required": true,
10346
+ "content": {
10347
+ "application/json": {
10348
+ "schema": {
10349
+ "type": "object",
10350
+ "properties": {
10351
+ "songs": {
10352
+ "type": "array",
10353
+ "items": {
10354
+ "type": "object",
10355
+ "properties": {
10356
+ "audio_url": {
10357
+ "type": "string",
10358
+ "description": "原曲(带人声)https 地址或 static/ 路径;与 track_id 二选一"
10359
+ },
10360
+ "track_id": {
10361
+ "type": "integer",
10362
+ "exclusiveMinimum": 0,
10363
+ "description": "曲库/平台作曲的曲目 id(本人上传或公开的)"
10364
+ },
10365
+ "instrumental_url": {
10366
+ "type": "string",
10367
+ "description": "同一首歌的伴奏轨(与原曲逐采样对齐);给了才能「台词段只留伴奏」"
10368
+ },
10369
+ "placement": {
10370
+ "type": "string",
10371
+ "enum": [
10372
+ "opening",
10373
+ "ending",
10374
+ "range"
10375
+ ]
10376
+ },
10377
+ "sb_from": {
10378
+ "type": "integer",
10379
+ "exclusiveMinimum": 0,
10380
+ "description": "起镜号(storyboard_number)"
10381
+ },
10382
+ "sb_to": {
10383
+ "type": "integer",
10384
+ "exclusiveMinimum": 0,
10385
+ "description": "止镜号(含)"
10386
+ },
10387
+ "track_offset_ms": {
10388
+ "type": "integer",
10389
+ "minimum": 0,
10390
+ "description": "从曲内第几毫秒开始放(跳过前奏)"
10391
+ },
10392
+ "gain_db": {
10393
+ "type": "number",
10394
+ "minimum": -30,
10395
+ "maximum": 6,
10396
+ "description": "主题曲音量 dB,默认 -8"
10397
+ },
10398
+ "fade_in_ms": {
10399
+ "type": "integer",
10400
+ "minimum": 0,
10401
+ "maximum": 10000,
10402
+ "description": "默认 1000"
10403
+ },
10404
+ "fade_out_ms": {
10405
+ "type": "integer",
10406
+ "minimum": 0,
10407
+ "maximum": 10000,
10408
+ "description": "默认 2000"
10409
+ },
10410
+ "dialogue_mode": {
10411
+ "type": "string",
10412
+ "enum": [
10413
+ "duck",
10414
+ "instrumental"
10415
+ ],
10416
+ "description": "有台词时:duck=整首压低;instrumental=切伴奏(需 instrumental_url)"
10417
+ },
10418
+ "duck_db": {
10419
+ "type": "number",
10420
+ "minimum": -40,
10421
+ "maximum": 0,
10422
+ "description": "duck 模式压低量 dB,默认 -18"
10423
+ },
10424
+ "bgm_yield": {
10425
+ "type": "boolean",
10426
+ "description": "播放区间里配乐让开,默认 true"
10427
+ }
10428
+ },
10429
+ "required": [
10430
+ "placement"
10431
+ ],
10432
+ "additionalProperties": false
10433
+ },
10434
+ "maxItems": 3,
10435
+ "description": "整组主题曲;[] = 清空"
10436
+ }
10437
+ },
10438
+ "required": [
10439
+ "songs"
10440
+ ]
10441
+ }
10442
+ }
10443
+ }
10444
+ },
10445
+ "responses": {
10446
+ "200": {
10447
+ "description": "StarReel envelope",
10448
+ "content": {
10449
+ "application/json": {
10450
+ "schema": {
10451
+ "type": "object",
10452
+ "properties": {
10453
+ "code": {
10454
+ "type": "integer"
10455
+ },
10456
+ "message": {
10457
+ "type": "string"
10458
+ },
10459
+ "data": {
10460
+ "description": "Operation result payload"
10461
+ }
10462
+ }
10463
+ }
10464
+ }
10465
+ }
10466
+ },
10467
+ "402": {
10468
+ "description": "Insufficient prepaid balance (never overdrafts); response carries `needed` points"
10469
+ }
10470
+ }
10471
+ }
10472
+ },
10280
10473
  "/episodes/{episode_id}/transitions": {
10281
10474
  "post": {
10282
10475
  "operationId": "generate_transitions",
@@ -15621,6 +15814,199 @@
15621
15814
  }
15622
15815
  }
15623
15816
  },
15817
+ "/storyboards/{storyboard_id}/verse-cards": {
15818
+ "get": {
15819
+ "operationId": "get_shot_verse_cards",
15820
+ "summary": "读这一镜的诗句/歌词字卡现值(免费)",
15821
+ "description": "读这一镜的诗句/歌词字卡现值(免费)。set_shot_verse_cards 是**整组覆盖**,改之前先读。enabled=false 表示本剧尚未开通(存了也暂不进成片)。",
15822
+ "tags": [
15823
+ "storyboards"
15824
+ ],
15825
+ "parameters": [
15826
+ {
15827
+ "name": "storyboard_id",
15828
+ "in": "path",
15829
+ "required": true,
15830
+ "schema": {
15831
+ "type": "integer",
15832
+ "exclusiveMinimum": 0
15833
+ }
15834
+ }
15835
+ ],
15836
+ "responses": {
15837
+ "200": {
15838
+ "description": "StarReel envelope",
15839
+ "content": {
15840
+ "application/json": {
15841
+ "schema": {
15842
+ "type": "object",
15843
+ "properties": {
15844
+ "code": {
15845
+ "type": "integer"
15846
+ },
15847
+ "message": {
15848
+ "type": "string"
15849
+ },
15850
+ "data": {
15851
+ "description": "Operation result payload"
15852
+ }
15853
+ }
15854
+ }
15855
+ }
15856
+ }
15857
+ },
15858
+ "402": {
15859
+ "description": "Insufficient prepaid balance (never overdrafts); response carries `needed` points"
15860
+ }
15861
+ }
15862
+ },
15863
+ "put": {
15864
+ "operationId": "set_shot_verse_cards",
15865
+ "summary": "给某一镜加/改/清「诗句·歌词字卡」(终拼时烧进成片;免费纯数据)",
15866
+ "description": "给某一镜加/改/清「诗句·歌词字卡」(终拼时烧进成片;免费纯数据)。一镜最多 6 张,**整组覆盖**(先 get_shot_verse_cards 读现值再改),cards=[] 清空。国风电影感短片的三种标配都走它:①画面一侧/居中淡入的竖排或横排诗句、歌词(text 里换行=另起一列/一行,标点原样保留,可多张错开 start_ms 依次出现);②整屏黑底结语卡 backdrop=\"black\"(通常放末镜,orient=horizontal、position=center);③叠在画面上的片尾名单 motion=\"scroll_up\"(自下而上滚动,一行一个名字,空行分组)。字卡自动避开对白字幕所在的那条带,不会与台词重叠;字号放不下会自动缩小,不会顶出画面。改完调 compose_episode 重新终拼生效(rerender_episode 沿用已存时间轴,带不上)。get_shot_verse_cards 的 enabled=false 表示本剧尚未开通(存了也暂不进成片)。★别自己下载视频叠字再上传——那会绕开渲染机字体与字幕避让。★文字只写客户给的诗句/名单,别替客户编。",
15867
+ "tags": [
15868
+ "storyboards"
15869
+ ],
15870
+ "parameters": [
15871
+ {
15872
+ "name": "storyboard_id",
15873
+ "in": "path",
15874
+ "required": true,
15875
+ "schema": {
15876
+ "type": "integer",
15877
+ "exclusiveMinimum": 0
15878
+ }
15879
+ }
15880
+ ],
15881
+ "requestBody": {
15882
+ "required": true,
15883
+ "content": {
15884
+ "application/json": {
15885
+ "schema": {
15886
+ "type": "object",
15887
+ "properties": {
15888
+ "cards": {
15889
+ "type": "array",
15890
+ "items": {
15891
+ "type": "object",
15892
+ "properties": {
15893
+ "text": {
15894
+ "type": "string",
15895
+ "minLength": 1,
15896
+ "description": "字卡文字;换行=另起一列(竖排)/一行(横排);≤300 字"
15897
+ },
15898
+ "orient": {
15899
+ "type": "string",
15900
+ "enum": [
15901
+ "vertical",
15902
+ "horizontal"
15903
+ ],
15904
+ "description": "默认 vertical 竖排(从右往左);滚动名单自动横排"
15905
+ },
15906
+ "position": {
15907
+ "type": "string",
15908
+ "enum": [
15909
+ "left",
15910
+ "right",
15911
+ "center"
15912
+ ],
15913
+ "description": "默认 right;滚动名单默认 center"
15914
+ },
15915
+ "start_ms": {
15916
+ "type": "integer",
15917
+ "minimum": 0,
15918
+ "maximum": 60000,
15919
+ "description": "镜内起点毫秒(相对本镜开头),默认 0"
15920
+ },
15921
+ "duration_ms": {
15922
+ "type": "integer",
15923
+ "minimum": 500,
15924
+ "maximum": 60000,
15925
+ "description": "显示时长,默认 4000;超出本镜部分截掉"
15926
+ },
15927
+ "fade_in_ms": {
15928
+ "type": "integer",
15929
+ "minimum": 0,
15930
+ "maximum": 5000,
15931
+ "description": "淡入毫秒,默认 600"
15932
+ },
15933
+ "fade_out_ms": {
15934
+ "type": "integer",
15935
+ "minimum": 0,
15936
+ "maximum": 5000,
15937
+ "description": "淡出毫秒,默认 600"
15938
+ },
15939
+ "font_size": {
15940
+ "type": "integer",
15941
+ "minimum": 20,
15942
+ "maximum": 200,
15943
+ "description": "字号上限(输出像素),默认 64;放不下自动缩小"
15944
+ },
15945
+ "font": {
15946
+ "type": "string",
15947
+ "description": "字体名,默认行书 Zhi Mang Xing;规整宋体填 Noto Serif CJK SC(渲染机已装的才生效)"
15948
+ },
15949
+ "backdrop": {
15950
+ "type": "string",
15951
+ "enum": [
15952
+ "none",
15953
+ "black"
15954
+ ],
15955
+ "description": "black = 整屏黑底结语卡"
15956
+ },
15957
+ "motion": {
15958
+ "type": "string",
15959
+ "enum": [
15960
+ "static",
15961
+ "scroll_up"
15962
+ ],
15963
+ "description": "scroll_up = 自下而上滚动的片尾名单"
15964
+ }
15965
+ },
15966
+ "required": [
15967
+ "text"
15968
+ ],
15969
+ "additionalProperties": false
15970
+ },
15971
+ "maxItems": 6,
15972
+ "description": "整组字卡(顺序即渲染顺序);[] = 清空本镜字卡"
15973
+ }
15974
+ },
15975
+ "required": [
15976
+ "cards"
15977
+ ]
15978
+ }
15979
+ }
15980
+ }
15981
+ },
15982
+ "responses": {
15983
+ "200": {
15984
+ "description": "StarReel envelope",
15985
+ "content": {
15986
+ "application/json": {
15987
+ "schema": {
15988
+ "type": "object",
15989
+ "properties": {
15990
+ "code": {
15991
+ "type": "integer"
15992
+ },
15993
+ "message": {
15994
+ "type": "string"
15995
+ },
15996
+ "data": {
15997
+ "description": "Operation result payload"
15998
+ }
15999
+ }
16000
+ }
16001
+ }
16002
+ }
16003
+ },
16004
+ "402": {
16005
+ "description": "Insufficient prepaid balance (never overdrafts); response carries `needed` points"
16006
+ }
16007
+ }
16008
+ }
16009
+ },
15624
16010
  "/storyboards/{storyboard_id}/video-lock": {
15625
16011
  "post": {
15626
16012
  "operationId": "lock_shot_video",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starreel/mcp",
3
- "version": "0.1.212",
3
+ "version": "0.1.214",
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",
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.212",
5
+ "version": "0.1.214",
6
6
  "websiteUrl": "https://starreel.ai",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@starreel/mcp",
11
- "version": "0.1.212",
11
+ "version": "0.1.214",
12
12
  "transport": {
13
13
  "type": "stdio"
14
14
  },