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
This commit is contained in:
Miguel Ángel
2026-05-20 09:06:02 +02:00
committed by GitHub
28 changed files with 290 additions and 43 deletions
+12
View File
@@ -685,6 +685,7 @@ export async function bundleToSingleHtml(
const compStyleChunks: string[] = [...subCompResult.styles];
const compScriptChunks: string[] = [...subCompResult.scripts];
const compExternalScriptSrcs: string[] = [...subCompResult.externalScriptSrcs];
const compExternalLinks = [...subCompResult.externalLinks];
const compVariablesByComp: Record<string, Record<string, unknown>> = {
...subCompResult.variablesByComp,
};
@@ -811,6 +812,17 @@ export async function bundleToSingleHtml(
}
}
for (const link of compExternalLinks) {
const escapedHref = link.href.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
if (!document.querySelector(`link[href="${escapedHref}"]`)) {
const linkEl = document.createElement("link");
linkEl.setAttribute("rel", link.rel);
linkEl.setAttribute("href", link.href);
if (link.crossorigin != null) linkEl.setAttribute("crossorigin", link.crossorigin);
document.head.appendChild(linkEl);
}
}
if (compStyleChunks.length) {
const style = document.createElement("style");
style.textContent = compStyleChunks.join("\n\n");
@@ -131,6 +131,91 @@ describe("inlineSubCompositions #ID selector scoping divergence", () => {
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
});
it("extracts <link> elements from sub-composition <head> with original rel and crossorigin", () => {
const subCompWithLinks = `<!doctype html>
<html><head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800&display=swap">
</head><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<span>Hello</span>
</div>
</body></html>`;
const document = makeHostDocument("captions");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => subCompWithLinks,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.externalLinks).toHaveLength(3);
expect(result.externalLinks[0]).toEqual({
href: "https://fonts.googleapis.com",
rel: "preconnect",
crossorigin: undefined,
});
expect(result.externalLinks[1]).toEqual({
href: "https://fonts.gstatic.com",
rel: "preconnect",
crossorigin: "",
});
expect(result.externalLinks[2]).toEqual({
href: "https://fonts.googleapis.com/css2?family=Montserrat:wght@800&display=swap",
rel: "stylesheet",
crossorigin: undefined,
});
});
it("deduplicates link hrefs across multiple sub-compositions", () => {
const subComp = `<!doctype html>
<html><head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800">
</head><body>
<div data-composition-id="cap1" data-width="1920" data-height="1080"><span>A</span></div>
</body></html>`;
const { document } = parseHTML(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">
<div data-composition-id="cap1" data-composition-src="cap1.html" data-start="0" data-duration="4" data-track-index="0"></div>
<div data-composition-id="cap2" data-composition-src="cap2.html" data-start="4" data-duration="4" data-track-index="1"></div>
</div>
</body></html>`);
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
const result = inlineSubCompositions(document, hosts, {
resolveHtml: () => subComp,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.externalLinks).toHaveLength(1);
expect(result.externalLinks[0]!.href).toBe(
"https://fonts.googleapis.com/css2?family=Montserrat:wght@800",
);
});
it("propagates data-timeline-locked from inner root to host element", () => {
const lockedSubComp = `<!doctype html>
<html><head></head><body>
<div id="captions" data-composition-id="captions" data-timeline-locked data-width="1920" data-height="1080">
<span>Hello</span>
</div>
</body></html>`;
const document = makeHostDocument("captions");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
inlineSubCompositions(document, [host], {
resolveHtml: () => lockedSubComp,
parseHtml: (html) => parseHTML(html).document,
});
expect(host.hasAttribute("data-timeline-locked")).toBe(true);
});
it("producer path propagates data-hf-authored-id to host when inner root has id", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
@@ -97,6 +97,7 @@ export interface InlineSubCompositionsResult {
styles: string[];
scripts: string[];
externalScriptSrcs: string[];
externalLinks: { href: string; rel: string; crossorigin?: string }[];
variablesByComp: Record<string, Record<string, unknown>>;
}
@@ -149,6 +150,8 @@ export function inlineSubCompositions(
const styles: string[] = [];
const scripts: string[] = [];
const externalScriptSrcs: string[] = [];
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
const seenLinkHrefs = new Set<string>();
const variablesByComp: Record<string, Record<string, unknown>> = {};
for (const hostEl of hosts) {
@@ -221,6 +224,19 @@ export function inlineSubCompositions(
externalScriptSrcs.push(externalSrc);
}
}
for (const link of [
...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
]) {
const href = (link.getAttribute("href") || "").trim();
if (href && !seenLinkHrefs.has(href)) {
seenLinkHrefs.add(href);
const rel = (link.getAttribute("rel") || "").trim();
const crossorigin = link.hasAttribute("crossorigin")
? link.getAttribute("crossorigin") || ""
: undefined;
externalLinks.push({ href, rel, crossorigin });
}
}
}
// Extract styles from content
@@ -286,6 +302,10 @@ export function inlineSubCompositions(
);
}
if (innerRoot?.hasAttribute("data-timeline-locked")) {
hostEl.setAttribute("data-timeline-locked", "");
}
// Copy dimension attributes from inner root to host if missing
if (innerRoot) {
const innerW = innerRoot.getAttribute("data-width");
@@ -325,5 +345,5 @@ export function inlineSubCompositions(
hostEl.removeAttribute("data-composition-src");
}
return { styles, scripts, externalScriptSrcs, variablesByComp };
return { styles, scripts, externalScriptSrcs, externalLinks, variablesByComp };
}
@@ -262,6 +262,8 @@ async function mountCompositionContent(params: {
headStyles?: HTMLStyleElement[];
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
headScripts?: HTMLScriptElement[];
/** Extra <link> elements from the parsed document <head> (font stylesheets, preconnects). */
headLinks?: HTMLLinkElement[];
/**
* Defaults extracted from the sub-composition's own
* `<html data-composition-variables="...">` attribute. Layered under the
@@ -297,6 +299,15 @@ async function mountCompositionContent(params: {
? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]`
: undefined;
if (params.headLinks) {
for (const link of params.headLinks) {
const href = link.getAttribute("href") || "";
if (!href) continue;
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
document.head.appendChild(link.cloneNode(true));
}
}
// Inject <head> styles from non-template sub-compositions first (they define
// element styles like backgrounds and positioning that the composition needs).
if (params.headStyles) {
@@ -395,6 +406,9 @@ async function mountCompositionContent(params: {
if (heightRaw) params.host.setAttribute("data-height", heightRaw);
if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx;
if (heightPx && params.host instanceof HTMLElement) params.host.style.height = heightPx;
if (innerRoot.hasAttribute("data-timeline-locked")) {
params.host.setAttribute("data-timeline-locked", "");
}
params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
} else if (params.hasTemplate) {
params.host.appendChild(document.importNode(contentNode, true));
@@ -581,6 +595,13 @@ export async function loadExternalCompositions(
const headScripts = !template
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
: undefined;
const headLinks = !template
? Array.from(
doc.head.querySelectorAll<HTMLLinkElement>(
'link[rel="stylesheet"], link[rel="preconnect"]',
),
)
: undefined;
await mountCompositionContent({
host,
authoredCompositionId,
@@ -595,6 +616,7 @@ export async function loadExternalCompositions(
parseDimensionPx: params.parseDimensionPx,
headStyles,
headScripts,
headLinks,
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
onDiagnostic: params.onDiagnostic,
});
@@ -612,6 +612,18 @@ function inlineSubCompositions(
}
}
if (result.externalLinks.length && head) {
for (const link of result.externalLinks) {
const escapedHref = link.href.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
if (document.querySelector(`link[href="${escapedHref}"]`)) continue;
const el = document.createElement("link");
el.setAttribute("rel", link.rel);
el.setAttribute("href", link.href);
if (link.crossorigin != null) el.setAttribute("crossorigin", link.crossorigin);
head.appendChild(el);
}
}
// Append collected styles to <head>
if (result.styles.length && head) {
const styleEl = document.createElement("style");
@@ -12,6 +12,7 @@ import type {
} from "./domEditingTypes";
import {
buildStableSelector,
findClosestByAttribute,
getCuratedComputedStyles,
getDataAttributes,
getInlineStyles,
@@ -175,18 +176,21 @@ export function resolveDomEditCapabilities(args: {
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
isCompositionHost: boolean;
isInsideLockedComposition: boolean;
isMasterView: boolean;
}): DomEditCapabilities {
if (!args.selector) {
if (!args.selector || args.isInsideLockedComposition) {
return {
canSelect: false,
canSelect: !args.isInsideLockedComposition,
canEditStyles: false,
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: "Studio could not resolve a stable patch target for this element.",
reasonIfDisabled: args.isInsideLockedComposition
? "This element belongs to a locked composition."
: "Studio could not resolve a stable patch target for this element.",
};
}
@@ -298,6 +302,7 @@ export function resolveDomEditSelection(
const inlineStyles = getInlineStyles(current);
const computedStyles = getCuratedComputedStyles(current);
const textFields = collectDomEditTextFields(current);
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
const capabilities = resolveDomEditCapabilities({
selector,
tagName: current.tagName.toLowerCase(),
@@ -305,6 +310,7 @@ export function resolveDomEditSelection(
inlineStyles,
computedStyles,
isCompositionHost: Boolean(compositionSrc),
isInsideLockedComposition: isInsideLocked,
isMasterView: options.isMasterView,
});
const rect = current.getBoundingClientRect();
@@ -318,6 +324,7 @@ export function resolveDomEditSelection(
compositionPath,
compositionSrc,
isCompositionHost: Boolean(compositionSrc),
isInsideLockedComposition: isInsideLocked,
label: buildElementLabel(current),
tagName: current.tagName.toLowerCase(),
boundingBox: {
@@ -488,7 +495,11 @@ export function getDomEditTargetKey(
}
export function isTextEditableSelection(selection: DomEditSelection): boolean {
return selection.textFields.length > 0 && !selection.isCompositionHost;
return (
selection.textFields.length > 0 &&
!selection.isCompositionHost &&
!selection.isInsideLockedComposition
);
}
// buildElementAgentPrompt is in domEditingAgentPrompt.ts
@@ -78,6 +78,7 @@ export interface DomEditSelection extends PatchTarget {
compositionPath: string;
compositionSrc?: string;
isCompositionHost: boolean;
isInsideLockedComposition: boolean;
boundingBox: { x: number; y: number; width: number; height: number };
textContent: string | null;
dataAttributes: Record<string, string>;
@@ -263,6 +263,22 @@ describe("getTimelineEditCapabilities", () => {
});
});
it("locks all timeline edits for clips with data-timeline-locked", () => {
expect(
getTimelineEditCapabilities({
tag: "div",
duration: 8,
selector: '[data-composition-id="caption-highlight"]',
compositionSrc: "compositions/components/caption-highlight.html",
timelineLocked: true,
}),
).toEqual({
canMove: false,
canTrimStart: false,
canTrimEnd: false,
});
});
it("allows full editing of explicitly authored generic elements", () => {
expect(
getTimelineEditCapabilities({
@@ -211,8 +211,9 @@ export function getTimelineEditCapabilities(input: {
playbackStartAttr?: "media-start" | "playback-start";
sourceDuration?: number;
timingSource?: "authored" | "implicit";
timelineLocked?: boolean;
}): TimelineEditCapabilities {
if (input.timingSource === "implicit") {
if (input.timingSource === "implicit" || input.timelineLocked) {
return {
canMove: false,
canTrimStart: false,
@@ -280,6 +280,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
applyMediaMetadataFromElement(entry, el);
}
if (el.hasAttribute("data-timeline-locked")) {
entry.timelineLocked = true;
}
// Sub-compositions
const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
@@ -26,6 +26,8 @@ export interface TimelineElement {
compositionSrc?: string;
/** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
timingSource?: "authored" | "implicit";
/** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
timelineLocked?: boolean;
}
export type ZoomMode = "fit" | "manual";