From 2508684d874ac7430eccdf3dbbf4a633b04f15e1 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 29 Oct 2026 11:52:27 +1100 Subject: [PATCH] fix: give html5 and rich-caption clips their own look on the timeline --- .../components/clip/clip-component.ts | 78 ++++++++++++++- src/styles/timeline/timeline.css | 12 ++- tests/timeline-clip-labels.test.ts | 98 +++++++++++++++++++ 3 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 tests/timeline-clip-labels.test.ts diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts index 24c06d56..19f888ff 100644 --- a/src/components/timeline/components/clip/clip-component.ts +++ b/src/components/timeline/components/clip/clip-component.ts @@ -31,6 +31,58 @@ export interface ClipComponentOptions { } /** Renders a single clip element */ +function fileName(src: string): string { + return (src.split("/").pop() || src).split("?")[0]; +} + +function truncateLabel(text: string): string { + return text.substring(0, 20) + (text.length > 20 ? "..." : ""); +} + +const BLOCK_ELEMENTS = + "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"; + +/** Each block's text on an html5 page, in reading order, with inline pieces such as animated words joined. */ +function pageLines(html: string): string[] { + // Parsing runs none of the page's scripts. + const doc = new DOMParser().parseFromString(html, "text/html"); + doc.querySelectorAll("script, style, template, noscript").forEach(element => element.remove()); + const lines: string[] = []; + let line = ""; + const endLine = (): void => { + const text = line.replace(/\s+/g, " ").trim(); + if (text) lines.push(text); + line = ""; + }; + const read = (node: Node): void => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? ""; + // Line and word spans often sit flush against each other. Letters split one per span stay together. + if (text.trim().length > 1 && /\S$/.test(line) && /^\S/.test(text)) line += " "; + line += text; + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + const block = (node as Element).matches(BLOCK_ELEMENTS) || node.nodeName === "BR"; + if (block) endLine(); + node.childNodes.forEach(read); + if (block) endLine(); + }; + read(doc.body); + endLine(); + return lines; +} + +/** + * The line that reads as the page's headline. Pages often open with counters, bullets and all-caps labels, and a + * headline is the first line of two or more words in sentence case. + */ +export function pageHeadline(html: string): string | null { + const lines = pageLines(html); + const sentence = lines.find(text => /\p{Ll}/u.test(text) && (text.match(/\p{L}{2,}/gu)?.length ?? 0) >= 2); + return sentence ?? lines.find(text => /\p{L}/u.test(text)) ?? null; +} + export class ClipComponent { public readonly element: HTMLElement; private readonly options: ClipComponentOptions; @@ -44,6 +96,9 @@ export class ClipComponent { // Cached element references (avoid querySelector every frame) private iconEl: HTMLElement | null = null; private labelEl: HTMLElement | null = null; + // Parsing a page for its label is too slow to repeat on every draw. + private html5LabelSource: string | null = null; + private html5Label = ""; private badgeEl: HTMLElement | null = null; constructor(clip: ClipState, options: ClipComponentOptions) { @@ -368,6 +423,8 @@ export class ClipComponent { shape: "◇", caption: "≡", html: "<>", + html5: "<>", + "rich-caption": "≡", luma: "◐", svg: "◇" }; @@ -407,14 +464,27 @@ export class ClipComponent { return "Shape"; } - if ("src" in asset && typeof asset.src === "string") { + if (assetType === "html5") { + const html = "html" in asset && typeof asset.html === "string" ? asset.html : ""; + if (html !== this.html5LabelSource) { + this.html5LabelSource = html; + this.html5Label = truncateLabel(pageHeadline(html) ?? "HTML"); + } + return this.html5Label; + } + + // Its words come from parsing or transcribing the source at load, so the label names the source. + if (assetType === "rich-caption" && "src" in asset && typeof asset.src === "string") { const { src } = asset; - const filename = src.split("/").pop() || src; - return filename.split("?")[0]; + return `Captions · ${src.startsWith("alias://") ? src.slice("alias://".length) : fileName(src)}`; + } + + if ("src" in asset && typeof asset.src === "string") { + return fileName(asset.src); } if ("text" in asset && typeof asset.text === "string") { - return asset.text.substring(0, 20) + (asset.text.length > 20 ? "..." : ""); + return truncateLabel(asset.text); } return assetType || "Clip"; diff --git a/src/styles/timeline/timeline.css b/src/styles/timeline/timeline.css index 1464bca9..ebac99f6 100644 --- a/src/styles/timeline/timeline.css +++ b/src/styles/timeline/timeline.css @@ -256,13 +256,15 @@ .ss-track[data-asset-type="shape"] { background: rgba(255, 243, 184, 0.3); } -.ss-track[data-asset-type="caption"] { +.ss-track[data-asset-type="caption"], +.ss-track[data-asset-type="rich-caption"] { background: rgba(252, 228, 236, 0.3); } .ss-track[data-asset-type="svg"] { background: rgba(251, 233, 231, 0.3); } -.ss-track[data-asset-type="html"] { +.ss-track[data-asset-type="html"], +.ss-track[data-asset-type="html5"] { background: rgba(179, 229, 252, 0.3); } .ss-track[data-asset-type="luma"] { @@ -364,7 +366,8 @@ --clip-fg: #f57f17; --clip-border: #f9a825; } -.ss-clip[data-asset-type="caption"] { +.ss-clip[data-asset-type="caption"], +.ss-clip[data-asset-type="rich-caption"] { --clip-bg: #fce4ec; --clip-fg: #880e4f; --clip-border: #c2185b; @@ -374,7 +377,8 @@ --clip-fg: #bf360c; --clip-border: #e64a19; } -.ss-clip[data-asset-type="html"] { +.ss-clip[data-asset-type="html"], +.ss-clip[data-asset-type="html5"] { --clip-bg: #b3e5fc; --clip-fg: #006064; --clip-border: #0097a7; diff --git a/tests/timeline-clip-labels.test.ts b/tests/timeline-clip-labels.test.ts new file mode 100644 index 00000000..79fbd963 --- /dev/null +++ b/tests/timeline-clip-labels.test.ts @@ -0,0 +1,98 @@ +/** @jest-environment jsdom */ +import { Timeline } from "@timeline/timeline"; +import { AssetLoadTracker } from "@core/events/asset-load-tracker"; +import { EventEmitter } from "@core/events/event-emitter"; +import { sec } from "@core/timing/types"; + +async function mountClips(clips: Array<{ asset: Record }>) { + const events = new EventEmitter(); + const config = { timeline: { tracks: clips.map((clip, i) => ({ clips: [{ id: `clip-${i}`, start: 0, length: 5, ...clip }] })) } }; + const edit = { + events, + assetLoader: { loadTracker: new AssetLoadTracker() }, + getInternalEvents: () => events, + getResolvedEdit: () => config, + getEdit: () => config, + isClipSelected: () => false, + getClipGenerationState: () => undefined, + getClipError: () => null, + getPendingCaptures: () => ({ pending: 0, total: 0, progress: 1 }), + getClipCaptureProgress: () => null, + playbackTime: sec(0), + totalDuration: sec(5), + isPlaying: false + }; + const container = document.createElement("div"); + document.body.appendChild(container); + const timeline = new Timeline(edit as never, container); + ["html5", "rich-caption"].forEach(type => timeline.registerClipRenderer(type, { render: () => {} })); + await timeline.load(); + return [...container.querySelectorAll(".ss-clip")].map(clip => ({ + icon: clip.querySelector(".ss-clip-icon")?.textContent, + label: clip.querySelector(".ss-clip-label")?.textContent + })); +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +it("labels an html5 clip with its headline, under the html icon", async () => { + const [clip] = await mountClips([ + { + asset: { + type: "html5", + html: "

Lights out & away

We go racing

" + } + } + ]); + + expect(clip).toEqual({ icon: "<>", label: "Lights out & away" }); +}); + +it("labels an html5 clip with its first sentence, past counters, bullets and all-caps labels", async () => { + const clips = await mountClips([ + { + asset: { + type: "html5", + html: '01
/ LEARN

Learns your brand.

' + } + }, + { asset: { type: "html5", html: "●
SENNA · ONLINE

Meet Senna.

The AI agent that runs your ads.

" } } + ]); + + expect(clips.map(clip => clip.label)).toEqual(["Learns your brand.", "Meet Senna."]); +}); + +it("keeps words apart across spans with no space between them, and letters split one per span together", async () => { + const clips = await mountClips([ + { asset: { type: "html5", html: '

Your best adburns out

' } }, + { asset: { type: "html5", html: "

Go fast

" } } + ]); + + expect(clips.map(clip => clip.label)).toEqual(["Your best ad burns o...", "Go fast"]); +}); + +it("labels an html5 clip whose lines are all labels with the first one that has letters", async () => { + const [clip] = await mountClips([{ asset: { type: "html5", html: "
00:00.000
LAP 01
" } }]); + + expect(clip.label).toBe("LAP 01"); +}); + +it("labels an html5 clip with no visible text as HTML", async () => { + const [clip] = await mountClips([{ asset: { type: "html5", html: "
", css: ".grain { opacity: .2 }" } }]); + + expect(clip.label).toBe("HTML"); +}); + +it("labels a rich caption with what it captions, under the caption icon", async () => { + const clips = await mountClips([ + { asset: { type: "rich-caption", src: "alias://voiceover" } }, + { asset: { type: "rich-caption", src: "https://cdn.example.com/subs/race.srt?token=abc" } } + ]); + + expect(clips).toEqual([ + { icon: "≡", label: "Captions · voiceover" }, + { icon: "≡", label: "Captions · race.srt" } + ]); +});