266 lines
12 KiB
TypeScript
266 lines
12 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;
|
|
let minimumBodyAlpha = 255;
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
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 });
|
|
}
|
|
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;
|
|
}[]>;
|
|
}
|
|
}
|
|
|
|
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 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);
|
|
});
|