From ebfd04bdd5c917b6be082da9c655e34a827c31a6 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Thu, 6 Aug 2026 07:24:53 -0700 Subject: [PATCH] feat(media): model image, video, and 3D variants The existing Media shape is image-oriented: one URL and one pair of dimensions cannot describe alternate video resolutions and codecs, adaptive streaming manifests, device-specific 3D formats, poster images, or an externally hosted player's embed URL. Model Media as an open type discriminator with image, video, and model_3d variants. Keep `url` as the primary resource, add `sources` as the complete set of negotiable renditions, and represent externally hosted playback as a video with `embed_url` rather than a separate media type. Optional `preview` and `name` fields provide a poster and human-readable label; source metadata covers media type, format, dimensions, and byte size. The shape follows the schema.org MediaObject family while using scalar seconds for duration and bytes for file size. REST and MCP examples demonstrate self-hosted video, 3D models, and embedded video without vendor-specific hosts. This is additive and preserves generated-source compatibility: the base keeps `required: [type, url]` plus `width` and `height`, shared constraints are defined once on the base, and unknown media types remain valid but MUST be ignored by Platforms. Follow-up: teach the Schema Reference macro to merge inherited base constraints when rendering description-only variant refinements. --- .cspell/custom-words.txt | 10 +++ docs/specification/catalog/index.md | 49 ++++++++++++ docs/specification/catalog/mcp.md | 74 ++++++++++++++++++- docs/specification/catalog/rest.md | 74 ++++++++++++++++++- source/schemas/common/types/media.json | 69 +++++++++++++++-- source/schemas/common/types/media_image.json | 18 +++++ .../schemas/common/types/media_model_3d.json | 19 +++++ source/schemas/common/types/media_source.json | 40 ++++++++++ source/schemas/common/types/media_video.json | 35 +++++++++ 9 files changed, 380 insertions(+), 8 deletions(-) create mode 100644 source/schemas/common/types/media_image.json create mode 100644 source/schemas/common/types/media_model_3d.json create mode 100644 source/schemas/common/types/media_source.json create mode 100644 source/schemas/common/types/media_video.json diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index 4bef035a6..e669861e6 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -170,3 +170,13 @@ punycode userinfo examplecorp zapatillas +# rich media formats (video / 3D model source types) +glb +gltf +m3u8 +mp4 +mpd +mpegurl +usdz +vnd +webm diff --git a/docs/specification/catalog/index.md b/docs/specification/catalog/index.md index 2964757ac..4d417655b 100644 --- a/docs/specification/catalog/index.md +++ b/docs/specification/catalog/index.md @@ -132,8 +132,57 @@ as the first element. Platforms SHOULD treat the first element as featured. ### Media +A single logical media item on a Product or Variant: an image, a video, or a +3D model. `url` identifies the item's primary resource, and its interpretation +depends on `type`. `preview` is a still image the Platform can render before — +or instead of — loading that resource. + +`type` is an open string with three well-known values: `image`, `video`, and +`model_3d`. A Platform **MUST** ignore a media item whose `type` it does not +recognize. + +When present, `sources` contains the complete set of available renditions or +encodings. A Business that provides `sources` **SHOULD** include a source whose +`url` equals the media item's `url`, unless `url` is an externally hosted +video's watch page rather than a rendition. + +For an image, a self-hosted video, or a 3D model, a Platform loads the +renderable or loadable primary resource at `url`. For an externally hosted +video with `embed_url`, `url` identifies the watch page and `embed_url` +identifies third-party active player content. A Platform **MAY** decline to +embed that content under its security, privacy, or presentation policy and use +the watch-page `url` instead. For a thumbnail, a Platform can use `preview.url` +and fall back to `url` for an image, which is directly displayable. + {{ schema_fields('types/media', 'catalog') }} +#### Image + +{{ schema_fields('types/media_image', 'catalog') }} + +#### Video + +A self-hosted video's `url` identifies its directly playable default rendition. +An externally hosted video is distinguished by `embed_url`, which identifies +the third-party active player content; its `url` identifies the watch page, not +a playable rendition. + +A Platform that selects a rendition from `sources` **SHOULD** use the `width` +and `height` of that entry rather than those on the media item, unless the +entry omits them. + +For an externally hosted video, a Business **SHOULD** provide both +`preview.width` and `preview.height` with a ratio that matches the intended +player, unless it has no suitable preview. A Platform **SHOULD** reserve the +initial player layout using that ratio, unless its presentation policy supplies +another ratio. + +{{ schema_fields('types/media_video', 'catalog') }} + +#### 3D Model + +{{ schema_fields('types/media_model_3d', 'catalog') }} + ### Product Option {{ schema_fields('types/product_option', 'catalog') }} diff --git a/docs/specification/catalog/mcp.md b/docs/specification/catalog/mcp.md index d92fa4ea3..f401de45b 100644 --- a/docs/specification/catalog/mcp.md +++ b/docs/specification/catalog/mcp.md @@ -533,7 +533,79 @@ Maps to the [Catalog Lookup](lookup.md#get-product-get_product) capability. Retu { "type": "image", "url": "https://cdn.example.com/products/runner-pro-blue.jpg", - "alt_text": "Runner Pro in Blue" + "alt_text": "Runner Pro in Blue", + "width": 1600, + "height": 1600 + }, + { + "type": "video", + "url": "https://cdn.example.com/products/runner-pro-demo-720p.mp4", + "name": "Runner Pro flex demo", + "alt_text": "Runner Pro shoe flexing to show sole cushioning", + "duration": 24, + "preview": { + "url": "https://cdn.example.com/products/runner-pro-demo-poster.jpg", + "width": 1280, + "height": 720 + }, + "sources": [ + { + "url": "https://cdn.example.com/products/runner-pro-demo-720p.mp4", + "mime_type": "video/mp4", + "format": "mp4", + "width": 1280, + "height": 720, + "filesize": 4200000 + }, + { + "url": "https://cdn.example.com/products/runner-pro-demo-1080p.mp4", + "mime_type": "video/mp4", + "format": "mp4", + "width": 1920, + "height": 1080, + "filesize": 8480000 + }, + { + "url": "https://cdn.example.com/products/runner-pro-demo.m3u8", + "mime_type": "application/vnd.apple.mpegurl", + "format": "m3u8" + } + ] + }, + { + "type": "model_3d", + "url": "https://cdn.example.com/products/runner-pro.glb", + "alt_text": "Runner Pro 3D model", + "preview": { + "url": "https://cdn.example.com/products/runner-pro-3d-thumb.jpg" + }, + "sources": [ + { + "url": "https://cdn.example.com/products/runner-pro.glb", + "mime_type": "model/gltf-binary", + "format": "glb", + "filesize": 4200000 + }, + { + "url": "https://cdn.example.com/products/runner-pro.usdz", + "mime_type": "model/vnd.usdz+zip", + "format": "usdz", + "filesize": 5100000 + } + ] + }, + { + "type": "video", + "url": "https://videos.example.com/watch?v=abc123", + "embed_url": "https://videos.example.com/embed/abc123", + "name": "Runner Pro trail review", + "alt_text": "Reviewer running a trail in the Runner Pro", + "duration": 212, + "preview": { + "url": "https://cdn.example.com/products/runner-pro-review-poster.jpg", + "width": 1280, + "height": 720 + } } ], "options": [ diff --git a/docs/specification/catalog/rest.md b/docs/specification/catalog/rest.md index cd5b2ca31..05518372d 100644 --- a/docs/specification/catalog/rest.md +++ b/docs/specification/catalog/rest.md @@ -409,7 +409,79 @@ on option values and returns variants matching the selection. { "type": "image", "url": "https://cdn.example.com/products/runner-pro-blue.jpg", - "alt_text": "Runner Pro in Blue" + "alt_text": "Runner Pro in Blue", + "width": 1600, + "height": 1600 + }, + { + "type": "video", + "url": "https://cdn.example.com/products/runner-pro-demo-720p.mp4", + "name": "Runner Pro flex demo", + "alt_text": "Runner Pro shoe flexing to show sole cushioning", + "duration": 24, + "preview": { + "url": "https://cdn.example.com/products/runner-pro-demo-poster.jpg", + "width": 1280, + "height": 720 + }, + "sources": [ + { + "url": "https://cdn.example.com/products/runner-pro-demo-720p.mp4", + "mime_type": "video/mp4", + "format": "mp4", + "width": 1280, + "height": 720, + "filesize": 4200000 + }, + { + "url": "https://cdn.example.com/products/runner-pro-demo-1080p.mp4", + "mime_type": "video/mp4", + "format": "mp4", + "width": 1920, + "height": 1080, + "filesize": 8480000 + }, + { + "url": "https://cdn.example.com/products/runner-pro-demo.m3u8", + "mime_type": "application/vnd.apple.mpegurl", + "format": "m3u8" + } + ] + }, + { + "type": "model_3d", + "url": "https://cdn.example.com/products/runner-pro.glb", + "alt_text": "Runner Pro 3D model", + "preview": { + "url": "https://cdn.example.com/products/runner-pro-3d-thumb.jpg" + }, + "sources": [ + { + "url": "https://cdn.example.com/products/runner-pro.glb", + "mime_type": "model/gltf-binary", + "format": "glb", + "filesize": 4200000 + }, + { + "url": "https://cdn.example.com/products/runner-pro.usdz", + "mime_type": "model/vnd.usdz+zip", + "format": "usdz", + "filesize": 5100000 + } + ] + }, + { + "type": "video", + "url": "https://videos.example.com/watch?v=abc123", + "embed_url": "https://videos.example.com/embed/abc123", + "name": "Runner Pro trail review", + "alt_text": "Reviewer running a trail in the Runner Pro", + "duration": 212, + "preview": { + "url": "https://cdn.example.com/products/runner-pro-review-poster.jpg", + "width": 1280, + "height": 720 + } } ], "options": [ diff --git a/source/schemas/common/types/media.json b/source/schemas/common/types/media.json index 57e28cfbc..de0bb7150 100644 --- a/source/schemas/common/types/media.json +++ b/source/schemas/common/types/media.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/common/types/media.json", "title": "Media", - "description": "Media item (image, video, etc.).", + "description": "A single logical media item: an image, video, or 3D model. `url` is the primary resource; when present, `sources` is the complete set of available renditions. When `sources` is present and `url` identifies a rendition, the Business SHOULD include that rendition.", "type": "object", "required": [ "type", @@ -11,12 +11,16 @@ "properties": { "type": { "type": "string", - "description": "Media type. Well-known values: `image`, `video`, `model_3d`." + "description": "Media type. The Platform MUST ignore unknown values. Well-known values: `image`, `video`, `model_3d`." }, "url": { "type": "string", "format": "uri", - "description": "URL to the media resource." + "description": "Primary/canonical URL for the item; interpretation depends on `type` (specialized per type)." + }, + "name": { + "type": "string", + "description": "Human-readable title or label for the media, distinct from `alt_text` (accessibility text). Often present for `video` and `model_3d`." }, "alt_text": { "type": "string", @@ -25,12 +29,65 @@ "width": { "type": "integer", "minimum": 1, - "description": "Width in pixels (for images/video)." + "description": "Pixel width of the media." }, "height": { "type": "integer", "minimum": 1, - "description": "Height in pixels (for images/video)." + "description": "Pixel height of the media." + }, + "preview": { + "type": "object", + "description": "Poster/thumbnail still the Platform renders before or instead of the primary resource. A Business SHOULD provide it for `video` and `model_3d` unless no suitable still exists, and MAY provide it for `image` as a low-resolution placeholder. For externally hosted video, `preview.width` and `preview.height` provide the layout aspect ratio.", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL of the preview image." + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Actual pixel width of the preview image. The ratio of `preview.width` to `preview.height` is used to reserve embedded-video player layout." + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Actual pixel height of the preview image. The ratio of `preview.width` to `preview.height` is used to reserve embedded-video player layout." + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "image" } }, + "required": ["type"] + }, + "then": { + "$ref": "media_image.json" + } + }, + { + "if": { + "properties": { "type": { "const": "video" } }, + "required": ["type"] + }, + "then": { + "$ref": "media_video.json" + } + }, + { + "if": { + "properties": { "type": { "const": "model_3d" } }, + "required": ["type"] + }, + "then": { + "$ref": "media_model_3d.json" + } } - } + ] } diff --git a/source/schemas/common/types/media_image.json b/source/schemas/common/types/media_image.json new file mode 100644 index 000000000..1153a468c --- /dev/null +++ b/source/schemas/common/types/media_image.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/media_image.json", + "title": "Media Image", + "description": "Type-specific properties for image media.", + "type": "object", + "properties": { + "url": { + "description": "A directly displayable image." + }, + "width": { + "description": "Authoritative pixel width of the image." + }, + "height": { + "description": "Authoritative pixel height of the image." + } + } +} diff --git a/source/schemas/common/types/media_model_3d.json b/source/schemas/common/types/media_model_3d.json new file mode 100644 index 000000000..2266dec68 --- /dev/null +++ b/source/schemas/common/types/media_model_3d.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/media_model_3d.json", + "title": "Media Model 3D", + "description": "Type-specific properties for 3D model media.", + "type": "object", + "properties": { + "url": { + "description": "The primary model file (e.g. GLB)." + }, + "sources": { + "type": "array", + "description": "The complete set of available model encodings/file formats (glTF/GLB/USDZ). When present, the Business SHOULD include a source whose `url` equals the media `url`.", + "items": { + "$ref": "media_source.json" + } + } + } +} diff --git a/source/schemas/common/types/media_source.json b/source/schemas/common/types/media_source.json new file mode 100644 index 000000000..f89c7b21d --- /dev/null +++ b/source/schemas/common/types/media_source.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/media_source.json", + "title": "Media Source", + "description": "A single encoded rendition of a parent Media item — one resolution/format of a video, or one file format of a 3D model. The Platform selects the best supported source.", + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to this rendition's file or streaming manifest." + }, + "mime_type": { + "type": "string", + "description": "IANA media type of this source. A Platform MUST NOT reject the parent media item solely because this value is unknown, but MAY skip a source it cannot decode. Examples: `video/mp4`, `video/webm`, `application/vnd.apple.mpegurl` (HLS), `application/dash+xml` (DASH), `model/gltf-binary` (GLB), `model/gltf+json` (glTF), `model/vnd.usdz+zip` (USDZ)." + }, + "format": { + "type": "string", + "description": "Short format token for display/selection. A Platform MUST NOT reject the parent media item solely because this value is unknown, but MAY skip a source it cannot decode. Examples: `mp4`, `webm`, `m3u8`, `mpd`, `glb`, `gltf`, `usdz`." + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Pixel width of this rendition (video). Omitted for adaptive manifests and 3D models." + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Pixel height of this rendition (video). Omitted for adaptive manifests and 3D models." + }, + "filesize": { + "type": "integer", + "minimum": 1, + "description": "File size in bytes. Lets the Platform decide whether to prefetch a large asset (e.g. a multi-megabyte 3D model). Omitted for adaptive-streaming manifests, whose total size is not fixed." + } + } +} diff --git a/source/schemas/common/types/media_video.json b/source/schemas/common/types/media_video.json new file mode 100644 index 000000000..0c0520139 --- /dev/null +++ b/source/schemas/common/types/media_video.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/media_video.json", + "title": "Media Video", + "description": "Type-specific properties for video media.", + "type": "object", + "properties": { + "url": { + "description": "The directly playable default rendition for a self-hosted video, or the watch-page URL for an externally hosted video with `embed_url`. When `sources` is present and `url` identifies a rendition, the Business SHOULD include a source whose `url` equals the media `url`; this does not apply when `url` is an external watch-page URL." + }, + "embed_url": { + "type": "string", + "format": "uri", + "description": "Player embed (iframe) URL for an externally hosted video, through which the Platform may play the video; absent for self-hosted video. A Platform MAY decline third-party active content under its security, privacy, or presentation policy and use the watch-page `url` instead." + }, + "duration": { + "type": "integer", + "minimum": 1, + "description": "Duration in seconds." + }, + "width": { + "description": "The video's display width in pixels, for layout and aspect ratio. A selected rendition's exact width is in that `sources[]` entry." + }, + "height": { + "description": "The video's display height in pixels, for layout and aspect ratio. A selected rendition's exact height is in that `sources[]` entry." + }, + "sources": { + "type": "array", + "description": "The complete set of available encoded renditions (MP4/WebM, HLS/DASH manifests). A Business that provides `sources` SHOULD include a source whose `url` equals the media `url` when `url` identifies a rendition; an external watch-page `url` does not identify a rendition.", + "items": { + "$ref": "media_source.json" + } + } + } +}