Skip to content

Commit d9157ef

Browse files
feat: data-timeline-locked, fix sub-comp fonts, caption overlay UX (#981)
## Summary ### `data-timeline-locked` attribute - Clips with this attribute are fully locked in the Studio timeline (no move, no trim-start, no trim-end) - Parsed in `timelineDOM.ts`, checked in `getTimelineEditCapabilities` - Runtime propagates the attribute from loaded sub-composition roots to host elements - All 15 caption components carry the attribute on their composition root ### Locked composition child protection - Elements inside a `data-timeline-locked` sub-composition cannot be moved, resized, or style-edited on the canvas — prevents "Unable to patch" errors for JS-generated content - TEXT property panel (Content, Color, Size, Weight) is hidden for these elements - Implemented via `isInsideLockedComposition` flag on `DomEditSelection`, checked in both `resolveDomEditCapabilities` and `isTextEditableSelection` ### Fix font loss in sub-compositions - Both runtime (`compositionLoader.ts`) and compiler (`inlineSubCompositions.ts`, `htmlBundler.ts`, `htmlCompiler.ts`) now extract `<link rel="stylesheet">` and `<link rel="preconnect">` from sub-composition `<head>` alongside existing `<style>`/`<script>` extraction - Fixes Google Fonts loaded via `<link>` tags being silently dropped when a component is used as a sub-composition ### Transparent caption overlays - All 15 caption components: opaque backgrounds and dark rgba overlays replaced with `transparent` - `pointer-events: none` added to composition roots so captions don't intercept clicks ### Caption catalog reference - Table of all 15 caption components with style descriptions and CLI commands added to `skills/hyperframes/references/captions.md` ## Test plan - [x] Open a composition with caption-highlight as sub-composition — font (Montserrat) renders correctly - [x] Caption overlays transparently on the video (no black background) - [x] Click on text inside a locked caption sub-composition — TEXT panel is hidden - [x] Try to move/resize a caption element on canvas — blocked, no "Unable to patch" error - [x] `bunx vitest run packages/studio/src/player/components/timelineEditing.test.ts` — 37 tests pass - [x] In Studio timeline, verify a `data-timeline-locked` clip cannot be moved or trimmed
2 parents 07bcb4f + 3e17ef7 commit d9157ef

28 files changed

Lines changed: 290 additions & 43 deletions

File tree

.filesize-allowlist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ packages/studio/src/App.tsx
1010
packages/studio/src/player/components/Timeline.tsx
1111
packages/studio/src/player/components/timelineEditing.test.ts
1212
packages/studio/src/components/editor/domEditing.test.ts
13+
packages/studio/src/components/editor/domEditingLayers.ts

packages/core/src/compiler/htmlBundler.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,7 @@ export async function bundleToSingleHtml(
685685
const compStyleChunks: string[] = [...subCompResult.styles];
686686
const compScriptChunks: string[] = [...subCompResult.scripts];
687687
const compExternalScriptSrcs: string[] = [...subCompResult.externalScriptSrcs];
688+
const compExternalLinks = [...subCompResult.externalLinks];
688689
const compVariablesByComp: Record<string, Record<string, unknown>> = {
689690
...subCompResult.variablesByComp,
690691
};
@@ -811,6 +812,17 @@ export async function bundleToSingleHtml(
811812
}
812813
}
813814

815+
for (const link of compExternalLinks) {
816+
const escapedHref = link.href.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
817+
if (!document.querySelector(`link[href="${escapedHref}"]`)) {
818+
const linkEl = document.createElement("link");
819+
linkEl.setAttribute("rel", link.rel);
820+
linkEl.setAttribute("href", link.href);
821+
if (link.crossorigin != null) linkEl.setAttribute("crossorigin", link.crossorigin);
822+
document.head.appendChild(linkEl);
823+
}
824+
}
825+
814826
if (compStyleChunks.length) {
815827
const style = document.createElement("style");
816828
style.textContent = compStyleChunks.join("\n\n");

packages/core/src/compiler/inlineSubCompositions.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,91 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
131131
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
132132
});
133133

134+
it("extracts <link> elements from sub-composition <head> with original rel and crossorigin", () => {
135+
const subCompWithLinks = `<!doctype html>
136+
<html><head>
137+
<link rel="preconnect" href="https://fonts.googleapis.com">
138+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
139+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800&display=swap">
140+
</head><body>
141+
<div data-composition-id="captions" data-width="1920" data-height="1080">
142+
<span>Hello</span>
143+
</div>
144+
</body></html>`;
145+
146+
const document = makeHostDocument("captions");
147+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
148+
149+
const result = inlineSubCompositions(document, [host], {
150+
resolveHtml: () => subCompWithLinks,
151+
parseHtml: (html) => parseHTML(html).document,
152+
});
153+
154+
expect(result.externalLinks).toHaveLength(3);
155+
expect(result.externalLinks[0]).toEqual({
156+
href: "https://fonts.googleapis.com",
157+
rel: "preconnect",
158+
crossorigin: undefined,
159+
});
160+
expect(result.externalLinks[1]).toEqual({
161+
href: "https://fonts.gstatic.com",
162+
rel: "preconnect",
163+
crossorigin: "",
164+
});
165+
expect(result.externalLinks[2]).toEqual({
166+
href: "https://fonts.googleapis.com/css2?family=Montserrat:wght@800&display=swap",
167+
rel: "stylesheet",
168+
crossorigin: undefined,
169+
});
170+
});
171+
172+
it("deduplicates link hrefs across multiple sub-compositions", () => {
173+
const subComp = `<!doctype html>
174+
<html><head>
175+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800">
176+
</head><body>
177+
<div data-composition-id="cap1" data-width="1920" data-height="1080"><span>A</span></div>
178+
</body></html>`;
179+
180+
const { document } = parseHTML(`<!DOCTYPE html>
181+
<html><body>
182+
<div data-composition-id="main">
183+
<div data-composition-id="cap1" data-composition-src="cap1.html" data-start="0" data-duration="4" data-track-index="0"></div>
184+
<div data-composition-id="cap2" data-composition-src="cap2.html" data-start="4" data-duration="4" data-track-index="1"></div>
185+
</div>
186+
</body></html>`);
187+
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
188+
189+
const result = inlineSubCompositions(document, hosts, {
190+
resolveHtml: () => subComp,
191+
parseHtml: (html) => parseHTML(html).document,
192+
});
193+
194+
expect(result.externalLinks).toHaveLength(1);
195+
expect(result.externalLinks[0]!.href).toBe(
196+
"https://fonts.googleapis.com/css2?family=Montserrat:wght@800",
197+
);
198+
});
199+
200+
it("propagates data-timeline-locked from inner root to host element", () => {
201+
const lockedSubComp = `<!doctype html>
202+
<html><head></head><body>
203+
<div id="captions" data-composition-id="captions" data-timeline-locked data-width="1920" data-height="1080">
204+
<span>Hello</span>
205+
</div>
206+
</body></html>`;
207+
208+
const document = makeHostDocument("captions");
209+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
210+
211+
inlineSubCompositions(document, [host], {
212+
resolveHtml: () => lockedSubComp,
213+
parseHtml: (html) => parseHTML(html).document,
214+
});
215+
216+
expect(host.hasAttribute("data-timeline-locked")).toBe(true);
217+
});
218+
134219
it("producer path propagates data-hf-authored-id to host when inner root has id", () => {
135220
const document = makeHostDocument("intro");
136221
const host = document.querySelector('[data-composition-src="intro.html"]')!;

packages/core/src/compiler/inlineSubCompositions.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export interface InlineSubCompositionsResult {
9797
styles: string[];
9898
scripts: string[];
9999
externalScriptSrcs: string[];
100+
externalLinks: { href: string; rel: string; crossorigin?: string }[];
100101
variablesByComp: Record<string, Record<string, unknown>>;
101102
}
102103

@@ -149,6 +150,8 @@ export function inlineSubCompositions(
149150
const styles: string[] = [];
150151
const scripts: string[] = [];
151152
const externalScriptSrcs: string[] = [];
153+
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
154+
const seenLinkHrefs = new Set<string>();
152155
const variablesByComp: Record<string, Record<string, unknown>> = {};
153156

154157
for (const hostEl of hosts) {
@@ -221,6 +224,19 @@ export function inlineSubCompositions(
221224
externalScriptSrcs.push(externalSrc);
222225
}
223226
}
227+
for (const link of [
228+
...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
229+
]) {
230+
const href = (link.getAttribute("href") || "").trim();
231+
if (href && !seenLinkHrefs.has(href)) {
232+
seenLinkHrefs.add(href);
233+
const rel = (link.getAttribute("rel") || "").trim();
234+
const crossorigin = link.hasAttribute("crossorigin")
235+
? link.getAttribute("crossorigin") || ""
236+
: undefined;
237+
externalLinks.push({ href, rel, crossorigin });
238+
}
239+
}
224240
}
225241

226242
// Extract styles from content
@@ -286,6 +302,10 @@ export function inlineSubCompositions(
286302
);
287303
}
288304

305+
if (innerRoot?.hasAttribute("data-timeline-locked")) {
306+
hostEl.setAttribute("data-timeline-locked", "");
307+
}
308+
289309
// Copy dimension attributes from inner root to host if missing
290310
if (innerRoot) {
291311
const innerW = innerRoot.getAttribute("data-width");
@@ -325,5 +345,5 @@ export function inlineSubCompositions(
325345
hostEl.removeAttribute("data-composition-src");
326346
}
327347

328-
return { styles, scripts, externalScriptSrcs, variablesByComp };
348+
return { styles, scripts, externalScriptSrcs, externalLinks, variablesByComp };
329349
}

packages/core/src/runtime/compositionLoader.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ async function mountCompositionContent(params: {
262262
headStyles?: HTMLStyleElement[];
263263
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
264264
headScripts?: HTMLScriptElement[];
265+
/** Extra <link> elements from the parsed document <head> (font stylesheets, preconnects). */
266+
headLinks?: HTMLLinkElement[];
265267
/**
266268
* Defaults extracted from the sub-composition's own
267269
* `<html data-composition-variables="...">` attribute. Layered under the
@@ -297,6 +299,15 @@ async function mountCompositionContent(params: {
297299
? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]`
298300
: undefined;
299301

302+
if (params.headLinks) {
303+
for (const link of params.headLinks) {
304+
const href = link.getAttribute("href") || "";
305+
if (!href) continue;
306+
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
307+
document.head.appendChild(link.cloneNode(true));
308+
}
309+
}
310+
300311
// Inject <head> styles from non-template sub-compositions first (they define
301312
// element styles like backgrounds and positioning that the composition needs).
302313
if (params.headStyles) {
@@ -395,6 +406,9 @@ async function mountCompositionContent(params: {
395406
if (heightRaw) params.host.setAttribute("data-height", heightRaw);
396407
if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx;
397408
if (heightPx && params.host instanceof HTMLElement) params.host.style.height = heightPx;
409+
if (innerRoot.hasAttribute("data-timeline-locked")) {
410+
params.host.setAttribute("data-timeline-locked", "");
411+
}
398412
params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
399413
} else if (params.hasTemplate) {
400414
params.host.appendChild(document.importNode(contentNode, true));
@@ -581,6 +595,13 @@ export async function loadExternalCompositions(
581595
const headScripts = !template
582596
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
583597
: undefined;
598+
const headLinks = !template
599+
? Array.from(
600+
doc.head.querySelectorAll<HTMLLinkElement>(
601+
'link[rel="stylesheet"], link[rel="preconnect"]',
602+
),
603+
)
604+
: undefined;
584605
await mountCompositionContent({
585606
host,
586607
authoredCompositionId,
@@ -595,6 +616,7 @@ export async function loadExternalCompositions(
595616
parseDimensionPx: params.parseDimensionPx,
596617
headStyles,
597618
headScripts,
619+
headLinks,
598620
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
599621
onDiagnostic: params.onDiagnostic,
600622
});

packages/producer/src/services/htmlCompiler.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,18 @@ function inlineSubCompositions(
612612
}
613613
}
614614

615+
if (result.externalLinks.length && head) {
616+
for (const link of result.externalLinks) {
617+
const escapedHref = link.href.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
618+
if (document.querySelector(`link[href="${escapedHref}"]`)) continue;
619+
const el = document.createElement("link");
620+
el.setAttribute("rel", link.rel);
621+
el.setAttribute("href", link.href);
622+
if (link.crossorigin != null) el.setAttribute("crossorigin", link.crossorigin);
623+
head.appendChild(el);
624+
}
625+
}
626+
615627
// Append collected styles to <head>
616628
if (result.styles.length && head) {
617629
const styleEl = document.createElement("style");

packages/studio/src/components/editor/domEditingLayers.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
} from "./domEditingTypes";
1313
import {
1414
buildStableSelector,
15+
findClosestByAttribute,
1516
getCuratedComputedStyles,
1617
getDataAttributes,
1718
getInlineStyles,
@@ -175,18 +176,21 @@ export function resolveDomEditCapabilities(args: {
175176
inlineStyles: Record<string, string>;
176177
computedStyles: Record<string, string>;
177178
isCompositionHost: boolean;
179+
isInsideLockedComposition: boolean;
178180
isMasterView: boolean;
179181
}): DomEditCapabilities {
180-
if (!args.selector) {
182+
if (!args.selector || args.isInsideLockedComposition) {
181183
return {
182-
canSelect: false,
184+
canSelect: !args.isInsideLockedComposition,
183185
canEditStyles: false,
184186
canMove: false,
185187
canResize: false,
186188
canApplyManualOffset: false,
187189
canApplyManualSize: false,
188190
canApplyManualRotation: false,
189-
reasonIfDisabled: "Studio could not resolve a stable patch target for this element.",
191+
reasonIfDisabled: args.isInsideLockedComposition
192+
? "This element belongs to a locked composition."
193+
: "Studio could not resolve a stable patch target for this element.",
190194
};
191195
}
192196

@@ -298,13 +302,15 @@ export function resolveDomEditSelection(
298302
const inlineStyles = getInlineStyles(current);
299303
const computedStyles = getCuratedComputedStyles(current);
300304
const textFields = collectDomEditTextFields(current);
305+
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
301306
const capabilities = resolveDomEditCapabilities({
302307
selector,
303308
tagName: current.tagName.toLowerCase(),
304309
className: current.className,
305310
inlineStyles,
306311
computedStyles,
307312
isCompositionHost: Boolean(compositionSrc),
313+
isInsideLockedComposition: isInsideLocked,
308314
isMasterView: options.isMasterView,
309315
});
310316
const rect = current.getBoundingClientRect();
@@ -318,6 +324,7 @@ export function resolveDomEditSelection(
318324
compositionPath,
319325
compositionSrc,
320326
isCompositionHost: Boolean(compositionSrc),
327+
isInsideLockedComposition: isInsideLocked,
321328
label: buildElementLabel(current),
322329
tagName: current.tagName.toLowerCase(),
323330
boundingBox: {
@@ -488,7 +495,11 @@ export function getDomEditTargetKey(
488495
}
489496

490497
export function isTextEditableSelection(selection: DomEditSelection): boolean {
491-
return selection.textFields.length > 0 && !selection.isCompositionHost;
498+
return (
499+
selection.textFields.length > 0 &&
500+
!selection.isCompositionHost &&
501+
!selection.isInsideLockedComposition
502+
);
492503
}
493504

494505
// buildElementAgentPrompt is in domEditingAgentPrompt.ts

packages/studio/src/components/editor/domEditingTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export interface DomEditSelection extends PatchTarget {
7878
compositionPath: string;
7979
compositionSrc?: string;
8080
isCompositionHost: boolean;
81+
isInsideLockedComposition: boolean;
8182
boundingBox: { x: number; y: number; width: number; height: number };
8283
textContent: string | null;
8384
dataAttributes: Record<string, string>;

packages/studio/src/player/components/timelineEditing.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,22 @@ describe("getTimelineEditCapabilities", () => {
263263
});
264264
});
265265

266+
it("locks all timeline edits for clips with data-timeline-locked", () => {
267+
expect(
268+
getTimelineEditCapabilities({
269+
tag: "div",
270+
duration: 8,
271+
selector: '[data-composition-id="caption-highlight"]',
272+
compositionSrc: "compositions/components/caption-highlight.html",
273+
timelineLocked: true,
274+
}),
275+
).toEqual({
276+
canMove: false,
277+
canTrimStart: false,
278+
canTrimEnd: false,
279+
});
280+
});
281+
266282
it("allows full editing of explicitly authored generic elements", () => {
267283
expect(
268284
getTimelineEditCapabilities({

packages/studio/src/player/components/timelineEditing.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,9 @@ export function getTimelineEditCapabilities(input: {
211211
playbackStartAttr?: "media-start" | "playback-start";
212212
sourceDuration?: number;
213213
timingSource?: "authored" | "implicit";
214+
timelineLocked?: boolean;
214215
}): TimelineEditCapabilities {
215-
if (input.timingSource === "implicit") {
216+
if (input.timingSource === "implicit" || input.timelineLocked) {
216217
return {
217218
canMove: false,
218219
canTrimStart: false,

0 commit comments

Comments
 (0)