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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ type BodyBlock =
| Tweet
| Video
| YoutubeVideo
| ClipSet
| Text
```

Expand Down Expand Up @@ -544,8 +545,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
Expand All @@ -555,8 +554,72 @@ interface YoutubeVideo extends Node {
}
```


**YoutubeVideo** represents a video referenced by a Youtube URL.

### `ClipSet`

```ts
interface ClipSet extends Node {
Copy link
Contributor

@epavlova epavlova Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): How feasible would it be to support alternativeText and alternativeImage now or in a future iteration? I’m wondering if we could use the poster image as an alternative.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So based on what's in Spark Clips:
description is the equivalent of alternativeText - "Describe this clip (for those who cannot see it)"

poster should be usable as an alternativeImage. I'm now wondering why it's a string and not ImageSet (as it is in the CAPI response) 🤔

Copy link
Contributor

@umbobabo umbobabo Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How feasible would it be to support alternativeText and alternativeImage now or in a future iteration?

Is this for distributable reasons?

If the answer is yes and the objective is to produce a valid HTML tag that is renderable in every context, then with Clipset data it's possible to create basic HTML video tag that is playable by every browser.
The following it's a simplified example extracted from this article. It could even be simplified more avoiding to use multiple sources but just one of them as src:

<video
    poster="<POSTER_URL>"
   >
    <source id="video-source-0-daecfa57-a12a-468b-8045-ad32cfa79b3b"
        src="https://spark-clips-prod.s3.eu-west-1.amazonaws.com/optimised-media-files/16984229396750/640x360.mp4"
        type="video/mp4">
    <source id="video-source-1-daecfa57-a12a-468b-8045-ad32cfa79b3b"
        src="https://spark-clips-prod.s3.eu-west-1.amazonaws.com/optimised-media-files/16984229396750/1280x720.mp4"
        type="video/mp4">
    <source id="video-source-2-daecfa57-a12a-468b-8045-ad32cfa79b3b"
        src="https://spark-clips-prod.s3.eu-west-1.amazonaws.com/optimised-media-files/16984229396750/1920x1080.mp4"
        type="video/mp4">
    <source id="video-source-3-daecfa57-a12a-468b-8045-ad32cfa79b3b"
        src="https://spark-clips-prod.s3.eu-west-1.amazonaws.com/optimised-media-files/16984229396750/0x0.mp3"
        type="audio/mpeg">
    <track label="English" kind="captions" srclang="en" src="https://next-media-api.ft.com/clips/captions/32065539">
</video>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@epavlova I suspect you may want to use XML for the bodyXML field. In that case you should be able to easily map the data from the Clipset model into an XML format, something like the following (Not one of our clips):

<video id="abc123" xmlns="https://example.com/video/1.0">
  <title>Building a Birdhouse</title>
  <description>Step-by-step guide.</description>
  <language>en</language> <!-- BCP 47 -->
  <published>2025-08-01T10:30:00Z</published> <!-- RFC 3339 -->
  <duration>PT4M12S</duration> <!-- ISO 8601 -->
  <people>
    <creator role="host">Pat Lee</creator>
    <contributor role="editor">R. Singh</contributor>
  </people>

  <content>
    <container>mp4</container>
    <videoCodec>h264</videoCodec>
    <audioCodec>aac</audioCodec>
    <width>1920</width>
    <height>1080</height>
    <frameRate>29.97</frameRate>
    <bitrate unit="bps">3500000</bitrate>
    <aspectRatio>16:9</aspectRatio>
  </content>

  <files>
    <file role="main" bytes="184563210" checksum="sha256:...">
      <url>https://cdn.example.com/v/abc123/master.mp4</url>
    </file>
    <file role="1080p" bitrate="3500000">
      <url>https://cdn.example.com/v/abc123/1080p.mp4</url>
    </file>
    <file role="720p" bitrate="1800000">
      <url>https://cdn.example.com/v/abc123/720p.mp4</url>
    </file>
  </files>

  <tracks>
    <captions lang="en" kind="subtitles" format="vtt">
      <url>https://cdn.example.com/v/abc123/en.vtt</url>
    </captions>
    <audio lang="en" channels="2"/>
  </tracks>

  <chapters>
    <chapter start="PT0S" title="Intro"/>
    <chapter start="PT1M10S" title="Tools"/>
    <chapter start="PT2M45S" title="Assembly"/>
  </chapters>

  <thumbnails>
    <image width="1280" height="720">https://cdn.example.com/v/abc123/cover.jpg</image>
    <sprite columns="10" rows="10">https://cdn.example.com/v/abc123/sprite.jpg</sprite>
  </thumbnails>

  <rights>
    <license>CC-BY-4.0</license>
    <drm scheme="fairplay" keyId="..."/>
  </rights>

  <tags>
    <tag>DIY</tag><tag>woodwork</tag>
  </tags>
</video>

type: "clip-set"
id: string
autoplay: boolean
loop: boolean
muted: boolean
layoutWidth: 'in-line' | 'mid-grid' | 'full-grid'
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
}
```

**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
Expand Down
168 changes: 164 additions & 4 deletions content-tree.d.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -171,6 +171,49 @@ 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: 'in-line' | 'mid-grid' | 'full-grid';
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;
};
interface ScrollyBlock extends Parent {
type: "scrolly-block";
theme: "sans" | "serif";
Expand Down Expand Up @@ -274,7 +317,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 {
Expand Down Expand Up @@ -446,6 +489,49 @@ 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: 'in-line' | 'mid-grid' | 'full-grid';
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;
};
interface ScrollyBlock extends Parent {
type: "scrolly-block";
theme: "sans" | "serif";
Expand Down Expand Up @@ -550,7 +636,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 {
Expand Down Expand Up @@ -717,6 +803,37 @@ 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: 'in-line' | 'mid-grid' | 'full-grid';
}
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;
};
interface ScrollyBlock extends Parent {
type: "scrolly-block";
theme: "sans" | "serif";
Expand Down Expand Up @@ -811,7 +928,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 {
Expand Down Expand Up @@ -983,6 +1100,49 @@ 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: 'in-line' | 'mid-grid' | 'full-grid';
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;
};
interface ScrollyBlock extends Parent {
type: "scrolly-block";
theme: "sans" | "serif";
Expand Down
37 changes: 37 additions & 0 deletions libraries/from-bodyxml/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

/**
Expand All @@ -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
Expand Down Expand Up @@ -308,6 +327,24 @@ export let defaultTransformers = {
children: null,
};
},
/**
* @type {Transformer<ContentTree.transit.ClipSet>}
*/
[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<ContentTree.transit.Recommended>}
*/
Expand Down
Loading
Loading