## What
- enforce a finite, validated `meta/videos.json` contract shared by Plan v1 and Plan v2
- preserve authored finite ends and source-derived trim-aware ends; bound any still-open end at the validated composition end
- fail distributed planning when any declared video source did not extract instead of publishing a blank-capable plan
- make the v1 chunk reader reject malformed/null video timing before frame injection
- route deterministic video-source/metadata failures as non-retryable in AWS and GCP while retaining retries for transient extraction failures
## Why
An open-ended video whose remote source could not be resolved retained `Infinity` through planning. Plan v2 correctly rejected that value, while Plan v1 serialized it as `null`; the v1 frame lookup could then suppress injected frames and silently produce incorrect output.
The invariant belongs at the shared metadata boundary. Both protocols must receive identical finite timing, and unavailable sources must fail closed before plan publication.
## Test plan
- [x] producer distributed planning, metadata, v1 chunk boundary, Plan v2 conversion/materialization, and public exports
- [x] core runtime media semantics (authored slots, natural duration, looping, non-looping hold)
- [x] engine video extraction and frame lookup
- [x] AWS Lambda/CDK/SAM and GCP Cloud Run error normalization/retry classification
- [x] producer, core, engine, AWS, and GCP typechecks/builds
- [x] formatting, oxlint, tracked-artifact, fallow, and commit hooks
- [x] exact incident composition replayed through the AWS Lambda handler's Lambda-local path in a Lambda-like container; Plan v1 and Plan v2 both fail closed as `VIDEO_SOURCE_UNRENDERABLE` during planning, before plan publication
- [x] full PR CI, including all nine regression shards and Windows render/tests
No production flags or deployment/release workflows are changed.
## What
Extracts the reliability-critical telemetry **delivery layer** — the in-memory event queue, async `flush()`, and the exit-time detached-child `flushSync()` — out of `packages/cli/src/telemetry/client.ts` into a new `transport.ts`.
`client.ts` stays the CLI-facing **policy** layer:
- `shouldTrack()` opt-out checks (dev mode, `DO_NOT_TRACK`, `HYPERFRAMES_NO_TELEMETRY`, config)
- `trackEvent()` system-metadata enrichment
- `showTelemetryNotice()` first-run disclosure
…and re-exports `flush` / `flushSync`, so `events.ts`, `index.ts`, and the `cli.ts` exit handlers keep importing from `./client.js` **unchanged**.
## Why
This is the code path that had the process-exit data-loss bug fixed in #2105 — render telemetry was ~6× undercounted and geographically US-skewed because the old drain-first flush emptied the queue before delivery confirmed, and the render command's `process.exit()` teardown killed the in-flight request. Isolating the delivery mechanism into its own focused, dependency-light module (only `./config` + node builtins) keeps that subtle, reliability-critical path in one place and reduces `client.ts` to just policy.
Follow-up to the render-telemetry-gap investigation. A delivery-health canary was also added to the [CLI Observability dashboard](https://us.posthog.com/project/356858/dashboard/1634055) — `render_complete ÷ successful render commands`, which should sit ~1.0 and would surface any regression of this class immediately.
## How
Pure code motion — **no behavior change, public API identical**. `transport.ts` owns the queue and stamps each event's dedup `uuid` + ISO timestamp in a new `enqueue()`; `trackEvent()` enriches with system metadata then calls `enqueue()`. `buildPayload`/`flush`/`flushSync` bodies are moved verbatim.
## Test plan
- [x] `vitest run src/telemetry/client.test.ts src/telemetry/events.test.ts` → **38/38 pass** (client.test.ts still validates queue-retention, uuid idempotency, and the detached-child flushSync path through the public API — unchanged)
- [x] `oxlint` clean, `oxfmt --check` clean
- [x] `tsc --noEmit` — no new type errors in `telemetry/`
- [x] `bun run build` succeeds
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
Adds the weekly digest entry for July 20 to July 27, 2026 to `docs/weekly-updates.mdx`.
Generated with `bun run changelog:weekly --from 2026-07-20 --to 2026-07-27 --write`, then rewritten to publish quality: entries grouped under Features, Fixes, and Docs, prose rewritten to describe user impact, and the `<!-- TODO: review -->` marker removed.
## Why
Keeps the public weekly-updates page current. The digest is the RSS-backed summary readers get between versioned releases.
## How
- Ran the generator over the July 20 to July 27 range.
- Rewrote the new `<Update>` block: 11 Features, 15 Fixes, 3 Docs bullets.
- Every commit and PR link the generator produced for these entries is retained. No link was invented; all references trace back to `updates/weekly/2026-07-27.md`.
- Headline items: professional color grading across core, Studio, and the CLI; a versioned distributed plan protocol with direct S3 and GCS publishing; the Studio keyframe ease editor; data-driven registry caption components.
- `renderStretch` (#2676) is deliberately omitted, since it was reverted in the same window by #2730 and is not present at HEAD.
- CI-only performance and internal refactor entries are omitted as not user-facing.
## Test plan
Docs-only change. No runtime code touched.
- [ ] Unit tests added/updated
- [x] Manual testing performed
- [x] Documentation updated (if applicable)
Verification performed:
- `git diff origin/main --stat` shows only `docs/weekly-updates.mdx`, 49 insertions and 0 deletions (purely additive, the prior week's entry is untouched).
- Confirmed the TODO marker is gone from the file.
- Confirmed every commit SHA and PR number in the new block appears in the generator's own draft, and that each link label matches its href.
- Confirmed the block contains no em-dashes and no stray JSX-unsafe characters outside inline code.
- `node scripts/check-tracked-artifacts.mjs` passes.
* feat(check): opt-in --layout proseCoverageFloor for text_occluded
Keep the default prose coverage floor at 0.15 for all callers, and allow
stricter agents (e.g. Zephyr) to lower it via --layout "proseCoverageFloor=0.05"
without changing other layout gates.
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(check): collapse --layout comments and docs to one line
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(check): strict proseCoverageFloor parse + pin 0.07 floor tests
Reject trailing-garbage fractions that Number.parseFloat would accept, and
pin the existing ~0.07 coverage fixture for default vs floor=0.05 (atomic
labels unchanged) plus a collectLayout forwarding assertion.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(check): share parseNumberStrict across layout and frame-check
Sweep the sibling --frame-check tol parser (and caption fractions) onto the
same strict Number() helper so trailing garbage cannot prefix-parse.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Review follow-ups on the expanded keyframe lanes.
Writer:
- `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it
dispatched update-meta, so a rejected write left the diamond parked at its drop
position. `observeGsapMutation` now resolves to whether the mutation landed and
the boundary branch returns it, matching the other branches.
- "Delete All Keyframes" cleared only the first keyframed tween on the layer, so
a layer with position AND opacity keyframes kept half of them. It now walks
every keyframed tween, serially, through the clicked element's selection.
- The post-convert lookup in `commitFlatViaKeyframes` matched by target selector,
which picks an arbitrary tween when a target carries several. Match by id first.
Interaction and a11y:
- A rejected retime whose commit settled after a newer drag reverted the
selection to its own source keyframe, undoing a retime the user could see. The
revert now only runs while it is still the lane's latest gesture.
- Diamonds key on the authored identity instead of index plus rendered clip-%, so
a neighbour's retime no longer remounts the button mid-drag.
- The disclosure caret gets `aria-controls` on an always-mounted lanes container,
and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum.
- `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane
rows instead of hardcoding LABEL_COL_W over the canvas.
Test locks:
- The timeline callbacks harness resolves a DISTINCT selection per element, so
the clicked-element writes are actually pinned; three assertions that passed
either way now name the clicked element's selection.
- New: null-selection aborts every mutation, delete-all covers both tweens, a
rejected boundary retime reports `false`, and a stale revert leaves selection.
- The playhead-percentage assertion checks 25, not `expect.any(Number)` (which
also accepts NaN); ease segments assert their label ORDER, not just that the
three curves differ; the collapsed-diamond callback asserts the whole target.
- Dropped a duplicate `selection override` describe left by a rebase.
## What
Fix Windows cleanup ordering for timed-out CLI downloads so `downloadFile()` rejects only after the active response pipeline has closed and the `.tmp` file has been removed.
## Why
Main CI run 30299854376 failed in `packages/cli/src/utils/download.test.ts` because the timeout path rejected from the request `error` handler while the response pipeline still held the temporary file open. Unix permits unlinking an open file, but Windows reports the file as locked; that cleanup error was swallowed and the promise settled while the partial file still existed.
The race was introduced by #2415 / `75eedf5cc1`, which added the inactivity timeout and separate request-error cleanup path.
## How
- Track whether a response pipeline owns the temporary file.
- When the request errors after streaming has begun, destroy the active response and let `pipeline()` finish tearing down the writer.
- Preserve the original request/timeout error, then remove the partial file before rejecting.
- Make the regression deterministic on every OS by simulating Windows `EBUSY` until the response closes.
## Test plan
- [x] Unit test updated to reproduce the Windows locked-file lifecycle on Linux
- [x] Focused regression test (20 consecutive runs)
- [x] Full CLI suite: 164 files passed, 1 skipped; 2,162 tests passed, 2 skipped
- [x] CLI typecheck
- [x] Full workspace build
- [x] Changed-file oxlint, oxfmt, Fallow, tracked-artifact check, and pre-commit hooks
- [ ] CI green on Linux, macOS, and Windows
An explicit null selection override now aborts the write instead of falling
back to domEditSelection: a caller that resolved a selection for its own
element and found none was committing onto whichever element happened to be
selected. Ease changes and the playhead keyframe toggle resolve the edited
element's animations and selection instead of the current selection's, and
lane header rows follow the real label-column width so a narrowed column no
longer hangs its value readout over the canvas.
Review follow-ups on the expanded-lanes tip:
- TimelinePropertyLanes.groupKeyframes re-derived the clip-relative
percentage inline and skipped toClipKeyframes' rounding, the one
precision every keyframe-cache writer has to agree on (selection keys
embed the number). It now loops the shared helper per animation with
the group filter and only overrides the lane's own group and ease.
- TimelineClipDiamonds rebuilt and re-sorted each tween's sibling row
inside the marker loop, so a row of N diamonds allocated N sorted
copies of itself on every playhead tick. Built once per render instead,
keyed by animation id.
- Add the missing symmetry test at the real callback boundary: the
diamond test mocks onMoveKeyframe, so nothing proved the rapid-second
retime resolves a pending clip-% the keyframe cache has not caught up
to. The new test asserts the identity-carrying target retimes and that
the same drag without identity fields cannot.
CONTRIBUTING.md allows `as unknown as T` with a justification, not a bare
`as T`; the gsapShared fixtures only carry the fields under test.
The fallow complexity gate flagged three functions on this branch. Each is
split at its natural seam rather than suppressed: the auto-expand scan moves
out of the effect, the four repeated attribute guards in
nodeMatchesManifestClip collapse into one table-driven check, and the
segment-% interpolation moves out of onPathDown.
Selector reads now go through one inverse of `idSelector`. Every writer emits
`[id="01-hook-hero"]` for an id a `#id` selector can't address, but the readers
still matched `#id` only, so the post-commit keyframe-cache refresh, the AST
load and the remove-all-keyframes clear all silently skipped exactly the ids
`idSelector` was added to support.
A keyframe merged from two tweens with different eases kept whichever ease
iterated last. Readers that don't check `easeAmbiguous` showed a curve from a
different animation than an edit would target, so the ambiguous flag now clears
`ease` instead of leaving an arbitrary one behind.
One tolerance for "the playhead is on this keyframe". The motion-path drag used
0.05% while the toolbar and the playhead apply used 1%, so a drag that landed a
fraction of a percent off an authored waypoint skipped the update-point branch
and appended a near-duplicate. `buildTemporalArcKeyframes` now owns the
invariant and replaces any keyframe inside the tolerance, rather than trusting
each caller's own pre-check.
The pending-retime bookkeeping matches on keyframe identity, not just on
"something is near that percentage" — an evenly spaced row cleared the entry off
an unrelated sibling. The neighbour clamp composes pending destinations in
before sorting, so a second drag can't cross a neighbour that already moved.
Also: `keyframeCache`/`gsapAnimations` setters return the same state for a write
that changes nothing (every no-op re-rendered every subscriber), the auto-expand
set drops clips that left the source so an undo/paste under the same id expands
again, `invalidateGsapCache` has a stable identity instead of re-creating the
whole timeline edit context each render, the studio test hook deletes its window
key rather than leaving it enumerable as undefined, and the past-last-row
extrapolation documents why it uses TRACK_H where the pre-first-row branch uses
row 0's own height.
Covers `idFromSelector` round-trips, the insert boundary band across plain,
expanded and unusable row heights, and the collapsed selection key for a
colon-bearing element id.
Two more regressions the QA triage attributed to this stack.
Dragging a flat tween's boundary diamond did nothing: the handler bailed on
`!anim.keyframes` even though resolveKeyframeRetime already resolves that case
to a position/duration resize. The empty remap now dispatches update-meta, which
moves the window without rewriting the authored flat tween into keyframes form
(what the keyframed-resize writer would do as a side effect).
The property panel's keyframe gutter guessed one animation per property group,
so clicking a keyframe authored by a sibling tween on a merged row named the
wrong tween and the writer silently removed nothing. The diamond now reports the
clicked keyframe's own animationId, with the group guess kept as the fallback for
cache rows that carry no identity.
The QA fleet's 295 findings were replayed against the merge-base. Most were
pre-existing, but these were caused by this stack:
Deleting one keyframe destroyed the whole tween. The lane-header remove toggle
escalated a flat tween to a whole-animation delete, which took the authored
`tl.to(...)` and its source comment with it on a single click. The base build
posts remove-keyframe and lets the writer refuse it; restore that.
A keyframed layer could not be hidden at all. The visibility eye had moved off
the always-mounted layer row onto a hover-gated property-group row, so it only
existed while the lanes were expanded AND the pointer was over that lane. A
keyboard-only user could reach no eye at all, and its label named a track its
row did not act on. It goes back on the layer row.
A drag from the centre of a clip bar did nothing, because the 16px inline ease
button sits exactly there and swallowed the press. It now lets the press through
to the clip and keeps only the click, dropped if the pointer travelled.
Dragging a diamond onto a neighbour silently discarded the retime. The clamp
bounded the dragged keyframe by the whole merged row, so two animations
colliding at one percentage pinned each other in place and the drag resolved
back to a click. Clamp against the dragged keyframe's own tween instead.
Also: floor the diamond hit box at 12px (the gap-derived size fell to ~7px at
the zoom floor), round the diamond tooltip percentage, and prune the keyframe
caches when a composition switch drops a file from the scan set — each file only
ever cleared its own entries, so the previous composition leaked every element
into both keyframeCache and gsapAnimations, with nothing to evict it.
Validate the parse response before reading `.animations` instead of casting
the JSON blind, and narrow the fetch's return type to the slice callers read.
Route the AST cache load through the shared clip-keyframe and cache-key
helpers so it can't drift from the other writer. Drop the unused numeric
track-count branches from `trackHeights`/`getTimelineCanvasHeight`, and pick
the widest keyframed clip with a reduce so there's no index assertion.
Carries the gsapAnimations/keyframeCache symmetry fix into the extracted
AST loader: an ungrouped tween now lands in both stores, so the collapsed
row and the expanded lanes agree on what a clip animates.
Auto-expansion picks the composition whose clip window contains the
playhead, and those windows are half-open. With the playhead parked on
the last clip's end - where playback stops - it sat inside nothing, so
every expanded sub-composition row and its keyframe lanes collapsed to a
single host row.
Accept the closing boundary, but only when the strict pass matched
nothing, so a playhead on the seam between two adjacent clips still
expands the one that is starting.
The keyframe cache was fetched for a single source file: the selected
element's, else the active composition. On open nothing is selected, so
a project whose clips live in sub-compositions loaded only index.html
and every property lane rendered empty until a clip was clicked.
Load the cache for each composition file the timeline has rows for, so
keyframed clips are expanded on open as intended. The AST load path
moves to keyframeCacheAstLoad.ts to keep useGsapTweenCache.ts under the
600-line cap.
- revert diamond selection when a rejected retime leaves the source in place
- clear project-local ease focus and expansion on player store reset
- share one static-position-hold predicate across the tween cache
- invalidate the GSAP cache even when a group timing rewrite throws
- stamp each lane keyframe's ease from its own source tween
- memoize property lanes and row offsets so memo'd diamond lanes hold
- use the editable tween duration for drag position commits
- restore the pre-t=0 pad in the all-collapsed content origin
- clamp the drag ghost and drop placeholder to the collapsed clip height
- aria-expanded on the layer disclosure, aria-pressed plus state-specific
labels on the keyframe toggle, 24px chevron targets, focus-visible parity
The diamond context menu still declared `onChangeEase` and `onCopyProperties`
props, and `TimelineOverlays` still threaded `onChangeKeyframeEase` plus a
`keyframeCache` it never read. Nothing on any timeline branch calls them, so
they are removed along with the `onChangeKeyframeEase` callback implementation.
Also from review:
- `deleteSelectedKeyframes` only falls back to the sole keyframed animation when
there is exactly one. A collapsed selection key carries no animation id, so
taking the first of several deleted an arbitrary tween's keyframe.
- The duration-less retime test asserts the real 87.601% instead of
`expect.any(Number)`, so a wrong timing basis fails it.
- `Timeline` wires the keyframe handlers' `onSelectSegment` through to the
diamonds; it was built and then dropped, so segment ease selection never fired.
- The flat text section arms auto-focus in state rather than reading and
clearing a ref during render, which Strict Mode's double render swallowed.
The context menu handed its actions loose positional arguments, so the
overlay adapters forwarded the percentage alone and dropped the property
group, tween percentage, and animation id the menu had resolved. The target
then fell back to first-match-by-percentage and deleted or retimed the wrong
animation whenever two collide at one percentage. Every action now carries a
TimelineKeyframeTarget, which leaves no adapter shape that can drop it.
TimelineClipDiamonds.tsx had grown past the 600-line studio file gate, and
its keyframe/props types were declared a second time next to the ones the
lanes already share. The types, the render constants, and the keyframe
identity helper move to timelineDiamondTypes.ts; the rendering stays put.
The collapsed clip row dropped a keyframe's property group and animation id
before handing it to a callback, so the same keyframe hashed to a different
selection key than the expanded property lane did. Selecting a diamond in one
view left it unselected in the other, and retime/delete on the collapsed row
lost the animation id they use to pick between two animations that collide at
one percentage.
Diamonds now always carry their full identity, the collapsed shim just curries
the element id, and Timeline reuses useTimelineKeyframeHandlers instead of its
own inline copy of the same three handlers.
Neighbour geometry moves into one marker record per diamond, which drops the
index-lookup non-null assertions the connector pass needed.
Two sibling call paths still answered from GSAP's 0.5s default while the
toggle path had moved to the clip-wide fallback.
isPlayheadWithinTween now takes the selection, so the toolbar stops
promising "extends animation" on a duration-less tween whose click actually
toggles an interior keyframe. The arc drag commit resolves its replacement
duration the same way its click-path sibling does, instead of authoring
duration 0.5 and collapsing the arc window.
The flat text section drops its two marker-clearing effects: the autofocus
marker is a ref now, read and cleared by the render that consumes it.
Each timeline keyframe callback now reads and writes through the SAME
element: the clicked element's animations resolve the target, its selection
commits the mutation, and its animation computes the playhead percentage.
onMoveKeyframeToPlayhead previously took the percentage from the clicked
element and the animation plus selection from the current one, so a context
menu on a non-selected diamond retimed against one tween and wrote into
another file. onChangeKeyframeEase and onToggleKeyframeAtPlayhead had the
same split. An explicit null selection override now aborts the write instead
of falling back to the current selection.
* docs(send-to-guide): enhance turns are free; render is the paid step
The shipped pricing model is import + enhance turns free, only the final
render charged (a monthly free-render credit, then per-minute). The guide
labeled enhance as 'the paid step', which misstates the model to the
authoring agent. Move the paid label to Render.
* docs(send-to-guide): state the tiered render billing contract + pin it in the guide test
Address review: the pricing line must teach Claude the real tiered contract,
not a single universal free render. Per heygen-server usage_limits.py: FREE
accounts get 3 renders/month (then blocked, not billed); paid plans are charged
20 credits per rendered minute at completion. Enhance turns are free.
Also pin the invariant in sendToGuideContract.test.ts: assert Enhance=free /
Render=paid + the tiered figures, and a negative assertion blocking the retired
'Enhance ... paid step' wording from returning.
The timeline-element animation lookup rebuilt the three cache-key variants
by hand. elementCacheKeys already owns that list for the writers, so this
reader takes it from there instead of drifting from it.
Drag-to-retime resolved the dragged diamond against the selected element's
animations and committed through the selected element's DOM selection, so
dragging a diamond on a non-selected clip retimed the wrong tween. It now
resolves against the clicked element's animations and commits through that
element's selection, matching the delete path.
The three diamond callbacks also take the TimelineKeyframeTarget they already
had instead of five positional fields, and the two copies of the
sourceFile#domId split share splitTimelineElementKey.
The lane-header keyframe toggle fires on whichever element owns the lane,
which need not be the selected one. The remove path looked the animation up
in the selected element's animations, so a non-selected element's flat tween
missed and silently took the remove-one-keyframe branch, stranding the tween
instead of deleting it.
Three review follow-ups on the editor-callback consolidation.
The keyframe-target resolve now takes the clicked element's key and reads
that element's keyframe cache. The diamond context menu and move-to-playhead
pass no explicit target, so they fell through to the cache of whatever
element happened to be selected: opening the menu on a non-selected
element's diamond resolved against the wrong keyframes.
PropertyPanelFlat opens the Motion group by adjusting state during render
instead of in an effect, so the AnimationCard mounts on the same commit the
focus request arrives on rather than a frame later.
Both animation sections pass a module-level focus consumer instead of a
fresh inline arrow, so AnimationCard's focus effect stops re-running on
every parent render.
LegacyTrackHeader reads as deprecated code. It is the live path for every
track that has no keyframe clip to disclose, so call it PlainTrackHeader and
say so in a comment.
The header file owned value sampling, readout formatting, lane-state
resolution and the JSX at once, so a formatting change and a layout change
edited the same file. Sampling and formatting now live in
trackHeaderLaneValues, lane-state resolution in trackHeaderLaneState, and
resolveLaneHeaderState returns only the four fields its caller reads.
Also shows the track's clip count next to the track identity, which the
header promised but never rendered.
The prev/next keyframe chevrons and the group toggle diamond let their
click bubble to the ancestor track row, so seeking to a keyframe also
reselected the track. The disclosure caret and the eye already stop it;
these now match.
Truncated labels (layer name, track label, group label, value readout)
also carry a title so the full text is reachable on hover.
CONTRIBUTING.md asks for a guard clause rather than a non-null assertion outside
an already-checked path. The index check and the lookup are now the same guard.
Escape now ends an in-flight diamond drag the way it already ends clip
and element drags: the armed gesture is marked cancelled, the preview is
dropped, and the pointerup that follows is swallowed instead of falling
through to the click branch.
The preview also flushes once per animation frame instead of once per
pointermove, so a high-rate trackpad no longer re-renders every diamond
in the row several times a frame. Single-diamond retime stays the
documented scope; multi-select drag needs a batched mutation the script
ops do not express yet.
Rows stopped sharing one pixel height when lanes gained expansion, so the only
production caller was passing cumulative row coordinates with trackHeight 1 and
both scrollTops zeroed. The parameter names described units the values no longer
carried. The vertical axis is now a row index and the caller keeps ownership of
folding scroll and per-row heights into it.