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/**
- 2 * A stable crop of a few hero images for anything with a key (a card, a profile, a page).
- 3 * Copy into your project and replace CROPS with framings you picked by eye on your images.
- 4 */
- 6/** Where a crop is centered (percent of the frame) and how far it zooms in (1 = cover). */
- 7export interface Crop {
- 8 x: number;
- 9 y: number;
- 10 zoom: number;
- 11}
- 13/** Framings that hold up at card size. Pick each one by looking at the image. */
- 14export const CROPS: readonly Crop[] = [
- 15 { x: 70, y: 35, zoom: 1.8 },
- 16 { x: 12, y: 62, zoom: 2 },
- 17 { x: 38, y: 30, zoom: 2.4 },
- 18 { x: 50, y: 80, zoom: 1.6 },
- 19];
- 21/** FNV-1a, then a finisher so every bit depends on every character. */
- 22function hash(seed: string): number {
- 23 let h = 2166136261;
- 24 for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619);
- 25 h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
- 26 h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
- 27 return (h ^ (h >>> 16)) >>> 0;
- 28}
- 30/** One framing for the seed, nudged a little so neighbors that share it still differ. */
- 31export function cropFor(seed: string, crops: readonly Crop[] = CROPS): Crop {
- 32 const h = hash(seed);
- 33 const base = crops[h % crops.length] ?? { x: 50, y: 50, zoom: 1 };
- 34 const nudge = (bits: number) => ((h >>> bits) & 255) / 255 - 0.5;
- 35 const clamp = (n: number) => Math.min(100, Math.max(0, Math.round(n)));
- 36 return {
- 37 x: clamp(base.x + nudge(8) * 10),
- 38 y: clamp(base.y + nudge(16) * 10),
- 39 zoom: Math.max(1, Math.round(base.zoom * (1 + nudge(24) * 0.25) * 100) / 100),
- 40 };
- 41}
- 43/** Inline styles for an <img> with object-fit: cover inside an overflow-hidden box. */
- 44export function cropStyle(c: Crop): Record<string, string | undefined> {
- 45 return {
- 46 objectPosition: `${String(c.x)}% ${String(c.y)}%`,
- 47 transformOrigin: `${String(c.x)}% ${String(c.y)}%`,
- 48 transform: c.zoom === 1 ? undefined : `scale(${String(c.zoom)})`,
- 49 };
- 50}

