dsh-creation-mode 3.0.0 → 3.0.1

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,276 @@
1
+ # 中文排版
2
+
3
+ 画面上的字是观众唯一会「读」的东西。画得再好,字排得散,整条片子就掉档。
4
+
5
+ 这一份讲:字号怎么定、中文的坑在哪、文字怎么量、怎么排、怎么动。
6
+
7
+ ---
8
+
9
+ ## 一、字号阶梯
10
+
11
+ 不要给每个元素随便定字号。整条片子最多四级,比例固定:
12
+
13
+ | 层级 | 相对大小 | 1080p 下的字号 | 用途 |
14
+ |---|---|---|---|
15
+ | 主标题 | 1.0 | 88–112px | 一场只有一句 |
16
+ | 副标题 | 0.55 | 48–60px | 补充主标题 |
17
+ | 正文 / 字幕 | 0.38 | 34–42px | 信息 |
18
+ | 角标 / 注释 | 0.26 | 22–28px | 来源、时间、编号 |
19
+
20
+ **两条硬规则:**
21
+
22
+ 1. 相邻两级的比例不能小于 1.4 倍。1.2 倍的差距在画面上看起来像「没对齐」,
23
+ 不像「分级」。
24
+ 2. 一条片子里只用四级。出现第五级,说明信息结构没想清楚。
25
+
26
+ ### 竖屏
27
+
28
+ 9:16 的片子以宽度定字号(1080 宽为基准),比横屏小 15–20%。
29
+ 手机观看距离近,但屏幕小,字号不能照搬横屏。
30
+
31
+ | 层级 | 1080×1920 竖屏 |
32
+ |---|---|
33
+ | 主标题 | 72–88px |
34
+ | 副标题 | 44–52px |
35
+ | 字幕 | 32–38px |
36
+ | 角标 | 22–26px |
37
+
38
+ ---
39
+
40
+ ## 二、中文的六条硬规矩
41
+
42
+ ### 2.1 行高
43
+
44
+ 1.4–1.6。低于 1.3 上下行会「粘」在一起,高于 1.8 会散成两段。
45
+
46
+ ```js
47
+ const lineHeight = fontSize * 1.5;
48
+ ```
49
+
50
+ ### 2.2 字距
51
+
52
+ 默认 0。**中文字距一拉大就散架**,因为每个字是等宽的方块,拉开后字与字之间的
53
+ 空隙和字内部笔画之间的空隙接近,视觉上会「碎」。
54
+
55
+ 只有两种情况可以加:
56
+
57
+ - 主标题想强调:`letter-spacing: 0.08em`(不能再大)
58
+ - 全大写的英文副标:`0.12em`
59
+
60
+ ### 2.3 单行长度
61
+
62
+ 不超过 18 个汉字。超过必须换行,不要靠缩小字号硬塞——缩到 28px 以下,
63
+ 在手机上就看不清了。
64
+
65
+ 竖屏不超过 12 个汉字。
66
+
67
+ ### 2.4 安全边距
68
+
69
+ 左右各 8%,上下各 10%。贴边的字在播放器里会被裁掉一部分(尤其是竖屏底部
70
+ 有控制条)。
71
+
72
+ ```js
73
+ const padX = W * 0.08;
74
+ const padY = H * 0.10;
75
+ const maxWidth = W - padX * 2;
76
+ ```
77
+
78
+ ### 2.5 中英混排
79
+
80
+ 中文和英文/数字之间留半个字宽的空隙,视觉上才不打架:
81
+
82
+ ```js
83
+ // 「3.2秒」→「3.2 秒」
84
+ // 「B3风格」→「B3 风格」
85
+ const mix = (s) => s.replace(/([A-Za-z0-9])([\u4e00-\u9fa5])/g, '$1 $2')
86
+ .replace(/([\u4e00-\u9fa5])([A-Za-z0-9])/g, '$1 $2');
87
+ ```
88
+
89
+ ### 2.6 标点
90
+
91
+ | 规则 | 说明 |
92
+ |---|---|
93
+ | 句末不加句号 | 字幕不是文章,句号显得死板 |
94
+ | 逗号用全角「,」 | 半角「,」在中文里像排版错误 |
95
+ | 破折号用两个全角横线「——」 | 不要用 `--` 或单个「—」 |
96
+ | 省略号用「……」 | 六个点,不是三个 |
97
+ | 引号用「」或“” | 全文统一一种 |
98
+
99
+ ---
100
+
101
+ ## 三、量文字
102
+
103
+ Canvas 不会自动换行,必须自己量。
104
+
105
+ ### 3.1 单行宽度
106
+
107
+ ```js
108
+ ctx.font = '700 96px "PingFang SC", "Microsoft YaHei", sans-serif';
109
+ const w = ctx.measureText('创作模式').width;
110
+ ```
111
+
112
+ 注意:**先设 `font` 再量**。`measureText` 用的是当前 `ctx.font`。
113
+
114
+ ### 3.2 自动换行
115
+
116
+ ```js
117
+ function wrapText(ctx, text, maxWidth) {
118
+ const lines = [];
119
+ let line = '';
120
+ for (const ch of text) {
121
+ const test = line + ch;
122
+ if (ctx.measureText(test).width > maxWidth && line) {
123
+ lines.push(line);
124
+ line = ch;
125
+ } else {
126
+ line = test;
127
+ }
128
+ }
129
+ if (line) lines.push(line);
130
+ return lines;
131
+ }
132
+ ```
133
+
134
+ 逐字断行对中文是对的(中文可以在任意字之间断),英文要按词断,混排时优先在
135
+ 空格处断。
136
+
137
+ ### 3.3 垂直居中
138
+
139
+ `textBaseline` 的三个值差别很大:
140
+
141
+ | 值 | 基线位置 | 适合 |
142
+ |---|---|---|
143
+ | `alphabetic` | 字母底部(默认) | 精确对齐西文 |
144
+ | `middle` | 文字视觉中心偏上 | 单行居中(中文慎用,会偏上) |
145
+ | `top` | 字体顶部 | 多行排版 |
146
+
147
+ 中文单行垂直居中,用 `middle` 后还要再往下挪 `fontSize * 0.08` 才真正居中。
148
+
149
+ ---
150
+
151
+ ## 四、排版样式
152
+
153
+ ### 4.1 字重比字号更有效
154
+
155
+ 想强调一个词,优先加粗或换色,而不是放大。放大破坏节奏,加粗只改变视觉重量。
156
+
157
+ ```js
158
+ ctx.font = '400 42px ...'; // 正文
159
+ ctx.font = '700 42px ...'; // 强调(字号不变)
160
+ ```
161
+
162
+ 字体族只用系统字体,**不要引用任何网络字体**:离线渲染拿不到,会退回默认字体,
163
+ 排版全乱。
164
+
165
+ ```js
166
+ const FONT = '"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif';
167
+ ```
168
+
169
+ ### 4.2 对齐
170
+
171
+ - 标题左对齐,起点固定在一个 x 上(不要居中——居中会让每行起点跳动)
172
+ - 多行正文左对齐,右端不齐是正常的
173
+ - 数字右对齐(小数点对齐),尤其是数据对比
174
+
175
+ ### 4.3 描边与阴影
176
+
177
+ 深色背景上的白字,加一层极淡的阴影让边缘清晰:
178
+
179
+ ```js
180
+ ctx.shadowColor = 'rgba(0,0,0,0.35)';
181
+ ctx.shadowBlur = 12;
182
+ ctx.shadowOffsetY = 2;
183
+ ctx.fillText(text, x, y);
184
+ ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; // 用完立刻复位
185
+ ```
186
+
187
+ 描边(`strokeText`)超过 2px 会显得廉价,只在超大标题上用 1–2px。
188
+
189
+ ---
190
+
191
+ ## 五、文字动画
192
+
193
+ ### 5.1 逐字进场
194
+
195
+ 三条限制:
196
+
197
+ 1. 每个字的延迟 **20–40ms**。超过 60ms 就变成「一个一个蹦」,慢得难受。
198
+ 2. 进场位移不超过字高的 30%。位移太大像在掉字。
199
+ 3. 最后一个字落定后,**至少留 6 帧静止**再切走,否则观众读不完。
200
+
201
+ ```js
202
+ function drawChars(t, text, x, y, start, perChar = 0.03) {
203
+ let cursor = x;
204
+ for (let i = 0; i < text.length; i++) {
205
+ const u = tween(t, start + i * perChar, 0.35);
206
+ if (u <= 0) { cursor += ctx.measureText(text[i]).width; continue; }
207
+ const alpha = easeOutCubic(u);
208
+ const dy = 12 * (1 - easeOutBack(u));
209
+ ctx.globalAlpha = alpha;
210
+ ctx.fillText(text[i], cursor, y + dy);
211
+ cursor += ctx.measureText(text[i]).width;
212
+ }
213
+ ctx.globalAlpha = 1;
214
+ }
215
+ ```
216
+
217
+ ### 5.2 打字机
218
+
219
+ 逐字显示 + 一个光标。光标要闪,但**不要用 `Math.sin(t*10)` 这种连续值**,
220
+ 用方波:
221
+
222
+ ```js
223
+ const blink = Math.floor(t * 2) % 2 === 0; // 每 0.5s 切换
224
+ ```
225
+
226
+ ### 5.3 数字滚动
227
+
228
+ 数字从 0 滚到目标值,末尾要减速,否则读不出最终值:
229
+
230
+ ```js
231
+ const u = tween(t, start, 0.9);
232
+ const shown = Math.round(target * easeOutQuint(u));
233
+ ```
234
+
235
+ 千分位、单位、正负号在滚动过程中就要固定,不要到最后才出现。
236
+
237
+ ### 5.4 遮罩揭示
238
+
239
+ 文字从下往上「擦」出来,比淡入有力:
240
+
241
+ ```js
242
+ ctx.save();
243
+ ctx.beginPath();
244
+ ctx.rect(0, y - fontSize, W, fontSize * 1.3 * u); // 逐渐露出
245
+ ctx.clip();
246
+ ctx.fillText(text, x, y);
247
+ ctx.restore();
248
+ ```
249
+
250
+ ---
251
+
252
+ ## 六、常见错误
253
+
254
+ | 错误 | 后果 | 修正 |
255
+ |---|---|---|
256
+ | 字号 28px 以下放正文 | 手机上看不清 | 缩内容,不缩字 |
257
+ | 行高 1.0 | 上下行粘连 | 1.4–1.6 |
258
+ | 中文字距 0.2em | 字散成碎片 | 0,或 ≤0.08em |
259
+ | 引用 Google Fonts | 离线渲染退回默认字体 | 只用系统字体 |
260
+ | 文字贴边 | 播放器裁切 | 左右 8% 安全边距 |
261
+ | 一句字幕 30 字 | 读不完 | ≤ 镜头时长 × 5.5 字 |
262
+ | 每行都居中 | 起点跳动,像 PPT | 左对齐 |
263
+ | 阴影 blur 30px | 糊成一团 | 8–14px |
264
+
265
+ ---
266
+
267
+ ## 七、自检清单
268
+
269
+ - [ ] 字号只有四级,相邻级差 ≥ 1.4 倍?
270
+ - [ ] 行高在 1.4–1.6?
271
+ - [ ] 单行 ≤ 18 字(竖屏 ≤ 12 字)?
272
+ - [ ] 左右留了 8% 安全边距?
273
+ - [ ] 中英之间有空格?
274
+ - [ ] 用的是系统字体,没有网络字体?
275
+ - [ ] 逐字动画的字间隔 20–40ms,末尾留了 6 帧静止?
276
+ - [ ] 深色背景上的字有没有加淡阴影保证边缘清晰?
package/tools.js CHANGED
@@ -8,10 +8,10 @@
8
8
  * No second model call happens here: the Agent that already holds the user's
9
9
  * context is the one that authors the animation.
10
10
  */
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
 
14
- import { OUTPUT_DIR, STILLS_DIR, catalog, defaultMusicFor, describe, searchStyles, styleById } from './data.js';
14
+ import { KEEP_SAVES, OUTPUT_DIR, REFERENCES, REFERENCES_DIR, STILLS_DIR, catalog, defaultMusicFor, describe, searchStyles, styleById } from './data.js';
15
15
 
16
16
  const MAX_HTML_BYTES = 2 * 1024 * 1024;
17
17
  const MAX_STILLS_PER_CALL = 4;
@@ -105,6 +105,67 @@ function round(value, digits = 2) {
105
105
  return Math.round(Number(value) * factor) / factor;
106
106
  }
107
107
 
108
+ /**
109
+ * 只留成品:一次创作只保留最近一次保存,不留中间稿。
110
+ *
111
+ * 动画是 `anim-<title>-<ts>.{html,score.js,json}`,分镜是 `sb-<title>-<ts>.json`,
112
+ * 同一个前缀下的文件按 id 归组,除这次刚存的 `keepId` 外,最多再留 KEEP_SAVES-1 份。
113
+ *
114
+ * `keepId` 直接算在名额里,所以 KEEP_SAVES = 1 就是「只留这一次」。这一点很重要:
115
+ * 清理发生在落盘之前还是之后都不影响结果,也不会因为两次保存落在同一毫秒
116
+ * (mtime 相同、排序谁前谁后说不准)而多留一份。
117
+ *
118
+ * @returns 被删掉的文件名,用于在工具返回值里如实说明。
119
+ */
120
+ function pruneSaves(prefix, keepId) {
121
+ let names = [];
122
+ try { names = readdirSync(OUTPUT_DIR); } catch { return []; }
123
+
124
+ const sets = new Map();
125
+ for (const name of names) {
126
+ if (!name.startsWith(prefix)) continue;
127
+ const matched = /^(.+?)\.(html|score\.js|json)$/.exec(name);
128
+ if (!matched) continue;
129
+ const id = matched[1];
130
+ const entry = sets.get(id) || { id, mtime: 0 };
131
+ try { entry.mtime = Math.max(entry.mtime, statSync(join(OUTPUT_DIR, name)).mtimeMs); } catch { /* 读不到时间就按 0 算 */ }
132
+ sets.set(id, entry);
133
+ }
134
+
135
+ const older = [...sets.values()]
136
+ .filter((entry) => entry.id !== keepId)
137
+ .sort((a, b) => b.mtime - a.mtime || b.id.localeCompare(a.id));
138
+ const keep = new Set([keepId, ...older.slice(0, Math.max(0, KEEP_SAVES - 1)).map((x) => x.id)]);
139
+
140
+ const removed = [];
141
+ for (const id of sets.keys()) {
142
+ if (keep.has(id)) continue;
143
+ for (const ext of ['.html', '.score.js', '.json']) {
144
+ const file = join(OUTPUT_DIR, `${id}${ext}`);
145
+ if (!existsSync(file)) continue;
146
+ try { unlinkSync(file); removed.push(`${id}${ext}`); } catch { /* 删不掉就留着,不影响这次保存 */ }
147
+ }
148
+ }
149
+ return removed;
150
+ }
151
+
152
+ /** 最近一次保存的 id,用作 base 兜底(旧稿已被清理时仍能返工)。 */
153
+ function latestSave(prefix, ext) {
154
+ try {
155
+ let best = '';
156
+ let bestTime = -1;
157
+ for (const name of readdirSync(OUTPUT_DIR)) {
158
+ if (!name.startsWith(prefix) || !name.endsWith(ext)) continue;
159
+ let mtime = 0;
160
+ try { mtime = statSync(join(OUTPUT_DIR, name)).mtimeMs; } catch { /* 忽略 */ }
161
+ if (mtime > bestTime) { bestTime = mtime; best = name.slice(0, -ext.length); }
162
+ }
163
+ return best;
164
+ } catch {
165
+ return '';
166
+ }
167
+ }
168
+
108
169
  /**
109
170
  * Phrases that make on-screen copy read as machine-written.
110
171
  *
@@ -213,6 +274,56 @@ export function registerCreationTools(deps) {
213
274
  : () => deps.attachments;
214
275
  const disposers = [];
215
276
 
277
+ disposers.push(tools.register({
278
+ name: 'get_reference',
279
+ description: [
280
+ '取一份工艺参考:缓动、转场、排版、调色、音画同步、音色配方、文案标准。',
281
+ '这些是写画面和配乐的具体做法,按需加载,不占每轮的上下文。',
282
+ '写动画之前至少取 motion;画面里有中文标题取 typography;要片子感取 color;',
283
+ '写 score_js 之前取 timbre 和 audio;写分镜文案之前取 copy。',
284
+ '同一份取过一次就不必再取。',
285
+ ].join('\n'),
286
+ parameters: {
287
+ type: 'object',
288
+ properties: {
289
+ name: {
290
+ type: 'string',
291
+ description: '参考名:motion / transitions / typography / color / audio / timbre / copy。',
292
+ },
293
+ },
294
+ required: ['name'],
295
+ additionalProperties: false,
296
+ },
297
+ output: {
298
+ schema: {
299
+ type: 'object',
300
+ properties: {
301
+ name: { type: 'string' },
302
+ title: { type: 'string' },
303
+ body: { type: 'string', description: '参考全文(Markdown)。' },
304
+ },
305
+ required: ['name', 'title', 'body'],
306
+ additionalProperties: false,
307
+ },
308
+ render: (_args, value) => [{ type: 'text', text: value.body }],
309
+ presentationMeta: (_args, value) => value,
310
+ },
311
+ async execute(args) {
312
+ const name = String(args?.name || '').trim().toLowerCase();
313
+ const hit = REFERENCES[name];
314
+ if (!hit) {
315
+ throw new Error(`没有这份参考:${name || '(空)'}。可用:${Object.keys(REFERENCES).join(' / ')}`);
316
+ }
317
+ let body;
318
+ try {
319
+ body = readFileSync(join(REFERENCES_DIR, `${name}.md`), 'utf8');
320
+ } catch {
321
+ throw new Error(`参考文件缺失:references/${name}.md`);
322
+ }
323
+ return { name, title: hit, body };
324
+ },
325
+ }));
326
+
216
327
  disposers.push(tools.register({
217
328
  name: 'list_styles',
218
329
  description: [
@@ -515,7 +626,10 @@ export function registerCreationTools(deps) {
515
626
  const { warnings, totalSeconds } = inspectShots(shots, duration);
516
627
  const music = resolveMusic(args?.music, style);
517
628
 
518
- const id = `sb-${slug(args?.title)}-${Date.now().toString(36)}`;
629
+ // 同一毫秒内保存两次会撞 id,后一份会覆盖前一份:探测到空闲的名字为止。
630
+ const stem = `sb-${slug(args?.title)}-${Date.now().toString(36)}`;
631
+ let id = stem;
632
+ for (let n = 2; existsSync(join(OUTPUT_DIR, `${id}.json`)); n++) id = `${stem}-${n}`;
519
633
  const storyboard = {
520
634
  id,
521
635
  title: String(args?.title || '分镜'),
@@ -543,6 +657,12 @@ export function registerCreationTools(deps) {
543
657
  };
544
658
 
545
659
  mkdirSync(OUTPUT_DIR, { recursive: true });
660
+
661
+ // 只留成品:分镜也只保留最新这一份,改一次覆盖一次,不堆中间稿。
662
+ // 先清理再落盘,这样新分镜的 warnings 里能如实写上清了几个文件。
663
+ const removed = pruneSaves('sb-', id);
664
+ if (removed.length) storyboard.warnings = [...warnings, `已清理 ${removed.length} 个旧分镜文件,只保留这一份`];
665
+
546
666
  writeFileSync(join(OUTPUT_DIR, `${id}.json`), JSON.stringify(storyboard, null, 2), 'utf8');
547
667
  return storyboard;
548
668
  },
@@ -555,6 +675,7 @@ export function registerCreationTools(deps) {
555
675
  '调用前应当已经完成:风格拍板(get_style)与分镜拍板(save_storyboard)。把分镜 id 传进 storyboard_id,卡片会带上分镜信息。',
556
676
  'HTML 必须满足:单个完整文件、只含原生 HTML/JS/Canvas、不引用任何外部库或图片、实现 window.renderAt(t)(t 为秒,同一 t 渲染同一帧)、使用固定种子随机、不启用 requestAnimationFrame 自播。',
557
677
  '生成后用 renderAt 检查首帧、中段、尾帧,确认不是黑屏或静止。',
678
+ '每次保存都会覆盖上一版:工作区里只留最新这一份动画(html + 配乐 + 元数据),旧稿自动删除,不要指望旧版文件还在。',
558
679
  ].join('\n'),
559
680
  parameters: {
560
681
  type: 'object',
@@ -605,7 +726,16 @@ export function registerCreationTools(deps) {
605
726
  },
606
727
  duration: { type: 'number', description: '片长(秒),用于预览进度条与导出。默认 8。' },
607
728
  fps: { type: 'integer', description: '帧率,默认 30。' },
608
- html: { type: 'string', description: '完整的动画 HTML 文件正文。' },
729
+ base_id: {
730
+ type: 'string',
731
+ description: [
732
+ '返工时用:上一版动画的 id(save_animation 返回值里的 id)。',
733
+ '给了它就可以省略 html,系统会读回上一版正文,只改你这次传的字段。',
734
+ '只换配乐时,传 base_id + 新的 score_js 即可,不必重发整份 HTML。',
735
+ '工作区只保留最新一版,所以 base_id 写错或已被清理时,系统会自动退到最近一版继续。',
736
+ ].join(''),
737
+ },
738
+ html: { type: 'string', description: '完整的动画 HTML 文件正文。给了 base_id 时可省略。' },
609
739
  score_js: {
610
740
  type: 'string',
611
741
  description: [
@@ -618,11 +748,13 @@ export function registerCreationTools(deps) {
618
748
  '把声音接到 dest 上,并在绝对时间点上 schedule(用 start(t))。',
619
749
  '所有节点必须按 duration 收尾,不要在函数里 await。',
620
750
  '卡点用 beats 里的 time:它来自动画代码本身,比 cuts 准。',
621
- '留空则退回内置合成引擎。',
751
+ '禁止做低频扫频底鼓(正弦从一两百赫兹滑到三四十赫兹):听感像放屁。',
752
+ '要重音就给固定频率的低正弦加鼓皮噪声,让包络做冲击,不要让频率滑。',
753
+ '必填:没有兜底引擎,不传这段代码这片子就没有声音。',
622
754
  ].join(''),
623
755
  },
624
756
  },
625
- required: ['title', 'html'],
757
+ required: [],
626
758
  additionalProperties: false,
627
759
  },
628
760
  output: {
@@ -638,7 +770,7 @@ export function registerCreationTools(deps) {
638
770
  cuts: { type: 'array', items: { type: 'number' }, description: '切点秒数,配乐按这些点卡拍。' },
639
771
  cues: { type: 'array', items: { type: 'object', additionalProperties: true }, description: '精确卡点列表' },
640
772
  beats: { type: 'array', items: { type: 'object', additionalProperties: true }, description: '从动画代码导出的节奏点' },
641
- scoreUrl: { type: 'string', description: '模型自写配乐的地址;空串表示使用内置引擎。' },
773
+ scoreUrl: { type: 'string', description: '模型自写配乐的地址。' },
642
774
  duration: { type: 'number' },
643
775
  fps: { type: 'integer' },
644
776
  url: { type: 'string' },
@@ -656,6 +788,7 @@ export function registerCreationTools(deps) {
656
788
  value.warnings.length ? `注意:${value.warnings.join(';')}` : '',
657
789
  `地址:${value.url}`,
658
790
  `配乐:${value.music}`,
791
+ value.scoreUrl ? `配乐地址:${value.scoreUrl}` : '',
659
792
  `卡点对齐:${value.cuts.length} 个分镜切点${value.cues && value.cues.length ? ` + ${value.cues.length} 个镜头重点` : ''}已毫秒级精准对齐`,
660
793
  '对话末尾已显示预览卡片:点击播放音画同步出声,点击导出直接一键下载成片。',
661
794
  ].filter(Boolean).join('\n'),
@@ -663,8 +796,37 @@ export function registerCreationTools(deps) {
663
796
  presentationMeta: (_args, value) => value,
664
797
  },
665
798
  async execute(args) {
666
- const html = stripWrappers(args?.html);
667
- if (!html) throw new Error('html 为空');
799
+ // A revision reuses the previous cut: only the parts the caller actually changed
800
+ // travel over the wire, so "just change the music" stays a two-field call.
801
+ const notes = [];
802
+ let baseId = String(args?.base_id || '').trim();
803
+ // 旧稿会被自动清理,所以 base_id 指向的那一版可能已经不在了:退到最近一版,
804
+ // 让「只换配乐」这类返工不会因为文件被清掉而直接失败。
805
+ if (baseId && !existsSync(join(OUTPUT_DIR, `${baseId}.html`))) {
806
+ const newest = latestSave('anim-', '.html');
807
+ if (newest) {
808
+ notes.push(`上一版 ${baseId} 已随旧稿清理,已改用最近一版 ${newest} 作为底稿`);
809
+ baseId = newest;
810
+ } else {
811
+ baseId = '';
812
+ }
813
+ }
814
+ let base;
815
+ if (baseId) {
816
+ try {
817
+ base = JSON.parse(readFileSync(join(OUTPUT_DIR, `${baseId}.json`), 'utf8'));
818
+ } catch { /* no sidecar: fall back to the html alone */ }
819
+ }
820
+
821
+ let html = stripWrappers(args?.html);
822
+ if (!html && baseId) {
823
+ try { html = readFileSync(join(OUTPUT_DIR, `${baseId}.html`), 'utf8'); } catch { /* reported below */ }
824
+ }
825
+ if (!html) {
826
+ throw new Error(baseId
827
+ ? `找不到上一版 ${baseId} 的正文,请改用完整 html 重新提交`
828
+ : 'html 为空');
829
+ }
668
830
  const bytes = Buffer.byteLength(html, 'utf8');
669
831
  if (bytes > MAX_HTML_BYTES) throw new Error(`HTML 过大(${(bytes / 1048576).toFixed(1)} MB),上限 2 MB`);
670
832
 
@@ -672,8 +834,18 @@ export function registerCreationTools(deps) {
672
834
  assertRecordable(html);
673
835
 
674
836
  const warnings = inspectHtml(html);
675
- const style = args?.style_id ? styleById(args.style_id) : undefined;
676
- const storyboardId = String(args?.storyboard_id || '');
837
+ const styleId = args?.style_id || base?.styleId;
838
+ const style = styleId ? styleById(styleId) : undefined;
839
+ let storyboardId = String(args?.storyboard_id || base?.storyboardId || '');
840
+
841
+ // 分镜同样只留最新一份,旧 id 可能已被清理:退到最近一版分镜,别让卡点凭空消失。
842
+ if (storyboardId && !existsSync(join(OUTPUT_DIR, `${storyboardId}.json`))) {
843
+ const newestBoard = latestSave('sb-', '.json');
844
+ if (newestBoard) {
845
+ notes.push(`分镜 ${storyboardId} 已随旧稿清理,已改用最近一版 ${newestBoard}`);
846
+ storyboardId = newestBoard;
847
+ }
848
+ }
677
849
 
678
850
  // The storyboard supplies both the score choice and the cut points; without it
679
851
  // the score still works, it just has no edits to land on.
@@ -688,7 +860,7 @@ export function registerCreationTools(deps) {
688
860
  warnings.push('没有关联分镜:这版动画未经用户拍板');
689
861
  }
690
862
 
691
- const music = resolveMusic(args?.music || (board && board.music), style);
863
+ const music = resolveMusic(args?.music || base?.music || (board && board.music), style);
692
864
  const cuts = Array.isArray(board?.shots)
693
865
  ? board.shots
694
866
  .map((shot) => round(Number(shot.start) || 0))
@@ -710,8 +882,10 @@ export function registerCreationTools(deps) {
710
882
  }
711
883
  }
712
884
  }
713
- if (Array.isArray(args?.cues)) {
714
- for (const c of args.cues) {
885
+ const cueSource = Array.isArray(args?.cues) && args.cues.length ? args.cues
886
+ : (Array.isArray(base?.cues) ? base.cues : []);
887
+ if (cueSource.length) {
888
+ for (const c of cueSource) {
715
889
  const t = round(Number(c.time) || 0);
716
890
  if (t > 0.05 && t < (Number(args?.duration) || board.duration || 8)) {
717
891
  cues.push({ time: t, type: c.type || 'hit', label: c.label || '' });
@@ -719,12 +893,14 @@ export function registerCreationTools(deps) {
719
893
  }
720
894
  }
721
895
 
722
- const duration = Math.max(1, Math.min(Number(args?.duration) || board?.duration || 8, 120));
723
- const fps = Math.max(1, Math.min(Number(args?.fps) || board?.fps || 30, 60));
896
+ const duration = Math.max(1, Math.min(Number(args?.duration) || base?.duration || board?.duration || 8, 120));
897
+ const fps = Math.max(1, Math.min(Number(args?.fps) || base?.fps || board?.fps || 30, 60));
724
898
 
725
899
  // Beats come from the animation code the model just wrote, so they are the most
726
900
  // trusted sync points: keep sub-second precision rather than rounding like cues.
727
- const beats = (Array.isArray(args?.beats) ? args.beats : [])
901
+ const beatSource = Array.isArray(args?.beats) && args.beats.length ? args.beats
902
+ : (Array.isArray(base?.beats) ? base.beats : []);
903
+ const beats = beatSource
728
904
  .map((b) => ({
729
905
  time: Math.round((Number(b.time) || 0) * 1000) / 1000,
730
906
  type: String(b.type || 'hit'),
@@ -734,35 +910,50 @@ export function registerCreationTools(deps) {
734
910
  .filter((b) => b.time > 0.02 && b.time < duration)
735
911
  .sort((a, b) => a.time - b.time);
736
912
 
737
- const id = `anim-${slug(args?.title)}-${Date.now().toString(36)}`;
913
+ // 配乐必须由模型当场写。没有兜底引擎:拿不到 score_js 就报错,让 Agent 重写,
914
+ // 绝不悄悄换成一段通用配乐。校验放在写文件之前,否则被拒的那次会留下孤儿 HTML。
915
+ let scoreJs = stripScore(args?.score_js);
916
+ if (!scoreJs && baseId && base?.scoreUrl) {
917
+ try { scoreJs = readFileSync(join(OUTPUT_DIR, `${baseId}.score.js`), 'utf8'); } catch { /* 下面统一报错 */ }
918
+ }
919
+ if (!scoreJs) {
920
+ throw new Error('缺少 score_js:配乐必须由你为这个片子当场写,没有兜底引擎。'
921
+ + '返工时可以传 base_id 沿用上一版配乐,但首版必须自带 score_js。');
922
+ }
923
+ if (!/createOscillator|createBufferSource|createGain|createBiquadFilter/.test(scoreJs)) {
924
+ throw new Error('score_js 里没有创建任何音频节点(createOscillator / createBufferSource / '
925
+ + 'createGain / createBiquadFilter),这段代码发不出声音。');
926
+ }
738
927
 
928
+ // Two saves inside the same millisecond would otherwise share an id and the
929
+ // second would silently overwrite the first, so probe until the name is free.
739
930
  mkdirSync(OUTPUT_DIR, { recursive: true });
931
+ const stem = `anim-${slug(args?.title || base?.title)}-${Date.now().toString(36)}`;
932
+ let id = stem;
933
+ for (let n = 2; existsSync(join(OUTPUT_DIR, `${id}.html`)); n++) id = `${stem}-${n}`;
934
+
740
935
  writeFileSync(join(OUTPUT_DIR, `${id}.html`), html, 'utf8');
741
936
 
742
- // A model-written score replaces the built-in engine entirely for this clip.
743
- let scoreUrl = '';
744
- const scoreJs = stripScore(args?.score_js);
745
- if (scoreJs) {
746
- if (!/createOscillator|createBufferSource|createGain|createBiquadFilter/.test(scoreJs)) {
747
- warnings.push('自写配乐里没有创建任何音频节点,已退回内置引擎');
748
- } else {
749
- writeFileSync(join(OUTPUT_DIR, `${id}.score.js`), scoreJs, 'utf8');
750
- scoreUrl = `/code-animation-api/score/${id}`;
751
- }
752
- }
937
+ writeFileSync(join(OUTPUT_DIR, `${id}.score.js`), scoreJs, 'utf8');
938
+ const scoreUrl = `/code-animation-api/score/${id}`;
753
939
 
754
940
  // Without beats the score has to infer where the picture peaks, which is exactly
755
941
  // the guessing the beats parameter exists to remove.
756
- if (scoreUrl && !beats.length) {
942
+ if (!beats.length) {
757
943
  warnings.push('配乐没有收到 beats:卡点只能靠 cuts 推,精度会差一些');
758
944
  }
759
945
 
946
+ // 只留成品:这一版已经落盘,再把之前的动画稿(html + 配乐 + sidecar)全部删掉。
947
+ // 必须写在所有写入之后,否则新稿会被自己的清理逻辑扫到。
948
+ const removed = pruneSaves('anim-', id);
949
+ if (removed.length) notes.push(`已清理 ${removed.length} 个旧稿文件,只保留这一版`);
950
+
760
951
  const value = {
761
952
  id,
762
- title: String(args?.title || '动画'),
953
+ title: String(args?.title || base?.title || '动画'),
763
954
  styleId: style ? style.id : '',
764
955
  styleName: style ? style.name : '',
765
- music: scoreUrl ? '模型自写配乐' : music,
956
+ music: music && music !== 'none' ? music : '模型自写配乐',
766
957
  cuts,
767
958
  cues,
768
959
  beats,
@@ -771,9 +962,12 @@ export function registerCreationTools(deps) {
771
962
  fps,
772
963
  url: `/code-animation-api/animation/${id}`,
773
964
  bytes,
774
- warnings,
965
+ warnings: notes.length ? [...notes, ...warnings] : warnings,
775
966
  };
776
967
  if (storyboardId) value.storyboardId = storyboardId;
968
+
969
+ // Keep a sidecar so a later revision can inherit everything it did not resend.
970
+ writeFileSync(join(OUTPUT_DIR, `${id}.json`), JSON.stringify({ ...value, baseId: baseId || undefined }, null, 2), 'utf8');
777
971
  return value;
778
972
  },
779
973
  }));