diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 1d1cd56e..86d7af7a 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -77,6 +77,12 @@ import { type AttachmentItem, type AttachmentRejection, useAttachments, + GeneratedImages, + generatedImageViewerItems, + MediaViewer, + useMediaViewer, + type ConversationImage, + type PartImage, } from "@corbits/react-ui"; import { CommandPalette, @@ -1457,6 +1463,79 @@ function AttachmentsScenario() { ); } +/** A flat swatch as an inline SVG, so nothing is fetched. */ +function swatch(index: number): ConversationImage { + const shade = 40 + index * 20; + const svg = ``; + return { + id: `swatch-${index}`, + mediaType: "image/svg+xml", + width: 300, + height: 200, + alt: `Swatch ${index}`, + url: `data:image/svg+xml,${encodeURIComponent(svg)}`, + }; +} + +const SWATCHES: PartImage = { + kind: "image", + id: "swatches", + prompt: "Three swatches", + state: "done", + images: [swatch(1), swatch(2), swatch(3)], +}; + +/** Starts generating, so landing the images is watched and reveals them. */ +function MediaViewerScenario() { + const [landed, setLanded] = useState(false); + const part: PartImage = landed + ? SWATCHES + : { + ...SWATCHES, + state: "generating", + images: [], + expected: { count: 3, width: 300, height: 200 }, + }; + const viewer = useMediaViewer({ items: generatedImageViewerItems(part) }); + return ( +
+ + + +
+ ); +} + +const REMOTE: PartImage = { + kind: "image", + id: "remote", + prompt: "Remote swatches", + state: "done", + images: [1, 2].map((index) => ({ + id: `remote-${index}`, + mediaType: "image/svg+xml", + width: 300, + height: 200, + alt: `Remote ${index}`, + url: `https://images.example.test/remote-${index}.svg`, + })), +}; + +/** Images from another origin inside a scrolling log, on a page that scrolls. */ +function MediaViewerChatScenario() { + const viewer = useMediaViewer({ items: generatedImageViewerItems(REMOTE) }); + return ( +
+
+
+ +
+
+ +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -1527,4 +1606,6 @@ export const scenarios: Record ReactNode> = { ), attachments: () => , + "media-viewer": () => , + "media-viewer-chat": () => , }; diff --git a/e2e/media-viewer.spec.ts b/e2e/media-viewer.spec.ts new file mode 100644 index 00000000..ebdf7b62 --- /dev/null +++ b/e2e/media-viewer.spec.ts @@ -0,0 +1,179 @@ +import { expect, test, type Page } from "@playwright/test"; + +const grain = (page: Page) => + page.locator("[data-slot='generated-image-grain']"); + +async function land(page: Page) { + await page.goto("/media-viewer"); + await expect( + page.locator("[data-slot='generated-image-placeholder']"), + ).toHaveCount(3); + await expect(page.getByRole("status")).toHaveText("Generating 3 images"); + await page.getByRole("button", { name: "Land images" }).click(); +} + +test("images made while watched are revealed by the grain", async ({ + page, +}) => { + await land(page); + await expect(grain(page)).toHaveCount(3); + await expect(page.getByRole("status")).toHaveText("Made 3 images"); + // The reveal is one 700ms pass per tile, cascading 90ms apart. + await expect(grain(page)).toHaveCount(0, { timeout: 3_000 }); + const tiles = page.locator("[data-slot='generated-image']"); + await expect(tiles).toHaveCount(3); + for (const tile of await tiles.all()) { + await expect(tile).toHaveAttribute("data-state", "loaded"); + } +}); + +test("with reduced motion, landed images skip the grain", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await land(page); + await expect( + page.locator("[data-slot='generated-image'][data-state='loaded']"), + ).toHaveCount(3); + await expect(grain(page)).toHaveCount(0); +}); + +test("arrow keys move between items, Escape closes, focus returns to the tile", async ({ + page, +}) => { + await land(page); + await expect(grain(page)).toHaveCount(0, { timeout: 3_000 }); + const tile = page.getByRole("button", { + name: "Open Swatch 2", + exact: true, + }); + await tile.click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toHaveAccessibleName("three-swatches-2.svg"); + await expect(page.getByRole("button", { name: "Close" })).toBeFocused(); + await expect(dialog.getByRole("img")).toHaveAttribute("alt", "Swatch 2"); + + await page.keyboard.press("ArrowRight"); + await expect(dialog).toHaveAccessibleName("three-swatches-3.svg"); + await expect(dialog.getByRole("status")).toHaveText( + "three-swatches-3.svg, 3 of 3", + ); + // Stepping stops at the ends rather than wrapping. + await page.keyboard.press("ArrowRight"); + await expect(dialog).toHaveAccessibleName("three-swatches-3.svg"); + await expect(dialog.getByRole("button", { name: "Next" })).toHaveAttribute( + "aria-disabled", + "true", + ); + + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowLeft"); + await expect(dialog).toHaveAccessibleName("three-swatches-1.svg"); + await page.keyboard.press("ArrowLeft"); + await expect(dialog).toHaveAccessibleName("three-swatches-1.svg"); + + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(tile).toBeFocused(); +}); + +test("the viewer opens from the keyboard and closes from its button or the backdrop", async ({ + page, +}) => { + await land(page); + await expect(grain(page)).toHaveCount(0, { timeout: 3_000 }); + const tile = page.getByRole("button", { + name: "Open Swatch 3", + exact: true, + }); + await tile.focus(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toHaveAccessibleName("three-swatches-3.svg"); + await dialog.getByRole("button", { name: "Previous" }).click(); + await expect(dialog).toHaveAccessibleName("three-swatches-2.svg"); + await dialog.getByRole("button", { name: "Close" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(tile).toBeFocused(); + + // The action button opens it too, and a click outside the media closes it. + const action = page.getByRole("button", { + name: "Open Swatch 1 full size", + }); + await action.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog")).toHaveAccessibleName( + "three-swatches-1.svg", + ); + await page.mouse.click(4, 4); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(action).toBeFocused(); +}); + +const REMOTE_SVG = + ''; + +async function openRemote(page: Page) { + await page + .context() + .route("https://images.example.test/**", (route) => + route.fulfill({ contentType: "image/svg+xml", body: REMOTE_SVG }), + ); + await page.goto("/media-viewer-chat"); + await page + .getByRole("button", { name: "Open Remote 2", exact: true }) + .click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toHaveAccessibleName("remote-swatches-2.svg"); + return dialog; +} + +const scrolls = (page: Page) => + page.evaluate(() => ({ + page: document.scrollingElement?.scrollTop ?? 0, + log: document.querySelector("[data-testid='log']")?.scrollTop ?? 0, + })); + +test("with the viewer open, the wheel scrolls neither the page nor the log behind it", async ({ + page, +}) => { + await openRemote(page); + const before = await scrolls(page); + expect(before.log).toBeGreaterThan(0); + // Over the backdrop, the picture and the bar, in both directions. + for (const [x, y] of [ + [640, 360], + [20, 20], + [640, 640], + ] as const) { + await page.mouse.move(x, y); + await page.mouse.wheel(0, 800); + await page.mouse.wheel(0, -400); + } + await page.waitForTimeout(300); + expect(await scrolls(page)).toEqual(before); + + // Closing hands scrolling back to the page. + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect( + await page.evaluate(() => document.documentElement.style.overflow), + ).toBe(""); + await page.mouse.move(640, 700); + await page.mouse.wheel(0, 400); + await expect + .poll(async () => (await scrolls(page)).page) + .toBeGreaterThan(before.page); +}); + +test("Download of an image from another origin leaves the chat in place", async ({ + page, +}) => { + const dialog = await openRemote(page); + const popup = page.waitForEvent("popup"); + await dialog + .getByRole("link", { name: "Download remote-swatches-2.svg" }) + .click(); + await popup; + expect(new URL(page.url()).pathname).toBe("/media-viewer-chat"); + await expect(dialog).toBeVisible(); +}); diff --git a/src/hooks/use-media-viewer.test.ts b/src/hooks/use-media-viewer.test.ts new file mode 100644 index 00000000..3dfad00f --- /dev/null +++ b/src/hooks/use-media-viewer.test.ts @@ -0,0 +1,90 @@ +import { act } from "react"; +import { describe, expect, test } from "bun:test"; + +import { renderHook } from "../test/render-hook.js"; +import { + useMediaViewer, + type UseMediaViewerOptions, +} from "./use-media-viewer.js"; + +const ITEMS = ["a", "b", "c"] as const; + +function viewer(options: Partial> = {}) { + return renderHook(() => useMediaViewer({ items: ITEMS, ...options })); +} + +describe("useMediaViewer", () => { + test("starts closed and opens an item", () => { + const hook = viewer(); + expect(hook.result.current.index).toBe(null); + expect(hook.result.current.item).toBe(null); + act(() => hook.result.current.open(1)); + expect(hook.result.current.index).toBe(1); + expect(hook.result.current.item).toBe("b"); + hook.unmount(); + }); + + test("next and previous stop at the ends", () => { + const hook = viewer({ defaultIndex: 1 }); + act(() => hook.result.current.next()); + expect(hook.result.current.index).toBe(2); + expect(hook.result.current.hasNext).toBe(false); + act(() => hook.result.current.next()); + expect(hook.result.current.index).toBe(2); + + act(() => hook.result.current.previous()); + act(() => hook.result.current.previous()); + expect(hook.result.current.index).toBe(0); + expect(hook.result.current.hasPrevious).toBe(false); + act(() => hook.result.current.previous()); + expect(hook.result.current.index).toBe(0); + expect(hook.result.current.hasNext).toBe(true); + hook.unmount(); + }); + + test("closing clears the open item", () => { + const hook = viewer({ defaultIndex: 2 }); + act(() => hook.result.current.close()); + expect(hook.result.current.index).toBe(null); + expect(hook.result.current.item).toBe(null); + expect(hook.result.current.hasNext).toBe(false); + expect(hook.result.current.hasPrevious).toBe(false); + // Stepping does nothing while closed. + act(() => hook.result.current.next()); + expect(hook.result.current.index).toBe(null); + hook.unmount(); + }); + + test("an index outside the items reads as closed, and open ignores one", () => { + const hook = viewer({ defaultIndex: 5 }); + expect(hook.result.current.index).toBe(null); + act(() => hook.result.current.open(3)); + expect(hook.result.current.index).toBe(null); + act(() => hook.result.current.open(-1)); + expect(hook.result.current.index).toBe(null); + hook.unmount(); + }); + + test("controlled, it reports changes and follows the value it is given", () => { + const changes: (number | null)[] = []; + const hook = viewer({ + index: 0, + onIndexChange: (index) => changes.push(index), + }); + act(() => hook.result.current.next()); + act(() => hook.result.current.close()); + expect(changes).toEqual([1, null]); + expect(hook.result.current.index).toBe(0); + hook.unmount(); + }); + + test("keeps the element that opened it", () => { + const hook = viewer(); + const button = document.createElement("button"); + act(() => hook.result.current.open(0, button)); + expect(hook.result.current.opener.current).toBe(button); + act(() => hook.result.current.open(1)); + expect(hook.result.current.opener.current).toBe(null); + hook.unmount(); + }); +}); diff --git a/src/hooks/use-media-viewer.ts b/src/hooks/use-media-viewer.ts new file mode 100644 index 00000000..d5258c1d --- /dev/null +++ b/src/hooks/use-media-viewer.ts @@ -0,0 +1,94 @@ +import { useCallback, useRef } from "react"; +import type { RefObject } from "react"; + +import { useControllableState } from "./use-controllable-state.js"; + +export type UseMediaViewerOptions = { + /** Everything the viewer can step through, in order. */ + readonly items: readonly T[]; + /** Controlled open item; `null` is closed. Supply with `onIndexChange`. */ + readonly index?: number | null | undefined; + /** Seeds the open item when `index` is not supplied. */ + readonly defaultIndex?: number | null; + readonly onIndexChange?: ((index: number | null) => void) | undefined; +}; + +export type MediaViewerState = { + readonly items: readonly T[]; + /** The open item's position, or `null` when closed. */ + readonly index: number | null; + readonly item: T | null; + readonly hasPrevious: boolean; + readonly hasNext: boolean; + /** + * Opens the item at `index`. Pass the element that opened it, so focus can + * go back there on close even where a click does not focus a button. + */ + open(index: number, opener?: HTMLElement | null): void; + close(): void; + next(): void; + previous(): void; + /** + * The element focus returns to on close, if `open` was given one. + * `MediaViewer` clears it on close, so it serves one viewing. + */ + readonly opener: RefObject; +}; + +/** + * Which item of a set is open in a viewer. Stepping stops at either end + * rather than wrapping, and an index outside `items` reads as closed, so a + * list that shrinks under an open viewer closes it rather than showing + * nothing. + */ +export function useMediaViewer({ + items, + index: controlledIndex, + defaultIndex = null, + onIndexChange, +}: UseMediaViewerOptions): MediaViewerState { + const [rawIndex, setIndex] = useControllableState({ + value: controlledIndex, + defaultValue: defaultIndex, + onChange: onIndexChange, + name: "useMediaViewer", + }); + const opener = useRef(null); + const count = items.length; + const index = + rawIndex !== null && + Number.isInteger(rawIndex) && + rawIndex >= 0 && + rawIndex < count + ? rawIndex + : null; + + const open = useCallback( + (next: number, element?: HTMLElement | null) => { + if (!Number.isInteger(next) || next < 0 || next >= count) return; + opener.current = element ?? null; + setIndex(next); + }, + [count, setIndex], + ); + const close = useCallback(() => setIndex(null), [setIndex]); + const next = useCallback(() => { + if (index !== null && index < count - 1) setIndex(index + 1); + }, [index, count, setIndex]); + const previous = useCallback(() => { + if (index !== null && index > 0) setIndex(index - 1); + }, [index, setIndex]); + + return { + items, + index, + item: index === null ? null : (items[index] ?? null), + hasPrevious: index !== null && index > 0, + hasNext: index !== null && index < count - 1, + open, + close, + next, + previous, + opener, + }; +} diff --git a/src/index.ts b/src/index.ts index 2564847d..6d7434b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export * from "./hooks/use-history-window.js"; export * from "./hooks/use-reading-position.js"; export * from "./hooks/use-attachments.js"; export * from "./hooks/use-file-drop.js"; +export * from "./hooks/use-media-viewer.js"; export * from "./lib/artifact.js"; export * from "./lib/chart-palette.js"; export * from "./lib/chat-message.js"; @@ -156,3 +157,6 @@ export * from "./ui/conversation-earlier.js"; export * from "./ui/conversation-map.js"; export * from "./ui/attachments.js"; export * from "./ui/attachment-drop-zone.js"; +export * from "./ui/generated-images.js"; +export * from "./ui/sources.js"; +export * from "./ui/media-viewer.js"; diff --git a/src/lib/dither-field.test.ts b/src/lib/dither-field.test.ts new file mode 100644 index 00000000..0e57e78e --- /dev/null +++ b/src/lib/dither-field.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; + +import { + BAYER_4, + DITHER_REVEAL_MS, + createDitherFieldFrame, + ditherFieldFrame, + type DitherFieldFrame, +} from "./dither-field.js"; + +const SIZE = 32; +const START = 10_000; + +function frameAt(timeMs: number, revealStartMs: number | null) { + return ditherFieldFrame({ + cols: SIZE, + rows: SIZE, + seed: 7, + timeMs, + revealStartMs, + }); +} + +function clearedSet(frame: DitherFieldFrame): Set { + const cells = new Set(); + for (let at = 0; at < frame.cleared.length; at += 2) { + cells.add(`${frame.cleared[at]},${frame.cleared[at + 1]}`); + } + return cells; +} + +function threshold(x: number, y: number): number { + const value = BAYER_4[y % 4]?.[x % 4]; + if (value === undefined) throw new Error("outside the matrix"); + return value; +} + +describe("ditherFieldFrame", () => { + test("before the reveal no cell is cleared", () => { + const forming = frameAt(START, null); + expect(forming.cleared).toEqual([]); + expect(forming.done).toBe(false); + expect(forming.ink.some((cells) => cells.length > 0)).toBe(true); + + const scheduled = frameAt(START - 1, START); + expect(scheduled.cleared).toEqual([]); + expect(scheduled.done).toBe(false); + }); + + test("at the end every cell is cleared and nothing is lit", () => { + const frame = frameAt(START + DITHER_REVEAL_MS, START); + expect(clearedSet(frame).size).toBe(SIZE * SIZE); + expect(frame.ink.every((cells) => cells.length === 0)).toBe(true); + expect(frame.band.every((cells) => cells.length === 0)).toBe(true); + expect(frame.done).toBe(true); + }); + + test("inside the edge, cells clear in Bayer order", () => { + let partialDiagonals = 0; + for (let elapsed = 50; elapsed < DITHER_REVEAL_MS; elapsed += 50) { + const cleared = clearedSet(frameAt(START + elapsed, START)); + // With a square grid, cells on one anti-diagonal sit at the same + // distance from the edge, so only their thresholds decide. + for (let sum = 0; sum <= 2 * (SIZE - 1); sum += 1) { + let highestCleared = -1; + let lowestKept = 2; + for ( + let x = Math.max(0, sum - SIZE + 1); + x <= Math.min(sum, SIZE - 1); + x += 1 + ) { + const y = sum - x; + const value = threshold(x, y); + if (cleared.has(`${x},${y}`)) { + highestCleared = Math.max(highestCleared, value); + } else { + lowestKept = Math.min(lowestKept, value); + } + } + expect(highestCleared).toBeLessThan(lowestKept); + if (highestCleared >= 0 && lowestKept <= 1) partialDiagonals += 1; + } + } + // The edge really is dithered: some diagonals are part cleared. + expect(partialDiagonals).toBeGreaterThan(0); + }); + + test("the reveal only ever clears more", () => { + let previous = new Set(); + for (let elapsed = 0; elapsed <= DITHER_REVEAL_MS; elapsed += 35) { + const cleared = clearedSet(frameAt(START + elapsed, START)); + for (const cell of previous) expect(cleared.has(cell)).toBe(true); + previous = cleared; + } + }); + + test("a reused frame is emptied before it is filled", () => { + const out = createDitherFieldFrame(); + ditherFieldFrame( + { + cols: SIZE, + rows: SIZE, + seed: 1, + timeMs: START + 400, + revealStartMs: START, + }, + out, + ); + expect(out.cleared.length).toBeGreaterThan(0); + ditherFieldFrame( + { cols: SIZE, rows: SIZE, seed: 1, timeMs: START, revealStartMs: null }, + out, + ); + expect(out.cleared).toEqual([]); + expect(out.done).toBe(false); + }); +}); diff --git a/src/lib/dither-field.ts b/src/lib/dither-field.ts new file mode 100644 index 00000000..5533a252 --- /dev/null +++ b/src/lib/dither-field.ts @@ -0,0 +1,165 @@ +/** + * 4×4 Bayer thresholds, 0–1: the brand's ordered dither. A pure function of + * position, so the grain stays locked to its surface while the field under it + * moves. `DitherCanvas` keeps its own private copy of the same matrix. + */ +export const BAYER_4: readonly (readonly number[])[] = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], +].map((row) => row.map((value) => (value + 0.5) / 16)); + +/** Alpha steps a cell fades through, so cells ease in and out instead of blinking. */ +export const DITHER_GRAIN_STEPS = 6; + +/** How long the reveal's edge takes to cross the field. */ +export const DITHER_REVEAL_MS = 700; + +/** Width of the reveal's edge, as a share of the diagonal. Cells inside it + * clear in Bayer order, so the edge itself is grain. */ +const REVEAL_EDGE = 0.16; + +/** How long the ambient band takes to give way once the reveal starts. */ +const AMBIENT_FADE_MS = 300; + +export type DitherFieldInput = { + readonly cols: number; + readonly rows: number; + /** Offsets the field and band, so neighbouring fields never move in step. */ + readonly seed: number; + /** The page clock, in ms. */ + readonly timeMs: number; + /** When the reveal starts on the same clock; `null` while still forming. */ + readonly revealStartMs: number | null; +}; + +/** + * One frame, as flat `[x, y, x, y, …]` cell coordinates. `ink[step]` and + * `band[step]` hold the lit cells at each alpha step (0 faintest) in the + * field colour and the band colour; `cleared` holds the cells the reveal has + * passed, which show what is underneath. + */ +export type DitherFieldFrame = { + readonly ink: number[][]; + readonly band: number[][]; + readonly cleared: number[]; + /** The reveal has crossed the whole field: every cell is cleared. */ + done: boolean; +}; + +export function createDitherFieldFrame(): DitherFieldFrame { + return { + ink: Array.from({ length: DITHER_GRAIN_STEPS }, (): number[] => []), + band: Array.from({ length: DITHER_GRAIN_STEPS }, (): number[] => []), + cleared: [], + done: false, + }; +} + +function easeInOutCubic(p: number): number { + return p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +/** + * The forming grain: a slow interference field and a band sweeping corner to + * corner, each cell lit by comparing the field against its Bayer threshold. + * With a reveal, the band makes one last pass as the reveal's edge: behind it + * cells clear (in Bayer order, so the boundary is grain too), ahead of it the + * grain thickens. + * + * Writes into `out` (cleared first) and returns it, so a draw loop can reuse + * one frame's arrays instead of allocating a fresh set sixty times a second. + */ +export function ditherFieldFrame( + input: DitherFieldInput, + out: DitherFieldFrame = createDitherFieldFrame(), +): DitherFieldFrame { + const { cols, rows, seed, timeMs, revealStartMs } = input; + for (const cells of out.ink) cells.length = 0; + for (const cells of out.band) cells.length = 0; + out.cleared.length = 0; + + const t = timeMs / 1000; + const phase = (seed * 0.37) % 1; + const ambient = ((t / 5.5 + phase) % 1) * 1.6 - 0.3; + const progress = + revealStartMs === null + ? 0 + : clamp01((timeMs - revealStartMs) / DITHER_REVEAL_MS); + // The edge travels from just off one corner to just past the other. + const front = + revealStartMs === null || timeMs < revealStartMs + ? null + : easeInOutCubic(progress) * (1 + 2 * REVEAL_EDGE) - REVEAL_EDGE; + const ambientWeight = + revealStartMs === null + ? 1 + : 1 - clamp01((timeMs - revealStartMs) / AMBIENT_FADE_MS); + + // The diagonal waves split into per-column and per-row terms + // (sin(a + b) = sin a cos b + cos a sin b), so a cell costs a few + // multiplies rather than three trig calls. + const colWave = new Float32Array(cols); + const colSin = new Float32Array(cols); + const colCos = new Float32Array(cols); + for (let x = 0; x < cols; x += 1) { + const u = x / cols; + colWave[x] = Math.sin(u * 7.1 + t * 0.55 + seed); + colSin[x] = Math.sin(u * 11 - t * 0.9 + seed * 2); + colCos[x] = Math.cos(u * 11 - t * 0.9 + seed * 2); + } + const rowWave = new Float32Array(rows); + const rowSin = new Float32Array(rows); + const rowCos = new Float32Array(rows); + for (let y = 0; y < rows; y += 1) { + const v = y / rows; + rowWave[y] = Math.cos(v * 5.3 - t * 0.4); + rowSin[y] = Math.sin(v * 11); + rowCos[y] = Math.cos(v * 11); + } + + let remaining = 0; + for (let y = 0; y < rows; y += 1) { + const thresholds = BAYER_4[y % 4]; + for (let x = 0; x < cols; x += 1) { + const threshold = thresholds?.[x % 4] ?? 0.5; + const diagonal = (x / cols + y / rows) / 2; + let band = 0; + let charge = 0; + if (front !== null) { + if ((front - diagonal) / REVEAL_EDGE > threshold) { + out.cleared.push(x, y); + continue; + } + // Just ahead of the edge the band rides it and the grain thickens. + const ahead = diagonal - front; + band = Math.exp(-(ahead * ahead) / 0.004); + charge = 0.35 * Math.exp(-(ahead * ahead) / 0.03); + } + remaining += 1; + const drift = diagonal - ambient; + band = Math.max(band, ambientWeight * Math.exp(-(drift * drift) / 0.01)); + const field = + 0.3 + + charge + + 0.22 * (colWave[x] ?? 0) * (rowWave[y] ?? 0) + + 0.12 * + ((colSin[x] ?? 0) * (rowCos[y] ?? 0) + + (colCos[x] ?? 0) * (rowSin[y] ?? 0)); + const level = (field + band * 0.5 - threshold) / 0.14 + 0.5; + if (level <= 0) continue; + const step = Math.min( + DITHER_GRAIN_STEPS - 1, + Math.floor(level * DITHER_GRAIN_STEPS), + ); + (band > 0.3 ? out.band : out.ink)[step]?.push(x, y); + } + } + out.done = front !== null && progress >= 1 && remaining === 0; + return out; +} diff --git a/src/lib/media-src.test.ts b/src/lib/media-src.test.ts new file mode 100644 index 00000000..c0f68d56 --- /dev/null +++ b/src/lib/media-src.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { safeMediaSrc } from "./media-src.js"; + +describe("safeMediaSrc", () => { + test("network and blob URLs load in every sink", () => { + for (const src of [ + "https://example.com/a.png", + "http://example.com/a.pdf", + "blob:https://example.com/0f6c1b52", + ]) { + expect(safeMediaSrc(src, "media")).toBe(src); + expect(safeMediaSrc(src, "frame")).toBe(src); + } + }); + + test("a media element takes a data: URL of image, video or audio only", () => { + const image = "data:image/svg+xml;base64,PHN2Zy8+"; + expect(safeMediaSrc(image, "media")).toBe(image); + expect(safeMediaSrc("data:video/mp4;base64,AAAA", "media")).toBe( + "data:video/mp4;base64,AAAA", + ); + expect(safeMediaSrc("data:text/html,", "media")).toBe( + undefined, + ); + }); + + test("a frame never takes a data: URL", () => { + expect(safeMediaSrc("data:image/png;base64,AAAA", "frame")).toBe(undefined); + expect(safeMediaSrc("data:application/pdf;base64,AAAA", "frame")).toBe( + undefined, + ); + }); + + test("script and relative URLs are refused", () => { + expect(safeMediaSrc("javascript:alert(1)", "media")).toBe(undefined); + expect(safeMediaSrc("JavaScript:alert(1)", "frame")).toBe(undefined); + expect(safeMediaSrc("/files/a.png", "media")).toBe(undefined); + }); +}); diff --git a/src/lib/media-src.ts b/src/lib/media-src.ts new file mode 100644 index 00000000..82670f9b --- /dev/null +++ b/src/lib/media-src.ts @@ -0,0 +1,22 @@ +import { isSafeUrl } from "./url.js"; + +/** Where a media sink may load from. */ +export type MediaSink = "media" | "frame"; + +const NETWORK_PROTOCOLS: readonly string[] = ["http:", "https:", "blob:"]; + +/** `data:` media types an ``, `