Skip to content

Commit 96f05ea

Browse files
committed
feat: give html5 and rich-caption clips their own look on the timeline
1 parent a474456 commit 96f05ea

3 files changed

Lines changed: 180 additions & 8 deletions

File tree

‎src/components/timeline/components/clip/clip-component.ts‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,58 @@ export interface ClipComponentOptions {
3131
}
3232

3333
/** Renders a single clip element */
34+
function fileName(src: string): string {
35+
return (src.split("/").pop() || src).split("?")[0];
36+
}
37+
38+
function truncateLabel(text: string): string {
39+
return text.substring(0, 20) + (text.length > 20 ? "..." : "");
40+
}
41+
42+
const BLOCK_ELEMENTS =
43+
"address, article, aside, blockquote, dd, div, dl, dt, figcaption, figure, footer, h1, h2, h3, h4, h5, h6, header, li, main, nav, ol, p, pre, section, table, td, th, tr, ul";
44+
45+
/** Each block's text on an html5 page, in reading order, with inline pieces such as animated words joined. */
46+
function pageLines(html: string): string[] {
47+
// Parsing runs none of the page's scripts.
48+
const doc = new DOMParser().parseFromString(html, "text/html");
49+
doc.querySelectorAll("script, style, template, noscript").forEach(element => element.remove());
50+
const lines: string[] = [];
51+
let line = "";
52+
const endLine = (): void => {
53+
const text = line.replace(/\s+/g, " ").trim();
54+
if (text) lines.push(text);
55+
line = "";
56+
};
57+
const read = (node: Node): void => {
58+
if (node.nodeType === Node.TEXT_NODE) {
59+
const text = node.textContent ?? "";
60+
// Line and word spans often sit flush against each other. Letters split one per span stay together.
61+
if (text.trim().length > 1 && /\S$/.test(line) && /^\S/.test(text)) line += " ";
62+
line += text;
63+
return;
64+
}
65+
if (node.nodeType !== Node.ELEMENT_NODE) return;
66+
const block = (node as Element).matches(BLOCK_ELEMENTS) || node.nodeName === "BR";
67+
if (block) endLine();
68+
node.childNodes.forEach(read);
69+
if (block) endLine();
70+
};
71+
read(doc.body);
72+
endLine();
73+
return lines;
74+
}
75+
76+
/**
77+
* The line that reads as the page's headline. Pages often open with counters, bullets and all-caps labels, and a
78+
* headline is the first line of two or more words in sentence case.
79+
*/
80+
export function pageHeadline(html: string): string | null {
81+
const lines = pageLines(html);
82+
const sentence = lines.find(text => /\p{Ll}/u.test(text) && (text.match(/\p{L}{2,}/gu)?.length ?? 0) >= 2);
83+
return sentence ?? lines.find(text => /\p{L}/u.test(text)) ?? null;
84+
}
85+
3486
export class ClipComponent {
3587
public readonly element: HTMLElement;
3688
private readonly options: ClipComponentOptions;
@@ -44,6 +96,9 @@ export class ClipComponent {
4496
// Cached element references (avoid querySelector every frame)
4597
private iconEl: HTMLElement | null = null;
4698
private labelEl: HTMLElement | null = null;
99+
// Parsing a page for its label is too slow to repeat on every draw.
100+
private html5LabelSource: string | null = null;
101+
private html5Label = "";
47102
private badgeEl: HTMLElement | null = null;
48103

49104
constructor(clip: ClipState, options: ClipComponentOptions) {
@@ -368,6 +423,8 @@ export class ClipComponent {
368423
shape: "◇",
369424
caption: "≡",
370425
html: "<>",
426+
html5: "<>",
427+
"rich-caption": "≡",
371428
luma: "◐",
372429
svg: "◇"
373430
};
@@ -407,14 +464,27 @@ export class ClipComponent {
407464
return "Shape";
408465
}
409466

410-
if ("src" in asset && typeof asset.src === "string") {
467+
if (assetType === "html5") {
468+
const html = "html" in asset && typeof asset.html === "string" ? asset.html : "";
469+
if (html !== this.html5LabelSource) {
470+
this.html5LabelSource = html;
471+
this.html5Label = truncateLabel(pageHeadline(html) ?? "HTML");
472+
}
473+
return this.html5Label;
474+
}
475+
476+
// Its words come from parsing or transcribing the source at load, so the label names the source.
477+
if (assetType === "rich-caption" && "src" in asset && typeof asset.src === "string") {
411478
const { src } = asset;
412-
const filename = src.split("/").pop() || src;
413-
return filename.split("?")[0];
479+
return `Captions · ${src.startsWith("alias://") ? src.slice("alias://".length) : fileName(src)}`;
480+
}
481+
482+
if ("src" in asset && typeof asset.src === "string") {
483+
return fileName(asset.src);
414484
}
415485

416486
if ("text" in asset && typeof asset.text === "string") {
417-
return asset.text.substring(0, 20) + (asset.text.length > 20 ? "..." : "");
487+
return truncateLabel(asset.text);
418488
}
419489

420490
return assetType || "Clip";

‎src/styles/timeline/timeline.css‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,15 @@
256256
.ss-track[data-asset-type="shape"] {
257257
background: rgba(255, 243, 184, 0.3);
258258
}
259-
.ss-track[data-asset-type="caption"] {
259+
.ss-track[data-asset-type="caption"],
260+
.ss-track[data-asset-type="rich-caption"] {
260261
background: rgba(252, 228, 236, 0.3);
261262
}
262263
.ss-track[data-asset-type="svg"] {
263264
background: rgba(251, 233, 231, 0.3);
264265
}
265-
.ss-track[data-asset-type="html"] {
266+
.ss-track[data-asset-type="html"],
267+
.ss-track[data-asset-type="html5"] {
266268
background: rgba(179, 229, 252, 0.3);
267269
}
268270
.ss-track[data-asset-type="luma"] {
@@ -364,7 +366,8 @@
364366
--clip-fg: #f57f17;
365367
--clip-border: #f9a825;
366368
}
367-
.ss-clip[data-asset-type="caption"] {
369+
.ss-clip[data-asset-type="caption"],
370+
.ss-clip[data-asset-type="rich-caption"] {
368371
--clip-bg: #fce4ec;
369372
--clip-fg: #880e4f;
370373
--clip-border: #c2185b;
@@ -374,7 +377,8 @@
374377
--clip-fg: #bf360c;
375378
--clip-border: #e64a19;
376379
}
377-
.ss-clip[data-asset-type="html"] {
380+
.ss-clip[data-asset-type="html"],
381+
.ss-clip[data-asset-type="html5"] {
378382
--clip-bg: #b3e5fc;
379383
--clip-fg: #006064;
380384
--clip-border: #0097a7;

‎tests/timeline-clip-labels.test.ts‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/** @jest-environment jsdom */
2+
import { Timeline } from "@timeline/timeline";
3+
import { AssetLoadTracker } from "@core/events/asset-load-tracker";
4+
import { EventEmitter } from "@core/events/event-emitter";
5+
import { sec } from "@core/timing/types";
6+
7+
async function mountClips(clips: Array<{ asset: Record<string, unknown> }>) {
8+
const events = new EventEmitter();
9+
const config = { timeline: { tracks: clips.map((clip, i) => ({ clips: [{ id: `clip-${i}`, start: 0, length: 5, ...clip }] })) } };
10+
const edit = {
11+
events,
12+
assetLoader: { loadTracker: new AssetLoadTracker() },
13+
getInternalEvents: () => events,
14+
getResolvedEdit: () => config,
15+
getEdit: () => config,
16+
isClipSelected: () => false,
17+
getClipGenerationState: () => undefined,
18+
getClipError: () => null,
19+
getPendingCaptures: () => ({ pending: 0, total: 0, progress: 1 }),
20+
getClipCaptureProgress: () => null,
21+
playbackTime: sec(0),
22+
totalDuration: sec(5),
23+
isPlaying: false
24+
};
25+
const container = document.createElement("div");
26+
document.body.appendChild(container);
27+
const timeline = new Timeline(edit as never, container);
28+
["html5", "rich-caption"].forEach(type => timeline.registerClipRenderer(type, { render: () => {} }));
29+
await timeline.load();
30+
return [...container.querySelectorAll<HTMLElement>(".ss-clip")].map(clip => ({
31+
icon: clip.querySelector(".ss-clip-icon")?.textContent,
32+
label: clip.querySelector(".ss-clip-label")?.textContent
33+
}));
34+
}
35+
36+
afterEach(() => {
37+
document.body.replaceChildren();
38+
});
39+
40+
it("labels an html5 clip with its headline, under the html icon", async () => {
41+
const [clip] = await mountClips([
42+
{
43+
asset: {
44+
type: "html5",
45+
html: "<style>h1 { color: red }</style><div><h1>Lights out &amp; away</h1><p>We go racing</p></div><script>run()</script>"
46+
}
47+
}
48+
]);
49+
50+
expect(clip).toEqual({ icon: "<>", label: "Lights out & away" });
51+
});
52+
53+
it("labels an html5 clip with its first sentence, past counters, bullets and all-caps labels", async () => {
54+
const clips = await mountClips([
55+
{
56+
asset: {
57+
type: "html5",
58+
html: '<b>01</b><div class="k">/ LEARN</div><h1><span class="mask"><span class="wd">Learns</span></span> <span class="wd">your brand.</span></h1>'
59+
}
60+
},
61+
{ asset: { type: "html5", html: "<b>●</b><div>SENNA · ONLINE</div><h2>Meet Senna.</h2><p>The AI agent that runs your ads.</p>" } }
62+
]);
63+
64+
expect(clips.map(clip => clip.label)).toEqual(["Learns your brand.", "Meet Senna."]);
65+
});
66+
67+
it("keeps words apart across spans with no space between them, and letters split one per span together", async () => {
68+
const clips = await mountClips([
69+
{ asset: { type: "html5", html: '<h1><span class="ln">Your best ad</span><span class="ln">burns out</span></h1>' } },
70+
{ asset: { type: "html5", html: "<h1><span>G</span><span>o</span> <span>f</span><span>a</span><span>s</span><span>t</span></h1>" } }
71+
]);
72+
73+
expect(clips.map(clip => clip.label)).toEqual(["Your best ad burns o...", "Go fast"]);
74+
});
75+
76+
it("labels an html5 clip whose lines are all labels with the first one that has letters", async () => {
77+
const [clip] = await mountClips([{ asset: { type: "html5", html: "<div>00:00.000</div><div><span>LAP</span> <b>01</b></div>" } }]);
78+
79+
expect(clip.label).toBe("LAP 01");
80+
});
81+
82+
it("labels an html5 clip with no visible text as HTML", async () => {
83+
const [clip] = await mountClips([{ asset: { type: "html5", html: "<div class='grain'></div>", css: ".grain { opacity: .2 }" } }]);
84+
85+
expect(clip.label).toBe("HTML");
86+
});
87+
88+
it("labels a rich caption with what it captions, under the caption icon", async () => {
89+
const clips = await mountClips([
90+
{ asset: { type: "rich-caption", src: "alias://voiceover" } },
91+
{ asset: { type: "rich-caption", src: "https://cdn.example.com/subs/race.srt?token=abc" } }
92+
]);
93+
94+
expect(clips).toEqual([
95+
{ icon: "≡", label: "Captions · voiceover" },
96+
{ icon: "≡", label: "Captions · race.srt" }
97+
]);
98+
});

0 commit comments

Comments
 (0)