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,202 @@
1
+ // 中国水墨写意(八大山人/齐白石)——纯代码。
2
+ // 上游:huashu-art-motion / scripts/engine/scenes/17_ink.js(MIT)。以下是节选,保留了
3
+ // 「宣纸 → 淡墨远山+朱日 → 窗/桌飞白笔触 → 竹枝 → 挂轴虾 → 猫(赭石没骨+留白+焦墨)
4
+ // → 热气、钤印」的完整管线;seal(钤印)在 label 里。
5
+ // 少女段落(花青没骨+destination-out 挖交领+藤黄发+1.8px 焦墨白描面部)在同一文件里叫
6
+ // girlWash / girlInk / girlArm,几何来自上游 RIG 库,没有随包 vendor —— 技法与猫是同一套,照猫写即可。
7
+ //
8
+ // 运行环境是上游的浏览器 harness:全局有 SCENES / U / PAINT(P) / KIT / RIG,逐帧调用 draw(ctx, lt, t)。
9
+ // 这里只作阅读参考,不参与 DrawAI 的打包与渲染(见 brief 第 5 节怎么改写成 Remotion 场景)。
10
+ SCENES['17_ink'] = (() => {
11
+ const W = 1920, H = 1080, { clamp, lerp, rng, ease } = U, P = PAINT;
12
+ const { ss } = U;
13
+
14
+ // ---------- 宣纸 ----------
15
+ const paper = () => P.cached('ink_paper', W, H, (g) => {
16
+ g.drawImage(P.texture('xuan', '#efe6d0', { scale: 0.0025, amt: 9, grain: 7, seed: 5 }), 0, 0);
17
+ const r = rng(41);
18
+ g.lineCap = 'round';
19
+ for (let i = 0; i < 2600; i++) { // 纸纤维:短弧,亮暗混合
20
+ const x = r() * W, y = r() * H, a = r() * Math.PI * 2, l = 6 + r() * 26;
21
+ g.strokeStyle = r() < 0.5 ? `rgba(150,125,90,${0.05 + r() * 0.08})` : `rgba(255,252,240,${0.12 + r() * 0.15})`;
22
+ g.lineWidth = 0.6 + r() * 0.8; g.beginPath(); g.moveTo(x, y); g.quadraticCurveTo(x + Math.cos(a + 0.6) * l * 0.5, y + Math.sin(a + 0.6) * l * 0.5, x + Math.cos(a) * l, y + Math.sin(a) * l); g.stroke();
23
+ }
24
+ const vg = g.createRadialGradient(W / 2, H / 2, 500, W / 2, H / 2, 1250); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(120,90,50,.22)');
25
+ g.fillStyle = vg; g.fillRect(0, 0, W, H);
26
+ });
27
+
28
+ // 笔、叶、晕染都用库:P.brush(毛笔飞白)、P.leaf(两头尖单笔)、P.inkWash(墨晕洇开)
29
+ const brush = P.brush, leaf = P.leaf;
30
+ const washLayer = (c, key, grow, fn, o) => P.inkWash(c, 'ink_' + key, grow, fn, o);
31
+
32
+ // ---------- 静态笔触表(窗、桌、地) ----------
33
+ const WIN = [
34
+ { p: [[338, 116], [520, 112], [700, 118], [824, 114]], w: 16, d: 0.06, l: 0.18, dry: 0.55 },
35
+ { p: [[352, 104], [348, 300], [356, 470], [350, 578]], w: 15, d: 0.1, l: 0.16, dry: 0.5 },
36
+ { p: [[808, 108], [812, 330], [806, 572]], w: 14, d: 0.14, l: 0.16, dry: 0.6 },
37
+ { p: [[318, 572], [560, 566], [842, 574]], w: 18, d: 0.18, l: 0.16, dry: 0.45 },
38
+ { p: [[372, 140], [420, 140], [420, 186]], w: 4, d: 0.22, l: 0.08, dry: 0.3 }, { p: [[788, 140], [740, 140], [740, 186]], w: 4, d: 0.24, l: 0.08, dry: 0.3 },
39
+ { p: [[372, 546], [420, 546], [420, 500]], w: 4, d: 0.26, l: 0.08, dry: 0.3 }, { p: [[788, 546], [740, 546], [740, 500]], w: 4, d: 0.28, l: 0.08, dry: 0.3 },
40
+ ];
41
+ const TABLE = [
42
+ { p: [[800, 622], [1000, 614], [1212, 620]], w: 22, d: 0.12, l: 0.15, dry: 0.5 },
43
+ { p: [[820, 640], [1000, 646], [1196, 640]], w: 7, d: 0.2, l: 0.1, dry: 0.4 },
44
+ { p: [[842, 640], [846, 760], [838, 906]], w: 13, d: 0.18, l: 0.16, dry: 0.65 },
45
+ { p: [[1176, 640], [1172, 780], [1182, 906]], w: 13, d: 0.22, l: 0.16, dry: 0.65 },
46
+ { p: [[848, 664], [900, 690], [960, 676]], w: 5, d: 0.26, l: 0.1, dry: 0.4 }, { p: [[1170, 664], [1118, 690], [1058, 676]], w: 5, d: 0.28, l: 0.1, dry: 0.4 },
47
+ { p: [[300, 912], [700, 904], [1100, 914], [1520, 908]], w: 5, d: 0.3, l: 0.15, dry: 0.85, tone: 0.35 },
48
+ ];
49
+
50
+ // ---------- 远山、朱日 ----------
51
+ function mountains(g) {
52
+ g.save(); g.beginPath(); g.rect(366, 132, 428, 428); g.clip();
53
+ const ridge = (y0, amp, f, seed) => { const pts = []; for (let x = 340; x <= 830; x += 10) pts.push([x, y0 - amp * (0.5 + 0.5 * P.fbm(x * f + seed, seed, 3)) - amp * 0.6 * Math.exp(-Math.pow((x - 560 - seed * 40) / 120, 2))]); return pts; };
54
+ const fill = (pts, a, y1) => { const gr = g.createLinearGradient(0, Math.min(...pts.map(p => p[1])), 0, y1); gr.addColorStop(0, `rgba(40,40,44,${a})`); gr.addColorStop(1, 'rgba(40,40,44,0)'); g.fillStyle = gr; g.beginPath(); pts.forEach((p, i) => i ? g.lineTo(...p) : g.moveTo(...p)); g.lineTo(830, y1); g.lineTo(340, y1); g.closePath(); g.fill(); };
55
+ fill(ridge(380, 120, 0.006, 1), 0.32, 520); fill(ridge(450, 90, 0.009, 2.4), 0.5, 560);
56
+ g.restore();
57
+ }
58
+
59
+ // ---------- 竹(窗前,从右上斜入) ----------
60
+ function bamboo(c, t, rev) {
61
+ const sw = Math.sin(t * 2.9) * 0.11 + Math.sin(t * 6.1 + 1) * 0.025;
62
+ const base = [835, 70];
63
+ const rot = (x, y, k) => { const a = sw * k, dx = x - base[0], dy = y - base[1]; return [base[0] + dx * Math.cos(a) - dy * Math.sin(a), base[1] + dx * Math.sin(a) + dy * Math.cos(a)]; };
64
+ // 主枝:分节(每节一笔,节间留白),淡墨
65
+ const nodes = [[835, 70], [760, 150], [690, 215], [620, 268], [550, 312]];
66
+ for (let i = 0; i < nodes.length - 1; i++) {
67
+ const a = rot(...nodes[i], i / 4), b = rot(...nodes[i + 1], (i + 1) / 4);
68
+ const q0 = 0.04, A = [lerp(a[0], b[0], q0), lerp(a[1], b[1], q0)], B = [lerp(a[0], b[0], 0.94), lerp(a[1], b[1], 0.94)];
69
+ brush(c, [A, [lerp(A[0], B[0], .5), lerp(A[1], B[1], .5)], B], { w: 9, tone: 0.5, dry: 0.3, seed: 60 + i, reveal: ss(0.08 + i * 0.03, 0.18 + i * 0.03, rev), head: 0.06, tail: 0.1, bristles: 5 });
70
+ }
71
+ // 叶:个字、介字组,浓墨
72
+ const groups = [[2, -0.2, 0], [3, 0.4, 1], [4, -0.9, 2], [3, 2.2, 3], [4, 0.9, 4]];
73
+ groups.forEach(([ni, a0, gi]) => {
74
+ const np = nodes[Math.min(nodes.length - 1, ni)];
75
+ const p = rot(np[0], np[1], ni / 4); const flut = Math.sin(t * 7 + gi * 1.3) * 0.2;
76
+ const rv = ss(0.2 + gi * 0.04, 0.3 + gi * 0.04, rev); if (rv <= 0) return;
77
+ [[0, 1], [0.45, 0.82], [-0.42, 0.78]].forEach(([da, ls], k) => leaf(c, p[0], p[1], a0 + 1.4 + da + flut * (1 + k * 0.4) + sw * 2, 110 * ls * rv, 15, 0.86));
78
+ });
79
+ }
80
+
81
+ // ---------- 挂轴+齐白石虾 ----------
82
+ function scrollStatic(g) {
83
+ g.fillStyle = 'rgba(170,140,95,.32)'; g.fillRect(1560, 240, 230, 640); // 绫边
84
+ g.fillStyle = 'rgba(250,246,234,.9)'; g.fillRect(1580, 290, 190, 540);
85
+ g.fillStyle = 'rgba(60,40,25,.85)'; g.fillRect(1548, 870, 254, 16); g.fillRect(1556, 232, 238, 10);
86
+ g.strokeStyle = 'rgba(60,40,25,.6)'; g.lineWidth = 1.5; g.beginPath(); g.moveTo(1640, 232); g.lineTo(1675, 196); g.lineTo(1710, 232); g.stroke();
87
+ g.fillStyle = '#c23a2b'; g.fillRect(1742, 788, 16, 16);
88
+ }
89
+ function shrimp(c, x, y, ang, s, t, ph) {
90
+ c.save(); c.translate(x, y); c.rotate(ang); c.scale(s, s);
91
+ const flex = Math.sin(t * 7 + ph) * 0.12;
92
+ // 身体:6 节淡墨弧,从头到尾弯曲
93
+ let px = 0, py = 0, a = 0; const segs = [];
94
+ for (let i = 0; i < 6; i++) { a += 0.2 + flex; const l = 13 - i * 1.2; px += Math.cos(a) * l; py += Math.sin(a) * l; segs.push([px, py, a, 11 - i * 1.3]); }
95
+ segs.forEach(([sx, sy, sa, sw], i) => { c.fillStyle = `rgba(30,28,26,${0.22 + i * 0.03})`; c.beginPath(); c.ellipse(sx, sy, 9, sw * 1.05, sa + 0.25, 0, Math.PI * 2); c.fill(); c.strokeStyle = 'rgba(20,18,16,.3)'; c.lineWidth = 2.4; c.beginPath(); c.ellipse(sx, sy, 9, sw * 1.05, sa + 0.25, -1.2, 1.2); c.stroke(); });
96
+ const tl = segs[5]; [-0.5, 0, 0.5].forEach(d => leaf(c, tl[0], tl[1], tl[2] + d + 0.4, 15, 6, 0.4));
97
+ // 头胸甲:淡墨+中间一笔焦墨
98
+ c.fillStyle = 'rgba(30,28,26,.33)'; c.beginPath(); c.ellipse(-10, -2, 17, 10, -0.15, 0, Math.PI * 2); c.fill();
99
+ c.fillStyle = 'rgba(10,10,10,.9)'; c.beginPath(); c.ellipse(-8, -3, 7, 3.2, -0.2, 0, Math.PI * 2); c.fill();
100
+ c.beginPath(); c.arc(-24, -8, 2.6, 0, Math.PI * 2); c.arc(-23, -1, 2.6, 0, Math.PI * 2); c.fill();
101
+ // 须:两长两短,波动
102
+ c.strokeStyle = 'rgba(10,10,10,.8)'; c.lineWidth = 1.1;
103
+ [[-1, 120, 0.9], [1, 105, 1.4], [-1, 40, 2.1], [1, 36, 2.8]].forEach(([sg, L, q]) => { c.beginPath(); c.moveTo(-26, -4); for (let i = 1; i <= 12; i++) { const u = i / 12; c.lineTo(-26 - u * L, -4 + sg * u * L * 0.18 + Math.sin(t * 5 + q + u * 4) * 6 * u); } c.stroke(); });
104
+ // 钳:长臂三节+钳
105
+ [[-0.35, 0], [0.1, 1]].forEach(([da, k]) => { c.strokeStyle = 'rgba(10,10,10,.75)'; c.lineWidth = 1.6; const sw2 = Math.sin(t * 4 + k * 2 + ph) * 0.15; let qx = -20, qy = 4; c.beginPath(); c.moveTo(qx, qy);
106
+ [[22, 2.4], [20, 2.6], [16, 2.9]].forEach(([l, aa]) => { qx += Math.cos(aa + da + sw2) * l; qy += Math.sin(aa + da + sw2) * l; c.lineTo(qx, qy); }); c.stroke();
107
+ c.fillStyle = 'rgba(10,10,10,.82)'; c.beginPath(); c.ellipse(qx - 6, qy + 2, 8, 3, 2.9 + da, 0, Math.PI * 2); c.fill(); });
108
+ // 腿
109
+ c.strokeStyle = 'rgba(20,20,20,.5)'; c.lineWidth = 1; for (let i = 0; i < 5; i++) { const sg = segs[Math.min(4, i)]; c.beginPath(); c.moveTo(sg[0] - 2, sg[1] + 5); c.lineTo(sg[0] - 6 + Math.sin(t * 12 + i) * 2, sg[1] + 14); c.stroke(); }
110
+ c.restore();
111
+ }
112
+
113
+ // ---------- 猫(赭石没骨+留白+焦墨点斑+八大白眼) ----------
114
+ function catWash(g, K) {
115
+ g.fillStyle = '#c47a3c'; g.fill(K.body); g.fill(K.head);
116
+ g.lineCap = 'round'; g.lineJoin = 'round'; g.strokeStyle = '#c47a3c'; g.lineWidth = K.tailW; g.stroke(K.tail);
117
+ g.save(); g.globalCompositeOperation = 'destination-out'; g.fill(K.white); K.legs.forEach(l => g.fill(l));
118
+ g.beginPath(); g.ellipse(K.nose[0] - 22, K.nose[1] + 12, 36, 26, 0, 0, Math.PI * 2); g.fill(); g.restore();
119
+ }
120
+ function catInk(c, K, t, rev) {
121
+ const R_ = (pts, o) => brush(c, pts, { ...o, reveal: ss(o.d || 0, (o.d || 0) + 0.18, rev) });
122
+ // 背线、头顶:焦墨几笔
123
+ const b = K.bodyPts;
124
+ R_([b[5], b[4], b[3], b[2]], { w: 12, dry: 0.55, seed: 81, d: 0.12, tone: 0.9 });
125
+ R_([b[2], b[1], b[0]], { w: 8, dry: 0.7, seed: 82, d: 0.18, tone: 0.75 });
126
+ R_([b[6], b[7], b[8]], { w: 5, dry: 0.6, seed: 83, d: 0.2, tone: 0.55 });
127
+ K.stripes.forEach((s, i) => R_([s[0], [(s[0][0] + s[1][0]) / 2 + 3, (s[0][1] + s[1][1]) / 2], s[1]], { w: i < 3 ? 9 : 13, dry: 0.4, seed: 90 + i, d: 0.2 + i * 0.015, tone: 0.92, head: 0.2, tail: 0.6, bristles: 5 }));
128
+ if (rev < 0.25) return;
129
+ c.save(); c.globalAlpha = ss(0.25, 0.4, rev);
130
+ // 耳:两笔焦墨三角
131
+ c.fillStyle = 'rgba(14,12,10,.9)'; K.earInner.forEach(e => { c.beginPath(); c.moveTo(...e[0]); c.lineTo(...e[1]); c.lineTo(...e[2]); c.closePath(); c.fill(); });
132
+ // 头轮廓:一笔淡墨
133
+ c.strokeStyle = 'rgba(20,18,16,.55)'; c.lineWidth = 3; c.stroke(K.head);
134
+ // 八大式白眼:圆眶,瞳点顶到上方(白眼向天)
135
+ K.eyes.forEach((e, i) => { c.strokeStyle = 'rgba(10,10,10,.92)'; c.lineWidth = 2.6; c.beginPath(); c.ellipse(e.x, e.y, K.eyeR * 1.15, K.eyeR * (K.blink ? 0.15 : 1), 0, 0, Math.PI * 2); c.stroke();
136
+ if (!K.blink) { c.fillStyle = 'rgba(8,8,8,.95)'; c.beginPath(); c.arc(e.x + 2.5, e.y - K.eyeR * 0.55 + Math.sin(t * 2) * 1.2, K.eyeR * 0.42, 0, Math.PI * 2); c.fill(); } });
137
+ c.fillStyle = 'rgba(14,12,10,.9)'; c.beginPath(); c.ellipse(K.nose[0], K.nose[1], 6, 4, 0, 0, Math.PI * 2); c.fill();
138
+ c.strokeStyle = 'rgba(14,12,10,.8)'; c.lineWidth = 2; c.beginPath(); c.moveTo(...K.mouth[0]); c.quadraticCurveTo(...K.mouth[1], ...K.mouth[2]); c.stroke();
139
+ K.whiskers.forEach((w, i) => brush(c, [w[0], [(w[0][0] + w[1][0]) / 2, (w[0][1] + w[1][1]) / 2 - 2], w[1]], { w: 2.4, dry: 0.5, bristles: 3, seed: 99 + i, tone: 0.8, head: 0.05, tail: 0.6 }));
140
+ // 腿、爪:淡墨轻勾
141
+ c.strokeStyle = 'rgba(20,18,16,.38)'; c.lineWidth = 2; K.legs.forEach(l => c.stroke(l));
142
+ // 尾:焦墨环纹
143
+ const tp = K.tailPts; [5, 9, 13, 17].forEach((i, k) => { const p = tp[i], q = tp[i + 1], a = Math.atan2(q[1] - p[1], q[0] - p[0]) + Math.PI / 2; brush(c, [[p[0] - Math.cos(a) * 12, p[1] - Math.sin(a) * 12], [p[0] + Math.cos(a) * 12, p[1] + Math.sin(a) * 12]], { w: 8, dry: 0.3, bristles: 4, seed: 120 + k, tone: 0.85 }); });
144
+ c.restore();
145
+ }
146
+
147
+ return {
148
+ draw(c, lt, t) {
149
+ lt += 0.12; // 开场不从一张白纸开始:第 0 帧已经落下前几笔
150
+ const ch = KIT.choreo(lt, t);
151
+ const rev = clamp(lt / 0.45); // 开场「画出来」
152
+ const grow = ss(0, 1.15, lt); // 墨晕外扩
153
+ c.drawImage(paper(), 0, 0);
154
+ // 晕染:远山+朱日
155
+ washLayer(c, 'mtn', ss(0, 0.3, lt) * (0.6 + 0.4 * grow), mountains, { blur: 3, halo: 14, haloA: 0.3 });
156
+ c.save(); c.globalCompositeOperation = 'multiply'; c.globalAlpha = ss(0.1, 0.3, lt); c.filter = `blur(${2 + grow * 3}px)`; c.fillStyle = '#d8553f'; c.beginPath(); c.arc(700, 230, 30 + grow * 3, 0, Math.PI * 2); c.fill(); c.filter = 'none'; c.restore();
157
+ // 雾带:纸色横向晕带缓缓左移,远山时隐时现
158
+ c.save(); c.beginPath(); c.rect(366, 132, 428, 428); c.clip(); c.filter = 'blur(14px)';
159
+ [[420, 46, 0], [500, 34, 1], [330, 28, 2], [460, 30, 3]].forEach(([y, h, k]) => { const x = 900 - ((lt * (150 + k * 40) + k * 230) % 900); c.fillStyle = `rgba(242,234,214,${0.72 * ss(0.15, 0.4, lt)})`; c.beginPath(); c.ellipse(x, y, 230, h, 0, 0, Math.PI * 2); c.fill(); });
160
+ c.filter = 'none'; c.restore();
161
+ // 一只八大式小鸟掠过窗外(两笔翅,每 0.12s 扇一次)
162
+ if (lt > 0.25) { const bx = 820 - (lt - 0.25) * 420, by = 470 - Math.sin(lt * 3) * 26, fl = Math.sin(lt * 52);
163
+ c.save(); c.beginPath(); c.rect(366, 132, 428, 428); c.clip(); c.fillStyle = 'rgba(14,12,10,.9)';
164
+ c.beginPath(); c.ellipse(bx, by, 11, 6, -0.2, 0, Math.PI * 2); c.fill(); c.beginPath(); c.arc(bx - 11, by - 4, 5, 0, Math.PI * 2); c.fill();
165
+ leaf(c, bx - 2, by - 2, -1.2 - fl * 0.7, 28, 9, 0.9); leaf(c, bx + 4, by - 1, -0.5 - fl * 0.5, 24, 8, 0.75);
166
+ c.fillStyle = '#f2ead6'; c.beginPath(); c.arc(bx - 13, by - 5, 2, 0, Math.PI * 2); c.fill(); c.restore(); }
167
+ // 静态笔触(写完就缓存)
168
+ const stat = (c2, rv) => { WIN.forEach((s, i) => brush(c2, s.p, { w: s.w, dry: s.dry, seed: 10 + i, reveal: ss(s.d, s.d + s.l, rv), tone: s.tone || 0.88 })); TABLE.forEach((s, i) => brush(c2, s.p, { w: s.w, dry: s.dry, seed: 30 + i, reveal: ss(s.d, s.d + s.l, rv), tone: s.tone || 0.88 })); };
169
+ if (rev < 1) stat(c, rev); else c.drawImage(P.cached('ink_static', W, H, g => stat(g, 1)), 0, 0);
170
+ bamboo(c, t, rev);
171
+ // 挂轴+虾(两只,沿各自弧线游)
172
+ c.save(); c.globalAlpha = ss(0.15, 0.35, lt); scrollStatic(c);
173
+ c.beginPath(); c.rect(1580, 290, 190, 540); c.clip();
174
+ [[1716, 450, 0.5, 0], [1700, 700, 1.4, 1]].forEach(([x, y, a0, k]) => { const u = lt * 1.6 + k * 2; shrimp(c, x - 26 * Math.sin(u * 1.5) - lt * 40, y + Math.sin(u * 2) * 16 - lt * 30, -0.35 + a0 * 0.1 + Math.sin(u * 1.5) * 0.18, 1.45, t, k * 2); });
175
+ c.restore();
176
+ // 猫:先在剪影里补回纸色,再赭石晕染 → 焦墨
177
+ const K = RIG.cat({ tail: ch.tail, blink: ch.blink, breathe: ch.breathe });
178
+ c.save(); const ks = new Path2D(); ks.addPath(K.body); ks.addPath(K.head); c.clip(ks); c.drawImage(paper(), 0, 0); c.restore(); // 窗台墨线不透过赭石
179
+ washLayer(c, 'cat', ss(0.02, 0.3, lt) * (0.7 + 0.3 * grow), g => catWash(g, K), { blur: 2.5, halo: 9, haloA: 0.28, alpha: 0.78 });
180
+ catInk(c, K, t, rev);
181
+ // 热气:两缕淡墨游丝
182
+ if (lt > 0.3) { c.save(); c.globalAlpha = ss(0.3, 0.5, lt);
183
+ [0, 1].forEach(k => { const pts = []; for (let i = 0; i <= 8; i++) { const q = i / 8; pts.push([1180 + k * 14 + Math.sin(t * 3.5 + q * 5 + k * 2) * 10 * q, 610 - q * 80]); } brush(c, pts, { w: 3, dry: 0.7, bristles: 3, seed: 300 + k + P.boilSeed(t, 10), tone: 0.45, head: 0.05, tail: 0.8 }); });
184
+ c.restore(); }
185
+ },
186
+ label(c, lt) {
187
+ const a = ss(0.0, 0.25, lt);
188
+ KIT.label(c, { title: '水墨写意', sub: '八大山人 · 齐白石', tFont: '80px "LXGWWenKai-500"', sFont: '30px "LXGWWenKai-500"', tCol: `rgba(14,12,10,${0.92 * a})`, sCol: `rgba(40,34,28,${0.85 * a})`, spacing: 6 });
189
+ // 钤印:朱文方印「白石」,0.32s 盖下(先放大再压实,印泥略洇)
190
+ const q = ss(0.3, 0.4, lt); if (q <= 0) return;
191
+ const s = 1.5 - 0.5 * ease.out(q);
192
+ const seal = P.cached('ink_seal', 80, 80, g => { g.translate(40, 40);
193
+ g.fillStyle = '#c4281e'; g.fillRect(-34, -34, 68, 68);
194
+ g.globalCompositeOperation = 'destination-out'; g.font = '30px "LXGWWenKai-500"'; g.textAlign = 'center'; g.textBaseline = 'middle';
195
+ g.fillText('白', 0, -14); g.fillText('石', 0, 16);
196
+ const r = rng(7); for (let i = 0; i < 40; i++) g.fillRect(-34 + r() * 68, -34 + r() * 68, 1 + r() * 3, 1 + r() * 2); // 印面斑驳
197
+ });
198
+ c.save(); c.translate(1500, 98); c.scale(s, s); c.globalAlpha = Math.min(1, q * 1.6); c.globalCompositeOperation = 'multiply';
199
+ c.drawImage(seal, -40, -40); c.restore();
200
+ },
201
+ };
202
+ })();
@@ -3,7 +3,7 @@
3
3
  "commit": "49052d80bfaf9fa8412cc9710530e5e95a3b8d8c",
4
4
  "license": "MIT",
5
5
  "vendoredAt": "2026-10-05",
6
- "note": "只收文本:提示词 + demo 源码。视频/纹理/字体不在仓库里,也没有 vendor 进来。",
6
+ "note": "只收文本:提示词 + demo 源码。视频/纹理/字体不在仓库里,也没有 vendor 进来。23-ink-wash 来自 huashu-art-motion(MIT),见 origins。",
7
7
  "styles": [
8
8
  {
9
9
  "slug": "01-flat-vector",
@@ -176,6 +176,16 @@
176
176
  "demos/22-hud/js/util.js",
177
177
  "demos/22-hud/js/v5.js"
178
178
  ]
179
+ },
180
+ {
181
+ "slug": "23-ink-wash",
182
+ "heading": "水墨写意 Chinese Ink Wash / Xieyi",
183
+ "shot": "huashu 17_ink · 1920×1080 · 30 fps · 10 秒 · 单镜写意房间",
184
+ "prompt": "prompts/23-ink-wash.md",
185
+ "demos": [
186
+ "demos/23-ink-wash/film.js",
187
+ "demos/23-ink-wash/brush.js"
188
+ ]
179
189
  }
180
190
  ],
181
191
  "files": {
@@ -257,7 +267,10 @@
257
267
  "demos/22-hud/js/hud.js": "751c05b15e65474b89f55346587c85e9215317e900ecc64a0fce451827ee1c88",
258
268
  "demos/22-hud/js/post.js": "672ce9b4a1da6f59f2d65bb1f8e337851b7893553dc250aad2192388a0711a53",
259
269
  "demos/22-hud/js/util.js": "7ec7a52b27659de843f72f65ad4dbfc07e217f528bba5bbff69b31b4293081db",
260
- "demos/22-hud/js/v5.js": "af2cf92f176962361826fc84593241805628afdf13ca9e88e023712dc6626fcb"
270
+ "demos/22-hud/js/v5.js": "af2cf92f176962361826fc84593241805628afdf13ca9e88e023712dc6626fcb",
271
+ "prompts/23-ink-wash.md": "2f51e3251caca5e66f89c14be8fda0a66b9b3b1b6150e94e87235f963c754246",
272
+ "demos/23-ink-wash/film.js": "93b1e80a3731b479beac56e0a9dae13d52aa20b7bbc71678527e49dbd22eac4f",
273
+ "demos/23-ink-wash/brush.js": "e83db04e5fc9fef83237d984d2ceba3c1ec71c0c2a8f8a798ecc939fc8c3b2d4"
261
274
  },
262
275
  "removedStyles": [
263
276
  {
@@ -265,5 +278,17 @@
265
278
  "heading": "3D 渲染系(C4D/Blender 质感) 3D Render / CGI Motion Design",
266
279
  "reason": "画面是 Blender 离线渲的 CGI 质感,插件里没有能出同等质感的等价物(拿 DOM 滤镜凑只会更假)。"
267
280
  }
268
- ]
281
+ ],
282
+ "origins": {
283
+ "23-ink-wash": {
284
+ "source": "https://github.com/alchaincyf/huashu-art-motion",
285
+ "license": "MIT",
286
+ "files": {
287
+ "prompts/23-ink-wash.md": "references/风格配方/17_ink.md",
288
+ "demos/23-ink-wash/film.js": "scripts/engine/scenes/17_ink.js",
289
+ "demos/23-ink-wash/brush.js": "scripts/engine/lib/brush.js"
290
+ },
291
+ "note": "场景为节选(少女段落未收),brush.js 逐字收录;依赖上游全局 U/KIT/PAINT,只作阅读参考。"
292
+ }
293
+ }
269
294
  }
@@ -0,0 +1,59 @@
1
+ # 17_ink · 中国水墨写意(八大山人/齐白石)
2
+
3
+ > 上游:`huashu-art-motion`(alchaincyf,MIT)`references/风格配方/17_ink.md`,场景源码 `scripts/engine/scenes/17_ink.js`。
4
+ > 上游实验记录:帧差 0.45↔0.9s 为 3.3%(阈值 24),相邻帧中位 0.62%,没有静止帧对。
5
+ > 这份文件是上游的「配方卡」原文整理,不是 Remotion 施工说明 —— 怎么换到 DrawAI 的渲染栈里,看 `briefs/23-ink-wash.md`。
6
+
7
+ ## 管线(和 09 标杆相反:纸是底,墨只加不铺)
8
+
9
+ 开场 `lt += 0.12`,第 0 帧已经落下前几笔,不是一张白纸(审片指出白纸首帧不能当封面)。
10
+ 宣纸(缓存)→ 晕染层:淡墨远山、朱日 → 雾带(纸色模糊椭圆横移)→ 掠过的小鸟 → 静态笔触:窗、案、地线,写完缓存 → 竹枝(个字叶)→ 挂轴里的齐白石虾 → 猫:赭石晕染+焦墨 → 少女:花青晕染+白描+衣纹 → 热气游丝 → 钤印。
11
+
12
+ ## 宣纸
13
+
14
+ `P.texture('#efe6d0', scale .0025, amt 9, grain 7)`。上面叠 2600 根纸纤维短弧:一半褐色 α.05–.13,一半亮色 α.12–.27。再加径向暗角,到边缘 α.22。
15
+
16
+ ## 毛笔 `brush(c, pts, {w, tone, dry, reveal, bristles, head, tail})`
17
+
18
+ - 点列先 Catmull-Rom 加密,再按 4px 等弧长重采样(`KIT.resample`)。不重采样的话,飞白噪声的频率会随笔画长短变化。
19
+ - 笔压:`prof(q) = smoothstep(0,head,q) · (1−0.7·smoothstep(1−tail,1,q)) · (0.85+0.15·noise)`。
20
+ - 湿笔芯:整笔都用 ribbon 填,宽度 `w·prof·(1−0.85·dry·q^0.8)`,α 为 tone×0.72。越往后越细,表示墨在用完。
21
+ - 笔毛:每根一条**连续**折线,横向偏移 ±0.475w·prof。噪声 `noise(k·1.9, i·0.09)` 低于 `dry·q^1.2·0.95+0.05` 时断开,露出的纸就是飞白。
22
+ - 坑:第一版按「每一小段单独 stroke+round cap」画笔毛,接头处叠出一节节深点,看起来像竹节或麻绳。必须改成每根笔毛一条连续 path、butt cap。
23
+ - 参数:窗框 w14–18、dry .45–.6;案面 w22;衣纹 w4–6、dry .3–.4、tone .78;地线 w5、dry .85、tone .35。
24
+
25
+ ## 晕染层 `washLayer`
26
+
27
+ - 形状先画在 scratch 上,再用 multiply 合到纸上,分两遍。
28
+ - 外晕:`blur(halo·(0.4+grow))`,α 为 haloA·grow。
29
+ - 本体:`blur(blur·(1.6−0.6·grow))`。
30
+ - grow 按 lt 从 0 走到 1:开场是从无到有洇开,之后外晕继续慢慢变宽,这就是「墨在纸上晕开」。
31
+ - 少女 halo 12、α.6;猫 halo 9、α.78;远山 halo 14。
32
+
33
+ ## 留白
34
+
35
+ - 猫的白色部分(口鼻、胸、爪)用 `destination-out` 从赭石层里挖掉,露出纸色。这比画白色更有水墨味。
36
+ - 少女的交领内衫也这样挖掉。
37
+ - 面部用纸色填,再用 1.8px 焦墨白描勾。
38
+
39
+ ## 色
40
+
41
+ - 墨 `[16,14,12]`,用 tone 分出五色(焦 .9/浓 .8/重 .6/淡 .35/清 .15)。
42
+ - 赭石(猫)`#c47a3c`;花青(衣)`#6f8aa0`、广袖 `#7d97ab`;藤黄(金发)`rgba(226,170,52,.75)` multiply。
43
+ - 胭脂红只点三处:腰带、发簪珠、点唇,用 `[190,40,40]`。另有朱日 `#d8553f`、钤印 `#c4281e`。
44
+
45
+ ## 母题动画
46
+
47
+ - **开场写出**:lt 0→0.45,每笔 `reveal = ss(d, d+l, lt/0.45)`,按窗、案、竹、猫、人的顺序依次写出。
48
+ - 竹:分节淡墨主枝,绕 (835,70) 摆 `sin(2.9t)·0.11`,越往枝梢越大。5 组个字叶各 3 片(L110、w15),翻动 `sin(7t+g)·0.2`。
49
+ - 雾带:4 条纸色椭圆,blur 14、α.72,以 150–270px/s 横移,远山时隐时现。
50
+ - 八大式小鸟:从窗右侧以 420px/s 掠过,翅膀两笔,每秒约扇 8 次(`sin(52·lt)`)。
51
+ - 齐白石虾(挂轴里两只,1.45 倍):6 节淡墨节+头胸甲中间一笔焦墨,两长两短须做行波,三节长钳,身体弯曲 `sin(7t)·0.12`,沿曲线游动。
52
+ - 八大白眼:猫的瞳点顶到眼眶上方,「白眼向天」。
53
+ - 钤印:lt 0.3–0.4 从 1.5 倍压到 1 倍,multiply 盖上;白文「白石」。
54
+
55
+ ## 坑
56
+
57
+ - 静态笔触在写出期间实时画,写完一次性缓存成 `ink_static`,否则每帧要画几千根笔毛。
58
+ - 小鸟第一版的路径穿过竹叶丛,看不见,改到雾带上方 y≈470 才读得出来。
59
+ - 晕染用的是 multiply,透明,所以窗台墨线会透过猫的耳朵,桌腿会透过裙子,审片读成「层级错了」。修法是在猫和衣裙的剪影里先补一层纸色(clip 后 drawImage 纸),再上晕染。这等于在纸上「先留出这块地方」。
@@ -118,12 +118,16 @@ function motionMarkdown(style) {
118
118
 
119
119
  function styleMarkdown(style) {
120
120
  const palette = Object.entries(style.palette || {}).map(([role, hex]) => '| ' + role + ' | `' + hex + '` |')
121
+ // 风格不一定都来自 mg-styles-15:23-ink-wash 来自 huashu-art-motion。署名要跟着出处走,别张冠李戴。
122
+ const origin = style.origin && style.origin.source
123
+ ? style.origin.source.replace(/^https?:\/\/(github\.com\/)?/, '') + '(' + (style.origin.license || '见仓库') + (style.origin.commit ? ',commit ' + String(style.origin.commit).slice(0, 8) : '') + ')'
124
+ : 'mg-styles-15(MIT,commit ' + String(style.commit || '').slice(0, 8) + ')'
121
125
  return [
122
126
  '# 整片风格:' + style.name + '(' + style.slug + ')',
123
127
  '',
124
128
  style.oneLiner || '',
125
129
  '',
126
- '来源:mg-styles-15(MIT,commit ' + String(style.commit || '').slice(0, 8) + ')。上游是「HTML 页 + renderAt(t) + 截帧」,',
130
+ '来源:' + origin + '。上游是「HTML 页 + renderAt(t) + 截帧」,',
127
131
  '这里不是逐行搬运(整份 HTML 搬进场景会跟 Remotion 的帧模型打架),但动效、转场、冲击、',
128
132
  '相机与质感必须按下面的「效果配方」对齐——对不上就写偏离行,别静默省掉。',
129
133
  '',
package/lib/motion/qa.js CHANGED
@@ -10,6 +10,10 @@
10
10
  // 5. 空白 近乎纯色的帧(内容是"忘了放东西")
11
11
  // 6. 越界 四边安全区里出现了墨色(文字压边)
12
12
  // 7. 响度 综合响度 / 真峰值 / 静音段(ebur128)
13
+ // 8. 动能量 相邻帧差的平均幅度(blend=difference)——抓"缓慢缩放平移"的翻页感,
14
+ // 这一条 freezedetect 抓不到:Ken Burns 幻灯片每帧都在动,却整镜没节奏
15
+ // 9. 空画面 边密度(edgedetect 的 Y 均值)——抓"有渐变底、什么都没放",
16
+ // 这一条空白帧抓不到:底色渐变会让 ymax-ymin 很大
13
17
  //
14
18
  // 报告落 .out/qa/framecq/report.json,同时给一份能直接读的 markdown 摘要。
15
19
 
@@ -27,6 +31,12 @@ const DEFAULTS = {
27
31
  marginRatio: 0.035, // 四边安全区宽度(占画面宽/高)
28
32
  marginDark: 170, // 安全区里出现比这更暗的像素=内容压边
29
33
  blackPixThreshold: 0.10,
34
+ // 以下五个是量纲统一的:都在 480 宽缩略图上算,与成片分辨率无关。
35
+ lowDiff: 0.6, // 相邻帧平均绝对差低于它="这一帧没动"(0–255)
36
+ slideDiff: 1.2, // 镜头中位动能量低于它、且低动帧占比过线=翻页感
37
+ slideLowRatio: 0.7,
38
+ edgeMin: 1.5, // 边密度(edgedetect 的 Y 均值)低于它=有底没内容
39
+ edgeFrames: 8, // 连续多少帧边密度不足才算一段空画面
30
40
  }
31
41
 
32
42
  export function qaDir(dir) {
@@ -57,6 +67,26 @@ function runs(flags) {
57
67
 
58
68
  const second = (value) => Math.round(value * 100) / 100
59
69
 
70
+ function percentile(sorted, p) {
71
+ if (!sorted.length) return null
72
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * p)))
73
+ return sorted[index]
74
+ }
75
+
76
+ // 中位看"整镜到底动没动",p90/中位看"有没有节奏峰":匀速推镜的两头几乎相等。
77
+ function profile(values) {
78
+ const sorted = [...values].filter((value) => Number.isFinite(value)).sort((a, b) => a - b)
79
+ return { count: sorted.length, median: percentile(sorted, 0.5), p90: percentile(sorted, 0.9) }
80
+ }
81
+
82
+ // 最长连续命中:连续两个镜头都像翻页,就不是"某一镜的问题",是全片的问题。
83
+ function longestRun(flags) {
84
+ let longest = 0
85
+ let open = 0
86
+ for (const flag of flags) { open = flag ? open + 1 : 0; if (open > longest) longest = open }
87
+ return longest
88
+ }
89
+
60
90
  export async function runFrameQa({ dir, file, timeline, options = {} }) {
61
91
  // 工具层会把没传的参数也写成 undefined 传进来,展开时会把默认值顶掉(freezeSeconds 变 undefined,
62
92
  // 静止判定就整段失效)—— 所以先剔掉 undefined 再合。
@@ -78,6 +108,29 @@ export async function runFrameQa({ dir, file, timeline, options = {} }) {
78
108
  const stats = parseStats(read('stats.txt'))
79
109
  if (!stats.length) throw new Error('ffmpeg 没有输出逐帧统计,成片可能无法解码')
80
110
 
111
+ // 两份额外统计,都在同一条 480 宽缩略片上跑:
112
+ // 动能量 = 把两条流错开一帧再 blend=difference,得到的逐帧均值就是 |f(t+1)-f(t)|;
113
+ // 边密度 = edgedetect 把边缘画成白点,Y 均值就是"画面里还有多少结构"。
114
+ // 任一步失败都不该让整份帧检作废,所以单独兜住,报告里标成"未验证"。
115
+ let diffs = []
116
+ let edges = []
117
+ let motionError = null
118
+ try {
119
+ await call(['-i', 'scaled.mp4', '-an', '-filter_complex',
120
+ '[0:v]split=2[s0][s1];[s0]trim=start_frame=0,setpts=PTS-STARTPTS[a];'
121
+ + '[s1]trim=start_frame=1,setpts=PTS-STARTPTS[b];'
122
+ + '[a][b]blend=all_mode=difference,signalstats,metadata=print:file=diff.txt[out]',
123
+ '-map', '[out]', '-f', 'null', '-'])
124
+ await call(['-i', 'scaled.mp4', '-an', '-vf',
125
+ 'edgedetect=low=0.08:high=0.2,signalstats,metadata=print:file=edge.txt', '-f', 'null', '-'])
126
+ diffs = parseStats(read('diff.txt')).map((row) => row.yavg)
127
+ edges = parseStats(read('edge.txt')).map((row) => row.yavg)
128
+ if (!diffs.length) motionError = '差帧统计为空'
129
+ } catch (error) {
130
+ motionError = (error.message || String(error)).split('\n')[0]
131
+ }
132
+
133
+
81
134
  const bands = { left: '0:0', right: 'iw-iw*R:0', top: '0:0', bottom: '0:ih-ih*R' }
82
135
  const cropOf = {
83
136
  left: 'crop=iw*R:ih:0:0', right: 'crop=iw*R:ih:iw-iw*R:0',
@@ -111,6 +164,16 @@ export async function runFrameQa({ dir, file, timeline, options = {} }) {
111
164
  return shot ? shot.id : '(镜头之间)'
112
165
  }
113
166
 
167
+ // 逐镜动能量。差帧流比原片少一帧,所以按帧号切片时尾巴会短一个——不影响中位。
168
+ const energies = timeline.shots.map((shot) => {
169
+ const values = diffs.slice(shot.startFrame, shot.startFrame + shot.durationFrames)
170
+ const { median, p90 } = profile(values)
171
+ if (median === null) return null
172
+ const low = values.filter((value) => value < settings.lowDiff).length / values.length
173
+ return { median: second(median), p90: second(p90), low: second(low) }
174
+ })
175
+ const slides = energies.map((energy) => !!energy && energy.median < settings.slideDiff && energy.low > settings.slideLowRatio)
176
+
114
177
  // 单帧突变:比前后两帧都跳、而前后两帧彼此接近 —— 那是闪,不是运动。
115
178
  const flashes = []
116
179
  for (let i = 1; i < stats.length - 1; i += 1) {
@@ -180,12 +243,47 @@ export async function runFrameQa({ dir, file, timeline, options = {} }) {
180
243
  if (!longFreezes.length) add('pass', 'freeze', '没有超过 ' + settings.freezeSeconds + 's 的静止段')
181
244
  else if (!longFreezes.some((item) => item.seconds >= settings.freezeSeconds * 2)) add('pass', 'freeze', '静止段都在设计停留的量级(<' + settings.freezeSeconds * 2 + 's)')
182
245
 
246
+ // 动能量:这一条和 freeze 是两回事。freeze 只看"完全不动",Ken Burns 式缓慢缩放
247
+ // 每帧都在动、永远触发不了 freeze,但整镜没有一次加速——那正是翻页感的来源。
248
+ for (const [index, energy] of energies.entries()) {
249
+ if (!slides[index]) continue
250
+ add('warn', 'energy', '第 ' + (index + 1) + ' 镜 ' + timeline.shots[index].id + ' 整镜动能量偏低:中位 '
251
+ + energy.median + '、低动帧占 ' + Math.round(energy.low * 100) + '% —— 像翻页,不像动画', timeline.shots[index].id)
252
+ }
253
+ const slideRun = longestRun(slides)
254
+ if (slideRun >= 2)
255
+ add('fail', 'energy-ppt', '连续 ' + slideRun + ' 个镜头几乎没有运动(中位 < ' + settings.slideDiff
256
+ + '、低动帧 > ' + Math.round(settings.slideLowRatio * 100) + '%)—— 整体观感是幻灯片')
257
+ else if (energies.some(Boolean) && !slides.some(Boolean)) {
258
+ const lowest = energies.filter(Boolean).reduce((min, item) => (min === null || item.median < min ? item.median : min), null)
259
+ add('pass', 'energy', '镜头动能量正常(最低一镜中位 ' + lowest + ')')
260
+ }
261
+ if (motionError) add('info', 'energy', '动能量与边密度没测成:' + motionError + '(这两项未验证)')
262
+
263
+
183
264
  for (const flash of flashes.slice(0, 8)) add('warn', 'flash', '第 ' + flash.frame + ' 帧亮度突变(' + second(flash.t) + 's,Y≈' + second(flash.yavg) + ')', shotAt(flash.t))
184
265
  if (!flashes.length) add('pass', 'flash', '没有单帧闪烁')
185
266
 
186
267
  for (const blank of blanks) add('warn', 'blank', '近乎空白 ' + second(blank.from) + 's–' + second(blank.to) + 's(' + blank.frames + ' 帧)', shotAt(blank.from))
187
268
  if (!blanks.length) add('pass', 'blank', '没有连续空白帧')
188
269
 
270
+ // 空画面:blank 判的是"亮度极差小",只能抓近乎纯色。底色是渐变时极差很大,
271
+ // 但画面里其实什么都没有——那种要靠边密度才看得见。
272
+ const thin = edges.map((value) => Number.isFinite(value) && value < settings.edgeMin)
273
+ const emptyRuns = runs(thin).filter((run) => run.length >= settings.edgeFrames)
274
+ .map((run) => ({
275
+ from: second(stats[run.from]?.t ?? 0), to: second(stats[run.to]?.t ?? 0), frames: run.length,
276
+ edge: second(Math.min(...edges.slice(run.from, run.to + 1))),
277
+ }))
278
+ // 同一次失败只报一次:已经在 black / blank 里记过的段落不再重复记。
279
+ const already = (range) => blacks.some((item) => item.to >= range.from - 0.02 && item.from <= range.to + 0.02)
280
+ || blanks.some((item) => item.to >= range.from - 0.02 && item.from <= range.to + 0.02)
281
+ const emptyContent = emptyRuns.filter((range) => !already(range))
282
+ for (const range of emptyContent)
283
+ add('warn', 'content', '有底没内容 ' + range.from + 's–' + range.to + 's(' + range.frames + ' 帧,边密度 ' + range.edge + ')—— 只剩底色,没放东西', shotAt(range.from))
284
+ if (edges.length && !emptyContent.length) add('pass', 'content', '没有只有底色、没内容的段落')
285
+
286
+
189
287
  let marginCount = 0
190
288
  for (const [band, hits] of Object.entries(marginHits)) {
191
289
  if (!hits.length) continue
@@ -225,7 +323,7 @@ export async function runFrameQa({ dir, file, timeline, options = {} }) {
225
323
  if (audio.silence.length > 2) add('info', 'audio-silence', '有 ' + audio.silence.length + ' 处静音起点(设计上的空拍请忽略)', audio.silence)
226
324
  } else add('info', 'audio', '成片没有音轨')
227
325
 
228
- const byShot = timeline.shots.map((shot) => {
326
+ const byShot = timeline.shots.map((shot, index) => {
229
327
  const from = shot.startFrame / fps, to = (shot.startFrame + shot.durationFrames) / fps
230
328
  const inside = (time) => time >= from - 0.02 && time < to - 0.02
231
329
  return {
@@ -233,8 +331,10 @@ export async function runFrameQa({ dir, file, timeline, options = {} }) {
233
331
  freeze: second(Math.max(0, ...freezes.filter((item) => inside(item.from)).map((item) => item.seconds || 0))),
234
332
  black: blacks.filter((item) => inside(item.from)).length,
235
333
  blank: blanks.filter((item) => inside(item.from)).length,
334
+ content: emptyContent.filter((item) => inside(item.from)).length,
236
335
  flash: flashes.filter((item) => inside(item.t)).length,
237
336
  margin: Object.values(marginHits).flat().filter((item) => inside(item.from)).length,
337
+ energy: energies[index] || null,
238
338
  }
239
339
  })
240
340
 
@@ -244,6 +344,11 @@ export async function runFrameQa({ dir, file, timeline, options = {} }) {
244
344
  generatedAt: new Date().toISOString(), file: source, fps, frames: stats.length, durationSeconds: second(durationSeconds),
245
345
  verdict: fails ? 'fail' : warns ? 'warn' : 'pass', summary: { fail: fails, warn: warns, pass: checks.length - fails - warns },
246
346
  shots: byShot, checks, audio, pixels,
347
+ // 量纲写进报告:换个片子看这份 JSON 的人不该猜"2.3 是什么单位"。
348
+ motion: {
349
+ unit: '相邻帧平均绝对差(480 宽灰度 0–255)',
350
+ diffFrames: diffs.length, edgeFrames: edges.length, error: motionError,
351
+ },
247
352
  freezeSegments: freezes.map((item) => ({ from: second(item.from), to: item.to === undefined ? null : second(item.to), seconds: second(item.seconds || 0) })).filter((item) => item.seconds >= settings.freezeSeconds),
248
353
  settings,
249
354
  }
@@ -253,11 +358,11 @@ export async function runFrameQa({ dir, file, timeline, options = {} }) {
253
358
  try {
254
359
  const merged = join(dir, '.out', 'qa', 'report.json')
255
360
  const base = existsSync(merged) ? parseProjectJson(readFileSync(merged, 'utf8')) : {}
256
- base.frameQa = { generatedAt: report.generatedAt, verdict: report.verdict, summary: report.summary, checks, audio, pixels }
361
+ base.frameQa = { generatedAt: report.generatedAt, verdict: report.verdict, summary: report.summary, checks, audio, pixels, motion: report.motion }
257
362
  writeFileSync(merged, JSON.stringify(base, null, 2) + '\n', 'utf8')
258
363
  } catch { /* 合并失败不该影响帧检结论 */ }
259
364
  for (const name of readdirSync(work)) if (/\.(raw|log)$/.test(name)) rmSync(join(work, name), { force: true })
260
- for (const name of ['stats.txt', 'm-left.txt', 'm-right.txt', 'm-top.txt', 'm-bottom.txt', 'scaled.mp4', 'ff.log'])
365
+ for (const name of ['stats.txt', 'diff.txt', 'edge.txt', 'm-left.txt', 'm-right.txt', 'm-top.txt', 'm-bottom.txt', 'scaled.mp4', 'ff.log'])
261
366
  rmSync(join(work, name), { force: true })
262
367
  return report
263
368
  }
@@ -278,9 +383,14 @@ export function renderMarkdown(report) {
278
383
  '',
279
384
  '## 逐镜',
280
385
  '',
281
- '| 镜头 | 时长 | 最长静止 | 黑场 | 空白 | 闪烁 | 越界 |',
282
- '| --- | --- | --- | --- | --- | --- | --- |',
283
- ...report.shots.map((shot) => '| ' + shot.id + ' | ' + shot.seconds + 's | ' + shot.freeze + 's | ' + shot.black + ' | ' + shot.blank + ' | ' + shot.flash + ' | ' + shot.margin + ' |'),
386
+ '| 镜头 | 时长 | 最长静止 | 动能量 | 黑场 | 空白 | 空画面 | 闪烁 | 越界 |',
387
+ '| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
388
+ ...report.shots.map((shot) => '| ' + shot.id + ' | ' + shot.seconds + 's | ' + shot.freeze + 's | '
389
+ + (shot.energy ? shot.energy.median : '—') + ' | ' + shot.black + ' | ' + shot.blank + ' | '
390
+ + (shot.content || 0) + ' | ' + shot.flash + ' | ' + shot.margin + ' |'),
391
+ '',
392
+ '动能量 = 相邻帧平均绝对差(480 宽灰度 0–255):中位偏低且低动帧多,就是翻页感;'
393
+ + 'p90/中位 偏低,说明整镜匀速、没有加速段。',
284
394
  '',
285
395
  '## 发现',
286
396
  '',
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // 为什么值得有:画质的一半不在插件里,在场景怎么写。同一件事(亚像素抖动到底
7
7
  // 是居中抖还是右下角抖、弹簧是 9% 过冲还是 30% 过冲)让每个片子自己发明,
8
- // 结果就是 14 种风格长出 14 种错法。放在这里 = 只有一份实现。
8
+ // 结果就是 15 种风格长出 15 种错法。放在这里 = 只有一份实现。
9
9
 
10
10
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
11
11
  import { dirname, join } from 'node:path'
@@ -96,7 +96,7 @@ export function enforceMotionCards(composition, input) {
96
96
  return composition
97
97
  }
98
98
 
99
- // ── mg-styles:14 种整片风格(不是单镜卡;上游 15 支里的 04-3d-render 已移除) ──
99
+ // ── mg-styles:15 种整片风格(不是单镜卡;上游 15 支里的 04-3d-render 已移除,另接入 huashu 17_ink) ──
100
100
  // 上游是「HTML 页 + renderAt(t) + puppeteer 截帧」,这里是「提示词 + 移植 brief + 成片源码」,
101
101
  // 供 agent 在 Remotion 里照着写场景。源码只作阅读参考,不进 webpack(和镜头卡不同)。
102
102