The media_in_subcomposition rule blanket-errored every <video>/<audio>
inside a sub-composition, claiming nested media is "never seeked/decoded
and renders blank/black". This is false: the runtime discovers media with
a flat document.querySelectorAll("video, audio"), resolves each element's
host composition via closest("[data-composition-id]"), and rebases its
local data-start by the accumulated absolute start of every ancestor
composition (packages/core/src/runtime/{media,startResolver}.ts). Media
seeks and decodes at any nesting depth, verified end to end through the
producer render path.
- Remove the rule and flip its test to assert nested media is NOT flagged.
- Drop the now-dead media_in_subcomposition clause from the registry
components test.
- Drop the equivalent pre-render guard from the faceless-explainer and
pr-to-video assemble scripts.
- Correct the reference docs (hyperframes-core SKILL, data-attributes,
variables-and-media, composition-patterns; hyperframes-cli
lint-validate-inspect): media works at any depth. Preserve the one real
constraint, that a sub-comp timeline cannot reach host-root elements, so
host-root media motion is authored on the main timeline.
## What
Allow Studio Preview to serve an asset reached through a project-local symlink whose target is in a shared directory outside the project, including browser-hostile video assets that need an authoring proxy.
## Why
Preview rejected these assets with a 404 while the renderer accepted the same path. The initial static-route fix still failed for HEVC, ProRes, AV1, and VP9 assets because the proxy transcoder rejected the external target.
## How
Use lexical project-root containment for the read-only static asset route and proxy source request. The transcoder canonicalizes the target for ffmpeg and includes that identity in its cache key, while keeping the proxy cache inside the project. Composition source paths retain canonical containment because preview can persist their data-hf-id values.
## Test plan
- [x] Unit tests added/updated
- [x] `bun run --cwd packages/studio-server test` (397 tests)
- [x] Studio Server typecheck, oxlint, and oxfmt
- [x] External-symlinked hostile-video proxy route regression
- [x] Static-route traversal regression
- [ ] Documentation updated (not applicable)
`bundleToSingleHtml` compiles `data-duration` into `data-end` (in
`compileTimingAttrs`), then calls `validateHyperframeHtmlContract` against
the compiled HTML. The linter's `deprecated_data_end` rule fired on the
compiler's own consistent output — `<audio data-start="0" data-duration="18"
data-end="18">` — because `diagnoseDerivedEnd` unconditionally emitted a
`deprecated-end` diagnostic whenever both attributes were present, ignoring
whether the derived value matched.
Reporters routed this as "raw-source lint passes with 0 errors and 0
warnings, but `check --strict` still logs StaticGuard noise about
data-end without data-duration." Field cluster: cli-feedback crons 61-68,
n=25+ across darwin/arm64, darwin/x64, linux/x64, win32/x64, and versions
0.7.56 through 0.7.64. L3 reporter cite (ts=1784519869): "bundleToSingleHtml
compiles data-duration into data-end, then validates the compiled HTML and
reports its own generated data-end as deprecated."
Fix: `diagnoseDerivedEnd` now stays silent when the paired `data-end`
matches `data-start + data-duration` (within a 1ns epsilon to absorb
IEEE-754 residuals like `0.1 + 0.2 = 0.30000000000000004`). Truly-legacy
authoring shapes — `data-end` alone with no `data-duration`, or a
`data-end` that disagrees with `data-duration` — still fire
`deprecated_data_end`, with a refined message that names the drift on the
conflicting variant.
Facets covered: (a) validator treats compiler-derived `data-end` as legal
when paired with `data-duration`, and (e) recognizes the compile-time
rewrite site (`bundleToSingleHtml` → `compileHtml` → `compileTimingAttrs`).
Facets (b) stderr gating, (c) terminal JSON verdict, and (d) audio-not-
dropped are unblocked transitively — StaticGuard's `console.warn` is
already gated on `!isValid` and never drops audio; the check command
already emits JSON on every terminal path; so once the false-positive
diagnostic stops firing on compiler output, the noisy stderr line and the
misleading "check appears to fail" reporter framing both go away without
further wiring.
Co-Authored-By: Via <noreply@heygen.com>
* docs(media): document automatic proxying for hostile codecs
Describes the shipped behavior: which input codecs render, that live preview
auto-proxies what the browser cannot decode, where the cache lives, and how to
turn it off. Carries the skills notes and the hardening design documents.
* docs(media): align proxy guidance with runtime
## What
Part 2 of the GSAP seek-safety rules (stacks on #2611): the two rules that touch existing catalog content and required reconciliation with an existing rule.
- `gsap_relative_value_second_writer` (error) — a relative var value (`y: "-=15"`) on a property whose target has another writer **active at the relative tween's start**. The relative base is captured at tween init, which reads a different partial state per seek path: sequential seek inits it mid-entrance, a cold render worker inits it at the entrance's end state, and the element teleports at chunk boundaries (production case: all scene nodes jumping ~20px mid-scene). Writers that complete strictly before the start are safe (children render in start-time order within a seek pass — verified against gsap 3.15.0) and are not flagged; neither are single-writer relatives, `from()`/`fromTo()`, build-time `gsap.set`, or relative position parameters (`"+=0.5"`). Selector resolution bails on combinators and cross-composition scoping rather than guessing. Findings aggregate per tween pair and report the overlap window.
- `gsap_timeline_set_initial_hide` (warning) — initial-state hiding via `tl.set(target, vars, 0)` on a paused timeline is not rendered while the playhead sits at exactly 0, so frame 0 shows the unhidden state (verified against gsap 3.15.0: opacity stays 1 after `tl.time(0)`, applies only past 0). Exempt when the target is already hidden by authored CSS/inline styles or a standalone `gsap.set()`, and only sets preceding every tween in source order qualify (mutated position variables resolve to their initial binding in the parser — outro hard-kills don't masquerade as position-0 sets).
- Reconciliation: `gsap_fullscreen_overlay_starts_visible`'s fixHint previously recommended exactly the flagged `tl.set(sel, {opacity:0}, 0)` pattern; it now recommends authored CSS hiding or immediate `gsap.set()`.
- Docs for the full rule family in `docs/packages/lint.mdx`.
## Corpus impact (the reason this is its own PR)
These two rules are the ones that fire on repo-shipped content:
- `gsap_relative_value_second_writer`: 4 errors in `gooey-metaball`, all genuine overlaps. Measured with gsap 3.15.0: ballD diverges **3.31 xPercent / 1.99 yPercent (~8px/5px at 240px ball size)** between sequential and cold seek — a permanent base offset that appears as a teleport at a chunk boundary. Real but modest; happy to fix the block in a follow-up (start the drift at the entrance's end, or use absolute `fromTo`).
- `gsap_timeline_set_initial_hide`: 10 warnings across the catalog after the CSS-hidden exemption (down from 54 pre-narrowing); spot-checked as genuine frame-0 pops with no authored hide (e.g. `vfx-text-cursor` `#phrase-b`).
Adversarially reviewed the same way as #2611 (393-composition corpus + gsap semantics experiments); FP classes fixed and locked as negative tests: precede-only second writers, descendant/cross-composition selector mis-joins, CSS-hidden re-assertions, mutated position variables.
## Tests
Full `packages/lint` suite green at 440 tests including multi-composition roots; `tsc`, oxlint, fallow audit clean.
## What
Five lint rules (plus one extended core pattern) for GSAP defect classes that pass every existing check but break rendered output — the narrow, corpus-clean half of what was originally one PR (split per review; part 2 with the two catalog-touching rules stacks on top as #2612).
- `gsap_repeat_refresh_relative_value` (error) — `repeatRefresh: true` + relative value re-captures and accumulates per iteration; a cold seek into iteration N skips the accumulation (verified with gsap 3.15.0: sequential 47.5 vs cold 17.5).
- `gsap_function_value_hazard` (error/warning) — function values that call a method on the first parameter (GSAP passes `(index, target, targets)` — the first param is a number, so `(el) => el.getTotalLength()` throws and aborts the seeked frame) or measure the DOM: transform-sensitive reads (`getBoundingClientRect`, `getComputedStyle`, `gsap.getProperty`) are errors; transform-invariant layout reads (`offsetWidth`, `getBBox`, ...) are warnings. Pure-index arithmetic, `gsap.utils.wrap/distribute`, dataset/attribute reads, and closures over build-time constants are exempt.
- `gsap_callback_dom_measurement` (warning) — DOM layout measurement reachable from `tl.add()`/`tl.call()`/`eventCallback`/`onStart|onUpdate|...` via a two-hop named-function scan. The capture path seeks with `suppressEvents: false`, so callbacks re-fire on every seek and measured geometry is seek-order-dependent. `gsap.getProperty`-driven derived output (scramble/typewriter patterns) is exempt.
- `svg_measure_before_path_d` (error/warning) — `getTotalLength()` on a `<path>` with no static `d`: error when no `d` assignment exists anywhere (returns 0 in Chrome, silently killing dash animations); warning when assignments exist only inside function bodies. Recognizes `setAttribute`, GSAP `attr: { d }`, and CSS `d: path()`.
- `svg_drawon_css_dasharray_conflict` (error) — GSAP `strokeDasharray` on an element whose CSS declares a multi-component `stroke-dasharray`. GSAP merges per component, so `strokeDasharray: pathLength` computes to `"641.4px, 10px"` — the gap stays 10px, the hide-then-reveal hides only 10px, and the line stays visible all scene with a crawling notch. One of this repo's own producer fixtures has this exact bug (true positive from the corpus run).
- `gsap.utils.random()` and `"random(...)"` string tween values added to `non_deterministic_code` (core) — each worker inits independently, so the same tween resolves different randoms across chunks.
Both motivating production bugs (nodes teleporting at chunk boundaries; a draw-on line visible all scene) are minimally reproduced in the tests.
## Review hardening
Two independent adversarial reviews ran before submission: a false-positive hunt over all 393 compositions in this repo plus 23 constructed adversarial snippets (with gsap 3.15.0 semantics experiments), and a maintainer-conventions pass. Fixed FP classes are locked in as negative tests: all-interpolation template ids, GSAP attr-plugin `d` writes, getProperty-driven callbacks, transform-invariant marquee reads.
Corpus residue for these five rules: **1 error (a genuine dasharray bug in a producer fixture, happy to fix in a follow-up) and 2 warnings** (real layout reads in callbacks).
## Tests
`packages/lint` green at this commit in isolation; `tsc`, oxlint, fallow audit clean.
## Notes for reviewers
- All rules follow the file's conservative philosophy: anything not statically resolvable is skipped; false negatives over false positives.
- Open question: should the cold-seek family gate on `HyperframeLinterOptions.distributed` (error when distributed, warning otherwise), following the `system_font_will_alias` precedent? Happy to wire either way.
Moves assetResolution.ts from packages/lint into @hyperframes/parsers behind
a ./asset-resolution subpath export, mirroring the ./ff-binaries shape, and
points lint's two importers at it. Pure move: no behavior change. Unblocks
studio-server consuming the same helpers without a dependency cycle (the CLI
already depends on lint, so importing lint from studio-server is not an option).
The cli, engine, and lint packages each carried their own copy of the
ffmpeg/ffprobe lookup, annotated fallow-ignore code-duplication, and the
copies had drifted: the engine copy handled Windows PATHEXT and executed
which/where without a shell but lacked the Homebrew-dirs fallback for
GUI-spawned processes; the cli copy had the opposite. One resolver in
@hyperframes/parsers (the dependency-graph bottom) now carries the union
of both hardenings, and all three packages delegate to it. Every
consumer gets strictly more robust resolution; env-override semantics
per call site are preserved via configuredMustExist.
Field signal ts=1784040753 (#hyperframes-cli-feedback): a composition
with ~40 heavy overlay DOM elements — `filter:blur`, oversized
`radial-gradient`, and `clip-path` animations — captures solid-black for
the first ~half of the render, recovering near the end. Reproduces
identically via drawElement AND forced --no-browser-gpu screenshot
capture AND `snapshot`, so the capture layer itself is the offender, not
encoder/mux. Independent of duration (padding the timeline grows the bad
zone proportionally, doesn't shift it). Presence alone matters — even
opacity:0 / visibility:hidden / unused overlays contribute. Reporter's
workaround was splitting into per-transition mini-compositions +
FFmpeg concat.
Add compositionCheck rule `composition_heavy_overlay_count_high`
(warning). Counts DOM elements that carry any of: inline
`style` filter:blur / clip-path (non-none) / radial-gradient, or a
class/id whose top-level CSS rule body sets one of those. `display:none`
elements are counted-out (removed from render tree); opacity:0 /
visibility:hidden overlays are counted-in per the field-signal repro
shape. Warns at 25 to give lead time before the observed 40-element bad
zone. Skips registry source and installed-block files, mirroring
`composition_file_too_large`. Includes a `ts=1784040753` reference in
fixHint so authors can trace the risk shape.
Stack: PR #5 of 9 (base via/parity-telemetry-gate).
Signed-off-by: Via
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(lint): stop CSS comments in <style> from manufacturing phantom root tags
extractOpenTags scans raw source text with a flat regex that has no
concept of <style>/<script> block boundaries, so a CSS comment like
`/* <g> wrapper */` inside a <style> block reads as a real open tag.
findRootTag consumes that flat tag list and only skips tags literally
named script/style/meta/link/title, so the phantom <g> tag (not in
that skip list) wins the "first non-ignored body tag" search and gets
returned as the composition root instead of the real one that follows.
This manufactured root_missing_composition_id and root_missing_dimensions
(the phantom tag has neither) plus head_leaked_text (the leaked-text
scan slices up to the phantom tag's position, landing inside the
<style> block before its real closing tag, so the raw CSS text reads
as leaked markup) on an otherwise valid sub-composition — reported
with an exact bisected repro: a <template>-wrapped SVG sub-composition
whose <style> block comments reference an inner <g> element.
Fix: compute <style>/<script> content spans up front (reusing the
existing extractBlocks + STYLE_BLOCK_PATTERN/SCRIPT_BLOCK_PATTERN) and
skip any TAG_PATTERN match that falls inside one, before it ever
reaches findRootTag or any other extractOpenTags consumer. Same shape
as the prior fix for a leading <svg> defs block being mistaken for the
root (8ee4b7df) — this closes a sibling gap in the same function.
Test: new regression case with a <style> block containing a `/* <g> */`
comment ahead of an <svg data-composition-id> root, asserting none of
the three findings fire. Full lint package suite (318 tests) passes.
* feat(lint): flag duplicate data-composition-id values
Declaring data-composition-id on more than one element (commonly the <meta>
tag from the quickstart template AND the root <div> added to satisfy
root_missing_composition_id) is a silent collision: `compositions --json`
returns two entries for the same id (one duration:0) and inspect/snapshot
crash with "Cannot read properties of undefined (reading totalDuration)".
Lint passed clean through all of it.
New rule `duplicate_composition_id`: group elements by data-composition-id
value and error on any value shared by 2+ elements, naming the id and calling
out the meta-vs-root collision in the fixHint. 3 tests: dup fires, single id
passes, two distinct ids don't collide. (Implemented via Codex; verified
independently: 111 lint tests pass, oxfmt/oxlint clean.)
* fix(audits): avoid caption false positives
* fix(lint): ignore proxy-label tween overlaps
* fix(cli): preserve the five-percent text audit floor
* fix(lint): preserve proxy identity across lexical scopes
* fix(cli): audit only directly painted text
* fix(lint): compare live composition ids canonically
* fix(lint): preserve expanded proxy identities
* fix(cli): measure directly painted text geometry
* fix(lint): preserve first duplicate attribute value
* fix(lint): keep shared proxy identity across helpers
* fix(parsers): preserve expanded proxy identity
* fix(lint): decode composition IDs consistently
gsap_from_opacity_noop matched 'opacity: 0' as a prefix — an authored
'opacity: 0.98' triggered the rule. One boundary-anchored predicate now
owns the exactly-zero test for both block and inline declarations,
including a block's last declaration without a trailing semicolon.
domEditingDom now imports the grading contract attribute from core
instead of re-declaring the literal.
Brand-loop live test (SDS duplicate, plans/figma/brand-loop-test-plan.md)
proved the recolor chain end-to-end and surfaced three gaps:
- runtime now defines every declared composition variable as a CSS
custom property (document root at init + scoped sub-comp hosts in the
loader), so imported var(--slug, literal) fills resolve live — without
this the frozen literal always won and variable-driven rebranding
could not propagate. Slug kept byte-compatible with the figma
importer (parity test). render --variables overrides win.
- figma component --name: variant frames are often all named
'Platform=Desktop' and slug-collided across imports.
- imported fragments carry data-hf-snippet and the project linter skips
composition-root rules for them.
- /figma skill documents the field-tested non-Enterprise tokens path
(MCP get_variable_defs joined with REST boundVariables ids).
Shared-helper extractions (injectScopedStyles, flattenedRoot module,
parseHostVariableValues, rasterizeFallback, shapeCss) satisfy the
dedup/complexity audit the runtime changes tripped.
Validated live: brand-loop renders purple from the attribute alone (no
manual :root); 118 figma + 662 runtime/compiler + 331 lint tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* feat(engine): 3D projection + compositor-effect risk gate
* fix(engine): gate filter drop-shadow wherever blur gates (review)
detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only
detected blur( in its three scan paths — a drop-shadow comp stayed on the
fast path despite the gate's own correctness contract. Detect drop-shadow(
in computed styles, stylesheet rules, and GSAP tween vars, pinned by a
focused test that runs the real page-side closure against a DOM shim
(computed / stylesheet / tween coverage + blur regression + effect-free
null).
Addresses miguel-heygen's blocker on #1918.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension
# Conflicts:
# packages/engine/src/services/screenshotService.ts
* fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review)
Addresses miguel-heygen's blockers on #1919:
- HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to
a documented diagnostic flag: it exists for upstream-Chromium repro work
(gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect
classes; renders under it may be damaged BY DESIGN since it bypasses gates
whose thresholds encode measured damage. Never production; the safety-net
blank guard also stands down under it so diagnostic frames arrive unmodified.
- armStaticDedup is now idempotent: the drawElement init path arms dedup
before canvas injection, then initializeSession called it again — the
second run overwrote the armed state with skipReason="capture_mode"
(captureMode is "drawelement" by then), producing contradictory telemetry
(armed frames + a skip reason), and re-ran the verification seeks on the
fallback path. It now no-ops once staticFrames or a skip decision exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(producer): fast-capture render stages + remote bg-image localizer
* feat(lint,player): fast-capture lint rule + player media sync
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(lint): flag dir="rtl" on <html> as a confirmed silent render failure
Two independent reports diagnosed the same exact bug: dir="rtl" (or any
non-ltr value) on <html> renders correctly in preview/snapshot but
produces a fully blank/black video from render, with no other
lint/validate/inspect check catching it - output file size (far smaller
than expected) was the only tell for both reporters. Both independently
confirmed the same fix: drop dir from <html>, keep lang, and scope
direction: rtl to individual text-containing elements via CSS instead.
Could not empirically verify the render pipeline's own root cause in this
session (headless Chrome screenshot capture is unreliable in this
sandboxed environment - even a baseline, non-RTL capture timed out), so
this ships the safe, already-confirmed advisory rather than guessing at a
runtime fix. Both reporters explicitly asked for exactly this: "deserves
a lint rule or render-time warning."
* fix(lint): only flag valid non-ltr html dir values
Two independent post-release feedback reports of the same contradiction:
scene_layer_missing_visibility_kill / gsap_exit_missing_hard_kill tell you to
add `tl.set(selector, { visibility: "hidden" }, t)` on an exiting scene
element, but when that element is also class="clip", the exact tl.set they
recommend is then flagged by gsap_animates_clip_element (the framework
already owns visibility/display on clip elements). One report worked around
it by wrapping the scene's content in an inner non-clip div and asked that
the fix hint mention that pattern.
Both rules now detect when the exiting/flagged selector is a clip element
(scene_layer_missing_visibility_kill checks the tag's class list directly;
gsap_exit_missing_hard_kill reuses the clipIds/clipClasses maps already built
in its enclosing rule) and, only in that case, point at the inner-wrapper
pattern instead of a tl.set on the clip element itself. Non-clip targets are
unaffected — same fix hint as before.
Two independent post-release feedback reports of the same mechanism: a
leading <svg> block (icon/gradient/filter <defs>, referenced by url(#id)
elsewhere in the document) placed before the real [data-composition-id]
root manufactures root_missing_composition_id + root_missing_dimensions
on an otherwise-correct composition. Moving the <svg> after the root
cleared both findings for each reporter.
findRootTag returned the first body child that wasn't script/style/meta/
link/title, unconditionally — <svg> was never in that skip list, so a
leading defs-only <svg> got treated as the root.
Fix: skip a leading <svg> when it carries none of the composition markers
itself (data-composition-id/data-width/data-height), so an intentionally
SVG-rooted composition is still eligible as the root. The first attempt at
this only skipped the <svg> open tag, which surfaced a second bug:
extractOpenTags is a flat, nesting-unaware scan, so the very next tag it
returns after skipping <svg> is the svg's own nested child (<defs>,
<filter>, ...), not the sibling after </svg>. Track the svg's closing tag
position and skip every tag before it, not just the <svg> tag itself.
Tests: skips a leading svg defs block (no false root findings); still
treats an <svg> as the root when data-composition-id/data-width/
data-height are declared directly on it. Full lint suite (308 tests) passes.
WINDOW_TIMELINE_ASSIGN_PATTERN only matched window.__timelines["literal"]
or window.__timelines.prop, so registrations via a computed key like
window.__timelines[spec.id] (used by the code-particle-assemble and
code-3d-extrude registry blocks) went undetected. That made
gsap_timeline_not_registered false-fire on correctly registered timelines,
and let root_composition_missing_duration_source wrongly demand an
explicit data-duration on compositions that already have one.
Two independent post-release feedback reports of this rule hard-erroring
on OS system fonts (Hiragino Sans, Microsoft YaHei) that have no
downloadable file. Both asked for the same thing, in slightly different
words: a documented way to satisfy the check for a font that's genuinely
OS-bundled, not missing.
That way already exists and already works — extractFontFaceFamilies only
looks at the font-family declaration inside @font-face, never the src
value, so `@font-face { font-family: 'X'; src: local('X'); }` already
passes. One report found this themselves; the other didn't. The gap was
discoverability: the fixHint only described bundling a real font file, so
nobody would think to try `local()` unless they already knew about it.
Considered and rejected a broader fix: adding CJK system-font names to the
shared FONT_ALIAS_MAP (the mechanism that already exempts Latin system
fonts like Segoe UI/Verdana by aliasing them to a bundled fallback font).
That map has no CJK-equivalent bundled font to alias to (only Japanese has
one, noto-sans-jp) — aliasing "Microsoft YaHei" (Simplified Chinese) to a
Japanese font would silently swap in the wrong glyph shapes for shared Han
characters, and would specifically break distributed/Lambda rendering
(where system-font capture is disabled, per system_font_will_alias's own
comment) by removing the warning that currently prompts a real fix. The
local() message fix has none of that risk: it changes no detection logic,
only points at an already-correct existing escape hatch.
Tests: local() font-face no longer flags (proves the advice is accurate,
not just documented); fixHint contains "local(". 308 lint tests pass.
parseFloat('0.1') + parseFloat('0.2') = 0.30000000000000004, so authored
adjacencies whose sum is exact in decimal drift a few ulps and fire the
overlap rule under the strict compare.
Compare on end - start > 1e-6 instead — 11 orders above the worst observed
drift, 4 below one 60fps frame.
Renames the motion-surfacing tool from `hyperframes keyframes` to `hyperframes motion`,
renames the implementation from keyframes*.ts to motion*.ts (keeping the keyframe data
model name where still accurate), and renames the shipped skill from
hyperframes-keyframes to hyperframes-motion. Expands the skill from a command
reference into a full motion-design workflow: reading motion, 3D angle verification,
layered GSAP motion, one-shot reference reproduction, diagnostic checks, and
eval-derived craft guidance.
* fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional
The #2 render failure bucket ("Composition has zero duration") accounts for
~27K errors / ~7K affected users over 30 days (PostHog project 356858). Root
cause: only GSAP timelines got their duration auto-detected — CSS, WAAPI, and
Lottie compositions had no source of truth for total duration unless the
author remembered to set data-duration on the root element, and the render
engine hard-failed capture when neither was present.
Adds getInferredDurationSeconds() to the CSS, WAAPI, and Lottie runtime
adapters (packages/core/src/runtime/adapters/*.ts) — each reports the longest
finite end time it can discover from its own animations (CSS: computed
timing offset by data-start; WAAPI: effect.getComputedTiming().endTime;
Lottie: totalFrames/frameRate or the player's own duration). Infinite/
unbounded animations correctly return null and still require data-duration.
Wires this into the runtime's existing duration-floor resolution
(resolveAdapterDurationFloorSeconds in runtime/init.ts), alongside the
existing media-duration and authored-composition floors, so
window.__hf.duration becomes positive without any author action for
finite-duration non-GSAP compositions. Three.js is unchanged — no
AnimationClip/AnimationMixer inspection exists in that adapter, so
data-duration remains required there.
Tightens frameCapture.ts's zero-duration fast-fail gate to also check
hf.duration directly (not just the two authored signals), so a composition
mid-inference isn't fast-failed before its adapter-derived duration lands.
Adds a new lint rule (root_composition_missing_duration_source) that errors
only on genuinely non-inferable cases: no animation signal at all, Three.js
without data-duration, or an infinite/unbounded CSS or WAAPI animation
without data-duration. Deliberately silent on finite CSS/WAAPI/Lottie
animations, since the runtime now infers those — an autofix that "inserts
the inferred value" was considered and rejected: every case the rule flags
has no derivable value (an infinite spinner has no finite end time; a
duration-less Three.js scene has nothing to measure), so any autofix would
have to fabricate a placeholder, trading a loud correct failure for a silent
wrong-length render.
Updates the CSS/WAAPI/Lottie/Three adapter skill docs and the
hyperframes-core determinism-rules/data-attributes references to document
the new optionality and the runtime mechanism backing it.
Verified end-to-end against the real render pipeline (not just unit tests):
a CSS-only composition with a finite 3s animation, no GSAP timeline, and no
data-duration now renders a correct 3.000s MP4 via `hyperframes render`
(previously: "Composition has zero duration" failure). The infinite-CSS
negative control still fails fast with a clear diagnostic, matching the new
lint rule.
Adds a file-level fallow health exemption for lottie.ts's pre-existing
`seek` handler — unrelated to this change, but its line numbers shifted when
new functions were added earlier in the file, tripping fallow's
inherited-finding fingerprint (documented pattern already used elsewhere in
.fallowrc.jsonc for the same reason).
Known limitation: the static WAAPI usage detector in the lint rule
(/\.animate\(\s*[\[$A-Za-z_]/) can miss unusual call shapes; it only affects
whether the "no signal at all" branch fires, and errs toward NOT flagging
(reducing false positives) rather than over-flagging.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(lint): close 3 correctness gaps in root_composition_missing_duration_source
- Strip JS/CSS comments before scanning for GSAP/WAAPI/Three/Lottie/CSS
animation signals, so a commented-out `.animate()` call or a commented
`animation: ... infinite` rule can no longer satisfy the "has a duration
source" check and mask a real zero-duration render failure.
- Broaden the WAAPI detection regex to also match the object-literal
(PropertyIndexedKeyframes) form of `.animate()`, e.g.
`el.animate({ opacity: [0,1] }, { duration: 2000 })`, which the previous
character class silently missed. Corrected the adjacent comment that
incorrectly claimed this shape "can't be a false negative".
- Fix hasInfiniteCssAnimation to stop false-positiving on animation NAMEs
that merely contain the substring "infinite" (e.g. `infinite-spin`) by
anchoring the `infinite` keyword with hyphen-aware boundaries instead of
a bare `\b`. Also makes the longhand `animation-name` + separately
declared `animation-iteration-count: infinite` pattern detected
consistently.
Adds targeted unit tests for each fixed false-positive/false-negative.
* fix(runtime): keep finite duration signal when an unbounded animation coexists
getInferredDurationSeconds in the CSS and WAAPI adapters returned null
outright whenever any animation on the composition was unbounded
(infinite iteration count), even when other finite animations on the
same composition could still supply a valid duration. This disagreed
with the new root_composition_missing_duration_source lint rule, which
treats any animation-name as sufficient — so a composition mixing a
finite fadeIn with a decorative infinite spin passed lint but still
failed at render with "zero duration".
Unbounded animations are now skipped when computing the max end time
instead of short-circuiting the whole calculation. null is only
returned when every animation on the composition is unbounded, i.e.
there is no finite signal to fall back on at all.
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(skills): fix table separator width in data-attributes.md
oxfmt flagged the merged Composition Root table from the post-rebase
merge of the auto-infer-duration docs onto main's reformatted table —
the separator row was one dash short of the header width.
* fix(lint): keep infinite-CSS duration rule strict but make its message honest
Post-review (Vance): after the finite+infinite adapter fix, the runtime infers
a length for a mixed finite+infinite CSS composition, but this lint rule still
(intentionally) errors on it — an unbounded animation makes the intended total
length ambiguous, so we require explicit data-duration. Keep that strictness
(lint is advisory by default; it only blocks under --strict, and data-duration
is the one duration signal guaranteed correct across every adapter, known and
future). But the message wrongly claimed the render "will fail" — false for the
mixed case, where the runtime falls back to the finite animation. Rewrite it to
describe the ambiguity honestly, correct the rule's block comment, and add a
mixed finite+infinite test asserting it still errors with an honest message.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions
The #1 render failure bucket in production telemetry (PostHog project 356858,
dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K
occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent
authoring flows) is a `data-composition-src` reference pointing at a scene
file that is empty, malformed, or missing.
Root cause, traced end-to-end:
- The literal error "Composition HTML is empty or could not be parsed: <path>"
is real (not a PostHog paraphrase) — thrown by a since-reverted guard in
packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to
a silent skip in #1678 to avoid aborting renders on partial content during
authoring. #1629 added per-assembler guards for 3 skill workflows
(product-launch-video, faceless-explainer, pr-to-video), but general-video
and hand-authored flows — where the dominant filename `scene-title.html`
(40K+/68K of the bucket) originates — have no assembler and thus no guard.
#1678 assumed the assembler guards from #1629 covered this pre-render; they
only covered 3 of the many authoring flows.
- On current `main`, an empty/malformed data-composition-src file no longer
crashes or throws during render — it's silently dropped by the tolerant
inliner. Reproduced locally: `hyperframes render` on a project with an
empty scene-title.html "succeeds" after ~93s (two 45s
pollSubCompositionTimelines timeouts) with the scene silently missing from
the output video. `hyperframes validate` also reports "No console errors"
for the same broken project.
- The raw `Cannot destructure property 'firstElementChild' of
'documentElement' as it is null` crash reproduces directly against
linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in
the real CLI runtime) for empty and non-HTML input — confirmed with a
standalone repro script, not just inferred. jsdom/happy-dom (used in this
repo's own test environment) are spec-compliant and never produce a null
documentElement, which is why this needed a linkedom-specific test file.
Fix:
- New shared helper `checkSubCompositionUsability`
(packages/core/src/compiler/subCompositionValidity.ts) is the single
source of truth for "is this data-composition-src file usable" — mirrors
the inliner's own parse/template/body logic so all callers agree.
- `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared
helper internally but keeps its #1678 tolerant skip-and-continue behavior
unchanged — mid-authoring iteration on a partial project must keep
working. `onMissingComposition` now also receives a human-readable reason.
- New render-only pre-flight (`assertSubCompositionsUsable` in
packages/producer/src/services/htmlCompiler.ts) walks every
data-composition-src reference (including nested ones, root-relative,
matching parseSubCompositions' own resolution) before any compilation
work starts, and throws naming every offending file at once. This is
unconditional — not gated behind --strict — because a render that
silently drops a scene is strictly worse than one that refuses to start.
Confirmed locally: render now fails in ~0.4s with an actionable message
instead of "succeeding" after 93s with a missing scene.
- New `hyperframes lint` rule `missing_or_empty_sub_composition`
(packages/cli/src/utils/lintProject.ts) surfaces the same check as a
file-scoped, actionable lint error (already unconditional — lint exits 1
on any error).
- `hyperframes validate` now also runs this check before launching a
browser, so it no longer reports "No console errors" for a project with a
broken sub-composition.
- `packages/core/src/parsers/htmlParser.ts`: guarded every
`documentElement`-may-be-null access (parseHtml, updateElementInHtml,
addElementToHtml, removeElementFromHtml, extractCompositionMetadata,
validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or,
for validateCompositionHtml's collect-and-report contract, a typed
validation failure) instead of a raw crash.
Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested
sub-compositions (both happy path and broken-grandchild), and the happy path
— at the shared-helper, lint, and render pre-flight layers.
Not changed: the AI-agent authoring skills (skills/*). general-video and
hand-authored flows have no assemble-index.mjs equivalent to guard, so the
fix is at the CLI/render layer instead — flow-agnostic, covers every
authoring path, and the skills' existing "run lint/validate and stop on
failure" guidance now actually catches this class of mistake once run.
Not run in this environment: the producer package's full regression-harness
test suite (`bun test` in packages/producer) — it performs heavy real
rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and
did not complete in a reasonable time in this sandbox. Verified instead via
the targeted test file for all touched code (76/76 passing), whole-repo
typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code
gate, clean), and manual end-to-end CLI runs (render/lint/validate) against
reproduction projects, including a nested sub-composition scenario. CI
should run the full producer suite before merge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(parsers,lint): port empty-composition pre-flight to extracted packages
Rebased onto main, which extracted @hyperframes/lint from core (lint depends
only on parsers, not core). Relocate checkSubCompositionUsability from core to
@hyperframes/parsers so both core (inliner) and lint can consume it without a
core<->lint cycle; core keeps a @deprecated re-export shim.
Correctness fixes from code review:
- checkSubCompositionUsability now returns "no-composition-root" when the
<template>/<body> content has no [data-composition-id] element (previously
a marker-free placeholder body passed both guards).
- lint's missing/empty sub-composition rule now only checks files reachable
via data-composition-src from the root (matching render pre-flight), instead
of a raw filesystem walk that false-positived on orphaned files.
- drop `as string` cast in inlineSubCompositions in favor of an explicit
null guard (per CLAUDE.md).
Review-comment items:
- move EmptyCompositionError JSDoc above the class (was above the adapter fn).
- correct stale circular-ref comment to match actual silent-skip behavior.
- rewrite self-contradicting lint message ("silently drop") to describe the
new loud render-pre-flight abort.
- add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so
it agrees with lint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
The missing_three_script rule only treated a bare import from 'three' as
loading Three.js, so ESM imports whose specifier is a URL or path (e.g.
.../+esm CDN builds, esm.sh/three, unpkg three.module.js, or a local
three.module.js) were not recognized. Compositions using THREE. with such
an import got a false blocking error, pushing authors onto the deprecated
UMD global build.
Generalize the module-import detection to count any import/from whose
specifier contains "three" (case-insensitive), matching the existing
loose /three/i treatment of <script src>. Bare 'three', importmap, and
<script src> paths are unchanged; the specifier must still contain
"three", so unrelated imports do not satisfy it.
The font_family_without_font_face rule raised a blocking error on two
legitimate, very common cases:
1. The `-apple-system, BlinkMacSystemFont` system-ui stack. These two
tokens are the cross-browser incantation for the platform UI font
(synonyms of `system-ui`); they name no font file, so demanding an
@font-face for them is wrong. They appear in almost every CSS reset.
2. `font-family: var(--x)` indirection. The shared family extractor took
the literal `var(--heading)` as a font name. The linter cannot
statically resolve a custom property, so it must not flag it.
Fix at the root: add the two system-ui synonyms to GENERIC_FAMILIES, and
skip any parenthesised (function) token in the shared family extractor so
both this rule and system_font_will_alias stop misreading var(). A real
undeclared font sitting in the same stack is still flagged.
* fix(lint): stop overlapping_gsap_tweens flagging distinct unresolved targets
The GSAP parser assigns the sentinel `__unresolved__` to any tween whose
target it cannot statically resolve to a concrete element (a computed
variable, a helper call, etc.). The overlap check compared tweens by that
target string, so two tweens aimed at completely different elements via
unresolvable selectors (e.g. `#s0 .hl .w` and `#s1 .hl .w` produced by a
helper) both collapsed to `__unresolved__` and were reported as
overlapping, a false positive.
An unresolved target is an unknown element: two of them are not provably
the same element, so an overlap between them cannot be asserted. Skip
overlap analysis when the target is the sentinel. Genuine overlaps on a
resolved element are still flagged.
* fix(lint): guard gsap_exit_missing_hard_kill against the unresolved-target sentinel
The overlap rule already skips tweens whose target collapses to the
__unresolved__ sentinel, but the sibling exit rule in the same file did not.
A scene-boundary exit on an unresolved target could emit a finding like
GSAP exit on "__unresolved__" ... with a meaningless tl.set("__unresolved__", ...)
fix hint. An unresolved target is an unknown element: you cannot assert a
missing hard kill on it, so skip the window early in the loop, mirroring the
overlap rule. Exits on resolved selectors are still flagged.
* fix(skills): storyboard review — bg-on-clip rule, slideshow output, parser parity guard
Addresses the storyboard-angle review (jrusso1020):
- M1 (invisible text): frame-worker.md (x3) + SKILL.md Step 5 (x3) now require a
frame's full-bleed background on a class=clip layer, never the #root /
data-composition-id element (the root is clip-gated to its scene window, so a
background on it is not a dependable ground and dark text can land on the black
host body). The assembler already paints frame.md's canvas onto index #root as
the base ground; the per-frame clip rides on top.
- B3 (slideshow truncates to slide 1): slideshow/SKILL.md gains an Output section
(decks render via 'present'; 'render index.html' captures only the first
composition; linear main-line MP4 export is deferred).
- Parser drift: vendoredParity.test.ts guards the three vendored storyboard.mjs
copies (byte-identical + parse-parity with @hyperframes/core).
- skills-manifest.json regenerated for the edited SKILL.md files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): storyboard review — lint, validate help, snapshot, inspect, capture, render
Addresses the CLI findings from the storyboard-angle review (jrusso1020):
- lint (@hyperframes/lint): accept vendor-prefixed system-font keywords
-apple-system / BlinkMacSystemFont so a system stack with a generic fallback no
longer trips font_family_without_font_face (+ test).
- help: list 'validate' under Project in 'hyperframes --help' (was runnable but
undocumented).
- snapshot: honor -o/--output (the flag did not exist; output was hardcoded to
snapshots/). The dir is resolved once and threaded through capture + contact
sheet + Gemini.
- snapshot: split font status into loaded / error / unused with a one-line
summary; only a real 'error' is reported as FAILED (an unrequested @font-face
is 'unused', not a contradiction with 'loaded').
- inspect: suppress text_occluded across a scene-to-scene crossfade (occluder in
a different data-composition-id mount while a scene is mid-fade); a same-scene
or two-settled-scenes overlap still flags.
- inspect: suppress content_overlap between in-flow siblings governed by the same
flex/grid container (tight stacks / number lockups are layout slop).
- capture: record source resolution (videoWidth/Height) in video-manifest.json
alongside the DOM display box; consumers size off the source dims.
- render: warn when the target carries a slideshow island (render captures only
the first scene, so the MP4 is truncated to slide 1; use 'present').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>