Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .filesize-allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ packages/studio/src/App.tsx
packages/studio/src/player/components/Timeline.tsx
packages/studio/src/player/components/timelineEditing.test.ts
packages/studio/src/components/editor/domEditing.test.ts
packages/studio/src/components/editor/domEditingLayers.ts
12 changes: 12 additions & 0 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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");
Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]')!;
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>>;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -325,5 +345,5 @@ export function inlineSubCompositions(
hostEl.removeAttribute("data-composition-src");
}

return { styles, scripts, externalScriptSrcs, variablesByComp };
return { styles, scripts, externalScriptSrcs, externalLinks, variablesByComp };
}
22 changes: 22 additions & 0 deletions packages/core/src/runtime/compositionLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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,
Expand All @@ -595,6 +616,7 @@ export async function loadExternalCompositions(
parseDimensionPx: params.parseDimensionPx,
headStyles,
headScripts,
headLinks,
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
onDiagnostic: params.onDiagnostic,
});
Expand Down
12 changes: 12 additions & 0 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
19 changes: 15 additions & 4 deletions packages/studio/src/components/editor/domEditingLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
} from "./domEditingTypes";
import {
buildStableSelector,
findClosestByAttribute,
getCuratedComputedStyles,
getDataAttributes,
getInlineStyles,
Expand Down Expand Up @@ -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.",
};
}

Expand Down Expand Up @@ -298,13 +302,15 @@ 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(),
className: current.className,
inlineStyles,
computedStyles,
isCompositionHost: Boolean(compositionSrc),
isInsideLockedComposition: isInsideLocked,
isMasterView: options.isMasterView,
});
const rect = current.getBoundingClientRect();
Expand All @@ -318,6 +324,7 @@ export function resolveDomEditSelection(
compositionPath,
compositionSrc,
isCompositionHost: Boolean(compositionSrc),
isInsideLockedComposition: isInsideLocked,
label: buildElementLabel(current),
tagName: current.tagName.toLowerCase(),
boundingBox: {
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions packages/studio/src/components/editor/domEditingTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
16 changes: 16 additions & 0 deletions packages/studio/src/player/components/timelineEditing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/player/components/timelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/player/lib/timelineDOM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/player/store/playerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading