diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af45a3..cb2ff6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +- feat(app-store): `animate: { rise: seconds }` beside `animate: { rows: seconds }`. Row artwork's + cards come up out of an empty surface and sink back a few at a time, in a loop that closes. The + look is the SVG's own: what is drawn through the mask fades in with its card, and the file's + filters (`feOffset`, `feDropShadow`, `feGaussianBlur`) grow with the card's height, so a drop + shadow lengthens as the card lifts. Cards that draw the same share their frames: a 20 s 4K + header takes about 40 s on an M1 Max. `animate.rows` is unchanged. +- fix(app-store): stills of row artwork with filters are drawn past the canvas and cropped, so + the shadow of a card cut by the canvas edge matches every other card's. Stills of art without + filters are byte for byte what they were, and are not redrawn. +- fix(cli): `--set appStore..animate.rows=20`, as the README shows it, is accepted; it + failed with `Unknown --set path`. + ## [1.6.2] - 2026-10-06 - docs(readme): rewrite in plainer language, App Store video options; lockfile peer dependency (#13) diff --git a/README.md b/README.md index b52d912..39ff9e4 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ SVG inputs are rasterized at the density each size needs, so a small viewBox sti | `--app-store-center ` | `appStore.centerImage` | `appStore.centerImage` | path | the icon | Art centred in each safe area, such as a wordmark. | | `--set appStore.iconScale=` | `appStore.iconScale` | `appStore.iconScale` | number | `0.8` | How much of each safe area the centred art covers. | | `--set appStore.header.source=` | `appStore..source` | same keys | path | none | Finished art (SVG or PNG) for a placement. Required for `eventCard` and `eventDetails`. See [Row artwork](#row-artwork). | -| `--set appStore.header.animate.rows=20` | `appStore..animate` | same keys | `{ rows: seconds }` | none | A looping video from row artwork: 5-30 s, or 15-30 s for In-App Events. Not for `universal`. | +| `--set appStore.header.animate.rows=20` | `appStore..animate` | same keys | `{ rows: seconds }` or `{ rise: seconds }` | none | A looping video from row artwork, its rows sliding or its [cards rising](#cards-that-rise): 5-30 s, or 15-30 s for In-App Events. Not for `universal`. | | `--set appStore.searchResults.video=` | `appStore..video` | same keys | path | none | A [recording](#recordings) cut into the placement's looping video. Not for `universal`. | | `--set appStore.universal.enabled=false` | `appStore..enabled` | same keys | boolean | on; In-App Events off | Write a placement or skip it: `header`, `searchResults`, `universal`, `eventCard`, `eventDetails`. | | `--set appStore.searchResults.center=false` | `appStore..{backgroundImage,centerImage,center}` | same keys | per placement | `center: true` | Per-placement backdrop and centred art; `center: false` writes the backdrop alone. | @@ -302,6 +302,45 @@ Design at 3840x1646 (the header) and export SVG. Each rule is checked, and a fil Row-motion videos are fast: only the cards move, so each frame is blended from layers drawn once, several frames at a time. A 20 s 4K header takes about 30 s on an M1 Max. Art the blend can't reproduce exactly is drawn in full each frame instead. +### Cards that rise + +`animate` with `rise` in place of `rows` makes the other video row artwork can become. The rows stay where they are. The surface is empty, and cards come up out of it and sink back a few at a time, so the only cards on screen are the ones on their way in or out. The still shows every card up. + +```json +"appStore": { + "enabled": true, + "header": { "source": "./store/artwork.svg", "animate": { "rise": 20 } }, + "searchResults": { "source": "./store/artwork.svg", "animate": { "rise": 20 } } +} +``` + +How a card rises comes from your SVG: + +- **What is drawn through the mask** fades in with its card, after a moment as a faint relief. +- **Filters** grow with the card: `feOffset` and `feDropShadow` offsets from zero, blurs from a fifth of their size. Put a drop shadow on a group around the masked art and it lengthens as the card lifts. +- **Drawn on top, inside one card**: comes up with that card. +- **Everything else**: the surface, which stays put. + +```svg + +…the cards… + + …what each card shows… + +``` + +A card comes up every 10 s in a 20 or 30 s loop (once per loop in shorter ones), and cards next to each other come up at different moments. `rise` adds three rules: + +| Rule | Why | +|---|---| +| One element draws through the mask; wrap several in one `` | That element is the card. | +| No `` | The rise is the motion. | +| A card's shadows stay clear of the next card | Each card is drawn by itself; with every card up, the frame has to match the still. | + +To put the same art on every card (a logo pressed into each one, say), fill a full-canvas shape with a `pattern` whose tile is one card spacing wide and two rows tall. + +Cards that draw the same share their frames, so this is fast too: a 20 s 4K header takes about 40 s on an M1 Max. + ### Recordings Point `header.video` or `searchResults.video` at a recording (`.mov`, `.mp4` or `.m4v`), such as a simulator capture (`xcrun simctl io recordVideo`) of a tour that ends where it starts. tvos-assets fits it to the canvas, keeps a constant frame rate, blends the last 0.5 s into the start so the loop has no cut, and caps it at 30 s. diff --git a/schema.json b/schema.json index 231fea5..ebea635 100644 --- a/schema.json +++ b/schema.json @@ -480,9 +480,13 @@ "source": { "type": "string", "description": "Finished artwork (SVG or PNG) for this placement, replacing the backdrop and centre item. Row artwork, an SVG whose holds rows of one repeated shape over full-canvas fills, is re-tiled to the canvas, so one design serves the 21:9 header and the 3:2 search results." }, "animate": { "type": "object", - "description": "Video of row-artwork source: rows slide one card per loop, alternating direction, everything outside the mask fixed. header and searchResults only.", - "properties": { "rows": { "type": "number", "minimum": 5, "maximum": 30, "description": "Loop length in seconds." } }, - "required": ["rows"], + "description": "Video of row-artwork source, one of rows or rise. Not for universal.", + "properties": { + "rows": { "type": "number", "minimum": 5, "maximum": 30, "description": "Loop length in seconds. Rows slide one card per loop, alternating direction, everything outside the mask fixed." }, + "rise": { "type": "number", "minimum": 5, "maximum": 30, "description": "Loop length in seconds. Cards rise out of the surface and sink back a few at a time, their shadows growing with them; the surface is empty in between." } + }, + "minProperties": 1, + "maxProperties": 1, "additionalProperties": false }, "center": { "type": "boolean", "default": true, "description": "Draw the centre item. false writes the backdrop alone, such as a UI capture for search results." }, diff --git a/src/cli/set-option.ts b/src/cli/set-option.ts index cb2edf6..13cfc9d 100644 --- a/src/cli/set-option.ts +++ b/src/cli/set-option.ts @@ -23,7 +23,13 @@ const OPTIONAL_STRING_PATHS: ReadonlySet = new Set([ ]); /** Number keys with no default, so absent from the shape template. */ -const OPTIONAL_NUMBER_PATHS: ReadonlySet = new Set(["appStore.video.audioStart", "appStore.video.audioEnd"]); +const OPTIONAL_NUMBER_PATHS: ReadonlySet = new Set([ + "appStore.video.audioStart", + "appStore.video.audioEnd", + ...["header", "searchResults", "universal", "eventCard", "eventDetails"].flatMap((placement) => + ["rows", "rise"].map((kind) => `appStore.${placement}.animate.${kind}`), + ), +]); function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/config.ts b/src/config.ts index 2acc2bf..8183624 100644 --- a/src/config.ts +++ b/src/config.ts @@ -516,14 +516,19 @@ export function resolveConfig(cliArgs: CLIArgs): TvOSImageCreatorConfig { throw new Error(`appStore.${key} needs a source: Apple publishes no safe area for In-App Event media to place the icon in.`); } if (placement.animate) { - const seconds = placement.animate.rows; + const kinds = Object.keys(placement.animate); + if (kinds.length !== 1 || (kinds[0] !== "rows" && kinds[0] !== "rise")) { + throw new Error(`appStore.${key}.animate takes one of rows or rise, got: ${kinds.join(", ") || "neither"}.`); + } + const kind = kinds[0]; + const seconds = (placement.animate as Record)[kind]; const min = event ? 15 : 5; if (key === "universal") throw new Error(`appStore.universal.animate is not supported: ${NO_VIDEO}.`); if (!placement.source || !isSvgPath(placement.source)) { throw new Error(`appStore.${key}.animate needs an SVG appStore.${key}.source with its cards in a .`); } if (!Number.isFinite(seconds) || seconds < min || seconds > 30) { - throw new Error(`Invalid appStore.${key}.animate.rows: "${String(seconds)}". Use ${min} to 30 seconds.`); + throw new Error(`Invalid appStore.${key}.animate.${kind}: "${String(seconds)}". Use ${min} to 30 seconds.`); } } } diff --git a/src/generators/app-store-video.ts b/src/generators/app-store-video.ts index b0c630b..a313691 100644 --- a/src/generators/app-store-video.ts +++ b/src/generators/app-store-video.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import type { AppStoreVideoCodec, TvOSImageCreatorConfig } from "../types.js"; import { ensureDir } from "../utils/fs.js"; +import { riseFrames } from "../utils/rise-frames.js"; import { rowFrames } from "../utils/row-frames.js"; import type { RowMotion } from "../utils/row-frames.js"; import { animationPeriods, parseRowArtwork } from "../utils/svg-rows.js"; @@ -262,12 +263,7 @@ export function rowOffset(k: number, t: number, period: number, pitch: number): /** Frames rendered ahead of the one ffmpeg is waiting for. */ const IN_FLIGHT = Math.min(8, Math.max(2, os.availableParallelism?.() ?? os.cpus().length)); -/** - * Slide the rows of row artwork one pitch per loop, so the last frame runs into the first; - * everything outside the mask stays put. Frames are rendered several at a time and piped to - * ffmpeg in order. - */ -export async function encodeRows(options: { +interface RowVideo { ffmpeg: string; encoder: string; source: string; @@ -283,8 +279,25 @@ export async function encodeRows(options: { audio?: string; /** Seconds into `audio` the loop is taken from. */ audioWindow?: { start?: number; end?: number }; -}): Promise { - const { width, height, fps, seconds, centre } = options; +} + +/** + * Lift the cards of row artwork out of the surface and back, a few at a time, each at the same + * moments of every loop, so the last frame runs into the first. + */ +export async function encodeRise(options: RowVideo): Promise { + const { width, height, seconds } = options; + const art = parseRowArtwork(readFileSync(options.source, "utf8")); + const source = await riseFrames(art, width, height, options.centre ?? { x: width / 2, y: height / 2 }, seconds); + await encodeFrames(options, source.frame); +} + +/** + * Slide the rows of row artwork one pitch per loop, so the last frame runs into the first; + * everything outside the mask stays put. + */ +export async function encodeRows(options: RowVideo): Promise { + const { width, height, seconds, centre } = options; const art = parseRowArtwork(readFileSync(options.source, "utf8")); for (const dur of animationPeriods(art.source)) { const cycles = seconds / dur; @@ -292,7 +305,6 @@ export async function encodeRows(options: { throw new Error(`Row artwork: an lasts ${dur} s, which does not divide the ${seconds} s loop, so the loop would jump.`); } } - const frames = Math.round(seconds * fps); // Shapes riding a card hand over to the next card in the last moments, so the loop closes. const fade = Math.min(LOOP_CROSSFADE, seconds / 4); const motion = (t: number): RowMotion => ({ @@ -300,6 +312,13 @@ export async function encodeRows(options: { seam: t > seconds - fade ? { weight: (t - (seconds - fade)) / fade, shift: (k) => rowOffset(k, seconds, seconds, art.pitchX) } : undefined, }); const source = await rowFrames(art, width, height, centre ?? { x: width / 2, y: height / 2 }, motion, [seconds * 0.37, seconds - fade / 2]); + await encodeFrames(options, source.frame); +} + +/** Encode `frame(t)` over the loop: frames are rendered several at a time and piped to ffmpeg in order. */ +async function encodeFrames(options: RowVideo, frame: (t: number) => Promise): Promise { + const { width, height, fps, seconds } = options; + const frames = Math.round(seconds * fps); const pixelFormat = options.codec === "prores" ? "yuv422p10le" : "yuv420p"; const sound = await soundtrack(options.ffmpeg, options.audio, 1, frames / fps, options.audioWindow); const filter = [`[0:v]scale=out_color_matrix=bt709:out_range=tv:sws_dither=ed,format=${pixelFormat}[v]`, sound.filter].filter(Boolean).join(";"); @@ -329,7 +348,7 @@ export async function encodeRows(options: { for (let f = 0; f < frames; f++) { while (next < frames && next < f + IN_FLIGHT) { const n = next++; - const pending = source.frame(n / fps); + const pending = frame(n / fps); pending.catch(() => undefined); ahead.set(n, pending); } @@ -372,15 +391,17 @@ export async function generateAppStoreVideos(outDir: string, config: TvOSImageCr const common = { ffmpeg, encoder, output, width: asset.width, height: asset.height, fps, codec, audio, audioWindow }; const centre = designCentre(asset, readRowArtwork(placement.source)); const sound = [Boolean(audio), AUDIO_CROSSFADE, audioStart ?? null, audioEnd ?? null]; + const animate = (placement.animate ?? {}) as { rows?: number; rise?: number }; const key = placement.video ? inputKey([placement.video, audio], ["recording", common.width, common.height, fps, codec, encoder, sound]) - : inputKey([placement.source, audio], ["rows", common.width, common.height, fps, codec, encoder, placement.animate, centre, sound]); + : inputKey([placement.source, audio], [animate.rise ? "rise" : "rows", common.width, common.height, fps, codec, encoder, placement.animate, centre, sound]); if (cache.fresh(filename, key)) continue; if (placement.video) { await encodeRecording({ ...common, source: placement.video, minSeconds: asset.minSeconds ?? 5 }); + } else if (animate.rise) { + await encodeRise({ ...common, source: placement.source as string, seconds: animate.rise, centre }); } else { - const seconds = (placement.animate as { rows: number }).rows; - await encodeRows({ ...common, source: placement.source as string, seconds, centre }); + await encodeRows({ ...common, source: placement.source as string, seconds: animate.rows as number, centre }); } cache.record(filename, key); written.push(output); diff --git a/src/generators/app-store.ts b/src/generators/app-store.ts index 6ee5d1d..4f3497a 100644 --- a/src/generators/app-store.ts +++ b/src/generators/app-store.ts @@ -6,7 +6,8 @@ import type { AppStorePlacementConfig, TvOSImageCreatorConfig } from "../types.j import { ensureDir, safeWriteFile } from "../utils/fs.js"; import { compositeCenterInRect } from "../utils/image-processing.js"; import type { CenterItem, ContentBox, Rect } from "../utils/image-processing.js"; -import { animateAt, parseRowArtwork, renderRows } from "../utils/svg-rows.js"; +import { rowStill } from "../utils/rise-frames.js"; +import { animateAt, filterReach, parseRowArtwork } from "../utils/svg-rows.js"; import type { RowArtwork } from "../utils/svg-rows.js"; import { StoreCache, inputKey } from "../utils/store-cache.js"; @@ -161,7 +162,7 @@ export async function renderSource( const svg = readFileSync(source, "utf8"); const art = readRowArtwork(source); image = art - ? sharp(Buffer.from(renderRows({ ...art, source: animateAt(art.source, 0) }, width, height, undefined, centre))) + ? rowStill({ ...art, source: animateAt(art.source, 0) }, width, height, centre) : sharp(Buffer.from(svg)).resize(width, height, { fit: "cover" }); } else { image = sharp(source).resize(width, height, { fit: "cover" }); @@ -184,8 +185,11 @@ export async function generateAppStoreAssets( for (const asset of assets) { const placement = config.appStore[asset.placement]; if (placement.source) { - const centre = designCentre(asset, readRowArtwork(placement.source)); - const key = inputKey([placement.source], ["still", asset.width, asset.height, centre]); + const art = readRowArtwork(placement.source); + const centre = designCentre(asset, art); + // The bleed is part of the key: it decides what filtered art draws at the canvas edges. + const bleed = art ? filterReach(art.source) : 0; + const key = inputKey([placement.source], ["still", asset.width, asset.height, centre, ...(bleed ? [bleed] : [])]); if (cache.fresh(asset.filename, key)) continue; safeWriteFile(join(outDir, asset.filename), await renderSource(placement.source, asset.width, asset.height, centre)); cache.record(asset.filename, key); diff --git a/src/types.ts b/src/types.ts index 279bdd4..941c05c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -138,8 +138,11 @@ export interface AppStorePlacementConfig { * Row artwork (an SVG whose holds rows of one repeated shape) re-tiles to the canvas. */ source?: string; - /** Loop length in seconds of a video sliding the rows of row-artwork `source`; 5-30. */ - animate?: { rows: number }; + /** + * Loop length in seconds (5-30) of a video of row-artwork `source`: `rows` slides its rows, + * `rise` lifts its cards out of the surface and back, a few at a time. + */ + animate?: { rows: number } | { rise: number }; /** A recording (.mov, .mp4, .m4v) to cut into this placement's looping video. */ video?: string; } diff --git a/src/utils/rise-frames.ts b/src/utils/rise-frames.ts new file mode 100644 index 0000000..a5dc254 --- /dev/null +++ b/src/utils/rise-frames.ts @@ -0,0 +1,182 @@ +import sharp from "sharp"; +import { cardBox, filterReach, renderRows, riders, risenCard } from "./svg-rows.js"; +import type { RowArtwork } from "./svg-rows.js"; + +/** Heights a card is drawn at on its way up; a frame uses the nearest. */ +const STATES = 48; +/** Seconds a card spends out of the surface, and between its appearances, in a long loop. */ +const LIFE = 4.4, PERIOD = 10; +/** Mean difference per channel (0-255) every card fully up may show against the still. */ +const TOLERANCE = 0.5; +/** Cards drawn at once while the sprites are made. */ +const IN_FLIGHT = 4; + +export interface RiseFrames { + /** The frame at `t` seconds as raw RGB, `width` x `height`. */ + frame(t: number): Promise; +} + +/** How often a card comes up in a `seconds` loop, and how long it stays: both divide the loop. */ +export function riseTiming(seconds: number): { period: number; life: number } { + const period = seconds / Math.max(1, Math.round(seconds / PERIOD)); + return { period, life: Math.min(LIFE, period * 0.44) }; +} + +const smooth = (from: number, to: number, t: number): number => { + const x = Math.min(1, Math.max(0, (t - from) / (to - from))); + return x * x * x * (x * (x * 6 - 15) + 10); +}; + +/** Height (0 flat, 1 fully up) `t` seconds into a card's `life`: a faint relief, the rise, then the same back down. */ +export function riseHeight(t: number, life: number): number { + if (t <= 0 || t >= life) return 0; + const u = Math.min(t, life - t), half = life / 2; + return 0.2 * smooth(0, half * 0.4, u) + 0.8 * smooth(half * 0.42, half * 0.96, u); +} + +/** When in each `period` the card at column `n` of row `k` starts up: neighbours land far apart. */ +export function risePhase(n: number, k: number, period: number): number { + const turn = n * 0.6180339887 + k * 0.3819660113 + 0.11; + return (turn - Math.floor(turn)) * period; +} + +/** Row artwork on the canvas as raw RGB, drawn with bleed so filters see past the canvas edges. */ +export function rowStill(art: RowArtwork, width: number, height: number, centre: { x: number; y: number }): sharp.Sharp { + const bleed = filterReach(art.source); + const svg = renderRows(art, width + 2 * bleed, height + 2 * bleed, undefined, { x: centre.x + bleed, y: centre.y + bleed }); + return sharp(Buffer.from(svg)).extract({ left: bleed, top: bleed, width, height }).flatten({ background: { r: 0, g: 0, b: 0 } }).removeAlpha(); +} + +function meanDiff(a: Buffer, b: Buffer): number { + if (a.length !== b.length) return Infinity; + let diff = 0; + for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]); + return diff / a.length; +} + +/** 8x8 ordered-dither thresholds, 0-254. */ +const DITHER = Uint8Array.from({ length: 64 }, (_v, i) => { + let x = i & 7, y = i >> 3, rank = 0; + for (let bit = 0; bit < 3; bit++, x >>= 1, y >>= 1) rank = (rank << 2) | (((x ^ y) & 1) << 1) | (y & 1); + return Math.floor(((rank + 0.5) / 64) * 255); +}); + +/** + * Draw an RGBA sprite over an RGB frame at (`left`, `top`), faded to `opacity` and clipped to the + * frame. The fade is applied here and dithered: a faint sprite's gradient keeps its full depth, + * where an 8-bit sprite drawn faint would land on the frame in visible steps. + */ +function over(frame: Buffer, width: number, height: number, sprite: Buffer, w: number, h: number, left: number, top: number, opacity: number): void { + const c0 = Math.max(0, -left), c1 = Math.min(w, width - left), fade = Math.round(opacity * 256); + for (let r = Math.max(0, -top); r < h && top + r < height; r++) { + let s = (r * w + c0) * 4, d = ((top + r) * width + left + c0) * 3; + const row = ((top + r) & 7) << 3; + for (let c = c0; c < c1; c++, s += 4, d += 3) { + const a = sprite[s + 3] * fade; + if (a === 0) continue; + const round = DITHER[row | ((left + c) & 7)] << 8; + frame[d] += Math.floor(((sprite[s] - frame[d]) * a + round) / 65280); + frame[d + 1] += Math.floor(((sprite[s + 1] - frame[d + 1]) * a + round) / 65280); + frame[d + 2] += Math.floor(((sprite[s + 2] - frame[d + 2]) * a + round) / 65280); + } + } +} + +/** + * Frames of row artwork whose cards rise out of the surface and sink back, a few at a time, each + * by `riseHeight` from its `risePhase`. A card on its way is the art drawn through the mask for + * that one card with its filters scaled to the height (`risenCard`), drawn once per height and + * laid, faded by the height, over the surface (the art with the mask shut). Cards that draw the same share + * their sprites. Throws when the art breaks rule 9, or when every card up does not match the still. + */ +export async function riseFrames( + art: RowArtwork, + width: number, + height: number, + centre: { x: number; y: number }, + seconds: number, +): Promise { + const fail = (message: string): never => { + throw new Error(`Row artwork: ${message}.`); + }; + const id = art.source.match(/]*\sid="([^"]*)"/)?.[1] ?? ""; + if (art.source.split(`url(#${id})`).length - 1 !== 1) { + fail('for animate.rise one element must draw through the mask; wrap what the cards show in a single '); + } + if (/; remove it or use animate.rows"); + + const { width: W, height: H, pitchX, pitchY } = art; + const dx = centre.x - W / 2, dy = centre.y - H / 2; + const reach = filterReach(art.source) + 2; + const w = Math.ceil(art.card.width) + 2 * reach + 1, h = Math.ceil(art.card.height) + 2 * reach + 1; + const first = art.rows[0]; + + const riding = riders(art.source, art.cards); + let surface = art.source; + for (const { tag } of riding) surface = surface.split(tag).join(""); + const base = await sharp(Buffer.from(renderRows({ ...art, source: surface }, width, height, undefined, centre, "shut"))) + .flatten({ background: { r: 0, g: 0, b: 0 } }).removeAlpha().raw().toBuffer(); + + interface Cell { k: number; n: number; left: number; top: number; onTop: string; like: number } + const cells: Cell[] = []; + for (let k = Math.floor((-dy - h - first.y) / pitchY); first.y + k * pitchY + dy - h < height; k++) { + const src = art.rows[((k % 2) + 2) % 2] ?? first; + const y0 = first.y + k * pitchY - art.card.height / 2; + for (let n = Math.floor((-dx - w - src.xs[0]) / pitchX); src.xs[0] + n * pitchX + dx - w < width; n++) { + const x0 = src.xs[0] + n * pitchX - art.card.width / 2; + const left = Math.floor(x0 + dx) - reach, top = Math.floor(y0 + dy) - reach; + if (left + w <= 0 || top + h <= 0 || left >= width || top >= height) continue; + const onTop = riding.filter(({ tag }) => { + const b = cardBox(tag); + return typeof b !== "string" && b.x0 >= x0 - 1 && b.x1 <= x0 + art.card.width + 1 && b.y0 >= y0 - 1 && b.y1 <= y0 + art.card.height + 1; + }).map(({ tag }) => tag).join(""); + cells.push({ k, n, left, top, onTop, like: cells.length }); + } + } + + const draw = (cell: Cell, state: number): Promise => { + const source = risenCard(art.source, state / STATES, cell.onTop) as string; + const svg = renderRows({ ...art, source }, w, h, undefined, { x: dx - cell.left + W / 2, y: dy - cell.top + H / 2 }, { k: cell.k, n: cell.n }); + return sharp(Buffer.from(svg)).ensureAlpha().raw().toBuffer(); + }; + + // Cards whose fully risen sprites match share one set of sprites. + const sprites = new Map>(); + const up: Buffer[] = []; + for (let i = 0; i < cells.length; i += IN_FLIGHT) { + up.push(...await Promise.all(cells.slice(i, i + IN_FLIGHT).map((cell) => draw(cell, STATES)))); + } + cells.forEach((cell, i) => { + const twin = cells.findIndex((other, j) => j < i && other.like === j && meanDiff(up[j], up[i]) < 0.25); + cell.like = twin < 0 ? i : twin; + if (twin < 0) sprites.set(`${i}|${STATES}`, Promise.resolve(up[i])); + }); + const sprite = (cell: Cell, state: number): Promise => { + const key = `${cell.like}|${state}`; + let hit = sprites.get(key); + if (!hit) sprites.set(key, (hit = draw(cells[cell.like], state))); + return hit; + }; + + const compose = async (state: (cell: Cell) => number): Promise => { + const frame = Buffer.from(base); + for (const cell of cells) { + const s = state(cell); + if (s >= 1) over(frame, width, height, await sprite(cell, s), w, h, cell.left, cell.top, Math.pow(s / STATES, 0.75)); + } + return frame; + }; + + const diff = meanDiff(await compose(() => STATES), await rowStill(art, width, height, centre).raw().toBuffer()); + if (diff > TOLERANCE) { + fail(`for animate.rise each card must draw on its own; with every card up the frame is ${diff.toFixed(2)} off the still (shadows reaching the next card, or art that is not drawn through the mask)`); + } + + const { period, life } = riseTiming(seconds); + return { + frame: (t) => compose((cell) => { + const local = (((t - risePhase(cell.n, cell.k, period)) % period) + period) % period; + return Math.round(riseHeight(local, life) * STATES); + }), + }; +} diff --git a/src/utils/svg-rows.ts b/src/utils/svg-rows.ts index 8266ad8..7488de3 100644 --- a/src/utils/svg-rows.ts +++ b/src/utils/svg-rows.ts @@ -16,6 +16,8 @@ * it, stills show its first value, and its `dur` must divide the loop. * 8. What is drawn through the mask is the scene behind the cards and stays put. A shape drawn on * top that fits inside one card belongs to it and rides with its row; other shapes stay put. + * 9. For cards that rise (`animate.rise`), one element draws through the mask (a `` around + * several is one), no shape holds an ``, and a card's shadows stay clear of the next card. */ export interface Box { @@ -270,13 +272,59 @@ export function animateAt(svg: string, t: number): string { }); } +/** How far the SVG's filters carry an edge, in px: the largest offset plus three of the largest blur. */ +export function filterReach(svg: string): number { + let offset = 0, blur = 0; + for (const [tag] of svg.matchAll(/]*>/g)) { + offset = Math.max(offset, Math.abs(num(tag, "dx")), Math.abs(num(tag, "dy"))); + for (const v of (attr(tag, "stdDeviation") ?? "0").trim().split(/[\s,]+/)) blur = Math.max(blur, Number(v) || 0); + } + return Math.ceil(offset + 3 * blur); +} + +/** + * The SVG with its filters as a card `h` of the way up casts them (0 flat, 1 as drawn): offsets + * grow with the height, blurs from a fifth of their size. + */ +export function liftFilters(svg: string, h: number): string { + const scale = (tag: string, name: string, by: number): string => + tag.replace(new RegExp(`(\\s${name}=")([^"]*)"`), (_m, head: string, v: string) => + `${head}${v.trim().split(/[\s,]+/).map((n) => +(Number(n) * by).toFixed(4)).join(" ")}"`); + return svg.replace(/]*>/g, (tag) => + scale(scale(scale(tag, "dx", h), "dy", h), "stdDeviation", 0.2 + 0.8 * h)); +} + +/** + * The SVG cut down to what one card is made of, `h` of the way up: the element drawn through the + * mask inside the groups that hold it (their filters and clips apply), then `onTop`, with the + * filters of that height. Undefined when nothing draws through the mask. + */ +export function risenCard(svg: string, h: number, onTop = ""): string | undefined { + const mask = svg.match(/]*>[\s\S]*?<\/mask>/)?.[0]; + const id = mask?.match(/\sid="([^"]*)"/)?.[1]; + const at = id ? svg.indexOf(`mask="url(#${id})"`) : -1; + if (!mask || at < 0) return undefined; + const [start, end] = elementSpan(svg, at); + const open: string[] = []; + const before = svg.slice(0, start).replace(/<(mask|defs|clipPath)\b[\s\S]*?<\/\1>/g, ""); + for (const m of before.matchAll(/]*?(\/?)>|<\/g>/g)) { + if (m[0] === "") open.pop(); + else if (!m[1]) open.push(m[0]); + } + const defs = (svg.match(//g) ?? []).join(""); + const root = svg.match(/]*>/)?.[0] ?? ""; + const card = `${open.join("")}${svg.slice(start, end)}${"".repeat(open.length)}${onTop}`; + return liftFilters(`${root}${mask}${card}${defs}`, h); +} + /** * The artwork on a `width` x `height` canvas: the original's centre placed at `centre` (default the * canvas centre), its full-canvas fills stretched to cover the canvas, its rows re-tiled across it * with the source's spacing and stagger, each row shifted horizontally by `offsets(k)` (row 0 is the * top source row; rows above and below repeat the stagger). Shapes riding a card move with its row; * during `seam` each also fades into a copy `seam.shift(row)` back, so a loop ends where it began. - * `mask` "open" lets everything through the mask and "shut" nothing, in place of the cards. + * `mask` "open" lets everything through the mask and "shut" nothing, in place of the cards; a row + * `k` and column `n` (0 is the row's first source card) leaves that one card. */ export function renderRows( art: RowArtwork, @@ -284,7 +332,7 @@ export function renderRows( height: number, offsets: (row: number) => number = () => 0, centre: { x: number; y: number } = { x: width / 2, y: height / 2 }, - mask: "cards" | "open" | "shut" = "cards", + mask: "cards" | "open" | "shut" | { k: number; n: number } = "cards", seam?: { weight: number; shift: (row: number) => number }, ): string { const { width: W, height: H, pitchX, pitchY } = art; @@ -301,6 +349,7 @@ export function renderRows( const origin = src.xs[0] + offsets(k); const start = origin + Math.floor((left - origin) / pitchX) * pitchX; for (let x = start; x < right; x += pitchX) { + if (typeof mask === "object" && (k !== mask.k || Math.round((x - origin) / pitchX) !== mask.n)) continue; cells.push(`${src.template}`); } } @@ -340,8 +389,9 @@ export function renderRows( .replace(/^[\s\S]*?]*>/, "") .replace(/<\/svg>\s*$/, "") .replace(/]*)>[\s\S]*?<\/mask>/, (_m, attrs: string) => { - const region = attrs.replace(/\s(x|y|width|height)="[^"]*"/g, ""); - const content = mask === "cards" ? cells.join("") + // The region is in canvas px whatever units the source's mask used. + const region = `${attrs.replace(/\s(x|y|width|height|maskUnits)="[^"]*"/g, "")} maskUnits="userSpaceOnUse"`; + const content = mask === "cards" || typeof mask === "object" ? cells.join("") : mask === "open" ? `` : ""; return `${content}`; diff --git a/tests/cli/set-option.test.ts b/tests/cli/set-option.test.ts index 1950ef9..171e8b3 100644 --- a/tests/cli/set-option.test.ts +++ b/tests/cli/set-option.test.ts @@ -20,6 +20,13 @@ describe("parseSetEntry", () => { expect(() => parseSetEntry("appStore.video.audioEnd=soon")).toThrow(/expected a number/); }); + it("takes a placement's animate length, rows or rise", () => { + expect(parseSetEntry("appStore.header.animate.rows=20")).toEqual({ path: ["appStore", "header", "animate", "rows"], value: 20 }); + expect(parseSetEntry("appStore.searchResults.animate.rise=12.5").value).toBe(12.5); + expect(() => parseSetEntry("appStore.header.animate.rise=long")).toThrow(/expected a number/); + expect(() => parseSetEntry("appStore.header.animate.spin=20")).toThrow(/Unknown --set path/); + }); + it("coerces booleans", () => { expect(parseSetEntry("iosIcon.enabled=false").value).toBe(false); expect(parseSetEntry("iosIcon.enabled=true").value).toBe(true); diff --git a/tests/fixtures/row-artwork.ts b/tests/fixtures/row-artwork.ts index 8162713..ebcbe9d 100644 --- a/tests/fixtures/row-artwork.ts +++ b/tests/fixtures/row-artwork.ts @@ -16,3 +16,10 @@ ${rowAt(20, 50)}${rowAt(100, 0)}${rowAt(180, 50)} `; + +// The same wall with each card casting a grey shadow down and right, from a filter on a group +// around the masked fill. +export const RAISED_SVG = ROW_SVG + .replace('', '') + .replace('fill="#FFC312"/>', 'fill="#FFC312"/>') + .replace("", ''); diff --git a/tests/generators/app-store-video.test.ts b/tests/generators/app-store-video.test.ts index 7b1979b..10519c1 100644 --- a/tests/generators/app-store-video.test.ts +++ b/tests/generators/app-store-video.test.ts @@ -8,6 +8,7 @@ import { resolveConfig } from "../../src/config"; import { audioLoopFilter, bestLoopStart, + encodeRise, encodeRows, encoderPreference, ffmpegPath, @@ -17,7 +18,8 @@ import { rowOffset, videoAssets, } from "../../src/generators/app-store-video"; -import { ROW_SVG } from "../fixtures/row-artwork"; +import { risePhase, riseTiming } from "../../src/utils/rise-frames"; +import { RAISED_SVG, ROW_SVG } from "../fixtures/row-artwork"; const TMP = join(__dirname, "../../.test-tmp-app-store-video"); @@ -122,6 +124,21 @@ describe("row motion", () => { resolveConfig({ icon, background, color: "#000000", overrides: { appStore: { header: { source: art, animate: { rows: 2 } } } } }), ).toThrow(/5 to 30 seconds/); }); + + it("takes rise in place of rows, with the same limits, and never both", async () => { + const icon = await writeImage("icon.png", 1280, 4); + const background = await writeImage("bg.png", 2000, 3); + const art = join(TMP, "wall.svg"); + writeFileSync(art, ROW_SVG); + const header = (animate: object) => resolveConfig({ icon, background, color: "#000000", overrides: { appStore: { enabled: true, header: { source: art, animate } } } } as never); + expect(videoAssets(header({ rise: 20 })).map((asset) => asset.video)).toEqual(["header"]); + expect(() => header({ rise: 31 })).toThrow(/Invalid appStore\.header\.animate\.rise: "31"\. Use 5 to 30 seconds/); + expect(() => header({ rows: 20, rise: 20 })).toThrow(/animate takes one of rows or rise, got: rows, rise/); + expect(() => header({ fade: 20 })).toThrow(/animate takes one of rows or rise, got: fade/); + expect(() => + resolveConfig({ icon, background, color: "#000000", overrides: { appStore: { eventCard: { enabled: true, source: art, animate: { rise: 10 } } } } }), + ).toThrow(/animate\.rise: "10"\. Use 15 to 30 seconds/); + }); }); describe("audioLoopFilter", () => { @@ -175,6 +192,65 @@ describe("encoderPreference", () => { const hasFfmpeg = spawnSync(ffmpegPath(), ["-version"]).status === 0; const describeWithFfmpeg = hasFfmpeg ? describe : describe.skip; +describeWithFfmpeg("encodeRise (ffmpeg)", () => { + it("lifts cards out of an empty surface and back, in a loop that closes", async () => { + const source = join(TMP, "raised.svg"); + writeFileSync(source, RAISED_SVG); + const output = join(TMP, "rise.mp4"); + const ffmpeg = ffmpegPath(); + await encodeRise({ ffmpeg, encoder: await pickEncoder(ffmpeg, "h264"), source, output, width: 400, height: 200, fps: 30, seconds: 5, codec: "h264" }); + + const probe = JSON.parse( + execFileSync("ffprobe", ["-v", "error", "-show_streams", "-of", "json", output], { encoding: "utf-8" }), + ) as { streams: Record[] }; + const video = probe.streams.find((stream) => stream.codec_type === "video"); + expect(video).toMatchObject({ codec_name: "h264", width: 400, height: 200, r_frame_rate: "30/1" }); + expect(Number(video?.nb_frames)).toBe(150); + + const frames = execFileSync("ffmpeg", ["-v", "error", "-i", output, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { + maxBuffer: 64 * 1024 * 1024, + }); + const size = 400 * 200 * 3; + const frame = (n: number) => frames.subarray(n * size, (n + 1) * size); + const diff = (a: Buffer, b: Buffer) => { let d = 0; for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]); return d / a.length; }; + let biggest = 0; + for (let n = 0; n < 149; n++) biggest = Math.max(biggest, diff(frame(n), frame(n + 1))); + expect(biggest).toBeGreaterThan(0); // cards move + expect(diff(frame(149), frame(0))).toBeLessThanOrEqual(biggest); // the loop closes like any other step + + // The card at (200, 100), row 1 column 2: gold at the top of its rise, the black surface as it starts. + const start = risePhase(2, 1, 5), { life } = riseTiming(5); + const at = (t: number) => { const p = (100 * 400 + 180) * 3, f = frame(Math.round((t % 5) * 30) % 150); return [f[p], f[p + 1], f[p + 2]]; }; + expect(at(start + life / 2)[0]).toBeGreaterThan(200); + expect(at(start + life / 2)[2]).toBeLessThan(80); + expect(at(start)[0]).toBeLessThan(40); + }); + + it("writes a placement's video from animate.rise", async () => { + const icon = await writeImage("icon.png", 1280, 4); + const background = await writeImage("bg.png", 2000, 3); + // Cards far apart, so the 4K canvas holds a couple of dozen and little of it moves. + const cards = [[500, 250], [1500, 250], [0, 750], [1000, 750], [2000, 750]] + .map(([x, y]) => ``).join(""); + const art = join(TMP, "big.svg"); + writeFileSync(art, `${cards}`); + const config = resolveConfig({ + icon, + background, + color: "#000000", + overrides: { appStore: { enabled: true, header: { source: art, animate: { rise: 5 } } } }, + } as never); + const written = await generateAppStoreVideos(join(TMP, "AppStore"), config); + expect(written.map((path) => path.split("/").at(-1))).toEqual(["header.mp4"]); + const probe = JSON.parse( + execFileSync("ffprobe", ["-v", "error", "-show_streams", "-of", "json", written[0]], { encoding: "utf-8" }), + ) as { streams: Record[] }; + expect(probe.streams.find((stream) => stream.codec_type === "video")).toMatchObject({ width: 3840, height: 1646, nb_frames: "150" }); + // Unchanged inputs: nothing is redrawn. + expect(await generateAppStoreVideos(join(TMP, "AppStore"), config)).toEqual([]); + }); +}); + describeWithFfmpeg("encodeRows (ffmpeg)", () => { it("slides the rows in a seamless loop, the mark riding its card", async () => { const source = join(TMP, "wall.svg"); diff --git a/tests/utils/rise-frames.test.ts b/tests/utils/rise-frames.test.ts new file mode 100644 index 0000000..a0fc105 --- /dev/null +++ b/tests/utils/rise-frames.test.ts @@ -0,0 +1,139 @@ +jest.setTimeout(120000); + +import sharp from "sharp"; +import { riseFrames, riseHeight, risePhase, riseTiming, rowStill } from "../../src/utils/rise-frames"; +import { parseRowArtwork, renderRows } from "../../src/utils/svg-rows"; +import { RAISED_SVG, ROW_SVG } from "../fixtures/row-artwork"; + +const CENTRE = { x: 200, y: 100 }; + +function meanDiff(a: Buffer, b: Buffer): number { + let d = 0; + for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]); + return d / a.length; +} + +const pixel = (frame: Buffer, x: number, y: number, width = 400): number[] => [...frame.subarray((y * width + x) * 3, (y * width + x) * 3 + 3)]; + +describe("riseTiming", () => { + it("gives every card a whole number of appearances per loop", () => { + expect(riseTiming(20)).toEqual({ period: 10, life: 4.4 }); + expect(riseTiming(30)).toEqual({ period: 10, life: 4.4 }); + expect(riseTiming(15).period).toBe(7.5); + expect(riseTiming(15).life).toBeCloseTo(3.3, 9); + expect(riseTiming(5)).toEqual({ period: 5, life: 2.2 }); + }); +}); + +describe("riseHeight", () => { + it("is flat at both ends, fully up in the middle, and the same on the way down", () => { + expect(riseHeight(0, 4.4)).toBe(0); + expect(riseHeight(4.4, 4.4)).toBe(0); + expect(riseHeight(-1, 4.4)).toBe(0); + expect(riseHeight(9, 4.4)).toBe(0); + expect(riseHeight(2.2, 4.4)).toBeCloseTo(1, 9); + expect(riseHeight(1.3, 4.4)).toBeCloseTo(riseHeight(3.1, 4.4), 9); + }); + + it("holds a faint relief before the rise, and only ever climbs on the way up", () => { + expect(riseHeight(0.9, 4.4)).toBeCloseTo(0.2, 2); + let last = 0; + for (let t = 0; t <= 2.2; t += 0.05) { + const h = riseHeight(t, 4.4); + expect(h).toBeGreaterThanOrEqual(last); + last = h; + } + }); +}); + +describe("risePhase", () => { + it("starts each card inside the period, neighbours well apart", () => { + for (let k = -2; k < 4; k++) { + for (let n = -2; n < 6; n++) { + const p = risePhase(n, k, 10); + expect(p).toBeGreaterThanOrEqual(0); + expect(p).toBeLessThan(10); + // Apart around the loop: the next card in the row, and the one below. + for (const other of [risePhase(n + 1, k, 10), risePhase(n, k + 1, 10)]) { + const gap = Math.abs(p - other); + expect(Math.min(gap, 10 - gap)).toBeGreaterThan(2); + } + } + } + }); +}); + +describe("rowStill", () => { + it("draws filtered art as part of a bigger wall, with no edge of its own at the canvas", async () => { + const art = parseRowArtwork(RAISED_SVG); + const still = await rowStill(art, 400, 200, CENTRE).raw().toBuffer(); + const wall = await sharp(Buffer.from(renderRows(art, 480, 280, undefined, { x: 240, y: 140 }))) + .extract({ left: 40, top: 40, width: 400, height: 200 }).flatten({ background: "#000" }).removeAlpha().raw().toBuffer(); + expect(meanDiff(still, wall)).toBeLessThan(0.005); + // Drawn at the canvas size, the shadows at its edges come out differently. + const cropped = await sharp(Buffer.from(renderRows(art, 400, 200))).flatten({ background: "#000" }).removeAlpha().raw().toBuffer(); + expect(meanDiff(cropped, wall)).toBeGreaterThan(0.03); + }); + + it("draws art without filters exactly as the canvas-sized render does", async () => { + const art = parseRowArtwork(ROW_SVG); + const plain = await sharp(Buffer.from(renderRows(art, 400, 200))).flatten({ background: "#000" }).removeAlpha().raw().toBuffer(); + expect((await rowStill(art, 400, 200, CENTRE).raw().toBuffer()).equals(plain)).toBe(true); + }); +}); + +describe("riseFrames", () => { + // The card under the red mark: row 1 (y 100), its third card (x 200). + const start = risePhase(2, 1, 5), { life } = riseTiming(5); + + it("lifts a card out of the bare surface, with what rides it, and back", async () => { + const art = parseRowArtwork(RAISED_SVG); + const frames = await riseFrames(art, 400, 200, CENTRE, 5); + const still = await rowStill(art, 400, 200, CENTRE).raw().toBuffer(); + + const flat = await frames.frame(start); + expect(pixel(flat, 200, 100)).toEqual([0, 0, 0]); // no card, no mark + expect(pixel(flat, 180, 100)).toEqual([0, 0, 0]); + + const up = await frames.frame(start + life / 2); + for (const [x, y] of [[200, 100], [180, 100], [233, 123], [165, 100]]) { + // The mark, the gold face, the shadow below and right, the gap to the left: as in the still. + for (let c = 0; c < 3; c++) expect(Math.abs(pixel(up, x, y)[c] - pixel(still, x, y)[c])).toBeLessThanOrEqual(2); + } + expect(pixel(up, 200, 100)[0]).toBeGreaterThan(200); + expect(pixel(up, 200, 100)[1]).toBeLessThan(60); + + const half = await frames.frame(start + life * 0.36); + const gold = pixel(half, 180, 100)[0]; + expect(gold).toBeGreaterThan(40); // on its way + expect(gold).toBeLessThan(pixel(up, 180, 100)[0]); + }); + + it("casts a shorter shadow from a card part of the way up", async () => { + const art = parseRowArtwork(RAISED_SVG); + const frames = await riseFrames(art, 400, 200, CENTRE, 5); + // 4 px right of the card's edge at x 230: inside the full shadow (offset 5), outside the low one. + const up = pixel(await frames.frame(start + life / 2), 234, 104)[0]; + const low = pixel(await frames.frame(start + life * 0.15), 234, 104)[0]; + expect(up).toBeGreaterThan(60); + expect(low).toBeLessThan(10); + }); + + it("loops: a frame one loop later is the same frame", async () => { + const frames = await riseFrames(parseRowArtwork(RAISED_SVG), 521, 303, { x: 233.5, y: 160.25 }, 5); + for (const t of [0, 0.4, 2.71]) expect((await frames.frame(t)).equals(await frames.frame(t + 5))).toBe(true); + }); + + it("refuses art drawn through the mask by more than one element, and ", async () => { + const twice = ROW_SVG.replace("\n", '\n'); + await expect(riseFrames(parseRowArtwork(twice), 400, 200, CENTRE, 5)).rejects.toThrow(/one element must draw through the mask/); + const blinking = ROW_SVG.replace('fill="#FF0000"/>', 'fill="#FF0000">'); + await expect(riseFrames(parseRowArtwork(blinking), 400, 200, CENTRE, 5)).rejects.toThrow(/does not play /); + }); + + it("refuses art whose cards do not draw on their own", async () => { + // A shadow thrown 60 px sideways lands on the next card: sprites laid in turn cannot match the still. + const far = RAISED_SVG.replace('dx="5" dy="5"', 'dx="60" dy="0"').replace('flood-color="#808080"', 'flood-color="#FFFFFF"').replace('', ''); + await expect(riseFrames(parseRowArtwork(far), 400, 200, CENTRE, 5)).rejects.toThrow(/each card must draw on its own/); + }); +}); diff --git a/tests/utils/svg-rows.test.ts b/tests/utils/svg-rows.test.ts index 6a0385d..a1d6bd6 100644 --- a/tests/utils/svg-rows.test.ts +++ b/tests/utils/svg-rows.test.ts @@ -3,8 +3,8 @@ jest.setTimeout(60000); import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import sharp from "sharp"; -import { animateAt, animationPeriods, parseRowArtwork, pathCentre, renderRows, riders } from "../../src/utils/svg-rows"; -import { ROW_SVG } from "../fixtures/row-artwork"; +import { animateAt, animationPeriods, filterReach, liftFilters, parseRowArtwork, pathCentre, renderRows, riders, risenCard } from "../../src/utils/svg-rows"; +import { RAISED_SVG, ROW_SVG } from "../fixtures/row-artwork"; const EXAMPLES = join(__dirname, "../../examples/row-artwork"); @@ -107,6 +107,49 @@ describe("renderRows", () => { // Row 1 (y 100) moves half a pitch: its gap at x 50 becomes a card, its card at x 100 a gap. expect(await goldAt(svg, [[50, 100], [100, 100], [50, 20]])).toEqual([true, false, true]); }); + + it("leaves one card when given its row and column", async () => { + const art = parseRowArtwork(ROW_SVG); + // Row 1 (y 100) starts at x 0: column 2 is the card at x 200. + const svg = renderRows(art, 400, 200, undefined, undefined, { k: 1, n: 2 }); + expect(await goldAt(svg, [[180, 100], [100, 100], [300, 100], [150, 20]])).toEqual([true, false, false, false]); + }); +}); + +describe("filters of a rising card", () => { + const shadow = ''; + + it("measures how far they carry an edge", () => { + expect(filterReach(shadow)).toBe(18 + 3 * 10); + expect(filterReach(ROW_SVG)).toBe(0); + expect(filterReach(RAISED_SVG)).toBe(5 + 3 * 2); + }); + + it("grows offsets with the height and blurs from a fifth, and touches nothing else", () => { + expect(liftFilters(shadow, 1)).toBe(shadow); + expect(liftFilters(shadow, 0.5)).toBe( + '', + ); + expect(liftFilters(shadow, 0)).toContain(''); + expect(liftFilters(shadow, 0)).toContain('stdDeviation="2 0.8"'); + }); +}); + +describe("risenCard", () => { + it("keeps what is drawn through the mask, inside the groups that hold it, and drops the rest", () => { + const card = risenCard(RAISED_SVG, 0.5, "") as string; + expect(card).toContain(''); + expect(card).toContain(''); + expect(card).toContain(''); + expect(card).not.toContain('fill="#000000"'); // the surface + expect(card).not.toContain('fill="#FF0000"'); // the mark riding a card + expect(() => parseRowArtwork(card)).not.toThrow(); + }); + + it("is undefined when nothing draws through the mask", () => { + expect(risenCard(ROW_SVG.replace(' mask="url(#m)"', ""), 1)).toBeUndefined(); + }); }); /** A minimal row artwork whose mask holds `cards`, with optional extra markup. */