Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
7.8 kB · 203 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204/** * The `blue.lance.camo` record, and the editor settings that ride along on it. * * The declared fields — name, image, createdAt — come from the lexicon, which * is vendored in `lexicons/` and generated into `src/lexicons/`. Nothing here * restates them. * * `source` is the other half, and it is deliberately not in the schema. It is * what the editor needs to re-open a camo it made: which generator drew it, * with which parameters and which colours. Those meanings change whenever the * generator code changes — adding a pattern or fixing one moves what a seed * produces — so there is nothing here another implementation could rely on, * and the schema promises nothing about it. The image is the camo; this is a * convenience for the one client that wrote it. * * Two rules follow, and both are load-bearing: * * Anything unreadable is dropped rather than guessed at. A camo whose source * no longer parses still opens — as its picture, the way an imported file * does — because the picture is the part that is always true. * * An edit must put back the record it read, not a record rebuilt from the * fields this code knows about. The generated validators keep undeclared * fields through parse, build and a JSON round trip, so a future field written * by a later version of this editor survives being renamed by an older one. */
import type { Main as CamoMain } from "../lexicons/blue/lance/camo.js";import { GENERATORS } from "./patterns";import type { PatternParams } from "./patterns";import type { Palette, RGB } from "./palette";
/** * Bumped when the shape below changes in a way that makes an older reader * wrong. A reader that does not recognise the version drops the whole thing, * which is why every field can be added to but none can change meaning. * * 2 stores the sliders as thousandths rather than fractions. Version 1 could * never be written — the atproto data model has no float, so every record * carrying one failed to serialise — so no reader will ever meet one, and * this bump exists to make sure a v1 that somehow exists is dropped rather * than read as if 510 meant 0.51. */export const SOURCE_VERSION = 2;
/** * A slider position, as thousandths of its range: 0..1000 for a 0..1 control. * * The data model these end up in has integers and no float at all, which is * the whole reason for the unit. Thousandths because the sliders step in * hundredths and one spare digit costs nothing. */export type Thousandths = number;
/** How the editor drew a camo, enough to put the controls back where they were. */export type CamoSource = { v: typeof SOURCE_VERSION; /** A generator id from patterns.ts. Unknown here means a newer editor wrote it. */ generator: string; params: { /** Whole number already, and the one parameter that is not a fraction. */ seed: number; scale: Thousandths; contrast: Thousandths; detail: Thousandths; angle: Thousandths; }; palette: Palette;};
/** A 0..1 slider as thousandths, clamped so a stray value cannot travel. */function toThousandths(v: number): Thousandths { return Math.max(0, Math.min(1000, Math.round(v * 1000)));}
const fromThousandths = (v: Thousandths): number => v / 1000;
/** * A camo record as this client writes it: the lexicon's fields, plus the * editor's own. `source` is absent on a camo that came from an imported file, * which has no parameters to record. */export type CamoRecord = CamoMain & { source?: CamoSource };
/** A camo read back out of a repository, with everything the UI needs to list it. */export type StoredCamo = { uri: string; cid: string; rkey: string; name: string; createdAt: string; /** Public URL of the image, or null until the next listing reads its CID. */ imageUrl: string | null; /** The record as read. An edit sends only what changed, never this. */ record: CamoRecord; /** Null when the camo was imported, or when its source no longer reads. */ source: CamoSource | null; /** * Set on the value returned straight from a write, which is assembled here * rather than read back. It names a real record; it is just not a complete * copy of one, and the reload that follows replaces it. */ stale?: boolean;};
/** * A whole number, and nothing else. * * `Number.isInteger` is the check that matters rather than a range one: a * fraction anywhere in here cannot be written at all, because the data model * has no float, and a record that carries one is refused before it is sent. */const isWhole = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v);
const isSlider = (v: unknown): v is Thousandths => isWhole(v) && v >= 0 && v <= 1000;
function readParams(value: unknown): PatternParams | null { if (typeof value !== "object" || value === null) return null; const p = value as Record<string, unknown>; const { seed, scale, contrast, detail, angle } = p; if (!isWhole(seed)) return null; if (![scale, contrast, detail, angle].every(isSlider)) return null; return { seed, scale: fromThousandths(scale as Thousandths), contrast: fromThousandths(contrast as Thousandths), detail: fromThousandths(detail as Thousandths), angle: fromThousandths(angle as Thousandths), };}
function readColor(value: unknown): RGB | null { if (!Array.isArray(value) || value.length !== 3) return null; if (!value.every((c) => isWhole(c) && c >= 0 && c <= 255)) return null; return [value[0], value[1], value[2]] as RGB;}
function readPalette(value: unknown): Palette | null { if (typeof value !== "object" || value === null) return null; const p = value as Record<string, unknown>; if (typeof p.name !== "string" || !Array.isArray(p.colors)) return null; const colors: RGB[] = []; for (const raw of p.colors) { const color = readColor(raw); if (!color) return null; colors.push(color); } if (!colors.length) return null; return { name: p.name, colors, ...(p.flag === true ? { flag: true } : {}) };}
/** * The editor settings on a record, or null if there are none this editor can * act on. Null is a normal answer, not an error: an imported camo never had * any, and a camo drawn by a newer version may name a generator this build * does not have. */export function readSource(record: unknown): CamoSource | null { if (typeof record !== "object" || record === null) return null; const source = (record as Record<string, unknown>).source; if (typeof source !== "object" || source === null) return null; const s = source as Record<string, unknown>; if (s.v !== SOURCE_VERSION) return null; if (typeof s.generator !== "string") return null; if (!GENERATORS.some((g) => g.id === s.generator)) return null; const params = readParams(s.params); const palette = readPalette(s.palette); if (!params || !palette) return null; return { v: SOURCE_VERSION, generator: s.generator, params, palette };}
/** * The settings to write for a camo the editor drew, in the shape above. * * This is where fractions stop. Everything below is whole numbers, because * the record is serialised into a data model that has no float and refuses * the whole write when it meets one. */export function makeSource( generator: string, params: PatternParams, palette: Palette,): CamoSource { return { v: SOURCE_VERSION, generator, params: { seed: Math.trunc(params.seed), scale: toThousandths(params.scale), contrast: toThousandths(params.contrast), detail: toThousandths(params.detail), angle: toThousandths(params.angle), }, palette: { ...palette, colors: palette.colors.map( (c) => c.map((v) => Math.round(v)) as unknown as RGB, ), }, };}