Animate .scene-inner, never the .clip
The HyperFrames framework forces opacity:1 in every .clip that is active. If you animate the wrapper directly, the fade does not occur—the engine overrides your animation frame by frame. The solution is always to animate an internal child.
HyperFrames iterates over the active clips on each frame and applies element.style.opacity = "1" directly in .clip. Any GSAP animation that tries to gsap.to(".clip", {opacity:0}) will be overwritten on the next render tick — resulting in a ghost fade that never happens.
tl.to("#clip-cena-2", {
opacity: 0,
duration: 0.45
}, 4.5);
- ✗ Opacity overwritten in the next frame
- ✗ Fade is never visible in the rendered video
- ✗ Hard to debug (seems to work in the preview)
tl.to("#scene-inner-2", {
opacity: 0,
duration: 0.45
}, 4.5);
// required hard-kill:
tl.set("#scene-inner-2", {
opacity: 0
}, 4.95);
- ✓ Fade runs in the child DOM — the framework does not interfere
- ✓ Hard-kill with
tl.setensures opacity:0 at the end - ✓ Covers the gotcha
gsap_exit_missing_hard_kill
gsap_exit_missing_hard_killEven when using scene-inner, if the GSAP tween finishes before of the clip to disappear, the opacity may return. Always add a tl.set(target, {opacity:0}, tempoFinal) as a sentinel. The composition-template.mjs already includes this line automatically.
<div id="clip-cena-2"
class="clip"
data-start="4.0"
data-duration="3.0"
data-track-index="1">
<!-- animatable child -->
<div id="scene-inner-2" class="scene-inner">
<!-- scene content -->
</div>
</div>
Alternating tracks to avoid overlapping_clips_same_track
Adjacent scenes that touch at the time boundary — even by fractions of a float — trigger the error overlapping_clips_same_track during linting. The canonical fix is to alternate the data-track-index: scenes in 1/3, captions in 2/4.
0s → 4s
8s → 12s
16s → 20s
0s → 4s
8s → 12s
16s → 20s
4s → 8s
12s → 16s
20s → 24s
4s → 8s
12s → 16s
20s → 24s
data-start="0" data-duration="4.0"
data-track-index="1"
data-start="4.0" data-duration="4.0"
data-track-index="1"
// → lint: overlapping_clips_same_track
data-start="0" data-duration="4.0"
data-track-index="1" // scene 1 (odd)
data-start="4.0" data-duration="4.0"
data-track-index="3" // scene 2 (even)
// → lint: ok
The temptation is to add a small gap (data-start="4.001") to avoid the collision. This creates a visible black frame in the video and may also trigger other lint errors. The correct fix is always to change the track, never the timing.
data-layout-ignore in off-canvas decorative elements
Ghost text, glows, and bg-layers positioned outside the visible canvas (negative translateX, left:-200px, etc.) trigger overflow warnings in inspect—even when visually invisible. The attribute data-layout-ignore signals to the engine that these elements are intentionally off-canvas.
HyperFrames inspect calculates the bounding box of all visible elements. Off-canvas decorative elements—even with overflow:hidden in the parent — they can leak out and expand the measured bounding box. Result: video rendered with black bars around the edges.
<div
class="bg-layer"
style="left:-240px;top:0">
GHOST TEXT
</div>
// → inspect: overflow detected
<div
class="bg-layer"
style="left:-240px;top:0"
data-layout-ignore>
GHOST TEXT
</div>
// → inspect: ok
blur() that extend beyond the frame edges to create a soft halo.Local fonts: never Google Fonts via <link> or @import
HyperFrames renders in an offline environment (headless Chrome without a network connection). A <link> from Google Fonts fails silently — the video renders with the system fallback font, with no visible error in the terminal. The lint flags google_fonts_import e font_family_without_font_face.
Chrome headless doesn't report network failures as GSAP or JS errors. The page simply falls back to the fallback font (sans-serif → Arial/Helvetica). The video renders normally—but with the wrong typography. You might not notice until you watch the final result in high resolution.
<link href="https://fonts.googleapis.com/
css2?family=Inter:wght@400;700"
rel="stylesheet">
// → lint: google_fonts_import
@font-face {
font-family: 'Inter';
src: url('../fonts/inter-400.woff2')
format('woff2');
font-weight: 400;
font-display: block;
}
fetch-fonts.mjsnode scripts/fetch-fonts.mjs \
--family Inter \
--weights 400,500,600,700,800 \
--subset latin \
--out assets/fonts/
# result: assets/fonts/inter-400.woff2
# assets/fonts/inter-700.woff2
# assets/fonts/fonts.css (ready-to-use import)
The subset latin includes all characters used in Brazilian Portuguese (ã, ç, õ, á, é, etc.). You don't need to download the full subset. Use font-display: block to ensure headless Chrome waits for the font before capturing the frame.
ffmpeg -nostdin on Windows / git-bash
When running ffmpeg on Windows via git-bash (or any POSIX emulator on Win32), ffmpeg may inadvertently read stdin, interpret EOF as valid input, and return exit 0 without generating any output file. The problem is completely silent.
The pipeline continues without failing. The next step tries to read a file that doesn’t exist. The actual error appears several steps later, making it hard to trace. The flag -nostdin fully solves it—and costs nothing in normal environments.
ffmpeg -y \
-framerate 30 \
-i frames/%04d.png \
-i audio.wav \
output.mp4
# → exit 0, output.mp4 does not exist
ffmpeg -nostdin -y \
-framerate 30 \
-i frames/%04d.png \
-i audio.wav \
output.mp4
# → output.mp4 generated correctly
-ss 2.5 \
-i renders/video-16x9.mp4 \
-vframes 1 \
-update 1 \
thumbnail.png
If you need an absolute path on Windows: /c/ffmpeg/bin/ffmpeg.exe -nostdin ...
Determinism: prohibited Date.now(), Math.random() e fetch()
HyperFrames rendering is deterministic by design: the same HTML must produce exactly the same frames on any machine, at any time. Any source of non-determinism — real time, randomness, external data — breaks this guarantee and produces inconsistent frames or a render error.
The engine captures individual frames by synthetically navigating through the GSAP timeline—it doesn’t play the video in real time. This means that Date.now() returns different values for each captured frame, Math.random() is never reproducibly seeded and fetch() may fail or return different data each time it runs.
-
✗
Date.now()— returns a different timestamp for each frame -
✗
Math.random()— non-reproducible sequence -
✗
fetch()— external data can change or fail -
✗
setInterval()— based on the OS’s real-time clock -
✗
new Date()— same issue as Date.now()
-
✓
Calculated positions:
gsap.utils.mapRange() -
✓
External data: pre-baked into the HTML at build time
-
✓
Pseudorandom:
seededRand(n)with a fixed seed -
✓
Counters: JS variables updated by
tl.call() -
✓
Timeline recorded:
window.__timelines["main"]
function seededRand(seed) {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
};
}
const rand = seededRand(42);
// rand() always returns the same sequence
multiple_root_compositionsCan only exist a single file with data-composition-id at the project root. If you leave index-vertical.html, index-backup.html or any variation, the generator build-index.mjs conflicts and lint fails. Always use only index.html as the composition root.
What you learned in this module
You’ve completed Track 3 — Under the Hood. Now that you understand the internal structure, the gotchas, and how the generator works, it’s time to put it into practice: YouTube & Shorts, onboarding, lessons, and the prompt library to speed up your production.