Skip to content

Static-frame dedup misses when a mounted layer ends, then runs out of verification time on caption-heavy videos #3431

Description

@robinebers

Describe the bug

Hey! Rob (real human) here. The below is the summary of a multi-hour bug hunt. Apparently a previous issues tried to address this, but introduced a regression instead. All details below. Hope this helps.


Summary

We’re rendering a transparent caption-and-text-hook overlay on top of an existing video. The composition has no embedded video, no canvas, no CSS animations or transitions, and no continuous karaoke animation. Captions change through normal GSAP tl.set() calls.

Static-frame dedup should work well here because most frames between caption changes are identical. Instead, it disables itself. After fixing the first problem locally, we found a second limitation that still prevents dedup from helping.

First problem: the hook’s end disappears from the timing information dedup reads

The hook is mounted using the documented production structure:

<div
  id="fr-0"
  data-composition-id="text-hook-fr0"
  data-no-timeline
  data-composition-src="fragments/fr-0.html"
  data-start="0.000"
  data-duration="3.500"
></div>

The hook is just text and static CSS. Its fragment has no JavaScript, animation, or transition.

After the runtime mounts the composition, the same element still has data-start="0.000", but data-duration has been removed. The runtime preserves that value in data-hf-authored-duration instead.

That behavior is intentional in packages/core/src/runtime/init.ts, inside sanitizeCompositionDurationAttributes(). Other runtime code already knows how to read the preserved attribute.

But computeClipBoundaryFrames() in packages/engine/src/services/frameCapture.ts only reads:

dur: parseFloat((el as HTMLElement).dataset.duration || "")

So the dedup analyzer never learns that the hook ends at 3.5 seconds.

At 25 fps, that end falls around frame 88. The analyzer predicts frames 86–88 are unchanged, but the hook actually disappears during that interval. We instrumented the verifier without changing its comparison: its anchor was frame 85, and the first mismatching sampled frame was frame 88.

The actual log is:

static-frame dedup: disabled
(verification failed — content drifts from anchor at predicted-static frame 88)

We also inspected the live, mounted DOM at that point:

{
  "id": "fr-0",
  "compositionId": "text-hook-fr0",
  "start": "0.000",
  "duration": null,
  "visibility": "hidden"
}

The verifier is behaving correctly. The mistake is that boundary prediction and runtime normalization disagree about where a mounted layer’s duration lives.

Suggested fix

Have computeClipBoundaryFrames() read the same preserved timing attributes the runtime already uses:

const { start, duration, end, hfAuthoredDuration, hfAuthoredEnd } =
  (el as HTMLElement).dataset;

return {
  start: parseFloat(start || ""),
  dur: parseFloat(duration || hfAuthoredDuration || ""),
  end: parseFloat(end || hfAuthoredEnd || ""),
};

When a duration is present, calculate the end from start + duration. Otherwise, use the explicit end value when available. Existing public attributes should keep precedence over preserved values.

We tested this change against upstream source. It correctly protects frames 87–89 around the hook boundary, also handles data-hf-authored-end, and leaves existing data-duration behavior unchanged.

Second problem: fixing the boundary exposes the 15-second verification limit

With that exact patch applied to the published 0.8.10 engine, the false frame-88 mismatch disappears. However, the same real composition then fails with:

static-frame dedup: disabled
(verification budget exhausted before frame 86;
too much predicted-static material to fully verify — this is the safe fallback, not an error)

Verification took 15.318 seconds before stopping.

The 15-second limit was deliberately introduced in #2457 to prevent long renders from spending minutes verifying screenshots. We’re not suggesting removing that safeguard or disabling byte-for-byte verification. The problem is that a caption-heavy composition has many short static intervals, so verifying every interval with full-page screenshots uses up the budget before dedup can start.

This seems to require a separate improvement to how static intervals are verified, while preserving the existing safety checks.

Expected behavior

  1. Mounted layers retain visible start/end boundaries for the dedup analyzer after runtime normalization.
  2. Ordinary text hooks appearing or disappearing do not cause a false static-frame verification failure.
  3. Caption-heavy transparent overlays can benefit from dedup without exceeding the verification budget or weakening correctness checks.

The first item has a small, focused fix. The second verification-budget problem is separate, but it becomes visible immediately once that fix is applied.

Link to reproduction

NA

Steps to reproduce

  1. Build a transparent 25 fps caption overlay with a mounted, static text hook using data-start="0.000" and data-duration="3.500".
  2. Render the overlay as a transparent PNG sequence with static-frame dedup enabled.
  3. Observe the verifier reject frame 88 because the mounted hook duration moved to data-hf-authored-duration.
  4. Update computeClipBoundaryFrames() to read data-hf-authored-duration and data-hf-authored-end.
  5. Render again and observe the separate 15-second verification-budget failure on the caption-heavy composition.

Expected behavior

NA

Actual behavior

NA

Environment

- HyperFrames CLI and engine: **0.8.10**; also reproduced on **0.8.3** and confirmed in current `main`.
- Output: **1080 × 1920**, **25 fps**, **37.56 seconds**, **939 frames**.
- Composition: **121 caption groups** and one plain, static text hook.
- Render environment: headless Chrome on Linux, four CPU cores and four workers.
- Output format: transparent PNG sequence.

Additional context

No response

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions