@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/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
- let sidebarMetallicSnapshot;
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(context === 'sidebar' ? sidebarMetallicSnapshot : undefined);
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: true,
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(96, Math.ceil(visualSize * pixelRatio * 2));
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
- let visible = true;
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
- if (context === 'sidebar') {
283
- sidebarMetallicSnapshot = canvas.toDataURL();
284
- setSnapshot(sidebarMetallicSnapshot);
285
- return;
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
- if (animationFrame !== undefined)
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,4 @@
1
+ /** Fast closure, short hold, relaxed opening; zero velocity at each join. */
2
+ export declare function blinkClosure(progress: number): number;
3
+ export declare function attentionBlink(progress: number): number;
4
+ //# sourceMappingURL=character-expression.d.ts.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; elapsed-time continuous sampling, capped at 30 fps. */
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; elapsed-time continuous sampling, capped at 30 fps. */
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, turn = 0, active = '';
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 / 30);
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
- play((turn++ % 2 === 0 && clips.blink) || !gestures.length ? 'blink' : gestures[Math.floor(random() * gestures.length)]);
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);