diff --git a/web/src/lib/lab/flatten.ts b/web/src/lib/lab/flatten.ts index 3a8634e50..30d63ea02 100644 --- a/web/src/lib/lab/flatten.ts +++ b/web/src/lib/lab/flatten.ts @@ -27,7 +27,7 @@ import { KNOBS_ANNOTATION_RE, buildKnobsAnnotationLine, defaultKnobState } from import { exportStack } from "./compositor"; import { rampStateToHarness } from "./engine"; import { bytesToBase64 } from "./serialize"; -import { TEXT_EFFECT_RATE, activeTextEffect, textEffectPeriod, type ActiveTextEffect } from "./textEffects"; +import { TEXT_EFFECT_RATE, activeTextEffect, textEffectPeriod, textEffectSource, type ActiveTextEffect } from "./textEffects"; /** * Run-length encode straight RGBA bytes as {count-1, r, g, b, a} tuples @@ -68,7 +68,7 @@ function pixelBufferLiteral(data: Uint8ClampedArray, length = "__W * __H * 4"): ? `__rle("${bytesToBase64(rle)}", ${length})` : `__b64("${bytesToBase64(data)}", ${length})`; } -import { isRasterLayer, type CodeLayer, type KnobRange, type Layer, type RampLayer, type TextLayer } from "./types"; +import { isRasterLayer, type CodeLayer, type KnobRange, type Layer, type RampLayer, type TextLayer, type TextSource } from "./types"; type FlattenKnobs = { labels: string[]; ranges: KnobRange[] }; @@ -102,8 +102,7 @@ function lutLiteral(layer: RampLayer): string { * The fx object of a moving text layer. Scroll and ramp read the source, * twinkle the bitmap; ramp brings its ramp as a LUT. */ -function textFxLiteral(layer: TextLayer, effect: ActiveTextEffect): string { - const source = effect === "twinkle" ? { x: 0, width: layer.width, data: layer.data } : layer.source!; +function textFxLiteral(layer: TextLayer, effect: ActiveTextEffect, source: TextSource): string { return `{ kind: "${effect}", knob: ${layer.spec.effectKnob}, rate: ${TEXT_EFFECT_RATE[effect]}, period: ${textEffectPeriod(effect, layer)}, phase: 0, srcX: ${source.x}, srcW: ${source.width}, lead: ${layer.width - source.x}, @@ -286,23 +285,25 @@ function __mkDisplay(L) { // Text layers that move: each carries what it moves and a phase the knob // speeds up. The helpers are textEffects.ts step for step. - const effects = new Map(); + const effects = new Map(); for (const layer of stack) { - const effect = layer.type === "text" ? activeTextEffect(layer) : null; - if (layer.type === "text" && effect) effects.set(layer, effect); + if (layer.type !== "text") continue; + const effect = activeTextEffect(layer); + const source = effect ? textEffectSource(layer, effect) : null; + if (effect && source) effects.set(layer, { effect, source }); } if (effects.size > 0) parts.push(TEXT_FX_HELPERS); for (const layer of stack) { const roleProps = `role: "${layer.role}", maskInvert: ${layer.maskInvert}`; - const effect = layer.type === "text" ? effects.get(layer) : undefined; - if (layer.type === "text" && effect) { + const moving = layer.type === "text" ? effects.get(layer) : undefined; + if (layer.type === "text" && moving) { parts.push(` -// ── text layer, ${effect} on knob ${layer.spec.effectKnob + 1}: ${commentSafe(layer.name)} ── +// ── text layer, ${moving.effect} on knob ${layer.spec.effectKnob + 1}: ${commentSafe(layer.name)} ── __stack.push({ kind: "pixel", opacity: ${layer.opacity}, blend: "${layer.blend}", ${roleProps}, buf: new Uint8ClampedArray(__W * __H * 4), - fx: ${textFxLiteral(layer, effect)}, + fx: ${textFxLiteral(layer, moving.effect, moving.source)}, });`); continue; } diff --git a/web/src/lib/lab/hExport.ts b/web/src/lib/lab/hExport.ts index 80eaf929d..d9f448145 100644 --- a/web/src/lib/lab/hExport.ts +++ b/web/src/lib/lab/hExport.ts @@ -33,8 +33,8 @@ import { codeUsesValueField } from "@/lib/pattern/ramp"; import { KNOBS_ANNOTATION_RE } from "./annotations"; import { exportStack } from "./compositor"; import { rampStateToHarness } from "./engine"; -import { TEXT_EFFECT_RATE, activeTextEffect, textEffectPeriod, type ActiveTextEffect } from "./textEffects"; -import { isRasterLayer, type CodeLayer, type KnobRange, type Layer, type RampLayer, type TextLayer } from "./types"; +import { TEXT_EFFECT_RATE, activeTextEffect, textEffectPeriod, textEffectSource, type ActiveTextEffect } from "./textEffects"; +import { isRasterLayer, type CodeLayer, type KnobRange, type Layer, type RampLayer, type TextLayer, type TextSource } from "./types"; // ── helpers ── @@ -276,10 +276,12 @@ static const uint8_t MASK_INVERT[${maskCount}] = {${maskFlat.map((m) => (m.inver // Text layers that move: what each one moves, its phase, and for a ramp // its LUT. The steps are textEffects.ts, written in C++ below. - const effects = new Map(); + const effects = new Map(); stack.forEach((layer, index) => { - const effect = layer.type === "text" ? activeTextEffect(layer) : null; - if (layer.type === "text" && effect) effects.set(index, { layer, effect }); + if (layer.type !== "text") return; + const effect = activeTextEffect(layer); + const source = effect ? textEffectSource(layer, effect) : null; + if (effect && source) effects.set(index, { layer, effect, source }); }); // Per-code-layer ramp LUTs + paint helpers; pixel layers as RLE. @@ -287,8 +289,7 @@ static const uint8_t MASK_INVERT[${maskCount}] = {${maskFlat.map((m) => (m.inver const layer = stack[index]; const moving = effects.get(index); if (moving) { - const { layer: text, effect } = moving; - const source = effect === "twinkle" ? { x: 0, width: text.width, data: text.data } : text.source!; + const { layer: text, effect, source } = moving; const rle = rleEncode(source.data); parts.push(` // ── L${index} "${text.name.replace(/\n/g, " ")}": text, ${effect} on knob ${text.spec.effectKnob + 1} — ${ diff --git a/web/src/lib/lab/legacyDraft.ts b/web/src/lib/lab/legacyDraft.ts index fa9509a6c..e0e259ecd 100644 --- a/web/src/lib/lab/legacyDraft.ts +++ b/web/src/lib/lab/legacyDraft.ts @@ -14,7 +14,7 @@ import { COLOR_MODES } from "@/lib/ai/settings"; import type { ColorMode } from "@/lib/ai/settings"; -import { isValidMatrix, type MatrixSize } from "@/lib/pattern/matrix"; +import { parseMatrixSize, type MatrixSize } from "@/lib/pattern/matrix"; import { LAB_STORAGE, readJson, removeStorage as remove } from "./persist"; @@ -77,14 +77,11 @@ function oneOf(value: unknown, allowed: readonly T[], fallback // Accepts the current `{width, height}` shape and the older `resPreset: "128x64"` // string, so a draft written before the frame rework still restores. function readMatrix(raw: Record): MatrixSize | undefined { - const candidate = raw.matrix as Partial | undefined; - if (isValidMatrix(candidate)) { - return { width: Number(candidate!.width), height: Number(candidate!.height) }; - } + const matrix = parseMatrixSize(raw.matrix as Partial | undefined); + if (matrix) return matrix; if (typeof raw.resPreset === "string") { const [w, h] = raw.resPreset.split("x"); - const legacy = { width: Number(w), height: Number(h) }; - if (isValidMatrix(legacy)) return legacy; + return parseMatrixSize({ width: Number(w), height: Number(h) }) ?? undefined; } return undefined; } diff --git a/web/src/lib/lab/serialize.ts b/web/src/lib/lab/serialize.ts index 06909aecb..c605be977 100644 --- a/web/src/lib/lab/serialize.ts +++ b/web/src/lib/lab/serialize.ts @@ -13,7 +13,7 @@ import { RAMP_MODES, type RampMode } from "@/lib/pattern/harness"; import { COLOR_MODES } from "@/lib/ai/settings"; import type { ColorMode } from "@/lib/ai/settings"; -import { isValidMatrix, DEFAULT_MATRIX, type MatrixSize } from "@/lib/pattern/matrix"; +import { parseMatrixSize, DEFAULT_MATRIX, type MatrixSize } from "@/lib/pattern/matrix"; import { loadDraft } from "@/lib/lab/legacyDraft"; import { PATTERN_KNOB_COUNT } from "@/lib/pattern/harness"; import { @@ -444,10 +444,7 @@ export function deserializeProject(json: string): (LabProject & { savedAt: numbe .filter((layer): layer is Layer => layer !== null); if (layers.length === 0) return null; - const matrixCandidate = raw.matrix as Partial | undefined; - const matrix = isValidMatrix(matrixCandidate) - ? { width: Number(matrixCandidate!.width), height: Number(matrixCandidate!.height) } - : DEFAULT_MATRIX; + const matrix = parseMatrixSize(raw.matrix as Partial | undefined) ?? DEFAULT_MATRIX; const forkOf = raw.forkOf && typeof raw.forkOf === "object" diff --git a/web/src/lib/lab/textEffects.test.ts b/web/src/lib/lab/textEffects.test.ts index b1a6c3152..df7d0c498 100644 --- a/web/src/lib/lab/textEffects.test.ts +++ b/web/src/lib/lab/textEffects.test.ts @@ -34,8 +34,10 @@ vi.mock("./textRaster", () => { }; }); -import { buildRampLUTRGBA } from "@/lib/pattern/harness"; +import { PatternRuntime, buildRampLUTRGBA, createIdleInput } from "@/lib/pattern/harness"; import { textCoverage } from "./textRaster"; +import { flattenLayers } from "./flatten"; +import { buildHExport } from "./hExport"; import { drawTextLayer, paintText } from "./text"; import { TEXT_EFFECT_RATE, @@ -45,6 +47,7 @@ import { renderTextEffect, sourceColumnLimit, textEffectPeriod, + textEffectSource, twinkleHash, } from "./textEffects"; import { LabEngine } from "./engine"; @@ -179,6 +182,51 @@ describe("drawing a moving text", () => { const shown = render(new LabEngine(), [layer], [0, 1, 0, 0], 0.5); expect(shownColumns(shown, 16)).toEqual(litColumns(layer.data, 16)); }); + + it("twinkle moves the bitmap, scroll and ramp only a source that fits", () => { + const twinkle = textAt({ text: "HEY", effect: "twinkle" }); + expect(textEffectSource(twinkle, "twinkle")).toEqual({ x: 0, width: 16, data: twinkle.data }); + const scroll = textAt({ text: "PATTERNFLOW!", effect: "scroll" }); + expect(textEffectSource(scroll, "scroll")).toBe(scroll.source); + expect(textEffectSource({ ...scroll, source: null }, "scroll")).toBeNull(); + expect(textEffectSource({ ...scroll, source: { ...scroll.source!, width: 23 } }, "scroll")).toBeNull(); + }); + + it("a frame asked of a scroll with no source is left as it was", () => { + const layer = { ...textAt({ text: "HEY", effect: "scroll" }), source: null }; + const out = new Uint8ClampedArray(layer.data); + renderTextEffect(layer, "scroll", 5, null, out); + expect(Array.from(out)).toEqual(Array.from(layer.data)); + }); +}); + +describe("a moving text with no source, exported", () => { + // A save from a build that did not keep the source: activeTextEffect names + // no effect, and both exports carry the letters as the Lab shows them. + it.each(["scroll", "ramp"] as const)("%s: the flattened JS and the header carry the bitmap, standing still", (effect) => { + const layer = { ...textAt({ text: "HEY", effect }), source: null }; + + const code = flattenLayers([layer], frame); + expect(code).not.toContain("function __fxDraw"); + const runtime = new PatternRuntime(frame.width, frame.height); + expect(runtime.loadCode(code).error).toBeUndefined(); + const input = createIdleInput([0, 0, 0, 0], { knobValues: [1, 1, 1, 1], knobNormalized: [1, 1, 1, 1] }); + for (let f = 0; f < 3; f++) { + expect(runtime.renderFrame(0.5, f * 0.5, input).error).toBeUndefined(); + expect(shownColumns(runtime.data, 16)).toEqual(litColumns(layer.data, 16)); + } + + const { scaffold } = buildHExport({ + name: "NoSource", + matrix: frame, + layers: [layer], + knobs: [1, 1, 1, 1], + ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + knobLabels: ["A", "B", "C", "D"], + }); + expect(scaffold).toContain('"Text": text as drawn'); + expect(scaffold).not.toContain("_SRC_W"); + }); }); describe("moving text in the engine", () => { diff --git a/web/src/lib/lab/textEffects.ts b/web/src/lib/lab/textEffects.ts index 8a382975e..d36d84ce8 100644 --- a/web/src/lib/lab/textEffects.ts +++ b/web/src/lib/lab/textEffects.ts @@ -98,6 +98,17 @@ export function activeTextEffect(layer: TextLayer): ActiveTextEffect | null { return sourceFits(layer.source, layer.height) ? effect : null; } +/** + * What the effect draws each frame from: twinkle the bitmap as it stands, + * scroll and ramp the source. Null when that source does not fit, and then + * activeTextEffect is null too. flatten.ts and hExport.ts write this out + * for the exports. + */ +export function textEffectSource(layer: TextLayer, effect: ActiveTextEffect): TextSource | null { + if (effect === "twinkle") return { x: 0, width: layer.width, data: layer.data }; + return sourceFits(layer.source, layer.height) ? layer.source : null; +} + /** How many steps the phase counts before it starts again. */ export function textEffectPeriod(effect: ActiveTextEffect, layer: TextLayer): number { if (effect === "twinkle") return TWINKLE_PERIOD; @@ -168,7 +179,9 @@ export function renderTextEffect( return; } - const source = layer.source!; + // activeTextEffect names scroll or ramp only for a source that fits. + const source = layer.source; + if (!sourceFits(source, height)) return; const src = source.data; const srcWidth = source.width; diff --git a/web/src/lib/pattern/matrix.test.ts b/web/src/lib/pattern/matrix.test.ts index 4ad0ac85e..e7ac21bd7 100644 --- a/web/src/lib/pattern/matrix.test.ts +++ b/web/src/lib/pattern/matrix.test.ts @@ -12,13 +12,14 @@ import { describeMatrixShape, formatMatrix, isStockPanelFrame, - isValidMatrix, matchMatrixAnnotation, matrixFit, matrixFromCode, parseMatrixAnnotation, + parseMatrixSize, stripMatrixAnnotation, withMatrixAnnotation, + type MatrixSize, } from "./matrix"; const DRAW = "function draw(display) {\n display.clear();\n}"; @@ -83,11 +84,17 @@ describe("sizes typed by hand", () => { expect(clampMatrixDimension(Number.NaN)).toBeNull(); }); - it("isValidMatrix wants both sides inside the range", () => { - expect(isValidMatrix({ width: 128, height: 64 })).toBe(true); - expect(isValidMatrix({ width: 4, height: 64 })).toBe(false); - expect(isValidMatrix({ width: 128 })).toBe(false); - expect(isValidMatrix(null)).toBe(false); + it("parseMatrixSize wants both sides inside the range", () => { + expect(parseMatrixSize({ width: 128, height: 64 })).toEqual({ width: 128, height: 64 }); + expect(parseMatrixSize({ width: 4, height: 64 })).toBeNull(); + expect(parseMatrixSize({ width: 128 })).toBeNull(); + expect(parseMatrixSize(null)).toBeNull(); + }); + + it("parseMatrixSize reads a side saved as text and rounds one between whole numbers", () => { + const saved = JSON.parse('{"width":"64","height":"128"}') as Partial; + expect(parseMatrixSize(saved)).toEqual({ width: 64, height: 128 }); + expect(parseMatrixSize({ width: 127.6, height: 64 })).toEqual({ width: 128, height: 64 }); }); }); diff --git a/web/src/lib/pattern/matrix.ts b/web/src/lib/pattern/matrix.ts index 4ecc7ce43..d22cbbab7 100644 --- a/web/src/lib/pattern/matrix.ts +++ b/web/src/lib/pattern/matrix.ts @@ -76,9 +76,17 @@ export function clampMatrixDimension(value: number): number | null { */ export const MATRIX_HEAVY_PIXELS = DEFAULT_MATRIX.width * DEFAULT_MATRIX.height * 4; -export function isValidMatrix(size: Partial | null | undefined): boolean { - if (!size) return false; - return clampDimension(Number(size.width)) !== null && clampDimension(Number(size.height)) !== null; +/** + * The frame a stored size names, or null when a side is out of range. A side + * saved as "128" reads as 128. One between two whole numbers rounds, as a + * typed one does. + */ +export function parseMatrixSize(size: Partial | null | undefined): MatrixSize | null { + if (!size) return null; + const width = clampDimension(Number(size.width)); + const height = clampDimension(Number(size.height)); + if (width === null || height === null) return null; + return { width, height }; } export function parseMatrixAnnotation(code: string): MatrixSize | null {