@lemoncat7/dsh-theme-xiaohei 0.3.2 → 0.3.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.
- package/README.md +5 -5
- package/lib/bloub-heixiu/runtime.js +17 -3
- package/lib/chrome/conversation-messages.js +8 -8
- package/lib/chrome/sidebar.d.ts +1 -1
- package/lib/chrome/sidebar.js +13 -15
- package/lib/chrome/tokens.d.ts +1 -1
- package/lib/chrome/tokens.js +4 -4
- package/lib/client.js +413 -99
- package/lib/gaze.js +15 -2
- package/lib/metallic-brand-mark.js +13 -40
- package/lib/scene/character-expression.d.ts +4 -0
- package/lib/scene/character-expression.js +15 -0
- package/lib/scene/character-eye-material.d.ts +13 -0
- package/lib/scene/character-eye-material.js +88 -0
- package/lib/scene/character-eye-renderer.d.ts +5 -0
- package/lib/scene/character-eye-renderer.js +80 -0
- package/lib/scene/character-eye-rig.d.ts +20 -0
- package/lib/scene/character-eye-rig.js +35 -0
- package/lib/scene/character-gaze.d.ts +16 -0
- package/lib/scene/character-gaze.js +36 -0
- package/lib/scene/character-motion-clock.d.ts +1 -1
- package/lib/scene/character-motion-clock.js +17 -4
- package/lib/scene/character-motion.js +66 -12
- package/lib/scene/character-rig-renderer.d.ts +3 -2
- package/lib/scene/character-rig-renderer.js +28 -18
- package/lib/scene/character-rig.d.ts +2 -0
- package/lib/scene/character-rig.js +9 -1
- package/lib/sidebar-reveal.js +11 -3
- package/package.json +1 -1
package/lib/gaze.js
CHANGED
|
@@ -43,14 +43,14 @@ export function installXiaoheiGaze(doc = typeof document === 'undefined' ? undef
|
|
|
43
43
|
gaze.style.setProperty('--xiaohei-gaze-y', `${currentY.toFixed(3)}px`);
|
|
44
44
|
};
|
|
45
45
|
const wake = () => {
|
|
46
|
-
if (disposed || frameId !== 0 || gaze === undefined)
|
|
46
|
+
if (disposed || doc.hidden || frameId !== 0 || gaze === undefined)
|
|
47
47
|
return;
|
|
48
48
|
setMoving(true);
|
|
49
49
|
frameId = win.requestAnimationFrame(tick);
|
|
50
50
|
};
|
|
51
51
|
const tick = (time) => {
|
|
52
52
|
frameId = 0;
|
|
53
|
-
if (disposed || gaze === undefined || mascot === undefined)
|
|
53
|
+
if (disposed || doc.hidden || gaze === undefined || mascot === undefined)
|
|
54
54
|
return;
|
|
55
55
|
const target = resolveGazeTarget(doc, mascot, pointer, portalActive, behaviorDisabled());
|
|
56
56
|
const elapsed = lastFrameTime === 0 ? 16 : Math.min(48, Math.max(1, time - lastFrameTime));
|
|
@@ -107,6 +107,17 @@ export function installXiaoheiGaze(doc = typeof document === 'undefined' ? undef
|
|
|
107
107
|
};
|
|
108
108
|
const onPreferenceChange = () => wake();
|
|
109
109
|
const onStateChange = () => wake();
|
|
110
|
+
const onVisibilityChange = () => {
|
|
111
|
+
if (doc.hidden) {
|
|
112
|
+
if (frameId !== 0)
|
|
113
|
+
win.cancelAnimationFrame(frameId);
|
|
114
|
+
frameId = 0;
|
|
115
|
+
lastFrameTime = 0;
|
|
116
|
+
setMoving(false);
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
wake();
|
|
120
|
+
};
|
|
110
121
|
const unsubscribeHostDom = subscribeXiaoheiHostDom(doc, attach);
|
|
111
122
|
const stateObserver = new win.MutationObserver(onStateChange);
|
|
112
123
|
stateObserver.observe(doc.documentElement, {
|
|
@@ -114,6 +125,7 @@ export function installXiaoheiGaze(doc = typeof document === 'undefined' ? undef
|
|
|
114
125
|
attributeFilter: ['data-xiaohei-state'],
|
|
115
126
|
});
|
|
116
127
|
doc.addEventListener('pointermove', onPointerMove, { passive: true });
|
|
128
|
+
doc.addEventListener('visibilitychange', onVisibilityChange);
|
|
117
129
|
doc.addEventListener('pointerleave', clearPointer);
|
|
118
130
|
doc.addEventListener(XIAOHEI_PORTAL_ACTIVITY_EVENT, onPortalActivity);
|
|
119
131
|
win.addEventListener('blur', clearPointer);
|
|
@@ -127,6 +139,7 @@ export function installXiaoheiGaze(doc = typeof document === 'undefined' ? undef
|
|
|
127
139
|
unsubscribeHostDom();
|
|
128
140
|
stateObserver.disconnect();
|
|
129
141
|
doc.removeEventListener('pointermove', onPointerMove);
|
|
142
|
+
doc.removeEventListener('visibilitychange', onVisibilityChange);
|
|
130
143
|
doc.removeEventListener('pointerleave', clearPointer);
|
|
131
144
|
doc.removeEventListener(XIAOHEI_PORTAL_ACTIVITY_EVENT, onPortalActivity);
|
|
132
145
|
win.removeEventListener('blur', clearPointer);
|
|
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
3
3
|
import { XIAOHEI_BRAND_AVATAR } from './generated-identity.js';
|
|
4
4
|
// Native wide/rail switches remount this slot. A tiny navigation mark does not
|
|
5
5
|
// need a fresh GPU context and shader compilation on every toggle.
|
|
6
|
-
|
|
6
|
+
const metallicSnapshots = new Map();
|
|
7
7
|
const VERTEX_SHADER = `#version 300 es
|
|
8
8
|
precision highp float;
|
|
9
9
|
in vec2 a_position;
|
|
@@ -170,7 +170,7 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
170
170
|
const [ready, setReady] = useState(false);
|
|
171
171
|
const visualSize = Math.max(24, size + 4);
|
|
172
172
|
const context = size >= 32 ? 'hero' : 'sidebar';
|
|
173
|
-
const [snapshot, setSnapshot] = useState(
|
|
173
|
+
const [snapshot, setSnapshot] = useState(metallicSnapshots.get(visualSize));
|
|
174
174
|
useEffect(() => {
|
|
175
175
|
if (snapshot !== undefined)
|
|
176
176
|
return;
|
|
@@ -179,7 +179,7 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
179
179
|
return;
|
|
180
180
|
const gl = canvas.getContext('webgl2', {
|
|
181
181
|
alpha: true,
|
|
182
|
-
antialias:
|
|
182
|
+
antialias: false,
|
|
183
183
|
powerPreference: 'low-power',
|
|
184
184
|
premultipliedAlpha: true,
|
|
185
185
|
});
|
|
@@ -213,7 +213,7 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
213
213
|
}
|
|
214
214
|
const uniforms = Object.fromEntries(UNIFORM_NAMES.map(name => [name, gl.getUniformLocation(program, name)]));
|
|
215
215
|
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
216
|
-
const renderSize = Math.max(
|
|
216
|
+
const renderSize = Math.max(64, Math.ceil(visualSize * pixelRatio));
|
|
217
217
|
canvas.width = renderSize;
|
|
218
218
|
canvas.height = renderSize;
|
|
219
219
|
gl.viewport(0, 0, renderSize, renderSize);
|
|
@@ -244,28 +244,13 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
244
244
|
setRgb(gl, uniforms.u_darkColor, [0.025, 0.04, 0.045]);
|
|
245
245
|
setRgb(gl, uniforms.u_tint, [0.84, 0.92, 0.89]);
|
|
246
246
|
let disposed = false;
|
|
247
|
-
|
|
248
|
-
let animationFrame;
|
|
249
|
-
let lastFrame = 0;
|
|
250
|
-
let metallicTime = 1450;
|
|
251
|
-
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
247
|
+
const metallicTime = 1450;
|
|
252
248
|
const texture = gl.createTexture();
|
|
253
249
|
const image = new Image();
|
|
254
250
|
const draw = () => {
|
|
255
251
|
gl.uniform1f(uniforms.u_time, metallicTime);
|
|
256
252
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
257
253
|
};
|
|
258
|
-
const render = (now) => {
|
|
259
|
-
if (disposed)
|
|
260
|
-
return;
|
|
261
|
-
if (visible && document.visibilityState !== 'hidden' && now - lastFrame >= 40) {
|
|
262
|
-
const delta = lastFrame === 0 ? 0 : Math.min(80, now - lastFrame);
|
|
263
|
-
lastFrame = now;
|
|
264
|
-
metallicTime += delta * 0.11;
|
|
265
|
-
draw();
|
|
266
|
-
}
|
|
267
|
-
animationFrame = window.requestAnimationFrame(render);
|
|
268
|
-
};
|
|
269
254
|
image.onload = () => {
|
|
270
255
|
if (disposed || texture === null)
|
|
271
256
|
return;
|
|
@@ -279,29 +264,17 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
279
264
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
280
265
|
gl.uniform1i(uniforms.u_tex, 0);
|
|
281
266
|
draw();
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
267
|
+
// Preserve the metallic appearance without a permanent GPU animation.
|
|
268
|
+
// Updating snapshot unmounts the canvas and runs the resource cleanup.
|
|
269
|
+
const result = canvas.toDataURL();
|
|
270
|
+
metallicSnapshots.set(visualSize, result);
|
|
271
|
+
setSnapshot(result);
|
|
287
272
|
setReady(true);
|
|
288
|
-
if (!reduceMotion.matches)
|
|
289
|
-
animationFrame = window.requestAnimationFrame(render);
|
|
290
273
|
};
|
|
291
274
|
image.src = XIAOHEI_BRAND_AVATAR;
|
|
292
|
-
const visibilityObserver = typeof IntersectionObserver === 'function'
|
|
293
|
-
? new IntersectionObserver(entries => {
|
|
294
|
-
visible = entries.some(entry => entry.isIntersecting);
|
|
295
|
-
if (visible)
|
|
296
|
-
lastFrame = 0;
|
|
297
|
-
})
|
|
298
|
-
: undefined;
|
|
299
|
-
visibilityObserver?.observe(canvas);
|
|
300
275
|
return () => {
|
|
301
276
|
disposed = true;
|
|
302
|
-
|
|
303
|
-
window.cancelAnimationFrame(animationFrame);
|
|
304
|
-
visibilityObserver?.disconnect();
|
|
277
|
+
image.onload = null;
|
|
305
278
|
if (texture !== null)
|
|
306
279
|
gl.deleteTexture(texture);
|
|
307
280
|
gl.deleteBuffer(buffer);
|
|
@@ -312,12 +285,12 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
312
285
|
const geometry = {
|
|
313
286
|
'--xiaohei-brand-mark-size': `${visualSize}px`,
|
|
314
287
|
};
|
|
315
|
-
return (_jsxs("span", { className: className === undefined ? 'xiaohei-brand-mark' : `${className} xiaohei-brand-mark`, "data-brand-context": context, "data-metallic-ready": ready || snapshot !== undefined ? 'true' : 'false', style: geometry, "aria-hidden": "true", children: [_jsx("img", { className: "xiaohei-brand-mark__fallback", src: XIAOHEI_BRAND_AVATAR, alt: "" }), snapshot !== undefined
|
|
288
|
+
return (_jsxs("span", { className: className === undefined ? 'xiaohei-brand-mark' : `${className} xiaohei-brand-mark`, "data-brand-context": context, "data-xiaohei-sidebar-brand": context === 'sidebar' ? '' : undefined, "data-metallic-ready": ready || snapshot !== undefined ? 'true' : 'false', style: geometry, "aria-hidden": "true", children: [_jsx("img", { className: "xiaohei-brand-mark__fallback", src: XIAOHEI_BRAND_AVATAR, alt: "" }), snapshot !== undefined
|
|
316
289
|
? _jsx("img", { className: "xiaohei-brand-mark__metal", src: snapshot, alt: "" })
|
|
317
290
|
: _jsx("canvas", { className: "xiaohei-brand-mark__metal", ref: canvasRef })] }));
|
|
318
291
|
}
|
|
319
292
|
/** Official sidebar brand-name occupant paired with the Xiaohei mark. */
|
|
320
293
|
export function XiaoheiBrandName() {
|
|
321
|
-
return _jsx("span", { className: "xiaohei-brand-name", children: "\u7F57\u5C0F\u9ED1 \u00B7 \u5996\u7075\u4F1A\u9986" });
|
|
294
|
+
return _jsx("span", { className: "xiaohei-brand-name", "data-xiaohei-sidebar-brand": "", children: "\u7F57\u5C0F\u9ED1 \u00B7 \u5996\u7075\u4F1A\u9986" });
|
|
322
295
|
}
|
|
323
296
|
//# sourceMappingURL=metallic-brand-mark.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const smooth = (t) => { const x = Math.max(0, Math.min(1, t)); return x * x * (3 - 2 * x); };
|
|
2
|
+
/** Fast closure, short hold, relaxed opening; zero velocity at each join. */
|
|
3
|
+
export function blinkClosure(progress) {
|
|
4
|
+
if (progress <= 0 || progress >= 1)
|
|
5
|
+
return 0;
|
|
6
|
+
if (progress < .3)
|
|
7
|
+
return smooth(progress / .3);
|
|
8
|
+
if (progress < .4)
|
|
9
|
+
return 1;
|
|
10
|
+
return 1 - smooth((progress - .4) / .6);
|
|
11
|
+
}
|
|
12
|
+
export function attentionBlink(progress) {
|
|
13
|
+
return blinkClosure((progress - .12) / .16);
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=character-expression.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EyeRig } from './character-eye-rig.js';
|
|
2
|
+
/** Quadratic lid ordinate at a horizontal art-space coordinate. */
|
|
3
|
+
export declare function lidY(eye: EyeRig, control: readonly [number, number], x: number): number;
|
|
4
|
+
/** Cache original ink and a locally textured skin plate, never read pixels per frame. */
|
|
5
|
+
export declare function prepareEyeMaterial(doc: Document, source: CanvasRenderingContext2D, eye: EyeRig): {
|
|
6
|
+
ink: HTMLCanvasElement;
|
|
7
|
+
skin: HTMLCanvasElement;
|
|
8
|
+
left: number;
|
|
9
|
+
top: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=character-eye-material.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Quadratic lid ordinate at a horizontal art-space coordinate. */
|
|
2
|
+
export function lidY(eye, control, x) {
|
|
3
|
+
let low = 0, high = 1;
|
|
4
|
+
for (let i = 0; i < 14; i++) {
|
|
5
|
+
const t = (low + high) / 2, s = 1 - t;
|
|
6
|
+
if (s * s * eye.left[0] + 2 * s * t * control[0] + t * t * eye.right[0] < x)
|
|
7
|
+
low = t;
|
|
8
|
+
else
|
|
9
|
+
high = t;
|
|
10
|
+
}
|
|
11
|
+
const t = (low + high) / 2, s = 1 - t;
|
|
12
|
+
return s * s * eye.left[1] + 2 * s * t * control[1] + t * t * eye.right[1];
|
|
13
|
+
}
|
|
14
|
+
/** Cache original ink and a locally textured skin plate, never read pixels per frame. */
|
|
15
|
+
export function prepareEyeMaterial(doc, source, eye) {
|
|
16
|
+
const left = Math.floor(eye.left[0] - 9), right = Math.ceil(eye.right[0] + 9);
|
|
17
|
+
const top = Math.floor(Math.min(eye.upper[1], eye.left[1], eye.right[1]) - 5);
|
|
18
|
+
const bottom = Math.ceil(Math.max(eye.lower[1], eye.left[1], eye.right[1]) + 5);
|
|
19
|
+
const width = right - left, height = bottom - top;
|
|
20
|
+
const canvas = () => { const c = doc.createElement('canvas'); c.width = width; c.height = height; return c; };
|
|
21
|
+
const ink = canvas(), skin = canvas(), mask = canvas();
|
|
22
|
+
const ic = ink.getContext('2d'), sc = skin.getContext('2d'), mc = mask.getContext('2d');
|
|
23
|
+
const pixels = source.getImageData(0, 0, source.canvas.width, source.canvas.height);
|
|
24
|
+
const pixel = (x, y) => {
|
|
25
|
+
const offset = (Math.max(0, Math.min(pixels.height - 1, Math.round(y / 2))) * pixels.width + Math.max(0, Math.min(pixels.width - 1, Math.round(x / 2)))) * 4;
|
|
26
|
+
return [pixels.data[offset], pixels.data[offset + 1], pixels.data[offset + 2]];
|
|
27
|
+
};
|
|
28
|
+
const ref = pixel(...eye.skin), output = ic.createImageData(width, height), plate = sc.createImageData(width, height);
|
|
29
|
+
const isSkin = (c) => c.every((v, i) => Math.abs(v - ref[i]) < 25);
|
|
30
|
+
const skinAt = (x, y, c) => {
|
|
31
|
+
if (isSkin(c))
|
|
32
|
+
return c;
|
|
33
|
+
const sum = [ref[0] * .05, ref[1] * .05, ref[2] * .05];
|
|
34
|
+
let weight = .05;
|
|
35
|
+
// Inpaint from nearby matching face pixels, preserving local lighting.
|
|
36
|
+
// Source hair, green iris and dark ink cannot bleed into the skin plate.
|
|
37
|
+
for (const radius of [6, 12, 20, 30])
|
|
38
|
+
for (let i = 0; i < 8; i++) {
|
|
39
|
+
const p = pixel(x + Math.cos(i * Math.PI / 4) * radius, y + Math.sin(i * Math.PI / 4) * radius);
|
|
40
|
+
if (!isSkin(p))
|
|
41
|
+
continue;
|
|
42
|
+
const w = 1 / radius;
|
|
43
|
+
weight += w;
|
|
44
|
+
for (let k = 0; k < 3; k++)
|
|
45
|
+
sum[k] += p[k] * w;
|
|
46
|
+
}
|
|
47
|
+
return sum.map(v => Math.round(v / weight));
|
|
48
|
+
};
|
|
49
|
+
for (let x = 0; x < width; x++) {
|
|
50
|
+
const ax = left + x, upper = lidY(eye, eye.upper, ax);
|
|
51
|
+
// Restrict extraction to the upper lash: exclude eyebrow, pupil and hair.
|
|
52
|
+
for (let y = 0; y < height; y++) {
|
|
53
|
+
const ay = top + y, c = pixel(ax, ay), index = (y * width + x) * 4;
|
|
54
|
+
const darkness = 1 - (c[0] + c[1] + c[2]) / Math.max(1, ref[0] + ref[1] + ref[2]);
|
|
55
|
+
const green = c[1] > c[0] * 1.15 && c[1] > c[2] * 1.12;
|
|
56
|
+
const inBand = ay >= upper - 12 && ay <= upper + 3;
|
|
57
|
+
const alpha = inBand && !green ? Math.max(0, Math.min(1, (darkness - .3) / .35)) : 0;
|
|
58
|
+
output.data.set([...c, Math.round(alpha * 255)], index);
|
|
59
|
+
plate.data.set([...skinAt(ax, ay, c), 255], index);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
ic.putImageData(output, 0, 0);
|
|
63
|
+
sc.putImageData(plate, 0, 0);
|
|
64
|
+
// Opaque coverage of extracted ink, including its antialiased fringe. Using
|
|
65
|
+
// the ink's original alpha here leaves a second, stationary lash behind.
|
|
66
|
+
const inkMask = mc.createImageData(width, height);
|
|
67
|
+
for (let i = 0; i < output.data.length; i += 4)
|
|
68
|
+
inkMask.data[i + 3] = output.data[i + 3] > 4 ? 255 : 0;
|
|
69
|
+
mc.putImageData(inkMask, 0, 0);
|
|
70
|
+
const expanded = canvas(), ec = expanded.getContext('2d');
|
|
71
|
+
for (const [dx, dy] of [[0, 0], [-1, 0], [1, 0], [0, -1], [0, 1]])
|
|
72
|
+
ec.drawImage(mask, dx, dy);
|
|
73
|
+
mc.drawImage(expanded, 0, 0);
|
|
74
|
+
mc.translate(-left, -top);
|
|
75
|
+
mc.beginPath();
|
|
76
|
+
mc.moveTo(eye.left[0] - 7, eye.left[1]);
|
|
77
|
+
mc.quadraticCurveTo(eye.upper[0], eye.upper[1] - 4, eye.right[0] + 7, eye.right[1]);
|
|
78
|
+
mc.quadraticCurveTo(eye.lower[0], eye.lower[1] + 20, eye.left[0] - 7, eye.left[1]);
|
|
79
|
+
mc.closePath();
|
|
80
|
+
mc.fill();
|
|
81
|
+
// Feather only a subpixel edge, not the eye or the whole character.
|
|
82
|
+
sc.globalCompositeOperation = 'destination-in';
|
|
83
|
+
sc.filter = 'blur(0.65px)';
|
|
84
|
+
sc.drawImage(mask, 0, 0);
|
|
85
|
+
sc.filter = 'none';
|
|
86
|
+
return { ink, skin, left, top, width, height };
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=character-eye-material.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type EyeRig } from './character-eye-rig.js';
|
|
2
|
+
import type { Point } from './character-motion-types.js';
|
|
3
|
+
/** Prepare tiny iris surfaces once. Never shift a rectangular face/eye patch. */
|
|
4
|
+
export declare function createEyeRenderer(ctx: CanvasRenderingContext2D, body: HTMLImageElement, eyes: readonly EyeRig[]): (gaze: Point, closure: number) => void;
|
|
5
|
+
//# sourceMappingURL=character-eye-renderer.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { clampGaze, eyelidControls } from './character-eye-rig.js';
|
|
2
|
+
import { lidY, prepareEyeMaterial } from './character-eye-material.js';
|
|
3
|
+
/** Prepare tiny iris surfaces once. Never shift a rectangular face/eye patch. */
|
|
4
|
+
export function createEyeRenderer(ctx, body, eyes) {
|
|
5
|
+
const doc = ctx.canvas.ownerDocument;
|
|
6
|
+
const source = doc.createElement('canvas');
|
|
7
|
+
source.width = body.width;
|
|
8
|
+
source.height = body.height;
|
|
9
|
+
const sample = source.getContext('2d', { willReadFrequently: true });
|
|
10
|
+
sample.drawImage(body, 0, 0);
|
|
11
|
+
const color = (p) => {
|
|
12
|
+
const data = sample.getImageData(Math.round(p[0] / 2), Math.round(p[1] / 2), 1, 1).data;
|
|
13
|
+
return [data[0], data[1], data[2]];
|
|
14
|
+
};
|
|
15
|
+
const rgb = (c) => `rgb(${c.map(v => Math.round(v)).join(' ')})`;
|
|
16
|
+
const prepared = eyes.map(eye => {
|
|
17
|
+
const [cx, cy, rx, ry, angle] = eye.iris;
|
|
18
|
+
// Full-size local surface preserves source texels when moved fractionally.
|
|
19
|
+
const iris = doc.createElement('canvas');
|
|
20
|
+
iris.width = Math.ceil(rx * 2 + 4);
|
|
21
|
+
iris.height = Math.ceil(ry * 2 + 4);
|
|
22
|
+
const ic = iris.getContext('2d');
|
|
23
|
+
ic.translate(iris.width / 2, iris.height / 2);
|
|
24
|
+
ic.rotate(angle);
|
|
25
|
+
ic.beginPath();
|
|
26
|
+
ic.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
|
|
27
|
+
ic.clip();
|
|
28
|
+
ic.rotate(-angle);
|
|
29
|
+
ic.drawImage(body, 0, 0, body.width, body.height, -cx, -cy, body.width * 2, body.height * 2);
|
|
30
|
+
return { eye, iris, skin: color(eye.skin), material: prepareEyeMaterial(doc, sample, eye) };
|
|
31
|
+
});
|
|
32
|
+
function aperture(eye, closure) {
|
|
33
|
+
const { upper, lower, left, right } = eyelidControls(eye, closure);
|
|
34
|
+
ctx.beginPath();
|
|
35
|
+
ctx.moveTo(...left);
|
|
36
|
+
ctx.quadraticCurveTo(...upper, ...right);
|
|
37
|
+
ctx.quadraticCurveTo(...lower, ...left);
|
|
38
|
+
ctx.closePath();
|
|
39
|
+
}
|
|
40
|
+
return (gaze, closure) => {
|
|
41
|
+
const [gx, gy] = clampGaze(...gaze);
|
|
42
|
+
ctx.save();
|
|
43
|
+
ctx.scale(.5, .5);
|
|
44
|
+
for (const { eye, iris, skin, material } of prepared) {
|
|
45
|
+
const [cx, cy, rx, ry] = eye.iris;
|
|
46
|
+
ctx.save();
|
|
47
|
+
ctx.drawImage(material.skin, material.left, material.top);
|
|
48
|
+
ctx.save();
|
|
49
|
+
aperture(eye, 0);
|
|
50
|
+
ctx.clip();
|
|
51
|
+
if (closure < .999) {
|
|
52
|
+
ctx.save();
|
|
53
|
+
aperture(eye, closure);
|
|
54
|
+
ctx.clip();
|
|
55
|
+
const shade = ctx.createLinearGradient(0, cy - ry, 0, cy + ry);
|
|
56
|
+
const brightness = (skin[0] + skin[1] + skin[2]) / 765;
|
|
57
|
+
const white = brightness > .65 ? [255, 253, 241] : [166, 185, 193];
|
|
58
|
+
shade.addColorStop(0, rgb(white.map(c => c * .83)));
|
|
59
|
+
shade.addColorStop(.5, rgb(white));
|
|
60
|
+
ctx.fillStyle = shade;
|
|
61
|
+
ctx.fillRect(cx - 70, cy - 70, 140, 140);
|
|
62
|
+
const dx = gx * rx * .26, dy = gy * ry * .13;
|
|
63
|
+
ctx.drawImage(iris, cx - iris.width / 2 + dx, cy - iris.height / 2 + dy);
|
|
64
|
+
ctx.restore();
|
|
65
|
+
}
|
|
66
|
+
ctx.restore();
|
|
67
|
+
const lids = eyelidControls(eye, closure);
|
|
68
|
+
// Move narrow strips of the original brushwork, preserving lash thickness.
|
|
69
|
+
// Neither a replacement stroke nor an opacity swap is used for blinking.
|
|
70
|
+
for (let x = 0; x < material.width; x++) {
|
|
71
|
+
const ax = material.left + x;
|
|
72
|
+
const dy = lidY({ ...eye, left: lids.left, right: lids.right }, lids.upper, ax) - lidY(eye, eye.upper, ax);
|
|
73
|
+
ctx.drawImage(material.ink, x, 0, 1, material.height, ax, material.top + dy, 1, material.height);
|
|
74
|
+
}
|
|
75
|
+
ctx.restore();
|
|
76
|
+
}
|
|
77
|
+
ctx.restore();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=character-eye-renderer.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Point } from './character-motion-types.js';
|
|
2
|
+
/** Landmarks in the approved full-resolution artwork, not screen coordinates. */
|
|
3
|
+
export interface EyeRig {
|
|
4
|
+
left: Point;
|
|
5
|
+
right: Point;
|
|
6
|
+
upper: Point;
|
|
7
|
+
lower: Point;
|
|
8
|
+
iris: readonly [number, number, number, number, number];
|
|
9
|
+
skin: Point;
|
|
10
|
+
}
|
|
11
|
+
export declare const EYE_RIGS: Readonly<Record<string, readonly EyeRig[]>>;
|
|
12
|
+
export declare function clampGaze(x: number, y: number): Point;
|
|
13
|
+
/** Both lids meet on the same gently curved lash line, never a rectangular wipe. */
|
|
14
|
+
export declare function eyelidControls(eye: EyeRig, closure: number): {
|
|
15
|
+
upper: Point;
|
|
16
|
+
lower: Point;
|
|
17
|
+
left: Point;
|
|
18
|
+
right: Point;
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=character-eye-rig.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const EYE_RIGS = {
|
|
2
|
+
seated: [
|
|
3
|
+
{ left: [285, 223], right: [326, 224], upper: [302, 188], lower: [310, 276], iris: [309, 230, 13, 20, 0], skin: [315, 258] },
|
|
4
|
+
{ left: [373, 221], right: [421, 226], upper: [387, 186], lower: [393, 277], iris: [393, 228, 15, 22, 0], skin: [391, 261] },
|
|
5
|
+
],
|
|
6
|
+
peek: [
|
|
7
|
+
{ left: [80, 214], right: [119, 221], upper: [103, 186], lower: [91, 267], iris: [102, 225, 14, 20, .25], skin: [110, 253] },
|
|
8
|
+
{ left: [171, 252], right: [210, 265], upper: [195, 221], lower: [175, 300], iris: [186, 260, 15, 20, .4], skin: [186, 288] },
|
|
9
|
+
],
|
|
10
|
+
chin: [
|
|
11
|
+
{ left: [252, 252], right: [290, 247], upper: [266, 223], lower: [287, 297], iris: [278, 258, 14, 19, -.3], skin: [291, 285] },
|
|
12
|
+
{ left: [339, 234], right: [387, 227], upper: [358, 196], lower: [365, 280], iris: [361, 235, 15, 21, -.25], skin: [367, 269] },
|
|
13
|
+
],
|
|
14
|
+
halfpeek: [
|
|
15
|
+
{ left: [89, 390], right: [159, 395], upper: [116, 337], lower: [110, 471], iris: [116, 401, 23, 30, .22], skin: [117, 445] },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
export function clampGaze(x, y) {
|
|
19
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
20
|
+
return [0, 0];
|
|
21
|
+
const length = Math.max(1, Math.hypot(x, y));
|
|
22
|
+
return [x / length, y / length];
|
|
23
|
+
}
|
|
24
|
+
/** Both lids meet on the same gently curved lash line, never a rectangular wipe. */
|
|
25
|
+
export function eyelidControls(eye, closure) {
|
|
26
|
+
const t = Math.max(0, Math.min(1, closure));
|
|
27
|
+
// Upper lid does most of the travel. Closing at the eye's centre looked like
|
|
28
|
+
// a squint; the resting lash sits near the lower rim instead.
|
|
29
|
+
const cornerY = (eye.left[1] + eye.right[1]) / 2;
|
|
30
|
+
const drop = (eye.lower[1] - cornerY) * .24;
|
|
31
|
+
const middle = [(eye.left[0] + eye.right[0]) / 2, cornerY + drop + (eye.right[0] - eye.left[0]) * .18];
|
|
32
|
+
const lerp = (p) => [p[0] + (middle[0] - p[0]) * t, p[1] + (middle[1] - p[1]) * t];
|
|
33
|
+
return { upper: lerp(eye.upper), lower: lerp(eye.lower), left: [eye.left[0], eye.left[1] + drop * t], right: [eye.right[0], eye.right[1] + drop * t] };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=character-eye-rig.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Point } from './character-motion-types.js';
|
|
2
|
+
interface GazeClock {
|
|
3
|
+
requestAnimationFrame(callback: (time: number) => void): number;
|
|
4
|
+
cancelAnimationFrame(handle: number): void;
|
|
5
|
+
performance: {
|
|
6
|
+
now(): number;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Event-driven damping. At rest there are no queued frames or polling. */
|
|
10
|
+
export declare function createCharacterGaze(clock: GazeClock, changed: (point: Point) => void): {
|
|
11
|
+
target(x: number, y: number): void;
|
|
12
|
+
reset(): void;
|
|
13
|
+
dispose(): void;
|
|
14
|
+
};
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=character-gaze.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { clampGaze } from './character-eye-rig.js';
|
|
2
|
+
/** Event-driven damping. At rest there are no queued frames or polling. */
|
|
3
|
+
export function createCharacterGaze(clock, changed) {
|
|
4
|
+
let value = [0, 0], target = [0, 0], frame, last = 0, disposed = false;
|
|
5
|
+
function step(now) {
|
|
6
|
+
frame = undefined;
|
|
7
|
+
if (disposed)
|
|
8
|
+
return;
|
|
9
|
+
const dt = Math.min(64, Math.max(0, now - last));
|
|
10
|
+
last = now;
|
|
11
|
+
const amount = 1 - Math.exp(-dt / 95);
|
|
12
|
+
value = [value[0] + (target[0] - value[0]) * amount, value[1] + (target[1] - value[1]) * amount];
|
|
13
|
+
const settled = Math.hypot(value[0] - target[0], value[1] - target[1]) < .001;
|
|
14
|
+
if (settled)
|
|
15
|
+
value = target;
|
|
16
|
+
changed(value);
|
|
17
|
+
if (!settled)
|
|
18
|
+
frame = clock.requestAnimationFrame(step);
|
|
19
|
+
}
|
|
20
|
+
function stop() { if (frame !== undefined)
|
|
21
|
+
clock.cancelAnimationFrame(frame); frame = undefined; }
|
|
22
|
+
return {
|
|
23
|
+
target(x, y) {
|
|
24
|
+
if (disposed)
|
|
25
|
+
return;
|
|
26
|
+
target = clampGaze(x, y);
|
|
27
|
+
if (frame === undefined && Math.hypot(value[0] - target[0], value[1] - target[1]) >= .001) {
|
|
28
|
+
last = clock.performance.now();
|
|
29
|
+
frame = clock.requestAnimationFrame(step);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
reset() { stop(); target = value = [0, 0]; changed(value); },
|
|
33
|
+
dispose() { disposed = true; stop(); },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=character-gaze.js.map
|
|
@@ -8,7 +8,7 @@ interface Clock {
|
|
|
8
8
|
export interface CharacterMotionClip {
|
|
9
9
|
duration: number;
|
|
10
10
|
}
|
|
11
|
-
/** One deadline;
|
|
11
|
+
/** One deadline; 60 fps only during a short gesture, no idle frame loop. */
|
|
12
12
|
export declare function createCharacterMotionClock(clock: Clock, changed: (action: string | undefined, progress: number) => void, random?: () => number): {
|
|
13
13
|
setClips(next: Readonly<Record<string, CharacterMotionClip>> | undefined): void;
|
|
14
14
|
attention(): void;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/** One deadline;
|
|
1
|
+
/** One deadline; 60 fps only during a short gesture, no idle frame loop. */
|
|
2
2
|
export function createCharacterMotionClock(clock, changed, random = Math.random) {
|
|
3
|
-
let timer, disposed = false,
|
|
3
|
+
let timer, disposed = false, active = '';
|
|
4
4
|
let clips;
|
|
5
|
+
let blinksSinceEar = 0, nextEar = 0;
|
|
5
6
|
const clear = () => { if (timer !== undefined)
|
|
6
7
|
clock.clearTimeout(timer); timer = undefined; };
|
|
7
8
|
function rest() {
|
|
@@ -27,7 +28,7 @@ export function createCharacterMotionClock(clock, changed, random = Math.random)
|
|
|
27
28
|
rest();
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
|
-
timer = clock.setTimeout(step, 1000 /
|
|
31
|
+
timer = clock.setTimeout(step, 1000 / 60);
|
|
31
32
|
};
|
|
32
33
|
step();
|
|
33
34
|
}
|
|
@@ -36,7 +37,17 @@ export function createCharacterMotionClock(clock, changed, random = Math.random)
|
|
|
36
37
|
if (!clips || disposed)
|
|
37
38
|
return;
|
|
38
39
|
const gestures = Object.keys(clips).filter(name => name !== 'blink' && name !== 'attention');
|
|
39
|
-
|
|
40
|
+
const ears = gestures.filter(name => name.startsWith('ear'));
|
|
41
|
+
// Random selection alone can starve ear motion indefinitely. Keep a quiet,
|
|
42
|
+
// bounded cadence, alternating ears instead of twitching both in lockstep.
|
|
43
|
+
const action = ears.length && blinksSinceEar >= 2 ? ears[nextEar++ % ears.length]
|
|
44
|
+
: (random() < .72 && clips.blink) || !gestures.length ? 'blink'
|
|
45
|
+
: gestures[Math.floor(random() * gestures.length)];
|
|
46
|
+
if (action.startsWith('ear'))
|
|
47
|
+
blinksSinceEar = 0;
|
|
48
|
+
else
|
|
49
|
+
blinksSinceEar++;
|
|
50
|
+
play(action);
|
|
40
51
|
}
|
|
41
52
|
return {
|
|
42
53
|
setClips(next) {
|
|
@@ -45,6 +56,8 @@ export function createCharacterMotionClock(clock, changed, random = Math.random)
|
|
|
45
56
|
clear();
|
|
46
57
|
clips = next;
|
|
47
58
|
active = '';
|
|
59
|
+
blinksSinceEar = 0;
|
|
60
|
+
nextEar = 0;
|
|
48
61
|
changed(undefined, 0);
|
|
49
62
|
if (clips)
|
|
50
63
|
timer = clock.setTimeout(start, 1200 + random() * 800);
|