Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<placement>.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)

Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ SVG inputs are rasterized at the density each size needs, so a small viewBox sti
| `--app-store-center <path>` | `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.<placement>.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.<placement>.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.<placement>.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.<placement>.video` | same keys | path | none | A [recording](#recordings) cut into the placement's looping video. Not for `universal`. |
| `--set appStore.universal.enabled=false` | `appStore.<placement>.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.<placement>.{backgroundImage,centerImage,center}` | same keys | per placement | `center: true` | Per-placement backdrop and centred art; `center: false` writes the backdrop alone. |
Expand Down Expand Up @@ -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
<rect width="3840" height="1646" fill="#FFC312"/>
<mask id="cards">…the cards…</mask>
<g filter="url(#lift)">
<g mask="url(#cards)">…what each card shows…</g>
</g>
```

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 `<g mask="url(#id)">` | That element is the card. |
| No `<animate>` | 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 <udid> 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.
Expand Down
10 changes: 7 additions & 3 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mask> 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." },
Expand Down
8 changes: 7 additions & 1 deletion src/cli/set-option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ const OPTIONAL_STRING_PATHS: ReadonlySet<string> = new Set([
]);

/** Number keys with no default, so absent from the shape template. */
const OPTIONAL_NUMBER_PATHS: ReadonlySet<string> = new Set(["appStore.video.audioStart", "appStore.video.audioEnd"]);
const OPTIONAL_NUMBER_PATHS: ReadonlySet<string> = 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
Expand Down
9 changes: 7 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>)[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 <mask>.`);
}
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.`);
}
}
}
Expand Down
47 changes: 34 additions & 13 deletions src/generators/app-store-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -283,23 +279,46 @@ export async function encodeRows(options: {
audio?: string;
/** Seconds into `audio` the loop is taken from. */
audioWindow?: { start?: number; end?: number };
}): Promise<void> {
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<void> {
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<void> {
const { width, height, seconds, centre } = options;
const art = parseRowArtwork(readFileSync(options.source, "utf8"));
for (const dur of animationPeriods(art.source)) {
const cycles = seconds / dur;
if (!(dur > 0) || Math.abs(cycles - Math.round(cycles)) > 1e-6) {
throw new Error(`Row artwork: an <animate> 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 => ({
offsets: (k) => rowOffset(k, t, seconds, art.pitchX),
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<Buffer>): Promise<void> {
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(";");
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 8 additions & 4 deletions src/generators/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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" });
Expand All @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,11 @@ export interface AppStorePlacementConfig {
* Row artwork (an SVG whose <mask> 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;
}
Expand Down
Loading
Loading