Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling
WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload
Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
## What
Brief description of the change.
## Why
Why is this change needed?
## How
How was this implemented? Any notable design decisions?
## Test plan
How was this tested?
- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
* fix(skills): audit descriptions — trim routing prose, fix stale facts, add missing triggers
Descriptions are the always-loaded routing tier; this audit rebuilds them on
one principle: discriminate by input shape, not pipeline internals.
- Trim creation-workflow descriptions to positive trigger + nearest-neighbor
disambiguation + /hyperframes escape hatch; full routing prose already
lives in each skill body's route-confirm block and the router
- Codify the workflow-vs-domain split as ownership (owns the end-to-end
deliverable vs capability layer pulled in mid-flight) in /hyperframes,
and widen "make me a video" framing to deck / composition port
- Fix stale facts: embedded-captions identity count (desc 32, body 17 →
actual 36 = 10 classic + 26 themed), six→ten visual languages, retired
RVM/Standard wording in router details, figma shader transport
(MCP → MCP source / native export), keyframes "cursor demos" (no backing
content), hyperframes-media scripts/audio.mjs leak
- Register missing capabilities: motion-graphics maps category (was in
categories/ but absent from its own table, description, and router),
asset-fusion + news triggers, slideshow page-to-deck + presenter mode,
general-video editing, talking-head-recut 16:9/9:16/4:5 canvas,
cli feedback + lambda sites, product demos, mood-brief BGM generation
- website-to-video: relabel promo-shaped video types to keep the promo
boundary with /product-launch-video; drop headless-Chrome wording
- music-to-video: lyric timing via /hyperframes-media transcription or
user-supplied lyrics, placed on the beat grid
- Sync catalogs in lockstep (CLAUDE.md, AGENTS.md, README,
docs/guides/skills.mdx, CLI project templates): add music-to-video +
slideshow entries, complete the domain-skill lists, and extend the
catalog-maintenance rule to cover AGENTS.md and the templates
Validated with a 35-case description-only routing eval: 35/35 both before
and after the rewrite (including new maps / asset-fusion / news probes).
* fix(skills): post-media-v2 consistency — stale media ref, router figma wording, catalog rows
- music-to-video: lyric transcription now routes to /media-use (the
retired /hyperframes-media was still referenced)
- router capability map: figma row gains the shaders fact (MCP source /
native export), matching the SKILL.md source of truth
- media-use catalog rows (CLAUDE.md, README, docs/guides/skills.mdx):
add image models + captioning, aligning with the v2 description
- catalog rule #1: root AGENTS.md carries the workflow list only (it
has no domain-skill section) — rule wording now says so
* feat(cli): associate signed-in HeyGen account with telemetry
Sign-in telemetry currently attributes everything to the anonymous
install id, so the sign-in funnel can be counted but a completed sign-in
can't be tied to the account it produced. This associates the two.
- On a completed sign-in, emit a PostHog `$identify` alias whose
`$anon_distinct_id` is the install's anonymousId, so events recorded
before sign-in stitch to the same person, and tag `auth_login_completed`
with the account identity (the pre-plumbed `distinctId`).
- `/v3/users/me` exposes no opaque user_id, so the identity key is the
account email, falling back to username (single `identityKey` helper).
- Both no-op under the `telemetry disable` opt-out and only fire after
the user chooses to sign in.
Privacy disclosure updated in lockstep, since this is the first PII the
CLI attaches: the first-run telemetry notice and the telemetry section
of docs/packages/cli.mdx now state that signing in links your account
email to your usage.
Tests: identifyUser payload + no-op, completion attribution incl.
username fallback and no-identity-on-reject/empty. Verified end-to-end
against the built CLI: pre-auth events anonymous, $identify carries
$anon_distinct_id, completion carries the account email.
* docs(cli): disclose the username identity fallback
Review gating item: identityKey is `email ?? username`, but the
first-run notice and cli.mdx said only "email", so an emailless
account's username would reach PostHog undisclosed. `/v3/users/me`
treats email as optional (pickString), so the fallback is live code,
not dead — disclose it rather than assert an unverifiable email
guarantee. Both surfaces now say "email, or username if the account
has no email".
Also soften the identityKey comment: it implied username is "less
identifying", but HeyGen usernames are often email-shaped, so the note
now states username is a fallback, not a privacy win.
* feat(engine,producer,cli): verify video comps via deferred DE init + capture p50
Closes the two biggest gaps in the first day of v0.7.38 wild data: 88% of
drawElement renders (video comps initialized via probe sessions) ran with
self-verification unarmed, and speedup was measurable on only 3 of 76 renders.
- Deferred drawElement init: probe sessions initialize before video
extraction, so they have no frame injector — ground-truth screenshots
would capture black <video> boxes, and verification skipped the whole
comp. DE init now stops after the gates for injector-less video comps
(deInitDeferred; autoAlpha flag retracted in case no path completes it)
and completeDeferredDrawElementInit finishes verification + canvas
injection + worker-encode at capture time, once
prepareCaptureSessionForReuse has attached the injector. Validated
end-to-end: a probe-path video comp now arms 4 ground-truth frames with
real video pixels (3x inf + 64.7dB) and renders drawElement verified.
- capture_p50_ms: per-frame capture durations are sampled
(capturePerf.frameMs; batch frames get the batch mean) and the median
ships as CapturePerfSummary.p50TotalMs -> RenderPerfSummary.captureP50Ms
-> render_complete capture_p50_ms. Unlike capture_avg_ms it is immune
to first-frame warmup and stage-setup amortization — smoke: avg 15ms vs
p50 8ms on the same render, p50 matching the measured steady-state
floor. Dashboard speedup tiles can drop their frame-count floor once
this ships.
- video_count on render_complete: segments speedup by video-injection
comps (whose per-frame gain is legitimately lower) vs pure-graphics.
Canary suite 7/7; engine suite 905 passed (1 pre-existing upstream
failure); tsc/oxlint/oxfmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(producer): complete deferred drawElement init on the disk capture path
Review (miga): a probe-initialized video comp falling back to the disk path
kept deInitDeferred and silently stayed in screenshot mode — a regression
for PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true renders that previously ran
drawElement there. Complete the deferred init on the sequential disk path
under the same explicit-opt-in test the orchestrator clamp uses; default-on
renders stay on the screenshot baseline (this path has no drain-time
self-verification, per the #1998 confinement rule).
Validated: video comp + PRODUCER_ENABLE_STREAMING_ENCODE=false + explicit
opt-in logs "(deferred drawElement init)" completion on capture_disk and
renders correct video pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): align easing docs with motion doctrine, add baked springEase
The easing adapter contradicted the workflow doctrine: it taught a
power2.out entrance default and pitched back/elastic as playful
defaults, while motion-language.md says power3 / smooth-beats-bouncy.
A worker following the adapter produced exactly the flat, cheap motion
users complain about.
- gsap-easing-and-stagger: power3.out becomes the documented house
default; back/elastic/bounce capped as RARE playful-only; new
"Spring Eases (baked physics, seek-safe)" section — closed-form
damped-spring ease springEase(response, dampingFraction), measured
damping ladder, response/duration table, craft notes
- gsap-timeline-and-labels: last leftover power2.out default -> power3
- spring-pop-entrance: exact-physics option (zeta=1) for the settle;
playful variant now prefers spring zeta 0.6-0.7 over back.out
- motion-language x3 (product-launch-video, faceless-explainer,
pr-to-video): doctrine "Smooth beats bouncy" wired to the baked
springEase — real physics, same doctrine, not a license for bounce
Verified with node + GSAP 3.15: zeta=1 strictly monotone with zero
overshoot; scrambled out-of-order seeks return bit-identical values;
ease(0)=0 and ease(1)=1 exact; snippet re-extracted from the published
markdown and re-run.
* chore: format README.md (landed unformatted on main; unblocks the repo-wide Format check)
The media-use v2 row is the widest cell in the domain-skills table; oxfmt
re-pads every other row's trailing pipe to match. The merge added the row
without re-aligning, so oxfmt --check flagged README on CI (linux). Content
is unchanged; only trailing whitespace in 10 table rows.
* feat(cli): forward feedback submissions to backend endpoint
* fix(cli): truncate feedback fields to backend caps + ack before forwarding
Addresses PR review (via):
- Truncate comment (2000) / cli_version (100) / env (500) to the backend DTO
caps before POSTing, so a pasted stack trace is forwarded truncated instead
of rejected with a 422 the best-effort path swallows silently.
- Print "Thanks for the feedback!" before the best-effort forward so the ack
isn't blocked behind the (bounded) network call.
* fix(cli): type feedback fetch mock
Default-on drawElement ships with a runtime self-verification net (#1998);
this makes its in-the-wild behavior observable. Every render_complete event
now answers: which capture mode actually ran, why drawElement disengaged
when it did (compile gate / producer clamp / engine init gate), whether the
self-verify net fired and why, and how much margin verification had.
Follows the static-dedup telemetry pattern: engine session fields →
CapturePerfSummary → RenderPerfSummary.drawElement → snake_case props on
render_complete.
New event props: de_capture_mode, de_compile_gate, de_clamp_reason,
de_gate_reason, de_worker_encode, de_verify_armed, de_verify_checked,
de_verify_min_db (margin above the 32dB threshold — drift here is the
early-warning signal before fallbacks spike), de_verify_init_ms,
de_self_verify_fallback, de_fallback_reason, de_blank_suspects,
de_blank_deterministic_accepts, de_blank_recaptures, de_boundary_frames,
de_ncpr_fallbacks.
Validated end-to-end on live renders: drawelement path reports mode/verify
counters/minDb/init cost; a blur-gated comp reports mode=screenshot +
gate_reason=css_effect:filter; a forced verification failure reports
self_verify_fallback=true + fallback_reason=psnr.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
validate served the composition over a loopback origin and let headless
Chrome fetch remote <img crossorigin>/@font-face assets cross-origin, while
the render pipeline downloads them to disk first. Buckets whose CORS
allowlist omits the loopback origin then failed the CORS-mode request with a
false net::ERR_FAILED that never occurs in the real render, pushing authors
(and agent pipelines) to delete crossorigin — which disables WebGL
color-grading/shaders for that asset.
Reuse producer's localizeRemote{Media,Image,FontFace}Sources in validate,
downloading into a temp dir served as an extra static-server asset root
(project dir untouched, cleaned up after). validate now matches render.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Field feedback from a raw-API agent build (join-the-world-flow): the
catalog blurb's word 'animatics' encodes the PNG-slideshow architecture
the skill body explicitly forbids — an agent routing by the blurb
concludes the shipped behavior is frames-as-pictures. Reworded to
'reconstructed motion (frames read as states, not slides)' across all
catalog surfaces (skill frontmatter, CLAUDE.md, README, skills.mdx,
hyperframes router, figma guide).
Also codifies the stronger doctrine that build demonstrated as
storyboard rule 10: when every frame is the same product UI in
successive states, rebuild the app as live DOM (Phase-3 for stateful
parts, real pixels for static chrome — code what changes state, freeze
what doesn't) and perform frame deltas as interactions instead of
tweens. Spec §5.1 records the escalation + field origin.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine,producer): drawElement fast-capture default-on with runtime self-verification safety net
Flip useDrawElement + worker-encode defaults on (HF_DE_BATCH default 4),
clamped in resolveConfig to hosts where drawElement can engage (macOS +
hardware-GPU browser) so page-side shader compositing is untouched
everywhere else; explicit env opt-in keeps attempt-and-gate semantics.
Safety net makes default-on safe: the compile/init gates catch predictable
incompatibility; this catches the intermittent residue no static analysis
can see (stale paints, dropped background images, transient blank frames).
- engine: captureDeVerificationFrames — K=4 (HF_DE_VERIFY) ground-truth
screenshots at init, after gates + armStaticDedup, BEFORE canvas
injection (post-injection screenshots show the canvas bitmap, not the
DOM). Runs the video-injection hook per sample; double-captures so
rAF-driven text counters settle (a single immediate screenshot captures
stale text and false-positives). Skips png, <10 frames, implausible
__hf.duration (infinite-repeat GSAP sentinel).
- producer: guardFrame on both worker-encode drains — rolling-median blank
guard with retry-once at drain (byte-identical retry ⇒ deterministic dark
frame, accepted; retry save/restores the static-dedup anchor) + ffmpeg
PSNR self-verify vs ground truth (HF_DE_VERIFY_MIN_DB, default 32dB;
natural agreement ≥45dB, damage ≤25dB). Breach dumps the frame pair to
tmpdir and throws DrawElementVerificationError.
- orchestrator: one-shot retry — on verification error the whole render
re-runs with forceScreenshot (slower, never wrong); telemetry flag
deSelfVerifyFallback.
- tooling: de-canary-suite.sh (7-comp release gate with expected verdicts),
de-gatecheck.sh (init-only corpus routing classifier), we-render.mjs.
Validated: canary suite 7/7; 611-comp routing sample 54% drawelement /
37.5% gated / 8.3% comp-defect; 12/12 risk-band renders clean on bare
defaults (48/48 verify samples); engine suite 888 passed; caught two real
intermittent damage classes in the wild (background-image drop, root-props
offset) that previously shipped silently.
Kill switches: PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false,
HF_DE_WORKER_ENCODE=false, HF_DE_BATCH=0, HF_DE_VERIFY=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(engine,producer): harden the drawElement self-verification net (max code-review findings)
15 confirmed findings from the adversarial review of the default-on flip;
the load-bearing five:
- Ground-truth capture no longer scrubs GSAP state: seek(0) + forced frame
FIRST (lazy .from()/overlap tweens record start values on first seek —
mid-timeline scrubs corrupted them for the whole render, and since DE
frames and truth shared the corruption, PSNR passed on damaged output),
then ascending even-spread fractions, page left at frame 0.
- Default-on drawElement is confined to the verified path: resolveConfig
requires worker-encode (the drain that runs the net), the orchestrator
disengages the default when the render takes the disk path or parallel
capture (no drain verification there), and closes a drawElement-initialized
probe session rather than letting the unverified path reuse it. Explicit
PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true keeps old attempt-and-gate behavior.
- Blank-frame retry can no longer splice wrong-frame pixels: recapture goes
through recaptureDrawElementFrameForVerify — no static-dedup shortcut
(lastEncodeResult runs ahead of the drain) and no "No cached paint record"
screenshot fallback (post-injection that captures the canvas = the LAST
drawn frame); any recapture failure falls back the whole render.
- Verify indices derive from the producer-resolved duration
(CaptureOptions.compositionDurationSeconds) instead of raw __hf.duration,
so samples always land inside the drained range.
- The platform clamp accepts "auto" GPU mode — the stock CLI resolves auto,
and the literal-"hardware" clamp made default-on a no-op for the primary
audience (masked in validation by explicitly-set env).
Also: NaN-safe env parses (HF_DE_VERIFY / HF_DE_VERIFY_MIN_DB / HF_DE_BATCH);
video comps skip verification when the session has no frame injector (probe
sessions — black-video truth false-positived); psnr infrastructure failures
skip the sample instead of failing the render; boundary-saturated sample
indices are skipped; shader-transition comps prefer page-side compositing
over default drawElement and compile-gated comps get page-side compositing
restored; observability.clearFailure un-brands the recovered first streaming
attempt; canary suite exempts known-marginal "any" comps from the cross-path
PSNR gate; dead we-render options removed; clamp tests pin their env.
Validated: canary suite 7/7; auto-GPU bare render engages the full stack;
disk-path and worker-encode-off renders disengage default drawElement;
malformed HF_DE_VERIFY_MIN_DB still verifies at the default threshold;
engine suite 890 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(engine,producer): review fixes — PSC intent, verify-threshold clamp, fail-closed canaries
Addresses miguel-heygen's review on #1998:
- Page-side compositing restore preserves explicit caller intent (blocker):
resolveConfig now records pageSideCompositingAutoDisabled only when IT
turned page-side compositing off because drawElement was on; the
compile-time drawElement gates restore page-side compositing only when
that flag is set. An explicit enablePageSideCompositing:false from the
programmatic API or HF_PAGE_SIDE_COMPOSITING=false stays off. Pinned by
two config tests.
- HF_DE_VERIFY_MIN_DB clamped to [10, 60] with a warning on out-of-range
values: below ~10dB the check passes severe damage; above ~60dB natural
encoder differences force a screenshot fallback on every verified render.
- de-canary-suite.sh + de-gatecheck.sh run under set -euo pipefail with
explicit `|| true` on expected-nonzero commands (render exits handled by
the suite's own checks, grep no-match, kill/pkill/wait races) and a hard
FAIL when the PSNR compare produces no value — release canaries fail
closed. Full suite re-run green (7/7) under the new flags.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Amortizes per-frame CDP protocol overhead (~3.5-9ms/frame) by looping
seek -> paint-wait -> drawElementImage -> createImageBitmap in ONE
page.evaluate for runs of consecutive frames; bitmaps still post to the
encode worker per frame. Validated on 19 stratified DE comps: median
1.20x on top of worker-encode (to 1.56x), zero damaged frames, edge
comps (static-dedup-heavy, clip-cut) bit-identical; mid-batch failure
re-captures via the per-frame path (screenshot-fallback semantics
preserved). Off by default; opt in with HF_DE_BATCH=4.
Co-authored-by: Claude Opus 4.8 <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(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
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* chore(ci): ignore drawElementService exports pending upstack consumers
Fallow's per-PR audit diffs against the merge base, so the bottom of the
fast-capture stack (#1917) sees drawElementService's exports as unused —
their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports
entry documents this and can be dropped once #1919 merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* chore(ci): ignore drawElementService exports pending upstack consumers
Fallow's per-PR audit diffs against the merge base, so the bottom of the
fast-capture stack (#1917) sees drawElementService's exports as unused —
their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports
entry documents this and can be dropped once #1919 merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engine): drawElementImage capture service
* chore(ci): ignore drawElementService exports pending upstack consumers
Fallow's per-PR audit diffs against the merge base, so the bottom of the
fast-capture stack (#1917) sees drawElementService's exports as unused —
their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports
entry documents this and can be dropped once #1919 merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Base of the studio UX-review stack (148 findings audited across the studio; 13 critical). This PR hardens the shared `components/ui` primitives that every later PR in the stack builds on.
## Changes
- **Button / IconButton**: visible `focus-visible` outline (studio accent); `disabled:pointer-events-none` removed (replaced with `disabled:cursor-not-allowed`, hover/active gated behind `enabled:`) so disabled buttons can host explain-why tooltips.
- **Tooltip**: keyboard support (`onFocus`/`onBlur` triggers), `role="tooltip"`, Escape-to-hide, viewport flip (top↔bottom) + horizontal clamping. API unchanged — all ~28 call sites unaffected.
- **HyperframesLoader**: `role="status"` on the loader; determinate track is a real `role="progressbar"` with `aria-valuenow/min/max` (was `aria-hidden`).
- **VideoFrameThumbnail**: error event resolves to a static fallback-label tile instead of an infinite shimmer; `motion-reduce` guard.
- **NEW `useDialogBehavior`**: shared modal contract — document-level Escape, Tab focus trap, focus-first-on-open, focus-restore-on-close, `canClose()` veto for dirty-draft guards. Adopted by every modal later in the stack.
- **NEW `SearchInput`**: shared search primitive with required `aria-label`, panel-input token style (kills the two-divergent-search-styles inconsistency in the sidebar).
- **studio.css**: `hf-toast-in/out` + `hf-backdrop-in` keyframes with `prefers-reduced-motion` guards (the previous `animate-in fade-in` classes were dead — no tailwindcss-animate plugin exists).
## Verification
- oxlint 0 errors, oxfmt clean, `tsc --noEmit` clean at stack top
- Full studio suite at stack top: 1189 tests pass
## Stack
PR 1/7 of the studio UX-review fixes. Merges bottom-up; the stack top is fully green (tsc + 1189 tests). Some shared-file edits span PRs, so intermediate branches may not typecheck in isolation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## drawElement fast-capture — config + CLI flag (stack 1/6)
Foundation layer for the drawElement fast-capture feature: the config surface and CLI/Docker plumbing that the rest of the stack builds on.
### What this adds
- **`packages/engine/src/config.ts`** — new config fields for fast capture: `useDrawElement` / `enableDrawElementWorkerEncode` (macOS-GPU `drawElementImage` capture + worker-offloaded JPEG encode), resolved from env in `resolveConfig` (env `HF_DE_WORKER_ENCODE`). Wired alongside main's existing `staticFrameDedup` (unified downstream in 4/6).
- **`packages/cli/src/commands/render.ts`** — `--experimental-fast-capture` flag → sets `experimentalFastCapture`; `--debug` passthrough.
- **`packages/cli/src/utils/dockerRunArgs.ts`** — pass the fast-capture env through to the container.
- **`.github/workflows/fast-video-validation.yml`** — CI job validating fast-capture renders.
- `.oxlintrc.json` / `.fallowrc.jsonc` — ignore-pattern housekeeping for the new paths.
### Notes
- Config-only + entrypoint; no capture behavior yet (that's 2/6–4/6).
- Tests: `config.test.ts`, `dockerRunArgs.test.ts` added.
---
**Stack (drawElement fast-capture, rebased onto current `main`, supersedes #1295 + #1444):**
1. **#1916 config + CLI** ← you are here
2. #1917 drawElementImage capture service
3. #1918 3D projection + compositor-effect risk gate
4. #1919 frame-capture core (routing, worker-encode, static-dedup unification)
5. #1920 producer render stages + remote bg-image localizer
6. #1921 lint rule + player media sync
⚠️ Intermediate PRs (1–5) are split by package boundary for review and **do not each compile independently** (cross-file deps); the complete feature is green at the stack tip (#1921) — tsc-clean on engine + producer, 231 tests pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The timing compiler scanned raw HTML with tag regexes that weren't
comment-aware, so a comment or script merely mentioning `<video>`/`<audio>`
was rewritten as a real element — injecting id/data-start/data-hf-auto-start
into the comment text. That phantom attribute then tripped the probe stage's
substring check (`html.includes("data-hf-auto-start")`), launching an
unnecessary browser probe on every render with an unexplained empty reasons list.
- Mask comments, <script>, and <style> regions before the tag scan, then
restore them verbatim (compileTimingAttrs, extractResolvedMedia).
- Replace the probe's substring match with a DOM query
(video[data-hf-auto-start]) and add "auto-start video(s)" to the reasons list.
* 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
* feat(cli): emit sign-in lifecycle telemetry
The CLI tracks command and render lifecycles but emits nothing for
`auth login`, so sign-in outcomes are invisible on the observability
dashboards — a completed sign-in, an abandoned browser flow, and a
rejected key all look identical (absent). This leaves a blind spot in
the same funnel the render events already cover.
Add three events mirroring the existing `trackX` pattern:
- auth_login_started (method: oauth | api_key)
- auth_login_completed (method)
- auth_login_failed (method, reason)
`reason` is a fixed low-cardinality enum (flow_error / no_credential /
rejected / invalid_input). No token, key, identity, email, or free text
is ever attached — consistent with the existing anonymous telemetry and
the `telemetry disable` opt-out. Wired into both the OAuth and
--api-key paths in `auth login`, with unit coverage for the new events.
* fix(cli): close sign-in telemetry funnel dropout gaps
Follow-up so `started` reconciles to `completed + failed` on the common
abandonment paths, which the first cut missed:
- Interactive prompt cancel (Ctrl-C) now surfaces as a throw that the
single catch in the api-key path records as `aborted`, instead of a
bare exit with no event.
- A stdin read that times out in non-TTY `--api-key` mode now records
`aborted` before the error propagates, rather than exiting silently.
- OAuth split: a timed-out browser callback (user closed the tab) is
tagged `flow_timeout`, separated from real `flow_error` (IdP/network),
since the walk-away timeout is the dominant non-error dropout.
Also pre-plumb an optional `distinctId` on the three trackers, mirroring
trackRenderComplete/trackRenderError. Unused today; it lets a later
identity-level attribution be a one-line callsite change rather than a
signature sweep. Coverage added for the new reasons and forwarding.