diff --git a/README.md b/README.md index 7e2e08c..a01ea0f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ type BodyBlock = | Tweet | Video | YoutubeVideo + | ClipSet | Text ``` @@ -550,8 +551,6 @@ interface Video extends Node { The `title` can be obtained by fetching the Video from the content API. -TODO: Figure out how Clips work, how they are different? - ### `YoutubeVideo` ```ts @@ -561,8 +560,76 @@ interface YoutubeVideo extends Node { } ``` + **YoutubeVideo** represents a video referenced by a Youtube URL. +### `ClipSet` + +```ts +interface ClipSet extends Node { + type: "clip-set" + id: string + autoplay: boolean + loop: boolean + muted: boolean + layoutWidth: ClipSetLayoutWidth + external noAudio: boolean + external caption: string + external credits: string + external description: string + external displayTitle: string + external systemTitle: string + external source: string + external contentWarning: string[] + external publishedDate: string + external subtitle: string + external clips: Clip[] + external accessibility: ClipAccessibility +} +``` + +```ts +type Clip = { + id: string + format: 'standard-inline' | 'mobile' + dataSource: ClipSource[] + poster: string +} +``` + +```ts +type ClipSource = { + audioCodec: string + binaryUrl: string + duration: number + mediaType: string + pixelHeight: number + pixelWidth: number + videoCodec: string +} +``` + +```ts +type ClipCaption = { + mediaType?: string + url?: string +} +``` +```ts +type ClipAccessibility = { + captions?: ClipCaption[] + transcript?: Body +} +``` + +```ts +type ClipSetLayoutWidth = Extract +``` + +**ClipSet** represents a short piece of possibly-looping video content for an article. + +The external fields are derived from the separately published [ClipSet](https://api.ft.com/schemas/clip-set.json) and [Clip](https://api.ft.com/schemas/clip.json) objects in the Content API. + ### `ScrollyBlock` ```ts diff --git a/content-tree.d.ts b/content-tree.d.ts index bbe0cff..075cb02 100644 --- a/content-tree.d.ts +++ b/content-tree.d.ts @@ -1,5 +1,5 @@ export declare namespace ContentTree { - type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text; + type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | ClipSet | Text; type LayoutWidth = "auto" | "in-line" | "inset-left" | "inset-right" | "full-bleed" | "full-grid" | "mid-grid" | "full-width"; type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link; interface Node { @@ -175,6 +175,50 @@ export declare namespace ContentTree { type: "youtube-video"; url: string; } + interface ClipSet extends Node { + type: "clip-set"; + id: string; + autoplay: boolean; + loop: boolean; + muted: boolean; + layoutWidth: ClipSetLayoutWidth; + noAudio: boolean; + caption: string; + credits: string; + description: string; + displayTitle: string; + systemTitle: string; + source: string; + contentWarning: string[]; + publishedDate: string; + subtitle: string; + clips: Clip[]; + accessibility: ClipAccessibility; + } + type Clip = { + id: string; + format: 'standard-inline' | 'mobile'; + dataSource: ClipSource[]; + poster: string; + }; + type ClipSource = { + audioCodec: string; + binaryUrl: string; + duration: number; + mediaType: string; + pixelHeight: number; + pixelWidth: number; + videoCodec: string; + }; + type ClipCaption = { + mediaType?: string; + url?: string; + }; + type ClipAccessibility = { + captions?: ClipCaption[]; + transcript?: Body; + }; + type ClipSetLayoutWidth = Extract; interface ScrollyBlock extends Parent { type: "scrolly-block"; theme: "sans" | "serif"; @@ -279,7 +323,7 @@ export declare namespace ContentTree { attributes: CustomCodeComponentAttributes; } namespace full { - type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text; + type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | ClipSet | Text; type LayoutWidth = "auto" | "in-line" | "inset-left" | "inset-right" | "full-bleed" | "full-grid" | "mid-grid" | "full-width"; type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link; interface Node { @@ -455,6 +499,50 @@ export declare namespace ContentTree { type: "youtube-video"; url: string; } + interface ClipSet extends Node { + type: "clip-set"; + id: string; + autoplay: boolean; + loop: boolean; + muted: boolean; + layoutWidth: ClipSetLayoutWidth; + noAudio: boolean; + caption: string; + credits: string; + description: string; + displayTitle: string; + systemTitle: string; + source: string; + contentWarning: string[]; + publishedDate: string; + subtitle: string; + clips: Clip[]; + accessibility: ClipAccessibility; + } + type Clip = { + id: string; + format: 'standard-inline' | 'mobile'; + dataSource: ClipSource[]; + poster: string; + }; + type ClipSource = { + audioCodec: string; + binaryUrl: string; + duration: number; + mediaType: string; + pixelHeight: number; + pixelWidth: number; + videoCodec: string; + }; + type ClipCaption = { + mediaType?: string; + url?: string; + }; + type ClipAccessibility = { + captions?: ClipCaption[]; + transcript?: Body; + }; + type ClipSetLayoutWidth = Extract; interface ScrollyBlock extends Parent { type: "scrolly-block"; theme: "sans" | "serif"; @@ -560,7 +648,7 @@ export declare namespace ContentTree { } } namespace transit { - type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text; + type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | ClipSet | Text; type LayoutWidth = "auto" | "in-line" | "inset-left" | "inset-right" | "full-bleed" | "full-grid" | "mid-grid" | "full-width"; type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link; interface Node { @@ -731,6 +819,38 @@ export declare namespace ContentTree { type: "youtube-video"; url: string; } + interface ClipSet extends Node { + type: "clip-set"; + id: string; + autoplay: boolean; + loop: boolean; + muted: boolean; + layoutWidth: ClipSetLayoutWidth; + } + type Clip = { + id: string; + format: 'standard-inline' | 'mobile'; + dataSource: ClipSource[]; + poster: string; + }; + type ClipSource = { + audioCodec: string; + binaryUrl: string; + duration: number; + mediaType: string; + pixelHeight: number; + pixelWidth: number; + videoCodec: string; + }; + type ClipCaption = { + mediaType?: string; + url?: string; + }; + type ClipAccessibility = { + captions?: ClipCaption[]; + transcript?: Body; + }; + type ClipSetLayoutWidth = Extract; interface ScrollyBlock extends Parent { type: "scrolly-block"; theme: "sans" | "serif"; @@ -826,7 +946,7 @@ export declare namespace ContentTree { } } namespace loose { - type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | Text; + type BodyBlock = Paragraph | Heading | ImageSet | Flourish | BigNumber | CustomCodeComponent | Layout | List | Blockquote | Pullquote | ScrollyBlock | ThematicBreak | Table | Recommended | Tweet | Video | YoutubeVideo | ClipSet | Text; type LayoutWidth = "auto" | "in-line" | "inset-left" | "inset-right" | "full-bleed" | "full-grid" | "mid-grid" | "full-width"; type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link; interface Node { @@ -1002,6 +1122,50 @@ export declare namespace ContentTree { type: "youtube-video"; url: string; } + interface ClipSet extends Node { + type: "clip-set"; + id: string; + autoplay: boolean; + loop: boolean; + muted: boolean; + layoutWidth: ClipSetLayoutWidth; + noAudio?: boolean; + caption?: string; + credits?: string; + description?: string; + displayTitle?: string; + systemTitle?: string; + source?: string; + contentWarning?: string[]; + publishedDate?: string; + subtitle?: string; + clips?: Clip[]; + accessibility?: ClipAccessibility; + } + type Clip = { + id: string; + format: 'standard-inline' | 'mobile'; + dataSource: ClipSource[]; + poster: string; + }; + type ClipSource = { + audioCodec: string; + binaryUrl: string; + duration: number; + mediaType: string; + pixelHeight: number; + pixelWidth: number; + videoCodec: string; + }; + type ClipCaption = { + mediaType?: string; + url?: string; + }; + type ClipAccessibility = { + captions?: ClipCaption[]; + transcript?: Body; + }; + type ClipSetLayoutWidth = Extract; interface ScrollyBlock extends Parent { type: "scrolly-block"; theme: "sans" | "serif"; diff --git a/libraries/from-bodyxml/index.js b/libraries/from-bodyxml/index.js index 417b290..0f435df 100644 --- a/libraries/from-bodyxml/index.js +++ b/libraries/from-bodyxml/index.js @@ -8,6 +8,7 @@ let ContentType = { content: "http://www.ft.com/ontology/content/Content", article: "http://www.ft.com/ontology/content/Article", customCodeComponent: "http://www.ft.com/ontology/content/CustomCodeComponent", + clipSet: "http://www.ft.com/ontology/content/ClipSet", }; /** @@ -32,6 +33,24 @@ function toValidLayoutWidth(layoutWidth) { return "full-width"; } } + +/** + * @param {string} layoutWidth + * @returns {ContentTree.ClipSet["layoutWidth"]} + */ +function toValidClipLayoutWidth(layoutWidth) { + if ( + [ + "in-line", + "full-grid", + "mid-grid", + ].includes(layoutWidth) + ) { + return /** @type {ContentTree.ClipSet["layoutWidth"]} */ (layoutWidth); + } else { + return "in-line"; + } +} /** * @typedef {import("unist").Parent} UParent * @typedef {import("unist").Node} UNode @@ -320,6 +339,24 @@ export let defaultTransformers = { children: null, }; }, + /** + * @type {Transformer} + */ + [ContentType.clipSet](clip) { + const id = clip.attributes.url ?? ""; + const uuid = id.split("/").pop(); + return { + type: "clip-set", + id: uuid ?? "", + layoutWidth: toValidClipLayoutWidth( + clip.attributes["data-layout-width"] || "" + ), + autoplay: clip.attributes?.autoplay === "true", + loop: clip.attributes?.loop === "true", + muted: clip.attributes?.muted === "true", + children: null, + }; + }, /** * @type {Transformer} */ diff --git a/schemas/body-tree.schema.json b/schemas/body-tree.schema.json index 1c331ad..8ca4aed 100644 --- a/schemas/body-tree.schema.json +++ b/schemas/body-tree.schema.json @@ -120,6 +120,9 @@ { "$ref": "#/definitions/ContentTree.transit.YoutubeVideo" }, + { + "$ref": "#/definitions/ContentTree.transit.ClipSet" + }, { "$ref": "#/definitions/ContentTree.transit.Text" } @@ -139,6 +142,48 @@ ], "type": "object" }, + "ContentTree.transit.ClipSet": { + "additionalProperties": false, + "properties": { + "autoplay": { + "type": "boolean" + }, + "data": {}, + "id": { + "type": "string" + }, + "layoutWidth": { + "$ref": "#/definitions/ContentTree.transit.ClipSetLayoutWidth" + }, + "loop": { + "type": "boolean" + }, + "muted": { + "type": "boolean" + }, + "type": { + "const": "clip-set", + "type": "string" + } + }, + "required": [ + "autoplay", + "id", + "layoutWidth", + "loop", + "muted", + "type" + ], + "type": "object" + }, + "ContentTree.transit.ClipSetLayoutWidth": { + "enum": [ + "full-grid", + "in-line", + "mid-grid" + ], + "type": "string" + }, "ContentTree.transit.CustomCodeComponent": { "additionalProperties": false, "properties": { diff --git a/schemas/content-tree.schema.json b/schemas/content-tree.schema.json index 703a637..feb2509 100644 --- a/schemas/content-tree.schema.json +++ b/schemas/content-tree.schema.json @@ -145,6 +145,9 @@ { "$ref": "#/definitions/ContentTree.full.YoutubeVideo" }, + { + "$ref": "#/definitions/ContentTree.full.ClipSet" + }, { "$ref": "#/definitions/ContentTree.full.Text" } @@ -164,6 +167,184 @@ ], "type": "object" }, + "ContentTree.full.ClipSet": { + "additionalProperties": false, + "properties": { + "accessibility": { + "additionalProperties": false, + "properties": { + "captions": { + "items": { + "additionalProperties": false, + "properties": { + "mediaType": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "transcript": { + "$ref": "#/definitions/ContentTree.full.Body" + } + }, + "type": "object" + }, + "autoplay": { + "type": "boolean" + }, + "caption": { + "type": "string" + }, + "clips": { + "items": { + "additionalProperties": false, + "properties": { + "dataSource": { + "items": { + "additionalProperties": false, + "properties": { + "audioCodec": { + "type": "string" + }, + "binaryUrl": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "mediaType": { + "type": "string" + }, + "pixelHeight": { + "type": "number" + }, + "pixelWidth": { + "type": "number" + }, + "videoCodec": { + "type": "string" + } + }, + "required": [ + "audioCodec", + "binaryUrl", + "duration", + "mediaType", + "pixelHeight", + "pixelWidth", + "videoCodec" + ], + "type": "object" + }, + "type": "array" + }, + "format": { + "enum": [ + "mobile", + "standard-inline" + ], + "type": "string" + }, + "id": { + "type": "string" + }, + "poster": { + "type": "string" + } + }, + "required": [ + "dataSource", + "format", + "id", + "poster" + ], + "type": "object" + }, + "type": "array" + }, + "contentWarning": { + "items": { + "type": "string" + }, + "type": "array" + }, + "credits": { + "type": "string" + }, + "data": {}, + "description": { + "type": "string" + }, + "displayTitle": { + "type": "string" + }, + "id": { + "type": "string" + }, + "layoutWidth": { + "$ref": "#/definitions/ContentTree.full.ClipSetLayoutWidth" + }, + "loop": { + "type": "boolean" + }, + "muted": { + "type": "boolean" + }, + "noAudio": { + "type": "boolean" + }, + "publishedDate": { + "type": "string" + }, + "source": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "systemTitle": { + "type": "string" + }, + "type": { + "const": "clip-set", + "type": "string" + } + }, + "required": [ + "accessibility", + "autoplay", + "caption", + "clips", + "contentWarning", + "credits", + "description", + "displayTitle", + "id", + "layoutWidth", + "loop", + "muted", + "noAudio", + "publishedDate", + "source", + "subtitle", + "systemTitle", + "type" + ], + "type": "object" + }, + "ContentTree.full.ClipSetLayoutWidth": { + "enum": [ + "full-grid", + "in-line", + "mid-grid" + ], + "type": "string" + }, "ContentTree.full.CustomCodeComponent": { "additionalProperties": false, "properties": { diff --git a/schemas/transit-tree.schema.json b/schemas/transit-tree.schema.json index 273226b..2c90acb 100644 --- a/schemas/transit-tree.schema.json +++ b/schemas/transit-tree.schema.json @@ -145,6 +145,9 @@ { "$ref": "#/definitions/ContentTree.transit.YoutubeVideo" }, + { + "$ref": "#/definitions/ContentTree.transit.ClipSet" + }, { "$ref": "#/definitions/ContentTree.transit.Text" } @@ -164,6 +167,48 @@ ], "type": "object" }, + "ContentTree.transit.ClipSet": { + "additionalProperties": false, + "properties": { + "autoplay": { + "type": "boolean" + }, + "data": {}, + "id": { + "type": "string" + }, + "layoutWidth": { + "$ref": "#/definitions/ContentTree.transit.ClipSetLayoutWidth" + }, + "loop": { + "type": "boolean" + }, + "muted": { + "type": "boolean" + }, + "type": { + "const": "clip-set", + "type": "string" + } + }, + "required": [ + "autoplay", + "id", + "layoutWidth", + "loop", + "muted", + "type" + ], + "type": "object" + }, + "ContentTree.transit.ClipSetLayoutWidth": { + "enum": [ + "full-grid", + "in-line", + "mid-grid" + ], + "type": "string" + }, "ContentTree.transit.CustomCodeComponent": { "additionalProperties": false, "properties": {