PTENES
MODULE 3.4

🧯 Gotchas & fixes

The six most common pitfalls that silently break renders, lint, and inspect—and how to fix them once and for all.

6
Topics
25
Minutes
Advanced
Level
Debug
Type
🎭
Topic 1

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.

Framework mechanics

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.

✗ PROBLEM — animate the .clip
// ❌ WRONG: the engine forces opacity:1
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)
✓ FIX — animate .scene-inner
// ✅ CORRECT: internal child
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.set ensures opacity:0 at the end
  • ✓ Covers the gotcha gsap_exit_missing_hard_kill
💡
Related gotcha: gsap_exit_missing_hard_kill

Even 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.

Recommended HTML structure
<!-- framework-managed wrapper -->
<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>
Key concepts
.clip
engine-controlled wrapper
.scene-inner
GSAP-animatable child
opacity:1
forced on the active clip
tl.set
hard kill at the end of the scene
🔀
Topic 2

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.

Timeline track workflow
Track 1
Scene 1
0s → 4s
Scene 3
8s → 12s
Scene 5
16s → 20s
Track 2
Caption 1
0s → 4s
Caption 3
8s → 12s
Caption 5
16s → 20s
Track 3
Scene 2
4s → 8s
Scene 4
12s → 16s
Scene 6
20s → 24s
Track 4
Caption 2
4s → 8s
Caption 4
12s → 16s
Caption 6
20s → 24s
Odd-numbered scenes (1,3,5…) → track 1 · Even-numbered scenes (2,4,6…) → track 3 · Captions mirror on tracks 2/4
✗ PROBLEM — same track, edges touching
// ❌ scene 1 and scene 2 on track 1
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
✓ FIX — alternating tracks
// ✅ template: s.i%2===1 ? 1 : 3
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
⚠️
DON’T create gaps between scenes

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.

Key concepts
Track 1/3
odd and even scenes
Track 2/4
alternating captions
No gaps
never change data-start
s.i%2
template logic
👻
Topic 3

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.

🔴
The inspector will flag overflow—even when it’s intentional

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.

✗ PROBLEM — decorative without an attribute
<!-- ghost text overflowing -->
<div
  class="bg-layer"
  style="left:-240px;top:0">
  GHOST TEXT
</div>
// → inspect: overflow detected
✓ FIX — data-layout-ignore
<!-- ignore in layout calculation -->
<div
  class="bg-layer"
  style="left:-240px;top:0"
  data-layout-ignore>
  GHOST TEXT
</div>
// → inspect: ok
When adding data-layout-ignore
Ghost Text
Large, semitransparent text positioned partly or entirely outside the canvas as a decorative effect.
Glow / Bloom
Filter divs blur() that extend beyond the frame edges to create a soft halo.
BG-Layer
Background layer that extends beyond the canvas to cover the edges with a gradient, without creating a black bar.
Key concepts
data-layout-ignore
plain HTML attribute, with no value
bounding box
calculated by the inspect engine
visual overflow
black bar in the final video
🔤
Topic 4

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.

Why it fails silently

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.

✗ PROBLEM — Google Fonts via link
<!-- ❌ WRONG: requires network -->
<link href="https://fonts.googleapis.com/
css2?family=Inter:wght@400;700"
rel="stylesheet">
// → lint: google_fonts_import
✓ FIX — local @font-face
/* ✅ CORRECT: local woff2 */
@font-face {
  font-family: 'Inter';
  src: url('../fonts/inter-400.woff2')
       format('woff2');
  font-weight: 400;
  font-display: block;
}
Downloading with fetch-fonts.mjs
# downloads the latin subset (covers PT-BR)
node 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)
💡
Latin subset covers PT-BR

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.

Key concepts
@font-face
local declaration in CSS
.woff2
locally optimized format
fetch-fonts.mjs
skill script
font-display: block
waits for loading
⌨️
Topic 5

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.

🚨
Exit 0 with no file generated — the worst kind of error

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.

✗ PROBLEM — no -nostdin
# ❌ Windows/git-bash without -nostdin
ffmpeg -y \
  -framerate 30 \
  -i frames/%04d.png \
  -i audio.wav \
  output.mp4
# → exit 0, output.mp4 does not exist
✓ FIX — always use -nostdin
# ✅ -nostdin as the first argument
ffmpeg -nostdin -y \
  -framerate 30 \
  -i frames/%04d.png \
  -i audio.wav \
  output.mp4
# → output.mp4 generated correctly
💡
Extracting a single frame with -nostdin
ffmpeg -nostdin -y \
  -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 ...

Key concepts
-nostdin
first argument always
false exit 0
without a generated file
git-bash
POSIX/Win32 emulator
-update 1
for a single PNG frame
🎲
Topic 6

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.

Why rendering is frame by frame

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.

✗ PROBLEM — nondeterministic fonts
  • ✗
    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()
✓ FIX — everything via GSAP timeline
  • ✓
    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"]
Deterministic pseudorandom with a fixed seed
// ✅ seeded PRNG — reproducible
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
💡
Additional gotcha: multiple_root_compositions

Can 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.

Key concepts
Determinism
same input → same output
Seeded PRNG
reproducible random value
No fetch()
pre-baked data in the HTML
tl.call()
callbacks on the GSAP timeline
📋
Summary

What you learned in this module

✓
Animate .scene-inner, not .clip
The framework forces opacity:1 on the wrapper—fades go on the child + hard-kill with tl.set
✓
Alternating tracks 1/3 and 2/4
Avoids overlapping_clips_same_track without creating gaps or black frames
✓
data-layout-ignore on decorative elements
Ghost text and off-canvas glows need the attribute to prevent bounding box overflow
✓
@font-face with local .woff2
Google Fonts via link fails silently in offline headless Chrome
✓
ffmpeg -nostdin on Windows
Without the flag, exit 0 with no file — the pipeline's most silent error
✓
Deterministic render
Date.now(), Math.random(), fetch() are prohibited — use a GSAP timeline and seeded PRNG
💡
Next track
Track 4: Applications

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.

4.1
📺 YouTube & Shorts
4.2
🚀 Onboarding & lessons
4.3
🧰 Prompt library