Commit Graph
1211 Commits
Author SHA1 Message Date
Vance IngallsandClaude Sonnet 5.5 536346c5d1 feat(core): block capture motion blur on html-in-canvas defects (#5971)
html-in-canvas captures a few things wrongly, and a wrong blurred frame is
worse than a sharp one. Add a defect list the runtime checks before it wraps
a composition: a WebGL or WebGPU canvas (captured frozen), backface-hidden
faces in a 3D context (drawn mirrored), an iframe (reloaded by the wrap and
not recoverable), a transform or opacity below 1 on the root (ignored by the
capture) and a canvas that is already a capture canvas (nesting hangs the
renderer). Any hit turns blur off for the whole composition and the runtime
says which one.

The backface scan reads rules nested inside a style rule (CSS nesting),
sheets pulled in with @import and adopted style sheets. Shadow DOM stays a
documented limit.

A face whose backface-visibility: hidden arrives through an animation counts
too: the scan also takes the elements of running CSS or WAAPI animations that
key it, and elements naming a @keyframes rule that does, as candidates.

Support Chrome 157's html-in-canvas API alongside the older one: a canvas
marked content="drawable" with a drawable attribute on captured children
replaces layoutsubtree. Blur, the VFX runtime and Studio's browser check
detect both through the new @hyperframes/core/html-in-canvas subpath.

Folded review fixes: R14, R9 (the iframe defect entry).

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 15:02:16 -07:00
Vance IngallsandClaude Sonnet 5.5 1f8ead7bde feat(producer,studio): wire capture motion blur into renders and Studio (#5969)
Carry the runtime's blur through the render pipeline and the Studio render
panel.

- engine: installPageMotionBlur sets the page-side off switch and the render's
  device scale before any author script runs; CaptureOptions.pageMotionBlur
  lets a caller stand the page blur down.
- producer: a composition that asks for data-motion-blur on the root the
  runtime would pick forces screenshot capture, because html-in-canvas needs
  a real paint; the probe session carries the request's pageMotionBlur like
  every other session.
- studio-server and Studio: the render panel sends previewMotionBlur, the
  render route turns "off" into pageMotionBlur once, and the Studio dev server
  and the CLI server both receive the final value.
- mover scan: a large element that is see-through while it moves (a sweep, a
  light leak) is no mover, including a large image or canvas with alpha; the
  scan skips elements moved by a 3D individual transform property.
- cli snapshot injects video frames before the seek settles, matching the
  engine's order.

Folded review fixes: R1, R2, R3 (scan half), R7, R16, R18, T3 (render
adapters).

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 13:32:50 -07:00
WaterrrForeverandClaude Opus 5.5 688229314f feat(skills)!: publish seven knowledge skills aligned with Studio and retire the workflow skills (#5651)
* refactor(skills): fold six domain skills into hyperframes and media-use guides

hyperframes-core, -animation, -keyframes, -creative and -studio move to
skills/hyperframes/{core,animation,keyframes,creative,studio}/, and
hyperframes-audio moves to skills/media-use/mix/. Each moved SKILL.md becomes
a frontmatter-free GUIDE.md so installers that scan recursively for SKILL.md
no longer see them as skills; the published list goes from 21 to 15.

Relative links, code paths and skill-name mentions are rewritten to the new
locations, the catalog surfaces, marketplace core-skills list, fallback core
set, module-copy targets and skills-manifest.json follow, and
`hyperframes skills update` now removes the retired skills (RETIRED_SKILLS)
when they are still installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): slim the core entries and give media-use a HeyGen and a free path

The four entries every run opens are rewritten to what an agent cannot infer:
hyperframes 21.8 KB to 7.7 KB, hyperframes-cli 24.8 KB to 6.9 KB,
hyperframes-registry 9.9 KB to 4.3 KB, media-use 9.7 KB to 4.9 KB. Detail moved
whole into references (routing close calls and creator edits, CLI pin upgrades,
catalog search tiers, CLI conventions and feedback reports).

- hyperframes: state table, routing, the render contract, a terminal work loop
  (check, snapshot, preview before render), the desktop-app hand-off, and a
  table of the folded guides.
- media-use: one table of HeyGen versus free local tools per need; HeyGen
  sign-in, credits and the CLI's video-agent, avatar, clone, lipsync, translate
  and clipping commands; every free path works with no account.
- scripts/check-core-skills.mjs holds entries and descriptions to budget and
  checks every taught `npx hyperframes` command and path; runs in lint.
- Core entries are exempt from the formatter: its table padding cost ~1.5 KB
  per entry on every run.
- Content tests follow the text to its new home.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): plugin runs skip skills update; media-use explains HeyGen sign-in and TTS setup

The smoke eval showed a presenter request never reaching media-use and the
free voiceover failing on a missing kokoro-onnx; plugin runs also ran the
skills update the plugin rules forbid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(skills): drop a committed Python bytecode file and ignore bytecode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): align the entry with the Studio skills and keep the workflows' measured facts

The entry drops routing and the intent interview and carries the contract, the
editable-project rules and the work loop. Studio's format, runtimes, motion,
look and captions references come over for terminal use, frame presets move to
assets/, media-use gains Studio's mixing and local-command references, and
film.md and overlays.md keep what the workflow skills measured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills)!: retire the workflow skills and folded guides; publish seven knowledge skills

The skill pack now publishes exactly seven skills - hyperframes,
hyperframes-cli, hyperframes-registry, media-use, figma, slideshow and
remotion-to-hyperframes - carrying knowledge, not process. The eight
workflow skills, the folded domain guides and the routing/interview
references are deleted; their measured facts already live in the
hyperframes references (format, runtimes, motion, look, captions, film,
overlays) and media-use's mixing reference.

- bake-basemap.mjs and locate.mjs move to skills/hyperframes/scripts/
  (bake-basemap gains --help); extract-audio-data.py moves to
  media-use/audio/scripts/ for the caption motion reference.
- skills update: the eight workflows join RETIRED_SKILLS so existing
  installs prune them (by lock attribution only, since their names are
  generic); naming a retired skill reports where its knowledge went and
  succeeds instead of failing as unknown.
- Capture prompts point at the hyperframes skill's references/film.md;
  the live-map warning names the new bake-basemap path.
- Content tests that pinned knowledge are retargeted to the kept
  references; tests of deleted workflows, scripts and process go.
- Catalog surfaces, templates and docs describe the seven skills.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): resolve the local.md captions link and allow capture's output files in citations

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): regenerate the skills manifest from tracked files only

The committed media-use hash included an ignored __pycache__ bytecode file present
in the local checkout, so CI's clean checkout hashed differently.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(docs): restore generated catalog pages and docs navigation to main

Catalog PRs commit item sources only; CI regenerates the pages from the edited
registry sources. docs/docs.json is catalog navigation, so its Workflows group
rename is left for a non-catalog PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* build(cli): raise the CLI size budget for the skill references it bundles

The CLI bundles skills/hyperframes, which now carries the folded domain guides and
frame presets (155 KB to 1.08 MB in git). Budgets are the CI-measured size plus 5%:
packed 7526634, unpacked 22593213.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: keep repo-sweep guards meaningful after the workflow skills left

The last shell ffprobe caller and most root index.html examples lived in retired
skills. The shell detector is now pinned on a literal command line, and the
structure-rule sweep needs more than 5 roots (11 remain) rather than 30.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): slim figma, slideshow and remotion to the Studio shape; restore the storyboard board

slideshow 33.5 KB to 4.9, figma 18.2 to 6.2, remotion-to-hyperframes 11.0 to
4.9, each keeping the facts only it holds (the figma motion helpers, verify gate,
storyboard decoding and usage beacons; present and hand-off; the Remotion lint,
SSIM check and API map). The board page format from the storyboard recipe comes
back as hyperframes/references/storyboard.md: the desktop app's Storyboard tab
reads boards/storyboard.html by its frame, title and time classes. The entry and
the init template now say to snapshot the moments an edit changed before
replying, which the terminal eval found skipped in 22 of 38 runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): manifest without local bytecode; Studio's edit prompt names kept files

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): render when the person asks for the file or for no stops, not only after approval

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): keep the voiceover carve in media-use; presets stage their own fonts

Review follow-ups on #5651:
- carve.mjs and its tests move to skills/media-use/audio/scripts; the audio
  docs, mixing.md and two Studio comments point there.
- code-editorial's FRAME.md says to copy its bundled WOFF2s and add the
  @font-face blocks, instead of promising the retired build-frame.mjs.
- media-use states the host rule before the HeyGen sign-in steps.
- storyboard-format marks transition_in as a note the agent builds itself.
- Two stale citations (creator-editing-recipes.md, sub-compositions.md).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(skills): regenerate the manifest without local bytecode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): the CLI render step follows step 4's rule, not approval alone

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ci): run the core-skills check from lint again after the main merge

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): films that tell a story start from three storylines; maps use real imagery

Opus 5.5 arm D read motion.md and applied it, but its launch film was a
feature list (pain, three feature cards, proof, logo): film.md left story
to the model. And the map case never opened film.md, so it drew Natural
Earth outlines instead of baking satellite tiles.

- references/story.md: the generic half of Studio's moment-film story and
  craft (one thing, three storylines with world and device, the hook,
  beats, worlds, copy, brand, tells of a generated film). The person picks
  a storyline, or the agent takes its recommendation when asked for the
  file or for no stops.
- The entry's work loop names the storyline step for films over ~15 s; the
  references table routes stories to story.md and maps to film.md.
- film.md points at story.md and gives maps and real places a section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): story.md follows the entry: no voiceover unless asked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): bake-basemap starts from a globe and stops waiting out its idle timeout

The Opus 5.5 map run spent 13 of its 38 minutes on parallel bakes that
stalled and 15 more recording 300 frames, after rewriting the script because
it could only start on a flat map.

- PROJECTION=globe with a seeded starfield and atmosphere, and FROM/PAN to
  turn the globe to CENTER before the dive.
- Each frame polls for loaded tiles instead of waiting for an "idle" event
  that had often already fired: 30 frames went from 198-219 s to 10-13 s
  locally with byte-identical frames.
- Progress prints seconds a frame and minutes left; film.md shows the globe
  call and says to run one bake at a time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): render in the foreground; wait on a detached render's log, never pgrep -f

Both Opus 5.5 explainer runs started a detached render, then waited with
`until ! pgrep -f "hyperframes render"`, which matches its own loop: each
render took 88-117 s and the wait sat for the tool's full 10 minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): route each kind of film; storylines carry look and sound; add review and sound

The entry routes by request (launch, explainer, music, data, vertical, 3D,
character) to a self-contained file under references/types/, and a film
over ~15 s now gets music and sounds on its visible actions unless the
person asked for silence. Each storyline names its look and its sound, so
picking one picks both; STORYBOARD.md frames carry `reads` and `sound`.

review.md is the whole-film pass: the check warnings, the review sheets,
seven questions to look for, P0/P1/P2 notes and REVIEW.md. motion.md gains
eases by role, six title entrances, a reading-time rule and nine measured
transitions; look.md a design plan before the first scene, with presets
kept for decks and named picks. story.md adds the second default that
generated design falls into, with what to do instead.

The blank template and format.md no longer teach the fade-and-slide-up
entrance on a dark canvas.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(cli): check warns on unread lines, dead stretches, small type, empty frame 0 and silence

A coding agent cannot watch its film, so check now measures what a viewer
notices over time. A new `craft` section holds warnings only (exit 0
unless --strict), sampled on the dense 8 fps grid the overlap pass
already seeks, skipped for --range checks:

- line_unread: a reading-size line (>= 30 px tall at 1080) on screen
  under 0.4 s for 1-2 words, else under 3/4 of 0.5 s + 0.3 s a word;
  the film's last line needs 2 s. Short lines are re-measured between
  samples to ~0.01 s before judging. Captions-track and
  data-layout-ignore text are exempt.
- dead_stretch: more than 3 s where the keepsMoving liveness signature
  does not change, outside the final 3 s and any data-hold element;
  quiet when sweep_static fires or a whole-composition keepsMoving
  assertion owns the limit.
- text_below_floor: text under 2.6% of the short side (28 px on 1080)
  held 0.5 s, one finding per file listing every element.
- first_frame_empty: frame 0 paints only the background.
- film_silent: over 15 s with no unmuted audio or audible video.

Each message names the fix and the skill reference to read. keepsMoving
now also leaves the film's last 3 s (a still end card) out of its
static runs, so the two agree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(cli): snapshot --review captures a beat sheet, seam strips and a phone sheet

snapshot --review picks the frames that show a film over time instead of
evenly spaced ones, in the same single browser session:

- review-beats.jpg: one settled frame per scene at its start + 0.3 s,
  plus one per timed data-beat="name" element, labelled time and id.
  Scenes are the composition's top-level timed clips and sub-composition
  hosts, read like `timeline` reads rows (captions and audio excluded)
  and timed by the runtime's start resolver over the live page.
- review-seam-NN.jpg: frames at -0.2, -0.1, 0, +0.1, +0.2 s around
  every cut (each scene start, and any scene end no scene starts at).
- review-phone.jpg: the beat frames at 360 px wide.

--composition scopes the review to one sub-composition; with fewer than
two scenes the beats spread evenly and a note says so. snapshot also
gains --json: one envelope with every frame's path and time and, under
--review, the scenes, beats and seams behind the sheets; progress moves
to stderr.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): craft warnings stay out of --strict; a hold gets a real next action, not a caret

Craft findings (unread lines, dead stretches, small type, an empty first
frame, silence) are judgment calls a film may break on purpose, so
--strict keeps gating defects only. The check reference no longer
suggests a blinking caret to keep a still frame moving.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): review spreads beats when every scene starts together; map credits skip the type floor

A map film whose basemap and overlay both start at 0 s got one beat
frame; the even spread now keys on distinct scene starts. film.md tells
agents to mark the imagery credit data-layout-ignore and back it, since
the new type floor flagged it on the first real run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): review frames each scene at its middle too, not only 0.3 s in

On the first real explainer, 0.3 s into a scene showed a bare colour
field before its title entered. Each scene longer than about a second
and a half now adds a frame at its middle, so the sheet shows what the
scene builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): a film is built from a numeric SPEC.md, with defaults banned by name and a look test

Studied in full: flxrnc's Motion Lab (vague vs directed prompt), 0xMovez's
course, EianLu's riso skill, agentchengfeng's style samples and the two
Opus 5.5 guides. Their common point: adjectives are where the default look
lives, so the agent now writes SPEC.md before any scene (GOAL, OUTPUT,
TIME, LOOK, MOTION, SHOTS, BANNED, QA), with a number for every adjective.

spec.md bans the defaults by name, unless the brand or reference uses
them (a dark void with small things floating, glow and particles, the
cream-serif-mono-label look, fade-and-slide-up, corner brackets), and
grows that list after the first render. Each storyline gets a 3 s test of
its hero shot before the pick; a look is written as the rules of one
medium. Composition numbers: the hero fills 40% of the frame, one hero
move per shot, the shot's information in within 2.5 s then a held poster.

motion.md adds springs by damping ratio, shorter exits, overlap, stagger
and the beat grid; review.md reviews as a harsh director and grows BANNED;
vertical.md takes the riso skill's measured safe band; explainer.md the
shapes of information.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): story before look; every object reads as itself; posters only for info cards

The first spec-driven C02 swapped the dark default for a flat Swiss
poster, and the story went with it: two black bars nobody read as mirrors,
slogans instead of cause and effect, a storyline picked for its picture.

The spec now serves the story: a storyline is picked for the story a
stranger would follow, the look test asks first whether a stranger
understands what they see, and SPEC.md gains an OBJECTS line giving each
thing the material and details that make it read without a label. The
2.5 s held-poster rule is limited to information cards; in a story film
every shot causes the next. Explainers start from a person or situation
the viewer knows. Review names every object as a stranger would and
treats one that needs explaining as a P0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): storylines are three films in words, each with a device and a form

The three storylines are pitched as text and the person picks from them;
nothing is built for a storyline before the pick, so the 3 s hero-shot
look test is gone. Ideas pair a device with a form, five or more first,
keeping the three that differ most, and the one stop states the rest in
an assumption line that names every paid item beside its free option.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci: list main's new embedded-captions hf-cli test among the retired workflow files

The merge with main removed it with the rest of the retired skill, and the
deletion guard rejects any removal it has not been told about.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(core): keep the audio preset comment within core's size budget

The comment's new citation to media-use's mixing reference made the published
package 14 bytes larger than its budget, which sits at main's exact size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): craft keeps its own 8 fps grid; comments back within the checks

Review on #5651 found that the merge moved craft onto the overlap pass's
times, which main caps at 180, so a brief line in a long film fell between
samples and line_unread missed it. Craft now builds its own grid at 8 fps up to
600 samples, as before the merge, and a 60 s film with a line shown for 0.2 s
pins it.

The Comments check passes again: REVIEW.md and STORYLINES.md join the files a
skill writes into a project, the comment lines this branch added come back
under each file's share at the merge-base, and the audio preset comment drops
a word the check reads as history.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 19:19:31 +00:00
Miguel Ángel 234ebc4ba1 test(studio): real-Chrome tests recover from a stuck Chrome launch or close (#5961)
* test(studio): real-Chrome tests recover from a stuck Chrome launch or close

* test(studio): retry a stuck Chrome launch that Windows reports as already running
2026-10-20 16:53:10 +00:00
Miguel Ángel f5033879f7 fix(studio): keep an edit's preview waiting while the window draws no frames (#5926)
* fix(studio): keep an edit's preview waiting while the window draws no frames

An edit that reloads the preview loads it hidden and swaps it in once it
has drawn two frames. Its 45 s wait ran on the wall clock and paused only
when the page reported itself hidden, but a window behind others on some
Linux desktops draws no frames while still reporting visible. The swap
then gave up and the old preview stayed on screen until the next edit.

A wait budget in which no frame was drawn is no longer spent; the next
frame restarts the full budget, as showing a hidden tab already does.

* fix(studio): keep the frame-wait note in the budget comment
2026-10-20 02:08:09 +00:00
Miguel Ángel 4ff4284442 fix(studio): a deselect made while an edit saves stays deselected (#5907)
* fix(studio): a deselect made while an edit saves stays deselected

A save resyncs the selection it started with once the write returns. It now
does so only while that element is still the one selected, so a deselect or
a different pick made during the save is no longer undone. The preview-reload
sync now routes through the same resync instead of a copy of it.

* refactor(studio): drop two comments the names already say
2026-10-19 20:51:57 +00:00
Miguel Ángel 93c321285c fix(studio): render scratch stays out of the project and error telemetry counts real failures only (#5902)
* fix(studio): a lost preview server or a superseded edit no longer files as a crash

* fix(studio-server): a render's scratch files no longer stream changes or list as compositions
2026-10-19 19:52:04 +00:00
Miguel Ángel 959dd3e584 perf(core): playback and jumps do only the work on screen (#5754)
* perf(core): playback work follows what is on screen, so long films keep their frame rate

* fix(core): a timing change during playback re-decides every clip on that frame

* style(core): format the scene clock's rest rule

* fix(core): narrow playback media before armed lookups

* perf(core): a jump starts its video first and rebuilds the clip list after the frame lands
2026-10-19 14:04:17 +00:00
Miguel Ángel 5975dd7579 feat(studio): the desktop-app button says Install the desktop app and never dead-ends (#5854)
* feat(studio): the header's desktop-app button says Install the desktop app and never dead-ends

The button now reads "Install the desktop app" beside Framey drawn as the app
draws it. Where the app is installed, a press opens this project in it; a
folder preview serves from a bare index.html gets its hyperframes.json first,
so the app no longer refuses it. Without the app, the button links straight to
hyperframes.dev's download, and a failed open lands there too. Platforms with
no build of the app show no button. Telemetry now records eligibility, one
impression, the click and the press outcome, with OS, app installed and
download available.

* feat(studio): the desktop-app button records eligibility, impression, click and outcome

* feat(studio): a desktop-app press carries its page id to the download and the app hand-off

* chore(studio): describe Framey's drawing without internal names

* feat(studio): the desktop-app button opens a guide that walks download, install and open

Without the app, the header button opens a card where Framey walks three steps: the
download opens in a new tab, the install is found by asking the preview server while the
card is open, and the project opens through the existing hand-off. A failed hand-off shows
the folder to copy and Try again. With the app ready the press opens the project directly.
Each step change is recorded through trackStudioEvent; a download is never counted as an
install.

* fix(studio): the desktop-app guide counts one install per page

The app removed and found again on the same page recorded install_detected twice; a page
latch keeps it to one, as the telemetry contract says.
2026-10-18 17:34:56 -04:00
Miguel Ángel 2b9009ab52 fix(studio): keep a preview reload waiting while a slow project boots (#5823)
* fix(studio): keep a preview reload waiting while a slow project boots

Studio gives a reload up to three 15 s budgets, but the player failed it at
8 s and Studio's own 5 s safety net stopped listening for the runtime. A host
can now own the load deadline (no-load-timeout); script and runtime errors
still fail at once.

* fix(player): send the 8 s load deadline through the probe's one error path
2026-10-18 05:46:53 +00:00
Miguel Ángel 7266646264 fix(studio): a failed preview reload says which step it was still waiting for (#5700)
* fix(studio): a failed preview reload says which step it was still waiting for

* fix(studio): a failed reload of a cross-origin preview still cleans up and reports

Naming the step the reload waited for read the hidden frame's player, which throws
for a cross-origin frame and skipped the cleanup and the failure report.

* refactor(studio): name the reachable-player check instead of commenting it
2026-10-16 17:56:55 +00:00
Xuanru Li 893b4f6757 feat(studio): record video state, edit targets and artifact cleanup in project history (#5524)
* feat(studio): record video state, edit targets and artifact cleanup

* fix(studio): clear video-state CI gates

* fix(studio-server): pin cleanup reads to file descriptors

* chore: account for cleanup descriptor code in package budget
2026-10-13 05:12:34 +00:00
Meghna VasudevaandClaude Opus 5.5 b100ff16f3 refactor(core): share keyframe property defaults between SDK and Studio (#5438)
Move the keyframe rest-value table into @hyperframes/core/keyframe-defaults
and import it from the SDK backfill and Studio's gsapShared, replacing the two
hand-synced copies.

Closes #5387

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:07:40 +00:00
Miguel Ángel 86521b94f5 fix(studio): an edit the history could not record says why in the log (#5489) 2026-10-12 09:18:48 +00:00
Jerrai e1925d4317 feat(audio): composition master bus (data-fx-chain on the root) (#5420)
* feat(audio): composition master bus (data-fx-chain on the root)

A clip can belong to only one audio group, so there was no way to put one
effect, such as a limiter, across the whole mix. The data-fx-chain on the
top-level composition root now runs once over the sum of every clip and every
group, after each group's own chain and fader. No new attribute: a sub-composition
root's data-fx-chain was never applied to anything and still is not.

Render: when a master chain exists, mixAudioTracks writes a float WAV instead
of AAC, applyAudioFxChain runs the chain over it, and ffmpeg encodes AAC last,
so the chain reads unclipped samples. An unreadable chain is a mix failure, as
for a group. Preview: the transport splices the chain between the clips' sum and
the monitor, re-anchored per play generation and watched for edits.

Only the top-level root is read. No master automation: the root's data-automation
is not read as the bus's. No lint rule: data-fx-chain is valid on clips, groups
and roots, and the one place it does nothing (a sub-composition root) has always
been silent.

— Jerrai

* fix(core): preview routes every audible video through the master bus

A root data-fx-chain is attached only inside the Web Audio graph, but a plainly
audible <video> joined that graph only for its own chain, group, automation or a
volume over 1. So preview limited the music and left the talking head dry, and a
composition whose only audio is video got no master chain at all, while export
sums every audible element into the master. A master chain on the top-level
composition root is now a reason to join, the same as a video's own chain.
Elements under a root with no chain keep native output. Sub-composition roots
are still ignored.

— Jerrai

* fix(audio): master bus review round 2 (temp dir, tests, docs, Studio meter)

- audioMixer: a fatal FX error in the master pass or a group bus left the
  work directory (the full float sum, about 1.4 GB an hour) on disk. The
  final mix now runs in try/finally and the group rethrow removes it first.
- Tests: the "once over the sum" case now uses a limiter then a gain, so a
  per-clip chain (-10.4) and a doubled one (-18) both fail it; a new case
  proves the sum stays float until the chain reads it; the fader case uses
  a group volume of 0.5 so the fader's position is exercised; the work-dir
  removal is covered for both paths.
- Docs: "Three rules exist" (the master-bus lint rule is gone), a bad master
  chain stops the render with audio_processing_failed, a sub-composition
  file opened on its own has its root chain as the master bus, and a seek
  clears a group or master limiter.
- Studio: TimelineElement.masterChain carries the root's data-fx-chain, so
  the program meter shows for a video-only composition whose only audio
  routing is the master chain, as preview now plays it.
- Comment blocks over four lines cut; webAudioTransport.setRate split so
  its CRAP score is under the Fallow threshold.

— Jerrai

* fix(audio): master chain reaches tracks the render adds; honest plain-limiter copy

- Master chain: it was carried on each parsed AudioElement, so a track the
  producer adds itself (appendAutoDetectedVideoAudio, the browser-discovered
  audio in probeStage) never had it, and a composition whose only audio was
  such a track exported with no master chain, no ceiling and no warning. It is
  now a composition property: parseMasterFxChain(html) at compile, carried on
  CompiledComposition and CompositionMetadata, passed to processCompositionAudio
  by runAudioStage (in-process and distributed plan). AudioElement.masterFxChain
  is removed so there is one source. Each forward (compile stage, local
  orchestrator, distributed plan) has a test that fails when it is dropped.
- Plain limiter: it follows the level with no lookahead and Level after is
  applied past the ceiling, so it cannot promise "nothing gets past" or "never
  clips". Studio copy (description, panel text, knob label, summary) and the
  hyperframes-audio skill now say a fast or inter-sample peak can pass it and
  point to the true-peak limiter for a delivery limit. A copy test guards it.

— Jerrai

* fix(producer): keep master bus comments within the ratchet; run the plan test with the ffmpeg tests

Drop three one-line field comments that raised their files' comment share,
and list plan.masterBus.test.ts with the integration tests because plan()
reads the ffmpeg version.

— Jerrai

* chore(ci): repin the producer test-classification source in the reachability map

The reachability map pins the hash of test-classification.mjs, which now
lists one more integration test. The map's producer lane command already
covers every test under src, in both the unit and integration matrix lanes.

— Jerrai
2026-10-11 19:07:00 +00:00
Miguel Ángel 2fbaef6abb fix(core): an element its script hides stays hidden while studio preview plays (#5458)
* fix(core): an element its script hides stays hidden while studio preview plays

* fix(core): a stamped element re-timed in the timeline hides outside its new window

Studio timing edits drop the runtime-stamped mark, and a script-hidden stamped
container no longer holds back its images.

* fix(core): a preview-only stamp copied into a project no longer changes the render

Only the Studio preview honours the stamp, copied live markup drops it, and a
lane-only move makes a stamped element authored like the save does.
2026-10-11 16:45:42 +00:00
Miguel Ángel ba08acb1f5 fix(studio): keep the shortcuts panel above the player controls and inside the window (#5457)
* fix(studio): keep the shortcuts panel above the player controls and inside the window

The panel was drawn inline next to its button at z-50, so a host layer
above that (the desktop's playback bar, z-55) covered its Playback rows,
and the preview pane's overflow clip cut its top off in a short window.
It now opens through Studio's Popover on the shared popup layer: portaled
out of the button's boxes, held above the button, and capped to the room
there with a scrolling body.

* fix(studio): the shortcuts button keeps its tooltip description for screen readers
2026-10-11 10:21:21 +00:00
Miguel Ángel 9825614d91 fix(studio): Studio's own text reads without em or en dashes (#5414)
* fix(studio): text in Studio reads without em or en dashes

* fix(studio): the dash check also catches HTML entities, and peak reads Unavailable

* test(studio): the dash check test covers every entity form

* fix(studio): messages Studio shows from lint, sdk, core and the renderer drop dashes too

* fix(producer): the gate check stops early on the engine's new fallback wording
2026-10-10 19:08:30 +00:00
Miguel Ángel 165be85132 fix(cli): timeline split keeps a sub-composition's place in both halves (#5410) 2026-10-10 08:05:14 +00:00
Miguel Ángel 91f9c9eb04 feat(studio): export can encode on the GPU, like render --gpu (#5397)
* feat(studio): export can encode on the GPU, like render --gpu

* test(studio): the 4K export assertion carries the GPU flag

* refactor(studio): the GPU flag needs no comment beyond its name

* fix(studio-server): only MP4 exports encode on the GPU, and render errors report it

* fix(studio): the disabled GPU option dims like the other export controls
2026-10-10 04:45:05 +00:00
Miguel Ángel d93b769763 fix(studio): a stalled font or asset no longer leaves the preview empty or drops reloads (#5370)
* fix(studio): a stalled font or asset no longer leaves the preview empty or drops reloads

* fix(studio): a page's own late load no longer counts as replaced content

* refactor(studio): one announcement owns when a preview page has loaded

* fix(studio): let hosts with their own player announce a new preview page

* fix(studio): a late ready from the replaced preview page no longer claims the new page

* refactor(studio): keep the Player doc comment on Player
2026-10-10 02:38:58 +00:00
Miguel Ángel 5562c280da fix(core): stop small previews dropping layers on heavy compositions (#5268)
* fix(player): render the preview at its shown size so heavy compositions stop dropping layers

* test(player): check a small preview rasters its will-change layers at the shown size

* fix(player): accept device-pixel snapping in the zoom probe

* fix(player): probe the initial frame and preserve editing coordinates

* fix(player): defer every initial source reload until setup

* fix(studio): convert measured frame coordinates once

* test(studio): bank reduced preview opening work

* fix(core): keep centered layers exact when the preview is zoomed

GSAP infers a -50% CSS translate from the rendered pixel matrix. At a
fractional preview zoom that matrix is snapped, so a centered layer lost
its percentage and shifted by half its height after reload, render and
redo. The runtime now restores the authored percentage before GSAP's
first transform parse. Also restores the original thumbnail ceilings.

* test(studio): bank the two resize cases that now pass edit accuracy

resize-scale-px-r30-{root,nested}-z50-after measured 0.53 px tracking on
main and 0.49 px here. Also drops a comment line the runtime name covers.

* fix(core): keep stylesheet-centered layers exact under preview zoom

The centering repair now also reads percentages from a stylesheet
transform (the common translate(-50%, -50%) pattern), runs for every
property that makes GSAP parse a transform (transformOrigin first, too),
resolves against the border box, and leaves elements without a layout
box alone instead of throwing. The browser check now runs zoomed.

* fix(core): restore only centering, the one percentage GSAP infers

GSAP folds every other CSS percentage translate into pixels, and
compositions rely on that (a translateX(-100%) title tweened to x: 0
slides in). The repair now gives back only a -50% that zoom snapping
lost, and treats a size that is not in px (a hidden element sized in
percent) as no box.

* fix(core): restore centering only where GSAP would infer it

A pixel part in the translate means GSAP never infers -50%, so the
repair leaves it alone, and a layer without a layout box is left to
GSAP whatever size its computed style reports (that differs by Chrome
build for hidden percent-sized layers).

* fix(player): fit the preview before the composition starts loading

The zoom was applied on the first resize callback, after the source was
assigned, so a fast-loading composition could run its scripts and lay
out unzoomed and then reflow. Fitting on connect, before any source,
makes the first layout use the preview zoom.

* test(player): pin the first fit before the composition source

* revert(player): drop the zoom fit so the preview keeps the export's layout

CSS zoom shaped text at the zoomed size, so paragraph heights in the
preview differed from the render by up to a line. The transform fit
stays; the centering adapter and zoom-only bench changes go with it.

* fix(core): raster a small preview at the size it is shown

Chromium never rasters a will-change: transform layer below device
scale, so a preview shown at a quarter size rastered every hinted layer
at full size and could run out of tile memory, dropping layers. While
the player shows the composition below 1x, the runtime swaps the hint
for an identity filter, which keeps its stacking context and
containing block; preserve-3d containers keep the hint.

* fix(core): keep a small preview's raster swap out of filters edits can save

The identity filter that replaced will-change: transform showed up in computed filter, so
Studio's blur slider saved it, GSAP filter tweens and clearProps picked it up, and it cut
backdrop-filter off from what is behind it. A far perspective keeps the same stacking context
and containing block, is never composited for flat content, and nothing reads or writes it.

Also: the runtime guard lint (realm-safe element checks), a class change that drops the hint
undoes the swap, and the browser check measures raster scale with the swap off and on at one
shown size and compares frosted glass and 3D pixels.

* fix(core): drop preview raster hints only where nothing depends on them

The perspective that stood in for will-change: transform could still reach a saved project:
Studio records perspective keyframes from GSAP's computed value, and copying a generated
element serialised its inline styles. It also went stale when a parent class or a later
stylesheet changed the element.

Now the runtime writes no inline style. It marks a hinted layer with a preview attribute
and one injected rule sets its will-change to auto. Only layers whose hint changes nothing
are marked: positioned, with no fixed descendants, and no z-indexed or blending descendants
unless the layer has its own z-index. Every class or tree change re-checks the whole page.
Studio strips the mark from copied markup, as it does the other preview marks.

* fix(core): keep raster hints that pseudo-elements, other hints or inline styles need

A layer's ::before or ::after can sit behind it, blend with it or be fixed to it, so those count
like children. A layer that also hints opacity or filter keeps every hint, since those make their
own boundaries. Inline style changes, which scripts make every frame, are watched cheaply: they
only give the hint back to the layers holding an element that now depends on it, or to a layer
that turned static or changed its z-index. Text changes no longer trigger a full pass; a new
stylesheet still does.

* fix(core): re-read a marked layer's own hints and follow any attribute or late sheet

A marked layer now has its mark lifted while its hints are read, so a class or inline value
that adds opacity or filter gives every hint back. Any attribute change re-checks its subtree,
so attribute selectors are followed, and a linked stylesheet re-runs the pass once it loads.

* perf(core): only lift a raster mark on the element that changed

Lifting a mark to read a layer's own hints repaints it, and a change on a scene container lifted
every marked layer under it (about 6 ms and a re-raster per event). Now only the changed element
is lifted, and a full pass clears all marks at once so the page restyles a single time. Tests
cover leaving marked layers alone, ignoring our own marks, and the linked-sheet load pass.

* fix(core): decide raster marks before writing them in a full pass

A full pass re-marked layers one at a time, so each read restyled the page again. Every mark is
now decided first and written after. The browser check holds off the player's own scale
messages while it turns the swap off and on, since a late resize tick could turn it back on.

* test(player): hold the player's own scale while the raster check turns the swap off

Turning the swap off restyles the page, and the player answers by resending its real scale,
which turned the swap back on within milliseconds, so the off half compared the swapped page
with itself. The check now holds the player's sends on the host page, asserts the swap is off
before the native shots, and removes the layers that keep their hint before either trace.

* test(player): give the raster check's 3D probe a solid fill

Stripes a few pixels wide alias differently at each raster scale, so the probe measured texture
resampling rather than the 3D shape it is there for. A solid fill keeps the shape and position.

* fix(studio): keep the preview raster mark out of stacking and saved text edits

Studio's stacking model read a marked layer's will-change as auto, so a timeline drag could
write a z-index that does nothing in the render; a marked element now still counts as a stacking
context. A text edit copied every live attribute but style, so it could save the mark; the
preview-only attributes are now one list, used by both the copy and the text-edit paths.

* fix(core): keep a marked video through a scene swap

The runtime keeps a video across a scene swap only when its live attributes match the fresh
import, and the preview raster mark made them differ, so a hinted video in a small preview was
rebuilt on every edit. The comparison now ignores every preview-only attribute, which also
fixes the same rebuild for data-hf-upcoming. The shared list is renamed PREVIEW_ONLY_ATTRS, and
the lazy-image strip in copied markup gets a test.
2026-10-09 23:18:27 +00:00
Miguel Ángel 6b341b0d84 fix(studio): a resized corner stays on the pointer when the size rounds to whole pixels (#5213)
* fix(studio): a resized corner stays on the pointer when the size rounds to whole pixels

Resized sizes stay on whole CSS pixels (GSAP centres a -50% translate from the
integer offset size), so the centre-anchored resize left the grabbed corner up to
a third of a pixel times the element's scale off the pointer: 0.53 px on a 1.5x
scaled layer. The centre pin now takes half the rounding remainder along the
element's rendered edges, so the grabbed corner lands where the wanted size puts
it while the written size stays whole.

* fix(studio): a scale resize saves the box exactly where it was dropped

finalizeScaleResizeCommit skipped its position correction whenever the box sat
within 0.5 px of the drop point on each axis, so a sub-pixel drop (now the
norm, with the grabbed corner on the pointer) saved up to half a pixel off.
It now skips only when the correction rounds to nothing at the precision the
file is written in.

* fix(studio): a scale resize lands centred on the box that was dropped

finalizeScaleResizeCommit lined the committed box up with the drop by its
top-left corner. When one scale cannot reproduce the dragged width and height
(the draft rounds each to whole pixels), that put the whole gap on the far
edges, up to half a pixel. The resize is centre-anchored, so it now lines up
centres and the gap splits evenly.

* chore(studio): keep the complexity note on the resize intercept, not the new helper

* test(studio): bank the two scaled resize cases that now pass the edit accuracy bench

* fix(studio): a scale resize leaves animated positions alone and mirrored corners track

* test(studio): bank only the two newly passing scaled resize cases

* fix(studio): a mirrored or parent-rotated layer's resize corner tracks the pointer

* fix(studio): a resize corner on a skewed layer picks its side from the box's own edges

* fix(studio): resizing a layer that renders with no area moves nothing
2026-10-09 19:20:01 +00:00
Miguel Ángel a1803448b8 fix(studio): clip strips resize on the next frame, ending ResizeObserver loop errors (#5187)
* fix(studio): clip strips resize on the next frame, ending ResizeObserver loop errors

* test(studio): verify strip resize frame scheduling deterministically

* fix(studio): a menu set to render inline opens beside its trigger

* fix(studio): a popover set to render inline opens beside its trigger

* fix(studio): a remounted clip strip keeps its own size instead of a queued one

* test(studio): zoom thumbnail tests run resize frames on fake timers

* test(studio): zoom preview test runs the resize frame it guards
2026-10-09 17:54:14 +00:00
Miguel Ángel cea48a548a fix(core): preview stops at the film's end when a scene loops forever (#5320)
* fix(core): preview stops at the film's end when a scene loops forever

* refactor(core): the loop length threshold lives with the composition length

* test(core): pin the endless-loop length at the boundary and through the runtime

* style(core): keep composition length comments under the file's share

* refactor(studio): preview duration guard reads the core loop length limit

* docs(core): length doc says a loop yields only to a floor or fallback
2026-10-09 17:11:11 +00:00
Miguel Ángel 7a84d29df1 fix(studio): let small picks move when dragged by their middle (#5206)
* fix(studio): let small picks move when dragged by their middle

* fix(studio): keep the grabbed corner under the cursor while resizing

* fix(studio): let picks a few pixels across resize from their corner

* docs(studio): say which picks the resize guard still lets resize
2026-10-09 09:08:02 +00:00
Miguel Ángel 621ddd4693 fix(studio): stop file names with %, apostrophes or $ from breaking Studio (#5156)
* fix(studio): serve files whose name has a bare percent sign

A browser sends src="100%.png" with the % unescaped, and decoding the whole path threw, so Studio answered 500. Only well-formed escapes are decoded now.

* fix(cli): serve files with a bare percent sign from the check and snapshot server

One shared decode rule for request paths; the image thumbnail route's malformed-escape 400 could no longer fire, so a name that is not a file is a 404.

* fix(cli): name a failed file with a bare percent sign in check

* fix(studio): keep apostrophes and dollar signs in file names when editing attributes

An attribute value is matched by its own quote kind, so src="it's.png" is replaced instead of duplicated, and every insert uses a function replacer, so $1 or $& in a file name is written as is.

* fix(studio): rename references spelled with %20 or &amp; and read quoted media sources

WIP: not yet tested; see the resume note.

* fix(studio): match whole attribute names when editing media sources

* fix(studio): compare decoded names and escape replacement references

* fix(studio): preserve file references across URL and string consumers

* docs: clarify raw file references and URL fields

* fix(studio): keep raw path identity distinct from URL spelling

* fix: share attribute grammar and decode media URLs at their readers

* fix: preserve local filename identity in HTML URL output

* fix: decode entity references before rename candidate matching

* style: format rename entity handling and regressions

* fix: preserve platform file identity when renaming references

* fix(core): decode CSS escapes before rebasing asset URLs

* fix(core): consume CSS continuations before URL encoding

* fix(core): preserve CSS URL identity and escape rebased suffixes

* fix(core): express CSS suffix controls with Unicode categories

* fix(core): preserve URL backslash identity while rebasing

* test(core): respect platform backslash filename semantics

* fix(studio): a dropped file with %, # or ? in its name loads, and an encoded reference renames

* fix(studio): read an authored src as a URL so dropped files with spaces, %, # or ? load

* fix(studio): media routes find files named with ? or #, and the probe reads a src as a URL

* fix(studio-server): one rule for request file paths, so loudness finds files named with %, # or ?

* chore: drop the design note from the change

* fix(core): rename replaced clips whose src is URL-encoded
2026-10-08 16:37:49 +00:00
Miguel Ángel de4b31a262 fix(studio): update unreferenced generated media names on replace (#5169)
* fix(core): derive generated media labels from current source

* fix(core): decode generated media filenames

* fix(core): share filename decoding across URL forms

* fix(core): strip filename suffixes without unsafe indexing

* fix(studio): rename unreferenced generated media ids on replace

* fix(core): register media ID exports and trim names in linear time
2026-10-08 10:38:12 +00:00
Miguel Ángel 042ec2ebd0 fix(studio): restore overscan and publish the zoom viewport (#5151)
* fix(studio): restore the timeline overscan budget

* test(studio): align window witnesses with the restored budget

* fix(studio): publish the viewport when a zoom anchor lands

* fix(studio): publish the viewport when a zoom-out ends at its laid-out scale

An eased zoom-out that ends at the scale already laid out only moves the scroll position,
so the timeline drew from the old position until the next scroll event. The zoom input
now publishes that scroll through the same callback the zoom anchor uses.

* fix(studio): a pan at the laid-out scale publishes its scroll before paint

The same-scale ending of a zoom ran outside React, so its scroll publish rendered on the
next task, after the browser painted the old window. It now flushes like the scale change
beside it, and its test pans at the laid-out scale instead of changing it.

* test(studio): a same-scale pan must render its scroll before paint

Reads the render window outside act, so unwrapping the flush fails it, and records each
published scroll at call time instead of reading the live element afterwards.
2026-10-08 08:37:55 +00:00
Miguel Ángel b66e8a195e perf(studio): preview decodes hard-to-play videos at the size they are shown (#5184)
* perf(studio): preview decodes hard-to-play videos at the size they are shown

Studio's copy of an HEVC or ProRes clip was made at the source's full size, so a 4K clip was decoded at 4K inside a preview a few hundred pixels wide, and back-to-back 4K clips flashed the background at their cuts. The player now reports how large it shows the composition, the runtime asks for a copy sized to the shown video (on a ladder of sizes, never above the source), and Studio stops pre-warming source-size copies. CLI play, static serving, publish and renders keep the source-size copy.

* fix(studio): sized preview copies follow zoom, hover previews and the newest size

Studio reports the preview's on-screen scale when a zoom settles and for composition hover previews, which no player measures. The runtime loads only the newest size asked for, regrows a first copy made while the frame grew, and never replaces a copy with one smaller on either side. The player's scale is measured against the frame's own layout width.

* fix(studio): keep comment shares and file sizes within the repo ratchets

* test(studio-server): end the sizing test's ffprobe options before its input

* fix(studio): hover previews report their size once their runtime is ready
2026-10-08 00:26:08 +00:00
Miguel Ángel 1e711b087d feat(studio): a host can show the hover box and give the stage its starting size (#5116)
* test(studio): pin a host's hover box and a starting stage size

* feat(studio): a host can show the hover box and give the stage its starting size

* fix(studio): keep the comment share flat and let a measured size win over the hint
2026-10-07 09:13:07 +00:00
Miguel Ángel 17c699db4a fix(studio): replacing an image's fill on an img changes its picture (#5170)
* fix(studio): replace image fill through its source attribute

* fix(studio): preserve literal percent image sources
2026-10-07 09:11:58 +00:00
Miguel Ángel 9f09861d2e perf(studio): zooming a long timeline re-decodes and redraws less (#5142)
* perf(studio): reuse filmstrip frames when a zoom changes a strip's width

A zoom that changed a strip's frame count decoded the whole strip again
on the main thread, and showed the clip's single poster frame until it
landed. Frames are now sampled at each slice's left edge, so a strip
twice as long holds every frame of the shorter one; decoded frames are
shared per source and time, so zooming out decodes nothing and zooming in
decodes only the new half. The strip on screen stays until the new width
is ready.

* perf(studio): redraw only the waveforms on or near the screen after a zoom

A zoom resizes every mounted clip, and every mounted waveform redrew in
that same frame, on screen or not. A waveform now draws only while it is
on or near the screen (one shared observer, a screen of margin each side)
and draws when it comes near if a zoom changed it meanwhile.

* perf(studio): never start a beat-analysis stage while the timeline zooms

Analyzing a long music track runs for over a second on the main thread,
and when it started late it landed inside a zoom. The analysis now awaits
an optional pause before each long stage; Studio's waits for the timeline
to rest and the browser to go idle. Results are unchanged. The tempo
detector's stage is one call, so a zoom that starts inside it still waits
for it.

* fix(studio): reuse a filmstrip frame only where its own strip would show it

A shared frame was keyed by the time a strip asked for, not the time it
was decoded at, so a coarse strip's keyframe-early frame could land in a
finer strip and run its frames backwards. A frame now carries its decoded
time and is reused only when the strip would have accepted that keyframe
itself. The frame-count ladder now tops out at a power of two (32), so
its top step shares frames with the one below instead of none.

* feat(studio): show the clip's last frame at the end of every filmstrip

Left-edge sampling never showed a clip's final slice, so an end card or a
fade-out was missing from its strip. Each strip now also decodes the
clip's last frame (at its own time, never an earlier keyframe), shared
across zoom steps like the others, and the last tile shows it.

The strip on screen is now held per clip and range, not per props: a
clip crossing between on and off screen keeps it, and a clip narrowed to
one frame lets it go, so widening again never re-decodes a dropped width.

* docs(studio): keep the filmstrip files at or under main's comment share

The comment ratchet allows no file a higher share of comment lines than on
main; the added explanations move to the PR description.

* fix(studio): keep a held filmstrip leased so a full cache cannot evict it mid-zoom

The hold leased the strip on screen only once a new width was requested,
so for one commit it had no lease; with the cache over budget it was
evicted, the poster showed, and the old width decoded again. The strip on
screen is now leased for as long as it is the clip's, and shown only while
held. A frame that fails to decode takes the frame before it, so every
slot keeps its time and the last tile still shows the end.

* refactor(studio): one owner for which clip a held filmstrip belongs to

The hold checked the clip twice, in its lease and again before showing
it; the lease alone decides now, and the cross-clip test pins it. A test
now also pins that a filled frame is not charged to the cache.

* fix(core): hold the tempo detector until idle after a slow import

* fix(studio): scope shared video frames to timeline sessions

* chore(core): keep beat analysis comments within the ratchet
2026-10-07 07:38:13 +00:00
Miguel Ángel dbb8222c8d fix(studio): catch up the preview after missed file changes (#5126)
* fix(studio): recover previews after missed file changes

* fix(studio): keep queued reloads with the current owner

* fix(studio): keep a queued outside change when switching compositions

* test(cli): the project watcher closes its parent folder's watch too

* fix(studio): keep the project-folder change out of the shared signature check

* fix(studio): keep the edited file's draft and reload every session after a reconnect

A reconnect or replaced folder is a project-wide change: every open SDK session reloads, a failed save is recorded against the file the draft belongs to, and two queued changes to different files merge into one project-wide reload.

* fix(studio): refresh the preview cache when the .hyperframes folder is replaced

* fix(cli): report the files of a folder moved into the project

Replacing a populated folder such as .hyperframes/ now reaches the signature cache through its files, so the shared signature rule stays as on main and Studio's own writes inside .hyperframes/ still invalidate nothing.

* refactor(studio): name the merged file-change payload and share the test sender

* fix(studio): keep a failed draft's recovery copy until that file itself saves

* style(studio): rewrap the merged payload line after the rename

* fix(studio): deliver a held change's full reload when its block clears

A clean save, Use external and Keep Studio now all deliver the merged scope a blocked change owes, and a moved-in .hyperframes folder reports its manifests so a dropped one still clears the cached signature.

* fix(studio): keep restored drafts out of the reload scope and clear the lint gates

* fix(studio): retain held reload scope before React renders
2026-10-07 07:32:16 +00:00
Miguel Ángel eee0c5101c perf(studio): smooth timeline zoom on long projects (#5109)
* perf(studio): coalesce timeline zoom to one write per frame and zoom to a range

* perf(studio): coalesce timeline zoom to one write per frame and zoom to a range

Every zoom input goes through one owner that writes the store once per frame.

zoomTimelineToRange eases to a time range; the zoom buttons use it.

* feat(studio): zoom-to-range reports done or cancelled and is not a person zoom

* perf(studio): place clips in percent of a one-second layer so zoom restyles nothing

A zoom step rewrites one width per row; clip thumbnails start decoding at rest.

* perf(studio): a zoom step re-renders only clips whose drawing it changes

Clip handlers read the lane callbacks when pressed, so a skipped clip never acts on a stale zoom.

* refactor(studio): move at-rest waveform and thumbnail work to its own change

* perf(studio): draw waveforms and decode thumbnails when the timeline rests

Waveforms draw only the visible span of a clip.

A zoom or scroll step stretches what is drawn until the timeline rests.

* test(studio): fade handles measure the clip only while they show

* test(studio): waveforms draw their visible span and redraw at rest

* feat(studio): export currentTimelineRange, the inverse of zoomTimelineToRange

* perf(studio): zoom gestures scale the drawn timeline and lay it out once at rest

No layout or render per gesture frame; past 0.67x or 2x the preview is laid out and continues.

* perf(studio): rebase a zoom preview only past 0.5x or 4x

Clips stay mounted half a viewport each side, so a zoom-out preview to 0.5x shows no gaps.

* fix(studio): keep the zoom readout, edges and Fit right during a preview

The slider and its percent follow a zoom while it is previewed. A zoom in
from below Fit no longer jumps when it lands: the preview now uses the
width the timeline will really have. A zoom-out lays out as soon as it
would show time with no clips mounted, so edges never show empty strips.
Fit, a press or a scroll on the timeline end a pending zoom first. An
older abort no longer cancels a newer zoom-to-range, and an instant one
lands on the next frame instead of inside its caller. Range selection and
gap tints scale with the preview.

* fix(studio): keep a pending zoom when the timeline re-registers its view

The timeline registers its scroll view again whenever clips are added or
removed, which used to drop a zoom still waiting to be laid out. Now only
a real unmount drops it, and unmounting one timeline leaves a newer
timeline's registration alone.

* refactor(studio): share the zoom geometry helpers the timeline already has

Zoom scales come from getTimelinePixelsPerSecond, the range margins from one
helper, and a zoom step starts from the same playhead anchor every zoom uses.
The clip minimum width is named and shared with the drag ghost.

* fix(studio): keep a zoom preview through scrolls and past the content end

A scroll during a zoom preview, including the one a laid-out zoom makes
itself, used to lay the next preview out at once, so a pinch or eased zoom
fell back to a full layout every frame. A scroll now only rescales what it
mounted. Zooming out also lays out before the preview shows past where the
ruler and beat lines are drawn, not only past the mounted clips.

* fix(studio): keep a moved audio clip's waveform drawn where it shows

A waveform now draws the stretch of its clip that the shared strip tracker
reports near the screen, the tracker the filmstrip already uses. It follows
scrolls, resizes and moves (a drag or ripple that shifts the clip), so a
moved clip no longer shows a blank stretch, and a zoom redraws it once.

* perf(studio): lay a zoom-out step out once, at its start

An eased zoom-out (a Zoom out click, or zooming out to a range) used to
lay out partway, once the shrinking preview reached time with nothing
mounted, and again at the end. It now lays the target out first and eases
by scaling that layout down from the old view, so each click lays out
once. Each eased frame also draws its preview in that frame.

* fix(studio): keep previewing a zoom at the content's end and over new rows

The preview now lays out only once it would show past the window the
timeline draws clips, ruler ticks and beat lines in, with two pixels of
slack, so rounding at the content's end no longer lays out every frame.
Rows mounted into the timeline during a preview are scaled before they
paint.

* fix(studio): redraw a waveform when its clip's width changes or it moves

A waveform drawn whole kept its old bars, stretched, after a zoom changed
its clip's width; it now redraws on any resize. A long clip also marks
its undrawn ends for the strip tracker, so a move without a scroll (a
ripple, a drop, an undo) re-measures it instead of leaving a blank
stretch.

* perf(studio): skip the layout when a zoom preview ends on its own scale

An eased zoom-out lays its target out first, so its preview ends on the
scale already laid out. Landing it now only sets the scroll, instead of
writing the same zoom again and counting the person's zoom twice.

* fix(studio): measure filmstrips and waveforms after a zoom preview ends

The strip tracker measured clip boxes while a zoom preview scaled them,
so after a Zoom out click a long clip's filmstrip or waveform could stay
blank across part of the screen. Reads during a preview now wait, and the
tracker re-measures every strip once the preview ends. A zoom-out lays its
target out in its first frame rather than inside the caller, and only when
the new view holds the old one.

* perf(studio): watch a waveform's undrawn ends only when it has some

A clip drawn whole has no undrawn ends, so it no longer mounts the two
empty markers the strip tracker watches; with hundreds of short audio
clips that saved an observer entry each.

* fix(studio): measure strips each time a zoom preview is laid out

A zoom-out lays its target out partway into the ease, after which the
strip tracker kept the stretch it measured before the zoom, so a long
filmstrip or waveform went partly blank near the end of the ease. Every
layout of a preview now re-measures the strips.

* docs(studio): keep the zoom-to-range doc comment within four lines

* docs(studio): keep the zoom files' comment share at or under main's

The comment ratchet failed on ten files this branch touches. Comments are
cut or folded into one line; no code changes.
2026-10-07 00:46:17 +00:00
Miguel Ángel 1825c37268 test(studio): launch real-Chrome tests like the e2e launcher, bounded by the test budget (#5134)
* test(studio): launch real-Chrome tests like the e2e launcher, bounded by the test budget

* test(studio): share the real-Chrome page setup between the two runtime tests

* test(studio): keep the real-Chrome test helper with the other test helpers
2026-10-07 00:25:18 +00:00
Miguel Ángel e8cfd6b73e perf(studio): parse saved HTML once when syncing timeline length (#5092)
* perf(studio): parse the saved file once to sync the composition length after a timeline edit

* perf(studio): keep empty duration updates free of parsing

* test(studio): pin one parse for rootless moves and the delete length readout

* test(studio): check the delete's length readout before its save lands
2026-10-06 23:16:21 +00:00
Miguel Ángel dc244d765c fix(studio): drags after a resize, undo or reload land where they were pressed (#5088)
* test(studio): bench moves pressed right after Add keyframe

* fix(studio): an undo that reloads the preview keeps the selection, so a drag after it moves

* fix(studio): a drag started while a resize saves keeps its animations paused

* fix(parsers): an edit pins no position on a keyframed tween it did not touch

* fix(studio): a move pressed while the preview changes lands on the new preview, at the time pressed

* test(studio): bench waiting presses, a held box resized twice, and reloads at the last playhead

* refactor(studio): tidy the waiting press, bound its wait, and freeze edit stamps after early exits

* test(studio): load the property panel once so its first test stops timing out

* fix(studio): presses queued on a held box keep their release, hover and newest outline

* test(studio): judge bench undo by the history groups Studio claims, not by file writes

* refactor(studio): one waiting-box lookup in the bench and one raf-pause write per release

Behaviour-identical tidy of the waiting-press work: the bench reads the waiting
box through one helper, releaseActiveGesture sets the raf pause once, clearPointerState
writes the false it always computed, and previewGestureStarts calls the opts setters
directly instead of through aliases named like the gesture hook's gated setters.

* fix(studio): a queued press lands on the element it pressed and survives a window switch

* fix(studio): undo cancels a waiting press, a stuck save stops holding presses, scene swaps hold them

* refactor(studio): keep the complexity waiver on clearPointerState itself

* fix(studio): undo of a queued press leaves nothing waiting, and hosts keep their own undo

* fix(studio): a waiting press keeps the box still after a blur, and undo reports what it took back

* style(studio): bring the PR's comments within the comment limits

* fix(studio): bank the four bench cases this fixes and meet the comment, complexity and duplication gates

* test(studio): pin that a waiting press is never a pending edit, and name the new duplication waivers
2026-10-06 20:22:37 +00:00
Miguel Ángel b9e511a768 fix(studio): a timeline jump no longer starts thumbnail loads it is about to cancel (#5085)
* fix(studio): a timeline jump no longer starts thumbnail loads it is about to cancel

* fix(studio): pump inline after the release batch, and pin the synchronous slot free
2026-10-06 18:04:50 +00:00
Miguel Ángel 5fad52f21d fix(studio): an offline preview says GSAP could not load instead of an unhandled Event (#5111)
* fix(studio): an offline preview says GSAP could not load instead of an unhandled Event

* fix(studio): the preview error list survives a second capture attach without duplicates

* fix(studio): the console capture keeps listening after a long early error list

* refactor(studio): the console capture owns every reset of its findings
2026-10-06 16:35:11 +00:00
Miguel Ángel b9998dd5a8 test(studio): bundle the real-Chrome undo test with esbuild so its setup fits on Windows (#5080)
* test(studio): bundle the real-Chrome undo test with esbuild so its setup fits on Windows

* test(studio): stub only the Node built-ins the undo test bundle reaches
2026-10-06 15:32:53 +00:00
Vance IngallsandClaude Opus 5.5 518694ac66 fix(studio): clip #root so programmatic scrolls cannot push the header off-screen (#5110)
Dockview keeps inactive "always"-rendered panel overlays in the DOM with
visibility:hidden and no position, so each one falls to its static position
below the dock at full dock height. That made the document ~2x the viewport
tall (1954px in a 997px window). body overflow:hidden only blocks user
scrolling, so the scrollIntoView that runs when a property group opens
scrolled the viewport 124px and cut off the header and panel tabs.

overflow:clip on #root (not hidden, which is itself programmatically
scrollable) removes the overflow: scrollHeight drops to the viewport height
and scrollIntoView on a hidden overlay no longer moves anything.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:03:11 +00:00
Miguel Ángel 8b5b012e5c fix(studio): dragging a Layers row no longer highlights the row names (#5104) 2026-10-06 06:03:20 +00:00
Miguel Ángel 4905b4dbca fix(studio): make clip peak warnings plain and hoverable (#5099)
* fix(studio): make clip peak warnings plain and hoverable

* fix(studio): expose peak detail on clip focus above waveforms

* fix(studio): preserve shared badge styling and track test identity
2026-10-06 04:11:06 +00:00
Miguel Ángel 3c40f37938 test: pin the guards that keep script-placed scenes and unparseable scripts out of the in-place move (#5079)
* test: pin the guards that keep script-placed scenes and unparseable scripts out of the in-place move

* test: make the script-placed scene check able to fail and pin its page order
2026-10-05 21:54:47 +00:00
Miguel Ángel 0d5e395cd3 fix(studio): keyframed animations keep their ease through every Studio edit (#5051)
* fix(parsers): dragging a keyframe keeps the keyframes' ease

* fix(parsers): splitting a keyframed tween into groups keeps its eases

* fix(studio): keyframed tweens keep their eases when Studio rewrites them

Extending a keyframed tween to the playhead and merging a gesture recording into it now carry the tween's eases and each keyframe's own ease. The parsers no longer report a tween-level easeEach, which GSAP ignores, as the keyframes' ease.

* fix(studio): a gesture merged into a tween writes rounded keyframe percentages

* fix(studio): a gesture merged into an eased tween plays at the times it was recorded

Merged keyframes are placed through the tween's run ease, as the extend path already does, and recorded constant-speed segments stay linear.

* fix(studio): the ease card describes a keyframed tween by its keyframes and eases

The summary line read 'no properties yet using a none curve' for every keyframed tween. It now names the keyframed properties, the keyframe count, the segment ease and the run ease that GSAP plays.

* fix(studio): a gesture merged over a strong ease-out keeps its keyframes and the ones before it

The merge keeps or replaces old keyframes by when they play, and rounds percentages only when the run ease does not warp time.

* fix(studio): the ease card labels an unset segment with the ease GSAP plays

* fix(studio): the ease card header shows the segment ease its rows play

The header shows the one ease every segment plays, or Mixed, and a run ease other than none gets its own line.

* fix(parsers): keyframe percentages are written as decimal keys the parsers read back

A percentage below 1e-6 printed as '1.28e-7%', which both parsers reject. One plainPercentKey owner now writes every percentage key in both writers and the unroller.

* fix(studio): the ease card's segment rows show rounded percentages
2026-10-05 19:18:37 +00:00
Miguel Ángel 98fe011699 fix(studio): undo takes back the last edit, even right after a resize or a nudge (#5048)
* fix(studio): a size edit right after a resize is its own undo step

* fix(studio): a nudge still saving is recorded before the next edit

* fix(studio): published types name cn by its package, not a node_modules path

* fix(studio): drop baseUrl so declarations never name a node_modules path

* fix(studio): an edit started inside the save of another edit never flushes

* fix(studio): an edit made while a nudge is still saving writes after it

* fix(studio): a later edit never overtakes one still waiting on a nudge save

* fix(studio): undoing a gsap.set clears what it applied from the preview

* fix(studio): an edit never waits on itself behind a nudge save

* fix(studio): a second nudge burst saves when its commit runs behind the first

* fix(studio): undoing a W on an animated box clears the width it set live

* fix(studio): any GSAP edit that drops a live-applied set clears it from the preview

* fix(studio): a soft reload clears exactly the set props a live patch applied

* fix(studio): name width and height as set-patch props
2026-10-05 16:52:53 +00:00
Miguel Ángel f7d6734761 fix(studio): Layers rows show a kind icon, and only a group row has a caret (#5067)
* fix(studio): Layers rows show a kind icon, and only a group's row has a caret

Rows show what an element is (image, video, audio, vector, group, text or shape) instead of the tag's first letters. The caret column sits only on rows with children, a group's children indent under its icon, and rows are inset with rounded corners so the selection reads centred.

* style(studio): name the Layers indent and phrasing tags instead of commenting them

* refactor(studio): Layers text and host checks reuse the editor's own rules

* fix(studio): a Layers child's icon starts where its parent's label starts

* refactor(studio): Layers text icon uses the inline editor's text check

* fix(studio): a Layers row with children shows the group icon, and its icon keeps the row's hint

* fix(studio): Layers text with styled spans keeps the Text icon
2026-10-05 16:51:04 +00:00
Miguel Ángel 5d19e23cb4 fix(core): a sub-composition follows its host clip when the host moves without a reload (#5058)
* fix(core): a sub-composition follows its host clip when the host moves without a reload

* perf(studio): move live tweens in place for a sub-composition host's clip too

* fix(core): keep sub-compositions nested where a fresh load nests them across rebinds and re-runs

* fix(studio): match the preview's re-printed script by its tokens and check every other timeline

* fix: compare preview scripts by syntax tree and keep scenes a re-run script places

* fix(parsers): keep a tagged template's raw text and a __proto__ key in the script compare

* fix(parsers): keep only a tagged template's raw text in the script compare

* perf(studio): read each preview script once across repeat timeline drags

* style(studio): format the preview script read test

* perf(studio): keep only script shapes and refresh them on reuse
2026-10-05 16:20:35 +00:00
Miguel Ángel 5c7f11ab6d fix(studio): plain elements keep their place and size when keyframed (#5045)
* test(studio): bench a plain element animated through auto-record and Add keyframe

* fix(studio): seed a first tween from the offset a plain move left in CSS translate

* test(studio): bench resize then drag on a GSAP tween, root and nested

* fix(studio): a resize under auto-record keys size at the playhead, held at all times

* fix(studio): keyframe tweens Studio writes end on their last key, so a seek matches playback

* test(studio): keep the plain-to-keyframes moves inside the nested frame

* fix(studio): a resize moves its anchor from the drag stamp it read at release

* fix(studio): an undo shown in place is no longer replaced by an older reload

* refactor: split the trailing keyframe trim and drop an unused GSAP reader

* test(studio): settle the resize-then-drag bench steps so a two-save resize is one undo

* fix(studio): a preview reload waits for an undo shown before its write lands

* fix(studio): Add keyframe reads a subtracted calc() translate with its sign

* refactor: tidy the keyframe hold, trim and resize code after review

* fix(parsers): the keyframe trim leaves looping, chained and relatively placed tweens alone

* fix(parsers): a size hold pins only a lone key, so an edit never changes frames left alone

* fix(studio): every undo applied in place counts as a preview change, script-only ones too

* fix(studio): a resize keys size only when a move or size is keyframed, not a fade

* test(studio): a bench step whose save never lands fails the case

* fix(parsers): holds and trims change only the tweens an edit touches

* test(studio): bank the plain-to-keyframes and resize-then-drag bench cases
2026-10-05 15:46:50 +00:00