fix(preview): rewrite sub-composition asset urls in styles (#174)

## Summary
- rewrite CSS `url(...)` asset paths from sub-compositions before styles are hoisted into bundled/master preview output
- rewrite standalone sub-composition preview HTML so `src`/`href` paths keep resolving correctly under the preview root `<base>`
- add regression tests for bundled CSS asset rewriting and standalone sub-composition preview rewriting

## Root cause
Standalone composition previews reused the project `<head>` with a preview-root `<base href="/api/projects/:id/preview/">`, but the sub-composition body still contained `../...` asset references. Those escaped the preview route and 404ed. Separately, bundled preview already rewrote `<img src="../...">` paths but left hoisted CSS asset references like `@font-face src: url("../font.woff2")` untouched.

## Validation
- `pnpm --filter @hyperframes/core exec vitest run src/compiler/htmlBundler.test.ts src/studio-api/helpers/subComposition.test.ts`
- `pnpm --filter @hyperframes/core exec tsc --noEmit`
- `pnpm --filter @hyperframes/producer exec tsc --noEmit` *(blocked by pre-existing `packages/producer/src/services/deterministicFonts.ts` importing missing generated file `./fontData.generated.js` in the worktree install)*
- browser verification with `agent-browser` against the local preview server using `/Users/miguel07code/dev/test-hyperframes/heygen-promo`

## Browser proof
Verified the fixed preview routes in-browser after seeking to visible frames:
- standalone composition preview
- bundled master preview
This commit is contained in:
Miguel Ángel
2026-04-01 18:13:38 +02:00
committed by GitHub
parent 1a5badc803
commit 1681350ac4
25 changed files with 1923 additions and 34 deletions
+1
View File
@@ -61,6 +61,7 @@
"@hyperframes/engine": "workspace:^",
"hono": "^4.6.0",
"linkedom": "^0.18.12",
"postcss": "^8.4.0",
"puppeteer": "^24.0.0",
"puppeteer-core": "^24.39.1"
},
+31 -30
View File
@@ -12,6 +12,7 @@
import { readFileSync, existsSync, mkdirSync } from "fs";
import { join, dirname, resolve } from "path";
import { parseHTML } from "linkedom";
import postcss from "postcss";
import {
compileTimingAttrs,
injectDurations,
@@ -20,6 +21,7 @@ import {
type ResolvedDuration,
type UnresolvedElement,
rewriteAssetPaths,
rewriteCssAssetUrls,
} from "@hyperframes/core";
import { extractVideoMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
import {
@@ -370,36 +372,31 @@ function promoteCssImportsToLinkTags(html: string): string {
*/
function scopeCssToComposition(css: string, compositionId: string): string {
const scope = `[data-composition-id="${compositionId}"]`;
// Extract @import rules first — they have no {} block and the selector
// regex corrupts them by treating the text after @ as a selector.
const importRe = /@import\s+url\([^)]*\)\s*;|@import\s+["'][^"']+["']\s*;/gi;
const imports: string[] = [];
const cssWithoutImports = css.replace(importRe, (match) => {
imports.push(match.trim());
return "";
const globalAtRules = new Set(["keyframes", "-webkit-keyframes", "font-face"]);
const root = postcss.parse(css);
root.walkRules((rule) => {
// Skip rules nested inside @keyframes or @font-face — they're global
let node: postcss.Node | undefined = rule.parent;
while (node) {
if (
node.type === "atrule" &&
globalAtRules.has((node as postcss.AtRule).name.toLowerCase())
) {
return;
}
node = (node as postcss.ChildNode).parent;
}
rule.selectors = rule.selectors.map((sel) => {
if (!sel.trim()) return sel;
if (/^(html|body|:root|\*)$/i.test(sel.trim())) return sel;
if (sel.includes(`data-composition-id="${compositionId}"`)) return sel;
return `${scope} ${sel}`;
});
});
// Split on top-level rule boundaries. Simple regex approach:
// scope each selector in rule blocks while preserving at-rules.
const scoped = cssWithoutImports.replace(/([^{}@]+)\{/g, (match, selectors: string) => {
const trimmed = selectors.trim();
// Skip @-rule headers (they don't have selectors to scope)
if (trimmed.startsWith("@")) return match;
// Skip if already scoped to this composition
if (trimmed.includes(`data-composition-id="${compositionId}"`)) return match;
// Scope each comma-separated selector
const scopedSelectors = trimmed
.split(",")
.map((s: string) => {
const sel = s.trim();
if (!sel) return sel;
// Don't scope :root, html, body, or * alone — they're global
if (/^(html|body|:root|\*)$/i.test(sel)) return sel;
return `${scope} ${sel}`;
})
.join(", ");
return `${scopedSelectors} {`;
});
return imports.length > 0 ? imports.join("\n") + "\n\n" + scoped : scoped;
return root.toResult().css;
}
function coalesceHeadStylesAndBodyScripts(html: string): string {
@@ -527,7 +524,7 @@ function inlineSubCompositions(
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || null;
for (const styleEl of contentDoc.querySelectorAll("style")) {
const css = styleEl.textContent || "";
const css = rewriteCssAssetUrls(styleEl.textContent || "", srcPath);
const scopeId = compId || inferredCompId;
if (scopeId && css.trim()) {
// Scope sub-composition styles to their composition ID to prevent
@@ -746,6 +743,9 @@ export async function compileForRender(
const mainVideos = parseVideoElements(html);
const mainAudios = parseAudioElements(html);
// Keep inlined sub-composition media authoritative on ID collisions.
// inlineSubCompositions() hoists those nodes into the final HTML, so the
// producer should follow the same precedence the runtime sees in the merged DOM.
const videos = dedupeElementsById([...mainVideos, ...subVideos]);
const audios = dedupeElementsById([...mainAudios, ...subAudios]);
@@ -948,6 +948,7 @@ export async function recompileWithResolutions(
const mainVideos = parseVideoElements(html);
const mainAudios = parseAudioElements(html);
// Keep inlined sub-composition media authoritative on ID collisions.
const videos = dedupeElementsById([...mainVideos, ...subVideos]);
const audios = dedupeElementsById([...mainAudios, ...subAudios]);
@@ -0,0 +1,12 @@
{
"name": "heygen-promo-preview-assets",
"description": "Regression fixture using the HeyGen promo composition with sub-composition images and local brand fonts referenced via ../ paths. Guards against broken asset rewriting in compiled output and rendered snapshots.",
"tags": ["regression", "sub-composition", "preview-assets", "prod-style", "slow"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 120,
"renderConfig": {
"fps": 30
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18f17bdc8f66b8d695408c5fd19661e63d3e3658cba931a0c69345f6c0173759
size 6292940
@@ -0,0 +1,336 @@
<template id="scene1-heygen-hero-template">
<div
data-composition-id="scene1-heygen-hero"
data-width="1920"
data-height="1080"
data-duration="5"
>
<div class="scene">
<div class="halo halo-left"></div>
<div class="halo halo-right"></div>
<div class="mesh"></div>
<div class="nav-shell">
<div class="nav-pill">
<img class="nav-logo" src="../heygen-logo.png" alt="HeyGen" />
<div class="nav-links">
<span>Platform</span>
<span>Use cases</span>
<span>Pricing</span>
<span>Enterprise</span>
</div>
<div class="nav-action">Sign in</div>
</div>
</div>
<div class="copy">
<div class="eyebrow">HEYGEN / AI VIDEO GENERATOR</div>
<h1>
<span class="dark">Create studio-quality</span>
<span class="blue">AI videos</span>
<span class="dark">in minutes.</span>
</h1>
<p class="subhead">
Turn scripts, avatars, photos, and multilingual dubbing into polished videos with one
fast workflow built for teams.
</p>
<div class="actions">
<div class="cta primary">Get Started for Free</div>
<div class="cta secondary">heygen.com</div>
</div>
</div>
<div class="visuals">
<div class="browser-frame">
<div class="browser-top">
<div class="dots">
<span></span><span></span><span></span>
</div>
<div class="browser-url">www.heygen.com</div>
</div>
<img class="browser-shot" src="../heygen-homepage.png" alt="HeyGen homepage" />
</div>
<img class="prism" src="../hero-prism.png" alt="HeyGen avatars" />
</div>
</div>
<style>
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("../TT_Norms_Pro_Normal.woff2") format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 500;
font-display: block;
src: url("../TT_Norms_Pro_Medium.woff2") format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("../TT_Norms_Pro_Bold.woff2") format("woff2");
}
@font-face {
font-family: "ABC Solar Display";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("../ABCSolarDisplay-Bold.woff2") format("woff2");
}
[data-composition-id="scene1-heygen-hero"] {
position: relative;
overflow: hidden;
background: #fcfcfd;
}
.scene {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background:
radial-gradient(circle at 22% 30%, rgba(0, 195, 255, 0.12), transparent 26%),
radial-gradient(circle at 78% 22%, rgba(191, 127, 255, 0.12), transparent 24%),
linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
font-family: "TT Norms Pro", Arial, sans-serif;
color: #333333;
}
.halo,
.mesh {
position: absolute;
inset: 0;
pointer-events: none;
}
.halo {
filter: blur(38px);
}
.halo-left {
background: radial-gradient(circle at 20% 30%, rgba(0, 195, 255, 0.18), transparent 24%);
}
.halo-right {
background: radial-gradient(circle at 80% 20%, rgba(191, 127, 255, 0.16), transparent 24%);
}
.mesh {
opacity: 0.22;
background-image:
linear-gradient(rgba(0, 195, 255, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(191, 127, 255, 0.06) 1px, transparent 1px);
background-size: 96px 96px;
mask-image: linear-gradient(180deg, black 20%, rgba(0, 0, 0, 0.25) 100%);
}
.nav-shell,
.copy,
.visuals {
position: absolute;
}
.nav-shell {
left: 42px;
top: 24px;
width: 1836px;
}
.nav-pill {
height: 74px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-radius: 28px;
background:
linear-gradient(#ffffff, #ffffff) padding-box,
linear-gradient(90deg, rgba(0, 195, 255, 0.55), rgba(191, 127, 255, 0.45)) border-box;
border: 1px solid transparent;
box-shadow: 0 10px 30px rgba(51, 51, 51, 0.06);
}
.nav-logo {
width: 96px;
height: auto;
object-fit: contain;
}
.nav-links {
display: flex;
gap: 42px;
margin-left: auto;
margin-right: 22px;
font-size: 25px;
font-weight: 500;
}
.nav-action {
padding: 14px 24px;
border-radius: 20px;
background: #111111;
color: #ffffff;
font-size: 23px;
font-weight: 700;
}
.copy {
left: 86px;
top: 182px;
width: 820px;
}
.eyebrow {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.18em;
color: rgba(51, 51, 51, 0.56);
}
h1 {
margin-top: 34px;
font-size: 108px;
line-height: 0.9;
letter-spacing: -0.05em;
}
h1 span {
display: block;
}
.dark {
font-family: "ABC Solar Display", "TT Norms Pro", Arial, sans-serif;
color: #333333;
}
.blue {
font-family: "ABC Solar Display", "TT Norms Pro", Arial, sans-serif;
color: #00c3ff;
}
.subhead {
margin-top: 34px;
width: 700px;
font-size: 34px;
line-height: 1.22;
color: rgba(51, 51, 51, 0.72);
}
.actions {
margin-top: 42px;
display: flex;
gap: 18px;
}
.cta {
border-radius: 22px;
padding: 18px 28px;
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.primary {
background: #00c3ff;
color: #333333;
box-shadow: 0 18px 40px rgba(0, 195, 255, 0.28);
}
.secondary {
background: rgba(255, 255, 255, 0.9);
color: rgba(51, 51, 51, 0.72);
border: 1px solid rgba(51, 51, 51, 0.12);
}
.visuals {
right: 60px;
top: 130px;
width: 940px;
height: 860px;
}
.browser-frame {
position: absolute;
left: 22px;
top: 188px;
width: 680px;
height: 456px;
border-radius: 28px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 28px 80px rgba(35, 43, 62, 0.16);
}
.browser-top {
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
border-bottom: 1px solid rgba(51, 51, 51, 0.08);
background: rgba(255, 255, 255, 0.96);
}
.dots {
display: flex;
gap: 8px;
}
.dots span {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(51, 51, 51, 0.14);
}
.browser-url {
font-size: 18px;
color: rgba(51, 51, 51, 0.54);
}
.browser-shot {
width: 100%;
height: calc(100% - 52px);
object-fit: cover;
object-position: center top;
}
.prism {
position: absolute;
right: 0;
top: 10px;
width: 720px;
height: auto;
object-fit: contain;
filter: drop-shadow(0 28px 60px rgba(0, 195, 255, 0.18));
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const root = document.currentScript.parentElement;
const tl = gsap.timeline({ paused: true });
tl.from(root.querySelector(".nav-pill"), { y: -36, opacity: 0, duration: 0.75, ease: "power3.out" }, 0);
tl.from(root.querySelector(".eyebrow"), { y: 20, opacity: 0, duration: 0.5, ease: "power2.out" }, 0.2);
tl.from(root.querySelectorAll("h1 span"), { yPercent: 110, opacity: 0, duration: 1, ease: "power4.out", stagger: 0.1 }, 0.28);
tl.from(root.querySelector(".subhead"), { y: 28, opacity: 0, duration: 0.8, ease: "power2.out" }, 0.7);
tl.from(root.querySelectorAll(".cta"), { y: 22, opacity: 0, scale: 0.96, duration: 0.7, ease: "back.out(1.3)", stagger: 0.08 }, 0.92);
tl.from(root.querySelector(".browser-frame"), { x: 140, y: 40, rotate: -5, opacity: 0, duration: 1, ease: "power3.out" }, 0.45);
tl.from(root.querySelector(".prism"), { x: 160, y: -40, rotate: 8, opacity: 0, duration: 1.1, ease: "power3.out" }, 0.6);
tl.to(root.querySelector(".browser-frame"), { y: -16, duration: 5, ease: "sine.inOut" }, 0);
tl.to(root.querySelector(".prism"), { y: 10, rotation: -3, duration: 5, ease: "sine.inOut" }, 0);
tl.to(root.querySelectorAll(".halo"), { scale: 1.08, duration: 5, ease: "sine.inOut" }, 0);
window.__timelines["scene1-heygen-hero"] = tl;
</script>
</div>
</template>
@@ -0,0 +1,255 @@
<template id="scene2-heygen-showcase-template">
<div
data-composition-id="scene2-heygen-showcase"
data-width="1920"
data-height="1080"
data-duration="6"
>
<div class="scene">
<div class="top-pills">
<span>Text to Video</span>
<span>Photo to Video</span>
<span>Product Ads</span>
<span>UGC ads</span>
<span>AI Models</span>
</div>
<div class="header">
<div class="eyebrow">ONE PLATFORM / MULTIPLE WAYS TO CREATE</div>
<h2>Text, avatars, dubbing, and AI Studio in one flow.</h2>
<p>
Build product videos, talking photos, translated content, and social-ready promos
without leaving the same system.
</p>
</div>
<div class="stage">
<div class="shot large">
<img src="../heygen-superpower.png" alt="HeyGen product section" />
</div>
<div class="shot small">
<img src="../feature-strip.png" alt="HeyGen feature strip" />
</div>
<img class="prism-mini" src="../hero-prism.png" alt="HeyGen prism" />
</div>
<div class="stats">
<div class="stat-card">
<div class="number">117,048,632</div>
<div class="label">Videos generated</div>
</div>
<div class="stat-card">
<div class="number">90,808,526</div>
<div class="label">Avatars generated</div>
</div>
<div class="stat-card">
<div class="number">16,125,234</div>
<div class="label">Videos translated</div>
</div>
</div>
</div>
<style>
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("../TT_Norms_Pro_Normal.woff2") format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 500;
font-display: block;
src: url("../TT_Norms_Pro_Medium.woff2") format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("../TT_Norms_Pro_Bold.woff2") format("woff2");
}
@font-face {
font-family: "ABC Solar Display";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("../ABCSolarDisplay-Bold.woff2") format("woff2");
}
[data-composition-id="scene2-heygen-showcase"] {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, #ffffff 0%, #f7f9fc 100%);
font-family: "TT Norms Pro", Arial, sans-serif;
color: #333333;
}
.scene {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
.top-pills,
.header,
.stage,
.stats {
position: absolute;
}
.top-pills {
left: 190px;
top: 72px;
display: flex;
gap: 18px;
}
.top-pills span {
padding: 18px 28px;
border-radius: 28px;
background: #f1f1f3;
font-size: 28px;
font-weight: 700;
color: #333333;
box-shadow: inset 0 0 0 1px rgba(51, 51, 51, 0.04);
}
.header {
left: 96px;
top: 208px;
width: 660px;
}
.eyebrow {
font-size: 20px;
letter-spacing: 0.16em;
font-weight: 700;
color: rgba(51, 51, 51, 0.54);
}
h2 {
margin-top: 24px;
font-family: "ABC Solar Display", "TT Norms Pro", Arial, sans-serif;
font-size: 86px;
line-height: 0.94;
letter-spacing: -0.05em;
}
p {
margin-top: 28px;
font-size: 30px;
line-height: 1.24;
color: rgba(51, 51, 51, 0.72);
}
.stage {
right: 72px;
top: 160px;
width: 980px;
height: 620px;
}
.shot {
position: absolute;
overflow: hidden;
border-radius: 34px;
background: #ffffff;
box-shadow: 0 24px 60px rgba(23, 30, 43, 0.16);
}
.shot img {
width: 100%;
height: 100%;
object-fit: cover;
}
.large {
left: 0;
top: 0;
width: 760px;
height: 468px;
}
.large img {
object-position: center top;
}
.small {
right: 0;
bottom: 0;
width: 500px;
height: 300px;
}
.small img {
object-position: center center;
}
.prism-mini {
position: absolute;
left: -30px;
bottom: -12px;
width: 420px;
height: auto;
object-fit: contain;
opacity: 0.92;
filter: drop-shadow(0 24px 42px rgba(0, 195, 255, 0.12));
}
.stats {
left: 92px;
right: 92px;
bottom: 74px;
display: flex;
gap: 18px;
}
.stat-card {
flex: 1;
border-radius: 28px;
background: #171717;
color: #ffffff;
padding: 26px 28px;
text-align: center;
}
.number {
font-size: 56px;
line-height: 1;
font-weight: 700;
letter-spacing: -0.04em;
}
.label {
margin-top: 10px;
font-size: 24px;
color: rgba(255, 255, 255, 0.78);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const root = document.currentScript.parentElement;
const tl = gsap.timeline({ paused: true });
tl.from(root.querySelectorAll(".top-pills span"), { y: -26, opacity: 0, duration: 0.7, ease: "power3.out", stagger: 0.06 }, 0);
tl.from(root.querySelector(".eyebrow"), { y: 18, opacity: 0, duration: 0.5, ease: "power2.out" }, 0.24);
tl.from(root.querySelector("h2"), { y: 38, opacity: 0, duration: 0.8, ease: "power3.out" }, 0.34);
tl.from(root.querySelector("p"), { y: 22, opacity: 0, duration: 0.7, ease: "power2.out" }, 0.52);
tl.from(root.querySelector(".large"), { x: 120, y: 24, rotate: -4, opacity: 0, duration: 1, ease: "power3.out" }, 0.4);
tl.from(root.querySelector(".small"), { x: 140, y: 50, rotate: 5, opacity: 0, duration: 1, ease: "power3.out" }, 0.58);
tl.from(root.querySelector(".prism-mini"), { x: -120, y: 40, rotate: -7, opacity: 0, duration: 1, ease: "power3.out" }, 0.66);
tl.from(root.querySelectorAll(".stat-card"), { y: 26, opacity: 0, duration: 0.7, ease: "power3.out", stagger: 0.08 }, 1.05);
tl.to(root.querySelector(".large"), { y: -16, duration: 6, ease: "sine.inOut" }, 0);
tl.to(root.querySelector(".small"), { y: 10, duration: 6, ease: "sine.inOut" }, 0);
tl.to(root.querySelector(".prism-mini"), { y: -14, duration: 6, ease: "sine.inOut" }, 0);
window.__timelines["scene2-heygen-showcase"] = tl;
</script>
</div>
</template>
@@ -0,0 +1,288 @@
<template id="scene3-heygen-cta-template">
<div
data-composition-id="scene3-heygen-cta"
data-width="1920"
data-height="1080"
data-duration="5"
>
<div class="scene">
<div class="bg-orb left"></div>
<div class="bg-orb right"></div>
<div class="grid"></div>
<div class="card">
<div class="brand-row">
<img class="logo" src="../heygen-logo.png" alt="HeyGen" />
<div class="tag">Used by 100,000+ teams</div>
</div>
<div class="content">
<div class="copy">
<div class="eyebrow">HEYGEN / START CREATING</div>
<h2>Fast to prompt.<br />Faster to publish.</h2>
<p>
Create AI videos with avatars, dubbing, and text-first editing in one polished
workflow built to move at team speed.
</p>
<div class="actions">
<div class="cta primary">Get Started for Free</div>
<div class="cta secondary">heygen.com</div>
</div>
</div>
<div class="stack">
<div class="mini browser">
<img src="../heygen-homepage.png" alt="HeyGen homepage" />
</div>
<div class="mini prism-shell">
<img src="../hero-prism.png" alt="HeyGen prism" />
</div>
</div>
</div>
</div>
</div>
<style>
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("../TT_Norms_Pro_Normal.woff2") format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 500;
font-display: block;
src: url("../TT_Norms_Pro_Medium.woff2") format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("../TT_Norms_Pro_Bold.woff2") format("woff2");
}
@font-face {
font-family: "ABC Solar Display";
font-style: normal;
font-weight: 700;
font-display: block;
src: url("../ABCSolarDisplay-Bold.woff2") format("woff2");
}
[data-composition-id="scene3-heygen-cta"] {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #0b1018 0%, #111827 48%, #0a0f16 100%);
font-family: "TT Norms Pro", Arial, sans-serif;
color: #ffffff;
}
.scene {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
.bg-orb,
.grid {
position: absolute;
inset: 0;
pointer-events: none;
}
.bg-orb {
filter: blur(46px);
}
.left {
background: radial-gradient(circle at 16% 24%, rgba(0, 195, 255, 0.26), transparent 26%);
}
.right {
background: radial-gradient(circle at 84% 22%, rgba(191, 127, 255, 0.22), transparent 26%);
}
.grid {
opacity: 0.14;
background-image:
linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 84px 84px;
}
.card {
position: absolute;
left: 60px;
top: 56px;
width: 1800px;
height: 968px;
padding: 42px 46px;
border-radius: 36px;
background: linear-gradient(180deg, rgba(17, 24, 39, 0.88), rgba(10, 16, 24, 0.94));
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
}
.brand-row,
.content {
position: absolute;
}
.brand-row {
left: 46px;
right: 46px;
top: 38px;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
width: 112px;
height: auto;
object-fit: contain;
}
.tag {
padding: 14px 22px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 22px;
color: rgba(255, 255, 255, 0.78);
}
.content {
left: 46px;
right: 46px;
top: 144px;
bottom: 46px;
display: flex;
justify-content: space-between;
}
.copy {
width: 920px;
}
.eyebrow {
font-size: 21px;
letter-spacing: 0.18em;
font-weight: 700;
color: rgba(255, 255, 255, 0.58);
}
h2 {
margin-top: 26px;
font-family: "ABC Solar Display", "TT Norms Pro", Arial, sans-serif;
font-size: 112px;
line-height: 0.9;
letter-spacing: -0.06em;
}
p {
margin-top: 28px;
width: 760px;
font-size: 32px;
line-height: 1.22;
color: rgba(255, 255, 255, 0.74);
}
.actions {
margin-top: 40px;
display: flex;
gap: 18px;
}
.cta {
padding: 20px 30px;
border-radius: 24px;
font-size: 28px;
font-weight: 700;
}
.primary {
background: #00c3ff;
color: #333333;
box-shadow: 0 20px 46px rgba(0, 195, 255, 0.28);
}
.secondary {
color: rgba(255, 255, 255, 0.76);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stack {
position: relative;
width: 620px;
height: 700px;
}
.mini {
position: absolute;
border-radius: 30px;
overflow: hidden;
box-shadow: 0 22px 64px rgba(0, 0, 0, 0.3);
}
.browser {
right: 0;
top: 26px;
width: 560px;
height: 360px;
background: #ffffff;
}
.browser img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
}
.prism-shell {
left: 0;
bottom: 20px;
width: 500px;
height: 340px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
display: flex;
align-items: center;
justify-content: center;
}
.prism-shell img {
width: 520px;
height: auto;
object-fit: contain;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const root = document.currentScript.parentElement;
const tl = gsap.timeline({ paused: true });
tl.from(root.querySelector(".card"), { opacity: 0, y: 28, scale: 0.985, duration: 0.8, ease: "power3.out" }, 0);
tl.from(root.querySelector(".brand-row"), { y: -20, opacity: 0, duration: 0.55, ease: "power2.out" }, 0.16);
tl.from(root.querySelector(".eyebrow"), { y: 16, opacity: 0, duration: 0.45, ease: "power2.out" }, 0.28);
tl.from(root.querySelector("h2"), { y: 34, opacity: 0, duration: 0.8, ease: "power3.out" }, 0.38);
tl.from(root.querySelector("p"), { y: 22, opacity: 0, duration: 0.7, ease: "power2.out" }, 0.58);
tl.from(root.querySelectorAll(".cta"), { y: 24, opacity: 0, scale: 0.96, duration: 0.65, ease: "back.out(1.25)", stagger: 0.08 }, 0.82);
tl.from(root.querySelector(".browser"), { x: 120, y: -18, rotate: 4, opacity: 0, duration: 1, ease: "power3.out" }, 0.48);
tl.from(root.querySelector(".prism-shell"), { x: 90, y: 42, rotate: -4, opacity: 0, duration: 1, ease: "power3.out" }, 0.64);
tl.to(root.querySelector(".browser"), { y: -14, duration: 5, ease: "sine.inOut" }, 0);
tl.to(root.querySelector(".prism-shell"), { y: 10, duration: 5, ease: "sine.inOut" }, 0);
tl.to(root.querySelectorAll(".bg-orb"), { scale: 1.08, duration: 5, ease: "sine.inOut" }, 0);
window.__timelines["scene3-heygen-cta"] = tl;
</script>
</div>
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>HeyGen Promo</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #ffffff;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="16"
data-width="1920"
data-height="1080"
>
<div
id="scene-1"
data-composition-id="scene1-heygen-hero"
data-composition-src="compositions/scene1-heygen-hero.html"
data-start="0"
data-duration="5"
data-track-index="0"
data-width="1920"
data-height="1080"
></div>
<div
id="scene-2"
data-composition-id="scene2-heygen-showcase"
data-composition-src="compositions/scene2-heygen-showcase.html"
data-start="5"
data-duration="6"
data-track-index="0"
data-width="1920"
data-height="1080"
></div>
<div
id="scene-3"
data-composition-id="scene3-heygen-cta"
data-composition-src="compositions/scene3-heygen-cta.html"
data-start="11"
data-duration="5"
data-track-index="0"
data-width="1920"
data-height="1080"
></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body>
</html>