de_parallel_router is present on 95.4% of render_complete events and 0.83%
of render_error. Capture context itself survives failures fine (capture_mode
is on 98.6% of them), so this is not renders failing before capture — the
routing state specifically is being dropped.
Cause is ordering. deParallelRouter is assigned twice: once before the
capture-observability update, and again inside syncCapturePlan where routing
is actually resolved — including the 'reverted' case, which the earlier
assignment cannot know. The update in between recorded whatever was true
first, so a render that failed while routed reported no routing state at all.
The existing comment at the earlier call site says it is recorded there
precisely so hard failures carry it; that intent was correct and the value
just arrived too late.
This matters for the #2840 ramp specifically. The per-install circuit breaker
only arms on a revert, which requires the render to finish and self-detect —
it cannot catch a crash or hang. Those are exactly the failure modes a
percentage ramp exists to bound, and they were the ones telemetry could not
see.
Also makes the ffprobe contract sweep resilient per entry. A dangling symlink
under packages/studio/data/projects threw ENOENT on stat and aborted the whole
traversal, so every package sorting after 'studio' — both studio-server
callers included — silently stopped being checked. main is currently red on
this. The manifest assertion is what caught it, which is what it was added
for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A near-uniform drag collapses to the `scale` shorthand, but the finalize step
measured the element at the per-axis pair it computed rather than the single
value the commit writes. The element was measured at a scaleY the file never
gets, so the position correction came out tilted by the difference.
Adds a sweep over the shapes a composition produces — shrink, grow, first
resize, rotated, steeply rotated, non-uniform, near-zero, inline-sized, no
position write, animated position, and two drags in a row — each checking the
element renders on its drop point from the PERSISTED scale and position. The
geometry model is calibrated against real gesture traces: the same inputs
reproduce the rects the browser reported to three decimals.
A scale resize measured its drop-point correction while the gesture's own
translation was still applied, but the position commit adds that correction
onto the element's PRE-gesture position, which it reads from the gesture's
base attributes. The two disagreed by the whole drag distance, so the commit
persisted a position a drag-length from where the element was dropped: it
held the drop point for one frame and then slid off.
Move the element back to that base before measuring, so the residual and the
commit share one origin. For an element whose position is a static hold that
usually means no correction at all, which is the right answer: scaling about
the centre already leaves it on the drop point.
An element whose position is animated left the drop point anyway. The
finalize step wrote its correction as a static position hold, and the
element's position tween rendered its own value a frame later and won.
Before that it stood down entirely on such elements, on the grounds that
a keyframed path has no single anchor to preserve, which had the same
visible result: the element moved.
It has an anchor, the frame the user is looking at. The correction now
goes into that tween at the playhead, through commitGsapPositionFromDrag,
which is the same commit a drag on the same element already uses. Static
holds keep the existing path.
This is the difference the debug log showed between an element carrying
position:to, which moved after release, and one carrying position:set,
which did not.
The finalize step measures where the committed scale put the box and
shifts the position hold by the difference. Whether the commit had
actually rendered when it measured was luck: on a first resize the
timeline had not re-seeked, so it measured the element at its natural
size still sitting on the drop point, saw no residual, and skipped the
correction. The scale then landed, GSAP rendered it about the element's
centre, and the element jumped by the whole drag distance. Elements
resized before got a correction only because their previous scale made
the residual non-zero by accident.
The committed scale is now applied to the live element before measuring,
so the measurement means what its comment says either way, and a skipped
correction is logged rather than silent.
Confirmed against a real session: a first resize of a 630px chip now
reports residual -109.93 and lands on the drop point, where it previously
logged no scale-finalize at all.
* feat(sdk): expose a paint query for transparent-composition hit-testing
A host layering a transparent composition over other content has to know
whether a point carries ink before it decides to swallow a click. The adapter
only answered "what element is here", so AI Studio wrote its own answer and
could not reach the per-pixel alpha the adapter already samples for <img>.
Add PreviewAdapter.paintsAt, plus the pieces it is built from on a new
./adapters/iframe subpath so a host with different hit-test policy can compose
its own walk.
The walk is geometric rather than elementsFromPoint-based: that stack omits
pointer-events:none nodes, and a decorative overlay carrying it still paints,
so a z-stack query would report no ink over visible artwork — the direction
that makes a composition vanish from under the cursor.
fullBleedFraction is an option rather than a constant because "a layer covering
the whole frame is background, not artwork" is host policy, not a fact about
the composition.
* fix(sdk): scope the full-bleed frame to the root under the point
compositionFrameArea took the smallest [data-composition-id] in the whole
document, so an unrelated sub-composition sized the reference frame for points
nowhere near it: a 300x300 badge in a corner made every mid-size painter in a
1920x1080 outer frame read as full-bleed, and the composition went
click-through under artwork the user can plainly see. That is the direction the
fail-safe exists to avoid, and the docs already described the intended
behaviour — the innermost root CONTAINING the point.
Also read the alpha channel instead of matching known transparent spellings.
Only the `transparent` keyword computes to rgba(0, 0, 0, 0); a faded-out white
stays rgba(255, 255, 255, 0), which the set counted as painted. That erred
toward absorbing clicks rather than losing them, so it was a false positive
rather than a hazard, but it is wrong.
Both are pinned by tests that fail when the fix is reverted.
* fix(sdk): stop the paint query answering "no ink" over visible artwork
Four cases where the walk landed on the wrong side of its own fail-safe.
The full-bleed veto tested the winner's border-box area even when the alpha
sampler had just read an opaque pixel there, so a full-frame transparent PNG or
SVG overlay — the case this feature exists for — reported background over
visibly opaque artwork, with no fullBleedFraction that worked. Ink now carries
how it was established, and a measured pixel is never vetoed. An image whose
pixels could NOT be read stays inferred, so a tainted CDN overlay still yields
to the veto rather than absorbing every click.
Composition roots were excluded from candidacy outright, so a root carrying a
background answered false even at fraction 0, where the docs promise every
painting box counts. Roots are candidates now; the veto discounts them without
a special case, since a root's box is the frame.
An <img> with a clear pixel early-returned past its own background, padding
plate and border, which any other element would have counted.
A same-origin iframe mid-navigation exposes a readable but empty document, so
the !doc guard never fired and a loading composition answered a confident
"no ink" — the exact failure the null convention exists to prevent.
Also: the sort comparator's epsilon tie was intransitive, leaving the
smallest-first guarantee (and the lazy single-sample property that rides on it)
engine-dependent; the guide's pass-through recipe called a function that does
not exist and hand-waved the coordinate mapping that makes it correct; and the
reference now states the under-counts alongside the over-counts, the walk's
blindness to runtime-mounted content, and compositionPaintsAt's preconditions.
Each fix is pinned by a test that fails when the fix is reverted.
* refactor(sdk)!: invert the paint query to isProvablyEmptyAt
paintsAt handed callers three falsy bottom values with opposite safe readings:
false meant "no ink, pass the click through", null meant "not knowable, treat as
painted", and undefined from an adapter without the method also meant painted.
The idiomatic `if (!preview.paintsAt?.(x, y)) passThrough()` therefore did the
dangerous thing for two of the three, and the convention needed defending in the
interface docstring, the reference and the guide — plus a dedicated comment and
a pinning test on the headless adapter to stop null regressing to false.
Inverting the polarity collapses the tri-state to a plain boolean and makes the
safe reading structural: true only when the composition was readable and nothing
painted there, so ink, an unreadable or still-loading document, and a missing
implementation all land on "keep the composition clickable". The prose stays as
rationale, but nothing depends on a reader remembering it.
PaintsAtOptions becomes PaintQueryOptions, since it now describes the walk that
both the adapter method and the exported compositionPaintsAt share rather than
one method's arguments. compositionPaintsAt keeps its ink-positive name: it
answers the other question, and its docstring points callers who need the
fail-safe contract at the adapter.
Nothing is released yet, so no consumer is on the old name.
A uniform drag committed the `scale` shorthand. If the tween's keyframes
already stated `scaleX` and `scaleY`, the commit left both forms in the
same keyframe, and GSAP animates each property name independently, so the
longhands ran alongside the shorthand and won.
The resize therefore computed the right number, wrote it to the file, and
did nothing: the element snapped back to its old size the moment the
handle was released. Reproduced from a real session, where a drop at 384px
on a 630px element wrote {scaleX: 1, scaleY: 1, scale: 0.61} and rendered
at the original size.
The mixing hazard was already known in the other direction, where a
non-uniform drag takes a rewrite path that normalizes every keyframe to
the longhands. This makes the condition symmetric: whenever the tween
already speaks longhands, a uniform drag speaks them too.
Resizing an element whose size is driven by a scale animation committed a
scale computed against a hardcoded 200px fallback, because the only
original size the draft recorded was the element's INLINE width, and a
composition sizes its elements from the stylesheet.
A 630px chip dropped at 1260px wide committed a scale of 6.3 instead of 2,
so it landed at over three times the size it was dropped at. The next drag
compounded it, because that wrong scale then counted as the element's live
one.
The draft now records the box it measured, once, before it writes a width
of its own, and the intercept reads that. The inline attributes keep their
own job of restoring an inline style, which is why they cannot answer this
question.
Review caught that the same anti-pattern was still live in the Studio binding:
canaryEventProperties destructured only `enabled` and dropped the reason. Its
own doc comment promised 'identical shape to the CLI, so a rollout spanning
both reads as one flag' — which the CLI-only fix had just made false.
This matters beyond symmetry. A CLI-launched Studio adopts the CLI's decisions
and shares its bucket seed, so a cohort flip can surface on either surface.
Emitting attribution on only one leaves Studio-observed flips unattributable
and makes the two flip counts irreconcilable — and Studio is the surface most
likely to expose a shared-seed-with-diverging-id pattern, which is the open
question the reason exists to answer.
Also adds the no_unit_id emission test the CLI side advertised but never
asserted, and a Studio pair pinning that a URL override and a cohort roll
produce the same assignment with different reasons.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The calibration contract deferred this until the stability check came back
dirty. It did: the first fleet read found 304 installs (1.08%) reporting both
values for a canary whose percentage never moved, and the genuinely anomalous
ones could not be separated from a developer toggling HF_CANARY_*, because the
assignment alone is identical in both cases.
resolveCanary has always computed the reason and canaryEventProperties dropped
it. Now every canary emits canary_reason_<name> beside its assignment.
Deliberately outside the $feature/ namespace: PostHog treats those as flag
values, and a non-boolean there would corrupt the flag's own breakdowns.
Two of the six wire values are immediately useful beyond override attribution.
'excluded' identifies CI installs, which today have to be dropped by joining
on is_ci — conflating them with out_of_cohort is what made the first accuracy
read look like a significant failure (9.22% against a 10% target) when it was
not. 'no_unit_id' surfaces the fails-closed corner.
The reason is optional on the core helper so existing callers are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(cli): classify identity persistence on every telemetry event
Install-grain metrics currently trust every anonymousId equally, but
ephemeral/isolated-HOME workloads mint a fresh id per run — one machine
produced 2,956 rotating render identities since Jul 30 (94.4% seen on a
single render command), inflating acquisition and diluting per-install
penetration while looking like real product usage.
Every event now carries:
- identity_persistence: durable (id loaded from a preexisting config —
proven to survive a process boundary) | unknown (minted+persisted this
run; an ephemeral HOME is indistinguishable from a genuine first run
from inside one process) | process_only (persist failed). Sticky per
process so a fresh install re-reading its own write cannot self-promote.
- config_write_outcome: ok | ok_unmirrored | failed for the identity-
establishing write; absent when the id came from disk.
- invocation_id: random uuid per CLI process, so one invocation's events
group even when the install identity is untrustworthy (unlike run_id,
which needs an orchestrator to set HYPERFRAMES_RUN_ID).
Install metrics can then count only durable identities, and a daily
churn monitor can alert on the unknown share.
* fix(cli): require the anonymousId to come off disk before classifying durable
Review finding: materializeConfig mints a replacement anonymousId when a
hand-edited/image-baked config lacks one. That replacement only reaches
disk when the bucket-seed backfill happens to write; with a seed present
the read path performs no write at all, so the install re-mints a fresh
id every run while the unconditional durable branch stamped each of them
with the one label durable-only counting is allowed to trust.
durable now requires parseNonEmptyString(parsed.anonymousId): a minted
replacement classifies like a fresh mint — by the backfill write outcome
when that path runs (unknown/process_only), and process_only on the
no-write path where the id provably dies with the process. Two tests pin
both shapes.
carriedSectionsFrom() decided whether a ## Usage section was generated by matching
its first line against a list of historical opener phrases — so a hand-written
Usage section that happened to open that way was classified as generated and
silently deleted on regeneration. Ownership is now purely set membership: a
section is generated iff its heading is one the template emits, and ambiguous
'usage' is no longer in that set (the template never emits it), so any ## Usage is
carried. Exported carriedSectionsFrom behind an entrypoint guard and added two
executable preservation fixtures. Flagged by Magi (#5).
- edit-operations: dispatch() does not consult can() (session.ts:608 -> applyOp
with no validation; a no-timeline addGsapTween/addLabel is a no-op but a missing
target still writes via selector fallback). Use Rames's wording: call can() first
and skip on failure — no false 'applies nothing' guarantee.
- timing-and-animation: the second E_NO_GSAP_TIMELINE site — gated setGsapTween on
an error it cannot return and called shipped parser code 'a later phase'. Rewrote
to gate addGsapTween (which can return it); dropped the stale can() comment in
types.ts:595.
- html-schema: data-playback-start is read by runtime, Studio and CLI (Studio also
writes it, timelineEditingHelpers.ts); only the compile path is media-start only.
Document the layered precedence instead of calling it runtime-only.
Round-2 findings from Magi (#2/#3/#4) and Rames.
My generator rebuild replaced the '## Related topics' section (still required by
docs/AGENTS.md) with the provenance footer, dropping it from all 168 generated
Catalog pages. Emit it again as the final section so pages end with it, and stop
carriedSectionsFrom() breaking at the footer marker so a human section appended
below the generated tail survives a regeneration. Adds a per-page regeneration
assertion so a future drop fails CI. Flagged by Magi (P1) and Rames.
Fixes#3012
## What
Fixes `HeadlessExperimental.beginFrame` hanging at 60fps and other high frame rates.
## Why
Warmup always advances Chrome in 33ms steps, but the first capture timestamp was calculated from the output fps.
At 60fps, the last warmup tick is 1947ms, while the next commit tick used to jump back to about 1067ms. Chrome sees time moving backwards and `beginFrame` can stall.
## How
Keep the old timestamps when they are already safe. If the commit tick would go backwards, move the capture baseline past warmup first.
The init commit and both producer probes now use the same timestamp helpers, so they cannot calculate different values.
## Test plan
- [x] Added tests for 24, 30, 31, 32, 33, 60, 120, 240, and 59.94fps
- [x] Added coverage for the actual session baseline, commit parameters, and both producer probe paths
- [x] Engine tests: 1383 passed, 3 skipped
- [x] Producer unit tests passed
- [x] Engine and producer typechecks and builds passed
- [ ] Manual render test
A probe that could not run is no evidence about the GPU, so pointing the
operator at GPU passthrough hid broken Chrome installs behind a phantom
problem. Carry a cause off the probe and emit the matching remediation.
Also un-exports buildUnverifiedHardwareGpuWarning (Fallow: engine test
files are not audit entry points, so a test-only import would not have
counted as a consumer) and covers the non-linux branch via the spy.
Chrome's hardware GL args are advisory: with no usable GPU it silently
falls back to software WebGL and the capture runs at CPU speed. Run the
existing WebGL probe for explicit hardware mode too and warn loudly with
the platform's remediation. The requested mode is still honoured.
Fixes#2967