mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user