Skip to content
SlashClone

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.

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