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
23 changes: 12 additions & 11 deletions web/src/lib/lab/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[] };

Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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<TextLayer, ActiveTextEffect>();
const effects = new Map<TextLayer, { effect: ActiveTextEffect; source: TextSource }>();
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;
}
Expand Down
15 changes: 8 additions & 7 deletions web/src/lib/lab/hExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──

Expand Down Expand Up @@ -276,19 +276,20 @@ 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<number, { layer: TextLayer; effect: ActiveTextEffect }>();
const effects = new Map<number, { layer: TextLayer; effect: ActiveTextEffect; source: TextSource }>();
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.
for (let index = 0; index < stack.length; index++) {
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} — ${
Expand Down
11 changes: 4 additions & 7 deletions web/src/lib/lab/legacyDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -77,14 +77,11 @@ function oneOf<T extends string>(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<string, unknown>): MatrixSize | undefined {
const candidate = raw.matrix as Partial<MatrixSize> | undefined;
if (isValidMatrix(candidate)) {
return { width: Number(candidate!.width), height: Number(candidate!.height) };
}
const matrix = parseMatrixSize(raw.matrix as Partial<MatrixSize> | 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;
}
Expand Down
7 changes: 2 additions & 5 deletions web/src/lib/lab/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<MatrixSize> | undefined;
const matrix = isValidMatrix(matrixCandidate)
? { width: Number(matrixCandidate!.width), height: Number(matrixCandidate!.height) }
: DEFAULT_MATRIX;
const matrix = parseMatrixSize(raw.matrix as Partial<MatrixSize> | undefined) ?? DEFAULT_MATRIX;

const forkOf =
raw.forkOf && typeof raw.forkOf === "object"
Expand Down
50 changes: 49 additions & 1 deletion web/src/lib/lab/textEffects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -45,6 +47,7 @@ import {
renderTextEffect,
sourceColumnLimit,
textEffectPeriod,
textEffectSource,
twinkleHash,
} from "./textEffects";
import { LabEngine } from "./engine";
Expand Down Expand Up @@ -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", () => {
Expand Down
15 changes: 14 additions & 1 deletion web/src/lib/lab/textEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
19 changes: 13 additions & 6 deletions web/src/lib/pattern/matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
Expand Down Expand Up @@ -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<MatrixSize>;
expect(parseMatrixSize(saved)).toEqual({ width: 64, height: 128 });
expect(parseMatrixSize({ width: 127.6, height: 64 })).toEqual({ width: 128, height: 64 });
});
});

Expand Down
14 changes: 11 additions & 3 deletions web/src/lib/pattern/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MatrixSize> | 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<MatrixSize> | 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 {
Expand Down
Loading