feat: data-timeline-locked + fix font loss in sub-compositions

Timeline locking:
- Add data-timeline-locked attribute support — fully disables move,
  trim-start, and trim-end in Studio for clips that carry this attr
- Runtime propagates the attribute from inner composition root to host
  element so component authors control it from their HTML
- All 15 caption components in the registry now carry the attribute

Font fix:
- Extract <link rel="stylesheet"> and <link rel="preconnect"> from
  sub-composition <head> alongside existing <style>/<script> extraction
- Fixes caption components (and any sub-comp using Google Fonts via
  <link> tags) losing their font-family when loaded as sub-compositions
- Applied in both runtime (compositionLoader) and compiler
  (inlineSubCompositions) paths
This commit is contained in:
Miguel Ángel
2026-05-20 01:55:17 -04:00
parent 6d9670fd58
commit 471b4efb4b
23 changed files with 98 additions and 17 deletions
+13
View File
@@ -685,6 +685,7 @@ export async function bundleToSingleHtml(
const compStyleChunks: string[] = [...subCompResult.styles]; const compStyleChunks: string[] = [...subCompResult.styles];
const compScriptChunks: string[] = [...subCompResult.scripts]; const compScriptChunks: string[] = [...subCompResult.scripts];
const compExternalScriptSrcs: string[] = [...subCompResult.externalScriptSrcs]; const compExternalScriptSrcs: string[] = [...subCompResult.externalScriptSrcs];
const compExternalLinkHrefs: string[] = [...subCompResult.externalLinkHrefs];
const compVariablesByComp: Record<string, Record<string, unknown>> = { const compVariablesByComp: Record<string, Record<string, unknown>> = {
...subCompResult.variablesByComp, ...subCompResult.variablesByComp,
}; };
@@ -811,6 +812,18 @@ export async function bundleToSingleHtml(
} }
} }
for (const href of compExternalLinkHrefs) {
if (!document.querySelector(`link[href="${href}"]`)) {
const linkEl = document.createElement("link");
linkEl.setAttribute(
"rel",
href.includes(".css") || href.includes("css2?") ? "stylesheet" : "preconnect",
);
linkEl.setAttribute("href", href);
document.head.appendChild(linkEl);
}
}
if (compStyleChunks.length) { if (compStyleChunks.length) {
const style = document.createElement("style"); const style = document.createElement("style");
style.textContent = compStyleChunks.join("\n\n"); style.textContent = compStyleChunks.join("\n\n");
@@ -97,6 +97,7 @@ export interface InlineSubCompositionsResult {
styles: string[]; styles: string[];
scripts: string[]; scripts: string[];
externalScriptSrcs: string[]; externalScriptSrcs: string[];
externalLinkHrefs: string[];
variablesByComp: Record<string, Record<string, unknown>>; variablesByComp: Record<string, Record<string, unknown>>;
} }
@@ -149,6 +150,7 @@ export function inlineSubCompositions(
const styles: string[] = []; const styles: string[] = [];
const scripts: string[] = []; const scripts: string[] = [];
const externalScriptSrcs: string[] = []; const externalScriptSrcs: string[] = [];
const externalLinkHrefs: string[] = [];
const variablesByComp: Record<string, Record<string, unknown>> = {}; const variablesByComp: Record<string, Record<string, unknown>> = {};
for (const hostEl of hosts) { for (const hostEl of hosts) {
@@ -221,6 +223,14 @@ export function inlineSubCompositions(
externalScriptSrcs.push(externalSrc); externalScriptSrcs.push(externalSrc);
} }
} }
for (const link of [
...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
]) {
const href = (link.getAttribute("href") || "").trim();
if (href && !externalLinkHrefs.includes(href)) {
externalLinkHrefs.push(href);
}
}
} }
// Extract styles from content // Extract styles from content
@@ -325,5 +335,5 @@ export function inlineSubCompositions(
hostEl.removeAttribute("data-composition-src"); hostEl.removeAttribute("data-composition-src");
} }
return { styles, scripts, externalScriptSrcs, variablesByComp }; return { styles, scripts, externalScriptSrcs, externalLinkHrefs, variablesByComp };
} }
@@ -262,6 +262,8 @@ async function mountCompositionContent(params: {
headStyles?: HTMLStyleElement[]; headStyles?: HTMLStyleElement[];
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */ /** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
headScripts?: HTMLScriptElement[]; headScripts?: HTMLScriptElement[];
/** Extra <link> elements from the parsed document <head> (font stylesheets, preconnects). */
headLinks?: HTMLLinkElement[];
/** /**
* Defaults extracted from the sub-composition's own * Defaults extracted from the sub-composition's own
* `<html data-composition-variables="...">` attribute. Layered under the * `<html data-composition-variables="...">` attribute. Layered under the
@@ -297,6 +299,15 @@ async function mountCompositionContent(params: {
? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]` ? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]`
: undefined; : 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 // Inject <head> styles from non-template sub-compositions first (they define
// element styles like backgrounds and positioning that the composition needs). // element styles like backgrounds and positioning that the composition needs).
if (params.headStyles) { if (params.headStyles) {
@@ -395,6 +406,9 @@ async function mountCompositionContent(params: {
if (heightRaw) params.host.setAttribute("data-height", heightRaw); if (heightRaw) params.host.setAttribute("data-height", heightRaw);
if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx; if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx;
if (heightPx && params.host instanceof HTMLElement) params.host.style.height = heightPx; 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)); params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
} else if (params.hasTemplate) { } else if (params.hasTemplate) {
params.host.appendChild(document.importNode(contentNode, true)); params.host.appendChild(document.importNode(contentNode, true));
@@ -581,6 +595,13 @@ export async function loadExternalCompositions(
const headScripts = !template const headScripts = !template
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script")) ? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
: undefined; : undefined;
const headLinks = !template
? Array.from(
doc.head.querySelectorAll<HTMLLinkElement>(
'link[rel="stylesheet"], link[rel="preconnect"]',
),
)
: undefined;
await mountCompositionContent({ await mountCompositionContent({
host, host,
authoredCompositionId, authoredCompositionId,
@@ -595,6 +616,7 @@ export async function loadExternalCompositions(
parseDimensionPx: params.parseDimensionPx, parseDimensionPx: params.parseDimensionPx,
headStyles, headStyles,
headScripts, headScripts,
headLinks,
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement), declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
onDiagnostic: params.onDiagnostic, onDiagnostic: params.onDiagnostic,
}); });
@@ -612,6 +612,19 @@ function inlineSubCompositions(
} }
} }
if (result.externalLinkHrefs.length && head) {
for (const href of result.externalLinkHrefs) {
if (document.querySelector(`link[href="${href}"]`)) continue;
const el = document.createElement("link");
el.setAttribute(
"rel",
href.includes(".css") || href.includes("css2?") ? "stylesheet" : "preconnect",
);
el.setAttribute("href", href);
head.appendChild(el);
}
}
// Append collected styles to <head> // Append collected styles to <head>
if (result.styles.length && head) { if (result.styles.length && head) {
const styleEl = document.createElement("style"); const styleEl = document.createElement("style");
@@ -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", () => { it("allows full editing of explicitly authored generic elements", () => {
expect( expect(
getTimelineEditCapabilities({ getTimelineEditCapabilities({
@@ -211,8 +211,9 @@ export function getTimelineEditCapabilities(input: {
playbackStartAttr?: "media-start" | "playback-start"; playbackStartAttr?: "media-start" | "playback-start";
sourceDuration?: number; sourceDuration?: number;
timingSource?: "authored" | "implicit"; timingSource?: "authored" | "implicit";
timelineLocked?: boolean;
}): TimelineEditCapabilities { }): TimelineEditCapabilities {
if (input.timingSource === "implicit") { if (input.timingSource === "implicit" || input.timelineLocked) {
return { return {
canMove: false, canMove: false,
canTrimStart: false, canTrimStart: false,
@@ -280,6 +280,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
applyMediaMetadataFromElement(entry, el); applyMediaMetadataFromElement(entry, el);
} }
if (el.hasAttribute("data-timeline-locked")) {
entry.timelineLocked = true;
}
// Sub-compositions // Sub-compositions
const compSrc = const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file"); el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
@@ -26,6 +26,8 @@ export interface TimelineElement {
compositionSrc?: string; compositionSrc?: string;
/** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */ /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
timingSource?: "authored" | "implicit"; 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"; export type ZoomMode = "fit" | "manual";
@@ -83,7 +83,7 @@
<body> <body>
<div <div
id="clip-wipe" id="clip-wipe"
data-composition-id="caption-clip-wipe" data-composition-id="caption-clip-wipe" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -123,7 +123,7 @@
<body> <body>
<div <div
id="editorial-emphasis" id="editorial-emphasis"
data-composition-id="caption-editorial-emphasis" data-composition-id="caption-editorial-emphasis" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -119,7 +119,7 @@
<body> <body>
<div <div
id="emoji-pop" id="emoji-pop"
data-composition-id="caption-emoji-pop" data-composition-id="caption-emoji-pop" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -103,7 +103,7 @@
<body> <body>
<div <div
id="caption-glitch-rgb" id="caption-glitch-rgb"
data-composition-id="caption-glitch-rgb" data-composition-id="caption-glitch-rgb" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -71,7 +71,7 @@
<body> <body>
<div <div
id="gradient-fill" id="gradient-fill"
data-composition-id="caption-gradient-fill" data-composition-id="caption-gradient-fill" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -102,7 +102,7 @@
<body> <body>
<div <div
id="highlight" id="highlight"
data-composition-id="caption-highlight" data-composition-id="caption-highlight" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -70,7 +70,7 @@
<body> <body>
<div <div
id="kinetic-slam" id="kinetic-slam"
data-composition-id="caption-kinetic-slam" data-composition-id="caption-kinetic-slam" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -91,7 +91,7 @@
<body> <body>
<div <div
id="matrix-decode" id="matrix-decode"
data-composition-id="caption-matrix-decode" data-composition-id="caption-matrix-decode" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -119,7 +119,7 @@
<body> <body>
<div <div
id="neon-accent" id="neon-accent"
data-composition-id="caption-neon-accent" data-composition-id="caption-neon-accent" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -82,7 +82,7 @@
<body> <body>
<div <div
id="neon-glow" id="neon-glow"
data-composition-id="caption-neon-glow" data-composition-id="caption-neon-glow" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -147,7 +147,7 @@
<body> <body>
<div <div
id="parallax-layers" id="parallax-layers"
data-composition-id="caption-parallax-layers" data-composition-id="caption-parallax-layers" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -93,7 +93,7 @@
<body> <body>
<div <div
id="particle-burst" id="particle-burst"
data-composition-id="caption-particle-burst" data-composition-id="caption-particle-burst" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -125,7 +125,7 @@
<body> <body>
<div <div
id="pill-karaoke" id="pill-karaoke"
data-composition-id="caption-pill-karaoke" data-composition-id="caption-pill-karaoke" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
+1 -1
View File
@@ -96,7 +96,7 @@
<body> <body>
<div <div
id="caption-texture" id="caption-texture"
data-composition-id="caption-texture" data-composition-id="caption-texture" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"
@@ -111,7 +111,7 @@
<body> <body>
<div <div
id="weight-shift" id="weight-shift"
data-composition-id="caption-weight-shift" data-composition-id="caption-weight-shift" data-timeline-locked
data-start="0" data-start="0"
data-duration="8" data-duration="8"
data-fps="30" data-fps="30"