---
title: Common Mistakes
description: "Pitfalls that break Hyperframes compositions."
---

These are mistakes that cannot be caught by the linter. For automated checks, run `npx hyperframes lint` (see [CLI](/packages/cli#lint)).

<Warning>
  The first two mistakes — animating video element dimensions and controlling media playback in scripts — are the most common causes of broken compositions. If your video looks wrong, check these first.
</Warning>

<AccordionGroup>
  <Accordion title="Animating video element dimensions">
    **Symptom:** Video frames stop updating, or browser performance drops severely.

    **Cause:** GSAP animating `width`, `height`, `top`, `left` directly on a `<video>` element can cause the browser to stop rendering frames.

    **Before (broken):**

    ```javascript index.html
    // Animating the video element directly — causes frame rendering to stop
    tl.to("#el-video", { width: 500, height: 280, top: 700, left: 1400 }, 26);
    ```

    **After (fixed):**

    ```html index.html
    <!-- Wrap the video in a div and animate the wrapper -->
    <div id="pip-wrapper" style="position: absolute; width: 1920px; height: 1080px;">
      <video id="el-video" data-start="0" data-track-index="0"
             src="./assets/video.mp4" style="width: 100%; height: 100%;"></video>
    </div>
    ```

    ```javascript index.html
    // Animate the wrapper — the video fills it at 100%
    tl.to("#pip-wrapper", { width: 500, height: 280, top: 700, left: 1400 }, 26);
    ```

    Use a non-timed wrapper `<div>` for visual effects like picture-in-picture. Animate the wrapper; let the video fill it via CSS.
  </Accordion>

  <Accordion title="Controlling media playback in scripts">
    **Symptom:** Audio/video playback is out of sync, or plays when it should not.

    **Cause:** Calling `video.play()`, `video.pause()`, or setting `audio.currentTime` in your scripts. The [framework owns all media playback](/reference/html-schema#framework-managed-behavior).

    **Before (broken):**

    ```javascript index.html
    // Conflicts with framework media sync
    document.getElementById("el-video").play();
    document.getElementById("el-audio").currentTime = 5;
    ```

    **After (fixed):**

    ```javascript index.html
    // Don't control media playback at all. The framework handles it.
    // Use GSAP for visual animations only:
    tl.to("#el-video", { opacity: 1, duration: 0.5 }, 0);
    ```

    The framework reads [`data-start`](/concepts/data-attributes#timing-attributes), [`data-media-start`](/concepts/data-attributes#media-attributes), and [`data-volume`](/concepts/data-attributes#media-attributes) to control when and how media plays. See [Compositions: Two Layers](/concepts/compositions#two-layers-primitives-and-scripts) for the separation between HTML primitives and scripts.
  </Accordion>

  <Accordion title="Composition duration shorter than video">
    **Symptom:** Video plays for a few seconds then stops. Timeline shows 8-10 seconds even though the video is minutes long.

    **Cause:** The composition duration equals the [GSAP timeline duration](/guides/gsap-animation#timeline-duration-and-composition-duration), not `data-duration` on the video. If your last GSAP animation ends at 8 seconds, the composition is 8 seconds long — regardless of how long the video source is.

    **Before (broken):**

    ```javascript index.html
    // Timeline is only 7.8s long — video cuts off after 7.8 seconds
    tl.to("#lower-third", { left: -640, duration: 0.6 }, 7.2);
    ```

    **After (fixed):**

    ```javascript index.html
    tl.to("#lower-third", { left: -640, duration: 0.6 }, 7.2);

    // Extend the timeline to 283 seconds to match the video length
    tl.set({}, {}, 283);
    ```

    `tl.set({}, {}, TIME)` adds a zero-duration tween at the specified time, extending the timeline without affecting any elements.

    <Tip>
      A quick check: run `npx hyperframes compositions` to see the resolved duration of each composition. If it is shorter than expected, your timeline needs extending.
    </Tip>
  </Accordion>

  <Accordion title="Setting the root render length from a script or --variables does nothing">
    **Symptom:** You set the **root** composition's `data-duration` from a script (or expose it as a variable and pass `--variables`), but the render length never changes. It stays locked at whatever `data-duration` is written in the source HTML.

    **Cause:** The root composition's `data-duration` (the total render length / frame count) is read **once at compile time**, before your scripts run, exactly like `data-width` / `data-height`. A static root `data-duration` is locked at that point, so a later `root.setAttribute("data-duration", ...)` or a `--variables`-driven value is ignored. This is unlike a **clip's** `data-duration`, which the renderer re-reads from the live DOM after scripts run.

    **Before (broken):**

    ```html index.html
    <div id="root" data-composition-id="main" data-duration="2" data-width="1920" data-height="1080">
      ...
    </div>
    <script>
      // Ignored: the root render length was already fixed at 2s at compile time.
      document.getElementById("root").setAttribute("data-duration", "5");
    </script>
    ```

    **After (fixed):** author the root `data-duration` directly, one value per output. To produce a different length, author (or generate) a variant with that root `data-duration`. See [What can't be a variable](/concepts/variables#what-cant-be-a-variable).

    <Tip>
      Only the root composition's *total length* is compile-time-fixed. A clip's own `data-duration` (and a `<video>`/`<audio>` trim length) is re-read from the live DOM, so scripts and variables can still drive those, see [Swapping media: do you need to vary duration too?](/concepts/variables#swapping-media-do-you-need-to-vary-duration-too).
    </Tip>
  </Accordion>

  <Accordion title="Missing class='clip' on timed elements">
    **Symptom:** Elements are always visible, ignoring their `data-start` and `data-duration` timing.

    **Cause:** The [`class="clip"`](/concepts/data-attributes#element-visibility) attribute tells the runtime to manage the element's visibility lifecycle. Without it, the element is always rendered.

    **Before (broken):**

    ```html index.html
    <!-- Missing class="clip" — this element is always visible -->
    <h1 id="title" data-start="2" data-duration="5" data-track-index="0">
      Hello World
    </h1>
    ```

    **After (fixed):**

    ```html index.html
    <!-- With class="clip", the runtime shows this only from 2s to 7s -->
    <h1 id="title" class="clip" data-start="2" data-duration="5" data-track-index="0">
      Hello World
    </h1>
    ```

    <Note>
      The linter catches this one: `npx hyperframes lint` will flag timed elements missing `class="clip"`.
    </Note>
  </Accordion>

  <Accordion title="Oversized source images">
    **Symptom:** Preview stutters during scenes with images on screen. Render is slower than expected.

    **Cause:** Source images at much higher resolution than the canvas. Chrome decodes images to raw RGBA bitmaps before displaying them, and bitmap size is `width × height × 4` bytes — independent of file size on disk. A 7000×5000 JPEG is 140MB decoded, even if the file is only 2MB.

    Displaying such an image in a 384×1080 region wastes memory and forces the compositor to resample a huge texture every frame.

    **Before (bloated):**

    ```html index.html
    <!-- 7000x5000 source, ~140MB decoded -->
    <img class="clip" data-start="0" data-duration="3"
         src="./assets/hero-scene.jpg" />
    ```

    **After (sized to the canvas):**

    ```bash Terminal
    # Resize a batch of images to fit within 3840x3840, preserving aspect ratio
    mkdir -p assets/resized
    mogrify -path assets/resized -resize 3840x3840\> assets/*.jpg
    ```

    ```html index.html
    <!-- ~3840x2560 source, ~40MB decoded -->
    <img class="clip" data-start="0" data-duration="3"
         src="./assets/resized/hero-scene.jpg" />
    ```

    **Rule of thumb:** source images at most 2x the canvas dimensions. For a 1920×1080 composition, 3840×2160 is already plenty. See [Performance: Image sizing](/guides/performance#image-sizing).
  </Accordion>

  <Accordion title="Heavy backdrop-filter stacks">
    **Symptom:** Specific scenes drop to 5-10fps in preview. The composition is fine elsewhere.

    **Cause:** `backdrop-filter: blur()` on large elements, especially stacked at high radii. Each blur layer forces the compositor to sample pixels behind the element, run a blur kernel, and composite the result. Stacked layers multiply the cost.

    **Before (expensive):**

    ```css
    /* 8 layers per side = 16 blur passes every frame */
    .pb-1 { backdrop-filter: blur(1px); }
    .pb-2 { backdrop-filter: blur(2px); }
    .pb-3 { backdrop-filter: blur(4px); }
    .pb-4 { backdrop-filter: blur(8px); }
    .pb-5 { backdrop-filter: blur(16px); }
    .pb-6 { backdrop-filter: blur(32px); }
    .pb-7 { backdrop-filter: blur(64px); }
    .pb-8 { backdrop-filter: blur(128px); }
    ```

    **After (3 tuned layers):**

    ```css
    /* Fewer passes with hand-picked radii — visually similar, much cheaper */
    .pb-1 { backdrop-filter: blur(4px); }
    .pb-2 { backdrop-filter: blur(16px); }
    .pb-3 { backdrop-filter: blur(48px); }
    ```

    **Guidelines:**

    - Keep stacked `backdrop-filter` layers to 2-3 per region
    - Avoid radii above 64px over large areas — the biggest radii dominate the total cost
    - For a static blur effect, pre-render it into a PNG once and overlay with a regular `<img>`

    See [Performance: backdrop-filter: blur()](/guides/performance#backdrop-filter-blur) for the full breakdown.
  </Accordion>

  <Accordion title="Expected HDR output but got SDR">
    **Symptom:** Expected an HDR render, but the output looks the same as SDR or `ffprobe` reports `color_transfer=bt709`.

    **Cause:** By default, Hyperframes only switches to HDR encoding when a source `<video>` or `<img>` is tagged with BT.2020 / PQ / HLG color metadata. Common reasons HDR is not engaged:

    1. **All sources are SDR.** Auto-detect leaves SDR-only compositions in SDR. Verify with `ffprobe`:

       ```bash Terminal
       ffprobe -v error -show_streams source.mp4 | grep color_transfer
       # Want: smpte2084 (PQ) or arib-std-b67 (HLG)
       # SDR:  bt709, smpte170m, bt470bg, etc.
       ```

    2. **Wrong output format.** HDR output requires MP4. `--format mov` and `--format webm` fall back to SDR — Hyperframes logs a warning when this happens.

    3. **SDR was forced.** `--sdr` disables HDR even when HDR sources are present.

    If you need HDR regardless of source metadata, use `--hdr` to force it.

    `--docker` works the same as local rendering — auto-detect, `--hdr`, and `--sdr` are all forwarded into the container and produce the same output decisions (slower, since the container falls back to software WebGL for SDR DOM capture).

    See [HDR Rendering](/guides/hdr) for the full source requirements and verification steps.
  </Accordion>

  <Accordion title="Timeline key doesn't match data-composition-id">
    **Symptom:** Animations don't play. The composition appears static.

    **Cause:** The key used in `window.__timelines` must exactly match the [`data-composition-id`](/concepts/data-attributes#composition-attributes) attribute on the composition root element.

    **Before (broken):**

    ```javascript index.html
    // Mismatch: HTML says "my-video", script registers "root"
    // <div data-composition-id="my-video" ...>
    window.__timelines["root"] = tl;
    ```

    **After (fixed):**

    ```javascript index.html
    // Key matches the data-composition-id attribute
    // <div data-composition-id="my-video" ...>
    window.__timelines["my-video"] = tl;
    ```
  </Accordion>
</AccordionGroup>

## Debugging Checklist

When something does not work, check in this order:

1. **Run the linter:** `npx hyperframes lint` — catches most structural issues
2. **Timeline registered?** Is `window.__timelines["<id>"]` set? Does the key match [`data-composition-id`](/concepts/data-attributes#composition-attributes)?
3. **GSAP-only animations?** Only animate visual properties (opacity, transform, color) — see [GSAP Animation](/guides/gsap-animation#key-rules)
4. **Timeline long enough?** Add `tl.set({}, {}, DURATION)` at the end — see [Timeline Duration](/guides/gsap-animation#timeline-duration-and-composition-duration)
5. **Console errors?** Open browser console — runtime errors show as `[Browser:ERROR]`
6. **Still stuck?** See [Troubleshooting](/guides/troubleshooting) for environment and rendering issues

## Next Steps

<CardGroup cols={2}>
  <Card title="Troubleshooting" icon="wrench" href="/guides/troubleshooting">
    Fix environment and rendering issues
  </Card>
  <Card title="GSAP Animation" icon="wand-magic-sparkles" href="/guides/gsap-animation">
    Review animation rules and patterns
  </Card>
  <Card title="HTML Schema Reference" icon="code" href="/reference/html-schema">
    Full attribute reference and checklist
  </Card>
  <Card title="Data Attributes" icon="database" href="/concepts/data-attributes">
    Timing, media, and composition attributes
  </Card>
</CardGroup>
