When the focused segment spans multiple source animations (collidingAnimationIds),
the ease editor commits through onUpdateSegmentEase (one-undo batch fan-out to all
of them) instead of the single-animation path; a single-property segment keeps the
single path. Show an 'Applies to N properties' note for multi-id segments; the
starting curve is the primary animation's ease (mixed eases collapse to primary).
The curve drag has no live-preview callbacks wired, so no preview fan-out is added.
Completes the bulk collapsed-row ease editor; supersedes the hide-on-ambiguity
behavior.
Add handleUpdateSegmentEase(animationIds, tweenPct, ease): a single id commits
through the existing single-mutation path; multiple ids dispatch one ease-update
mutation per id through the commit layer's batch path (gsap-mutations-batch) so
the whole fan-out is ONE undo entry, in the given deterministic order.
handleUpdateKeyframeEase is preserved as a thin single-id wrapper (no behavior
change). Exposed as onUpdateSegmentEase through the panel callback bag.
Remove the collision-based suppression: the collapsed-row inline ease button now
shows on every segment (bulk edit handles the shared-keyframe case in U4/U5).
Only a keyframe with no source animation id (runtime-scanned) still hides it.
onSelectSegment now carries the merged keyframe's collidingAnimationIds through
the target and into focusedEaseSegment (alongside the primary animationId,
tweenPercentage, and elementId scoping). Single-animation segments leave the set
undefined. Sets up the bulk ease commit + editor surface (U4/U5).
Foundation for bulk collapsed-row ease editing: each merged keyframe now carries
collidingAnimationIds (the source animation ids sharing that clip %, first-seen
order, deduped) via a shared accumulateCollidingAnimationIds helper used by both
merge sites (deduplicateKeyframes and updateKeyframeCacheFromParsed), replacing the
boolean easeAmbiguous. The inline ease button gate temporarily derives ambiguity
from the set length; U3 removes that suppression entirely.
- Flag a collapsed-row segment ambiguous on ANY cross-animation collision at the
same %, not only when raw eases differ: the single inline button can target
only one arbitrary animation, and each may inherit a different easeEach/animation
ease that raw comparison misses.
- Scope the flat inspector's ease-focus to the element the panel actually renders
(its own sourceFile#id) instead of the store's selectedElementId, which flips
synchronously during async selection resolution and let a stale panel consume a
focus meant for its successor when both share a class-selector animation id.
- Rename the stale byte-identical cache test to describe the shape+identity it now
asserts.
The earlier removal fix resolved the animation from the clicked element but
still called removeKeyframeTarget without a selection override, so the mutation
persisted through the current domEditSelection — wrong when the clicked element
lives in a different source file. Build the clicked element's own DomEditSelection
and pass it as the override. Tests assert the override fires for a non-selected
element's flat and keyframed endpoints.
Re-review of the earlier retime fixes found edge-case gaps:
- Rapid second retime now submits the pending (already-moved) position as the
mutation's FROM identity, not the stale rendered one, so the serialized
mutation locates the keyframe the first move relocated.
- Clamp the reselected tween % to the mapped range: clipToTweenPercentage
extrapolates, so a boundary drag past the range no longer reselects an
out-of-range % (e.g. 150%) while the mutation clamps the endpoint to 100%.
- Clear pending retime entries by tolerance, not exact-% equality (cache writers
round), so entries no longer leak after a successful retime.
- Hide the inline ease button on a segment whose keyframe has no source
animation id (runtime-scanned), avoiding a dead button with no target.
The collapsed clip row merges a clip's source animations and dedups keyframes
by percentage (last ease wins), so an inline ease segment where two animations
collide at the same clip % with different eases targeted an arbitrary tween.
Flag such merged keyframes (easeAmbiguous) at both merge sites (deduplicateKeyframes
and updateKeyframeCacheFromParsed), computed before the ease overwrite, and hide
the inline ease button on those segments; unambiguous segments are unchanged and
the user edits colliding ones by expanding the clip's per-lane view.
A second diamond retime issued before the first commit's async cache rebuild
resolved its delta from the original pointer-down clip %, so it landed on a
stale/no-op target and was silently discarded. Track the last-resolved clip %
per keyframe key and use it as the next drag origin; clear it once the cache
reflects that percentage. Preserves U1's post-move reselect; no optimistic hold.
The move branch reselected with {...target, percentage: toClipPct}, keeping the
stale pre-move tweenPercentage, so activeKeyframePct and a later canvas edit
targeted the old keyframe. Derive the post-move tween % from the new clip %
via clipToTweenPercentage (scoped to the target animation's keyframes; cache
rebuild is async so we can't read it back synchronously).
onDeleteKeyframe resolved the animation from selectedGsapAnimations, so removing
a flat-tween lane endpoint on a non-selected element found nothing and no-opped.
Resolve the clicked element's animations from the store (source-prefixed /
index.html / bare-id keys) and thread them through resolveKeyframeTarget and
removeKeyframeTarget; the drag-commit path keeps using the selection. Covers
non-selected flat + keyframed deletion and the selected-element regression.
The thumbnails-off-by-default toggle (6b5ea8257) left this test asserting
thumbnail content without enabling thumbnails, so it produced no thumbnail
content and failed. Enable thumbnailsEnabled for the case and reset it in
afterEach; the label ownership assertion is unchanged (TimelineClip owns names).
- focusedEaseSegment now carries elementId (set from the clicked timeline
element). PropertyPanelFlat and FlatMotionSection only open/consume the ease
editor when it matches the selected element, so a shared class-selector
animation id can no longer open the wrong element's editor. Verified the
common single-element inline-ease path still opens correctly.
- TimelineClipDiamonds: clear the armed drag and preview on pointercancel so an
interrupted diamond drag no longer leaves a stuck ghost.
- Timeline: hasKeyframedClips now uses animationContributesLane (the same
predicate the layout/auto-expand use) instead of checking animation.keyframes
directly, so a composition whose only animated clips are synthesized flat
tweens still reserves its label column and gutter.
- EaseParamFields: clamp cubic-bezier control-point X to [0,1] on commit so an
out-of-range entry can't persist as a non-monotonic (broken) custom ease; Y
stays free for overshoot curves.
- useGsapTweenCache: the file-wide cache producer now stamps animationId on
every keyframe, matching the selected-element and post-mutation writers, so
the inline clip-row ease button works on a collapsed clip that was never the
selected element (it silently no-opped before).
- springEase: restore the deprecated re-export of the parsers spring-ease
symbols on core's still-published ./spring-ease subpath (dropping them was a
breaking change for external importers).
- gsapKeyframeCacheHelpers.test: update two stale exact-shape assertions that
omitted animationId and were failing against the producer (pre-commit runs no
vitest, so they slipped in).
- EaseCurveSection: replace an em dash in the preset hint with a colon.
The flat inspector never consumed focusedEaseSegment: FlatMotionSection
rendered AnimationCard without focusedSegment/onFocusSegmentConsumed, and
the accordion left the Motion group collapsed, so clicking the inline
clip-row ease button only selected the element. Mirror the legacy
GsapAnimationSection: pass the focused segment to the matching card and
open the Motion group when a segment on this element is focused.
The mid-point ease button only rendered in the expanded per-property lanes: it
was gated on `groupAware`, and the inline clip-row diamonds are rendered
group-unaware without an onSelectSegment handler. The ease is per-keyframe
(each carries its own animationId/tweenPercentage), so the button resolves
unambiguously on the merged inline row too. Thread animationId into the
keyframe cache, gate the button on onSelectSegment (with an always group-aware
target), and forward onSelectSegment through the inline TimelineClipDiamonds.
Releasing a bezier control point committed the new ease AND cleared the local
draft synchronously. The committed ease prop only returns a few frames later
(write -> reparse -> re-render), so in the gap activeTuple fell back to curve
derived from the STALE prop -> the curve snapped to the old value for a frame
then jumped to the new one. Keep the draft on screen and clear it via an effect
when the ease prop actually changes, so curve already equals the draft when the
handoff happens.
Element ids that aren't valid CSS identifiers — most commonly digit-leading ones
like 01-hook-hero-word — make #id an invalid selector: querySelector and GSAP's
querySelectorAll throw a SyntaxError, surfacing as a masked cross-origin Script
error that crashes the preview the instant such a target is committed (dragging
the element writes gsap.set(#01-...)). Route the studio's commit/selection
selector building through a shared idSelector() that falls back to an
[id=...] attribute selector for those ids.
Dev-server thumbnail generation only probed three hardcoded system Chrome paths,
so on a machine with no system Chrome install (but a Puppeteer-cached
Chrome-for-Testing) it found no browser and every thumbnail request 500'd.
Resolve the executable from PUPPETEER_EXECUTABLE_PATH / CHROME_PATH / CHROME_BIN,
then a broader set of system browsers, then the Puppeteer cache (highest
version).
A GSAP keyframes tween resolves its inner timeline's ease ONCE, at build time,
via the internal ease map. The composition inline script builds these tweens
before the runtime registers the custom eases (hold/spring/wiggle/custom), so a
custom container ease bakes the inner _ease to undefined; GSAP then throws
_ease is not a function on the first render (masked as a cross-origin Script
error) on composition load and every manual edit. Re-resolve each keyframes
tween's inner ease during rebind, once the eases are registered.
The one-shot ensureStudioCustomEase() in init runs before GSAP may be ready and
never retries, so on a lost load-order race the custom eases are unregistered
when bindRootTimelineIfAvailable fires its prime render (progress/totalTime) —
and a keyframe segment using hold/spring/wiggle/custom throws _ease is not a
function (masked as Script error on composition load). Call the idempotent
ensure at the top of the rebind so registration is guaranteed before any render.
Custom eases (hold/spring/wiggle/custom) only overrode the public gsap.parseEase.
GSAP resolves a keyframe segment's ease through its INTERNAL _parseEase/_easeMap,
which never saw the override, so a custom ease inside keyframes:{...} resolved to
undefined and threw _ease is not a function on the first render (surfacing as a
masked cross-origin Script error on every keyframes composition and every manual
edit's soft-reload rebind). Register the eases in the internal map too; a
configurable ease's .config rejoins GSAP's comma-split params to losslessly
reconstruct the original string, including custom() bezier paths.
A %-keyed object keyframe step's duration (GSAP array-keyframe segment
timing) was collected as an animatable property, so it surfaced as a bogus
duration keyframe lane and got round-tripped as a property — corrupting the
tween on the next manual edit in Studio. Both the live acorn read parser and
the retired recast parser had the leak; the array-form parser already
excludes it. Skip duration in the %-keyed extraction to match.
Follow-up to the load-time TDZ fix: the real invariant is that the runtime's
external control surface must not be callable before the helpers its handlers
dispatch to are declared. Moves installRuntimeControlBridge to run after every
transport helper (seekTimelineAndAdapters, applyWebAudioRate, ...), so a
load-time seek / set-playback-rate can never reach an uninitialized helper.
One owner of the 'runtime is controllable' boundary -> the TDZ class can't
recur via a future helper added below the wiring.
Background thumbnail generation (per-clip composition/video frame capture)
lags timeline scrubbing on long videos (#2428). Adds a persisted
thumbnailsEnabled setting, default OFF, so the timeline is snappy out of
the box; a toggle next to Fit in the zoom toolbar turns thumbnails on.
When off, clips render as plain bars (audio still shows its waveform).
seekTimelineAndAdapters and applyWebAudioRate were const arrows declared
~370-680 lines after the control bridge + transport that call them. When
the studio issues a seek / set-playback-rate during composition load
(before those consts initialize) the runtime threw ReferenceError: Cannot
access '...' before initialization, cascading to 'l._ease is not a
function' in GSAP. Hoisting both to function declarations makes them
callable regardless of init order.
Squash-merge of the keyframe-timeline feature onto the current main, which
had refactored the timeline in parallel. Preserves main's work (z-order
mirror, track gap menu, gap highlights, hook extraction) and layers the
keyframe feature on top: expandable property lanes, per-segment ease editor
(curve/spring/wiggle), flat-tween lanes, prev/next keyframe nav.
Unified the horizontal origin on the threaded contentOrigin parameter
(GUTTER collapsed, LABEL_COL_W in keyframe mode); main's TRACKS_LEFT_PAD
horizontal pad is superseded by the label-column model.
* feat(skills): probe and bump stale CLI pins during project resume
The entry skill now keeps a resumed project's pinned CLI current instead of
leaving that to a notice nobody acts on. On resuming a project with pinned
scripts, run the read-only probe 'npx hyperframes@latest upgrade --project
. --check'; when it (or the stale-pin stderr notice, or _meta.updateAvailable
from a pinned run) reports the project behind, apply the bump and verify
with 'hyperframes check'. A failed check reverts the bump and keeps the
project on its pinned version, preserving the reproducibility contract the
pin exists for.
The probe matters because the stale-pin notice only exists in >= 0.7.59:
a pinned run of an older CLI prints no warning at all, so a notice-only
trigger never fires for exactly the projects most behind. The probe runs
unpinned, so its behavior never depends on the project's CLI age.
Telemetry: the fleet converges to new releases within about a week via the
background auto-updater and ephemeral npx, but pinned projects form a
persistent stale tail (~10% of weekly actives, e.g. 6.3k users still on
0.6.x three weeks after 0.7.0).
Both skill surfaces now pass an explicit dir ('--project .') because a bare
'--project' followed by another flag consumes that flag as its directory
value and no-ops; the parsing fix is a separate CLI change.
* fix(cli): stop bare --project from eating the next flag as its directory
citty parses --project as a string option, so 'upgrade --project --check'
arrived with project="--check": the dir resolved to a nonexistent path and
the command no-opd with 'No package.json found' while --check was lost.
The documented default-cwd behavior only worked when --project was the
final token — and the trap-prone form is exactly what the scaffolded
template CLAUDE.md instructs.
A leading dash can never be a real directory argument, so resolveProjectArgs
now reclaims the eaten token as the flag the user wrote (--check / --json),
falls back to the current directory, and drops unrelated eaten flags rather
than treating them as paths. Templates and skill references switch to the
explicit-dir form ('--project .'), which behaves correctly on every release
including ones that predate this fix.
* feat(skills): report a successful pin bump in the run summary
Review follow-up on the stale-pin rule: 'hyperframes check' validates
composition structure, not render-output equivalence, so a check-passing
bump can still shift a project's rendered output. The bump stays the right
default for stale projects, but it must not be silent — the summary now
names the old and new version so the user knows the reproducibility
trade was made.
* docs(guides): add Claude Design → Send to HyperFrames single-file import guide
Self-contained authoring guide for the 'Send to HyperFrames' MCP import path
(distinct from the download-ZIP flow): single self-contained HTML wire format,
the composition contract the importer validates, inline-brand-assets fidelity
rules (base64 data: URIs), no bundled/splash artifacts, keep-the-original-as-
reference, a complete worked skeleton, the animation-pattern catalog, and
determinism rules. Reviewed for technical accuracy against a known-good
imported+rendered composition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(send-to guide): assets must be resolvable (inline OR fetchable URL), not inline-only
Large media can exceed the import size cap, so "inline everything" is wrong for it,
and the import tool description now states "resolvable refs." Align the guide: inline
data: URIs preferred (fonts/images/logos — the only fully self-contained form), a
publicly-fetchable absolute URL allowed for large media, and explicitly forbid relative
paths / local-file variables / expiring-or-private hosts (the media-expiry trap).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The committed artifact was generated with an older esbuild than the
current lockfile resolves; CI's check:position-edits-render regen now
produces different (equivalent) minifier variable naming and fails the
diff gate. Regenerate to match — no source change to positionEdits.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A window.__timelines entry is authored content and may be a partial
RuntimeTimelineLike (duration/seek only, no pause). Timeline resolution
is deliberately permissive — duration-based — and such compositions
render fine, because the render path only seeks. But every interactive
transport path (play/pause/seek, bind, rebind-tick, boot) called
capturedTimeline.pause() unguarded, crashing studio playback with
'tl.pause is not a function' — the top recurring studio:unhandled_error
in telemetry across versions 0.6.121 through 0.7.59 (~150-175/day).
Guard all pause sites through one helper (typeof check + swallow, plus
a once-per-page timeline_missing_pause analytics event so composition
authors can find the partial timeline), matching the safeVoid pattern
player.ts already uses. In the rebind restore path, pause is guarded
separately so a missing pause() no longer aborts the seek/play restore
behind it in the same try/catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both panels showed Layout (X/Y/W/H/Rotation/Z-index) unconditionally —
no gate existed for it at all — and Style was gated only on
canEditStyles (a permission check), never on the element's tag. Neither
gate accounted for `<audio>`, which never paints a visual frame, so a
music track's inspector showed a full set of position/size/fill/shadow
controls with zero visual effect.
Add `layout`/`style` applicability to resolveEditingSections (core),
keyed on tag !== "audio", and gate both panels' Layout section and the
existing Style gate on it. Media/Motion/Grade/Text were already
correctly gated (verified via a research pass across both panels) and
are untouched.
6px between rows left little clearance above a row's value for the
promote-to-variable badge (now positioned above the row). Widen the
row gap to 10px.
Shrinking the wrapped control's width to make room for the badge (previous
commit) fixed the overlap but pushed the value left unnecessarily. Move the
badge to sit above the row instead, clearing the value without touching its
layout.
PromotableControl absolutely-positions its "◇ var" / "◆ {id}" badge over
the wrapped control without reserving any space, so on rows where the
value renders flush to the right edge (flat Font/Color rows) the badge
sat directly on top of the value text instead of beside it. Add a
right-padding gutter on the wrapper sized to each badge state, and cap
the bound chip to a fixed max-width so it always fits inside its gutter.
Addresses R2 CHANGES_REQUESTED from Miga + Rames on PR #2529:
1. Sibling-surface gap (blocker): `hyperframes cloudrun render{,-batch}`,
`hyperframes lambda render{,-batch}` all advertised the same tier-only
aliases (`1080p` / `hd` / `4k` / `uhd`) but normalized them to `landscape`
and never set `outputResolutionAspectAgnostic`. The distributed plumbing
PR #2529 added received `undefined` from those callers, so portrait `1080p`
still hit the original aspect-mismatch on Cloud Run / Lambda.
Fix: introduce `resolveResolutionFlagPair` in `@hyperframes/parsers` (the
single source of truth for the two-step normalize + aspect-agnostic
detect) and route every distributed entrypoint through a shared
`parseOutputResolutionFlag` CLI util so the alias signal now reaches
`SerializableDistributedRenderConfig`. Studio Server keeps its
canonical-only HTTP contract; that intent is now pinned in tests.
2. Preflight recompute (hardening): the earlier "downgrade aspect-mismatch"
preflight cleared un-remapped mismatches, so IG 4:5 (non-preset aspect,
no sibling) and portrait-4K comp + `--resolution 1080p` (remap +
downsample) both slipped through to fail late in `resolveDeviceScaleFactor`.
Now `checkRenderResolutionPreflight` computes the effective preset via
`suggestMatchingPreset` (mirroring the compile stage's
`adaptAspectAgnosticResolution`) and re-checks against that — only
genuinely-fixable mismatches clear early. New tests pin both regressed
input classes.
3. Docker forwarding boundary test (Miga's important #2): pinned
`1080p` survives verbatim as `--resolution 1080p` in the Docker args
so the in-container CLI can re-run `isAspectAgnosticResolutionAlias`.
4. Doc-nit (Miga): parsers/src/types.ts no longer references the
nonexistent `resolveResolutionForComposition` — points at the actual
remap helpers.
Fallow: cloudrun.ts / lambda.ts share 390 lines of pre-existing structural
symmetry (parallel AWS + GCP dispatchers), and lambda/render.ts +
render-batch.ts declare parallel RenderArgs interfaces. Both re-flagged
after threading the aspect-agnostic field through each surface; ignored
with justification in .fallowrc.jsonc. lambda.ts's `run` and
lambda/render.ts's `waitForCompletion` are pre-existing CRAP-score
hotspots untouched by this PR — added under health.ignore.
Co-Authored-By: Claude <noreply@anthropic.com>
— Via
Stroke width committed border-width and border-style together from one
free-text field, so setting a style meant typing an exact CSS keyword
(e.g. "dashed") with no indication of which ones were valid — the row
also duplicated the discoverable Stroke style select directly below it.
Stroke width now only commits border-width; style changes go exclusively
through the existing select.