~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
187 lines · 7.3 KB Raw History
// The collage layout that backs the gallery OG image. It is a pure function
// over a seeded PRNG, which is the whole point: an OG image is fetched by
// crawlers repeatedly and cached by URL, so the same gallery has to lay out
// identically every time. Randomness that is not reproducible would show up as
// a card that changes shape between fetches.
//
// These are invariants rather than golden values — a specific set of
// coordinates would only pin the current PRNG arithmetic, which is not the
// contract. Determinism, no overlaps, staying inside the frame and keeping each
// photo's shape are.

import { describe, expect, test } from "vitest";
import { calculateCollageLayout, type ImageItem, type Placement } from "../server/og/collage.ts";

const W = 1200;
const H = 630;
const GAP = 12;

const item = (url: string, aspectRatio: number): ImageItem => ({ url, aspectRatio });

/** A typical gallery: a mix of landscape, portrait and square. */
const MIXED: ImageItem[] = [
  item("a", 3 / 2),
  item("b", 2 / 3),
  item("c", 1),
  item("d", 16 / 9),
  item("e", 4 / 5),
  item("f", 1.5),
];

function overlaps(a: Placement, b: Placement): boolean {
  return !(
    a.x >= b.x + b.width ||
    a.x + a.width <= b.x ||
    a.y >= b.y + b.height ||
    a.y + a.height <= b.y
  );
}

function eachPair(ps: Placement[], fn: (a: Placement, b: Placement) => void) {
  for (let i = 0; i < ps.length; i++) {
    for (let j = i + 1; j < ps.length; j++) fn(ps[i], ps[j]);
  }
}

describe("calculateCollageLayout", () => {
  test("lays the same gallery out identically every time", async () => {
    const once = calculateCollageLayout(MIXED, W, H, GAP);
    const twice = calculateCollageLayout(MIXED, W, H, GAP);
    expect(twice).toEqual(once);

    // And across a fresh import, so nothing is being cached in module state.
    const { calculateCollageLayout: reimported } = await import("../server/og/collage.ts?fresh=1");
    expect(reimported(MIXED, W, H, GAP)).toEqual(once);
  });

  test("lays a different gallery out differently", () => {
    const a = calculateCollageLayout(MIXED, W, H, GAP);
    const b = calculateCollageLayout([...MIXED, item("g", 1.2)], W, H, GAP);
    expect(b).not.toEqual(a);
  });

  test("keys the seed on the photos, not on their order of arrival", () => {
    // The seed comes from url+ratio in the given order, so a reordered gallery
    // is a different seed — reordering a gallery is meant to change the card.
    const reordered = [...MIXED].reverse();
    expect(calculateCollageLayout(reordered, W, H, GAP)).not.toEqual(
      calculateCollageLayout(MIXED, W, H, GAP),
    );
  });

  test("never overlaps two photos", () => {
    for (const n of [2, 3, 4, 6]) {
      const placements = calculateCollageLayout(MIXED.slice(0, n), W, H, GAP);
      eachPair(placements, (a, b) => {
        expect(
          overlaps(a, b),
          `${a.item.url} at ${a.x},${a.y} ${a.width}x${a.height} overlaps ` +
            `${b.item.url} at ${b.x},${b.y} ${b.width}x${b.height}`,
        ).toBe(false);
      });
    }
  });

  test("keeps every photo inside the frame", () => {
    const placements = calculateCollageLayout(MIXED, W, H, GAP);
    for (const p of placements) {
      expect(p.x).toBeGreaterThanOrEqual(0);
      expect(p.y).toBeGreaterThanOrEqual(0);
      // Rounding can cost a pixel; the reserved gap absorbs it.
      expect(p.x + p.width).toBeLessThanOrEqual(W);
      expect(p.y + p.height).toBeLessThanOrEqual(H);
    }
  });

  test("keeps each photo's shape", () => {
    const placements = calculateCollageLayout(MIXED, W, H, GAP);
    expect(placements.length).toBeGreaterThan(0);
    for (const p of placements) {
      expect(p.width / p.height).toBeCloseTo(p.item.aspectRatio, 1);
    }
  });

  test("keeps every photo between the minimum and maximum size", () => {
    const placements = calculateCollageLayout(MIXED, W, H, GAP);
    const shortest = Math.min(W, H);
    for (const p of placements) {
      // 8% is the floor the reduced-size fallback will go down to; 75% the cap.
      expect(Math.min(p.width, p.height)).toBeGreaterThanOrEqual(shortest * 0.08 - 1);
      expect(Math.max(p.width, p.height)).toBeLessThanOrEqual(shortest * 0.75 + 1);
    }
  });

  test("places a single photo", () => {
    const placements = calculateCollageLayout([item("solo", 3 / 2)], W, H, GAP);
    expect(placements).toHaveLength(1);
    expect(placements[0].item.url).toBe("solo");
    expect(placements[0].width).toBeGreaterThan(0);
  });

  test("fits a single photo to the frame at its own shape, centred", () => {
    // One photo is not a collage: no random size, no crop. Whichever side runs
    // out first pins the photo to the frame, and the slack on the other side is
    // split evenly so the photo sits in the middle.
    const wide = calculateCollageLayout([item("wide", 3)], W, H, GAP)[0];
    expect(wide.width).toBe(W);
    expect(wide.height).toBe(Math.round(W / 3));
    expect(wide.x).toBe(0);
    expect(wide.y).toBe(Math.round((H - wide.height) / 2));

    const tall = calculateCollageLayout([item("tall", 2 / 3)], W, H, GAP)[0];
    expect(tall.height).toBe(H);
    expect(tall.width).toBe(Math.round(H * (2 / 3)));
    expect(tall.y).toBe(0);
    expect(tall.x).toBe(Math.round((W - tall.width) / 2));

    // A 3:2 photo is taller than this frame allows, so height decides.
    const landscape = calculateCollageLayout([item("solo", 3 / 2)], W, H, GAP)[0];
    expect(landscape.height).toBe(H);

    for (const p of [wide, tall, landscape]) {
      expect(p.width / p.height).toBeCloseTo(p.item.aspectRatio, 2);
      expect(p.x).toBeGreaterThanOrEqual(0);
      expect(p.y).toBeGreaterThanOrEqual(0);
      expect(p.x + p.width).toBeLessThanOrEqual(W);
      expect(p.y + p.height).toBeLessThanOrEqual(H);
    }
  });

  test("returns nothing for no photos", () => {
    expect(calculateCollageLayout([], W, H, GAP)).toEqual([]);
  });

  test("crowds without overlapping, dropping what will not fit", () => {
    // Twenty photos will not all fit at these sizes. Whatever is placed still
    // has to be laid out cleanly — the fallback shrinks and grid-searches
    // rather than stacking things on top of each other.
    const many = Array.from({ length: 20 }, (_, i) => item(`p${i}`, 1 + (i % 5) * 0.25));
    const placements = calculateCollageLayout(many, W, H, GAP);

    expect(placements.length).toBeGreaterThan(0);
    expect(placements.length).toBeLessThanOrEqual(many.length);
    eachPair(placements, (a, b) => expect(overlaps(a, b)).toBe(false));
    for (const p of placements) {
      expect(p.x).toBeGreaterThanOrEqual(0);
      expect(p.x + p.width).toBeLessThanOrEqual(W);
      expect(p.y + p.height).toBeLessThanOrEqual(H);
    }
  });

  test("lays out into a portrait frame as well as a landscape one", () => {
    const placements = calculateCollageLayout(MIXED, 630, 1200, GAP);
    expect(placements.length).toBeGreaterThan(0);
    eachPair(placements, (a, b) => expect(overlaps(a, b)).toBe(false));
    for (const p of placements) {
      expect(p.x + p.width).toBeLessThanOrEqual(630);
      expect(p.y + p.height).toBeLessThanOrEqual(1200);
    }
  });

  test("honours a wider gap by spacing photos further apart", () => {
    const tight = calculateCollageLayout(MIXED, W, H, 4);
    const loose = calculateCollageLayout(MIXED, W, H, 60);
    expect(loose).not.toEqual(tight);
    eachPair(loose, (a, b) => expect(overlaps(a, b)).toBe(false));
  });
});