Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions web/src/app/pattern-lab/HardwareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
cleanPastedUnit,
enforcePatternName,
} from "@/lib/lab/hExport";
import { downloadBlob } from "@/lib/lab/capture/download";
import { buildCppPrompt } from "@/lib/lab/cppPrompt";
import { promptKey, readPastes, writePastes } from "@/lib/lab/hardwarePastes";
import { currentPerformanceJson } from "@/lib/lab/director/publish";
Expand Down Expand Up @@ -197,17 +198,11 @@ export default function HardwareModal({
const copyLabel = (key: string, idle: string) =>
copied?.key === key ? (copied.ok ? "Copied ✓" : "Copy failed") : idle;

const downloadH = () => {
const blob = new Blob([assembled], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${
exportData.namespaceName.replace(/^LabStack_/, "").toLowerCase() || "pattern"
}.h`;
anchor.click();
URL.revokeObjectURL(url);
};
const downloadH = () =>
downloadBlob(
new Blob([assembled], { type: "text/plain" }),
`${exportData.namespaceName.replace(/^LabStack_/, "").toLowerCase() || "pattern"}.h`,
);

const label = lone?.name ?? name;

Expand Down
6 changes: 1 addition & 5 deletions web/src/app/pattern-lab/panels/KnobsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { labEngine } from "@/lib/lab/engine";
import { captureSession } from "@/lib/lab/capture/session";
import { useLabStore } from "@/lib/lab/store";
import { knobArrowValue } from "@/lib/pattern/controls";
import { knobArrowValue, roundRangeValue } from "@/lib/pattern/controls";
import local from "./KnobsPanel.module.css";
import dock from "../LabPanels.module.css";

Expand Down Expand Up @@ -39,10 +39,6 @@ function formatRangeControlValue(value: number) {
return value.toFixed(3);
}

function roundRangeValue(value: number) {
return Math.round(value * 1000) / 1000;
}

function getDigitStep(text: string, index: number) {
const char = text[index];
if (!char || char === "-" || char === ".") return null;
Expand Down
10 changes: 1 addition & 9 deletions web/src/app/pattern-lab/panels/director/showFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,12 @@
// blank title falls back to it, so the file downloads as <pattern>.pfs.

import { encodePfst, pfsFilename, serializePerformance, validatePerformance } from "@/lib/pattern/pfst";
import { downloadBlob } from "@/lib/lab/capture/download";
import { bakeShowV2, showFromPerformance } from "@/lib/lab/director/bake";
import { midiFilename, showToMidi } from "@/lib/lab/director/exporters/midi";
import type { DirectorShow } from "@/lib/lab/director/types";
import { readPerformanceFile } from "../../community";

function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}

const named = (director: DirectorShow, patternName: string) =>
director.title.trim() ? director : { ...director, title: patternName };

Expand Down
6 changes: 1 addition & 5 deletions web/src/app/pattern-lab/panels/director/useShowEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// panel's two keys (Delete, Space).

import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { resolveLane, resolvedLaneValue } from "@/lib/lab/director/bake";
import { resolveLane, resolvedLaneValue, sortedLane } from "@/lib/lab/director/bake";
import {
DEFAULT_CURVE_CP,
DIRECTOR_MAX_SECONDS,
Expand All @@ -29,10 +29,6 @@ export function interpOf(k: DirectorKeyframe): Interp {
return k.h === "auto" ? "auto" : "manual";
}

function sortedLane(lane: DirectorKeyframe[]): DirectorKeyframe[] {
return [...lane].sort((a, b) => a.t - b.t);
}

export function useShowEditing({
director,
updateDirector,
Expand Down
12 changes: 2 additions & 10 deletions web/src/lib/lab/capture/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import type { MatrixSize } from "@/lib/pattern/matrix";
import { LabEngine } from "../engine";
import { fnv1a } from "../hash";
import type { CaptureProject } from "./types";
import { stretchNearest } from "./core";
import { isRasterLayer, type Layer } from "../types";
Expand Down Expand Up @@ -348,22 +349,13 @@ export function probeKey(project: CaptureProject): string {
if (!layer.visible) continue;
parts.push(
layer.type === "code"
? `c:${layer.id}:${layer.role}:${layer.maskInvert}:${layer.opacity > 0}:${layer.recolor}:${layer.code.length}:${hash(layer.code)}`
? `c:${layer.id}:${layer.role}:${layer.maskInvert}:${layer.opacity > 0}:${layer.recolor}:${layer.code.length}:${fnv1a(layer.code)}`
: `p:${layer.id}:${layer.role}:${layer.maskInvert}:${layer.opacity > 0}:${layer.rev}`,
);
}
return parts.join("|");
}

function hash(text: string): number {
let h = 2166136261;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}

export function describeProbe(result: ProbeResult): string {
switch (result.reason) {
case "scales":
Expand Down
12 changes: 2 additions & 10 deletions web/src/lib/lab/capture/shaderStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
// Bounded on purpose: the newest few, a couple of hundred KB. A shader lost to
// pruning is one re-paste; a full quota breaks saving settings too.

import { fnv1a } from "../hash";
import { LAB_STORAGE, readJson, writeJson } from "../persist";

const STORAGE_KEY = LAB_STORAGE.shader;
Expand All @@ -26,17 +27,8 @@ const MAX_TOTAL_CHARS = 200_000;
type Entry = { source: string; at: number };
type Store = Record<string, Entry>;

function hash(text: string): number {
let h = 2166136261;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}

function keysFor(layerId: string, code: string): string[] {
return [`id:${layerId}`, `code:${hash(code)}`];
return [`id:${layerId}`, `code:${fnv1a(code)}`];
}

function read(): Store {
Expand Down
6 changes: 1 addition & 5 deletions web/src/lib/lab/cppPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,12 @@ import {
PATTERN_MATRIX_WIDTH,
buildRampLUTRGBA,
} from "@/lib/pattern/harness";
import { knobDetentStep } from "@/lib/pattern/controls";
import { knobDetentStep, roundRangeValue } from "@/lib/pattern/controls";
import { withMatrixAnnotation, type MatrixSize } from "@/lib/pattern/matrix";
import { codeUsesValueField } from "@/lib/pattern/ramp";
import type { KnobRange, RampState } from "./types";
import { rampStateToHarness } from "./engine";

function roundRangeValue(value: number) {
return Math.round(value * 1000) / 1000;
}

type CppPromptArgs = {
code: string;
matrix: MatrixSize;
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/lab/director/bake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function cubicBezierY(
return sampleY(t);
}

function sortedLane(lane: DirectorKeyframe[]): DirectorKeyframe[] {
export function sortedLane(lane: DirectorKeyframe[]): DirectorKeyframe[] {
return [...lane].sort((a, b) => a.t - b.t);
}

Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/lab/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function stripForEmbedding(code: string): string {
}

/** A layer name inside a `//` comment: every line terminator JS knows, not just \n. */
function commentSafe(name: string): string {
export function commentSafe(name: string): string {
return name.replace(/[\r\n\u2028\u2029]/g, " ");
}

Expand Down
9 changes: 3 additions & 6 deletions web/src/lib/lab/hardwarePastes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
// record would wipe each other's pastes. A tab's record ends with the tab,
// and nothing has to prune it.

import { fnv1a } from "./hash";

const KEY = "patternflow_lab_hardware_pastes_v1";

/**
Expand All @@ -24,12 +26,7 @@ const KEY = "patternflow_lab_hardware_pastes_v1";
*/
export function promptKey(prompt: string): string {
const asked = prompt.replace(/, current [^,\n]*/g, "");
let h = 2166136261;
for (let i = 0; i < asked.length; i++) {
h ^= asked.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return `${asked.length}:${(h >>> 0).toString(36)}`;
return `${asked.length}:${fnv1a(asked).toString(36)}`;
}

/** Every paste this tab kept, by the key of the prompt it answers. */
Expand Down
41 changes: 41 additions & 0 deletions web/src/lib/lab/hash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Until 25 October 2026 the Lab wrote its FNV-1a hash out three times
// (capture/probe.ts, capture/shaderStore.ts, hardwarePastes.ts). Two of the
// copies name what a browser keeps: the paste a tab files under its prompt,
// and the shader filed under its pattern's source. Moving the hash to one
// place must not move either key, or the next reload finds nothing there.

import { beforeEach, describe, expect, it } from "vitest";
import { loadShaderSource, saveShaderSource } from "./capture/shaderStore";
import { promptKey } from "./hardwarePastes";
import { fnv1a } from "./hash";
import { LAB_STORAGE } from "./persist";

const PROMPT = [
"- Knob 1: min 0, max 1, current 0.5, calibrated encoder step 0.01 per detent",
"- Knob 2: min 0, max 4, current 2, calibrated encoder step 0.083 per detent",
].join("\n");

describe("keys the Lab files under a hash", () => {
beforeEach(() => window.localStorage.clear());

it("is 32-bit FNV-1a", () => {
expect(fnv1a("")).toBe(0x811c9dc5);
expect(fnv1a("a")).toBe(0xe40c292c);
expect(fnv1a("foobar")).toBe(0xbf9cf968);
});

it("files a To hardware paste under the key it had", () => {
expect(promptKey(PROMPT)).toBe("128:1q359yv");
// Turning a knob is no reason to ask again, so the position stays out.
expect(promptKey(PROMPT.replace("current 0.5", "current 0.75"))).toBe("128:1q359yv");
});

it("files a shader under the source hash it had", () => {
const code = "function render(ctx) { return 0; }";
saveShaderSource("layer-a", code, "void main() {}");
const saved = JSON.parse(window.localStorage.getItem(LAB_STORAGE.shader) ?? "{}");
expect(Object.keys(saved).sort()).toEqual(["code:968204348", "id:layer-a"]);
// A reload mints new layer ids, and the hash finds the shader again.
expect(loadShaderSource("layer-b", code)).toBe("void main() {}");
});
});
14 changes: 14 additions & 0 deletions web/src/lib/lab/hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// ── Short hash of a string ───────────────────────────────────────────────────
// 32-bit FNV-1a over UTF-16 code units. Two of its callers file things in the
// browser under it: a To hardware paste (hardwarePastes.ts) and a shader twin
// (capture/shaderStore.ts). A change here orphans both, so hash.test.ts pins
// their keys.

export function fnv1a(text: string): number {
let h = 2166136261;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
7 changes: 1 addition & 6 deletions web/src/lib/lab/pixelToCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// canvas with a small drawing in it shrinks from ~44 KB to a few KB.

import { withMatrixAnnotation } from "@/lib/pattern/matrix";
import { rleEncodeRGBA } from "./flatten";
import { commentSafe, rleEncodeRGBA } from "./flatten";
import { bytesToBase64 } from "./serialize";
import { activeTextEffect } from "./textEffects";
import { fontLabel } from "./textFonts";
Expand Down Expand Up @@ -122,11 +122,6 @@ function asciiPreview(sprite: SpriteCrop): string[] {
return rows;
}

/** A layer name inside a `//` comment: no line terminators. */
function commentSafe(name: string): string {
return name.replace(/[\r\n\u2028\u2029]/g, " ");
}

const B64_DECODER = `function decodeB64(s, n) {
const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const out = new Uint8ClampedArray(n);
Expand Down
8 changes: 8 additions & 0 deletions web/src/lib/pattern/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ export function tidyKnobValue(value: number) {
return Number(value.toPrecision(12));
}

/**
* Three decimals, the grain of the Lab's knob range fields: an edge typed or
* dragged in the Knobs panel, and the detent step the C++ prompt quotes.
*/
export function roundRangeValue(value: number) {
return Math.round(value * 1000) / 1000;
}

/** The middle of a knob's range, where a card with no saved knob state starts. */
export function knobMidpoint(range: readonly [number, number]) {
return tidyKnobValue((range[0] + range[1]) / 2);
Expand Down
Loading