test(regression): add editor-agent-prod regression fixture (#37)

## Summary

- Adds `editor-agent-prod` regression test fixture for the producer
- Tests preview/render parity for compositions with sub-compositions, GSAP timelines, overlay elements, and `data-end` attributes  
- Adds `styles-g` shard to regression CI workflow to run the new test
- Creates comprehensive Basel Form-styled video composition with intro/outro cards, reveals, persistent overlays, and captions
- Includes biomorphic SVG assets and Inter font integration for typographic consistency
- Tests complex animation timing with staggered entrances, crossfades, and synchronized A-roll movements

## Test plan

- [x] Regression CI passes for editor-agent-prod shard
- [x] Existing regression tests unaffected
- [x] Preview/render output matches expected visual and timing specifications
This commit is contained in:
Miguel Ángel
2026-03-24 21:25:42 +01:00
committed by GitHub
17 changed files with 2132 additions and 12 deletions
+2
View File
@@ -47,6 +47,8 @@ jobs:
args: "style-13-prod style-15-prod style-16-prod"
- shard: styles-f
args: "style-17-prod style-18-prod"
- shard: styles-g
args: "overlay-montage-prod"
steps:
- name: Checkout (with LFS)
uses: actions/checkout@v4
+1
View File
@@ -1,2 +1,3 @@
docs/
DOCS_GUIDELINES.md
packages/producer/tests/
+7 -12
View File
@@ -391,27 +391,22 @@ export function lintHyperframeHtml(
}
}
// #3.7: Fabricated inline base64 media — CRITICAL
// Inline base64 audio/video data is almost always fabricated garbage that
// won't play. Real audio files are 100KB+ when base64-encoded.
// #3.7: Inline base64 audio/video — PROHIBITED
// Base64 audio/video bloats file size and breaks rendering. Use URLs or relative paths.
{
const base64MediaRe =
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{100,}))["']/gi;
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{20,}))["']/gi;
let b64Match: RegExpExecArray | null;
while ((b64Match = base64MediaRe.exec(source)) !== null) {
// Check if it's suspiciously repetitive (fake data has long runs of repeated chars)
const sample = (b64Match[2] || "").slice(0, 200);
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
// Any embedded base64 audio is suspicious — real audio should be a file
pushFinding({
code: "fabricated_inline_media",
severity: isSuspicious ? "error" : "warning",
message: isSuspicious
? `Fabricated base64 media detected (${(dataSize / 1024).toFixed(0)} KB). This is almost certainly fake data that won't play.`
: `Embedded base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB). Consider using a file URL instead.`,
fixHint: "Remove the data: URI and use a real media file URL instead.",
code: "base64_media_prohibited",
severity: "error",
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " — likely fabricated data" : ""}. Base64 media is prohibited — it bloats file size and breaks rendering.`,
fixHint: "Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
});
}
@@ -0,0 +1,12 @@
{
"name": "overlay-montage-prod",
"description": "Multi-composition montage with overlays, GSAP timelines, and data-end attributes. Tests preview/render parity for composition visibility.",
"tags": ["style-regression", "prod-style", "slow"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0.9,
"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:9be0476841134b2be51bd21231aee86e4bbf4eaecaa2f55b3b397d3c621889fd
size 15000289
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1920" width="1080" height="1920">
<path id="biomorphic-branch" fill="#0A0A0A" d="M 520 1180
C 510 1080, 525 980, 535 930
C 460 910, 340 860, 310 790
C 350 830, 440 880, 515 900
C 540 830, 660 760, 740 730
C 680 770, 580 820, 550 880
C 570 810, 590 760, 580 730
C 610 770, 580 830, 565 900
C 555 980, 550 1080, 560 1180
C 550 1190, 530 1190, 520 1180
Z" />
</svg>

After

Width:  |  Height:  |  Size: 541 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1920" width="1080" height="1920">
<path fill="#0A0A0A" d="M 440 670 C 410 800, 470 900, 450 1050 C 430 1180, 420 1230, 420 1250 C 480 1260, 600 1240, 660 1250 C 690 1100, 620 950, 650 800 C 670 680, 640 670, 640 670 C 580 660, 500 680, 440 670 Z" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1920" width="1080" height="1920">
<path fill="#0A0A0A" d="M 540 710 C 700 710, 790 800, 790 960 C 790 1120, 700 1210, 540 1210 C 450 1210, 550 1050, 400 1000 C 250 950, 350 1100, 290 960 C 230 820, 380 710, 540 710 Z" />
</svg>

After

Width:  |  Height:  |  Size: 287 B

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1920" width="1080" height="1920">
<g id="scatter-grid" fill="#0A0A0A">
<!-- Row 1 -->
<circle cx="405" cy="775" r="15" />
<circle cx="540" cy="775" r="15" />
<circle cx="675" cy="775" r="15" />
<!-- Row 2 -->
<circle cx="405" cy="898.3" r="15" />
<circle cx="540" cy="898.3" r="15" />
<circle cx="675" cy="898.3" r="15" />
<!-- Row 3 -->
<circle cx="405" cy="1021.7" r="15" />
<circle cx="540" cy="1021.7" r="15" />
<circle cx="675" cy="1021.7" r="15" />
<!-- Row 4 -->
<circle cx="405" cy="1145" r="15" />
<circle cx="540" cy="1145" r="15" />
<circle cx="675" cy="1145" r="15" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 718 B

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1920" width="1080" height="1920">
<g transform="translate(290, 710)">
<path fill="#0A0A0A" d="M 250 60
C 300 60, 320 150, 380 180
C 450 210, 480 280, 420 320
C 380 350, 320 300, 280 380
C 250 450, 180 480, 140 420
C 100 360, 180 320, 150 260
C 120 200, 50 180, 80 120
C 110 60, 180 60, 250 60 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 474 B

@@ -0,0 +1,228 @@
<template id="intro-outro-template">
<div data-composition-id="intro-outro" data-width="1080" data-height="1920" data-duration="42.36">
<!-- Intro Card (0s to 3s) -->
<div class="card intro-card" id="intro-card">
<div class="biomorphic-container upper-right">
<img src="assets/splat.svg" class="biomorphic-form" id="intro-splat" />
</div>
<div class="text-content">
<h1 class="title">first week</h1>
<div class="rule"></div>
<h2 class="subtitle">onboarding guide</h2>
</div>
</div>
<!-- Outro Card (39.36s to 42.36s) -->
<div class="card outro-card" id="outro-card">
<div class="biomorphic-container lower-left">
<img src="assets/branch.svg" class="biomorphic-form branch-white" id="outro-branch" />
</div>
<div class="text-content-outro">
<h1 class="cta-text">get started</h1>
</div>
<div class="biomorphic-container upper-right-outro">
<img src="assets/scatter.svg" class="biomorphic-form scatter-white" id="outro-scatter" />
</div>
</div>
<style>
[data-composition-id="intro-outro"] {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
font-family: 'Inter', sans-serif;
overflow: hidden;
}
[data-composition-id="intro-outro"] .card {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: none;
opacity: 0;
}
/* Intro Styles */
[data-composition-id="intro-outro"] .intro-card {
background-color: #F5F5F0; /* hf-white */
z-index: 10;
}
[data-composition-id="intro-outro"] .biomorphic-container {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
[data-composition-id="intro-outro"] .upper-right {
top: 0;
right: 0;
width: 50%;
height: 50%;
}
[data-composition-id="intro-outro"] .biomorphic-form {
width: 80%;
height: auto;
}
[data-composition-id="intro-outro"] .text-content {
position: absolute;
left: 80px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: flex-start;
}
[data-composition-id="intro-outro"] .title {
font-size: 80px;
font-weight: 700;
color: #0A0A0A; /* hf-black */
margin: 0;
padding: 0;
line-height: 1;
text-transform: lowercase;
letter-spacing: -0.01em;
}
[data-composition-id="intro-outro"] .rule {
width: 324px; /* 30% of 1080 */
height: 1px;
background-color: #0A0A0A;
margin: 20px 0;
transform-origin: left center;
transform: scaleX(0);
}
[data-composition-id="intro-outro"] .subtitle {
font-size: 28px;
font-weight: 400;
color: #0A0A0A;
margin: 0;
padding: 0;
text-transform: lowercase;
}
/* Outro Styles */
[data-composition-id="intro-outro"] .outro-card {
background-color: #0A0A0A; /* hf-black */
z-index: 20;
}
[data-composition-id="intro-outro"] .lower-left {
bottom: 0;
left: 0;
width: 50%;
height: 50%;
}
[data-composition-id="intro-outro"] .upper-right-outro {
top: 0;
right: 0;
width: 50%;
height: 50%;
}
[data-composition-id="intro-outro"] .branch-white,
[data-composition-id="intro-outro"] .scatter-white {
filter: invert(98%) sepia(4%) saturate(144%) hue-rotate(20deg) brightness(101%) contrast(94%);
}
[data-composition-id="intro-outro"] .text-content-outro {
position: absolute;
left: 80px;
top: 25%; /* Upper half of frame */
transform: translateY(-50%);
}
[data-composition-id="intro-outro"] .cta-text {
font-size: 64px;
font-weight: 700;
color: #F5F5F0; /* hf-white */
margin: 0;
padding: 0;
text-transform: lowercase;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
const intro = '#intro-card';
const outro = '#outro-card';
// --- Intro Card (0s to 3s) ---
tl.set(intro, { display: 'block', opacity: 1 }, 0);
// Entrance
tl.fromTo('#intro-splat',
{ scale: 0.4 },
{ scale: 1, duration: 0.7, ease: 'power1.out' },
0
);
tl.fromTo('[data-composition-id="intro-outro"] .title',
{ opacity: 0 },
{ opacity: 1, duration: 0.4 },
0.3
);
tl.to('[data-composition-id="intro-outro"] .rule',
{ scaleX: 1, duration: 0.3, ease: 'none' },
0.5
);
tl.fromTo('[data-composition-id="intro-outro"] .subtitle',
{ opacity: 0 },
{ opacity: 1, duration: 0.4 },
0.6
);
// Exit
tl.to(intro, { opacity: 0, duration: 0.3 }, 2.7);
tl.set(intro, { display: 'none' }, 3.0);
// --- Outro Card (39.36s to 42.36s) ---
const outroStart = 39.36;
tl.set(outro, { display: 'block', opacity: 1 }, outroStart);
// Entrance mirrors intro timing
tl.fromTo('#outro-branch',
{ scale: 0.4 },
{ scale: 1, duration: 0.7, ease: 'power1.out' },
outroStart
);
tl.fromTo('#outro-scatter',
{ scale: 0.4 },
{ scale: 1, duration: 0.7, ease: 'power1.out' },
outroStart
);
tl.fromTo('.cta-text',
{ opacity: 0 },
{ opacity: 1, duration: 0.4 },
outroStart + 0.3
);
// Buffer at end
tl.to({}, { duration: 2 }, 42.36);
window.__timelines["intro-outro"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,109 @@
<template id="persistent-overlays-template">
<div data-composition-id="persistent-overlays" data-width="1080" data-height="1920" data-duration="39.36">
<!-- Option A: Notch (Top-Left) -->
<div class="overlay notch-container">
<img src="assets/notch.svg" class="svg-icon notch-icon" />
</div>
<!-- Option B: Scatter (Bottom-Right) -->
<div class="overlay scatter-container">
<img src="assets/scatter.svg" class="svg-icon scatter-icon" />
</div>
<!-- Option A: Column (Top-Right) -->
<div class="overlay column-container">
<img src="assets/column.svg" class="svg-icon column-icon" />
</div>
<style>
[data-composition-id="persistent-overlays"] {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
background: transparent;
pointer-events: none;
}
[data-composition-id="persistent-overlays"] .overlay {
position: absolute;
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
}
[data-composition-id="persistent-overlays"] .svg-icon {
width: 250px;
height: 250px;
/* Basel Form Style: Strict monochromatic palette */
/* Using filter to ensure #0A0A0A (hf-black) if SVG is not already black */
filter: brightness(0) saturate(100%) invert(4%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(4%) contrast(100%);
}
/* Positioning - Asymmetrical, bleeding off edges */
[data-composition-id="persistent-overlays"] .notch-container {
top: -50px;
left: -80px;
}
[data-composition-id="persistent-overlays"] .scatter-container {
bottom: 120px;
right: -40px;
}
[data-composition-id="persistent-overlays"] .column-container {
top: 200px;
right: -100px;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// 3.0s to 15.0s: Option A - Notch (Top-Left)
// Fades in at 3.0s over 0.4s. Fades out at 15.0s over 0.4s.
tl.to('.notch-container', {
opacity: 1,
duration: 0.4,
ease: 'power2.inOut'
}, 3.0);
tl.to('.notch-container', {
opacity: 0,
duration: 0.4,
ease: 'power2.inOut'
}, 15.0);
// 15.0s to 30.0s: Option B - Scatter (Bottom-Right)
// Fades in at 15.0s over 0.4s. Fades out at 30.0s over 0.4s.
tl.to('.scatter-container', {
opacity: 1,
duration: 0.4,
ease: 'power2.inOut'
}, 15.0);
tl.to('.scatter-container', {
opacity: 0,
duration: 0.4,
ease: 'power2.inOut'
}, 30.0);
// 30.0s to 39.36s: Option A - Column (Top-Right)
// Fades in at 30.0s over 0.4s. Fades out at 39.36s over 0.4s.
tl.to('.column-container', {
opacity: 1,
duration: 0.4,
ease: 'power2.inOut'
}, 30.0);
tl.to('.column-container', {
opacity: 0,
duration: 0.4,
ease: 'power2.inOut'
}, 39.36 - 0.4); // Ends at 39.36s
window.__timelines["persistent-overlays"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,160 @@
<template id="reveal1-template">
<div data-composition-id="reveal1" data-width="1080" data-height="1920" data-start="4.619" data-duration="3.381">
<div class="reveal-bg"></div>
<div class="content-wrapper">
<div class="text-group">
<h1 class="title">activate accounts</h1>
<div class="rule-line"></div>
</div>
<div class="form-container">
<img src="assets/notch.svg" class="primary-form form-1" alt="Form 1">
<img src="assets/branch.svg" class="primary-form form-2" alt="Form 2">
<img src="assets/scatter.svg" class="primary-form form-3" alt="Form 3">
</div>
</div>
<style>
[data-composition-id="reveal1"] {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
overflow: hidden;
font-family: 'Inter', sans-serif;
background-color: #F5F5F0; /* hf-white */
}
[data-composition-id="reveal1"] .reveal-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #F5F5F0;
z-index: 1;
}
[data-composition-id="reveal1"] .content-wrapper {
position: relative;
width: 100%;
height: 100%;
z-index: 2;
}
[data-composition-id="reveal1"] .text-group {
position: absolute;
top: 80px;
left: 80px;
display: flex;
flex-direction: column;
align-items: flex-start;
}
[data-composition-id="reveal1"] .title {
font-size: 80px;
font-weight: 700;
color: #0A0A0A; /* hf-black */
margin: 0;
padding: 0;
line-height: 1.1;
text-align: left;
opacity: 0;
letter-spacing: -0.01em;
}
[data-composition-id="reveal1"] .rule-line {
width: 0%;
height: 1px;
background-color: #0A0A0A;
margin-top: 10px;
}
[data-composition-id="reveal1"] .form-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
[data-composition-id="reveal1"] .primary-form {
position: absolute;
transform: scale(0.5);
opacity: 1;
}
[data-composition-id="reveal1"] .form-1 {
top: 350px;
right: 150px;
width: 450px;
height: 450px;
}
[data-composition-id="reveal1"] .form-2 {
top: 650px;
right: 350px;
width: 300px;
height: 300px;
}
[data-composition-id="reveal1"] .form-3 {
top: 850px;
right: 100px;
width: 350px;
height: 350px;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Selectors scoped to this composition
const comp = document.querySelector('[data-composition-id="reveal1"]');
const forms = comp.querySelectorAll('.primary-form');
const title = comp.querySelector('.title');
const rule = comp.querySelector('.rule-line');
const aroll = document.querySelector('#aroll-video');
// --- ENTRANCE (Starts at 4.619s relative to master, 0s relative to this comp) ---
// Forms scale from 0.5 to 1.0 over 0.5s (power1.out)
tl.to(forms, {
scale: 1,
duration: 0.5,
stagger: 0.1,
ease: 'power1.out'
}, 0);
// Text fades in over 0.3s, starting 0.2s after form begins scaling
tl.to(title, {
opacity: 1,
duration: 0.3,
ease: 'none'
}, 0.2);
// Rule draws on from left (0.3s, power1.out, 0.1s delay after text)
tl.to(rule, {
width: '100%',
duration: 0.3,
ease: 'power1.out'
}, 0.6); // 0.2 (text start) + 0.3 (text duration) + 0.1 (delay) = 0.6
// --- EXIT (Starts at 7.7s relative to master, 7.7 - 4.619 = 3.081s in this comp) ---
// All elements fade to 0 over 0.3s simultaneously (power1.in)
tl.to([...forms, title, rule, comp.querySelector('.reveal-bg')], {
opacity: 0,
duration: 0.3,
ease: 'power1.in'
}, 3.081);
// Register timeline
window.__timelines["reveal1"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,130 @@
<template id="reveal2-template">
<div data-composition-id="reveal2" data-width="1080" data-height="1920" data-start="15.439" data-duration="3.561">
<div class="reveal2-bg"></div>
<div class="reveal2-form-container">
<img src="assets/column.svg" class="reveal2-form form-1" alt="Column Form">
<img src="assets/splat.svg" class="reveal2-form form-2" alt="Splat Form">
<img src="assets/notch.svg" class="reveal2-form form-3" alt="Notch Form">
</div>
<div class="reveal2-text">compliance training</div>
<style>
[data-composition-id="reveal2"] {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
overflow: hidden;
font-family: 'Inter', sans-serif;
}
.reveal2-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #0A0A0A; /* hf-black */
opacity: 0;
}
.reveal2-form-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.reveal2-form {
position: absolute;
transform: scale(0.5);
opacity: 0;
/* Ensure the SVG is white */
filter: brightness(0) invert(1) sepia(1) saturate(0) hue-rotate(0deg) brightness(0.96) contrast(1);
}
.form-1 {
top: 300px;
left: 100px;
width: 400px;
height: auto;
}
.form-2 {
top: 600px;
left: 250px;
width: 450px;
height: auto;
}
.form-3 {
top: 950px;
left: 80px;
width: 300px;
height: auto;
}
.reveal2-text {
position: absolute;
top: 200px;
right: 80px;
width: 400px;
font-size: 80px;
font-weight: 700;
color: #F5F5F0; /* hf-white */
text-align: left;
line-height: 1.1;
opacity: 0;
text-transform: lowercase;
letter-spacing: -0.01em;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Entrance (starts at 15.439s, which is 0s in this composition's local timeline)
// Background crossfades from white to black over 0.3s
tl.to('.reveal2-bg', {
opacity: 1,
duration: 0.3,
ease: 'none'
}, 0);
// Form scales from 0.5 to 1.0 over 0.5s (power1.out)
tl.to('.reveal2-form', {
opacity: 1,
scale: 1,
duration: 0.5,
stagger: 0.1,
ease: 'power1.out'
}, 0);
// Text fades in over 0.3s, starting 0.2s after form begins scaling
tl.to('.reveal2-text', {
opacity: 1,
duration: 0.3,
ease: 'power1.out'
}, 0.2);
// Hold: completely static (handled by the gap between entrance and exit)
// Exit (starts at 18.7s, which is 18.7 - 15.439 = 3.261s in local timeline)
const exitTime = 18.7 - 15.439;
// All elements fade to 0 over 0.3s simultaneously (power1.in)
tl.to(['.reveal2-bg', '.reveal2-form', '.reveal2-text'], {
opacity: 0,
duration: 0.3,
ease: 'power1.in'
}, exitTime);
window.__timelines["reveal2"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,118 @@
<template id="reveal3-template">
<div data-composition-id="reveal3" data-width="1080" data-height="1920" data-start="24.079" data-duration="3.921">
<div class="reveal3-bg"></div>
<div class="reveal3-form-container">
<img src="assets/splat.svg" class="reveal3-form form-1" />
<img src="assets/branch.svg" class="reveal3-form form-2" />
<img src="assets/column.svg" class="reveal3-form form-3" />
</div>
<div class="reveal3-text">schedule 1:1</div>
<style>
[data-composition-id="reveal3"] {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: 1920px;
overflow: hidden;
background-color: #F5F5F0; /* hf-white */
}
.reveal3-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #F5F5F0;
}
.reveal3-form-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.reveal3-form {
position: absolute;
object-fit: contain;
/* SVG fill is handled via CSS filter to ensure #0A0A0A if not already in SVG */
filter: brightness(0) saturate(100%) invert(4%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(96%) contrast(96%);
}
.form-1 {
top: 250px;
left: 50%;
margin-left: -200px; /* Instead of transform to avoid GSAP conflict */
width: 400px;
height: 400px;
}
.form-2 {
top: 550px;
left: 50%;
margin-left: -400px;
width: 300px;
height: 300px;
}
.form-3 {
top: 600px;
left: 50%;
margin-left: 100px;
width: 350px;
height: 350px;
}
.reveal3-text {
position: absolute;
bottom: 80px;
left: 80px;
font-family: 'Inter', sans-serif;
font-weight: 700;
font-size: 80px;
color: #0A0A0A; /* hf-black */
text-align: left;
opacity: 0;
letter-spacing: -0.01em;
}
</style>
<script>
(function() {
const tl = gsap.timeline({ paused: true });
// Reveal 3 (24.079s to 28.0s)
// Duration: 28.0 - 24.079 = 3.921s
// Entrance (starts at 24.079s, which is 0s in this composition's local timeline)
// Form scales from 0.5 to 1.0 over 0.5s (power1.out)
tl.fromTo('.reveal3-form',
{ scale: 0.5 },
{ scale: 1.0, duration: 0.5, stagger: 0.1, ease: 'power1.out' },
0
);
// Text fades in over 0.3s, starting 0.2s after form begins scaling
tl.to('.reveal3-text',
{ opacity: 1, duration: 0.3, ease: 'power1.out' },
0.2
);
// Exit (starts at 27.7s)
// Local time for exit: 27.7 - 24.079 = 3.621s
// All elements fade to 0 over 0.3s simultaneously (power1.in)
tl.to(['.reveal3-form', '.reveal3-text', '.reveal3-bg'],
{ opacity: 0, duration: 0.3, ease: 'power1.in' },
3.621
);
window.__timelines["reveal3"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080, height=1920, initial-scale=1.0">
<title>Basel Form Video</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
/*
* BASEL FORM (ARMIN HOFMANN)
* Strict monochromatic, biomorphic, typographic composition
*/
:root {
--hf-black: #0A0A0A;
--hf-white: #F5F5F0;
--hf-gray: #6B6B6B;
--frame-width: 1080px;
--frame-height: 1920px;
}
body, html {
margin: 0;
padding: 0;
width: var(--frame-width);
height: var(--frame-height);
background-color: var(--hf-white);
font-family: 'Inter', sans-serif;
overflow: hidden;
color: var(--hf-black);
}
#container {
width: var(--frame-width);
height: var(--frame-height);
position: relative;
}
/* TRACK 1: REVEALS (Behind A-roll) */
#track-1-reveals {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
}
/* TRACK 0: A-ROLL */
#track-0-aroll {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
display: flex;
justify-content: center;
align-items: center;
background-color: transparent;
}
#aroll-video {
width: 75%;
height: auto;
filter: saturate(0) contrast(1.3);
}
/* TRACK 2: OVERLAYS (Foreground) */
#track-2-overlays {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 30;
pointer-events: none;
}
/* TRACK 3: CAPTIONS */
#track-3-captions {
position: absolute;
top: 1365px; /* 555px from bottom */
left: 0;
width: 100%;
display: flex;
justify-content: center;
z-index: 40;
/* Caption placement y-conflict checked: well below face zone (263-736) */
}
.caption-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.caption-bg {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: var(--hf-black);
opacity: 0;
}
.caption-text {
position: relative;
font-size: 48px;
font-weight: 700;
color: var(--hf-white);
letter-spacing: 0.02em;
text-transform: lowercase;
padding: 10px 20px;
margin: 0;
white-space: nowrap;
opacity: 0;
text-align: center;
}
/* INTRO & OUTRO */
.full-frame-card {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 50; /* Above everything */
background-color: var(--hf-white);
}
.outro-card {
background-color: var(--hf-black);
}
</style>
</head>
<body>
<div id="container" data-composition-id="root" data-width="1080" data-height="1920" data-duration="42.36">
<!-- TRACK 1: Reveals (Behind A-roll) -->
<div id="track-1-reveals" style="z-index: 10; position: absolute; width: 100%; height: 100%;">
<div data-composition-src="compositions/reveal1.html" data-start="4.619" data-track-index="1"></div>
<div data-composition-src="compositions/reveal2.html" data-start="15.439" data-track-index="1"></div>
<div data-composition-src="compositions/reveal3.html" data-start="24.079" data-track-index="1"></div>
</div>
<!-- TRACK 0: A-Roll Video -->
<div id="track-0-aroll" style="z-index: 20; position: absolute; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;">
<video id="aroll-video" data-start="0" data-track-index="0" src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/cd544f35_70b817304d84455490962587aa52c167.mp4" playsinline></video>
</div>
<!-- TRACK 2: Overlays -->
<div id="track-2-overlays" style="z-index: 30; position: absolute; width: 100%; height: 100%; pointer-events: none;">
<div data-composition-src="compositions/persistent_overlays.html" data-start="0" data-track-index="2"></div>
</div>
<!-- TRACK 3: Captions -->
<div id="track-3-captions" style="z-index: 40; position: absolute; top: 1365px; left: 0; width: 100%; display: flex; justify-content: center;">
<div class="caption-container">
<div class="caption-bg"></div>
<div class="caption-text"></div>
</div>
</div>
<!-- Intro/Outro -->
<div data-composition-src="compositions/intro_outro.html" data-start="0" data-track-index="50" style="z-index: 50; position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></div>
</div>
<script>
// Transcript Data
const transcript = [
{'text': 'Welcome', 'start': 0.34, 'end': 0.659}, {'text': 'to', 'start': 0.74, 'end': 0.779}, {'text': 'your', 'start': 0.819, 'end': 0.919}, {'text': 'first', 'start': 0.959, 'end': 1.199}, {'text': 'week.', 'start': 1.259, 'end': 1.539},
{'text': 'There', 'start': 2.059, 'end': 2.18}, {'text': 'are', 'start': 2.22, 'end': 2.299}, {'text': 'a', 'start': 2.339, 'end': 2.419}, {'text': 'few', 'start': 2.46, 'end': 2.559}, {'text': 'things', 'start': 2.619, 'end': 2.819}, {'text': 'you', 'start': 2.859, 'end': 2.919}, {'text': 'need', 'start': 2.939, 'end': 3.039}, {'text': 'to', 'start': 3.079, 'end': 3.139}, {'text': 'know', 'start': 3.199, 'end': 3.279}, {'text': 'to', 'start': 3.339, 'end': 3.379}, {'text': 'get', 'start': 3.439, 'end': 3.559}, {'text': 'set', 'start': 3.639, 'end': 3.779}, {'text': 'up.', 'start': 3.859, 'end': 4.039},
{'text': 'Step', 'start': 4.619, 'end': 4.859}, {'text': 'one:', 'start': 4.96, 'end': 5.179}, {'text': 'activate', 'start': 5.42, 'end': 5.799}, {'text': 'your', 'start': 5.859, 'end': 5.92}, {'text': 'accounts.', 'start': 5.96, 'end': 6.48},
{'text': 'You', 'start': 7.0, 'end': 7.079}, {'text': 'should', 'start': 7.119, 'end': 7.239}, {'text': 'have', 'start': 7.279, 'end': 7.359}, {'text': 'received', 'start': 7.399, 'end': 7.739}, {'text': 'an', 'start': 7.779, 'end': 7.859}, {'text': 'email', 'start': 7.94, 'end': 8.22}, {'text': 'with', 'start': 8.279, 'end': 8.42}, {'text': 'links', 'start': 8.5, 'end': 8.699}, {'text': 'to', 'start': 8.76, 'end': 8.819}, {'text': 'Slack,', 'start': 8.92, 'end': 9.319}, {'text': 'your', 'start': 9.639, 'end': 9.739}, {'text': 'email,', 'start': 9.84, 'end': 10.219}, {'text': 'and', 'start': 10.519, 'end': 10.679}, {'text': 'our', 'start': 10.859, 'end': 10.96}, {'text': 'project', 'start': 11.079, 'end': 11.5}, {'text': 'management', 'start': 11.559, 'end': 11.899}, {'text': 'tool.', 'start': 12.0, 'end': 12.259},
{'text': 'Get', 'start': 12.799, 'end': 12.88}, {'text': 'those', 'start': 12.96, 'end': 13.18}, {'text': 'set', 'start': 13.239, 'end': 13.38}, {'text': 'up', 'start': 13.439, 'end': 13.539}, {'text': 'before', 'start': 13.599, 'end': 13.88}, {'text': 'anything', 'start': 14.039, 'end': 14.38}, {'text': 'else.', 'start': 14.46, 'end': 14.799},
{'text': 'Step', 'start': 15.439, 'end': 15.639}, {'text': 'two:', 'start': 15.759, 'end': 16.079}, {'text': 'complete', 'start': 16.379, 'end': 16.76}, {'text': 'your', 'start': 16.799, 'end': 16.879}, {'text': 'compliance', 'start': 16.94, 'end': 17.359}, {'text': 'training.', 'start': 17.44, 'end': 17.84},
{'text': "It's", 'start': 18.319, 'end': 18.46}, {'text': 'in', 'start': 18.479, 'end': 18.54}, {'text': 'the', 'start': 18.579, 'end': 18.62}, {'text': 'learning', 'start': 18.68, 'end': 18.959}, {'text': 'portal', 'start': 19.039, 'end': 19.299}, {'text': 'under', 'start': 19.42, 'end': 19.579}, {'text': 'required', 'start': 19.639, 'end': 20.079}, {'text': 'courses,', 'start': 20.159, 'end': 20.579}, {'text': 'and', 'start': 21.0, 'end': 21.1}, {'text': 'it', 'start': 21.139, 'end': 21.219}, {'text': 'needs', 'start': 21.299, 'end': 21.52}, {'text': 'to', 'start': 21.52, 'end': 21.6}, {'text': 'be', 'start': 21.639, 'end': 21.779}, {'text': 'done', 'start': 21.84, 'end': 22.039}, {'text': 'within', 'start': 22.1, 'end': 22.36}, {'text': 'your', 'start': 22.399, 'end': 22.539}, {'text': 'first', 'start': 22.559, 'end': 22.779}, {'text': 'five', 'start': 22.859, 'end': 23.039}, {'text': 'days.', 'start': 23.1, 'end': 23.479},
{'text': 'Step', 'start': 24.079, 'end': 24.299}, {'text': 'three:', 'start': 24.42, 'end': 25.059}, {'text': 'schedule', 'start': 25.159, 'end': 25.5}, {'text': 'a', 'start': 25.559, 'end': 25.599}, {'text': 'one-on-one', 'start': 25.68, 'end': 26.219}, {'text': 'with', 'start': 26.26, 'end': 26.379}, {'text': 'your', 'start': 26.399, 'end': 26.499}, {'text': 'manager.', 'start': 26.539, 'end': 26.959},
{'text': 'This', 'start': 27.539, 'end': 27.68}, {'text': 'is', 'start': 27.719, 'end': 27.86}, {'text': 'where', 'start': 27.879, 'end': 28.02}, {'text': "you'll", 'start': 28.079, 'end': 28.219}, {'text': 'walk', 'start': 28.239, 'end': 28.44}, {'text': 'through', 'start': 28.5, 'end': 28.639}, {'text': 'your', 'start': 28.659, 'end': 28.84}, {'text': 'thirty/sixty/ninety-day', 'start': 28.859, 'end': 29.939}, {'text': 'plan', 'start': 30.059, 'end': 30.639}, {'text': 'and', 'start': 30.719, 'end': 30.86}, {'text': 'get', 'start': 30.959, 'end': 31.119}, {'text': 'clarity', 'start': 31.199, 'end': 31.699}, {'text': 'on', 'start': 31.739, 'end': 31.92}, {'text': 'what', 'start': 31.939, 'end': 32.119}, {'text': 'success', 'start': 32.159, 'end': 32.759}, {'text': 'looks', 'start': 32.819, 'end': 33.02}, {'text': 'like', 'start': 33.059, 'end': 33.18}, {'text': 'in', 'start': 33.2, 'end': 33.279}, {'text': 'your', 'start': 33.319, 'end': 33.419}, {'text': 'first', 'start': 33.459, 'end': 33.659}, {'text': 'quarter.', 'start': 33.739, 'end': 34.099},
{'text': 'If', 'start': 34.619, 'end': 34.739}, {'text': 'you', 'start': 34.759, 'end': 34.84}, {'text': 'get', 'start': 34.88, 'end': 35.02}, {'text': 'stuck', 'start': 35.059, 'end': 35.259}, {'text': 'on', 'start': 35.299, 'end': 35.459}, {'text': 'any', 'start': 35.5, 'end': 35.659}, {'text': 'of', 'start': 35.68, 'end': 35.779}, {'text': 'this,', 'start': 35.819, 'end': 36.079}, {'text': 'your', 'start': 36.38, 'end': 36.479}, {'text': 'onboarding', 'start': 36.54, 'end': 37.02}, {'text': 'buddy', 'start': 37.079, 'end': 37.38}, {'text': 'is', 'start': 37.439, 'end': 37.599}, {'text': 'your', 'start': 37.639, 'end': 37.779}, {'text': 'first', 'start': 37.799, 'end': 38.059}, {'text': 'call.', 'start': 38.119, 'end': 38.819},
{'text': "Don't", 'start': 38.879, 'end': 39.06}, {'text': 'sit', 'start': 39.099, 'end': 39.2}, {'text': 'on', 'start': 39.259, 'end': 39.379}, {'text': 'questions.', 'start': 39.459, 'end': 40.219}, {'text': 'Ask', 'start': 40.319, 'end': 40.54}, {'text': 'early', 'start': 40.599, 'end': 40.999}, {'text': 'and', 'start': 41.059, 'end': 41.2}, {'text': 'ask', 'start': 41.279, 'end': 41.479}, {'text': 'often.', 'start': 41.579, 'end': 41.979}
];
// Group words into lines of 3
const captionLines = [];
let currentLine = [];
transcript.forEach(word => {
if (currentLine.length >= 3) {
captionLines.push(currentLine);
currentLine = [word];
} else {
currentLine.push(word);
}
});
if (currentLine.length > 0) {
captionLines.push(currentLine);
}
const captionContainer = document.querySelector('.caption-container');
const captionBg = document.querySelector('.caption-bg');
const captionText = document.querySelector('.caption-text');
// Main Timeline
window.__timelines = window.__timelines || {};
const rootTl = gsap.timeline({ paused: true });
// Build Caption Animations
captionLines.forEach((line, i) => {
const start = line[0].start;
const end = line[line.length - 1].end;
const text = line.map(w => w.text).join(' ');
// Entrance (instant)
rootTl.set(captionText, { textContent: text }, start);
rootTl.set([captionBg, captionText], { opacity: 1 }, start);
// Exit (instant)
rootTl.set([captionBg, captionText], { opacity: 0 }, end);
});
// A-roll Reveal Animations
const aroll = document.getElementById('aroll-video');
// Reveal 1 (4.619s to 8.0s) - Slide Left
rootTl.to(aroll, { x: -1080, duration: 0.8, ease: "power1.inOut" }, 4.619);
rootTl.to(aroll, { x: 0, duration: 0.8, ease: "power1.inOut" }, 7.2);
// Reveal 2 (15.439s to 19.0s) - Slide Right
rootTl.to(aroll, { x: 1080, duration: 0.8, ease: "power1.inOut" }, 15.439);
rootTl.to(aroll, { x: 0, duration: 0.8, ease: "power1.inOut" }, 18.2);
// Reveal 3 (24.079s to 28.0s) - Slide Up
rootTl.to(aroll, { y: -1920, duration: 0.8, ease: "power1.inOut" }, 24.079);
rootTl.to(aroll, { y: 0, duration: 0.8, ease: "power1.inOut" }, 27.2);
// Ensure timeline spans full duration
rootTl.to({}, { duration: 0.1 }, 42.36);
window.__timelines["root"] = rootTl;
</script>
</body>
</html>