@starreel/mcp 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
  }
@@ -51,6 +72,9 @@ const WORKFLOW_HINT = '★三档执行策略(别把三档混着问客户):' +
51
72
  '建剧/改设定时 AI 应主动告知客户「默认用视频原声,如需 TTS 配音把 use_clip_audio 设 false」,让客户选。' +
52
73
  '★图片模型默认香蕉2(Nano Banana 2 = gemini-3.1-flash-image·整剧统一画风):create_drama/update_project_settings 的 image_model 设,不传即默认香蕉2;' +
53
74
  '可选 gemini-3-pro-image(香蕉Pro·更精细·175点)/gemini-3.1-flash-lite-image(香蕉2 Lite·便宜·31点)/doubao-seedream-5-0-260128(Seedream5.0)/gpt-image-2(ChatGPT Image2);generate_frames 可临时覆盖某次。' +
75
+ '★视频引擎二选一(drama级·AI 建剧时应主动告知客户并给价差让客户定):seedance-2.5(默认·全能力·720p约212点/秒) vs ' +
76
+ 'hailuo-3(MiniMax H3·灰度·约1/3成本70点/秒·原生对白音效·2K·单镜约6分钟·就地编辑/延长/关键帧组暂不可用);' +
77
+ 'create_drama/update_project_settings 的 video_engine 设,★必须在出视频前定——切换不回溯已生成镜头,同剧混用会画风/身份漂移。' +
54
78
  '★图片生成慢≠失败:每张几十秒~数分钟、整集可能十几分钟,轮询 get_storyboards 看 frame_status——pending=还在生成(耐心等、别重复调 generate_frames 白花钱)、ready=完成、failed=才是真失败。' +
55
79
  '★改某一镜画面 / 换定妆图后要让新图生效,走**单镜重生 generate_shot_frame**(平台自动带该镜身份锚·场景道具参考·画风锚,保全片一致);' +
56
80
  'generate_frames 只批量补「缺帧」的镜、已有首帧的镜跳过(正常、不是"拒绝"),尾帧用 frame_type=last_frame 可批量补。换定妆图(set_character_portrait)后响应里的 stale_frames 就是被旧图污染、需逐镜重生的镜。' +
@@ -79,6 +103,8 @@ const ETHNICITY_CODES = [
79
103
  const FRAME_TYPE_ARG = z.enum(['first_frame', 'last_frame', 'both']);
80
104
  const ASPECT_RATIOS = ['9:16', '16:9', '1:1', '4:5', '4:3', '21:9'];
81
105
  const VIDEO_RESOLUTIONS = ['480p', '720p', '1080p', '4k'];
106
+ // 剧级视频引擎(与后端 video-engine-policy 白名单同源;短 id,后端归一化)。
107
+ const VIDEO_ENGINES = ['seedance-2.5', 'hailuo-3'];
82
108
  // 「项目设定页」通用视觉/音频/字幕/转场设定 —— create_drama 与 update_project_settings 共用。
83
109
  // 后端内部 PUT /dramas 已接受写库,facade 白名单 Wave3 已放行(produce-create-fields.ts)。
84
110
  // 不含内部产线/成本开关(strict_mode/best_of_n/shoppable/budget_points 等,需产品决策)。
@@ -87,6 +113,11 @@ const PROJECT_SETTINGS_FIELDS = {
87
113
  image_model: z.string().optional().describe('图片模型(★drama级·整剧统一画风·默认香蕉2 Nano Banana 2)。可选:' +
88
114
  'gemini-3.1-flash-image(香蕉2·默认·71点)/gemini-3-pro-image(香蕉Pro·精细·175点)/gemini-3.1-flash-lite-image(香蕉2 Lite·31点)/' +
89
115
  'doubao-seedream-5-0-260128(Seedream 5.0)/gpt-image-2(ChatGPT Image 2)。建剧即定、整剧统一;generate_frames 可临时覆盖某次出图'),
116
+ // drama 级视频引擎(整剧统一,单镜/批量/场景组/重生全走它)
117
+ video_engine: z.enum(VIDEO_ENGINES).optional().describe('视频引擎(★drama级·整剧统一·AI应主动告知客户可选并给出两档价差让客户定):' +
118
+ 'seedance-2.5(默认·全能力:帧链/场景组/就地编辑/延长/参考图锚·720p约212点/秒) / ' +
119
+ 'hailuo-3(MiniMax H3·灰度:约1/3成本 720p 70点/秒·原生对白与音效·支持2K·单镜生成约6分钟·就地编辑/延长/关键帧组暂不可用)。' +
120
+ '★必须在出视频**前**设置——切换不回溯已生成的镜头,同剧混用两引擎会有画风/身份漂移风险'),
90
121
  // 整剧视觉一致性锚(注入所有出图/视频 prompt,决定跨镜一致)
91
122
  cinematography_prompt: z.string().optional().describe('摄影DNA:镜头/镜片/光圈/调色一揽子,注入所有出图/视频prompt,整剧镜头一致'),
92
123
  art_bible: z.string().optional().describe('美术圣经:色调/材质/气质,注入所有生图prompt,统一视觉风格'),
@@ -124,7 +155,7 @@ function buildEthnicityLock(ethnicity, note) {
124
155
  }
125
156
  export function registerProduceTools(server, client) {
126
157
  // ---------- 建剧前:列可选项 ----------
127
- server.tool('list_project_options', '列出建剧的全部可选项:项目类型(短剧/广告/MV/品牌片)、画幅比例、视频分辨率(带中英标签+说明+默认值)。' +
158
+ server.tool('list_project_options', '列出建剧的全部可选项:项目类型(短剧/广告/MV/品牌片)、画幅比例、视频分辨率、视频引擎(Seedance 2.5/MiniMax H3,带价差与能力差)(带中英标签+说明+默认值)。' +
128
159
  '建剧前先调它,把选项给用户挑,再照 key 传给 create_drama。免费。', {}, async () => jsonResult(await client.produceGet('/project-options')));
129
160
  // ---------- 建剧 ----------
130
161
  // 参数=「项目设定 / 新建项目」页对外能设的全部通用字段(不含内部产线开关)。
@@ -592,13 +623,19 @@ export function registerProduceTools(server, client) {
592
623
  physical_size_hint: z.string().optional().describe('物理尺寸提示'),
593
624
  episode_id: z.number().int().positive().optional(),
594
625
  }, async ({ drama_id, ...fields }) => jsonResult(await client.producePost(`/props`, { drama_id, ...fields })));
595
- server.tool('update_prop', '改道具(名称/类型/描述/prompt/尺寸)。免费。', {
626
+ server.tool('update_prop', '改道具(名称/类型/描述/prompt/尺寸/多视角参考图)。免费。', {
596
627
  prop_id: z.number().int().positive(),
597
628
  name: z.string().optional(),
598
629
  type: z.string().optional(),
599
630
  description: z.string().optional(),
600
631
  prompt: z.string().optional(),
601
632
  physical_size_hint: z.string().optional(),
633
+ // v0.9.976 — 多视角/细节参考图(整份覆盖;传 [] 清空,最多 6 张 http(s) 图)。
634
+ // 与主图分工:主图锁「这是什么」,多视角锁「截面与部件怎么装配」——单张白底图
635
+ // 和文字都表达不了这个(生产实证:形制文本已送达,锤头仍画成被禁的形状)。
636
+ // 正视 / 侧视 / 端面各一张最有效,端面是截面形状唯一能说清的方式。
637
+ reference_images: z.array(z.string().url()).max(6).optional()
638
+ .describe('多视角参考图 URL 数组,整份覆盖;建议正视/侧视/端面各一张'),
602
639
  }, async ({ prop_id, ...fields }) => jsonResult(await client.producePut(`/props/${prop_id}`, fields)));
603
640
  server.tool('delete_prop', '删一个道具。免费。', { prop_id: z.number().int().positive() }, async ({ prop_id }) => jsonResult(await client.produceDelete(`/props/${prop_id}`)));
604
641
  server.tool('mark_signature_prop', '标记/取消「招牌道具」(会在多镜复现的关键道具,加强一致性追踪)。signature=false 取消。免费。', { prop_id: z.number().int().positive(), signature: z.boolean().optional().describe('默认 true 标记;false 取消') }, async ({ prop_id, signature }) => jsonResult(await client.producePost(`/props/${prop_id}/signature`, { signature: signature !== false })));
@@ -670,4 +707,91 @@ export function registerProduceTools(server, client) {
670
707
  character_id: z.number().int().positive(),
671
708
  voice_id: z.string().describe('来自 clone_voice / list_voices 的 voice_id(形如 lib:12)'),
672
709
  }, async ({ character_id, voice_id }) => jsonResult(await client.producePost(`/characters/${character_id}/voice`, { voice_id })));
710
+ // ── 素材交接包:客户自己拼片的通道 ──────────────────────────────────────
711
+ // 与 compose_episode 的分工:compose_episode = 「平台替你拼」;本组工具 = 「素材给你,
712
+ // 你自己决定转场、自己拼」。两条路都不丢客户的对白/音效/配乐/字幕成果。
713
+ server.tool('export_handoff_pack', '导出本集「素材交接包」清单:逐镜裸片 + 对白音轨 + 音效 + 配乐 + 字幕的可下载 URL,' +
714
+ '交给你在**自己那边**完成转场决策、拼接、混音、烧字幕——平台不参与终拼。免费,零扣费。\n' +
715
+ '\n【推荐流程】① 调本工具拿 manifest;② 按 clips[].url 把裸片下载到本地;' +
716
+ '③ 你自己看片判断每个接缝该用什么转场(manifest 给了 scene_boundary 场景边界作判据),' +
717
+ '写一份 plan.json;④ 用 get_handoff_toolchain 拿到 compile_timeline.py 展开时间轴、' +
718
+ 'assemble.sh 装配出成片。工具链已经把「加了重叠转场之后字幕/对白/音效怎么跟着位移」算好了。\n' +
719
+ '\n【三个不看就会翻车的事实】\n' +
720
+ '① audio_contract.mode="tts" 时**裸片里没有人声**,对白在 dialogue_audio 里;不铺就是整集没台词。' +
721
+ 'mode="clip" 时人声已烤在裸片音轨里,反过来**不要**再叠。\n' +
722
+ '② 每镜必须按 trim_head_ms / duration_ms 裁剪再用;直接拼整条裸片会把平台已经 QC 掉的' +
723
+ '首尾形变帧一起拼进去。\n' +
724
+ '③ 字幕 cue、dialogue_audio.offset_ms、sfx[].offset_ms 的基准都是「该镜 trim 之后的第 0 毫秒」,' +
725
+ '不是成片绝对时间。你加多少重叠转场都不用改它们——交给 compile_timeline.py 展开,别手算累加。\n' +
726
+ '\n想让平台代拼、要平台级质量闸(终拼预检/音画等长/响度母带),改用 compose_episode。', { episode_id: z.number().int().positive() }, async ({ episode_id }) => {
727
+ const manifest = await client.produceGet(`/episodes/${episode_id}/handoff-pack`);
728
+ return jsonResult({
729
+ ...manifest,
730
+ assembly_guide: {
731
+ step_1_download: '按 shots[].clip.url / dialogue_audio.url / sfx[].url / bgm[].url 下载素材。' +
732
+ 'URL 到 expires_at 失效,过期重新调本工具。',
733
+ step_2_decide_transitions: '自己分析画面决定每个接缝的转场。scene_boundary="start" 是换场(适合给转场),' +
734
+ '"continue" 是同场景(平台默认硬切——同场景逐镜叠化是"幻灯片拼凑感"的主因)。' +
735
+ 'transition_hint 是平台建议,你可以覆盖。',
736
+ step_3_plan_json: '写 plan.json:{"transitions":[{"before_shot":3,"type":"fade","overlap_ms":300}]}。' +
737
+ 'type 接受预设 id(fade/flash_white/whip_pan…)、导演语义键' +
738
+ '(match_cut/smash_cut/cross_dissolve…)、或直接给 ffmpeg xfade 名。' +
739
+ 'overlap_ms 不填就用该预设的规范时长。',
740
+ step_4_assemble: 'save_handoff_toolchain 把三个脚本落到本地目录,然后:\n' +
741
+ ' python3 fetch_pack.py manifest.json -o ./pack # 下载素材 + 内联字幕落成 SRT\n' +
742
+ ' python3 compile_timeline.py ./pack --transitions plan.json\n' +
743
+ ' ./assemble.sh ./pack out.mp4 plan.json',
744
+ gotchas: [
745
+ 'clip.duration_source="authored" 的镜是 probe 失败退回声明时长的,请自行 ffprobe 校正,否则拼接有累积误差。',
746
+ 'render_target.color_lut 非 null 时,裸片是**未调色**的:必须施加随包的 haldclut 查找表,' +
747
+ '否则你的成片与平台成片有色差。fetch_pack.py 会下载它、assemble.sh 会自动施加。',
748
+ '重叠转场会让其后所有镜整体前移且误差累积——用 compile_timeline.py 算,不要手算。',
749
+ '闪白/闪黑(flash_white/flash_black)是硬切+片头闪光,不能走 xfade,否则双重闪且破坏口型。工具链已处理。',
750
+ '响度必须两 pass 线性母带;单 pass 动态 loudnorm 会把对白间隙的 BGM 上提,让配乐增益调了等于没调。',
751
+ '侧链避让的 key 必须是纯人声轨,混进音效会让打击音也把 BGM 压下去,听感是配乐一惊一乍。',
752
+ ],
753
+ toolchain: 'save_handoff_toolchain 直接把工具链写到你的工作目录(推荐);或 get_handoff_toolchain 拿源码自己保存。',
754
+ },
755
+ });
756
+ });
757
+ server.tool('get_handoff_toolchain', '拿到素材交接包的装配工具链源码(compile_timeline.py 时间轴编译器 / assemble.sh ffmpeg 装配脚本)。' +
758
+ '免费。本工具只返回源码文本,**不会**在你机器上写文件——请自行保存到工作目录再执行。\n' +
759
+ 'compile_timeline.py 负责把「镜相对」的字幕/对白/音效锚点展开成你自己时间轴上的绝对时间码,' +
760
+ '并处理重叠转场引起的整体位移;assemble.sh 是从裸片到成片的完整 ffmpeg 装配基线' +
761
+ '(裁剪→规范化→拼接/xfade→配乐侧链→字幕→两 pass 母带)。' +
762
+ '依赖 ffmpeg(烧字幕需带 libass)、ffprobe、jq、python3。', {
763
+ script: z.enum([...TOOLCHAIN_FILENAMES, 'all']).optional().describe('要哪个;缺省 all'),
764
+ }, async ({ script }) => {
765
+ const want = script && script !== 'all' ? [script] : [...TOOLCHAIN_FILENAMES];
766
+ const files = {};
767
+ for (const f of want) {
768
+ try {
769
+ files[f] = readFileSync(join(HANDOFF_ASSETS, f), 'utf8');
770
+ }
771
+ catch (e) {
772
+ files[f] = `// 读取失败: ${e?.message || e}`;
773
+ }
774
+ }
775
+ return jsonResult({ files, usage: TOOLCHAIN_USAGE, note: TOOLCHAIN_NOTE });
776
+ });
777
+ server.tool('save_handoff_toolchain', '把装配工具链(fetch_pack.py / compile_timeline.py / assemble.sh)直接写到你本地的一个目录,' +
778
+ '省掉自己复制粘贴。免费。目录必须**已存在**且是绝对路径;文件名固定,不接受自定义。\n' +
779
+ '拒绝写入隐藏目录(~/.ssh、~/.config、.git…)与系统目录——这是写盘不是读盘,覆盖错地方不可逆。\n' +
780
+ '落盘后完整流程:\n' +
781
+ ' python3 fetch_pack.py manifest.json -o ./pack # 下载素材、内联字幕落成 SRT\n' +
782
+ ' python3 compile_timeline.py ./pack [--transitions plan.json]\n' +
783
+ ' ./assemble.sh ./pack out.mp4 [plan.json]', { dir: z.string().describe('已存在的绝对路径目录,如 /Users/me/work/starreel-pack') }, async ({ dir }) => {
784
+ const safe = assertSafeToolchainDir(dir);
785
+ const written = [];
786
+ for (const f of TOOLCHAIN_FILENAMES) {
787
+ const body = readFileSync(join(HANDOFF_ASSETS, f), 'utf8');
788
+ const dest = join(safe, f);
789
+ writeFileSync(dest, body, 'utf8');
790
+ // .sh 要可执行,否则第三方还得自己 chmod 一次才跑得起来
791
+ if (f.endsWith('.sh'))
792
+ chmodSync(dest, 0o755);
793
+ written.push({ file: dest, bytes: Buffer.byteLength(body), mode: f.endsWith('.sh') ? '0755' : '0644' });
794
+ }
795
+ return jsonResult({ written, usage: TOOLCHAIN_USAGE, note: TOOLCHAIN_NOTE });
796
+ });
673
797
  }