Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
// Smoke test for the Pattern Lab capture core (lib/lab/capture): geometry
// resolution, pixel-layer stretching, the two transparency models, wire
// merging, and a real multi-layer render at a non-panel size through the
// same engine the worker uses. Run: npx tsx scripts/lab-capture-smoke.ts
import {
CaptureCore,
clampScale,
mergeWireProject,
resolveGeometry,
stretchNearest,
unmultiply,
unpremultiply,
} from "../src/lib/lab/capture/core";
import { blockDetail, PROBE_LAYOUT_MAX, probeScaling } from "../src/lib/lab/capture/probe";
import {
buildFragmentSource,
checkShaderSource,
declaresPass,
remapShaderLog,
SHADER_PREAMBLE,
} from "../src/lib/lab/capture/shaderSource";
import { buildShaderPrompt } from "../src/lib/lab/capture/shaderPrompt";
import { SHADER_RAMP_SIZE, buildShaderRampLUT } from "../src/lib/lab/capture/shaderRamp";
import { rampStateToHarness } from "../src/lib/lab/engine";
import { sampleRampRGBA } from "../src/lib/pattern/harness";
import { DEFAULT_RAMP_STATE, type RampState } from "../src/lib/lab/types";
import { normalizeCaptureSettings } from "../src/lib/lab/capture/settings";
import { DEFAULT_CAPTURE_SETTINGS, type WireProject } from "../src/lib/lab/capture/types";
import { codeLayerFromSource } from "../src/lib/lab/store";
import { createPixelLayer, type CodeLayer, type Layer } from "../src/lib/lab/types";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
console.error(`FAIL: ${message}`);
process.exit(1);
}
}
const matrix = { width: 128, height: 64 };
// ── geometry ──
{
const native = resolveGeometry({ ...DEFAULT_CAPTURE_SETTINGS, style: "native", width: 1050, height: 600 }, matrix);
assert(native.render.width === 1050 && native.output.height === 600 && native.scale === 1, "native geometry");
const pixel = resolveGeometry({ ...DEFAULT_CAPTURE_SETTINGS, style: "pixel", scale: 8 }, matrix);
assert(pixel.render.width === 128 && pixel.output.width === 1024 && pixel.scale === 8, "pixel geometry");
const clamped = resolveGeometry({ ...DEFAULT_CAPTURE_SETTINGS, style: "led", scale: 64 }, matrix);
assert(clamped.output.width <= 4096 && clamped.scale === clampScale(64, matrix), "scale clamps to the side limit");
const huge = resolveGeometry({ ...DEFAULT_CAPTURE_SETTINGS, style: "native", width: 99999, height: 1 }, matrix);
assert(huge.output.width === 4096 && huge.output.height === 16, "native sides clamp");
const normalized = normalizeCaptureSettings({ style: "bogus", width: "x", rotation: 45, video: { fps: 17, seconds: 900 } });
assert(normalized.style === "auto" && normalized.rotation === 0 && normalized.video.fps === 30 && normalized.video.seconds === 60, "settings normalize");
// Turns: the size is the finished picture; the pattern runs in the frame that turns into it.
const portrait = resolveGeometry({ ...DEFAULT_CAPTURE_SETTINGS, style: "native", width: 1080, height: 1920, rotation: 90 }, matrix);
assert(portrait.render.width === 1920 && portrait.render.height === 1080 && portrait.output.width === 1080 && portrait.output.height === 1920, "90° native renders landscape into a portrait output");
const turnedPixel = resolveGeometry({ ...DEFAULT_CAPTURE_SETTINGS, style: "pixel", scale: 8, rotation: 270 }, matrix);
assert(turnedPixel.render.width === 128 && turnedPixel.box.width === 1024 && turnedPixel.output.width === 512 && turnedPixel.output.height === 1024, "270° pixel output is the turned box");
// Auto fallback: cover fit, centred, cropping the overflow.
const autoNative = resolveGeometry({ ...DEFAULT_CAPTURE_SETTINGS, style: "auto", width: 1050, height: 600 }, matrix, "native");
const autoPixel = resolveGeometry({ ...DEFAULT_CAPTURE_SETTINGS, style: "auto", width: 1050, height: 600 }, matrix, "pixel");
assert(autoNative.look === "native" && autoNative.render.width === 1050, "auto → native re-renders at the output size");
assert(autoPixel.look === "pixel" && autoPixel.render.width === 128 && autoPixel.output.width === 1050, "auto → pixel keeps the output size, renders the matrix");
assert(Math.abs(autoPixel.scale - 9.375) < 1e-9, `cover scale: ${autoPixel.scale}`);
assert(Math.abs(autoPixel.offsetX + 75) < 1e-9 && autoPixel.offsetY === 0, `cover offsets: ${autoPixel.offsetX}, ${autoPixel.offsetY}`);
// "smooth" was a look until 2026-08 — stored settings carrying it open as auto.
const legacy = normalizeCaptureSettings({ ...DEFAULT_CAPTURE_SETTINGS, style: "smooth" });
assert(legacy.style === "auto", "legacy smooth style falls back to auto");
}
// ── scaling probe ──
{
const ranges: Array<[number, number]> = [[0, 1], [0, 1], [0, 1], [0, 1]];
const project = (code: string) => {
const { layer } = codeLayerFromSource(code, "L");
return { matrix, layers: [layer], activeLayerId: layer.id, knobs: [0.5, 0.5, 0.5, 0.5], ranges };
};
// Frame-relative: a diagonal gradient plus a centred disc in frame units.
const safe = probeScaling(project(`export function draw(display, params, time) {
const w = display.width, h = display.height;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const u = x / w, v = y / h;
const dx = (u - 0.5) * (w / h), dy = v - 0.5;
const disc = dx * dx + dy * dy < 0.08 ? 1 : 0;
display.setValue(x, y, 0.6 * ((u + v) / 2) + 0.4 * disc);
}
}`));
assert(safe.verdict === "native", `frame-relative pattern scales: ${safe.reason} ${JSON.stringify(safe.metrics)}`);
// Pixel-unit stripes: re-rendered bigger they come back N× as dense.
const stripes = probeScaling(project(`export function draw(display, params, time) {
for (let y = 0; y < display.height; y++) for (let x = 0; x < display.width; x++)
display.setValue(x, y, 0.5 + 0.5 * Math.sin(x * 0.3 + time));
}`));
assert(stripes.verdict === "upscale" && stripes.reason === "frame-baked", `pixel-unit stripes are baked: ${stripes.reason} ${JSON.stringify(stripes.metrics)}`);
// Literal bounds: paints only the old 128×64 corner of a bigger frame.
const corner = probeScaling(project(`export function draw(display, params, time) {
for (let y = 0; y < 64; y++) for (let x = 0; x < 128; x++)
display.setValue(x, y, (x + y) % 16 < 8 ? 0.9 : 0.2);
}`));
assert(corner.verdict === "upscale", `corner-only pattern is baked: ${corner.reason} ${JSON.stringify(corner.metrics)}`);
// A 128×64 buffer indexed with a literal stride: reads garbage at any other size.
const stride = probeScaling(project(`export function setup(params) { params.buf = new Float32Array(128 * 64); }
export function update(dt, input, params) { for (let i = 0; i < params.buf.length; i++) params.buf[i] = (i % 128) / 128; }
export function draw(display, params, time) {
for (let y = 0; y < display.height; y++) for (let x = 0; x < display.width; x++) {
const v = params.buf[y * 128 + x];
display.setValue(x, y, v === undefined ? 0 : v);
}
}`));
assert(stride.verdict === "upscale", `literal-stride buffer is baked: ${stride.reason} ${JSON.stringify(stride.metrics)}`);
// Too dark to tell falls back rather than guessing.
const dark = probeScaling(project(`export function draw(display, params, time) {
for (let y = 0; y < display.height; y++) for (let x = 0; x < display.width; x++) display.setValue(x, y, 0.01);
}`));
assert(dark.verdict === "upscale" && dark.reason === "too-dark", `dark pattern: ${dark.reason}`);
// The blind spot the "no-detail" verdict closes: this one re-renders happily
// at any size and draws the SAME picture in bigger blocks, because it samples
// a fixed internal field. Layout and density score it perfect — a block
// upscale box-filters back to exactly the original — so only the detail
// metric catches it. (This is the shape an AI "any size" rewrite of a
// simulation pattern comes back in.)
const upsampler = probeScaling(project(`export function setup(params) {
params.W = 32; params.H = 16;
params.field = new Float32Array(params.W * params.H);
for (let y = 0; y < params.H; y++) for (let x = 0; x < params.W; x++)
params.field[y * params.W + x] = ((x * 7 + y * 13) % 11) / 10;
}
export function draw(display, params, time) {
for (let y = 0; y < display.height; y++) {
const sy = Math.min(params.H - 1, (y * params.H / display.height) | 0);
for (let x = 0; x < display.width; x++) {
const sx = Math.min(params.W - 1, (x * params.W / display.width) | 0);
display.setValue(x, y, params.field[sy * params.W + sx]);
}
}
}`));
assert(
upsampler.verdict === "upscale" && upsampler.reason === "no-detail",
`self-upscaling pattern: ${upsampler.reason} ${JSON.stringify(upsampler.metrics)}`,
);
assert(
upsampler.metrics.layout < PROBE_LAYOUT_MAX && upsampler.metrics.density > 0.55,
"and it got there past a perfect layout/density score",
);
assert(safe.metrics.detail > 0.2, `a real re-render carries detail: ${safe.metrics.detail}`);
}
// ── block detail ──
{
const grid = (width: number, height: number, fill: (x: number, y: number) => number) => {
const data = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const value = fill(x, y);
const index = (y * width + x) * 4;
data[index] = value;
data[index + 1] = value;
data[index + 2] = value;
data[index + 3] = 255;
}
}
return data;
};
// A 4× nearest blow-up: every 4×4 cell is one colour.
const blown = grid(32, 32, (x, y) => (((x >> 2) + (y >> 2)) % 2) * 200);
assert(blockDetail(blown, 32, 32, 4) === 0, "an exact block upscale has no detail");
// The same picture actually re-rendered finer.
const fine = grid(32, 32, (x, y) => ((x + y) % 2) * 200);
assert(blockDetail(fine, 32, 32, 4) === 1, "a per-pixel picture is all detail");
assert(blockDetail(fine, 32, 32, 1) === 1, "factor 1 cannot judge and says so");
}
// ── stretch ──
{
const src = new Uint8ClampedArray(2 * 2 * 4);
// (0,0) red, (1,0) green, (0,1) blue, (1,1) white
src.set([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255]);
const out = stretchNearest(src, 2, 2, 6, 3);
const px = (x: number, y: number) => Array.from(out.slice((y * 6 + x) * 4, (y * 6 + x) * 4 + 4));
assert(px(0, 0)[0] === 255 && px(2, 0)[0] === 255, "left column stays red");
assert(px(3, 0)[1] === 255 && px(5, 0)[1] === 255, "right column green");
assert(px(0, 2)[2] === 255, "bottom-left blue");
assert(px(5, 2)[0] === 255 && px(5, 2)[1] === 255, "bottom-right white");
}
// ── transparency models ──
{
const opaque = new Uint8ClampedArray([50, 0, 0, 255, 0, 0, 0, 255, 255, 128, 0, 255]);
const out = new Uint8ClampedArray(12);
unmultiply(opaque, out, 3);
assert(out[0] === 255 && out[3] === 50, "dim red → full red at alpha 50");
assert(out[7] === 0, "black → clear");
assert(out[8] === 255 && out[9] === 128 && out[11] === 255, "bright stays, alpha 255");
const coverage = new Uint8ClampedArray([128, 0, 255]);
const premul = new Uint8ClampedArray([64, 64, 64, 255, 0, 0, 0, 255, 255, 0, 0, 255]);
const straight = new Uint8ClampedArray(12);
unpremultiply(premul, coverage, straight, 3);
assert(straight[0] === 128 && straight[3] === 128, "half-covered grey → straight 128 @ 128");
assert(straight[7] === 0, "uncovered → clear");
assert(straight[8] === 255 && straight[11] === 255, "full coverage unchanged");
}
// ── wire merge ──
{
const art = createPixelLayer(matrix, "Art");
art.data[0] = 255;
art.data[3] = 255;
const first: WireProject = { matrix, layers: [art], activeLayerId: art.id, knobs: [0, 0, 0, 0], ranges: [[0, 1], [0, 1], [0, 1], [0, 1]] };
const merged = mergeWireProject(null, first);
assert(merged.layers.length === 1, "first merge keeps the layer");
const { data: _omitted, ...withoutData } = art;
void _omitted;
const second: WireProject = { ...first, layers: [{ ...withoutData, opacity: 0.5 }] };
const merged2 = mergeWireProject(merged, second);
const layer = merged2.layers[0];
assert(layer.type === "pixel" && layer.data === art.data && layer.opacity === 0.5, "rev-unchanged pixel layer keeps its buffer, takes new flags");
const third: WireProject = { ...first, layers: [{ ...withoutData, id: "unknown" }] };
assert(mergeWireProject(merged2, third).layers.length === 0, "a buffer-less unknown layer is dropped, not blanked");
}
// ── real render through the engine at an off-panel size ──
{
const base = codeLayerFromSource(
`export function draw(display, params, time) {
for (let y = 0; y < display.height; y++)
for (let x = 0; x < display.width; x++)
display.setPixel(x, y, 0, 60, 120);
}`,
"Base",
).layer;
// Pixel art: opaque red dot at matrix (10,10); must land stretched.
const art = createPixelLayer(matrix, "Art");
const dot = (10 * 128 + 10) * 4;
art.data[dot] = 255;
art.data[dot + 3] = 255;
art.rev = 1;
const top: CodeLayer = codeLayerFromSource(
`export function draw(display, params, time) {
for (let y = 0; y < display.height; y++)
for (let x = 0; x < display.width; x++)
display.setValue(x, y, x / display.width);
}`,
"Field",
).layer;
top.ramp = {
stops: [
{ position: 0, color: "#000000", alpha: 0 },
{ position: 1, color: "#ffffff", alpha: 1 },
],
mode: "linear",
wrap: false,
};
const layers: Layer[] = [top, art, base];
const project = { matrix, layers, activeLayerId: top.id, knobs: [0.5, 0.5, 0.5, 0.5], ranges: [[0, 1], [0, 1], [0, 1], [0, 1]] as Array<[number, number]> };
const core = new CaptureCore({ ...DEFAULT_CAPTURE_SETTINGS, style: "native", width: 512, height: 256 });
core.setProject(project);
const frame = core.step(1 / 30);
assert(frame && frame.geometry.render.width === 512 && frame.opaque.length === 512 * 256 * 4, "native frame at 512×256");
assert(Object.keys(frame.errors).length === 0, `no layer errors: ${JSON.stringify(frame.errors)}`);
const at = (buffer: Uint8ClampedArray, x: number, y: number) => Array.from(buffer.slice((y * 512 + x) * 4, (y * 512 + x) * 4 + 4));
// Far left: field is transparent → base blue shows.
const left = at(frame.opaque, 1, 128);
assert(left[2] > 100 && left[0] < 20, `left shows the base: ${left}`);
// Far right: field is opaque white.
const right = at(frame.opaque, 510, 128);
assert(right[0] > 240 && right[1] > 240, `right is white: ${right}`);
// The stretched dot: matrix (10,10) → output x 40..43, y 40..43.
const dotPx = at(frame.opaque, 41, 41);
assert(dotPx[0] > 200, `pixel art dot stretched into place: ${dotPx}`);
const offDot = at(frame.opaque, 45, 41);
assert(offDot[0] < 50, `next to the dot is not red: ${offDot}`);
// Time advances and state persists across steps.
const again = core.step(1 / 30);
assert(again && Math.abs(again.time - 2 / 30) < 1e-9, "clock advanced by two steps");
// Unpainted transparency: base covers everything, so alpha is 255 everywhere.
core.setSettings({ ...core.settings, backdrop: "transparent", cutout: "unpainted" });
const covered = core.step(0);
assert(covered && covered.rgba !== covered.opaque && covered.rgba[3] === 255, "coverage alpha is full under an opaque base");
// Hide the base: the left edge is now uncovered (field alpha 0, art elsewhere).
core.setProject({ ...project, layers: [top, art, { ...base, visible: false }] });
const holed = core.step(0);
const leftA = at(holed!.rgba, 1, 128)[3];
const rightA = at(holed!.rgba, 510, 128)[3];
// (x=510 samples the ramp at 0.996 → alpha 254; the edge is not a bug.)
assert(leftA < 8 && rightA >= 254, `unpainted cutout: left alpha ${leftA}, right alpha ${rightA}`);
// Dark-to-clear: with the base back, the left (blue 0,60,120) keys to alpha 120.
core.setProject(project);
core.setSettings({ ...core.settings, cutout: "dark" });
const keyed = core.step(0);
const keyedLeft = at(keyed!.rgba, 1, 128);
assert(keyedLeft[3] === 120 && keyedLeft[2] === 255, `unmult: ${keyedLeft}`);
// Pixel style renders at the matrix and reports the blow-up.
core.setSettings({ ...core.settings, style: "pixel", scale: 4, backdrop: "black" });
const blocks = core.step(0);
assert(blocks && blocks.geometry.render.width === 128 && blocks.geometry.output.width === 512 && blocks.rgba === blocks.opaque, "pixel style geometry");
// Restart clears the clock.
core.reset();
assert(core.time === 0, "reset zeroes time");
const fresh = core.step(0);
assert(fresh && fresh.time === 0, "fresh frame at t=0");
}
// ── shader twin: the half that runs without a GPU ──
{
const image = "void mainImage(out vec4 fragColor, in vec2 fragCoord) { fragColor = vec4(1.0); }";
const ok = checkShaderSource(image);
assert(ok.ok && !ok.hasState, "a bare image shader passes with no state pass");
const withState = `${image}
void mainState(out vec4 stateOut, in vec2 fragCoord) { stateOut = stateAt(fragCoord); }`;
const both = checkShaderSource(withState);
assert(both.ok && both.hasState, "a feedback shader declares both passes");
assert(declaresPass(withState, "state") && declaresPass(image, "image"), "entry detection");
// A mention in a comment is not a declaration.
assert(
!declaresPass(`// void mainState(out vec4 s, in vec2 f) — not implemented
${image}`, "state"),
"a commented-out entry does not count",
);
assert(!checkShaderSource("").ok, "an empty shader is refused");
assert(!checkShaderSource("void main() {}").ok, "a shader with no mainImage is refused");
const versioned = checkShaderSource(`#version 300 es
${image}`);
assert(!versioned.ok && versioned.error.includes("#version"), "the version line is the stage's job");
const declared = checkShaderSource(`out vec4 fragColor;
${image}`);
assert(!declared.ok && declared.error.includes("out vec4"), "the output is the stage's job");
const built = buildFragmentSource(withState, "state");
assert(built.startsWith("#version 300 es"), "version first");
assert(built.includes(SHADER_PREAMBLE), "preamble intact");
assert(built.includes(withState), "the user's source goes in verbatim");
assert(built.includes("mainState(pfOut, gl_FragCoord.xy);"), "the state pass calls mainState");
assert(
buildFragmentSource(withState, "image").includes("mainImage(pfOut, gl_FragCoord.xy);"),
"the image pass calls mainImage",
);
// #line 1 after the preamble means driver line numbers are the user's own.
assert(built.includes("#line 1"), "user line numbering is restored");
assert(
remapShaderLog("ERROR: 0:7: 'x' : undeclared identifier") === "ERROR: line 7: 'x' : undeclared identifier",
"a fault in the user's code is reported at the line they wrote",
);
// Drivers pad their logs with NULs; the text goes straight into the panel.
assert(!remapShaderLog("ERROR: 0:7: bad" + String.fromCharCode(0)).includes(String.fromCharCode(0)), "control characters are stripped");
assert(
remapShaderLog("ERROR: 0:100003: syntax error").startsWith("ERROR: wrapper:"),
"a fault in the wrapper is not reported as line 100003",
);
// The prompt carries the contract, the knob ranges and the ramp.
const prompt = buildShaderPrompt(
"export function draw(display, params, time) { display.setValue(0, 0, 1); }",
matrix,
DEFAULT_RAMP_STATE,
[[0.04, 0.059], [0, 1], [0, 1], [0, 1]],
);
assert(prompt.includes("void mainImage(out vec4 fragColor, in vec2 fragCoord)"), "prompt states the entry point");
assert(prompt.includes("0.04") && prompt.includes("0.059"), "prompt carries the real knob range");
assert(prompt.includes("#000000") && prompt.includes("#ffffff"), "prompt describes the ramp");
assert(prompt.includes("ramp(v)") && prompt.includes("DO NOT bake"), "prompt sends colour through the live ramp");
assert(prompt.includes("uState"), "prompt documents the feedback texture");
}
// ── the ramp as a texture ──
{
assert(SHADER_PREAMBLE.includes("uniform sampler2D uRamp"), "the ramp uniform is in the contract");
assert(SHADER_PREAMBLE.includes("vec4 ramp(float v)"), "so is the helper");
// The helper must land on texel centres, or ramp(0) and ramp(1) read short.
assert(
SHADER_PREAMBLE.includes(`${SHADER_RAMP_SIZE - 1}.0 + 0.5) / ${SHADER_RAMP_SIZE}.0`),
"the helper maps 0..1 onto texel centres",
);
const ramp: RampState = {
stops: [
{ position: 0, color: "#000000", alpha: 1 },
{ position: 0.4, color: "#ff5500", alpha: 0.5 },
{ position: 1, color: "#00ccff", alpha: 1 },
],
mode: "oklab",
wrap: false,
};
const lut = buildShaderRampLUT(ramp);
assert(lut.length === SHADER_RAMP_SIZE * 4, "one RGBA row");
// Every entry is the runtime's own sampler, so the twin cannot drift from
// the panel: same evaluator, same modes, same alpha, only more finely cut.
const harness = rampStateToHarness(ramp);
for (const index of [0, 1, 137, SHADER_RAMP_SIZE >> 1, SHADER_RAMP_SIZE - 1]) {
const [r, g, b, a] = sampleRampRGBA(harness, index / (SHADER_RAMP_SIZE - 1));
const got = [lut[index * 4], lut[index * 4 + 1], lut[index * 4 + 2], lut[index * 4 + 3]];
const want = [Math.round(r), Math.round(g), Math.round(b), Math.round(a * 255)];
assert(
got.every((value, channel) => value === want[channel]),
`ramp LUT entry ${index}: ${got} vs ${want}`,
);
}
const midStop = Math.round(0.4 * (SHADER_RAMP_SIZE - 1));
assert(
lut[3] === 255 && Math.abs(lut[midStop * 4 + 3] - 128) <= 1,
`alpha stops travel: ends ${lut[3]}, 0.4 stop ${lut[midStop * 4 + 3]}`,
);
// A ramp with no stops must still be readable rather than transparent black.
const empty = buildShaderRampLUT({ stops: [], mode: "linear", wrap: false });
assert(empty[0] === 255 && empty[3] === 255, "an empty ramp reads white, not garbage");
}
console.log("lab-capture-smoke: OK");