mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +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:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -23,14 +23,15 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#clip-wipe {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#wp-video {
|
||||
position: absolute;
|
||||
@@ -43,7 +44,7 @@
|
||||
.wp-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -84,6 +85,7 @@
|
||||
<div
|
||||
id="clip-wipe"
|
||||
data-composition-id="caption-clip-wipe"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
+4
-2
@@ -24,16 +24,17 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
background: transparent;
|
||||
font-family: "Inter", sans-serif;
|
||||
}
|
||||
|
||||
#editorial-emphasis {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#bg-video {
|
||||
@@ -124,6 +125,7 @@
|
||||
<div
|
||||
id="editorial-emphasis"
|
||||
data-composition-id="caption-editorial-emphasis"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -24,16 +24,17 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: transparent;
|
||||
font-family: "Gabarito", Arial, sans-serif;
|
||||
}
|
||||
|
||||
#emoji-pop {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#avatar-video {
|
||||
@@ -120,6 +121,7 @@
|
||||
<div
|
||||
id="emoji-pop"
|
||||
data-composition-id="caption-emoji-pop"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -23,14 +23,15 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#caption-glitch-rgb {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#gl-video {
|
||||
position: absolute;
|
||||
@@ -104,6 +105,7 @@
|
||||
<div
|
||||
id="caption-glitch-rgb"
|
||||
data-composition-id="caption-glitch-rgb"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -23,14 +23,15 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#gradient-fill {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#gr-container {
|
||||
position: absolute;
|
||||
@@ -72,6 +73,7 @@
|
||||
<div
|
||||
id="gradient-fill"
|
||||
data-composition-id="caption-gradient-fill"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -23,14 +23,15 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#highlight {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#hl-video {
|
||||
position: absolute;
|
||||
@@ -43,7 +44,7 @@
|
||||
.hl-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -103,6 +104,7 @@
|
||||
<div
|
||||
id="highlight"
|
||||
data-composition-id="caption-highlight"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -20,14 +20,15 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#kinetic-slam {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#kt-video {
|
||||
position: absolute;
|
||||
@@ -40,7 +41,7 @@
|
||||
.kt-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -71,6 +72,7 @@
|
||||
<div
|
||||
id="kinetic-slam"
|
||||
data-composition-id="caption-kinetic-slam"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -23,14 +23,15 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
background: transparent;
|
||||
}
|
||||
#matrix-decode {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
background: transparent;
|
||||
}
|
||||
#sc-video {
|
||||
position: absolute;
|
||||
@@ -43,7 +44,7 @@
|
||||
.sc-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -92,6 +93,7 @@
|
||||
<div
|
||||
id="matrix-decode"
|
||||
data-composition-id="caption-matrix-decode"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -24,16 +24,17 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: transparent;
|
||||
font-family: "Montserrat", Arial, sans-serif;
|
||||
}
|
||||
|
||||
#neon-accent {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#avatar-video {
|
||||
@@ -120,6 +121,7 @@
|
||||
<div
|
||||
id="neon-accent"
|
||||
data-composition-id="caption-neon-accent"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -23,14 +23,15 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
background: transparent;
|
||||
}
|
||||
#neon-glow {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
background: transparent;
|
||||
}
|
||||
#ne-video {
|
||||
position: absolute;
|
||||
@@ -43,7 +44,7 @@
|
||||
.ne-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -83,6 +84,7 @@
|
||||
<div
|
||||
id="neon-glow"
|
||||
data-composition-id="caption-neon-glow"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -24,16 +24,17 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
background: transparent;
|
||||
font-family: "Instrument Serif", serif;
|
||||
}
|
||||
|
||||
#parallax-layers {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#bg-video {
|
||||
@@ -148,6 +149,7 @@
|
||||
<div
|
||||
id="parallax-layers"
|
||||
data-composition-id="caption-parallax-layers"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -23,14 +23,15 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#particle-burst {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
background: transparent;
|
||||
}
|
||||
#bs-video {
|
||||
position: absolute;
|
||||
@@ -43,7 +44,7 @@
|
||||
.bs-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -94,6 +95,7 @@
|
||||
<div
|
||||
id="particle-burst"
|
||||
data-composition-id="caption-particle-burst"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -24,16 +24,17 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: transparent;
|
||||
font-family: "Poppins", Arial, sans-serif;
|
||||
}
|
||||
|
||||
#pill-karaoke {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#avatar-video {
|
||||
@@ -126,6 +127,7 @@
|
||||
<div
|
||||
id="pill-karaoke"
|
||||
data-composition-id="caption-pill-karaoke"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0805;
|
||||
background: transparent;
|
||||
}
|
||||
#texture-lava {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0a0805;
|
||||
background: transparent;
|
||||
}
|
||||
#lv-video {
|
||||
position: absolute;
|
||||
@@ -51,7 +51,7 @@
|
||||
.lv-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -97,6 +97,7 @@
|
||||
<div
|
||||
id="caption-texture"
|
||||
data-composition-id="caption-texture"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -24,16 +24,17 @@
|
||||
height: 1080px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: transparent;
|
||||
font-family: "Montserrat", Arial, sans-serif;
|
||||
}
|
||||
|
||||
#weight-shift {
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#avatar-video {
|
||||
@@ -112,6 +113,7 @@
|
||||
<div
|
||||
id="weight-shift"
|
||||
data-composition-id="caption-weight-shift"
|
||||
data-timeline-locked
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-fps="30"
|
||||
|
||||
@@ -117,6 +117,37 @@ GROUPS.forEach(function (group, gi) {
|
||||
tl.seek(0);
|
||||
```
|
||||
|
||||
## Pre-Built Caption Components
|
||||
|
||||
Before building caption styles from scratch, check the registry — 15 ready-to-use caption components cover the most common styles. Install with `npx hyperframes add <name>` and use as a sub-composition via `data-composition-src`.
|
||||
|
||||
```bash
|
||||
npx hyperframes catalog --tag caption-style # list all caption components
|
||||
npx hyperframes add caption-highlight # install a specific one
|
||||
```
|
||||
|
||||
| Style | Component | Best for |
|
||||
| ------------------------- | ---------------------------- | ---------------------------- |
|
||||
| TikTok-style highlight | `caption-highlight` | Social, high-energy |
|
||||
| Karaoke pill | `caption-pill-karaoke` | Music, lyric videos |
|
||||
| Cinematic editorial | `caption-editorial-emphasis` | Documentary, storytelling |
|
||||
| Glitch / cyber | `caption-glitch-rgb` | Tech, gaming |
|
||||
| Full-screen slam | `caption-kinetic-slam` | Hype, announcements |
|
||||
| Neon glow | `caption-neon-glow` | Night, club, neon aesthetics |
|
||||
| Neon accent (multi-color) | `caption-neon-accent` | Colorful, playful |
|
||||
| Wipe reveal | `caption-clip-wipe` | Clean, modern |
|
||||
| Gradient fill | `caption-gradient-fill` | Vibrant, eye-catching |
|
||||
| Matrix decode | `caption-matrix-decode` | Sci-fi, tech reveals |
|
||||
| Emoji pop | `caption-emoji-pop` | Social, casual |
|
||||
| Parallax layers | `caption-parallax-layers` | Depth, cinematic |
|
||||
| Particle burst | `caption-particle-burst` | Celebration, impact keywords |
|
||||
| Lava texture | `caption-texture` | Bold, dramatic |
|
||||
| Weight shift | `caption-weight-shift` | Elegant, typographic |
|
||||
|
||||
Browse all with previews: [hyperframes.heygen.com/catalog](https://hyperframes.heygen.com/catalog)
|
||||
|
||||
Caption components ship with transparent backgrounds — they're pure overlays. If the underlying video is bright or busy, add a contrast layer (e.g. a semi-transparent dark div) in the host composition beneath the caption sub-composition, not inside the component itself.
|
||||
|
||||
## Further References
|
||||
|
||||
- [dynamic-techniques.md](dynamic-techniques.md) — karaoke, clip-path reveals, slam words, scatter exits, elastic, 3D rotation
|
||||
|
||||
Reference in New Issue
Block a user