diff --git a/AGENTS.md b/AGENTS.md index b68f6055..9759d50f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,13 +307,13 @@ installation, players, backend services, the site, or production deployment. - `media upload --tag TAG` stores a 1–32 letter-or-digit tag on the ready object at declare. It is not redeclared at commit. Pattern `^[A-Za-z0-9]{1,32}$`; reject other values locally as `usage_error`. -- `media list [--tag TAG] [--kind image|video]` forwards those query - filters to `GET /api/v1/media`. Untagged objects are omitted when `--tag` +- `media list [--tag TAG] [--primitive image|video]` forwards `tag` and + `primitive` parameters to `GET /api/v1/media`. Untagged objects are omitted when `--tag` is present. - `media update (--tag TAG | --clear-tag) --if-match REVISION` binds `PATCH /api/v1/media/{id}`. Exactly one of `--tag` or `--clear-tag`. `--clear-tag` sends `null`. There is no other media metadata patch. Do - not add an update path for filename, kind, or codecs. + not add an update path for filename, primitive, or codecs. - This surface is **repository-ready** on public `main`. It is not in the locked plugin bundle. Do not claim marketplace or deployed. @@ -342,10 +342,10 @@ installation, players, backend services, the site, or production deployment. ## Page motion - Default templated pages stay `{ type: "crossfade", duration_ms: 200 }` with - no placement `enter`. Do not flip that default to swipe. + no object `enter`. Do not flip that default to swipe. - Full pages are opaque: the CLI forwards `transition.type` swipe variants (`swipe-left`, `swipe-right`, `swipe-up`, `swipe-down`) and optional - placement `enter: { type }` with that same object name. No snake_case + object `enter: { type }` with that same object name. No snake_case inside `enter`. `duration_ms` is required, 0 through 60000. Swipe authoring default when chosen is `duration_ms: 600`. - Teach swipe and `enter` as spare emphasis, not as the ordinary page. @@ -354,6 +354,7 @@ installation, players, backend services, the site, or production deployment. - `Application` carries no `state` and no `release_id`. It reports `latest_ready_release`. `OperationAccepted.release_id` is required, so `app upload` reports the release id without waiting on the operation result. + An application primitive pins that `release_id`; it has no selector. Knowing the id is not readiness; the operation still decides that. Optional `--name` (at most 120 characters, no line break) is sent as `ScreenRig-Application-Name`. Every upload still creates a new @@ -407,10 +408,13 @@ installation, players, backend services, the site, or production deployment. redacts and bounds the stderr tail; keep it on that path. - Transcoded bytes live in a `mkdtemp` directory the caller removes. Every failure path after that directory exists must remove it. -- Playlist pages the CLI emits may use only `image`, `video`, `iframe`, and - `application`. Do not emit native `text`, `box`, or `line`. Copy and chrome +- Playlist pages the CLI emits use `primitives[]`. Four wire primitives exist: + `image`, `video`, `iframe`, and `application`, named by the `primitive` + field. Image and video require a `selector` whose `by` is `id`, `ids`, `all`, + or `tag`; iframe and application do not take selectors. Do not emit native + `text`, `box`, or `line`. Copy and chrome are composed locally with `compose catalog` / `compose render`, uploaded as - `image`, then placed as one image. `playlist templates` is a local catalog. + `image`, then used as one image primitive. `playlist templates` is a local catalog. Templated pages that would emit vector chrome fail with `usage_error` pointing at those compose commands. Do not silently rasterize and upload. - `canvas.background` is a solid uppercase `#RRGGBBAA` or a top-to-bottom diff --git a/README.md b/README.md index 81ccafaf..55910e12 100644 --- a/README.md +++ b/README.md @@ -164,23 +164,23 @@ ScreenRig content has three families: static images, including stills produced by local compose; motion video; and web content delivered as an `iframe` or `application`. -Playlist pages on the wire use `image`, `video`, `iframe`, and `application` -only. Copy and chrome are composed locally, uploaded as `image`, then placed -as one image. `playlist templates` is a local catalog of slide ids; templates -that would emit native `text`, `box`, or `line` fail with a pointer at +Four wire primitives are supported: `image`, `video`, `iframe`, and +`application`. Copy and chrome are composed locally, uploaded as `image`, +then used as one image primitive. `playlist templates` is a local catalog of +slide ids; templates that would emit native `text`, `box`, or `line` fail with a pointer at `compose catalog` and `compose render`. Picture-only templates still expand -to image or video placements with selectors. A page without `template` is -forwarded unchanged when its placements are those wire kinds. +to image or video primitives with selectors. A page without `template` is +forwarded unchanged when its `primitives` use those four wire primitives. `canvas.background` is a solid uppercase `#RRGGBBAA` or a top-to-bottom linear gradient (`type` `linear`, 2 through 8 strictly increasing stops, -first `at` 0, last `at` 1, no angle). Image and video placements write a +first `at` 0, last `at` 1, no angle). Image and video primitives write a `selector` (`by` is `id`, `ids`, `all`, or `tag`). Do not put `media_id` on -the content object, and do not send server-resolved `items`. Page advance -uses `duration`, `application`, or `media_end`. There is no `video_end` -mode. +the primitive itself; it belongs inside the selector. Iframe and application +primitives do not take a selector. Page advance uses `duration`, `application`, +or `media_end`. There is no `video_end` mode. Default pages use `{ "type": "crossfade", "duration_ms": 200 }` and put no -`enter` on placements. `transition.type` may also be `swipe-left`, +`enter` on objects. `transition.type` may also be `swipe-left`, `swipe-right`, `swipe-up`, or `swipe-down`. `duration_ms` is required and runs from 0 through 60000. When a swipe type is chosen, write `duration_ms: 600`. That is the authoring default for swipe, not a schema @@ -188,17 +188,17 @@ default, and it does not change the template default. Swipe is the incoming page's type. The outgoing page follows so the edges stay touching. The name is motion direction: `swipe-left` moves content left. -Optional placement `enter` is `{ "type": "..." }` with that same object -name on playlist JSON. There is no snake_case rename inside it. Types are +Optional object `enter` is `{ "type": "..." }` with that same object name on +playlist JSON. There is no snake_case rename inside it. Types are `fade-up`, `fade-down`, `fade-left`, `fade-right`, `fade-in`, `zoom-in`, and `zoom-out`. Absent means no object animation. Use swipe and `enter` sparingly, for emphasis or a particular style, not on every page. If you -want object animation, layer the content and put the motion on the -top-layer text or images. Do not animate every placement. Object enter +want object animation, layer the primitives and put the motion on the +top-layer text or images. Do not animate every object. Object enter starts invisible. It runs 500 ms after the page occupies the full viewport, for 400 ms. Those delays are contract constants, not author fields and not CLI flags. The pinned CLI implements these playlist document -fields, and the control plane accepts swipe types and placement `enter`. This +fields, and the control plane accepts swipe types and object `enter`. This does not establish marketplace availability or deployment. The ordinary pair command currently accepts six canonical characters: @@ -405,7 +405,7 @@ renames it into place. The bundle contains `screenrig-bundle.json`, `playlist.json`, and content-addressed `media/.` files. The manifest records `selector_policy: "snapshot"` and `comments_policy: "excluded"`. Dynamic `all` and `tag` selectors become exact -`id` or `ids` snapshots. Iframes remain URLs. Application placements are +`id` or `ids` snapshots. Iframes remain URLs. Application primitives are rejected before any media download because v1 has no application-package export. @@ -509,7 +509,7 @@ relative to the spec file. The CLI does not fetch URLs. `--open` is only for viewing the still on this computer. Agent vision reads the file path. Do not cat pixels into chat. -A playlist page for that still is one `image` placement whose `rect` is the +A playlist page for that still is one `image` primitive whose `rect` is the canvas and whose `content_fit` is `fill`. ## Events @@ -530,7 +530,7 @@ screenrig --json playback list --screen-id scr_01 --day 2026-08-14 ``` A human line looks like -`at=2026-08-14T17:00:00.000Z type=application.event severity=info code=cta.pressed placement_id=weather`. +`at=2026-08-14T17:00:00.000Z type=application.event severity=info code=cta.pressed primitive_id=weather`. It carries `at`, `type`, `severity`, optional resource fields, scalar details, and a non-canned `message`. Canned server sentences are omitted. An `application.event` or `runtime.reported` with no remaining data is silent. A @@ -664,12 +664,13 @@ screenrig --json media upload ./clip.mov screenrig --json media upload ./clip.mov --codec hevc screenrig --json media upload ./poster.png --no-transcode screenrig --json media upload ./lobby-welcome.png --tag lobby -screenrig --json media list --tag lobby --kind image +screenrig --json media list --tag lobby --primitive image screenrig --json media update med_01 --tag lobby --if-match 1 screenrig --json media update med_01 --clear-tag --if-match 2 ``` -`media list` forwards `--tag` and `--kind image|video` as query filters. +`media list` forwards `--tag` and `--primitive image|video` to +`GET /api/v1/media` as `tag` and `primitive` parameters. Untagged objects are omitted when `--tag` is present. `media update` patches the tag only; `--clear-tag` sends `null`. There is no other media metadata patch. diff --git a/src/adapters/protocol.test.ts b/src/adapters/protocol.test.ts index a7420a45..a83b060c 100644 --- a/src/adapters/protocol.test.ts +++ b/src/adapters/protocol.test.ts @@ -102,7 +102,7 @@ test("adapter shapes mirror generated OpenAPI contract fields and Capabilities l "updated_at", ]); // release_id is required, not optional. The server has always returned it and - // it is the only handle a playlist placement accepts, so a caller must never + // it is the only handle an application primitive accepts, so a caller must never // have to poll the operation to learn it. const operationAccepted = interfaceBody(source, "OperationAccepted"); assert.deepEqual(quotedProperties(operationAccepted), [ @@ -501,7 +501,7 @@ test("published problem codes include payment_required at 402", () => { assert.doesNotMatch(source, /stripe|x402/i); }); -test("playlist transition types include swipe and optional placement enter", () => { +test("pinned backend snapshot includes swipe and its legacy placement enter schema", () => { const generated = readFileSync(GENERATED_CONTRACT, "utf8"); const openapi = readFileSync(OPENAPI_CONTRACT, "utf8"); @@ -570,7 +570,7 @@ test("playlist writes send a media selector and media_end, not a singular media_ assert.doesNotMatch(openapi, /enum: \[video_end\]/); }); -test("playlist and runtime placements are image, video, iframe, and application only", () => { +test("pinned backend snapshot still carries the pre-primitive placement unions", () => { const generated = readFileSync(GENERATED_CONTRACT, "utf8"); const openapi = readFileSync(OPENAPI_CONTRACT, "utf8"); diff --git a/src/adapters/protocol.ts b/src/adapters/protocol.ts index e7e1a7b3..8d306cec 100644 --- a/src/adapters/protocol.ts +++ b/src/adapters/protocol.ts @@ -133,7 +133,7 @@ export interface OperationAccepted { id: string; /** * Release created by this upload. The server has always returned it and the - * contract now requires it. It is the only handle a playlist placement + * contract now requires it. It is the only handle an application primitive * accepts, so report it rather than making the caller poll the operation. */ release_id: string; @@ -238,11 +238,11 @@ export function limitsFromCapabilities(capabilities: Capabilities): ArchiveLimit /** * Full playlist pages stay opaque: the CLI forwards author-supplied playlist * JSON unchanged, including `transition.type` swipe variants and optional - * placement `enter`. Templated pages are the exception — the CLI expands + * object `enter`. Templated pages are the exception — the CLI expands * `template` + `slots` into an ordinary write page in `playlist-templates.ts` * and never sends `template` to the server. Omitted templated transitions stay * `{ type: "crossfade", duration_ms: 200 }` with no `enter`. The contract's - * page, placement, and content schemas are still not mirrored here except for + * page and primitive schemas are still not mirrored here except for * the fields that expander writes. Mirror a schema only when the CLI builds or * reads its fields. * diff --git a/src/argv.ts b/src/argv.ts index 5c1b5de1..e60641dd 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -62,6 +62,7 @@ const VALUE_FLAGS = new Set([ "body-file", "command", "kind", + "primitive", "level", "text", "duration-ms", diff --git a/src/cli.test.ts b/src/cli.test.ts index 5430500a..384c61da 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1595,10 +1595,10 @@ test("formatEventLine prints logfmt, never canned messages", () => { type: "application.event", severity: "info", message: "Application emitted an event", - details: { extra: { nested: true }, count: 2, placement_id: "weather", code: "cta.pressed" }, + details: { extra: { nested: true }, count: 2, primitive_id: "weather", code: "cta.pressed" }, at: "2026-08-14T17:00:00.000Z", }), - "at=2026-08-14T17:00:00.000Z type=application.event severity=info code=cta.pressed placement_id=weather count=2", + "at=2026-08-14T17:00:00.000Z type=application.event severity=info code=cta.pressed primitive_id=weather count=2", ); assert.equal( formatEventLine({ @@ -1675,10 +1675,10 @@ test("formatEventLine prints logfmt, never canned messages", () => { severity: "info", message: "Doors open", resource: { type: "screen", id: "scr_1", revision: 3 }, - details: { code: "cta.pressed", placement_id: "weather", note: 'said "hi"' }, + details: { code: "cta.pressed", primitive_id: "weather", note: 'said "hi"' }, at: "2026-08-18T19:30:48.471Z", }), - 'at=2026-08-18T19:30:48.471Z type=application.event severity=info resource_type=screen resource_id=scr_1 code=cta.pressed placement_id=weather note="said \\"hi\\"" message="Doors open"', + 'at=2026-08-18T19:30:48.471Z type=application.event severity=info resource_type=screen resource_id=scr_1 code=cta.pressed primitive_id=weather note="said \\"hi\\"" message="Doors open"', ); assert.equal( formatEventLine({ @@ -1705,10 +1705,10 @@ test("formatEventLine prints logfmt, never canned messages", () => { type: "application.event", severity: "info", message: "cta.pressed", - details: { code: "cta.pressed", placement_id: "weather" }, + details: { code: "cta.pressed", primitive_id: "weather" }, at: "2026-08-14T17:00:07.000Z", }), - "at=2026-08-14T17:00:07.000Z type=application.event severity=info code=cta.pressed placement_id=weather", + "at=2026-08-14T17:00:07.000Z type=application.event severity=info code=cta.pressed primitive_id=weather", ); assert.equal( formatEventLine({ @@ -1810,7 +1810,7 @@ test("events list prints data or silence", async () => { type: "application.event", severity: "info", message: "Application emitted an event", - details: { code: "cta.pressed", placement_id: "weather" }, + details: { code: "cta.pressed", primitive_id: "weather" }, at: "2026-08-14T17:00:00.000Z", }, { @@ -1845,7 +1845,7 @@ test("events list prints data or silence", async () => { assert.equal(printed.code, 0, printed.stdout); assert.equal( printed.stdout, - "at=2026-08-14T17:00:00.000Z type=application.event severity=info code=cta.pressed placement_id=weather\nat=2026-08-14T17:00:01.000Z type=runtime.reported severity=warning code=decoder.stalled\n", + "at=2026-08-14T17:00:00.000Z type=application.event severity=info code=cta.pressed primitive_id=weather\nat=2026-08-14T17:00:01.000Z type=runtime.reported severity=warning code=decoder.stalled\n", ); assert.ok(!printed.stdout.includes("Application emitted an event")); assert.ok(!printed.stdout.includes("Runtime reported a bounded condition")); @@ -2039,7 +2039,7 @@ test("events --json omits tokens, pixels, authorization, and object keys", async test("events follow prints scalar details and skips empty frames", async () => { const transport = memoryBackend(); transport.pushStream( - "id: ev1_1\nevent: message\ndata: {\"cursor\":\"ev1_1\",\"type\":\"application.event\",\"severity\":\"info\",\"message\":\"Application emitted an event\",\"details\":{\"code\":\"cta.pressed\",\"placement_id\":\"weather\"},\"at\":\"2026-08-14T17:00:00.000Z\"}\n\n", + "id: ev1_1\nevent: message\ndata: {\"cursor\":\"ev1_1\",\"type\":\"application.event\",\"severity\":\"info\",\"message\":\"Application emitted an event\",\"details\":{\"code\":\"cta.pressed\",\"primitive_id\":\"weather\"},\"at\":\"2026-08-14T17:00:00.000Z\"}\n\n", ); transport.pushStream( "id: ev1_2\nevent: message\ndata: {\"cursor\":\"ev1_2\",\"type\":\"runtime.reported\",\"severity\":\"warning\",\"message\":\"Runtime reported a bounded condition\",\"details\":{\"code\":\"decoder.stalled\"},\"at\":\"2026-08-14T17:00:01.000Z\"}\n\n", @@ -2059,7 +2059,7 @@ test("events follow prints scalar details and skips empty frames", async () => { assert.equal(code, 0, stdout); assert.equal( stdout, - "at=2026-08-14T17:00:00.000Z type=application.event severity=info code=cta.pressed placement_id=weather\nat=2026-08-14T17:00:01.000Z type=runtime.reported severity=warning code=decoder.stalled\n", + "at=2026-08-14T17:00:00.000Z type=application.event severity=info code=cta.pressed primitive_id=weather\nat=2026-08-14T17:00:01.000Z type=runtime.reported severity=warning code=decoder.stalled\n", ); assert.ok(!stdout.includes("Application emitted an event")); assert.ok(!stdout.includes("Runtime reported a bounded condition")); @@ -3236,9 +3236,11 @@ test("comment show, set, and delete bind word commands to /api/v1/comment routes canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "duration", after_ms: 8000 }, - placements: [{ + primitives: [{ id: "hero", - content: { type: "iframe", src: "https://example.com/", title: "Lobby" }, + primitive: "iframe", + src: "https://example.com/", + title: "Lobby", rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "fill", @@ -3785,10 +3787,11 @@ async function scheduledPlaylistFixture(scheduled: boolean, dir: string) { transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "duration", duration_ms: 8000 }, ...(visibility ? { visibility } : {}), - placements: [ + primitives: [ { id: "poster", - content: { type: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, + primitive: "image", + selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" }, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "contain", @@ -3896,10 +3899,11 @@ test("a page disabled outright still counts as scheduled", async () => { canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "duration", duration_ms: 8000 }, - placements: [ + primitives: [ { id: "poster", - content: { type: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, + primitive: "image", + selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" }, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "contain", @@ -4263,7 +4267,7 @@ test("adding a schedule to a playlist an unzoned screen already runs is refused" await rm(scheduled.configDir, { recursive: true, force: true }); }); -test("app upload reports the release id a playlist placement needs", async () => { +test("app upload reports the release id an application primitive needs", async () => { const transport = memoryBackend(); const configDir = await testTemp("release-id-"); const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; @@ -4292,7 +4296,7 @@ test("playlist templates --json lists the fifteen closed ids without enrolling", const transport = memoryBackend(); const { code, stdout, configDir } = await withRuntime(["--json", "playlist", "templates"], transport); assert.equal(code, ExitCode.Success, stdout); - const envelope = JSON.parse(stdout) as { ok: true; data: { templates: Array<{ id: string }>; compose: { wire_kinds: string[] } } }; + const envelope = JSON.parse(stdout) as { ok: true; data: { templates: Array<{ id: string }>; compose: { wire_primitives: string[] } } }; assert.deepEqual(envelope.data.templates.map((template) => template.id), [ "slide-intro", "slide-text-only-1", @@ -4310,10 +4314,10 @@ test("playlist templates --json lists the fifteen closed ids without enrolling", "slide-photo", "slide-full-bleed", ]); - assert.deepEqual(envelope.data.compose.wire_kinds, ["image", "video", "iframe", "application"]); + assert.deepEqual(envelope.data.compose.wire_primitives, ["image", "video", "iframe", "application"]); const catalog = envelope.data as { templates: Array<{ id: string }>; - compose: { wire_kinds: string[] }; + compose: { wire_primitives: string[] }; transition: { type: string; duration_ms: number }; transition_types: string[]; swipe_duration_ms: number; @@ -4355,10 +4359,11 @@ test("playlist create expands a picture template and forwards a full page unchan canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "duration", after_ms: 8000 }, - placements: [ + primitives: [ { id: "hero", - content: { type: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, + primitive: "image", + selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" }, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "contain", @@ -4375,7 +4380,7 @@ test("playlist create expands a picture template and forwards a full page unchan id: "hero", template: "slide-full-bleed", slots: { - picture: { type: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, + picture: { primitive: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, }, }, fullPage, @@ -4389,17 +4394,17 @@ test("playlist create expands a picture template and forwards a full page unchan assert.equal(body.pages.length, 2); assert.equal("template" in body.pages[0]!, false); assert.equal("slots" in body.pages[0]!, false); - const introPlacements = body.pages[0]!.placements as Array<{ id: string; content: { type: string } }>; - assert.deepEqual(introPlacements.map((placement) => placement.id), ["picture"]); - assert.ok(introPlacements.every((placement) => placement.content.type === "image")); + const introPrimitives = body.pages[0]!.primitives as Array<{ id: string; primitive: string }>; + assert.deepEqual(introPrimitives.map((primitive) => primitive.id), ["picture"]); + assert.ok(introPrimitives.every((primitive) => primitive.primitive === "image")); assert.deepEqual(body.pages[1], fullPage); assert.deepEqual(body.pages[0]!.transition, { type: "crossfade", duration_ms: 200 }); - const heroPlacements = body.pages[0]!.placements as Array>; - assert.ok(heroPlacements.every((placement) => !("enter" in placement))); + const heroPrimitives = body.pages[0]!.primitives as Array>; + assert.ok(heroPrimitives.every((primitive) => !("enter" in primitive))); await rm(configDir, { recursive: true, force: true }); }); -test("playlist create forwards swipe transition and placement enter on a full page", async () => { +test("playlist create forwards swipe transition and object enter on a full page", async () => { const transport = memoryBackend(); const configDir = await testTemp("playlist-motion-"); const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; @@ -4413,17 +4418,19 @@ test("playlist create forwards swipe transition and placement enter on a full pa canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, transition: { type: "swipe-left", duration_ms: 600 }, advance: { mode: "duration", after_ms: 8000 }, - placements: [ + primitives: [ { id: "hero", - content: { type: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, + primitive: "image", + selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" }, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "contain", }, { id: "caption", - content: { type: "image", selector: { by: "id", media_id: "med_BBBBBBBBBBBBBBBBBBBBBBBB" } }, + primitive: "image", + selector: { by: "id", media_id: "med_BBBBBBBBBBBBBBBBBBBBBBBB" }, rect: { x: 80, y: 860, width: 1760, height: 160 }, layer: 2, content_fit: "contain", @@ -4489,10 +4496,11 @@ test("playlist create accepts a linear canvas.background on a picture template a canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: wash }, transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "duration", after_ms: 8000 }, - placements: [ + primitives: [ { id: "hero", - content: { type: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, + primitive: "image", + selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" }, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "contain", @@ -4510,7 +4518,7 @@ test("playlist create accepts a linear canvas.background on a picture template a template: "slide-full-bleed", canvas: { background: wash }, slots: { - picture: { type: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, + picture: { primitive: "image", selector: { by: "id", media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" } }, }, }, fullPage, @@ -4586,13 +4594,13 @@ test("playback list, media filters, media update, and app --name bind the consum transport.calls.length = 0; const listed = await withRuntime( - ["--json", "media", "list", "--tag", "lobby", "--kind", "image"], + ["--json", "media", "list", "--tag", "lobby", "--primitive", "image"], transport, { fs: fsLike }, ); assert.equal(listed.code, ExitCode.Success, listed.stdout); const listCall = transport.calls.find((call) => call.method === "GET" && call.path === "/api/v1/media"); - assert.deepEqual(listCall?.query, { tag: "lobby", kind: "image" }); + assert.deepEqual(listCall?.query, { tag: "lobby", primitive: "image" }); const listedEnvelope = JSON.parse(listed.stdout) as { data: { items: Array<{ tag?: string }> } }; assert.equal(listedEnvelope.data.items[0]?.tag, "lobby"); @@ -4628,12 +4636,28 @@ test("playback list, media filters, media update, and app --name bind the consum { fs: fsLike }, ); assert.equal(badTag.code, ExitCode.Usage, badTag.stdout); + + const badPrimitive = await withRuntime( + ["--json", "media", "list", "--primitive", "iframe"], + transport, + { fs: fsLike }, + ); + assert.equal(badPrimitive.code, ExitCode.Usage, badPrimitive.stdout); + assert.match(JSON.parse(badPrimitive.stdout).error.detail, /--primitive must be image or video/); + + const retiredKind = await withRuntime( + ["--json", "media", "list", "--kind", "image"], + transport, + { fs: fsLike }, + ); + assert.equal(retiredKind.code, ExitCode.Usage, retiredKind.stdout); + assert.match(JSON.parse(retiredKind.stdout).error.detail, /uses --primitive image\|video, not --kind/); } finally { await rm(configDir, { recursive: true, force: true }); } }); -test("playlist create refuses a mixed template-and-placements page before the write", async () => { +test("playlist create refuses a mixed template-and-primitives page before the write", async () => { const transport = memoryBackend(); const configDir = await testTemp("playlist-mixed-"); const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; @@ -4647,14 +4671,14 @@ test("playlist create refuses a mixed template-and-placements page before the wr file, JSON.stringify({ name: "Lobby", - pages: [{ id: "intro", template: "slide-intro", slots: { title: { text: "Welcome" } }, placements: [] }], + pages: [{ id: "intro", template: "slide-intro", slots: { title: { text: "Welcome" } }, primitives: [] }], }), ); const result = await withRuntime(["--json", "playlist", "create", file], transport, { fs: fsLike }); assert.equal(result.code, ExitCode.Usage, result.stdout); const envelope = JSON.parse(result.stdout) as { error: { code: string; detail: string } }; assert.equal(envelope.error.code, "usage_error"); - assert.match(envelope.error.detail, /mixes template and placements/); + assert.match(envelope.error.detail, /mixes template and primitives/); assert.equal(transport.calls.some((call) => call.method === "POST" && call.path === "/api/v1/playlists"), false); await rm(configDir, { recursive: true, force: true }); }); diff --git a/src/commands.ts b/src/commands.ts index 99e38b03..a56ba2e1 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -162,7 +162,7 @@ Commands: [--no-transcode] [--codec h264|hevc] [--max-fps N] [--max-edge PIXELS] [--webp-quality 1-100] [--no-progress] media show - media list [--tag TAG] [--kind image|video] + media list [--tag TAG] [--primitive image|video] media update (--tag TAG | --clear-tag) --if-match REVISION media delete --if-match REVISION compose catalog @@ -1632,7 +1632,7 @@ async function appUpload(args: ParsedArgs, runtime: CliRuntime, resolved: Awaite exitCode: ExitCode.Success, human: humanLines("Application uploaded", [ ["application_id", body.id], - // The release id is the only handle a playlist placement accepts, so + // The release id is the only handle an application primitive accepts, so // report it here rather than making the caller read the operation // result to find it. ["release_id", body.release_id], @@ -1706,16 +1706,16 @@ function applicationNameFromArgs(args: ParsedArgs): string | undefined { return name; } -function mediaKindFromArgs(args: ParsedArgs): "image" | "video" | undefined { - requireFlagValue(args, "kind", "image"); - const kind = flagString(args.flags, "kind"); - if (kind === undefined) { +function mediaPrimitiveFromArgs(args: ParsedArgs): "image" | "video" | undefined { + requireFlagValue(args, "primitive", "image"); + const primitive = flagString(args.flags, "primitive"); + if (primitive === undefined) { return undefined; } - if (kind !== "image" && kind !== "video") { - throw usageError("--kind must be image or video."); + if (primitive !== "image" && primitive !== "video") { + throw usageError("--primitive must be image or video."); } - return kind; + return primitive; } function screenListStateFromArgs(args: ParsedArgs): "archived" | undefined { @@ -1737,9 +1737,12 @@ async function mediaCommand( action: string | undefined, ): Promise { if (action === "list") { + if (Object.hasOwn(args.flags, "kind")) { + throw usageError("media list uses --primitive image|video, not --kind."); + } return simpleGet(args, runtime, resolved, "/api/v1/media", "Media", { tag: mediaTagFromArgs(args), - kind: mediaKindFromArgs(args), + primitive: mediaPrimitiveFromArgs(args), }); } const token = requireToken(resolved.token); @@ -3073,7 +3076,7 @@ export function formatEventLine(event: AccountEvent): string | undefined { const details = event.details ?? {}; const used = new Set(); - for (const key of ["code", "placement_id"]) { + for (const key of ["code", "primitive_id"]) { if (!pushLogfmtField(parts, key, details[key])) continue; used.add(key); payload += 1; diff --git a/src/compose/catalog.ts b/src/compose/catalog.ts index 5d5fc052..74550ab5 100644 --- a/src/compose/catalog.ts +++ b/src/compose/catalog.ts @@ -1,7 +1,7 @@ import { PINS, ROLES, SPACES } from "./tokens.js"; export const COMPOSE_TYPES = ["Frame", "Column", "Row", "Box", "Spacer", "Text", "Image"] as const; -export const WIRE_PLACEMENT_KINDS = ["image", "video", "iframe", "application"] as const; +export const WIRE_PRIMITIVES = ["image", "video", "iframe", "application"] as const; export const FONT_FALLBACKS = ["Helvetica Neue", "Helvetica", "Arial", "DejaVu Sans", "Liberation Sans"] as const; export interface ComposeCatalog { @@ -18,7 +18,7 @@ export interface ComposeCatalog { envelope: string; textShadow: string; }; - wire_kinds: typeof WIRE_PLACEMENT_KINDS[number][]; + wire_primitives: typeof WIRE_PRIMITIVES[number][]; font_fallbacks: typeof FONT_FALLBACKS[number][]; } @@ -37,7 +37,7 @@ export function composeCatalog(): ComposeCatalog { envelope: "structured JSON, not pixels", textShadow: "optional Text object { x, y, blur?, color }; omitted paints without a shadow", }, - wire_kinds: [...WIRE_PLACEMENT_KINDS], + wire_primitives: [...WIRE_PRIMITIVES], font_fallbacks: [...FONT_FALLBACKS], }; } @@ -55,7 +55,7 @@ export function formatComposeCatalog(catalog: ComposeCatalog): string { "fontSize: not authorable", `image_src: ${catalog.rules.image_src}`, `textShadow: ${catalog.rules.textShadow}`, - `wire_kinds: ${catalog.wire_kinds.join("|")}`, + `wire_primitives: ${catalog.wire_primitives.join("|")}`, "envelope: structured JSON, not pixels", ]; return lines.join("\n"); diff --git a/src/localhost-smoke.ts b/src/localhost-smoke.ts index 9a2a65d8..b9dc061f 100644 --- a/src/localhost-smoke.ts +++ b/src/localhost-smoke.ts @@ -126,7 +126,7 @@ async function main(): Promise { await run("app", "pack", app); // This is the documented "web app on a screen" path: upload the directory, // take the release id from the publication operation result, and pin that - // release in an application placement. The release id is the only handle a + // release in an application primitive. The release id is the only handle a // playlist accepts; the application id is for `kv` alone. const upload = await run("app", "upload", app, "--name", "Smoke board", "--poll-ms", "1"); const uploadData = upload.data as { @@ -139,7 +139,7 @@ async function main(): Promise { await run("app", "list"); await run("app", "show", "app_AAAAAAAAAAAAAAAAAAAAAAAA"); // An application-advance page requires exactly one controller application - // placement, `content_fit: "fill"`, and a `max_ms` backstop for an app that + // primitive, `content_fit: "fill"`, and a `max_ms` backstop for an app that // never calls nextPage(). await writeFile(playlist, JSON.stringify({ name: "Smoke application page", @@ -149,10 +149,11 @@ async function main(): Promise { canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "application", max_ms: 60000 }, - placements: [ + primitives: [ { id: "board", - content: { type: "application", release_id: releaseId }, + primitive: "application", + release_id: releaseId, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "fill", @@ -174,10 +175,11 @@ async function main(): Promise { enabled: true, windows: [{ days: ["fri", "sat"], start: "18:00", end: "02:00" }], }, - placements: [ + primitives: [ { id: "after-hours", - content: { type: "application", release_id: releaseId }, + primitive: "application", + release_id: releaseId, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "fill", @@ -244,7 +246,7 @@ async function main(): Promise { const mediaOperation = (mediaUpload.data as { operation?: { result?: { media_id?: string } } }).operation; const mediaId = mediaOperation?.result?.media_id ?? "med_AAAAAAAAAAAAAAAAAAAAAAAA"; await run("media", "show", mediaId); - await run("media", "list", "--tag", "lobby", "--kind", "image"); + await run("media", "list", "--tag", "lobby", "--primitive", "image"); await run("media", "update", mediaId, "--tag", "lobby2", "--if-match", "1"); await run("playback", "list", "--screen-id", "scr_PAIRINGAAAAAAAAAAAAAAAA", "--day", "2026-08-14"); await run("kv", "set", "greeting", "--application-id", "app_AAAAAAAAAAAAAAAAAAAAAAAA", "--json-value", "{\"message\":\"hello\"}"); diff --git a/src/playlist-bundle.test.ts b/src/playlist-bundle.test.ts index 16e2c45a..4e3f7e8f 100644 --- a/src/playlist-bundle.test.ts +++ b/src/playlist-bundle.test.ts @@ -40,15 +40,12 @@ function sha(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } -function mediaPlacement(selector: Record, items?: string[]) { +function mediaPrimitive(selector: Record) { return { id: "hero", - content: { - type: "image", - selector, - ...(items ? { items: items.map((media_id) => ({ media_id, intrinsic_size: { width: 1, height: 1 } })) } : {}), - alt: "Hero", - }, + primitive: "image", + selector, + alt: "Hero", rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "fill", @@ -56,7 +53,7 @@ function mediaPlacement(selector: Record, items?: string[]) { }; } -function playlist(placement: Record) { +function playlist(primitive: Record) { return { id: "pl_SOURCE", name: "Portable playlist", @@ -68,9 +65,11 @@ function playlist(placement: Record) { transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "duration", after_ms: 5000 }, comments: { private: "excluded" }, - placements: [placement, { + primitives: [primitive, { id: "web", - content: { type: "iframe", src: "https://example.com/", title: "Example" }, + primitive: "iframe", + src: "https://example.com/", + title: "Example", rect: { x: 0, y: 0, width: 100, height: 100 }, layer: 1, content_fit: "fill", @@ -84,7 +83,7 @@ function remoteMedia(id: string, bytes: Uint8Array, overrides: Record ({ status: 200, headers: {}, - body: options.playlist ?? playlist(mediaPlacement({ by: "tag", tag: "Lobby", one_at_a_time: false }, [id])), + body: options.playlist ?? playlist(mediaPrimitive({ by: "id", media_id: id })), })) .on("GET", `/api/v1/media/${id}`, () => ({ status: 200, headers: {}, body: item })) .on("HEAD", `/api/v1/media/${id}/content`, () => ({ status: 200, headers, body: undefined })) @@ -132,23 +131,24 @@ function exportTransport(bytes: Uint8Array, options: { playlist?: unknown; heade })); } -test("normalizes server playlists, snapshots dynamic selectors, preserves iframes, and removes response-only fields", () => { +test("normalizes server playlists, preserves snapshotted selectors and iframes, and removes response-only fields", () => { const normalized = normalizePlaylistForBundle(playlist( - mediaPlacement({ by: "tag", tag: "Lobby", one_at_a_time: true }, ["med_B", "med_A"]), + mediaPrimitive({ by: "ids", media_ids: ["med_B", "med_A"], one_at_a_time: true }), )); assert.deepEqual(normalized.mediaIds, ["med_A", "med_B"]); const text = JSON.stringify(normalized.playlist); - assert.doesNotMatch(text, /comments|controller|"items"|"by":"tag"/); + assert.doesNotMatch(text, /comments|controller|"by":"tag"/); assert.match(text, /"by":"ids","media_ids":\["med_B","med_A"\],"one_at_a_time":true/); - assert.match(text, /"type":"iframe"/); + assert.match(text, /"primitive":"iframe"/); }); -test("rejects application placements before any media lookup or local output", async () => { +test("rejects application primitives before any media lookup or local output", async () => { const dir = await testTemp("bundle-app-"); const output = path.join(dir, "export"); const source = playlist({ id: "app", - content: { type: "application", release_id: "rel_1" }, + primitive: "application", + release_id: "rel_1", rect: { x: 0, y: 0, width: 1, height: 1 }, layer: 0, content_fit: "fill", @@ -156,7 +156,7 @@ test("rejects application placements before any media lookup or local output", a }); const transport = new FakeTransport().on("GET", "/api/v1/playlists/pl_SOURCE", () => ({ status: 200, headers: {}, body: source })); const client = new ApiClient({ transport, token: "token" }); - await assert.rejects(() => exportPlaylistBundle({ playlistId: "pl_SOURCE", outputDirectory: output, client }), /application placements/); + await assert.rejects(() => exportPlaylistBundle({ playlistId: "pl_SOURCE", outputDirectory: output, client }), /application primitives/); assert.deepEqual(transport.calls.map((call) => `${call.method} ${call.path}`), ["GET /api/v1/playlists/pl_SOURCE"]); await assert.rejects(() => lstat(output), /ENOENT/); await rm(dir, { recursive: true, force: true }); @@ -181,7 +181,7 @@ test("exports through a private sibling, streams bytes, verifies headers and has assert.equal((await stat(path.join(output, "media", `${sha(bytes)}.png`))).mode & 0o777, 0o600); const savedPlaylist = JSON.parse(await readFile(path.join(output, PLAYLIST_BUNDLE_PLAYLIST), "utf8")); const savedManifest = JSON.parse(await readFile(path.join(output, PLAYLIST_BUNDLE_MANIFEST), "utf8")); - assert.equal(savedPlaylist.pages[0].placements[0].content.selector.by, "id"); + assert.equal(savedPlaylist.pages[0].primitives[0].selector.by, "id"); assert.equal(savedManifest.selector_policy, "snapshot"); assert.equal(savedManifest.comments_policy, "excluded"); assert.deepEqual(transport.calls.map((call) => call.method), ["GET", "GET", "HEAD", "GET"]); @@ -227,10 +227,10 @@ interface BundleSource { async function writeBundle(root: string, sources: BundleSource[]): Promise { await mkdir(path.join(root, "media"), { recursive: true }); - const placements = sources.map((source, index) => ({ - ...mediaPlacement({ by: "id", media_id: source.id }), + const primitives = sources.map((source, index) => ({ + ...mediaPrimitive({ by: "id", media_id: source.id }), id: `media_${index}`, - })).map(({ controller: _controller, ...placement }) => placement); + })).map(({ controller: _controller, ...primitive }) => primitive); const playlistJson = { name: "Imported", pages: [{ @@ -238,7 +238,7 @@ async function writeBundle(root: string, sources: BundleSource[]): Promise ({ @@ -310,27 +310,36 @@ test("preflight rejects traversal, backslash, absolute, control, and noncanonica } }); -test("preflight validates the complete playlist write surface and media kinds before network use", async () => { +test("preflight validates the complete playlist write surface and media primitives before network use", async () => { const cases: Array<[string, (playlist: any) => void]> = [ ["canvas", (value) => { value.pages[0].canvas.width = 0; }], ["transition", (value) => { value.pages[0].transition.duration_ms = 60_001; }], ["advance", (value) => { value.pages[0].advance.after_ms = 999; }], ["visibility", (value) => { value.pages[0].visibility = { enabled: true }; }], - ["rect", (value) => { value.pages[0].placements[0].rect.width = 0; }], - ["layer", (value) => { value.pages[0].placements[0].layer = 1025; }], - ["content_fit", (value) => { value.pages[0].placements[0].content_fit = "stretch"; }], - ["enter", (value) => { value.pages[0].placements[0].enter = { type: "spin" }; }], + ["rect", (value) => { value.pages[0].primitives[0].rect.width = 0; }], + ["layer", (value) => { value.pages[0].primitives[0].layer = 1025; }], + ["content_fit", (value) => { value.pages[0].primitives[0].content_fit = "stretch"; }], + ["enter", (value) => { value.pages[0].primitives[0].enter = { type: "spin" }; }], ["iframe", (value) => { - value.pages[0].placements[0].content = { type: "iframe", src: "http://127.0.0.1/private", title: "Private" }; - value.pages[0].placements[0].content_fit = "fill"; + value.pages[0].primitives[0] = { + ...value.pages[0].primitives[0], + primitive: "iframe", + src: "http://127.0.0.1/private", + title: "Private", + }; + delete value.pages[0].primitives[0].selector; + delete value.pages[0].primitives[0].alt; + value.pages[0].primitives[0].content_fit = "fill"; }], - ["media-kind", (value) => { - value.pages[0].placements[0].content = { - type: "video", + ["media-primitive", (value) => { + value.pages[0].primitives[0] = { + ...value.pages[0].primitives[0], + primitive: "video", selector: { by: "id", media_id: "med_SOURCE" }, muted: true, loop: false, }; + delete value.pages[0].primitives[0].alt; }], ]; for (const [name, mutate] of cases) { diff --git a/src/playlist-bundle.ts b/src/playlist-bundle.ts index 4cec2de9..1ae3d3c2 100644 --- a/src/playlist-bundle.ts +++ b/src/playlist-bundle.ts @@ -66,7 +66,7 @@ export interface PlaylistBundleManifest { interface RemoteMedia extends PlaylistBundleMedia { id: string; - kind?: string; + primitive?: string; } export interface PlaylistBundlePreflight { @@ -346,11 +346,11 @@ export async function preflightPlaylistBundle(directory: string): Promise [ + const mediaPrimitives = new Map(manifest.media.map((item) => [ item.source_id, item.content_type.startsWith("image/") ? "image" as const : "video" as const, ])); - const referenced = validatePlaylistWrite(playlist, mediaKinds); + const referenced = validatePlaylistWrite(playlist, mediaPrimitives); const listed = new Set(manifest.media.map((item) => item.source_id)); const missing = [...referenced].filter((id) => !listed.has(id)); const unused = [...listed].filter((id) => !referenced.has(id)); @@ -379,19 +379,24 @@ export async function preflightPlaylistBundle(directory: string): Promise 32) { - throw usageError("Playlist export requires 1 to 32 resolved media items per placement."); +function normalizedMediaSelector(input: unknown): JsonRecord { + const selector = record(input, "Playlist media selector"); + let ids: string[]; + if (selector.by === "id") { + ids = [stringField(selector, "media_id", "Playlist media selector")]; + } else if (selector.by === "ids" && Array.isArray(selector.media_ids)) { + ids = selector.media_ids.map((id) => { + if (typeof id !== "string") throw usageError("Playlist contains an invalid snapshotted media id."); + return id; + }); + } else { + throw usageError("Playlist export requires selectors snapshotted to by:id or by:ids."); + } + if (ids.length < 1 || ids.length > 32 || ids.some((id) => !MEDIA_ID_PATTERN.test(id))) { + throw usageError("Playlist export requires 1 to 32 valid snapshotted media ids per primitive."); } - const ids = items.map((value, index) => { - const item = record(value, `Resolved media item ${index}`); - const id = stringField(item, "media_id", `Resolved media item ${index}`); - if (!MEDIA_ID_PATTERN.test(id)) throw usageError("Playlist contains an invalid resolved media id."); - return id; - }); if (new Set(ids).size !== ids.length) throw usageError("Playlist contains duplicate resolved media ids in one selector."); - const originalRecord = record(original, "Playlist media selector"); - const oneAtATime = originalRecord.one_at_a_time === true; + const oneAtATime = selector.one_at_a_time === true; if (ids.length === 1 && !oneAtATime) return { by: "id", media_id: ids[0] }; return { by: "ids", media_ids: ids, one_at_a_time: oneAtATime }; } @@ -409,40 +414,39 @@ export function normalizePlaylistForBundle(input: unknown): { id: string; revisi const mediaIds = new Set(); const pages = source.pages.map((pageValue, pageIndex) => { const page = record(pageValue, `Playlist.pages[${pageIndex}]`); - if (!Array.isArray(page.placements)) throw usageError(`Playlist.pages[${pageIndex}].placements must be an array.`); - const placements = page.placements.map((placementValue, placementIndex) => { - const placement = record(placementValue, `Playlist.pages[${pageIndex}].placements[${placementIndex}]`); - const content = record(placement.content, `Playlist.pages[${pageIndex}].placements[${placementIndex}].content`); - const type = content.type; - if (type === "application") throw usageError("Playlist export does not support application placements; export stopped before media download."); - let normalizedContent: JsonRecord; - if (type === "iframe") { - normalizedContent = { - type: "iframe", - src: stringField(content, "src", "Iframe content"), - title: stringField(content, "title", "Iframe content"), + if (!Array.isArray(page.primitives)) throw usageError(`Playlist.pages[${pageIndex}].primitives must be an array.`); + const primitives = page.primitives.map((primitiveValue, primitiveIndex) => { + const primitive = record(primitiveValue, `Playlist.pages[${pageIndex}].primitives[${primitiveIndex}]`); + const category = primitive.primitive; + if (category === "application") throw usageError("Playlist export does not support application primitives; export stopped before media download."); + let normalized: JsonRecord; + if (category === "iframe") { + normalized = { + primitive: "iframe", + src: stringField(primitive, "src", "Iframe primitive"), + title: stringField(primitive, "title", "Iframe primitive"), }; - } else if (type === "image" || type === "video") { - const selector = normalizedMediaSelector(content.items, content.selector); + } else if (category === "image" || category === "video") { + const selector = normalizedMediaSelector(primitive.selector); const ids = selector.by === "id" ? [selector.media_id] : selector.media_ids; for (const mediaId of ids as string[]) mediaIds.add(mediaId); - normalizedContent = { - type, + normalized = { + primitive: category, selector, - ...(type === "image" && typeof content.alt === "string" ? { alt: content.alt } : {}), - ...(type === "image" && typeof content.dwell_ms === "number" ? { dwell_ms: content.dwell_ms } : {}), - ...(type === "video" ? { muted: content.muted === true, loop: content.loop === true } : {}), + ...(category === "image" && typeof primitive.alt === "string" ? { alt: primitive.alt } : {}), + ...(category === "image" && typeof primitive.dwell_ms === "number" ? { dwell_ms: primitive.dwell_ms } : {}), + ...(category === "video" ? { muted: primitive.muted === true, loop: primitive.loop === true } : {}), }; } else { - throw usageError("Playlist export encountered an unsupported placement content type."); + throw usageError("Playlist export encountered an unsupported primitive."); } return { - id: stringField(placement, "id", "Playlist placement"), - content: normalizedContent, - rect: cloneJson(placement.rect), - layer: placement.layer, - content_fit: placement.content_fit, - ...(placement.enter !== undefined ? { enter: cloneJson(placement.enter) } : {}), + id: stringField(primitive, "id", "Playlist primitive"), + ...normalized, + rect: cloneJson(primitive.rect), + layer: primitive.layer, + content_fit: primitive.content_fit, + ...(primitive.enter !== undefined ? { enter: cloneJson(primitive.enter) } : {}), }; }); return { @@ -451,7 +455,7 @@ export function normalizePlaylistForBundle(input: unknown): { id: string; revisi transition: cloneJson(page.transition), advance: cloneJson(page.advance), ...(page.visibility !== undefined ? { visibility: cloneJson(page.visibility) } : {}), - placements, + primitives, }; }); return { id, revision, playlist: { name, pages }, mediaIds: [...mediaIds].sort() }; @@ -590,7 +594,7 @@ export async function exportPlaylistBundle(options: { source_revision: normalized.revision, path: PLAYLIST_BUNDLE_PLAYLIST, }, - media: media.map(({ id: _id, kind: _kind, ...item }) => item), + media: media.map(({ id: _id, primitive: _primitive, ...item }) => item), }; await writePrivateJson(path.join(temporary, PLAYLIST_BUNDLE_MANIFEST), manifest); await destinationAbsent(destination); @@ -659,10 +663,9 @@ function rewritePlaylistIds(playlist: JsonRecord, mapping: Map): const output = cloneJson(playlist); const pages = output.pages as JsonRecord[]; for (const page of pages) { - for (const placement of page.placements as JsonRecord[]) { - const content = placement.content as JsonRecord; - if (content.type !== "image" && content.type !== "video") continue; - const selector = content.selector as JsonRecord; + for (const primitive of page.primitives as JsonRecord[]) { + if (primitive.primitive !== "image" && primitive.primitive !== "video") continue; + const selector = primitive.selector as JsonRecord; if (selector.by === "id") { const source = selector.media_id as string; const destination = mapping.get(source); diff --git a/src/playlist-templates.test.ts b/src/playlist-templates.test.ts index 3752629a..e56afc33 100644 --- a/src/playlist-templates.test.ts +++ b/src/playlist-templates.test.ts @@ -14,13 +14,13 @@ import { } from "./playlist-templates.js"; const MEDIA = { - type: "image" as const, + primitive: "image" as const, selector: { by: "id" as const, media_id: "med_AAAAAAAAAAAAAAAAAAAAAAAA" }, alt: "Lobby still", }; const VIDEO = { - type: "video" as const, + primitive: "video" as const, selector: { by: "id" as const, media_id: "med_BBBBBBBBBBBBBBBBBBBBBBBB" }, loop: false, }; @@ -52,20 +52,20 @@ function assertUsage(fn: () => unknown, pattern: RegExp): void { }); } -function asPlacements(value: unknown): Array<{ +function asPrimitives(value: unknown): Array<{ id: string; + primitive: string; layer: number; content_fit?: string; rect: { x: number; y: number; width: number; height: number }; - content: { type: string }; }> { - return (value as { placements: Array<{ + return (value as { primitives: Array<{ id: string; + primitive: string; layer: number; content_fit?: string; rect: { x: number; y: number; width: number; height: number }; - content: { type: string }; - }> }).placements; + }> }).primitives; } test("the catalog lists the fifteen slide ids and points copy at compose", () => { @@ -75,7 +75,7 @@ test("the catalog lists the fifteen slide ids and points copy at compose", () => assert.equal(catalog.canvas.background, SLIDE_BACKGROUND); assert.equal(catalog.canvas.width, 1920); assert.equal(catalog.canvas.height, 1080); - assert.deepEqual(catalog.compose.wire_kinds, ["image", "video", "iframe", "application"]); + assert.deepEqual(catalog.compose.wire_primitives, ["image", "video", "iframe", "application"]); assert.deepEqual(catalog.transition, { type: "crossfade", duration_ms: 200 }); assert.deepEqual(catalog.transition, SLIDE_DEFAULT_TRANSITION); assert.deepEqual(catalog.transition_types, [ @@ -101,7 +101,7 @@ test("the catalog lists the fifteen slide ids and points copy at compose", () => const formatted = formatTemplateCatalog(catalog); assert.match(formatted, /composed locally/); assert.match(formatted, /compose catalog/); - assert.match(formatted, /Default page transition is crossfade 200 ms with no placement enter/); + assert.match(formatted, /Default page transition is crossfade 200 ms with no object enter/); assert.doesNotMatch(formatted, /title \(text, required/); assert.doesNotMatch(formatted, /emit native text/); const intro = catalog.templates.find((template) => template.id === "slide-intro"); @@ -135,22 +135,22 @@ test("slide-full-bleed expands picture and logo only, with no text box or line", template: "slide-full-bleed", slots: { picture: MEDIA, logo: MEDIA }, }); - const placements = asPlacements(expanded); - assert.deepEqual(placements.map((item) => item.id), ["picture", "logo"]); - assert.ok(placements.every((item) => item.content.type === "image" || item.content.type === "video")); - assert.deepEqual(placements.find((item) => item.id === "picture")?.rect, { + const primitives = asPrimitives(expanded); + assert.deepEqual(primitives.map((item) => item.id), ["picture", "logo"]); + assert.ok(primitives.every((item) => item.primitive === "image" || item.primitive === "video")); + assert.deepEqual(primitives.find((item) => item.id === "picture")?.rect, { x: 0, y: 0, width: 1920, height: 1080, }); - assert.deepEqual(placements.find((item) => item.id === "logo")?.rect, SHARED_LOGO_RECT); - assert.equal(placements.find((item) => item.id === "logo")?.layer, 2); - assert.equal(placements.find((item) => item.id === "logo")?.content_fit, "contain"); + assert.deepEqual(primitives.find((item) => item.id === "logo")?.rect, SHARED_LOGO_RECT); + assert.equal(primitives.find((item) => item.id === "logo")?.layer, 2); + assert.equal(primitives.find((item) => item.id === "logo")?.content_fit, "contain"); }); test("slide-photo expands picture-only when caption is omitted; caption is compose", () => { - const photo = asPlacements(expandPlaylistPage({ + const photo = asPrimitives(expandPlaylistPage({ id: "still", template: "slide-photo", slots: { picture: MEDIA, logo: MEDIA }, @@ -167,7 +167,7 @@ test("slide-photo expands picture-only when caption is omitted; caption is compo }); test("a picture slot may override content_fit; omitted optional picture is dropped", () => { - const overridden = asPlacements(expandPlaylistPage({ + const overridden = asPrimitives(expandPlaylistPage({ id: "hero", template: "slide-full-bleed", slots: { picture: { ...MEDIA, content_fit: "contain" } }, @@ -195,16 +195,16 @@ test("logo rejects video; required picture is still required", () => { ); }); -test("a page without template is forwarded unchanged when placements are wire kinds", () => { +test("a page without template is forwarded unchanged when primitives use the wire vocabulary", () => { const full = { id: "poster", canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "duration", after_ms: 8000 }, - placements: [ + primitives: [ { id: "hero", - content: MEDIA, + ...MEDIA, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "contain", @@ -221,21 +221,58 @@ test("a full page that authors text, box, or line is refused", () => { () => expandPlaylistPage({ id: "poster", canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, - placements: [ + primitives: [ { id: "chrome", - content: { type, text: "nope" }, + primitive: type, + text: "nope", rect: { x: 0, y: 0, width: 1920, height: 16 }, layer: 1, content_fit: "fill", }, ], }), - /content\.type must be image\|video\|iframe\|application/, + /primitive must be image\|video\|iframe\|application/, ); } }); +test("only image and video primitives take selectors", () => { + const page = (primitive: Record) => ({ + id: "poster", + canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, + transition: { type: "crossfade", duration_ms: 200 }, + advance: { mode: "duration", after_ms: 8000 }, + primitives: [{ + id: "hero", + rect: { x: 0, y: 0, width: 1920, height: 1080 }, + layer: 0, + content_fit: "fill", + ...primitive, + }], + }); + assertUsage( + () => expandPlaylistPage(page({ primitive: "image" })), + /image requires a selector with by id\|ids\|all\|tag/, + ); + assertUsage( + () => expandPlaylistPage(page({ primitive: "video", media_id: "med_A" })), + /video requires a selector/, + ); + assertUsage( + () => expandPlaylistPage(page({ primitive: "image", selector: { by: "all" }, items: [] })), + /puts media selection in selector, not items/, + ); + assertUsage( + () => expandPlaylistPage(page({ primitive: "iframe", selector: { by: "all" }, src: "https:\/\/example.com" })), + /iframe does not take a selector/, + ); + assertUsage( + () => expandPlaylistPage(page({ primitive: "application", selector: { by: "id", media_id: "med_A" }, release_id: "rel_A" })), + /application does not take a selector/, + ); +}); + test("unknown and retired template ids name playlist templates", () => { for (const id of ["slide-hero", "slide-title"]) { assert.throws( @@ -273,9 +310,9 @@ test("templated page usage errors name the offending field", () => { id: "intro", template: "slide-intro", slots: { title: { text: "Welcome" } }, - placements: [], + primitives: [], }), - /mixes template and placements/, + /mixes template and primitives/, ); assertUsage( () => expandPlaylistPage({ @@ -304,9 +341,9 @@ test("omitted transition and advance take the slide defaults; supplied values re id: "hero", template: "slide-full-bleed", slots: { picture: MEDIA }, - }) as { transition: unknown; placements: Array> }; + }) as { transition: unknown; primitives: Array> }; assert.deepEqual(omitted.transition, { type: "crossfade", duration_ms: 200 }); - assert.ok(omitted.placements.every((placement) => !("enter" in placement))); + assert.ok(omitted.primitives.every((primitive) => !("enter" in primitive))); const custom = expandPlaylistPage({ id: "hero", template: "slide-full-bleed", @@ -326,9 +363,9 @@ test("a templated page forwards a supplied swipe transition and never invents en template: "slide-full-bleed", transition: { type: "swipe-left", duration_ms: 600 }, slots: { picture: MEDIA }, - }) as { transition: unknown; placements: Array> }; + }) as { transition: unknown; primitives: Array> }; assert.deepEqual(expanded.transition, { type: "swipe-left", duration_ms: 600 }); - assert.ok(expanded.placements.every((placement) => !("enter" in placement))); + assert.ok(expanded.primitives.every((primitive) => !("enter" in primitive))); }); test("a full page with swipe and enter is forwarded unchanged", () => { @@ -337,17 +374,17 @@ test("a full page with swipe and enter is forwarded unchanged", () => { canvas: { width: 1920, height: 1080, viewport_fit: "contain", background: "#000000FF" }, transition: { type: "swipe-left", duration_ms: 600 }, advance: { mode: "duration", after_ms: 8000 }, - placements: [ + primitives: [ { id: "hero", - content: MEDIA, + ...MEDIA, rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "contain", }, { id: "caption", - content: MEDIA, + ...MEDIA, rect: { x: 80, y: 860, width: 1760, height: 160 }, layer: 2, content_fit: "contain", @@ -399,13 +436,13 @@ test("every template uses the shared bottom-left logo rect", () => { test("no expanded template emits text, box, or line", () => { const pictureOnly = ["slide-photo", "slide-full-bleed"] as const; for (const id of pictureOnly) { - const placements = asPlacements(expandPlaylistPage({ + const primitives = asPrimitives(expandPlaylistPage({ id, template: id, slots: { picture: MEDIA, logo: MEDIA }, })); - for (const item of placements) { - assert.ok(["image", "video", "iframe", "application"].includes(item.content.type), `${id} ${item.id}`); + for (const item of primitives) { + assert.ok(["image", "video", "iframe", "application"].includes(item.primitive), `${id} ${item.id}`); } } for (const template of SLIDE_TEMPLATES) { diff --git a/src/playlist-templates.ts b/src/playlist-templates.ts index 0757e9ca..4de55077 100644 --- a/src/playlist-templates.ts +++ b/src/playlist-templates.ts @@ -1,4 +1,4 @@ -import { WIRE_PLACEMENT_KINDS } from "./compose/catalog.js"; +import { WIRE_PRIMITIVES } from "./compose/catalog.js"; import { usageError } from "./problems.js"; export const SLIDE_CANVAS_WIDTH = 1920; @@ -22,7 +22,7 @@ export const PLAYLIST_TRANSITION_TYPES = [ "swipe-up", "swipe-down", ] as const; -export const PLACEMENT_ENTER_TYPES = [ +export const OBJECT_ENTER_TYPES = [ "fade-up", "fade-down", "fade-left", @@ -498,7 +498,7 @@ export interface TemplateCatalog { compose: { catalog_command: string; render_command: string; - wire_kinds: readonly string[]; + wire_primitives: readonly string[]; }; canvas: { width: number; @@ -509,7 +509,7 @@ export interface TemplateCatalog { transition: { type: "crossfade"; duration_ms: number }; transition_types: readonly typeof PLAYLIST_TRANSITION_TYPES[number][]; swipe_duration_ms: number; - enter_types: readonly typeof PLACEMENT_ENTER_TYPES[number][]; + enter_types: readonly typeof OBJECT_ENTER_TYPES[number][]; advance: { mode: "duration"; after_ms: number }; templates: TemplateCatalogEntry[]; } @@ -519,7 +519,7 @@ export function playlistTemplateCatalog(): TemplateCatalog { compose: { catalog_command: "screenrig --json compose catalog", render_command: "screenrig --json compose render ", - wire_kinds: [...WIRE_PLACEMENT_KINDS], + wire_primitives: [...WIRE_PRIMITIVES], }, canvas: { width: SLIDE_CANVAS_WIDTH, @@ -530,7 +530,7 @@ export function playlistTemplateCatalog(): TemplateCatalog { transition: { ...SLIDE_DEFAULT_TRANSITION }, transition_types: [...PLAYLIST_TRANSITION_TYPES], swipe_duration_ms: SLIDE_SWIPE_AUTHORING_DURATION_MS, - enter_types: [...PLACEMENT_ENTER_TYPES], + enter_types: [...OBJECT_ENTER_TYPES], advance: { ...SLIDE_DEFAULT_ADVANCE }, templates: SLIDE_TEMPLATES.map((template) => ({ id: template.id, @@ -558,9 +558,9 @@ function catalogSlot(slot: SlotDef): TemplateCatalogSlot { export function formatTemplateCatalog(catalog: TemplateCatalog): string { const lines = [ "Slide layouts with copy or chrome are composed locally, uploaded as image, then placed as one image.", - `Wire kinds: ${catalog.compose.wire_kinds.join(", ")}.`, + `Wire primitives: ${catalog.compose.wire_primitives.join(", ")}.`, `Compose: ${catalog.compose.catalog_command}`, - `Default page transition is ${catalog.transition.type} ${catalog.transition.duration_ms} ms with no placement enter. Swipe and enter are optional and spare.`, + `Default page transition is ${catalog.transition.type} ${catalog.transition.duration_ms} ms with no object enter. Swipe and enter are optional and spare.`, ]; for (const template of catalog.templates) { const slots = template.slots.map((slot) => formatCatalogSlot(slot)).join("; "); @@ -576,7 +576,7 @@ function formatCatalogSlot(slot: TemplateCatalogSlot): string { function vectorChromeError(label: string): never { throw usageError( - `${label} would emit native text, box, or line placements. Compose a still with compose render, upload it as image, and place that image on the page.`, + `${label} would emit native text, box, or line primitives. Compose a still with compose render, upload it as image, and use that image primitive on the page.`, { command: "screenrig --json compose catalog", reason: "List the local compose catalog, then run compose render and media upload.", @@ -584,25 +584,37 @@ function vectorChromeError(label: string): never { ); } -function assertWirePlacementKinds(page: unknown, index: number): void { - if (!isRecord(page) || !Array.isArray(page.placements)) { +function assertWirePrimitives(page: unknown, index: number): void { + if (!isRecord(page) || !Array.isArray(page.primitives)) { return; } const label = pageLabel(page, index); - for (const [placementIndex, placement] of page.placements.entries()) { - if (!isRecord(placement) || !isRecord(placement.content)) { + for (const [primitiveIndex, primitive] of page.primitives.entries()) { + if (!isRecord(primitive)) { continue; } - const type = placement.content.type; - if (typeof type !== "string" || !(WIRE_PLACEMENT_KINDS as readonly string[]).includes(type)) { + const category = primitive.primitive; + if (typeof category !== "string" || !(WIRE_PRIMITIVES as readonly string[]).includes(category)) { throw usageError( - `${label} placements[${placementIndex}] content.type must be ${WIRE_PLACEMENT_KINDS.join("|")}. Compose copy and chrome locally.`, + `${label} primitives[${primitiveIndex}].primitive must be ${WIRE_PRIMITIVES.join("|")}. Compose copy and chrome locally.`, { command: "screenrig --json compose catalog", reason: "List the local compose catalog, then run compose render and media upload.", }, ); } + if (category === "image" || category === "video") { + if (!isRecord(primitive.selector) || !["id", "ids", "all", "tag"].includes(String(primitive.selector.by))) { + throw usageError(`${label} primitives[${primitiveIndex}] ${category} requires a selector with by id|ids|all|tag.`); + } + for (const retired of ["query", "filter", "items", "media_id", "media_ids"]) { + if (retired in primitive) { + throw usageError(`${label} primitives[${primitiveIndex}] puts media selection in selector, not ${retired}.`); + } + } + } else if ("selector" in primitive) { + throw usageError(`${label} primitives[${primitiveIndex}] ${category} does not take a selector.`); + } } } @@ -614,15 +626,15 @@ export function expandPlaylistPage(page: unknown, index = 0): unknown { if (!isRecord(page)) { return page; } - if ("template" in page && "placements" in page) { - throw usageError(`${pageLabel(page, index)} mixes template and placements. Use one page shape.`); + if ("template" in page && "primitives" in page) { + throw usageError(`${pageLabel(page, index)} mixes template and primitives. Use one page shape.`); } if (!("template" in page)) { - assertWirePlacementKinds(page, index); + assertWirePrimitives(page, index); return page; } const expanded = expandTemplatedPage(page, index); - assertWirePlacementKinds(expanded, index); + assertWirePrimitives(expanded, index); return expanded; } @@ -652,14 +664,14 @@ function expandTemplatedPage(page: Record, index: number): Reco const background = readBackground(page.canvas, label); - const placements: unknown[] = []; + const primitives: unknown[] = []; for (const slot of template.slots) { if (slot.kind !== "picture") { continue; } const pictured = readPictureSlot(slot, slots[slot.id]); if (pictured) { - placements.push(picturePlacement(slot, pictured)); + primitives.push(picturePrimitive(slot, pictured)); } } @@ -673,7 +685,7 @@ function expandTemplatedPage(page: Record, index: number): Reco }, transition: page.transition === undefined ? { ...SLIDE_DEFAULT_TRANSITION } : page.transition, advance: page.advance === undefined ? { ...SLIDE_DEFAULT_ADVANCE } : page.advance, - placements, + primitives, }; if ("visibility" in page) { expanded.visibility = page.visibility; @@ -771,36 +783,36 @@ function canvasBackground(value: unknown, name: string): CanvasBackground { function readPictureSlot( slot: PictureSlotDef, value: unknown, -): { content: Record; content_fit: "cover" | "contain" } | undefined { +): { primitive: Record; content_fit: "cover" | "contain" } | undefined { if (value === undefined) { return undefined; } if (!isRecord(value)) { throw usageError(`Slot ${slot.id} must be an object.`); } - if ("text" in value && !("type" in value)) { + if ("text" in value && !("primitive" in value)) { throw usageError( slot.accept === "image" ? `Slot ${slot.id} is an image slot and does not take text.` : `Slot ${slot.id} is an image or video slot and does not take text.`, ); } - const type = value.type; - if (type !== "image" && type !== "video") { + const primitive = value.primitive; + if (primitive !== "image" && primitive !== "video") { throw usageError( slot.accept === "image" - ? `Slot ${slot.id} must be image content.` - : `Slot ${slot.id} must be image or video content.`, + ? `Slot ${slot.id} must be an image primitive.` + : `Slot ${slot.id} must be an image or video primitive.`, ); } - if (slot.accept === "image" && type === "video") { + if (slot.accept === "image" && primitive === "video") { throw usageError(`Slot ${slot.id} is an image slot and does not take video.`); } - const allowed = type === "image" + const allowed = primitive === "image" ? slot.accept === "image" - ? ["type", "selector", "alt", "dwell_ms"] - : ["type", "selector", "alt", "dwell_ms", "content_fit"] - : ["type", "selector", "muted", "loop", "content_fit"]; + ? ["primitive", "selector", "alt", "dwell_ms"] + : ["primitive", "selector", "alt", "dwell_ms", "content_fit"] + : ["primitive", "selector", "muted", "loop", "content_fit"]; const extra = Object.keys(value).filter((key) => !allowed.includes(key)).sort(); if (extra.length > 0) { throw usageError(`Slot ${slot.id} has unsupported fields: ${extra.join(", ")}.`); @@ -809,15 +821,15 @@ function readPictureSlot( throw usageError(`Slot ${slot.id} requires a selector.`); } const content_fit = readContentFit(slot, value.content_fit); - const content: Record = { type, selector: value.selector }; - if (type === "image") { - if (value.alt !== undefined) content.alt = value.alt; - if (value.dwell_ms !== undefined) content.dwell_ms = value.dwell_ms; + const primitiveFields: Record = { primitive, selector: value.selector }; + if (primitive === "image") { + if (value.alt !== undefined) primitiveFields.alt = value.alt; + if (value.dwell_ms !== undefined) primitiveFields.dwell_ms = value.dwell_ms; } else { - if (value.muted !== undefined) content.muted = value.muted; - if (value.loop !== undefined) content.loop = value.loop; + if (value.muted !== undefined) primitiveFields.muted = value.muted; + if (value.loop !== undefined) primitiveFields.loop = value.loop; } - return { content, content_fit }; + return { primitive: primitiveFields, content_fit }; } function readContentFit(slot: PictureSlotDef, value: unknown): "cover" | "contain" { @@ -830,13 +842,13 @@ function readContentFit(slot: PictureSlotDef, value: unknown): "cover" | "contai return value; } -function picturePlacement( +function picturePrimitive( slot: PictureSlotDef, - pictured: { content: Record; content_fit: "cover" | "contain" }, + pictured: { primitive: Record; content_fit: "cover" | "contain" }, ): Record { return { id: slot.id, - content: pictured.content, + ...pictured.primitive, rect: { ...slot.rect }, layer: slot.layer, content_fit: pictured.content_fit, diff --git a/src/playlist-write-validation.test.ts b/src/playlist-write-validation.test.ts index 2e1c00a8..d56aaa98 100644 --- a/src/playlist-write-validation.test.ts +++ b/src/playlist-write-validation.test.ts @@ -3,17 +3,19 @@ import { test } from "node:test"; import { CliError } from "./problems.js"; import { validatePlaylistWrite } from "./playlist-write-validation.js"; -function iframePlacement(id: string, src: string) { +function iframePrimitive(id: string, src: string) { return { id, - content: { type: "iframe", src, title: "Example" }, + primitive: "iframe", + src, + title: "Example", rect: { x: 0, y: 0, width: 100, height: 100 }, layer: 0, content_fit: "fill", }; } -function playlistWithPlacements(placements: unknown[]) { +function playlistWithPrimitives(primitives: unknown[]) { return { name: "Lobby", pages: [{ @@ -21,28 +23,28 @@ function playlistWithPlacements(placements: unknown[]) { canvas: { width: 1920, height: 1080, background: "#000000FF" }, transition: { type: "crossfade", duration_ms: 200 }, advance: { mode: "duration", after_ms: 1000 }, - placements, + primitives, }], }; } const TWO_IFRAMES = [ - iframePlacement("web_a", "https://example.com/"), - iframePlacement("web_b", "https://example.org/"), + iframePrimitive("web_a", "https://example.com/"), + iframePrimitive("web_b", "https://example.org/"), ]; -test("playlist write accepts two iframe placements on a page", () => { - assert.deepEqual(validatePlaylistWrite(playlistWithPlacements(TWO_IFRAMES), new Map()), new Set()); +test("playlist write accepts two iframe primitives on a page", () => { + assert.deepEqual(validatePlaylistWrite(playlistWithPrimitives(TWO_IFRAMES), new Map()), new Set()); }); -test("playlist write rejects a third iframe placement on a page", () => { - const placements = [...TWO_IFRAMES, iframePlacement("web_c", "https://example.net/")]; +test("playlist write rejects a third iframe primitive on a page", () => { + const primitives = [...TWO_IFRAMES, iframePrimitive("web_c", "https://example.net/")]; assert.throws( - () => validatePlaylistWrite(playlistWithPlacements(placements), new Map()), + () => validatePlaylistWrite(playlistWithPrimitives(primitives), new Map()), (error: unknown) => { assert.ok(error instanceof CliError); assert.equal(error.problem.code, "usage_error"); - assert.match(error.problem.detail, /must contain at most 2 iframe placements/); + assert.match(error.problem.detail, /must contain at most 2 iframe primitives/); return true; }, ); diff --git a/src/playlist-write-validation.ts b/src/playlist-write-validation.ts index 12df63e3..98235ea9 100644 --- a/src/playlist-write-validation.ts +++ b/src/playlist-write-validation.ts @@ -181,8 +181,8 @@ function validateIframe(value: JsonRecord, name: string): void { function validateSelector( value: unknown, name: string, - kind: "image" | "video", - mediaKinds: ReadonlyMap, + primitive: "image" | "video", + mediaPrimitives: ReadonlyMap, referenced: Set, ): { count: number; oneAtATime: boolean } { const selector = record(value, name); @@ -204,9 +204,9 @@ function validateSelector( throw usageError(`${name} must be snapshotted to by:id or by:ids.`); } for (const id of ids) { - const actual = mediaKinds.get(id); + const actual = mediaPrimitives.get(id); if (!actual) throw usageError(`${name} references media missing from the bundle: ${id}.`); - if (actual !== kind) throw usageError(`${name} references ${actual} media from an ${kind} placement: ${id}.`); + if (actual !== primitive) throw usageError(`${name} references ${actual} media from an ${primitive} primitive: ${id}.`); referenced.add(id); } return { count: ids.length, oneAtATime: selector.one_at_a_time === true }; @@ -234,7 +234,7 @@ function validateAdvance(value: unknown, name: string): "duration" | "applicatio export function validatePlaylistWrite( value: unknown, - mediaKinds: ReadonlyMap, + mediaPrimitives: ReadonlyMap, ): Set { const playlist = record(value, "playlist.json"); exact(playlist, ["name", "pages"], "playlist.json"); @@ -248,7 +248,7 @@ export function validatePlaylistWrite( for (const [pageIndex, pageValue] of playlist.pages.entries()) { const pageName = `playlist.pages[${pageIndex}]`; const page = record(pageValue, pageName); - exact(page, ["id", "canvas", "transition", "advance", "visibility", "placements"], pageName); + exact(page, ["id", "canvas", "transition", "advance", "visibility", "primitives"], pageName); const pageId = string(page, "id", pageName, 64, ID_PATTERN); if (pageIds.has(pageId)) throw usageError(`Playlist page id is duplicated: ${pageId}.`); pageIds.add(pageId); @@ -257,54 +257,63 @@ export function validatePlaylistWrite( const advance = validateAdvance(page.advance, `${pageName}.advance`); if (page.visibility === undefined) unscheduled += 1; else validateVisibility(page.visibility, `${pageName}.visibility`); - if (!Array.isArray(page.placements) || page.placements.length < 1 || page.placements.length > 24) { - throw usageError(`${pageName}.placements must contain 1 to 24 placements.`); + if (!Array.isArray(page.primitives) || page.primitives.length < 1 || page.primitives.length > 24) { + throw usageError(`${pageName}.primitives must contain 1 to 24 primitives.`); } - const placementIds = new Set(); - const mediaPlacements: Array<{ type: "image" | "video"; content: JsonRecord; selector: { count: number; oneAtATime: boolean } }> = []; + const primitiveIds = new Set(); + const mediaPrimitivesOnPage: Array<{ + primitive: "image" | "video"; + value: JsonRecord; + selector: { count: number; oneAtATime: boolean }; + }> = []; let iframeCount = 0; - for (const [placementIndex, placementValue] of page.placements.entries()) { - const placementName = `${pageName}.placements[${placementIndex}]`; - const placement = record(placementValue, placementName); - exact(placement, ["id", "content", "rect", "layer", "content_fit", "enter"], placementName); - const placementId = string(placement, "id", placementName, 64, ID_PATTERN); - if (placementIds.has(placementId)) throw usageError(`${pageName} placement id is duplicated: ${placementId}.`); - placementIds.add(placementId); - validateRect(placement.rect, `${placementName}.rect`); - number(placement, "layer", placementName, { integer: true, min: 0, max: 1024 }); - if (placement.enter !== undefined) validateEnter(placement.enter, `${placementName}.enter`); - const content = record(placement.content, `${placementName}.content`); - if (content.type === "application") throw usageError("Playlist bundles do not support application placements."); - if (content.type === "iframe") { - validateIframe(content, `${placementName}.content`); - if (placement.content_fit !== "fill") throw usageError(`${placementName}.content_fit must be fill for an iframe.`); + for (const [primitiveIndex, primitiveValue] of page.primitives.entries()) { + const primitiveName = `${pageName}.primitives[${primitiveIndex}]`; + const primitive = record(primitiveValue, primitiveName); + const primitiveId = string(primitive, "id", primitiveName, 64, ID_PATTERN); + if (primitiveIds.has(primitiveId)) throw usageError(`${pageName} primitive id is duplicated: ${primitiveId}.`); + primitiveIds.add(primitiveId); + validateRect(primitive.rect, `${primitiveName}.rect`); + number(primitive, "layer", primitiveName, { integer: true, min: 0, max: 1024 }); + if (primitive.enter !== undefined) validateEnter(primitive.enter, `${primitiveName}.enter`); + if (primitive.primitive === "application") throw usageError("Playlist bundles do not support application primitives."); + if (primitive.primitive === "iframe") { + exact(primitive, ["id", "primitive", "src", "title", "rect", "layer", "content_fit", "enter"], primitiveName); + validateIframe({ type: "iframe", src: primitive.src, title: primitive.title }, primitiveName); + if (primitive.content_fit !== "fill") throw usageError(`${primitiveName}.content_fit must be fill for an iframe.`); iframeCount += 1; continue; } - if (content.type !== "image" && content.type !== "video") throw usageError(`${placementName}.content.type is unsupported.`); - if (!["contain", "cover", "fill"].includes(String(placement.content_fit))) throw usageError(`${placementName}.content_fit is invalid.`); - const kind = content.type; - exact(content, kind === "image" ? ["type", "selector", "alt", "dwell_ms"] : ["type", "selector", "muted", "loop"], `${placementName}.content`); - const selector = validateSelector(content.selector, `${placementName}.content.selector`, kind, mediaKinds, referenced); - if (kind === "image") { - if (content.alt !== undefined && (typeof content.alt !== "string" || content.alt.length > 300)) throw usageError(`${placementName}.content.alt is invalid.`); - if (content.dwell_ms !== undefined) number(content, "dwell_ms", `${placementName}.content`, { integer: true, min: 1000, max: 86_400_000 }); + if (primitive.primitive !== "image" && primitive.primitive !== "video") throw usageError(`${primitiveName}.primitive is unsupported.`); + if (!["contain", "cover", "fill"].includes(String(primitive.content_fit))) throw usageError(`${primitiveName}.content_fit is invalid.`); + const category = primitive.primitive; + exact( + primitive, + category === "image" + ? ["id", "primitive", "selector", "alt", "dwell_ms", "rect", "layer", "content_fit", "enter"] + : ["id", "primitive", "selector", "muted", "loop", "rect", "layer", "content_fit", "enter"], + primitiveName, + ); + const selector = validateSelector(primitive.selector, `${primitiveName}.selector`, category, mediaPrimitives, referenced); + if (category === "image") { + if (primitive.alt !== undefined && (typeof primitive.alt !== "string" || primitive.alt.length > 300)) throw usageError(`${primitiveName}.alt is invalid.`); + if (primitive.dwell_ms !== undefined) number(primitive, "dwell_ms", primitiveName, { integer: true, min: 1000, max: 86_400_000 }); } else { - if (content.muted !== undefined && content.muted !== true) throw usageError(`${placementName}.content.muted must be true.`); - optionalBoolean(content, "loop", `${placementName}.content`); + if (primitive.muted !== undefined && primitive.muted !== true) throw usageError(`${primitiveName}.muted must be true.`); + optionalBoolean(primitive, "loop", primitiveName); } - mediaPlacements.push({ type: kind, content, selector }); + mediaPrimitivesOnPage.push({ primitive: category, value: primitive, selector }); } - if (iframeCount > 2) throw usageError(`${pageName}.placements must contain at most 2 iframe placements.`); + if (iframeCount > 2) throw usageError(`${pageName}.primitives must contain at most 2 iframe primitives.`); if (advance === "application") throw usageError(`${pageName} application advance is unsupported because bundles exclude applications.`); if (advance === "media_end") { - if (page.placements.length !== 1 || mediaPlacements.length !== 1 || iframeCount > 0) throw usageError(`${pageName} media_end requires exactly one image or video placement.`); - const only = mediaPlacements[0]!; - if (only.type === "image" && only.content.dwell_ms === undefined) throw usageError(`${pageName} media_end image requires dwell_ms.`); - if (only.type === "video" && only.content.loop === true) throw usageError(`${pageName} media_end video must not loop.`); + if (page.primitives.length !== 1 || mediaPrimitivesOnPage.length !== 1 || iframeCount > 0) throw usageError(`${pageName} media_end requires exactly one image or video primitive.`); + const only = mediaPrimitivesOnPage[0]!; + if (only.primitive === "image" && only.value.dwell_ms === undefined) throw usageError(`${pageName} media_end image requires dwell_ms.`); + if (only.primitive === "video" && only.value.loop === true) throw usageError(`${pageName} media_end video must not loop.`); } else { - for (const media of mediaPlacements) { - if (media.type === "image" && media.content.dwell_ms !== undefined) throw usageError(`${pageName} duration pages forbid image dwell_ms.`); + for (const media of mediaPrimitivesOnPage) { + if (media.primitive === "image" && media.value.dwell_ms !== undefined) throw usageError(`${pageName} duration pages forbid image dwell_ms.`); if (media.selector.count > 1 && !media.selector.oneAtATime) throw usageError(`${pageName} multi-item selectors require one_at_a_time true.`); } } diff --git a/src/redact.test.ts b/src/redact.test.ts index 0419ab05..76656dbc 100644 --- a/src/redact.test.ts +++ b/src/redact.test.ts @@ -67,7 +67,7 @@ test("sensitive keys match embedded names, not only exact tokens", () => { } assert.equal(isSensitiveKey("capture_id"), false); assert.equal(isSensitiveKey("code"), false); - assert.equal(isSensitiveKey("placement_id"), false); + assert.equal(isSensitiveKey("primitive_id"), false); }); test("sensitive values match embedded credentials, not only at the start", () => { diff --git a/src/server-smoke.ts b/src/server-smoke.ts index 00d9e59b..1357b86b 100644 --- a/src/server-smoke.ts +++ b/src/server-smoke.ts @@ -26,9 +26,11 @@ function smokePlaylist(): Record { }, transition: { type: "crossfade", duration_ms: 0 }, advance: { mode: "duration", after_ms: 5000 }, - placements: [{ + primitives: [{ id: "smoke_item", - content: { type: "iframe", src: "https://example.com/", title: "ScreenRig smoke" }, + primitive: "iframe", + src: "https://example.com/", + title: "ScreenRig smoke", rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "fill", diff --git a/src/transport/fake.ts b/src/transport/fake.ts index b6d428c0..d6f30c9c 100644 --- a/src/transport/fake.ts +++ b/src/transport/fake.ts @@ -448,7 +448,7 @@ export function memoryBackend(): FakeTransport { updated_at: "2026-08-14T17:00:01.000Z", // A published application release reports both identifiers here. An // application id cannot be placed in a playlist; the release id can, so - // the operation result is where the placement's release_id comes from. + // the operation result is where the application primitive's release_id comes from. result: { application_id: id, release_id: releaseId }, }); return { @@ -733,12 +733,12 @@ export function memoryBackend(): FakeTransport { transport.on("GET", "/api/v1/media", (req) => { const tag = req.query?.tag; - const kind = req.query?.kind; + const primitive = req.query?.primitive; const items = [...media.values()].filter((item) => { - const record = item as { tag?: string; kind?: string; declaration?: unknown }; + const record = item as { tag?: string; primitive?: string; declaration?: unknown }; if (record.declaration) return false; if (tag && record.tag !== tag) return false; - if (kind && record.kind !== kind) return false; + if (primitive && record.primitive !== primitive) return false; return true; }); return { status: 200, headers: {}, body: { items } }; @@ -780,7 +780,7 @@ export function memoryBackend(): FakeTransport { const item = { id, filename: stored.declaration.filename, - kind: commit.content_type.startsWith("image/") ? "image" : "video", + primitive: commit.content_type.startsWith("image/") ? "image" : "video", content_type: commit.content_type, operation_id: operationId, sha256: commit.sha256,