dsh-drawai 0.4.3 → 0.4.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.
@@ -0,0 +1,147 @@
1
+ # 水墨写意 · 23-ink-wash
2
+
3
+ > 上游 `huashu-art-motion`(MIT)17_ink 的移植说明。上游是「HTML 页 + renderAt(t) + Playwright 截帧」,
4
+ > 这里是「Remotion 场景逐帧渲染」。同构点只有一个:每一帧都是 t 的纯函数。其余全要换。
5
+
6
+ ## 1. 一眼认出
7
+
8
+ - **纸接着画**:整幅只有一种底 —— `#EFE6D0` 宣纸,叠 2600 根纸纤维短弧(褐 α.05–.13 / 亮 α.12–.27)+ 四角暗角 α.22。画面里不出现纯白卡片、不出现纯黑。
9
+ - **每一笔都是毛笔**:控制点先 Catmull-Rom 加密,再按 **4px 等弧长重采样**(`demos/23-ink-wash/brush.js` 的 `P.brush`)。宽度按 `prof(q)=ss(0,head,q)·(1−0.7·ss(1−tail,1,q))·(0.85+0.15·noise)`,笔尾比笔头细 30–50%,笔毛按噪声断开露出纸。
10
+ - **墨只有一个颜色**:`[16,14,12]`,靠 `tone` 分五色(焦 .9 / 浓 .8 / 重 .6 / 淡 .35 / 清 .15)。全片第二个「黑」一旦出现,立刻不像水墨。
11
+ - **晕染是两遍 multiply**:外晕 `blur(halo·(0.4+grow))`、α `haloA·grow`;本体 `blur(blur·(1.6−0.6·grow))`、α `ss(0,0.6,grow)`。`grow` 0→1 是「墨在纸上洇开」,不是透明度淡入。
12
+ - **留白是挖出来的**:白色部分(猫的口鼻胸爪、交领内衫)用 `destination-out` 从色层里挖掉露出纸色,不是画白色。
13
+ - **收尾有一方印**:0.3–0.4s 内从 1.5 倍压到 1 倍,`multiply` 盖上,印面随机挖 40 个 1–4px 的斑驳。
14
+
15
+ ## 2. 色板
16
+
17
+ | 角色 | hex | 用在哪 |
18
+ | --- | --- | --- |
19
+ | 纸 `PAPER` | `#EFE6D0` | 整幅底;雾带用小一号的 `#F2EAD6` |
20
+ | 留白 `SURFACE` | `#F7F1E2` | 面部、白瓷、内衫(挖出来的地方偏亮一点) |
21
+ | 墨 `INK` | `#14120F` | 所有线;按 tone 分五色,不新增墨色 |
22
+ | 次色 `FIELD` | `#6F8AA0` | 花青:衣、远山冷部 |
23
+ | 灰 `DUST` | `#8A7C64` | 淡墨字、纸边、题款 |
24
+ | 朱 `STAMP` | `#C4281E` | **只三处**:钤印、腰带、点唇 |
25
+ | 暖点 | `#D8553F` / `#C47A3C` / `#E2AA34` | 朱日 / 赭石(猫) / 藤黄(发、光) |
26
+
27
+ **禁止色**:`#FFFFFF`、`#000000`、任何饱和度 > 0.6 的蓝紫。要让一块区域「亮」就用纸色,不要提亮到白。
28
+ **禁止手法**:给笔画加 `box-shadow`、`text-shadow`、CSS 渐变描边;那是排版,不是墨。
29
+
30
+ ## 3. 字体与字排
31
+
32
+ 上游用 `LXGWWenKai-500`(霞鹜文楷)。**当前优先使用插件内置字体**,替代栈:
33
+
34
+ - 标题 / 题款:`"Long Cang","Noto Serif SC",serif` —— 龙藏体是行草连笔,起收有粗细变化,接近毛笔题字。
35
+ 损失:龙藏体骨架偏软,写「永」「之」这类字时折笔没有楷书的骨力;要更端正就用 Noto Serif SC。
36
+ - 正文 / 旁白:`"Noto Serif SC","HarmonyOS Sans SC",serif` —— 宋体本身就是刻本的骨架,和宣纸同源。
37
+ - 数字 / 年份:`"IBM Plex Mono",monospace`,字距 +0.04em。
38
+
39
+ 字排规则:标题 72–96px、字距 6px;副题 28–32px、`DUST` 色;**标题允许竖排**(`writing-mode: vertical-rl`,从上到下、从右到左),竖排时右起第一列就是主标题。
40
+ 小字别用 Long Cang:它的细笔画在 4K 下会断成虚线,18px 以下一律 Noto Serif SC。
41
+ 中英混排时英文降到 0.92 倍字号、基线对齐,不要用斜体(行草里没有假斜体)。
42
+
43
+ ## 4. 动效语法
44
+
45
+ - **节拍**:60 BPM,30fps 下 **30 帧/拍**。水墨没有硬切,全片是一镜到底的呼吸,`cutFrames` 只用来对齐段落。
46
+ - **缓动**:只用 `smoothstep`(`ss(a,b,x)`)与 `easeOutCubic`。**禁用** `spring` / `back` / 任何 overshoot —— 墨落在纸上不会回弹。
47
+ - **签名动作**(帧数按 30fps):
48
+ 1. **写出**:`reveal = ss(d, d+l, lt/0.45)`,每笔自带起点 `d` 与时长 `l`(0.08–0.18s),按「窗 → 案 → 竹 → 鸟 → 猫 → 人」的顺序错峰;开场先把 `lt` 加 0.12,**第 0 帧就要有前几笔**。
49
+ 2. **洇开**:`grow = ss(0, 1.15, lt)`;外晕跟着 grow 变宽,本体跟着收紧 —— 一个变宽一个变实,缺一个就读成「模糊一遍就完事」。
50
+ 3. **摇曳**:竹绕基点摆 `sin(2.9t)·0.11`,越往梢越大(系数 `k = 节点序 / 4`);叶翻 `sin(7t+g)·0.2`。
51
+ 4. **雾带**:4 条纸色椭圆,`blur(14px)`、α.72,以 150–270px/s 横移,绕一圈回来 —— 远山靠它「时隐时现」。
52
+ 5. **压印**:`s = 1.5 − 0.5·easeOutCubic(q)`,`q = ss(0.3, 0.4, lt)`;同时 `globalCompositeOperation='multiply'`。
53
+ - **组成规则**:纸满幅;图层顺序固定为 纸 → 远山/朱日 → 雾带 → 静态笔触 → 竹 → 挂轴 → 动物 → 人物 → 热气 → 题款/印;四周留 6% 白边;**一屏只画一个「房间」**,水墨的空间感靠留白不靠透视。
54
+
55
+ ## 5. Remotion 落地配方
56
+
57
+ **组件形态**:一个 `<canvas>` 铺满 `AbsoluteFill`,`useLayoutEffect` 里按 `frame` 重画。Remotion 会为每一帧重新执行 effect,函数必须幂等:不读时钟、不留上一帧状态、不累加。
58
+
59
+ **必须换掉的上游写法**
60
+
61
+ | 上游 | 这里 |
62
+ | --- | --- |
63
+ | `requestAnimationFrame` / `draw(c, lt, t)` 主循环 | `const frame = useCurrentFrame()`;`t = frame / fps`、`lt = t / duration` |
64
+ | `U.rng(seed)` 种子随机 | `det(frame, salt, i)`(`scene-kit/det.ts`),或本文件里的 `hash()` |
65
+ | `P.cached(key, w, h, fn)` | 模块级 `Map<string, HTMLCanvasElement>`;key 带分辨率 |
66
+ | `P.texture('xuan', …)` 外部贴图 | 程序化纸:`feTurbulence` 转图,或直接画 2600 根短弧(推荐,路径可控) |
67
+ | `ctx.filter='blur()'` 循环调用 | 可用(Chromium 支持),但每帧多次 blur 很贵:晕染层做成 offscreen,只有 `grow` 变化的量重画 |
68
+ | CSS transition / animation | 一律算出每帧的数值,写进 canvas |
69
+ | `Math.random()` | 禁;用 `det`,否则 motion_qa 的「同一帧两次渲染不一致」会抓到你 |
70
+
71
+ **性能预算**:纸 2600 根弧 ≈ 12–18ms,只画一次;静态笔触 15 笔 × 8 根笔毛 × ~120 采样点 ≈ 每帧 2 万次 `lineTo`,**必须缓存**成 `ink_static` 位图(写出阶段才逐帧画);剩下每帧真正动的只有竹、雾带、虾、猫的呼吸、热气 —— 控制在 8ms 内。
72
+
73
+ **骨架**(可跑;`inkBrush` 就是 `demos/23-ink-wash/brush.js` 的 `P.brush`,把 `rng`→`det`、`ss`→上面的 smoothstep 即可):
74
+
75
+ ```tsx
76
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion'
77
+ import { useLayoutEffect, useRef } from 'react'
78
+ import { PAPER, INK, STAMP } from './theme'
79
+ import { inkBrush, inkLeaf, inkWash } from './ink'
80
+
81
+ const ss = (a: number, b: number, x: number) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t) }
82
+
83
+ export default function Scene({ shot }) {
84
+ const ref = useRef<HTMLCanvasElement>(null)
85
+ const { width, height, fps, durationInFrames } = useVideoConfig()
86
+ const frame = useCurrentFrame()
87
+ useLayoutEffect(() => {
88
+ const c = ref.current!.getContext('2d')!
89
+ const t = frame / fps, lt = frame / durationInFrames + 0.12
90
+ const grow = ss(0, 1.15, lt), rev = Math.min(1, lt / 0.45)
91
+ c.clearRect(0, 0, width, height)
92
+ c.drawImage(paper(width, height), 0, 0) // 宣纸:模块级缓存
93
+ inkWash(c, 'mtn', ss(0, 0.3, lt) * (0.6 + 0.4 * grow), g => mountains(g, width), { blur: 3, halo: 14, haloA: 0.3 })
94
+ if (rev < 1) staticStrokes(c, width, rev) else c.drawImage(staticCache(width), 0, 0)
95
+ bamboo(c, width, height, t, rev)
96
+ inkLeaf(c, width * 0.31, height * 0.21, -0.5 + Math.sin(t * 7) * 0.2, 104, 15, 0.86) // 一片竹叶也要走毛笔
97
+ }, [frame, width, height, fps, durationInFrames])
98
+ return <AbsoluteFill style={{ background: PAPER }}><canvas ref={ref} width={width} height={height} /></AbsoluteFill>
99
+ }
100
+
101
+ // 笔毛:每根一条连续折线,按噪声断开 —— 飞白就是断口露出的纸
102
+ function bristles(c: CanvasRenderingContext2D, d: Pt[], n: number, w: number, dry: number, salt: number) {
103
+ for (let k = 0; k < n; k++) {
104
+ c.beginPath(); let on = false
105
+ const off = (k / (n - 1) - 0.5) * 0.95 * w
106
+ for (let i = 0; i < d.length; i++) {
107
+ const q = i / (d.length - 1)
108
+ const vis = hash(salt * 31 + k * 7919 + i * 0.09) > dry * Math.pow(q, 1.2) * 0.95 + 0.05
109
+ const x = d[i][0] + off, y = d[i][1]
110
+ if (vis) { on ? c.lineTo(x, y) : c.moveTo(x, y); on = true } else on = false
111
+ }
112
+ c.stroke()
113
+ }
114
+ }
115
+ ```
116
+
117
+ `ink.ts` 里放纯函数(无 React、无 DOM 查询),这样 `motion_preview` 失败时能单独在 Node 里跑那 30 行算法定位。
118
+
119
+ ## 6. 镜头结构模板
120
+
121
+ | 时间 | 意图 |
122
+ | --- | --- |
123
+ | 0–0.1s | **有东西可看**:第 0 帧已有窗框两笔 + 一片竹叶(白纸首帧不能当封面) |
124
+ | 0.1–0.45s | 写出主体:按窗 → 案 → 竹 → 主体的顺序,每笔 0.08–0.18s,错峰 0.02–0.04s |
125
+ | 0.45–1.2s | 墨继续洇开(grow 走完),雾带开始横移,主体第一次呼吸 |
126
+ | 1.2–2.4s | 主体的小动作(动物眨眼/尾巴、衣带摆 3.4s 周期),竹持续摇曳 |
127
+ | 2.4–3.2s | 需要的话补第二层:热气、飘落物、第二只动物;**不换机位** |
128
+ | 3.2–3.8s | 题款 + 钤印压下去(1.5→1 倍),画面其余部分开始安静 |
129
+ | 3.8–4.0s | 定格 0.2s 收尾;最后 6 帧不要再加新笔(观众要看清那方印) |
130
+
131
+ 要 10 秒就把 1.2–3.2s 拉长,别加镜头;要 6 秒就砍到「写出 + 洇开 + 印」。
132
+
133
+ ## 7. 坑
134
+
135
+ - **multiply 透底**:晕染是透明叠加,背后的窗台墨线会透过猫的耳朵、桌腿会透过衣裙,读成「层级错了」。上色前先在剪影里 `clip` 后 `drawImage(纸)` 补一层纸色 —— 等于「先为它留出这块地方」。
136
+ - **笔毛接头**:按小段单独 `stroke` + `round` cap,接头处会叠出一节节深点,像竹节或麻绳。每根笔毛必须一条连续 path、`butt` cap。
137
+ - **低动画面**:全片都是缓慢晕染时,`motion_qa` 的「整镜动能量」会报 warn(它抓的正是翻页感)。解法是保证每 2 秒有一次可见变化:雾带过境、竹梢一摆、虾游一段 —— 不是加特效。
138
+ - **缓存泄漏**:纸和静态笔触的缓存 key 必须带分辨率;换竖版 1080×1920 时复用横版位图会整片糊。
139
+ - **字体回落**:Long Cang 没有的简体字会回落到 Noto Serif SC,同一行里两种骨力会看得出来;标题先试排一遍,缺字就整句改用 Noto Serif SC。
140
+ - **细线 shimmer**:1px 笔毛在缩放/编码后闪烁,`det` 抖动控制在 ±0.4px 以内,别用亚像素随机。
141
+
142
+ ## 8. 版权与素材
143
+
144
+ - 上游 `huashu-art-motion` 为 MIT;本风格的提示词整理、场景源码节选与笔刷库均随插件分发,保留出处即可。
145
+ - 笔法(飞白、没骨、晕染、钤印)、墨分五色、母题动法(写出、洇开、摇曳)是公共技法,可自由用。
146
+ - **不要复刻具体作品**:齐白石的虾、八大山人的白眼鸟是具体画面,可以借「六节虾身 + 焦墨头胸甲」的画法,不要连构图和姿态一起搬。
147
+ - 字体 Long Cang / Noto Serif SC 均为 SIL OFL,可随包分发;品牌名、印章文字必须虚构(别写真人的名号)。
@@ -0,0 +1,28 @@
1
+ {
2
+ "slug": "23-ink-wash",
3
+ "name": "水墨写意",
4
+ "oneLiner": "宣纸是底、墨只加不铺:一笔一笔写出窗、竹、猫与人,墨分五色,留白是挖出来的,钤印收尾。",
5
+ "signature": [
6
+ "纸是整幅唯一的底:#efe6d0 宣纸 + 2600 根纸纤维 + 四角暗角 α.22,没有纯白也没有纯黑背景",
7
+ "每一笔都是毛笔:4px 等弧长重采样,笔压起收,逐根笔毛按噪声断开出飞白,笔尾比笔头细 30–50%",
8
+ "墨只用一个色 [16,14,12],靠 tone 分五色(焦 .9/浓 .8/重 .6/淡 .35/清 .15),全片不出现第二种黑",
9
+ "晕染是两遍 multiply(外晕 halo·(0.4+grow) + 本体 blur·(1.6−0.6grow)),grow 0→1 是「洇开」不是「淡入」",
10
+ "留白用 destination-out 从色层里挖掉露出纸色,不是画白色;钤印 1.5→1 倍压下去、multiply 盖上"
11
+ ],
12
+ "palette": {
13
+ "page": "#EFE6D0",
14
+ "surface": "#F7F1E2",
15
+ "field": "#6F8AA0",
16
+ "text": "#14120F",
17
+ "muted": "#8A7C64",
18
+ "accent": "#C4281E",
19
+ "border": "#C9B58C"
20
+ },
21
+ "colors": ["#EFE6D0", "#F7F1E2", "#14120F", "#C4281E", "#D8553F", "#C47A3C", "#6F8AA0", "#E2AA34"],
22
+ "fonts": {
23
+ "display": "\"Long Cang\",\"Noto Serif SC\",serif",
24
+ "text": "\"Noto Serif SC\",\"HarmonyOS Sans SC\",serif",
25
+ "mono": "\"IBM Plex Mono\",monospace"
26
+ },
27
+ "rhythm": { "bpm": 60, "fps": 30, "duration": 10, "aspect": "16:9", "cutFrames": 30 }
28
+ }
@@ -2,7 +2,7 @@
2
2
  "source": "https://github.com/Vincentwei1021/mg-styles-15",
3
3
  "commit": "49052d80bfaf9fa8412cc9710530e5e95a3b8d8c",
4
4
  "license": "MIT",
5
- "note": "整片风格库:14 种 MG 视觉语言,每种由一份上游提示词 + 一支上游成片源码 + 一份 Remotion 移植 brief 组成。上游的 04-3d-render(Blender 离线渲的 CGI 质感)没有收录,需要那种质感请走外部渲染回灌。",
5
+ "note": "整片风格库:15 种 MG 视觉语言,每种由一份上游提示词 + 一支上游成片源码 + 一份 Remotion 移植 brief 组成。上游的 04-3d-render(Blender 离线渲的 CGI 质感)没有收录,需要那种质感请走外部渲染回灌。",
6
6
  "promptChars": 1258,
7
7
  "styles": [
8
8
  {
@@ -94,7 +94,8 @@
94
94
  "files": [
95
95
  "demos/01-flat-vector/index.html"
96
96
  ]
97
- }
97
+ },
98
+ "origin": null
98
99
  },
99
100
  {
100
101
  "slug": "02-line-art",
@@ -187,7 +188,8 @@
187
188
  "files": [
188
189
  "demos/02-line-art/film.js"
189
190
  ]
190
- }
191
+ },
192
+ "origin": null
191
193
  },
192
194
  {
193
195
  "slug": "03-isometric",
@@ -301,7 +303,8 @@
301
303
  "demos/03-isometric/js/ui.js",
302
304
  "demos/03-isometric/js/world.js"
303
305
  ]
304
- }
306
+ },
307
+ "origin": null
305
308
  },
306
309
  {
307
310
  "slug": "05-cel-boil",
@@ -396,7 +399,8 @@
396
399
  "files": [
397
400
  "demos/05-cel-boil/js/scene.js"
398
401
  ]
399
- }
402
+ },
403
+ "origin": null
400
404
  },
401
405
  {
402
406
  "slug": "06-collage",
@@ -490,7 +494,8 @@
490
494
  "files": [
491
495
  "demos/06-collage/index.html"
492
496
  ]
493
- }
497
+ },
498
+ "origin": null
494
499
  },
495
500
  {
496
501
  "slug": "07-liquid",
@@ -597,7 +602,8 @@
597
602
  "demos/07-liquid/js/shaders.js",
598
603
  "demos/07-liquid/js/type.js"
599
604
  ]
600
- }
605
+ },
606
+ "origin": null
601
607
  },
602
608
  {
603
609
  "slug": "08-morph",
@@ -693,7 +699,8 @@
693
699
  "files": [
694
700
  "demos/08-morph/index.html"
695
701
  ]
696
- }
702
+ },
703
+ "origin": null
697
704
  },
698
705
  {
699
706
  "slug": "09-bauhaus",
@@ -785,7 +792,8 @@
785
792
  "files": [
786
793
  "demos/09-bauhaus/film.js"
787
794
  ]
788
- }
795
+ },
796
+ "origin": null
789
797
  },
790
798
  {
791
799
  "slug": "10-synthwave",
@@ -877,7 +885,8 @@
877
885
  "files": [
878
886
  "demos/10-synthwave/index.html"
879
887
  ]
880
- }
888
+ },
889
+ "origin": null
881
890
  },
882
891
  {
883
892
  "slug": "12-aurora-glass",
@@ -973,7 +982,8 @@
973
982
  "demos/12-aurora-glass/main.js",
974
983
  "demos/12-aurora-glass/shaders.js"
975
984
  ]
976
- }
985
+ },
986
+ "origin": null
977
987
  },
978
988
  {
979
989
  "slug": "18-hanazi",
@@ -1064,7 +1074,8 @@
1064
1074
  "files": [
1065
1075
  "demos/18-hanazi/index.html"
1066
1076
  ]
1067
- }
1077
+ },
1078
+ "origin": null
1068
1079
  },
1069
1080
  {
1070
1081
  "slug": "19-paperclip",
@@ -1154,7 +1165,8 @@
1154
1165
  "files": [
1155
1166
  "demos/19-paperclip/index.html"
1156
1167
  ]
1157
- }
1168
+ },
1169
+ "origin": null
1158
1170
  },
1159
1171
  {
1160
1172
  "slug": "20-pixel",
@@ -1243,7 +1255,8 @@
1243
1255
  "files": [
1244
1256
  "demos/20-pixel/film.js"
1245
1257
  ]
1246
- }
1258
+ },
1259
+ "origin": null
1247
1260
  },
1248
1261
  {
1249
1262
  "slug": "22-hud",
@@ -1350,6 +1363,106 @@
1350
1363
  "demos/22-hud/js/util.js",
1351
1364
  "demos/22-hud/js/v5.js"
1352
1365
  ]
1366
+ },
1367
+ "origin": null
1368
+ },
1369
+ {
1370
+ "slug": "23-ink-wash",
1371
+ "name": "水墨写意",
1372
+ "en": "Chinese Ink Wash / Xieyi",
1373
+ "heading": "水墨写意 Chinese Ink Wash / Xieyi",
1374
+ "oneLiner": "宣纸是底、墨只加不铺:一笔一笔写出窗、竹、猫与人,墨分五色,留白是挖出来的,钤印收尾。",
1375
+ "shot": "huashu 17_ink · 1920×1080 · 30 fps · 10 秒 · 单镜写意房间",
1376
+ "tags": [
1377
+ "水墨",
1378
+ "写意",
1379
+ "国风",
1380
+ "宣纸",
1381
+ "毛笔",
1382
+ "墨分五色",
1383
+ "留白",
1384
+ "八大山人",
1385
+ "齐白石",
1386
+ "ink-wash",
1387
+ "xieyi",
1388
+ "chinese-ink"
1389
+ ],
1390
+ "signature": [
1391
+ "纸是整幅唯一的底:#efe6d0 宣纸 + 2600 根纸纤维 + 四角暗角 α.22,没有纯白也没有纯黑背景",
1392
+ "每一笔都是毛笔:4px 等弧长重采样,笔压起收,逐根笔毛按噪声断开出飞白,笔尾比笔头细 30–50%",
1393
+ "墨只用一个色 [16,14,12],靠 tone 分五色(焦 .9/浓 .8/重 .6/淡 .35/清 .15),全片不出现第二种黑",
1394
+ "晕染是两遍 multiply(外晕 halo·(0.4+grow) + 本体 blur·(1.6−0.6grow)),grow 0→1 是「洇开」不是「淡入」",
1395
+ "留白用 destination-out 从色层里挖掉露出纸色,不是画白色;钤印 1.5→1 倍压下去、multiply 盖上"
1396
+ ],
1397
+ "palette": {
1398
+ "page": "#EFE6D0",
1399
+ "surface": "#F7F1E2",
1400
+ "field": "#6F8AA0",
1401
+ "text": "#14120F",
1402
+ "muted": "#8A7C64",
1403
+ "accent": "#C4281E",
1404
+ "border": "#C9B58C"
1405
+ },
1406
+ "paletteSource": "brief",
1407
+ "colors": [
1408
+ "#EFE6D0",
1409
+ "#F7F1E2",
1410
+ "#14120F",
1411
+ "#C4281E",
1412
+ "#D8553F",
1413
+ "#C47A3C",
1414
+ "#6F8AA0",
1415
+ "#E2AA34"
1416
+ ],
1417
+ "fonts": {
1418
+ "display": "\"Long Cang\",\"Noto Serif SC\",serif",
1419
+ "text": "\"Noto Serif SC\",\"HarmonyOS Sans SC\",serif",
1420
+ "mono": "\"IBM Plex Mono\",monospace"
1421
+ },
1422
+ "rhythm": {
1423
+ "bpm": 60,
1424
+ "fps": 30,
1425
+ "duration": 10,
1426
+ "aspect": "16:9",
1427
+ "cutFrames": 30
1428
+ },
1429
+ "prompt": "prompts/23-ink-wash.md",
1430
+ "demos": [
1431
+ "demos/23-ink-wash/film.js",
1432
+ "demos/23-ink-wash/brush.js"
1433
+ ],
1434
+ "brief": "briefs/23-ink-wash.md",
1435
+ "engine": "canvas2d",
1436
+ "engines": [
1437
+ "canvas2d"
1438
+ ],
1439
+ "engineNote": "纯 Canvas2D:宣纸纤维、笔毛飞白、墨晕两遍 multiply 全在 2D 画布上画,没有 shader,也没有 DOM 或贴图合成。",
1440
+ "engineFiles": [
1441
+ "demos/23-ink-wash/brush.js"
1442
+ ],
1443
+ "motion": {
1444
+ "motionBlur": null,
1445
+ "transitions": "cut",
1446
+ "impact": false,
1447
+ "camera": false,
1448
+ "grain": {
1449
+ "size": null
1450
+ },
1451
+ "secondary": [],
1452
+ "feather": false,
1453
+ "files": [
1454
+ "demos/23-ink-wash/film.js"
1455
+ ]
1456
+ },
1457
+ "origin": {
1458
+ "source": "https://github.com/alchaincyf/huashu-art-motion",
1459
+ "license": "MIT",
1460
+ "files": {
1461
+ "prompts/23-ink-wash.md": "references/风格配方/17_ink.md",
1462
+ "demos/23-ink-wash/film.js": "scripts/engine/scenes/17_ink.js",
1463
+ "demos/23-ink-wash/brush.js": "scripts/engine/lib/brush.js"
1464
+ },
1465
+ "note": "场景为节选(少女段落未收),brush.js 逐字收录;依赖上游全局 U/KIT/PAINT,只作阅读参考。"
1353
1466
  }
1354
1467
  }
1355
1468
  ]
@@ -0,0 +1,149 @@
1
+ // 笔与水:毛笔飞白、两头尖单笔、墨晕晕染、长弯流线笔、水彩洗染、厚涂鬃毛笔、剪刀折线。全部挂在 PAINT(P)上。
2
+ // 来源:迁移测试 A(水墨/蒙克)、B(吉卜力)、C(马蒂斯)、D(伦勃朗)。确定性:所有随机都走种子。
3
+ // P.brush(c, pts, opt) 毛笔:笔压起收+湿笔芯+逐根笔毛飞白,可按 reveal 写出(水墨、书法、白描衣纹)
4
+ // P.leaf(c, x, y, ang, L, w) 两头尖的单笔(竹叶、兰叶、鸟翅、虾尾)
5
+ // P.inkWash(c, key, grow, fn, o) 墨晕:形状先画到 scratch,再「外晕+本体」两遍模糊 multiply 到纸上,grow 0→1 是洇开
6
+ // P.flowSeeds / P.flowLines 长弯流线笔:种子沿随时间扭动的方向场积分成 200–300px 的长笔(蒙克,不是梵高的短笔)
7
+ // P.deform / P.watercolor 水彩洗染:中点位移变形多边形 × 多层低 α multiply + 每层淡描边=水痕边(吉卜力背景)
8
+ // P.rectPts / P.ellipsePts 洗染常用的多边形点列
9
+ // P.impasto(c, pts, w, col, hi) 厚涂鬃毛笔:一笔=若干平行细鬃(伦勃朗高光、梵高厚涂补笔)
10
+ // P.cut(pts, seed, step, amp) 剪刀折线:沿轮廓每 step px 取点+抖动、直线相连(马蒂斯剪纸、剪影、木刻)
11
+ (() => {
12
+ let W = 1920, H = 1080; U.onStage((w, h) => { W = w; H = h; }); // 画布尺寸跟 U.setStage 走(默认 1920×1080)
13
+ const P = window.PAINT;
14
+ const { clamp, lerp, rng, ss } = U;
15
+
16
+ // ---------- 毛笔 ----------
17
+ // pts 控制点(会 Catmull-Rom 加密再按 4px 等弧长重采样——不重采样的话飞白噪声频率随笔画长短变)
18
+ // w 最大宽;tone 墨色透明度(焦 .9/浓 .8/重 .6/淡 .35/清 .15);dry 0..1 飞白程度(越到笔尾越干);reveal 0..1 写出比例
19
+ // head/tail 起笔/收笔占比;bristles 笔毛数;col 墨色 [r,g,b];per 加密倍数
20
+ // 坑:笔毛必须每根一条连续 path(butt 头);按小段单独 stroke+round 头,接头处会叠出一节节深点,像竹节/麻绳。
21
+ P.brush = (c, pts, { w = 10, tone = 0.85, dry = 0.45, seed = 1, reveal = 1, bristles = 8, col = [16, 14, 12], head = 0.12, tail = 0.4, per = 5 } = {}) => {
22
+ if (reveal <= 0) return;
23
+ const D = KIT.resample(KIT.densify(pts, per), 4), n = D.length, m = Math.max(2, Math.ceil(n * clamp(reveal)));
24
+ const r = rng(seed * 7919);
25
+ const prof = q => ss(0, head, q) * (1 - 0.7 * ss(1 - tail, 1, q)) * (0.85 + 0.15 * P.noise(seed * 3.3, q * 6)) + 0.08;
26
+ c.save(); c.lineCap = 'butt'; c.lineJoin = 'round';
27
+ const N = [];
28
+ for (let i = 0; i < n; i++) { const A = D[Math.max(0, i - 1)], B = D[Math.min(n - 1, i + 1)]; let dx = B[0] - A[0], dy = B[1] - A[1]; const d = Math.hypot(dx, dy) || 1; N.push([-dy / d, dx / d]); }
29
+ const core = q => w * prof(q) * (1 - dry * 0.85 * Math.pow(q, 0.8));
30
+ // 湿的笔芯:整笔都有,越往后越细越淡(墨在用完)
31
+ c.fillStyle = `rgba(${col[0]},${col[1]},${col[2]},${tone * 0.72})`; c.fill(KIT.ribbon(D.slice(0, m), (q, i) => core(i / (n - 1))));
32
+ // 笔毛:每根一条连续线,按噪声断开(飞白=笔毛之间露出的纸),越到笔尾断得越多
33
+ for (let k = 0; k < bristles; k++) {
34
+ const off = (k / (bristles - 1) - 0.5) * 0.95, bw = w / bristles * (0.9 + r() * 0.9), ph = r() * 50;
35
+ c.strokeStyle = `rgba(${col[0]},${col[1]},${col[2]},${tone * (0.45 + r() * 0.35)})`; c.lineWidth = Math.max(0.7, bw);
36
+ c.beginPath(); let on = false;
37
+ for (let i = 0; i < m; i++) {
38
+ const q = i / (n - 1), dryness = dry * Math.pow(q, 1.2);
39
+ const vis = P.noise(k * 1.9 + ph, i * 0.09) * 0.5 + 0.5 > dryness * 0.95 + 0.05;
40
+ const o = off * w * prof(q), x = D[i][0] + N[i][0] * o, y = D[i][1] + N[i][1] * o;
41
+ if (vis) { on ? c.lineTo(x, y) : c.moveTo(x, y); on = true; } else on = false;
42
+ }
43
+ c.stroke();
44
+ }
45
+ c.restore();
46
+ };
47
+ // 两头尖的单笔(个字竹叶、兰叶描、鸟翅):沿 ang 方向长 L,最宽 wmax,微弯
48
+ P.leaf = (c, x, y, ang, L, wmax, tone = 0.88, col = [16, 14, 12]) => {
49
+ if (!Array.isArray(col)) col = [16, 14, 12];
50
+ const pts = []; for (let i = 0; i <= 10; i++) { const q = i / 10, bend = Math.sin(q * Math.PI) * 0.08 * L; pts.push([x + Math.cos(ang) * L * q - Math.sin(ang) * bend, y + Math.sin(ang) * L * q + Math.cos(ang) * bend]); }
51
+ c.fillStyle = `rgba(${col[0]},${col[1]},${col[2]},${tone})`; c.fill(KIT.ribbon(pts, q => wmax * Math.pow(Math.sin(Math.PI * Math.min(1, q * 1.1)), 0.7) + 0.6));
52
+ };
53
+
54
+ // ---------- 墨晕 / 晕染层 ----------
55
+ // fn(g) 在 scratch 上画形状(实色即可);grow 0..1 控制洇开:外晕 blur 随 grow 变宽,本体 blur 随 grow 收紧。
56
+ // halo 外晕模糊半径、haloA 外晕透明度、blur 本体模糊、alpha 整体透明度。返回 scratch(可再拿来做留白挖洞)。
57
+ // 坑:multiply 是透明的,背后的墨线会透过晕染(窗台线透过猫耳)。先在剪影里补一层纸色(clip 后 drawImage 纸)再上晕染=「先留出这块地方」。
58
+ P.inkWash = (c, key, grow, fn, { blur = 2, halo = 10, haloA = 0.22, alpha = 1 } = {}) => {
59
+ const S = P.scratch('inkw_' + key), g = S.getContext('2d'); g.reset(); fn(g);
60
+ c.save(); c.globalCompositeOperation = 'multiply';
61
+ if (grow > 0) {
62
+ c.globalAlpha = haloA * alpha * grow; c.filter = `blur(${(halo * (0.4 + grow)).toFixed(2)}px)`; c.drawImage(S, 0, 0);
63
+ c.globalAlpha = alpha * ss(0, 0.6, grow); c.filter = `blur(${(blur * (1.6 - grow * 0.6)).toFixed(2)}px)`; c.drawImage(S, 0, 0);
64
+ }
65
+ c.filter = 'none'; c.restore();
66
+ return S;
67
+ };
68
+
69
+ // ---------- 长弯流线笔(蒙克《呐喊》式表现主义) ----------
70
+ // 种子:cell 抖动网格,每个种子带两个随机数 [x, y, r1, r2]
71
+ P.flowSeeds = (cell = 24, seed = 5) => { const r = rng(seed), o = []; for (let y = -10; y < H + 10; y += cell) for (let x = -10; x < W + 10; x += cell) o.push([x + (r() - .5) * cell, y + (r() - .5) * cell, r(), r()]); return o; };
72
+ // 每个种子:region(x,y,t) 判区 → 取该区参数 params[reg] = [steps, step, width, alpha] → 沿 angle(x,y,t,reg) 积分;越出本区就停(区域边界干净)。
73
+ // color(reg, sx, sy, r1, t) 返回 [r,g,b]。不描边、半透明叠色——蒙克是薄涂长笔;照搬梵高的深色描边会变成一节节毛毛虫短笔。
74
+ // 底下先铺同色系平涂,笔缝里露出的是同色而不是黑。长度:steps 26–42、step 6–8,一根笔走 200–300px。
75
+ P.flowLines = (c, seeds, { t = 0, region, angle, color, params, extraSteps = 8 }) => {
76
+ c.lineCap = 'round'; c.lineJoin = 'round';
77
+ for (const [sx, sy, r1, r2] of seeds) {
78
+ const reg = region(sx, sy, t); const pr = params[reg]; if (!pr) continue; const [steps, step, width, alpha] = pr;
79
+ const col = color(reg, sx, sy, r1, t);
80
+ let x = sx, y = sy; c.beginPath(); c.moveTo(x, y);
81
+ const n = steps + ((r2 * extraSteps) | 0);
82
+ for (let i = 0; i < n; i++) { const a = angle(x, y, t, reg); x += Math.cos(a) * step; y += Math.sin(a) * step; if (region(x, y, t) !== reg) break; c.lineTo(x, y); }
83
+ c.strokeStyle = P.rgb(col, alpha); c.lineWidth = width * (0.7 + r2 * 0.6); c.stroke();
84
+ }
85
+ };
86
+
87
+ // ---------- 水彩洗染(吉卜力式背景美术) ----------
88
+ // 中点位移:每轮在每条边中点沿法向推一个近似高斯量(3 个均匀数相加),幅度与边长成比例,每轮 amp×0.6
89
+ P.deform = (pts, depth, amp, r) => {
90
+ let out = pts;
91
+ for (let d = 0; d < depth; d++) {
92
+ const n = [];
93
+ for (let i = 0; i < out.length; i++) {
94
+ const a = out[i], b = out[(i + 1) % out.length], len = Math.hypot(b[0] - a[0], b[1] - a[1]);
95
+ const g = (r() + r() + r() - 1.5) * amp * Math.min(1, len / 80), nx = -(b[1] - a[1]) / (len || 1), ny = (b[0] - a[0]) / (len || 1);
96
+ n.push(a, [(a[0] + b[0]) / 2 + nx * g, (a[1] + b[1]) / 2 + ny * g]);
97
+ }
98
+ out = n; amp *= 0.6;
99
+ }
100
+ return out;
101
+ };
102
+ const fillPts = (g, pts) => { g.beginPath(); pts.forEach((q, i) => i ? g.lineTo(q[0], q[1]) : g.moveTo(q[0], q[1])); g.closePath(); };
103
+ // 一次洗染:基形 deform 3 轮;每层再 deform 2 轮、低 α 填(multiply = 透明颜料叠色)、α=edge 描 1.6px 同色边(颜料在边缘沉积 = 水痕边)。
104
+ // fill 可传渐变;不透明的白(窗框、云、桌布)用 blend:'source-over'、alpha .9。
105
+ // 常用:大墙 layers5 α.4 amp12;色晕 layers3 α.045 amp40;木板 layers3 α.5 amp3 edge.14。
106
+ // 坑:暗部用大幅变形多边形(amp 60),别用矩形拼——矩形接缝处会出硬边。色晕 α 别超 .05×3 层,否则房间像发霉的羊皮纸。
107
+ P.watercolor = (g, pts, color, { layers = 7, alpha = 0.13, amp = 10, seed = 1, edge = 0.1, blend = 'multiply', fill } = {}) => {
108
+ const r = rng(seed * 7919 + 13), base = P.deform(pts, 3, amp, r);
109
+ g.save(); g.globalCompositeOperation = blend; g.lineJoin = 'round';
110
+ for (let k = 0; k < layers; k++) {
111
+ const q = P.deform(base, 2, amp * 0.55, r);
112
+ fillPts(g, q); g.globalAlpha = alpha; g.fillStyle = fill || color; g.fill();
113
+ if (edge) { g.globalAlpha = edge; g.strokeStyle = color; g.lineWidth = 1.6; g.stroke(); }
114
+ }
115
+ g.restore();
116
+ };
117
+ P.rectPts = (x, y, w, h) => [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
118
+ P.ellipsePts = (cx, cy, rx, ry, n = 18) => Array.from({ length: n }, (_, i) => [cx + Math.cos(i / n * Math.PI * 2) * rx, cy + Math.sin(i / n * Math.PI * 2) * ry]);
119
+
120
+ // ---------- 厚涂鬃毛笔 ----------
121
+ // 一笔 = round(w/1.3) 条平行细鬃(亮度各自 ±17 抖、两端随机缩 0–25%)+下侧一条淡投影+上侧零星亮点 → 读成「被刷开的一坨厚颜料」。
122
+ // 只用在受光处(金发、领口、瓷器、珍珠、白毛)。坑:画成「暗影+主笔+亮脊」三条整齐的线会读成白色贴纸/创可贴。
123
+ P.impasto = (c, pts, w, col, hi) => {
124
+ const base = col.startsWith('#') ? P.hex(col) : [240, 230, 210];
125
+ const A = pts[0], B = pts[pts.length - 1], ang = Math.atan2(B[1] - A[1], B[0] - A[0]), nx = -Math.sin(ang), ny = Math.cos(ang);
126
+ const r = rng((A[0] * 13 + A[1] * 7) | 0), nb = Math.max(3, Math.round(w / 1.3));
127
+ c.save(); c.lineCap = 'round';
128
+ c.strokeStyle = 'rgba(40,18,6,.28)'; c.lineWidth = w * 0.9; c.beginPath(); pts.forEach((p, i) => i ? c.lineTo(p[0] + nx * w * 0.45 + 1, p[1] + ny * w * 0.45 + 1.5) : c.moveTo(p[0] + nx * w * 0.45 + 1, p[1] + ny * w * 0.45 + 1.5)); c.stroke();
129
+ for (let k = 0; k < nb; k++) {
130
+ const o = (k / (nb - 1) - 0.5) * w, s0 = r() * 0.25, s1 = 1 - r() * 0.25, sh = (r() - 0.5) * 34 + (k < nb / 2 ? 14 : -10);
131
+ c.strokeStyle = P.rgb([base[0] + sh, base[1] + sh, base[2] + sh * 0.8], 0.55 + r() * 0.4); c.lineWidth = 1.4 + r() * 1.2; c.beginPath();
132
+ const n = pts.length - 1; for (let i = 0; i <= 8; i++) { const q = lerp(s0, s1, i / 8), j = Math.min(n - 1, Math.floor(q * n)), f = q * n - j, x = lerp(pts[j][0], pts[j + 1][0], f) + nx * o, y = lerp(pts[j][1], pts[j + 1][1], f) + ny * o; i ? c.lineTo(x, y) : c.moveTo(x, y); }
133
+ c.stroke();
134
+ }
135
+ c.fillStyle = hi || 'rgba(255,250,232,.7)'; for (let k = 0; k < 2 + w / 3; k++) { const q = r(), x = lerp(A[0], B[0], q) - nx * w * 0.3, y = lerp(A[1], B[1], q) - ny * w * 0.3; c.beginPath(); c.arc(x, y, 0.9 + r(), 0, Math.PI * 2); c.fill(); }
136
+ c.restore();
137
+ };
138
+
139
+ // ---------- 剪刀折线 ----------
140
+ // 把一圈(闭合)点列按 step 重采样,每点加 ±amp 种子抖动,直线相连 → 手剪纸的边。RIG 部件可用 path.pts 喂进来重剪。
141
+ P.cut = (pts, seed, step = 18, amp = 2.5) => {
142
+ const r = rng(seed), out = [];
143
+ for (let i = 0; i < pts.length; i++) {
144
+ const a = pts[i], b = pts[(i + 1) % pts.length], L = Math.hypot(b[0] - a[0], b[1] - a[1]), n = Math.max(1, Math.round(L / step));
145
+ for (let k = 0; k < n; k++) { const q = k / n; out.push([lerp(a[0], b[0], q) + (r() - 0.5) * amp * 2, lerp(a[1], b[1], q) + (r() - 0.5) * amp * 2]); }
146
+ }
147
+ return U.poly(out);
148
+ };
149
+ })();