Commit Graph
505 Commits
Author SHA1 Message Date
Vance IngallsandClaude Sonnet 5 a355fb2f6b fix(producer,engine,cli): oom wrapping, cancellation, fallback-reason gaps
Three defects found by max-effort code review of this branch:

1. The Bun OOM exact-match regex was defeated by this codebase's own
   parallel-worker error wrapping. executeParallelCapture/formatWorkerFailure
   (parallelCoordinator.ts) always wrap a worker's error as
   "Worker N: <message>", optionally suffixed and joined with other workers'
   segments, all prefixed "[Parallel] Capture failed: ". That wrapping
   defeated the exact-message check for exactly the cohort (deParallelRouter
   routed, N separate Chrome processes) the OOM-drops-to-1 fix targets — a
   real OOM there would retry at the SAME worker count instead of dropping
   to 1. Added a second pattern that recovers the signal by requiring
   "out of memory" appear as the WHOLE content of a "Worker N: ..." segment
   (bounded by end-of-string/"; "), preserving the same exact-match property
   (no bare substring match) while surviving the wrapping. Verified against
   the real wrapping logic, not a hand-typed guess at its shape.

2. shouldRetryViaPinnedFallback didn't exclude cancellation, so aborting a
   render mid-capture on the pinned router/inversion cohort would detour
   through spawning a fresh encoder/capture session before the outer catch's
   RenderCancelledError branch ended the render — delaying "stop" with a
   pointless resource spin-up/tear-down. Added an isCancellation param
   (checked first, before isVerifyError) using the same
   `err instanceof RenderCancelledError || abortSignal?.aborted` check the
   outer catch already uses.

3. deFallbackReason (this PR's new "oom"/"capture_error" values) was set
   locally but never mirrored into RenderCaptureObservability alongside
   deSelfVerifyFallback, so a render that fails AFTER a fallback attempt
   (perfSummary never built) was indistinguishable in render_error telemetry
   from one that never attempted any fallback — undercutting the "how often
   does the OOM retry fire on a render that still ultimately fails"
   question this branch exists to answer. Threaded through
   RenderCaptureObservability → RenderObservabilityTelemetryPayload →
   renderObservabilityTelemetryPayload, mirroring the existing
   deSelfVerifyFallback plumbing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:31:24 -07:00
Vance IngallsandClaude Sonnet 5 b3f244a7e9 fix(engine): recognize Bun/JavaScriptCore's OOM message in isMemoryExhaustionError
Found while testing the previous commit's OOM-drops-to-1-worker fallback
end-to-end: the producer's deployed runtime is Bun (JavaScriptCore), not
Node (V8) — see packages/gcp-cloud-run/Dockerfile's `bun dist/server.js`
entrypoint. All 7 MEMORY_EXHAUSTION_ERROR_PATTERNS are V8-specific allocation
failure signatures; JSC's equivalent for the same single-oversized-allocation
RangeErrors is the bare string "Out of memory" (verified against real Bun
behavior), which none of them match. Without this, isMemoryExhaustionError
returns false for genuine production OOM, so the memory-specific worker-count
reduction just added would never actually engage where it's deployed — every
OOM would fall through to the generic capture_error retry path instead.

Matches the FULL (trimmed) message only, not merely a substring — same
rationale as the existing V8 patterns' comment: "out of memory" also appears
in benign WebGL/GPU console noise that must not trip this classifier.

Verified end-to-end from a script inside the producer workspace (importing
the real @hyperframes/engine source, not a stale globally-cached npm dist a
script outside the workspace would otherwise resolve to): a genuine Bun
RangeError from new Uint8Array(Number.MAX_SAFE_INTEGER) now correctly
classifies as memory exhaustion and drives both resolveInversionRetryPlan
and resolveParallelRouterRetryPlan down to workerCount=1 on retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 19:29:14 -07:00
Vance Ingalls 030fded71d chore: release v0.7.46 2026-07-09 12:01:33 -07:00
Vance Ingalls 65dd2dc77e Merge pull request #2093 from heygen-com/fix/static-dedup-tlcall-disqualify
fix(engine): disqualify static-frame dedup on any tl.call()
2026-07-09 12:00:16 -07:00
Vance Ingalls 31f0810be8 chore: release v0.7.45 2026-07-08 22:15:49 -07:00
Vance Ingalls b9321b7489 fix(engine): descend into repeating nested timelines for call() detection
Address PR #2093 review feedback (Miga, Rames D Jusso):

- The walker treated a repeating nested timeline (total > single) as an
  opaque interval and never descended into it, so a tl.call() living
  inside one would slip past hasTimelineCall detection entirely — the
  "any tl.call() disqualifies" claim wasn't quite literal. Now recurses
  for detection purposes even when the span is already opaque; the
  parent-level interval still dominates for frame-animated-marking, so
  this only widens what counts as "has a call()," never narrows the
  existing interval coverage.
- Restored the totalDuration() vs duration() rationale comment that got
  dropped when the tl.call() detection comment was added above it.
2026-07-08 21:19:22 -07:00
Vance Ingalls 50c4a10234 fix(engine): disqualify static-frame dedup on any tl.call()
Real bug report: a mono count span driven by a GSAP tl.call() (a counter
going "0 sur 0" -> "1 sur 1" at a later beat) rendered the LATER value
baked in from frame 0 of an EARLIER, unrelated static-hold span, despite
the dedup log reporting "verified".

Root cause: computeStaticFrameSet's tween walker only tracks property
tweens, so a call()-driven textContent mutation carries no tracked
interval and the span around it looks fully static. verifyStaticFramesSafe
does catch genuine drift WITHIN a run it's checking, but a call() is a
one-shot side effect wired as both onComplete and onReverseComplete (GSAP
has no separate "undo" — crossing it in either direction fires the SAME
forward mutation). Verifying a LATER run forward-seeks past the call(),
permanently mutating the live page; an EARLIER run already passed its own
check before that happened, so nothing re-verifies it afterward. Real
capture then starts on the same corrupted page and bakes the wrong value
into the earlier span's reused buffer.

No reliable way to tell a DOM-mutating call() from a harmless one
(analytics ping, class toggle) without executing it, so this disqualifies
the whole comp on ANY call() — conservative, costs some dedup perf on
comps that use call() harmlessly, but correctness over speed.
2026-07-08 21:03:36 -07:00
Vance Ingalls 1e8dd29815 chore: release v0.7.44 2026-07-08 16:21:05 -07:00
Vance Ingalls 381887541f fix(engine,producer): fix quadratic dedup rescan, correct race justification
Address two max-effort code-review findings on PR #2056 not covered by
the earlier review-gap commit:

- captureFrameToBufferPipelined's static-dedup reuse branch never
  advanced session.lastEncodeResultFrame, unlike its sibling real-capture
  branches. The gap-check window is computed from that watermark, so
  every consecutive reuse in a static run rescanned an ever-widening
  window instead of just the newest frame — O(n^2) total work over a
  long static stretch instead of O(n).

- The "single-threaded, no race" justification on the shared
  parallelGuard closure was wrong: the guard has real internal await
  points (recapture, PSNR) between reading and writing its
  sizes/absFloor/acceptedSmall state, so concurrent workers' calls do
  interleave there (confirmed). Replaced with the actual reason it's
  safe: absFloor only ratchets down, sizes is append-only and
  order-independent for the median, and acceptedSmall's fast path
  re-validates by exact byte-equality regardless of which worker wrote
  the reference buffer.
2026-07-08 16:11:46 -07:00
Vance Ingalls 2dbe958a49 fix(engine,producer): close review gaps in parallel drawElement streaming
Address PR #2056 review feedback:
- Fix totalFrames progress inflation for interleaved tasks — divide
  each task's span by its frameStride to match the actual per-worker
  frame count (captureFrameRange steps by stride), instead of summing
  raw endFrame-startFrame which double(N)-counts interleaved tasks.
- Attach a no-op .catch to each frame's pipelined encodeResult at kick
  time so an abandoned promise (loop exits early on abort/error before
  draining it) can't surface as an unhandled rejection during teardown.
- Document why the pipelined branch's stride=1 path is validation-only
  in production (HF_DE_PARALLEL_STREAM always uses interleaved
  distribution) so a future refactor doesn't unknowingly widen it.
- Comment the intentional single shared parallelGuard/parallelStats
  across workers (safe single-threaded, better rolling-median signal).
2026-07-08 16:11:46 -07:00
Vance IngallsandClaude Fable 5 b3493a7b61 feat(engine,producer): verified interleaved parallel drawElement streaming (opt-in)
Step 2 of the DE engagement plan: multi-worker drawElement capture through
the streaming encoder, with the full runtime self-verification net riding
along — the confinement rule that kept the parallel clamp in place is now
satisfied on this path. Opt-in via HF_DE_PARALLEL_STREAM=true; default
routing (including the #2026 single-worker inversion) is unchanged.

Mechanism:
- distributeFramesInterleaved + WorkerTask.frameStride: worker i captures
  frames i, i+N, i+2N... — seek-based capture makes stride free and the
  ordered writer's reorder window shrinks from totalFrames/N to N (contiguous
  chunks serialize workers behind the writer).
- Depth-2 pipelined worker-encode produce in the parallel worker loop (the
  same shape as the sequential loop; frame k's in-page encode overlaps
  k+stride's produce). HF_DE_PAR_DEBUG=1 traces the first frames per worker.
- Drain guard extracted to createDrainFrameGuard (session-parameterized):
  every parallel frame gets the SAME blank-guard + PSNR self-verify as the
  sequential drain, against its owning worker's pre-injection ground truth
  (all sessions arm identical sample indices from
  CaptureOptions.compositionDurationSeconds).
- FrameReorderBuffer.abort(err): a failed worker (e.g. verification error)
  rejects all parked and future waiters — without this, peers park forever
  in waitForFrame and the pool (which awaits ALL workers before surfacing
  errors) deadlocks. Found by the verify-trip test; unit-tested.
- The typed DrawElementVerificationError is preserved past the pool's
  error-string flattening so the orchestrator's verify-retry recognizes it.
- Static-dedup stride hazard fixed: lastEncodeResult reuse now requires EVERY
  frame in (lastEncodeResultFrame, i] to be predicted-static (sequential
  capture reduces to the old has(i) check).
- Workers get separate browser PROCESSES under the flag: pages co-tenant in
  one browser starve non-active pages of BeginFrames on the paint-wait path
  (measured 86s vs 30s on a 3,245-frame rAF comp).

Validation:
- Happy path W3: verify samples pass across workers (4x inf on the 2,381f
  comp), output vs single-worker DE = 59.3dB (encode noise floor) — the
  interleave + dedup-stride produce identical pixels.
- Verify-trip (marginal comp + HF_DE_VERIFY_MIN_DB=45): fails at frame 649
  (32.2dB < 45), peers abort instead of deadlocking, whole render retries
  via parallel screenshot, RENDER_OK in 42.6s.
- Canary suite 7/7 with the flag off (default paths untouched); producer
  orchestrator tests 99/99; engine suite 909 passed (14 pre-existing main
  failures, stash-A/B verified); reorder-buffer abort unit tests.

Perf note: capture-only parallel speedup measured 1.38x (W2) / 1.52x (W3)
over single-worker DE in the spike; end-to-end numbers on this machine are
currently noisy (separate-browser init overhead + bench load) — clean
benchmarks before any default routing change. The flag stays explicit
opt-in; promoting it into the router replaces the #2026 W=1 pin for the
same cohort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:11:46 -07:00
Vance Ingalls 8fee20a525 fix(engine): log composition-id attribution on script-failure bail too
Address max-effort code-review finding on PR #2045 (confirmed, not
addressed by the earlier review-gap commit): the script_failure bail
path skipped the composition-id enumeration entirely, so a render with
multiple sub-compositions sharing a failed script only logged the raw
failed URL(s), never which composition(s) were still waiting on it —
a real observability regression versus the pre-#2045 behavior, which
always logged the missing-id list on any non-ready outcome.

Now enumerate unregistered composition ids unconditionally and log them
alongside whichever reason (script_failure or natural timeout) fired.
2026-07-08 16:09:48 -07:00
Vance Ingalls 8ba3c33915 fix(engine,producer,cli): close review gaps in sub-timeline fail-fast
Address PR #2045 review feedback:
- Share a SubTimelineWaitOutcome type (engine) end-to-end instead of
  widening to string across CapturePerfSummary / RenderPerfSummary /
  telemetry, so the three layers can't drift.
- Dedupe scriptLoadFailures on push — a 4xx response and its trailing
  requestfailed both recorded the same URL, doubling the failed-URL
  list in the fail-fast warning.
- Thread the sub-timeline-wait outcome into render_error (not just
  render_complete): a render that fail-fasts and then fails downstream
  (pollVideosReady, extract, encode) previously dropped this signal on
  the floor. dedupPerfs is now function-scoped so the catch path can
  read it, same treatment as the existing captureAttempts array.
2026-07-08 16:09:47 -07:00
Vance IngallsandClaude Fable 5 54359f3d6a fix(engine): fail-fast the sub-composition timeline wait when a script 404s
pollSubCompositionTimelines waits for every [data-composition-id] host to
register window.__timelines[id]. When the script carrying that registration
fails to load (404 / request failure), the registration can never arrive —
but the poll still burned the full playerReadyTimeout (45s), then warned and
shipped a silently animation-less render. Wild scale: the capture-setup
histogram over 30 days of local renders decays smoothly (402/503/364/282/191
per 5s bucket) then spikes to 705 at the 45s bucket — ~1,000 renders/month
across 402 distinct users, ~15 user-hours of pure waiting.

- Sessions now record failed SCRIPT resources (requestfailed + HTTP>=400
  response, listeners that already existed for diagnostics) in
  session.scriptLoadFailures.
- pollSubCompositionTimelines takes a failure getter and cuts the wait to a
  2s grace once any script failed, with a loud warning naming the URL(s).
  Late-registering fetch-async comps are unaffected: no script failure means
  the full timeout still applies, and a registration landing inside the
  grace window still wins (tested).
- Outcome telemetry: session.subTimelineWaitOutcome ("ready" | "timeout" |
  "script_failure") -> CapturePerfSummary -> RenderPerfSummary.subTimelineWait
  (worst across sessions) -> render_complete sub_timeline_wait, so the wild
  rate becomes directly trackable instead of setup-histogram forensics.

Validation: the discovery comp (0768f038, its animations.js unreachable)
drops from ~72s to 23.1s total — poll cut at 2.1s with the script named;
healthy comp reports "ready". Canary suite 7/7 (PSNRs identical). 4 new
poll unit tests; engine suite 907 passed (14 failures are PRE-EXISTING on
main at v0.7.42 — 18 fail on a clean checkout, stash A/B verified).
tsc/oxlint/oxfmt clean.

Corpus note: 258/1,762 corpus comps (14%) reference local scripts missing
from the corpus fetch — their historical eval INIT timings measured this
timeout, not the engine. Capture-stage ratios remain valid (both paths paid
it equally).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:09:47 -07:00
Vance Ingalls f8f945d42e chore: release v0.7.43 2026-07-08 15:51:39 -07:00
Vance Ingalls 5f9ee0b678 fix(cli,engine): close review gaps in Chrome resolution fix
Address PR #2082 review feedback:
- Route studio thumbnail + render call sites through preferManagedChrome
  so studio renders no longer silently fall back to whatever system
  Chrome happens to be installed.
- `hyperframes browser ensure` now resolves through the same
  preferManagedChrome path render uses, so it reports what render will
  actually pick instead of any system Chrome it happens to find.
- Point the unsupported-Chrome fallback log at `browser ensure --force`
  instead of `doctor`, which doesn't check Chrome/drawElement capability.
- Fix stale findFromCache comment: the HF pin is now a Dev-channel build
  that can be newer than a user's puppeteer-cache Stable install.
2026-07-08 15:25:59 -07:00
Vance Ingalls 8854bad8f9 fix(engine,cli): resolve drawElement to a Chrome build that actually has it
canvas.drawElementImage is an unlaunched Dev/Canary-only Blink feature
(~151+). The CLI's pinned CHROME_VERSION fallback was still 131.0.6778.85 —
a puppeteer 24→25.2.1 bump that pinned it to Chrome Dev 151.0.7912.0 was
written on 2026-06-29 but never merged (orphaned local commit, no PR). Any
render on that pin, or on the shared puppeteer-cache binary, or on system
Chrome (Stable, no drawElementImage at all) got a canvas.getContext("2d")
missing the method and crashed mid-capture with "ctx.drawElementImage is
not a function" instead of falling back (HF#2060).

Three changes:
- Bump puppeteer/puppeteer-core to ^25.2.1 across every package that
  depends on it, and CHROME_VERSION to 152.0.7928.2 (today's Dev channel;
  confirmed via direct probe to implement drawElementImage, unlike 131).
- `ensureBrowser({ preferManagedChrome: true })`, always used by `render`:
  resolve straight to our pinned/cached build, skipping both the shared
  puppeteer-cache preference and system Chrome. Rendering shouldn't depend
  on whatever arbitrary Chrome a machine happens to have — that's exactly
  how this regressed (any Mac with Chrome.app installed bypassed the CLI's
  pin entirely).
- A runtime capability probe in the engine, right before any other
  drawElement work: if `drawElementImage` isn't a function on the injected
  canvas, route to the existing screenshot-fallback gate instead of
  crashing. This is the real backstop — it protects every resolution path
  (env override, stale cache entry, a future Chrome regression), not just
  the ones `preferManagedChrome` reaches.

Verified end-to-end: rendering against chrome-headless-shell 131 (confirmed
to lack drawElementImage) now falls back cleanly and produces a valid MP4
instead of crashing; rendering against a capable build still engages
drawElement normally. 922 engine tests + 1373 CLI tests pass.

Fixes #2060.
2026-07-08 14:14:28 -07:00
Miguel Ángel 6f8bf5f364 fix(engine): resolve relative data-start references for audio tracks (#2062)
parseAudioElements read data-start with a bare parseFloat, so a relative
reference (data-start="introClip", the documented 'start when that clip
ends' pattern) resolved to NaN. The mixer then silently dropped the track,
rendering the whole segment as pure digital silence — even though the SAME
reference on the sibling <video> placed the visual correctly (#2030 taught
parseVideoElements/parseImageElements to resolve refs; audio never learned).

Root fix, single source of truth: extract the Node-side reference resolver
out of videoFrameExtractor into referenceResolver.ts and use it in
parseAudioElements for both <audio> and <video data-has-audio> tracks. Now
every media parser resolves relative timing identically, so audio and video
cannot drift again. The two near-identical parse loops share one builder;
end stays a numeric read (mixer derives real length downstream), NaN-guarded.

Verified end-to-end: a composition with <audio data-start="clipId"> now
renders an audio stream that is silent before the referenced clip ends and
audible after (matches the numeric-start control); previously the output had
no audio stream at all. 78 engine media tests pass (4 new).
2026-07-08 15:53:04 -04:00
Miguel Ángel 5b9b71df25 fix(producer): suppress GSAP call side effects during render seeks (#2037)
* fix(producer): suppress GSAP call side effects during render seeks

* fix(core): preserve GSAP root render nudge safely
2026-07-07 21:06:59 -04:00
Vance IngallsandClaude Fable 5 f6cd711bf0 chore: release v0.7.42
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:04:26 -07:00
Miguel Ángel 4a36655b2b fix(engine): resolve relative data-start references in video-frame extraction
* fix(engine): resolve relative data-start references in video-frame extraction

<video data-start="intro"> (a relative reference to another clip's end) is
resolved by the browser runtime but parseVideoElements/parseImageElements did a
raw parseFloat, yielding NaN start/end. The FrameLookupTable active-window
checks (start <= t <= end) are then always false, so the clip is never injected
and composites BLANK in the final render — while lint/validate/inspect/snapshot
and the live preview all look fine. The docs' Relative Timing section teaches
exactly this pattern on <video>.

Share the pure reference-syntax parser (parseStartExpression) out of the runtime
resolver into @hyperframes/core, and resolve references in the extractor against
the linkedom document it already holds: a reference resolves to the target
clip's resolved start + its duration (data-duration or data-end) + offset,
mirroring the runtime. Cycle-guarded; an unknown target or unknown duration
falls back to the target's start / 0 (never NaN), matching runtime semantics.
Natural-media-duration-only targets aren't known at parse time (same limit as
the runtime's fallback). parseImageElements gets the same fix.

Runtime resolver behavior is unchanged (its 25-case suite still passes).

* chore: re-trigger CI to refresh a stuck CodeQL aggregate check
2026-07-07 18:11:21 -04:00
Miguel Ángel 60463d0bd5 chore: release v0.7.41 2026-07-07 20:30:24 +00:00
Varo 76204ec630 fix(engine): pre-create __render_frame__ siblings in initializeSession (#2006)
* fix(engine): commit render-frame siblings with a visual BeginFrame at init

Chunk-lambda renders drop a periodic near-black frame — one every
chunk_frames/worker_count frames (every 60 on a 4-worker single-video chunk),
YAVG ~22 against YMAX ~240 in signalstats. Local single-process renders don't
show it because they don't run under BeginFrame.

It's the isNewImage branch in injectVideoFramesBatch: the first time a session
paints a given videoId there's no __render_frame__ sibling yet, so it creates
the <img> on the spot (createElement + insertBefore) right before capture.
Under HeadlessExperimental.BeginFrame the compositor doesn't have that fresh
layer in the immediately-next frame, so the first captured frame per session
paints only body background + already-composited overlays. Each lambda worker
is its own session, hence the worker-boundary periodicity.

Pre-create the hidden sibling at the end of initializeSession, then drive one
non-capture visual BeginFrame (noDisplayUpdates: false) to composite the new
layers before the first real capture. The warmup ticks are noDisplayUpdates:
true (they advance the clock but don't paint) and the per-frame seek doesn't
tick, so this explicit visual frame is what actually commits the layers; its
tick sits in the gap between warmup and frame 0 so ticks stay monotonic and no
render frame is consumed. Every subsequent inject then takes the hasImg=true
(src-update) path; the isNewImage branch stays as a fallback for callers that
don't go through initializeSession.

* fix(engine): place the render-frame commit tick before the liveness probe

The commit tick at init sends its BeginFrame at `beginFrameTimeTicks - 1·interval`.
The producer's liveness probe then fires right after init at
`beginFrameTimeTicks - 5·interval` — an earlier tick. Per-session BeginFrame time
has to be monotonic, so the probe running backwards past the commit tick stalls
chrome-headless-shell indefinitely; the engine reads that timeout as a SwiftShader
heavy-layer stall and routes the render to screenshot capture, which then dies
relaunching and hangs the shard to the job timeout.

Reproduced on a native x86 SwiftShader host and bisected: with the commit tick
present the probe times out even with zero render-frame siblings created, so it's
the tick ordering, not layer count. Moving the commit tick to `-6·interval` (below
the probe, above the warmup ticks) keeps warmup < commit < probe < capture
monotonic and clears the stall on every affected comp — sub-composition-video,
chat, style-5-prod — while a healthy comp (style-18-prod) is unchanged. The commit
tick itself is untouched, so the black-frame fix it exists for still holds.
2026-07-07 16:21:59 -04:00
Miguel Ángel 2bd9bb6f69 chore: release v0.7.40 (#2024) 2026-07-07 12:30:13 -04:00
337d0b51bc refactor(engine,producer): adopt requestPaint contract, retire autoAlpha rewrite (#2021)
* refactor(engine,producer): adopt requestPaint contract, retire autoAlpha rewrite

crbug 529829538 was closed "working as intended": the html-in-canvas API's
contract is mutate -> canvas.requestPaint() -> await the canvas paint event ->
drawElementImage, which refreshes the subtree's paint records including
compositor-applied properties. Verified on the pinned 151 floor and 152
canary: root opacity, root filter, nested group opacity, and child transforms
(incl. will-change-promoted) all capture exactly; the root element's own
TRANSFORM is the one property still never baked.

- Paint invalidation: all three paint-wait sites (serial capture, worker
  produce, batch produce) now call canvas.requestPaint() when available and
  fall back to the __hf_de_tick sentinel background toggle on builds without
  it. The 250ms unsynchronized-draw safety net is unchanged.
- Root-opacity ratio correction REMOVED (all three draw sites + base-opacity
  recording at injection). Since 151 the paint wait bakes current root opacity
  into the snapshot as pixel alpha, so the ratio correction DOUBLE-APPLIED
  animated root fades: a root-fade A/B tripped the runtime self-verify at
  30.1dB (frame 24, ~0.92 expected vs ~0.85 rendered). Post-removal the same
  comp self-verifies at inf and matches the screenshot render at PSNR=inf.
  The root TRANSFORM correction stays — verified still required.
- autoAlpha rewrite machinery DELETED: the opt-in opacity->autoAlpha tween
  rewrite (default-off since the retraction fix; measured ~28dB damage on
  comps whose fades it touched), its flush-time transparent-target hiding,
  the __HF_FAST_CAPTURE_AUTOALPHA__ flag plumbing, and the deferral-time
  retract/re-assert dance. The stub keeps tween-target tracking (3D
  projection + at-risk scans depend on it).

Validation: canary suite 7/7 with PSNRs identical to baseline (58.30 /
43.13 / 54.15 dB); root-fade A/B PSNR=inf vs screenshot; engine suite 905
passed (1 pre-existing color-grading failure); tsc/oxlint/oxfmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engine,producer): review fixes — gate opacity correction by paint mechanism

Max code-review findings on the requestPaint adoption:

- Root-opacity ratio correction RESTORED, gated per frame on how the paint
  was produced: it applies on BeginFrame (sync=false) captures and on builds
  without canvas.requestPaint() — the two paths where the snapshot holds the
  root's load-time opacity — and is skipped only on requestPaint-driven
  paints, where the snapshot bakes the current opacity and the ratio
  double-applies (the proven 30.1dB root-fade failure). Base opacity is
  recorded at injection again.
- Invalidation extracted to a page-scope helper (__hfDeInvalidate, installed
  by injectDrawElementCanvas) shared by all three paint-wait sites: sentinel
  toggle ALWAYS (a paint is guaranteed even if requestPaint elides one on a
  clean subtree) + requestPaint() in a try/catch (a throwing implementation
  degrades to sentinel-only instead of rejecting the capture). Returns
  whether requestPaint ran, feeding the opacity-correction gate. Also removes
  the triplicated inline block and its three anonymous `as T` casts.
- HF_FAST_CAPTURE_AUTOALPHA now logs a retirement warning instead of being a
  silent no-op (the deleted rewrite's comment documented it as an operator
  escape hatch).
- Batch producer docstring updated (still described the tick-toggle-only
  paint wait); stub tween observer reshaped to a void fn (observeTweenCall)
  so no arg-rewriting seam survives.

Validation: canary suite 7/7 (58.30/43.13/54.15dB, d95f20b6 clean);
root-fade A/B self-verify 4x inf + whole-video PSNR=inf; engine suite 905
passed (1 pre-existing); tsc/oxlint/oxfmt clean; stub regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: WaterrrForever <miao.yang@heygen.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 01:52:26 -07:00
Vance Ingalls 229e88eac5 chore: release v0.7.39 2026-07-06 22:48:42 -07:00
Vance IngallsandClaude Fable 5 b26c27576b feat(engine,producer,cli): verify video comps via deferred DE init + capture p50 (#2015)
* 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>
2026-07-06 22:47:29 -07:00
Vance Ingalls d8d3a93b0d chore: release v0.7.38 2026-07-06 17:47:34 -07:00
Vance IngallsandClaude Fable 5 1005703441 feat(engine,producer,cli): drawElement release telemetry on render_complete (#2002)
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>
2026-07-06 17:39:46 -07:00
Vance IngallsandClaude Fable 5 ec06f4bf89 feat(engine,producer): drawElement fast-capture default-on with runtime self-verification safety net (#1998)
* 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>
2026-07-06 16:29:30 -07:00
Vance IngallsandClaude Opus 4.8 d5ecb013d7 perf(engine,producer): batch N drawElement frames per CDP round-trip (HF_DE_BATCH) (#1928)
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>
2026-07-06 16:27:31 -07:00
Vance IngallsandClaude Fable 5 0e58344dca feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension (#1919)
* 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>
2026-07-06 16:21:12 -07:00
Vance IngallsandClaude Fable 5 4749fe5716 feat(engine): 3D projection + compositor-effect risk gate (#1918)
* 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>
2026-07-06 16:19:44 -07:00
Vance IngallsandClaude Fable 5 795934adba feat(engine): drawElementImage capture service (#1917)
* 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>
2026-07-06 16:19:17 -07:00
Vance Ingalls e04f6dda37 feat(engine,cli): drawElement fast-capture config + CLI flag (#1916)
## 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)
2026-07-06 15:57:30 -07:00
ukimsanov 644ae384a5 fix(runtime): satisfy color grading audit 2026-07-06 12:49:34 -07:00
ukimsanov 870964b0cf feat(runtime): render media color grading shaders 2026-07-06 12:44:43 -07:00
James 9de41ce316 chore: release v0.7.37 2026-07-06 05:59:36 +01:00
Miguel Ángel e8d19b1b43 chore: release v0.7.36 2026-07-05 19:25:42 +00:00
Miguel Ángel 2f55ea678a chore: release v0.7.35 2026-07-05 18:48:24 +00:00
Miguel Ángel b7dcb9e2a3 fix(engine): harden ffmpeg binary resolution 2026-07-05 11:46:38 -07:00
Miguel Ángel 114d31919e chore: release v0.7.34 (#1954) 2026-07-05 10:55:11 -07:00
Miguel Ángel 56d4a7032b fix(engine): preserve DOM mask visibility state (#1953) 2026-07-05 07:24:26 -07:00
Miguel Ángel 78cca797f1 chore: release v0.7.33 2026-07-04 22:33:27 +00:00
Miguel Ángel bb066077b4 fix(producer): avoid reviving hidden DOM in HDR layers (#1935)
* fix(producer): avoid reviving hidden DOM in HDR layers

* fix(producer): filter transition HDR DOM masks

* fix(producer): keep hidden timed descendants masked
2026-07-04 15:30:56 -07:00
Miguel Ángel 16fe1368ff chore: release v0.7.32 2026-07-04 21:23:19 +00:00
Miguel Ángel 8a3227f548 fix(engine): write the audio mix filter graph to a file, not the command line (#1890)
* fix(engine): write the audio mix filter graph to a file, not the command line

mixAudioTracks built the ffmpeg -filter_complex argument as one inline
string scaling linearly with track count. Reported in the wild at 146
timed audio clips: the resulting command line exceeded the OS length
limit and spawn failed with ENAMETOOLONG, dropping audio entirely until
the user manually consolidated clips to reduce the count.

FFmpeg supports -filter_complex_script specifically for this - the same
filter graph read from a file instead of inlined as an argument. The -i
pairs for each track still scale with count but stay short and fixed-size
each, so the one component that actually grew unbounded (the filter
string) no longer sits on the command line at all. The temp file is
cleaned up immediately after ffmpeg exits, matching the existing sibling
temp-file convention in audioVolumeEnvelope.ts.

Verified end-to-end against a real ffmpeg binary (not just mocked): a
two-track mix produced correct output audio with no leftover temp files.

* fix(engine): create audio filter scripts safely
2026-07-04 14:08:15 -07:00
Vance Ingalls af5f3e5fab chore: release v0.7.31 2026-07-03 23:07:38 -07:00
Vance Ingalls cf594403ef chore: release v0.7.30 2026-07-03 21:47:20 -07:00
Vance IngallsandClaude Fable 5 74faa4b2a4 fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate (#1875)
* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate

A committed moveElement wrote data-x/data-y but nothing rendered them:
hosts shimmed CSS translate, which GSAP folds into the cached transform
at first parse and then discards on the animated axis at every seek —
dragging an animated element kept only the un-animated axis.

Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is
never read, folded, or cleared across seeks and composes natively with
the animated transform. So:

- moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y)
- the runtime (new core runtime/positionEdits.ts, applied at timeline
  bind — after GSAP parse) renders translate = (data-x − base), a pure
  delta that composes with GSAP tweens, tl.set positions, and CSS alike
- applyDraft now drives the drag preview through the same translate
  channel (the --hf-studio-dx/dy vars had no consumer outside authored
  Studio bridges), and commitPreview mirrors the committed move onto
  the live element so it holds without an srcdoc reload

Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts
(real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated,
Y-animated, and static elements hold both edited axes across the full
seek range. New subpath export @hyperframes/core/runtime/position-edits.

Known limitation (documented): a tween created lazily at runtime that
first-parses a marked element after apply folds the edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): harden position-edit rendering and the drag draft channel

Fixes six issues from adversarial review of the moveElement stack:

- Runtime: apply position edits at init as well as at timeline bind, so
  committed moves render in compositions with no usable GSAP timeline
  (CSS/WAAPI-animated or fully static) — previously the apply was
  unreachable outside the boundDuration > 0 bind branch and the edit
  silently vanished from reloads and renders.
- Runtime: guard bind-path re-apply against post-fold double-apply — if
  the previously written translate was consumed externally (a lazily
  created tween folding it into GSAP's cached transform), skip instead
  of re-setting it on top ({force} escape hatch for editor commits).
- Adapter: stop writing the --hf-studio-dx/dy custom properties during
  drags — compositions with the documented var-consuming drag-bridge
  CSS moved by twice the pointer delta (var transform + new inline
  translate). The inline translate is now the only draft channel;
  deltas accumulate in adapter fields. Docs updated to match.
- Adapter: switching applyDraft to a new id reverts the abandoned
  element's draft translate instead of leaving it displaced with no op.
- Adapter: cancelPreview restores the raw inline translate (removing it
  when there was none), so a stylesheet-authored translate is never
  promoted to a permanent inline style.
- Adapter: commitPreview reverts the draft and clears state when
  dispatch throws, instead of leaving the element shifted by an
  uncommitted draft.

Cleanups: reuse readCurrentTranslate from the core module (was a
verbatim copy), drop the dead __hfApplyPositionEdits window hook.
Browser acceptance test now also covers the GSAP-free composition path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry

Addresses PR #1875 review feedback (Rames, Miga):

- Prime the element's GSAP transform parse (gsap.getProperty) before the
  first translate apply — positioned tl.set()s and tweens that first
  RENDER after the apply now reuse the cache instead of folding the edit.
  This closes the lazy-first-parse fold-loss for any page where GSAP is
  loaded at apply time; the residual limitation is GSAP itself loading
  after the apply. Proven by the extended browser acceptance test.
- Emit position_edit_fold_skipped analytics at the fold-guard skip site
  so the residual degradation is observable instead of silent.
- Browser acceptance test: add a both-axis-animated element (the shape
  that originated the per-axis loss) and a positioned tl.set() element,
  asserted across the full seek range.
- Simplify the num() null guard (review nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 20:44:27 -07:00