Preserve the original reflection material while adding pointer inertia and a short exit hold. Retain the Rive pointer constraint through the fade and blend rapid re-entry to avoid abrupt changes. Cover timing, re-entry, cancellation, renderer disposal and native Rive frames. The fade regression fails with the previous resource and passes with the updated resource.
318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import { build } from "esbuild";
|
|
import { expect, test } from "@playwright/test";
|
|
import { buildMaterialPersona, buildOfflinePersona, encodeSource } from "../../scripts/build-agent-persona.mjs";
|
|
|
|
test.use({ launchOptions: { args: ["--enable-unsafe-swiftshader"] } });
|
|
|
|
const harness = `import { RuntimeLoader } from '@rive-app/react-webgl2';
|
|
RuntimeLoader.setWasmUrl('/rive-probe/runtime.wasm');
|
|
window.ready = new Promise(resolve => RuntimeLoader.getInstance(resolve)).then(runtime => {
|
|
window.renderResource = async (version, steps) => {
|
|
const bytes = new Uint8Array(await (await fetch('/rive-probe/' + version + '.riv')).arrayBuffer());
|
|
const file = await runtime.load(bytes, undefined, false);
|
|
const artboard = file.defaultArtboard();
|
|
const machine = new runtime.StateMachineInstance(artboard.stateMachineByName('default'), artboard);
|
|
const inputs = {};
|
|
for (let i = 0; i < machine.inputCount(); i++) {
|
|
const input = machine.input(i);
|
|
inputs[input.name] = input.asBool();
|
|
}
|
|
const canvas = document.querySelector('canvas');
|
|
const renderer = runtime.makeRenderer(canvas, true);
|
|
const copy = document.createElement('canvas');
|
|
copy.width = copy.height = 192;
|
|
const context = copy.getContext('2d');
|
|
try {
|
|
const snapshots = [];
|
|
for (const step of steps) {
|
|
for (const name in inputs) inputs[name].value = step.state === name || (name === 'hover' && !!step.hovered);
|
|
if (step.pointer) machine.pointerMove(step.pointer[0], step.pointer[1], 0);
|
|
for (let frame = 0; frame < step.frames; frame++) {
|
|
machine.advance(1 / 60);
|
|
artboard.advance(1 / 60);
|
|
}
|
|
renderer.clear();
|
|
renderer.save();
|
|
renderer.align(runtime.Fit.contain, runtime.Alignment.center,
|
|
{ minX: 0, minY: 0, maxX: 192, maxY: 192 }, artboard.bounds);
|
|
artboard.draw(renderer);
|
|
renderer.restore();
|
|
renderer.flush();
|
|
runtime.resolveAnimationFrame();
|
|
context.clearRect(0, 0, 192, 192);
|
|
context.drawImage(canvas, 0, 0);
|
|
const pixels = context.getImageData(0, 0, 192, 192).data;
|
|
let visiblePixels = 0, bluePixels = 0, outerPixels = 0, compositedBluePixels = 0, redSum = 0;
|
|
let minimumBodyAlpha = 255;
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
redSum += pixels[i];
|
|
const x = (i / 4) % 192 + 0.5 - 96;
|
|
const y = Math.floor(i / 4 / 192) + 0.5 - 96;
|
|
// Sample inside the feathered rim: map lines should not remain visible
|
|
// through the body's center in either online or offline states.
|
|
if (Math.hypot(x, y) < 50) minimumBodyAlpha = Math.min(minimumBodyAlpha, pixels[i + 3]);
|
|
if (pixels[i + 3] <= 32) continue;
|
|
visiblePixels++;
|
|
if (pixels[i + 2] > pixels[i] + 20 && pixels[i + 1] > pixels[i] + 10) bluePixels++;
|
|
// Check that the pale material stays blue when composited on white.
|
|
// A 20-level channel difference allows the selected white reflections.
|
|
if ((pixels[i + 2] - pixels[i]) * pixels[i + 3] / 255 > 20) compositedBluePixels++;
|
|
if (Math.hypot(x, y) > 79) outerPixels++;
|
|
}
|
|
snapshots.push({ image: canvas.toDataURL(), visiblePixels, bluePixels, outerPixels, compositedBluePixels, minimumBodyAlpha, redSum });
|
|
}
|
|
return snapshots;
|
|
} finally {
|
|
machine.delete(); artboard.delete(); file.delete(); renderer.delete();
|
|
}
|
|
};
|
|
});`;
|
|
|
|
declare global {
|
|
interface Window {
|
|
ready: Promise<void>;
|
|
renderResource: (version: string, steps: { state: string; frames: number; hovered?: boolean; pointer?: [number, number] }[]) => Promise<{
|
|
image: string;
|
|
visiblePixels: number;
|
|
bluePixels: number;
|
|
outerPixels: number;
|
|
compositedBluePixels: number;
|
|
minimumBodyAlpha: number;
|
|
redSum: number;
|
|
}[]>;
|
|
}
|
|
}
|
|
|
|
let script: string;
|
|
let original: Buffer;
|
|
let recolored: Buffer;
|
|
let modified: Buffer;
|
|
let motionOnly: Buffer;
|
|
let wasm: Buffer;
|
|
|
|
test.beforeAll(async () => {
|
|
const source = JSON.parse(await readFile("design/agent/obsidian-runtime.json", "utf8"));
|
|
original = encodeSource(source);
|
|
recolored = buildMaterialPersona(source);
|
|
modified = await readFile("public/agent/obsidian-water-v1.riv");
|
|
expect(buildOfflinePersona(source)).toEqual(modified);
|
|
const offline = JSON.parse(await readFile("design/agent/obsidian-offline.json", "utf8"));
|
|
for (const layer of offline.layers) {
|
|
for (const light of layer.opacity) light.rest = light.crest = 1;
|
|
}
|
|
motionOnly = buildOfflinePersona(source, offline);
|
|
const require = createRequire(import.meta.url);
|
|
const riveRequire = createRequire(require.resolve("@rive-app/react-webgl2"));
|
|
wasm = await readFile(riveRequire.resolve("@rive-app/webgl2/rive.wasm"));
|
|
const result = await build({
|
|
stdin: { contents: harness, resolveDir: process.cwd() },
|
|
bundle: true, write: false, format: "iife"
|
|
});
|
|
script = result.outputFiles[0].text;
|
|
});
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.route("**/rive-probe/**", async (route) => {
|
|
const path = new URL(route.request().url()).pathname;
|
|
if (path.endsWith(".wasm")) return route.fulfill({ body: wasm, contentType: "application/wasm" });
|
|
if (path.endsWith(".riv")) return route.fulfill({
|
|
body: path.includes("original") ? original : path.includes("recolored") ? recolored : path.includes("motion-only") ? motionOnly : modified
|
|
});
|
|
if (path.endsWith(".js")) return route.fulfill({ body: script, contentType: "application/javascript" });
|
|
return route.fulfill({
|
|
contentType: "text/html",
|
|
body: '<canvas width="192" height="192"></canvas><script src="/rive-probe/harness.js"></script>'
|
|
});
|
|
});
|
|
await page.goto("/rive-probe/");
|
|
await page.evaluate(() => window.ready);
|
|
});
|
|
|
|
test("uses the blue palette while preserving the recolored original's online states pixel for pixel", async ({ page }) => {
|
|
for (const state of ["idle", "thinking", "listening", "speaking", "hover"]) {
|
|
const result = await page.evaluate(async (state) => {
|
|
const steps = [{ state: "idle", frames: 90 }, { state, frames: 180 }, { state, frames: 180 }];
|
|
return {
|
|
original: await window.renderResource("original", steps),
|
|
recolored: await window.renderResource("recolored", steps),
|
|
modified: await window.renderResource("modified", steps)
|
|
};
|
|
}, state);
|
|
expect(result.original[1].visiblePixels).toBeGreaterThan(12000);
|
|
expect(result.original[1].image).not.toBe(result.original[2].image);
|
|
expect(result.modified[1].image).not.toBe(result.original[1].image);
|
|
expect(result.modified[1].bluePixels).toBeGreaterThan(10000);
|
|
expect(result.modified[1].compositedBluePixels).toBeGreaterThan(6000);
|
|
expect(result.modified[1].minimumBodyAlpha).toBeGreaterThanOrEqual(230);
|
|
expect(result.modified, state).toEqual(result.recolored);
|
|
}
|
|
});
|
|
|
|
test("original-size hover reflections follow the pointer instead of whitening the fixed center", async ({ page }) => {
|
|
const result = await page.evaluate(async () => {
|
|
const pixelsOf = async (source: string) => {
|
|
const image = new Image();
|
|
image.src = source;
|
|
await image.decode();
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = canvas.height = 192;
|
|
const context = canvas.getContext("2d")!;
|
|
context.drawImage(image, 0, 0);
|
|
return context.getImageData(0, 0, 192, 192).data;
|
|
};
|
|
const steps = [
|
|
{ state: "idle", frames: 90 },
|
|
{ state: "hover", frames: 180, pointer: [24, 64] as [number, number] },
|
|
{ state: "hover", frames: 1, pointer: [104, 64] as [number, number] },
|
|
{ state: "idle", frames: 180 }
|
|
];
|
|
const original = await window.renderResource("recolored", steps);
|
|
const edited = await window.renderResource("modified", steps);
|
|
const idle = await window.renderResource("modified", steps.map(({ frames }) => ({ state: "idle", frames })));
|
|
const centers = [];
|
|
for (const index of [1, 2]) {
|
|
const base = await pixelsOf(idle[index].image);
|
|
const active = await pixelsOf(edited[index].image);
|
|
let weight = 0;
|
|
let weightedX = 0;
|
|
for (let i = 0; i < base.length; i += 4) {
|
|
const brightness = Math.max(0, active[i] - base[i]);
|
|
if (brightness <= 8) continue;
|
|
weight += brightness;
|
|
weightedX += ((i / 4) % 192 + 0.5) * brightness;
|
|
}
|
|
centers.push({ x: weightedX / weight, weight });
|
|
}
|
|
return { centers, original, edited, recovered: edited[3].image === idle[3].image };
|
|
});
|
|
expect(result.edited).toEqual(result.original);
|
|
expect(result.centers[0].weight).toBeGreaterThan(1000);
|
|
expect(result.centers[1].weight).toBeGreaterThan(1000);
|
|
expect(result.centers[0].x).toBeLessThan(84);
|
|
expect(result.centers[1].x).toBeGreaterThan(108);
|
|
expect(result.centers[1].x - result.centers[0].x).toBeGreaterThan(30);
|
|
expect(result.recovered).toBe(true);
|
|
});
|
|
|
|
test("the original reflection fades in over 150ms and fades out over 600ms without changing its material", async ({ page }, testInfo) => {
|
|
const result = await page.evaluate(async () => {
|
|
const steps = [
|
|
{ state: "idle", frames: 90 },
|
|
{ state: "hover", frames: 1, pointer: [64, 64] as [number, number] },
|
|
{ state: "hover", frames: 4 },
|
|
{ state: "hover", frames: 6 },
|
|
{ state: "hover", frames: 180 },
|
|
{ state: "idle", frames: 1 },
|
|
{ state: "idle", frames: 11 },
|
|
{ state: "idle", frames: 12 },
|
|
{ state: "idle", frames: 24 }
|
|
];
|
|
const edited = await window.renderResource("modified", steps);
|
|
const idle = await window.renderResource("modified", steps.map(({ frames }) => ({ state: "idle", frames })));
|
|
const original = await window.renderResource("recolored", steps);
|
|
return {
|
|
brightness: edited.map((frame, index) => frame.redSum - idle[index].redSum),
|
|
materialPreserved: edited[4].image === original[4].image,
|
|
restored: edited[8].image === idle[8].image,
|
|
images: edited.slice(1).map((frame) => frame.image)
|
|
};
|
|
});
|
|
await page.evaluate((images) => {
|
|
const labels = ["Enter 17ms", "Enter 83ms", "Enter 183ms", "Original reflection", "Exit 17ms", "Exit 200ms", "Exit 400ms", "Settled"];
|
|
document.body.replaceChildren();
|
|
document.body.style.cssText = "display:flex;flex-wrap:wrap;gap:16px;background:#f5f7fa";
|
|
for (const [index, source] of images.entries()) {
|
|
const figure = document.createElement("figure");
|
|
figure.style.margin = "0";
|
|
const image = new Image(192, 192);
|
|
image.src = source;
|
|
const caption = document.createElement("figcaption");
|
|
caption.textContent = labels[index];
|
|
figure.append(image, caption);
|
|
document.body.append(figure);
|
|
}
|
|
}, result.images);
|
|
const sequence = await page.screenshot({ path: "/tmp/nexttjwater-persona-timing.png" });
|
|
await testInfo.attach("original-reflection-timing", { body: sequence, contentType: "image/png" });
|
|
expect(result.brightness[1]).toBeLessThan(result.brightness[2]);
|
|
expect(result.brightness[2]).toBeLessThan(result.brightness[3]);
|
|
expect(result.brightness[3]).toBeGreaterThan(1000);
|
|
expect(result.materialPreserved).toBe(true);
|
|
expect(result.brightness[5]).toBeGreaterThan(result.brightness[6]);
|
|
expect(result.brightness[6]).toBeGreaterThan(result.brightness[7]);
|
|
expect(result.brightness[7]).toBeGreaterThan(0);
|
|
expect(result.restored).toBe(true);
|
|
});
|
|
|
|
test("the original disappears offline while the edited resource keeps moving and recovers", async ({ page }) => {
|
|
const result = await page.evaluate(async () => {
|
|
const steps = [
|
|
{ state: "idle", frames: 90 }, { state: "asleep", frames: 180 },
|
|
{ state: "asleep", frames: 180 }, { state: "idle", frames: 120 },
|
|
{ state: "asleep", frames: 180 }, { state: "idle", frames: 120 }
|
|
];
|
|
return {
|
|
original: await window.renderResource("original", steps),
|
|
modified: await window.renderResource("modified", steps),
|
|
initiallyOffline: await window.renderResource("modified", [{ state: "asleep", frames: 180 }])
|
|
};
|
|
});
|
|
expect(result.original[1].visiblePixels).toBe(0);
|
|
expect(result.original[2].visiblePixels).toBe(0);
|
|
for (const frame of [...result.modified, ...result.initiallyOffline]) {
|
|
expect(frame.visiblePixels).toBeGreaterThan(12000);
|
|
expect(frame.minimumBodyAlpha).toBeGreaterThanOrEqual(230);
|
|
}
|
|
expect(result.modified[1].image).not.toBe(result.modified[2].image);
|
|
});
|
|
|
|
test("offline light settles without hiding the sphere and fully restores after reconnecting", async ({ page }) => {
|
|
const result = await page.evaluate(async () => {
|
|
const steps = [
|
|
{ state: "idle", frames: 90 },
|
|
{ state: "asleep", frames: 180 },
|
|
{ state: "asleep", frames: 180 },
|
|
{ state: "idle", frames: 120 },
|
|
{ state: "asleep", frames: 8 },
|
|
{ state: "idle", frames: 8 },
|
|
{ state: "asleep", frames: 8 },
|
|
{ state: "idle", frames: 180 },
|
|
{ state: "thinking", frames: 180 },
|
|
{ state: "listening", frames: 180 },
|
|
{ state: "speaking", frames: 180 },
|
|
{ state: "hover", frames: 180 }
|
|
];
|
|
return {
|
|
control: await window.renderResource("motion-only", steps),
|
|
edited: await window.renderResource("modified", steps)
|
|
};
|
|
});
|
|
for (const snapshot of result.edited) expect(snapshot.visiblePixels).toBeGreaterThan(12000);
|
|
for (const index of [1, 2]) {
|
|
expect(result.edited[index].image).not.toBe(result.control[index].image);
|
|
expect(result.edited[index].outerPixels).toBe(0);
|
|
}
|
|
for (const index of [0, 3, 7, 8, 9, 10, 11]) {
|
|
expect(result.edited[index], `recovered frame ${index}`).toEqual(result.control[index]);
|
|
}
|
|
});
|
|
|
|
test("offline has no outer halo, including hover and cold start, and reconnecting restores it", async ({ page }) => {
|
|
const snapshots = await page.evaluate(() => window.renderResource("modified", [
|
|
{ state: "asleep", frames: 180 },
|
|
{ state: "asleep", frames: 180, hovered: true },
|
|
{ state: "asleep", frames: 180 },
|
|
{ state: "idle", frames: 120 }
|
|
]));
|
|
for (const frame of snapshots.slice(0, 3)) {
|
|
expect(frame.visiblePixels).toBeGreaterThan(12000);
|
|
expect(frame.bluePixels).toBeGreaterThan(10000);
|
|
expect(frame.compositedBluePixels).toBeGreaterThan(6000);
|
|
expect(frame.outerPixels).toBe(0);
|
|
}
|
|
expect(snapshots[0].image).not.toBe(snapshots[2].image);
|
|
expect(snapshots[3].outerPixels).toBeGreaterThan(100);
|
|
});
|