@ninkch/dsh-infinite-canvas 0.19.2 → 0.19.4

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.
Files changed (89) hide show
  1. package/README.md +50 -0
  2. package/README.zh-CN.md +50 -0
  3. package/assets/canvas-director/SKILL.md +235 -0
  4. package/assets/canvas-director/forms/cinematic.md +56 -0
  5. package/assets/canvas-director/forms/commercial.md +63 -0
  6. package/assets/canvas-director/forms/documentary.md +57 -0
  7. package/assets/canvas-director/forms/explainer.md +57 -0
  8. package/assets/canvas-director/forms/interview.md +56 -0
  9. package/assets/canvas-director/forms/music-video.md +62 -0
  10. package/assets/canvas-director/forms/short-drama.md +60 -0
  11. package/assets/canvas-director/forms/trailer.md +58 -0
  12. package/assets/canvas-director/genres/action-mission.md +58 -0
  13. package/assets/canvas-director/genres/adventure.md +78 -0
  14. package/assets/canvas-director/genres/broken-mirror-reunion.md +112 -0
  15. package/assets/canvas-director/genres/counterattack.md +58 -0
  16. package/assets/canvas-director/genres/crime-police.md +78 -0
  17. package/assets/canvas-director/genres/family-relation.md +58 -0
  18. package/assets/canvas-director/genres/fantasy.md +82 -0
  19. package/assets/canvas-director/genres/hk-retro.md +80 -0
  20. package/assets/canvas-director/genres/identity-swap.md +58 -0
  21. package/assets/canvas-director/genres/modern-urban.md +78 -0
  22. package/assets/canvas-director/genres/parent-child-secret.md +113 -0
  23. package/assets/canvas-director/genres/period-power-struggle.md +58 -0
  24. package/assets/canvas-director/genres/rebirth-transmigration.md +58 -0
  25. package/assets/canvas-director/genres/revenge-face-slap.md +58 -0
  26. package/assets/canvas-director/genres/slice-of-life.md +58 -0
  27. package/assets/canvas-director/genres/suspense-rule.md +58 -0
  28. package/assets/canvas-director/genres/system-flow.md +58 -0
  29. package/assets/canvas-director/genres/wealthy-romance.md +58 -0
  30. package/assets/canvas-director/genres/workplace-comedy.md +58 -0
  31. package/assets/canvas-director/genres/wuxia.md +78 -0
  32. package/assets/canvas-director/genres/xianxia-cultivation.md +58 -0
  33. package/assets/canvas-director/industries/auto.md +44 -0
  34. package/assets/canvas-director/industries/education.md +44 -0
  35. package/assets/canvas-director/industries/fashion-beauty.md +45 -0
  36. package/assets/canvas-director/industries/finance.md +44 -0
  37. package/assets/canvas-director/industries/fmcg.md +44 -0
  38. package/assets/canvas-director/industries/food.md +45 -0
  39. package/assets/canvas-director/industries/medical.md +45 -0
  40. package/assets/canvas-director/industries/realestate.md +44 -0
  41. package/assets/canvas-director/industries/tech.md +44 -0
  42. package/assets/canvas-director/industries/travel.md +45 -0
  43. package/assets/canvas-director/lenses/bergman.md +207 -0
  44. package/assets/canvas-director/lenses/bi_gan.md +198 -0
  45. package/assets/canvas-director/lenses/coen_brothers.md +204 -0
  46. package/assets/canvas-director/lenses/fellini.md +207 -0
  47. package/assets/canvas-director/lenses/fincher.md +195 -0
  48. package/assets/canvas-director/lenses/hitchcock.md +203 -0
  49. package/assets/canvas-director/lenses/hou_hsiao_hsien.md +202 -0
  50. package/assets/canvas-director/lenses/kubrick.md +206 -0
  51. package/assets/canvas-director/lenses/kurosawa.md +206 -0
  52. package/assets/canvas-director/lenses/lu_yang.md +200 -0
  53. package/assets/canvas-director/lenses/malick.md +201 -0
  54. package/assets/canvas-director/lenses/michael_mann.md +202 -0
  55. package/assets/canvas-director/lenses/nolan.md +195 -0
  56. package/assets/canvas-director/lenses/park_chan_wook.md +202 -0
  57. package/assets/canvas-director/lenses/refn.md +196 -0
  58. package/assets/canvas-director/lenses/scorsese.md +205 -0
  59. package/assets/canvas-director/lenses/spielberg.md +202 -0
  60. package/assets/canvas-director/lenses/tarkovsky.md +194 -0
  61. package/assets/canvas-director/lenses/tsui_hark.md +201 -0
  62. package/assets/canvas-director/lenses/villeneuve.md +198 -0
  63. package/assets/canvas-director/lenses/wong_kar_wai.md +199 -0
  64. package/assets/canvas-director/lenses/wu_ershan.md +206 -0
  65. package/assets/canvas-director/lenses/yuen_woo_ping.md +200 -0
  66. package/assets/canvas-director/lenses/zhang_yimou.md +202 -0
  67. package/assets/canvas-director/looks/anime-thick-paint.md +81 -0
  68. package/assets/canvas-director/looks/dark-myth-cg.md +83 -0
  69. package/assets/canvas-director/looks/east-epic-3d.md +83 -0
  70. package/assets/canvas-director/looks/ghibli-watercolor.md +82 -0
  71. package/assets/canvas-director/looks/guoman-3d-ink.md +82 -0
  72. package/assets/canvas-director/looks/guoman-3d-xianxia.md +82 -0
  73. package/assets/canvas-director/looks/hk-retro-film.md +81 -0
  74. package/assets/canvas-director/looks/realistic-cinema.md +83 -0
  75. package/assets/canvas-director/looks/shinkai-light.md +82 -0
  76. package/assets/canvas-director/manifest.json +603 -0
  77. package/assets/canvas-director/moves/camera-moves.md +79 -0
  78. package/assets/canvas-director/moves/fight-rhythm.md +77 -0
  79. package/assets/canvas-director/pack.js +342 -0
  80. package/assets/canvas-studio/SKILL.md +34 -3
  81. package/assets/director/engine.js +17 -4
  82. package/client.js +19577 -18938
  83. package/docs/guide.en.md +66 -1
  84. package/docs/guide.zh-CN.md +64 -1
  85. package/index-core-skill-profile.js +39 -0
  86. package/index.js +127 -3
  87. package/package.json +65 -60
  88. package/skill-pack-loader.js +147 -0
  89. package/skill-pack.js +179 -0
@@ -0,0 +1,77 @@
1
+ ---
2
+ moves_id: fight-rhythm
3
+ title: 打斗节奏
4
+ kind: moves
5
+ ---
6
+
7
+ # 打斗节奏
8
+
9
+ 打斗不是"更快地打架",是**用镜头组织给观众建立重量感**。观众记住的从来不是招式,是某一下砸下去之后身体停住的那两帧。
10
+
11
+ ## 实测基线
12
+
13
+ 对一段 10 秒 / 300 帧的实拍打斗做了逐镜切分,读数如下——把这些数字当基准,不要凭感觉:
14
+
15
+ - **10 镜 / 10 秒,平均 1.0 秒一镜**。打斗没有想象中那么快剪。
16
+ - **最短 0.27 秒**(约 8 帧):发力瞬间,用来制造刺痛,一镜只用一次。
17
+ - **最长 1.80 秒**(约 54 帧):第一击接触,用来交代两人关系与武器,可长。
18
+ - **景别严格交替**:特写 → 双人中景 → 大全景 → 特写,**几乎每一镜都在换景别,没有连续两镜同景别**。
19
+ - **大全景只给两件事**:空间交代与招式高潮。其余全部落在中近景,节省信息成本。
20
+ - 结构是:建置(中景,平视)→ 接触(双人中景,长)→ 反打(过肩)→ 高潮碎切(0.5 / 0.27 / 1.6)→ 情绪长镜 → 大全景收。
21
+
22
+ ## 节奏三段
23
+
24
+ | 段 | 时长占比 | 镜头数 | 景别主力 | 目的 |
25
+ |---|---|---|---|---|
26
+ | 建置 | 15–20% | 2–3 | 中景 / 中近景 | 交代站位、武器、谁先动 |
27
+ | 高潮 | 55–65% | 5–8 | 特写 / 双人中景交替 | 打击密度推上去,碎切在这里 |
28
+ | 收束 | 20–25% | 2–3 | 情绪长镜 + 大全景 | 停一下,让重量落地 |
29
+
30
+ 节奏变化必须**可数**:同一段里至少出现一次明显的时长反差(例:1.8s 叙事 → 0.5s + 0.27s 快切 ×2 → 1.5s 反转 → 5s 情绪长镜)。全程 1.0 秒均匀切 = 没有节奏。
31
+
32
+ ---
33
+
34
+ ## 五条可迁移手法
35
+
36
+ ### 1. 刀锋入画特写
37
+
38
+ - **做什么**:先给"武器的压迫",再给"人"。刀尖从画面一侧入画、虚化,压向对方的脸,占据画面 1/3。
39
+ - **为什么有效**:威胁感比直接拍动作更强——观众先感到危险,再看到危险属于谁。
40
+ - **怎么拍**:大特写 / 镜头 85–135mm / 刀尖在前景虚化、脸在中景实 / 时长 0.8–1.2s / 一个镜一次。
41
+
42
+ ### 2. 受力点插入镜
43
+
44
+ - **做什么**:不拍全身对打,用极低角度的局部特写承担"物理重量"——靴底、剑身抽击、缠绕的衣料、被撞的木栏。
45
+ - **为什么有效**:全身镜里动作会被压缩成小位移;局部镜可以让材质与形变占满画面,重量感来自形变。
46
+ - **怎么拍**:大特写 / 镜头 50–85mm / 贴地或贴物机位 / 时长 0.5–1.0s / 同段最多两次。
47
+
48
+ ### 3. 反打过肩表现从容
49
+
50
+ - **做什么**:攻击方在前景虚化(只留后脑或肩线),防守方正面中景、从容站位、微微抬眼或微笑。
51
+ - **为什么有效**:一个镜头就把强弱关系倒过来——观众立刻知道防守方还没用力。
52
+ - **怎么拍**:过肩 / 镜头 50mm / 前景虚到只剩轮廓 / 防守方正面对焦、占画面高度 60–70% / 时长 1.0–1.8s。
53
+
54
+ ### 4. 情绪停顿长镜
55
+
56
+ - **做什么**:打斗中间插一个 1.5–2 秒的低机位近景,人物贴近地面、发丝垂落、呼吸可见、微表情清晰。
57
+ - **为什么有效**:比继续加速更抓人。观众在高速段被强行减速,注意力反而被拧紧。
58
+ - **怎么拍**:近景 / 镜头 85mm / 机位贴近地面 0.3–0.5m / 时长 1.5–2s / 一镜一次,放在高潮之后。
59
+
60
+ ### 5. 双色服装对位
61
+
62
+ - **做什么**:两方角色在服装上给**两个可区分的色块**(例:红 vs 墨绿),并在整场打斗中保持不换装。
63
+ - **为什么有效**:打斗镜头里角色辨识度靠色块,不是靠脸——脸在动作中永远是糊的。
64
+ - **怎么拍**:在提示词里把两方服装色固定成短语,逐镜逐字重复;光线变化时也保持相对色相不变。
65
+
66
+ ---
67
+
68
+ ## 生成时的明确边界
69
+
70
+ **这一段必须读懂,它决定了成品质量。**
71
+
72
+ 本卡只提供**镜头节奏与景别组织手法**,不承诺逐帧还原参考视频里的具体武打动作。
73
+
74
+ - 具体招式(怎么出拳、怎么格挡、身体重心怎么转移)**不能靠文字提示词硬凑**。用文字描述武打动作,模型只能给出"挥了一下"的糊动作,这是必然结果,不是参数没调好。
75
+ - 要真实动作,走两条路:① **3D 导演台的骨骼动画**(有真实关节角度与时间曲线);② 拿实拍或动捕参考做视频参考输入。
76
+ - 打斗分镜里,把文字用在**该写的地方**:景别、机位、时长、光向、服装色块、打击后的身体状态("停在半跪,右手撑地,头抬起看向对方")。这些都可执行。
77
+ - 反过来,**不要写**:"一记凌厉的侧踢接转身肘击" 这种招式描述——模型会给你一段谁也没看懂的肢体运动。
@@ -0,0 +1,342 @@
1
+ /**
2
+ * canvas-director 技能包:切片与索引的共享纯函数。
3
+ *
4
+ * 设计约束(务必遵守):
5
+ * - 纯函数:不读写 fs、不碰 DOM、不带副作用 → Host 与 Client 同源,且可直接单测。
6
+ * - 按「生成能力」切片,绝不整份注入:一份透镜全文 12–22KB,全注入会吃掉几千 token,
7
+ * 而剧本只关心叙事与节奏、分镜只关心镜头语言、提示词只关心风格参数与负向词。
8
+ * - 切片靠 Markdown 里的 `<!-- slice:xxx yyy -->` 标记 + `## ` 标题双重定位;
9
+ * 标记缺失时回落到纯标题匹配(保证「复制过来就能用」的鲁棒性)。
10
+ *
11
+ * 术语:kind = 生成能力(script / storyboard / prompt / assets),
12
+ * layer = 数据层(lens 导演 / genre 叙事题材 / industry 行业题材 / form 类型规格 / look 影调 / moves 运镜)。
13
+ */
14
+
15
+ /** Markdown 里 slice 标记的格式:`<!-- slice:story prompt -->`(一个标记可声明归属多个能力)。 */
16
+ export const SLICE_RE = /^<!--\s*slice:([a-z ]+?)\s*-->\s*$/;
17
+
18
+ /** 每张小节的「缺省归属」——标记缺失时用它兜底,避免老文件一个切片也取不到。 */
19
+ export const SECTION_DEFAULT_SLICE = {
20
+ // 透镜
21
+ '一句话镜头 / Lens in one line': 'story shot prompt',
22
+ '适用场景': 'story',
23
+ '反例:这不是什么 / What this lens is NOT': 'story shot',
24
+ '核心风格关键词': 'story shot prompt',
25
+ '叙事方法': 'story',
26
+ '镜头语言': 'story shot',
27
+ '灯光与色彩': 'shot prompt assets',
28
+ '剪辑节奏': 'story',
29
+ '声音与音乐': 'story',
30
+ '人物与表演': 'story shot',
31
+ '风格参数 / Style parameters': 'shot prompt assets',
32
+ '可迁移拍摄清单': 'storyboard shot',
33
+ '提示词模板 / Prompt templates': 'prompt',
34
+ '落地示例 / Worked example': 'prompt',
35
+ // 叙事题材卡
36
+ '题材核心与观众为什么看': 'story',
37
+ '压力来源与冲突发动机': 'story',
38
+ '人物策略与信息权限': 'story',
39
+ '观众回报与情绪落点': 'story',
40
+ '常见场面颗粒': 'story prompt',
41
+ '集尾钩子取向': 'story',
42
+ '前中后期打法': 'story',
43
+ '制作难点': 'story shot',
44
+ '禁止漂移': 'story shot',
45
+ '证据与置信度': 'story',
46
+ // 行业题材卡
47
+ '视觉母题': 'story prompt',
48
+ '证据点': 'story prompt',
49
+ '常见承诺': 'story prompt',
50
+ '禁项': 'prompt',
51
+ '参考镜头': 'prompt',
52
+ // 类型规格卡
53
+ '交付规格': 'story shot prompt',
54
+ '结构块': 'story',
55
+ '这个类型的观众契约': 'story shot',
56
+ '与导演透镜冲突时的改写': 'story shot',
57
+ '类型级负向词': 'prompt',
58
+ // 影调卡
59
+ '成像底层': 'story shot prompt',
60
+ '形体与骨相': 'story shot prompt',
61
+ '影调与光比': 'shot prompt assets',
62
+ '色彩命题': 'shot prompt assets',
63
+ '空气与介质': 'shot prompt assets',
64
+ '光学与镜头': 'shot prompt assets',
65
+ '材质响应': 'prompt assets',
66
+ '特效语言': 'shot prompt',
67
+ '针对性禁项': 'prompt',
68
+ '负向词块': 'prompt',
69
+ };
70
+
71
+ /**
72
+ * 把一份 Markdown 切成 { title, slice, body } 的小节数组。
73
+ * - 首个 `## ` 之前的内容(frontmatter + 一级标题 + 前言)归入 `preamble`。
74
+ * - 小节内的 ``` 代码围栏不打断(围栏内的 `## ` 不算标题)。
75
+ */
76
+ export function splitSections(md) {
77
+ const lines = String(md || '').split(/\r?\n/);
78
+ const sections = [];
79
+ let cur = null;
80
+ let pendingSlice = null;
81
+ let inFence = false;
82
+ const preamble = [];
83
+ for (const ln of lines) {
84
+ if (/^\s*```/.test(ln)) inFence = !inFence;
85
+ if (!inFence) {
86
+ const sm = SLICE_RE.exec(ln.trim());
87
+ if (sm) { pendingSlice = sm[1].trim(); continue; }
88
+ const hm = /^##\s+(.+)$/.exec(ln);
89
+ if (hm) {
90
+ if (cur) sections.push(cur);
91
+ const title = hm[1].trim();
92
+ cur = {
93
+ title,
94
+ slice: pendingSlice || SECTION_DEFAULT_SLICE[title] || 'story',
95
+ body: [],
96
+ };
97
+ pendingSlice = null;
98
+ continue;
99
+ }
100
+ }
101
+ if (cur) cur.body.push(ln);
102
+ else preamble.push(ln);
103
+ }
104
+ if (cur) sections.push(cur);
105
+ return sections.map((s) => ({ title: s.title, slice: s.slice, body: s.body.join('\n').replace(/^\n+|\n+$/g, '') }));
106
+ }
107
+
108
+ /** 按标题名精确取小节正文;找不到返回空串。 */
109
+ export function sectionText(md, title) {
110
+ const hit = splitSections(md).find((s) => s.title === title);
111
+ return hit ? hit.body : '';
112
+ }
113
+
114
+ /** 生成能力 → 该能力需要的小节标签集合(kind 与 slice 标签不是同一套词,必须显式映射)。 */
115
+ export const KIND_SLICES = {
116
+ // 剧本:叙事与节奏为主,镜头细节交给分镜;不取「镜头语言/风格参数/提示词模板」那些给分镜与提示词的节
117
+ script: ['story'],
118
+ // 分镜:镜头语言 + 灯光 + 可迁移清单;不取提示词模板与落地示例
119
+ storyboard: ['shot', 'storyboard'],
120
+ // 提示词:只取风格参数与提示词模板(外加灯光);不取叙事、剪辑、表演
121
+ prompt: ['prompt'],
122
+ // 资产设定图:只取成像与材质相关
123
+ assets: ['assets'],
124
+ };
125
+
126
+ /** 取一份 Markdown 在某个生成能力下的全部小节正文(按出现顺序拼接)。 */
127
+ export function pickSections(md, kind) {
128
+ const want = KIND_SLICES[String(kind || '').trim()];
129
+ if (!want) return '';
130
+ return splitSections(md)
131
+ .filter((s) => s.slice.split(/\s+/).some((t) => want.includes(t)))
132
+ .map((s) => '## ' + s.title + '\n\n' + s.body)
133
+ .join('\n\n');
134
+ }
135
+
136
+ /** 抽出 ```yaml 风格参数块原文(含围栏内文本,不含围栏本身)。没有则空串。 */
137
+ export function yamlBlock(md) {
138
+ const m = /```ya?ml\s*\n([\s\S]*?)```/.exec(String(md || ''));
139
+ return m ? m[1].replace(/\s+$/, '') : '';
140
+ }
141
+
142
+ /** 抽出负向词:优先取「负向词块」小节里的英文逗号短语,其次取 yaml 的 negative_prompt_adds。 */
143
+ export function negativePhrases(md) {
144
+ const text = String(md || '');
145
+ const sec = sectionText(text, '负向词块') || sectionText(text, '类型级负向词');
146
+ const fromSec = sec
147
+ ? sec.replace(/```[a-z]*\n?/g, '').split(/[,,\n]/)
148
+ .map((s) => s.replace(/^[-*\d.\s]+/, '').trim())
149
+ .filter((s) => /^[a-z][a-z0-9 '\-/]{2,}$/i.test(s))
150
+ : [];
151
+ if (fromSec.length) return [...new Set(fromSec)];
152
+ const y = yamlBlock(text);
153
+ const m = /negative_prompt_adds:\s*\[([^\]]*)\]/m.exec(y);
154
+ if (!m) return [];
155
+ return [...new Set(m[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean))];
156
+ }
157
+
158
+ /** 从 frontmatter 读一个标量字段(不带引号处理之外的 YAML 特性)。 */
159
+ export function frontmatterField(md, key) {
160
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(md || ''));
161
+ if (!fm) return '';
162
+ const m = new RegExp('^' + String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':\\s*(.+)$', 'm').exec(fm[1]);
163
+ return m ? m[1].trim().replace(/^["']|["']$/g, '') : '';
164
+ }
165
+
166
+ /** 从 frontmatter 读一个内联数组字段(`a: [x, y]` 或 `a: x`)。 */
167
+ export function frontmatterList(md, key) {
168
+ const raw = frontmatterField(md, key);
169
+ if (!raw) return [];
170
+ const inner = /^\[([\s\S]*)\]$/.exec(raw);
171
+ const body = inner ? inner[1] : raw;
172
+ return body.split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
173
+ }
174
+
175
+ /** 从 frontmatter 读 spec 子块的键(`spec:\n aspect: [...]` 这类两层缩进)。 */
176
+ export function frontmatterSpec(md) {
177
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(md || ''));
178
+ if (!fm) return {};
179
+ const out = {};
180
+ let inSpec = false;
181
+ for (const ln of fm[1].split(/\r?\n/)) {
182
+ if (/^spec:\s*$/.test(ln)) { inSpec = true; continue; }
183
+ if (inSpec) {
184
+ if (/^\S/.test(ln)) { inSpec = false; continue; } // 缩进归零 = spec 块结束
185
+ const m = /^\s+([A-Za-z0-9_]+):\s*(.+)$/.exec(ln);
186
+ if (!m) continue;
187
+ const v = m[2].trim().replace(/\s+#.*$/, '');
188
+ if (/^\[/.test(v)) out[m[1]] = frontmatterList('---\n' + m[1] + ': ' + v + '\n---', m[1]);
189
+ else if (/^-?\d+(\.\d+)?$/.test(v)) out[m[1]] = Number(v);
190
+ else out[m[1]] = v.replace(/^["']|["']$/g, '');
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /** 拼装一段可注入的文本块:标题 + 分区,并返回字节数(UTF-8)。 */
197
+ export function composePackText(blocks, opts) {
198
+ const o = opts || {};
199
+ const head = String(o.head || '').trim();
200
+ const parts = [];
201
+ if (head) parts.push(head);
202
+ for (const b of blocks || []) {
203
+ if (!b) continue;
204
+ const title = String(b.title || '').trim();
205
+ const body = String(b.body || '').trim();
206
+ if (!body) continue;
207
+ parts.push((title ? '### ' + title + '\n' : '') + body);
208
+ }
209
+ const text = parts.join('\n\n').trim();
210
+ return { text, bytes: utf8Bytes(text) };
211
+ }
212
+
213
+ /** UTF-8 字节数(不依赖 TextEncoder,纯算,便于在无环境桩下运行)。 */
214
+ export function utf8Bytes(str) {
215
+ const s = String(str || '');
216
+ let n = 0;
217
+ for (let i = 0; i < s.length; i++) {
218
+ const c = s.charCodeAt(i);
219
+ if (c < 0x80) n += 1;
220
+ else if (c < 0x800) n += 2;
221
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++; } // 代理对
222
+ else n += 3;
223
+ }
224
+ return n;
225
+ }
226
+
227
+ /** 校验并归一 manifest;返回 { ok, errors, manifest }。 */
228
+ export function parseManifest(json) {
229
+ const errors = [];
230
+ const m = (typeof json === 'string') ? safeJson(json) : (json || {});
231
+ if (!m || typeof m !== 'object') return { ok: false, errors: ['manifest 不是对象'], manifest: null };
232
+ const LAYERS = ['lenses', 'genres', 'industries', 'forms', 'looks'];
233
+ const out = {};
234
+ const ids = new Map();
235
+ for (const layer of LAYERS) {
236
+ const list = m[layer];
237
+ if (!Array.isArray(list)) { errors.push('缺少数组 ' + layer); out[layer] = []; continue; }
238
+ out[layer] = [];
239
+ for (const it of list) {
240
+ if (!it || typeof it !== 'object') { errors.push(layer + ' 含非对象项'); continue; }
241
+ const id = String(it.id || '').trim();
242
+ const name = String(it.name || '').trim();
243
+ if (!id) { errors.push(layer + ' 有项缺 id'); continue; }
244
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) { errors.push(layer + '/' + id + ': id 只允许小写字母数字下划线连字符'); }
245
+ if (ids.has(id)) errors.push('id 重复:' + id + '(' + ids.get(id) + ' 与 ' + layer + ')');
246
+ ids.set(id, layer);
247
+ if (!name) errors.push(layer + '/' + id + ': 缺 name');
248
+ if (!String(it.file || '').trim()) errors.push(layer + '/' + id + ': 缺 file');
249
+ out[layer].push({
250
+ id,
251
+ name,
252
+ group: String(it.group || '').trim(),
253
+ file: String(it.file || '').trim(),
254
+ glyph: String(it.glyph || id).trim(),
255
+ oneliner: String(it.oneliner || '').trim(),
256
+ tier: String(it.tier || '').trim(),
257
+ });
258
+ }
259
+ }
260
+ return { ok: errors.length === 0, errors, manifest: out };
261
+ }
262
+
263
+ function safeJson(s) {
264
+ try { return JSON.parse(s); } catch { return null; }
265
+ }
266
+
267
+ /**
268
+ * 组装某个生成能力下的注入文本。
269
+ * items: [{ layer, md, meta }],顺序即拼接顺序。
270
+ * 返回 { text, bytes, used: [{layer,id,bytes}] }
271
+ */
272
+ export function buildPackText(kind, items, opts) {
273
+ const o = opts || {};
274
+ const zh = o.zh !== false;
275
+ const blocks = [];
276
+ const used = [];
277
+ for (const it of items || []) {
278
+ if (!it || !it.md) continue;
279
+ const meta = it.meta || {};
280
+ const name = meta.name || meta.id || it.layer;
281
+ let body = '';
282
+ if (it.layer === 'moves') {
283
+ body = String(it.md || '').trim(); // 运镜词典整份注入(本身已是短条目)
284
+ } else {
285
+ body = pickSections(it.md, kind);
286
+ }
287
+ if (!body.trim()) continue;
288
+ blocks.push({ title: layerLabel(it.layer, zh) + ':' + name + (meta.oneliner ? ' —— ' + meta.oneliner : ''), body });
289
+ used.push({ layer: it.layer, id: meta.id || '', bytes: utf8Bytes(body) });
290
+ }
291
+ const head = o.head || '';
292
+ const r = composePackText(blocks, { head });
293
+ return { text: r.text, bytes: r.bytes, used };
294
+ }
295
+
296
+ /** 层的中文标签。 */
297
+ export function layerLabel(layer, zh) {
298
+ const ZH = { lens: '导演透镜', genre: '题材', industry: '行业题材', form: '类型规格', look: '影调', moves: '运镜与节奏' };
299
+ const EN = { lens: 'Director lens', genre: 'Genre', industry: 'Industry', form: 'Delivery form', look: 'Look', moves: 'Camera & rhythm' };
300
+ const m = zh === false ? EN : ZH;
301
+ return m[layer] || layer;
302
+ }
303
+
304
+ /** 默认配置(老项目无该字段时就是它,保证行为与未引入技能包前完全一致)。 */
305
+ export const DEFAULT_PROFILE = {
306
+ lens: '',
307
+ genres: [],
308
+ form: 'short-drama',
309
+ aspect: '',
310
+ targetSec: 0,
311
+ platform: '',
312
+ look: '',
313
+ genreMode: 'auto',
314
+ };
315
+
316
+ /** 归一技能配置:字符串裁剪、枚举由调用方给、非法回落、空值剔除。 */
317
+ export function normalizeProfile(raw, allowed) {
318
+ const b = (raw && typeof raw === 'object') ? raw : {};
319
+ const a = allowed || {};
320
+ const pick = (v, list) => {
321
+ const s = String(v == null ? '' : v).trim();
322
+ if (!s) return '';
323
+ if (Array.isArray(list) && list.length && !list.includes(s)) return '';
324
+ return s;
325
+ };
326
+ const genres = Array.isArray(b.genres)
327
+ ? [...new Set(b.genres.map((x) => String(x == null ? '' : x).trim()).filter(Boolean))]
328
+ .filter((x) => !Array.isArray(a.genres) || !a.genres.length || a.genres.includes(x))
329
+ .slice(0, 12)
330
+ : [];
331
+ const sec = Number(b.targetSec);
332
+ return {
333
+ lens: pick(b.lens, a.lenses),
334
+ genres,
335
+ form: pick(b.form, a.forms) || DEFAULT_PROFILE.form,
336
+ aspect: pick(b.aspect, a.aspects),
337
+ targetSec: Number.isFinite(sec) && sec > 0 ? Math.round(sec) : 0,
338
+ platform: pick(b.platform, a.platforms),
339
+ look: pick(b.look, a.looks),
340
+ genreMode: (b.genreMode === 'narrative' || b.genreMode === 'industry') ? b.genreMode : 'auto',
341
+ };
342
+ }
@@ -8,6 +8,35 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
8
8
  画布是一个按项目组织的节点工作台:剧本 → 分镜 → 图片 → 视频 → 成片。你(对话模型)是操作者,
9
9
  **所有画布操作只能通过 canvas_* 工具完成**,不要臆造节点或项目状态。
10
10
 
11
+ ## 〇## 〇、本插件技能清单(开工前先看这一节)
12
+
13
+ 本插件(无限画布)提供 4 份技能,**各管一段,不要漏读**:
14
+
15
+ | 技能 | 管什么 | 什么时候加载 |
16
+ |---|---|---|
17
+ | `canvas-studio` | 画布操作流程:建项目 / 写节点 / 触发生成 / 查状态 | 任何时候先读(本文件) |
18
+ | `canvas-craft` | 创作方法论:剧本四要素、分镜六要素、提示词规范 | 写剧本或拆分镜前 |
19
+ | `canvas-layout` | 节点布局纪律:一类型一列、绝不重叠 | 批量建节点前 |
20
+ | `canvas-director` | **导演透镜 24 / 叙事题材 21 + 行业题材 10 / 类型规格 8 / 影调 9 / 运镜词典** | **写任何提示词前** |
21
+
22
+ ### 怎么知道有哪些可用
23
+
24
+ 调 `canvas_list_skills`:它一次返回 4 份技能及各自何时用、技能包四层完整清单(每层的 id 与名称)、以及当前项目的已选配置。
25
+
26
+ ### 怎么按口径写提示词
27
+
28
+ 调 `canvas_skill_pack({ projectId, kind })` 取切片,**不要整份读技能包文件**:
29
+
30
+ - `kind: 'script'` —— 写剧本前
31
+ - `kind: 'storyboard'` —— 拆分镜前
32
+ - `kind: 'prompt'` —— 写图片或视频提示词前
33
+ - `kind: 'assets'` —— 写角色/场景/道具设定图提示词前
34
+
35
+ 返回的文本可直接拼进你的系统提示。也可临时覆盖:`{ lens:'fincher', genres:['crime-police'], form:'trailer', look:'dark-myth-cg' }`;
36
+ 传 `set:true` 会把配置写进项目(此后画布自生成也用同一套)。
37
+
38
+ **这是让对话写出的内容与画布自生成保持同一口径的唯一入口。** 跳过它直接写提示词,等于放弃用户已经选好的导演与题材。
39
+
11
40
  ## 一、硬约束(违反即视为操作失败)
12
41
 
13
42
  0. **设定集先行。** 写剧本、写分镜、生成任何画面之前,**必须先 `canvas_get_bible` 读设定集**:
@@ -107,9 +136,11 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
107
136
  **不带 = 默认画布**,会"报节点不存在"或写错画布。
108
137
  - 画布之间节点/连线互相隔离;**资产与设定集按项目共享**(所有画布同一份设定集、同一资产库),
109
138
  不要为不同画布重建设定集或重复上传资产。
110
- - **专家人设给方向,技能判据是准绳**:内置分镜专家的 persona 已写入六要素 / 每场 1 个大特写 /
111
- motion「起点→方式→终点」/ 同场连续三镜不同运动 / 每场冲撞点等硬规则,但仍以 `canvas-craft`
112
- 的完整判据与自检表为准;两者冲突时,**以技能判据为准**。
139
+ - **创作硬规则只有一份:`canvas-craft` 技能正文**(六要素 / 每场 1 个大特写 /
140
+ motion「起点→方式→终点」/ 同场连续三镜不同运动 / 每场冲撞点 / 因果钩子 / 术语勘误,全在里面)。
141
+ 原先另有一份「专家人设」复述同一批规则,已移除——两条并存会给出互相矛盾的指令。
142
+ 风格与题材侧(导演 / 题材 / 类型规格 / 影调)由 `canvas-director` 技能包的切片提供,
143
+ 用 `canvas_skill_pack` 取,见本节开头的技能清单。
113
144
 
114
145
  ## 二、标准工作流(对话填提示词+连线+触发,生成由节点完成)
115
146
 
@@ -3528,7 +3528,10 @@ export function cameraFlyStep(cam, input, dt, opts) {
3528
3528
  * 单位一律是 CSS 像素 → 同一相机/同一 CSS 尺寸下两者必然一致(历史 bug:绘制用设备像素、拾取用 CSS,命中区偏移)。
3529
3529
  */
3530
3530
  export function gizmoMetrics(cam, sel, hCss, originOverride) {
3531
- const origin = originOverride || v3(sel.pos.x, sel.pos.y + (Number(sel.poseCenterY) || 0), sel.pos.z);
3531
+ // 轴心 = 物体自身坐标系原点(人物是脚底中心),**不随姿态上浮**。
3532
+ // 轴朝向由 objectAxes(sel) 给出 → 物体旋转时轴跟着转(人物倒地后 z 轴仍指向头)。
3533
+ // 需要「轴心跟随身体」的场合显式传 originOverride(见 render 里的 gizmoFollowBody 开关)。
3534
+ const origin = originOverride || v3(sel.pos.x, sel.pos.y, sel.pos.z);
3532
3535
  const depth = depthOf(cam, origin);
3533
3536
  const pxWorld = pixelSizeAt(cam, hCss, depth);
3534
3537
  const minWorld = Math.max(0.25, (Number(sel.h) || 1.7) / 3);
@@ -5551,8 +5554,13 @@ export function createDirectorGlRenderer(canvas) {
5551
5554
  if (o.gizmo && o.selectedId) {
5552
5555
  const sel = (scene.objects || []).find((x) => x.id === o.selectedId);
5553
5556
  if (sel) {
5554
- // gizmo 跟随姿态中心(倒地/下蹲时身体不在原点:让轴心贴住身体,而非留在地面)
5555
- const origin = v3(sel.pos.x, sel.pos.y + (Number(sel.poseCenterY) || 0), sel.pos.z);
5557
+ // 轴心默认在物体原点(人物 = 脚底中心),轴朝向由 objectAxes(sel) 给出
5558
+ // → 物体旋转时轴跟着转(人物倒地后 z 轴仍指向头),不是固定世界坐标。
5559
+ // 「跟随身体」是显式开关:仅当选中项或场景打开 gizmoFollowBody 时才抬到髋高。
5560
+ const followBody = !!(sel.gizmoFollowBody || o.gizmoFollowBody);
5561
+ const origin = followBody
5562
+ ? v3(sel.pos.x, sel.pos.y + (Number(sel.poseCenterY) || 0), sel.pos.z)
5563
+ : v3(sel.pos.x, sel.pos.y, sel.pos.z);
5556
5564
  // 屏幕恒定大小(Blender: wm_gizmo_calculate_scale):轴长 105 CSS px,环 0.62×轴长。
5557
5565
  // 尺寸唯一来源 gizmoMetrics,拾取用同一个函数 → 不可能再出现「画的位置和点不中」。
5558
5566
  const gm = gizmoMetrics(cam, sel, hCss, origin);
@@ -5939,7 +5947,12 @@ export function directorPickGizmo(scene, selectedId, w, h, sx, sy) {
5939
5947
  if (!cam) return null;
5940
5948
  // 与绘制完全同源:精确屏幕投影 + 同一个 gizmoMetrics(w/h 都是 CSS 尺寸)
5941
5949
  const project = (p) => directorScreenOf(cam, w, h, p);
5942
- const gm = gizmoMetrics(cam, sel, h);
5950
+ // 轴心规则必须与绘制一致:默认物体原点,只有显式打开「跟随身体」才抬到髋高
5951
+ const pickFollow = !!(sel.gizmoFollowBody || scene.gizmoFollowBody);
5952
+ const pickOrigin = pickFollow
5953
+ ? v3(sel.pos.x, sel.pos.y + (Number(sel.poseCenterY) || 0), sel.pos.z)
5954
+ : v3(sel.pos.x, sel.pos.y, sel.pos.z);
5955
+ const gm = gizmoMetrics(cam, sel, h, pickOrigin);
5943
5956
  const origin = gm.origin, L = gm.L, R = gm.R;
5944
5957
  const ax = objectAxes(sel);
5945
5958
  const AXES = [['x', ax.x], ['y', ax.y], ['z', ax.z]];