Seeded image crops
@slashclone/seeded-image-crops
Give every card, profile, and page header its own cover from just a few hero images: each item gets a stable crop (a hand-picked framing, nudged per item), the same every time it loads. One small file shared by every card, so a page of cards downloads the images once. Includes a checker that previews how evenly your items spread across the crops.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Checks a list of crops: how many distinct covers a set of seeds gets, how evenly they
- 3// spread over the framings, and that the same seed always gets the same crop. Node
- 4// built-ins only; the picker matches examples/seeded-crop.ts.
- 5// Usage: node scripts/check-crops.mjs --crops examples/crops.json [--seeds 200]
- 6// [--out out/crop-report.json]
- 7import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
- 8import { dirname } from "node:path";
- 9import { parseArgs } from "node:util";
- 11const { values } = parseArgs({
- 12 options: {
- 13 crops: { type: "string" },
- 14 seeds: { type: "string", default: "200" },
- 15 out: { type: "string" },
- 16 },
- 17});
- 18if (!values.crops) {
- 19 console.error("Missing --crops: a JSON array of { x, y, zoom }.");
- 20 process.exit(2);
- 21}
- 22const crops = JSON.parse(readFileSync(values.crops, "utf8"));
- 23const valid = (c) => c.x >= 0 && c.x <= 100 && c.y >= 0 && c.y <= 100 && c.zoom >= 1;
- 24if (!Array.isArray(crops) || crops.length === 0 || !crops.every(valid)) {
- 25 console.error("Each crop needs x and y from 0 to 100 and a zoom of at least 1.");
- 26 process.exit(1);
- 27}
- 29function hash(seed) {
- 30 let h = 2166136261;
- 31 for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619);
- 32 h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
- 33 h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
- 34 return (h ^ (h >>> 16)) >>> 0;
- 35}
- 36function cropFor(seed) {
- 37 const h = hash(seed);
- 38 const base = crops[h % crops.length];
- 39 const nudge = (bits) => ((h >>> bits) & 255) / 255 - 0.5;
- 40 const clamp = (n) => Math.min(100, Math.max(0, Math.round(n)));
- 41 return {
- 42 framing: h % crops.length,
- 43 x: clamp(base.x + nudge(8) * 10),
- 44 y: clamp(base.y + nudge(16) * 10),
- 45 zoom: Math.max(1, Math.round(base.zoom * (1 + nudge(24) * 0.25) * 100) / 100),
- 46 };
- 47}
- 49const n = Math.max(1, Number(values.seeds));
- 50const seeds = Array.from({ length: n }, (_, i) => `item-${String(i)}`);
- 51const picked = seeds.map(cropFor);
- 52const stable = seeds.every((s, i) => JSON.stringify(cropFor(s)) === JSON.stringify(picked[i]));
- 53const distinct = new Set(picked.map((c) => `${c.x},${c.y},${c.zoom}`)).size;
- 54const perFraming = crops.map((_, i) => picked.filter((c) => c.framing === i).length);
- 55const report = { framings: crops.length, seeds: n, distinct, stable, perFraming };
- 57console.log(
- 58 `${String(n)} seeds -> ${String(distinct)} distinct covers over ${String(crops.length)} framings; per framing: ${perFraming.join(", ")}; stable: ${String(stable)}`,
- 59);
- 60if (values.out) {
- 61 mkdirSync(dirname(values.out), { recursive: true });
- 62 writeFileSync(values.out, JSON.stringify(report, null, 2) + "\n");
- 63}

