Commit Graph

12 Commits

Author SHA1 Message Date
Miguel Angel Simon Sierra 62f04ce240 fix(studio): count logical media fetches, not ranged reads
A media element pulls one file as a series of ranged reads. Counting raw
HTTP requests reported a file fetched once in eight chunks as "8x
amplification", and the number got WORSE when a fix deferred a source so
the browser later fetched it in pieces: 19 requests became 37 for byte
identical content, and the ratio climbed from 2.38x to 4.63x while the
actual cost was unchanged.

Ranged reads of one URL now collapse into a single logical fetch. Raw and
ranged counts stay in the output as diagnostics. Same tree, same fixture:
4.63x becomes 1.13x, which is what the transferred bytes said all along.
2026-08-03 21:51:44 -07:00
Miguel Angel Simon Sierra b5571f758c test(studio): measure idle CPU, scrub, request volume and switch retention
Open time is one number at one instant. It cannot see the CPU Studio burns
while idle, the full-document media scan every scrub frame, the same media file
fetched eight times on open, or the heap kept after a project switch. This adds
a browser gate for all four and the baseline they read today, before any fix.

The idle window samples consecutive ten-second windows until the style-recalc
count settles rather than sleeping: the first window on a media project drains
the thumbnail-extraction backlog and reads 56% of a core against a flat steady
state, so a fixed sleep would gate on a transient. Animation-frame scheduling is
attributed by call site in every frame, which is what makes a future regression
debuggable rather than merely red.

The scrub window counts full-document video/audio queries during a synthetic
playhead drag, patched in every frame — all of those queries happen inside the
preview iframe, not the Studio document.

The Chrome resolver both e2e gates need moves to a shared puppeteerEnv module.
2026-08-03 21:51:43 -07:00
Miguel Angel Simon Sierra b82105ac0e test(studio): back the load fixture with real reused media
The load fixture gave every clip its own source, so it could not reproduce the
request amplification a real project produces: opening a 273-clip project issues
337 media requests for 79 files on disk, one track.mp3 seven-plus times. A gate
built on the old shape would measure nothing.

Add mediaSourceCount/mediaClipCount, independently controllable, with clips
round-robining over the sources so every source is reused at least seven times.
Sources are real, browser-decodable media generated deterministically: 32x32
solid PNGs, one-second 8 kHz silent WAVs, and a checked-in 1,684-byte H.264 clip
each source varies with an ignored trailing free box, so no two sources are
byte-identical and no content-addressed cache can collapse them.

Media defaults to off, so existing zero-media generation is unchanged.
2026-08-03 21:49:10 -07:00
Miguel Angel Simon Sierra 2c1a02f57c test(studio): add a dense on-disk project generator for load measurement
Generates a real HyperFrames project at a parameterised clip and track
count, rather than injecting TimelineElement objects into the store the
way the existing timeline fixture does. Only a project on disk exercises
the open pipeline the load gate needs to measure.

Reproduces the conditions that make that pipeline expensive: most clips
carry no authored DOM id, and same-kind clips share a class name.

The output is not checked in. At 1,000 clips it is ~9,200 lines of fully
derivable markup that nothing reads; the tests generate into temp dirs
and the gate generates the size it needs at run time.
2026-08-03 21:49:10 -07:00
Miguel Ángel c6925e471a feat(studio): enable timeline virtualization by default (#2926)
* feat(studio): enable timeline virtualization by default

* fix(ci): measure timeline performance in production React
2026-07-31 19:43:47 +02:00
Miguel Ángel 723d3381c4 fix(studio): keep dense keyframes readable (#2925)
* perf(studio): define timeline viewport budgets and fixtures

* test(studio): gate timeline viewport performance in Chromium

* refactor(studio): isolate clip drag lifecycle

* refactor(studio): extract timeline render contracts

* perf(studio): centralize timeline viewport geometry

* perf(studio): follow playhead across virtualized rows

* perf(studio): add timeline clip-window index primitive

* perf(studio): virtualize timeline clip windows

* perf(studio): stop timeline scroll work when row virtualization is off

The row virtualization stack made the timeline publish a viewport snapshot
on every scroll frame and swap `renderClipContent` across every mounted clip
at gesture start and settle. Both are windowing concessions, and neither was
gated on the flag, so the build users actually run paid for them while
mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median
scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247.

Gate both on the row virtualization flag. The scroll path now stops at the
door when the flag is off, so `isScrolling` stays false and resize-driven
and programmatic syncs still publish through the immediate path.

The flag moves into its own module: the scroll-viewport hook needs to read
it, and the virtualization hook already imports the viewport snapshot type
back, which would have closed an import cycle.

Also release the perf fixture lease from the fixture rather than from the
test-hook effect. Loading a fixture writes player state, which changed that
effect's dependency identities and tore it down on the next frame, so the
lease was revoked moments after it was taken and live iframe discovery
overwrote the fixture before the gate could measure it.

The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements)
next to the existing flag-on one. It refuses the 50,000-element combination,
verifies from the mounted DOM that the server under test matches the
requested flag, and skips the DOM-size budgets for the unvirtualized build
rather than relaxing them, so a skipped budget never reads as a passed one.

Verified against a live Studio dev server on the fixture project:

  flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass
  flag off, after:  interactionP95  33.6ms, longest task   0ms, 5/5 runs pass
  flag on,  after:  interactionP95  33.2ms, 4/5 runs pass, exit 0

The flag-on arm's fourth run reproducibly reports a 55-58ms long task
against a 50ms budget. That is the residual tail of the window swap itself,
tracked separately and not addressed here.

* ci(studio): run the timeline viewport gate on studio changes

The gate has existed since the row virtualization stack landed but nothing
under `.github/` referenced it, so it only ever ran when someone ran it by
hand. That is how the flag-off scroll regression reached eight merged-ready
PRs without anything noticing.

Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one
per flag state, and runs both arms of the gate against them. Two servers are
needed because row virtualization is read from `import.meta.env` at module
load, so one process cannot serve both builds.

Scoped to a new `studio` paths filter rather than the broad `code` one: the
gate only says anything about `packages/studio`, `packages/core` and
`packages/studio-server`.

Adds a `ci` tier. It applies the constrained budgets without any emulation,
because a hosted runner is already slower and noisier than the machine the
strict numbers were recorded on, while the existing `low-resource` tier would
throttle it a further 4x and measure the throttle rather than the build.

The fixture composition is tracked under `tests/e2e/fixtures` but Studio
resolves projects from the gitignored `data/projects`, so the job copies it
into place instead of a project directory being committed.

Both arms run in about 7 seconds each locally, so the job cost is almost
entirely dependency install and the workspace build it shares with
`studio-load-smoke`.

* fix(ci): preserve both timeline gate evidence arms

* ci(studio): report timeline gate arm statuses

* ci(studio): require timeline gate evidence artifacts

* fix(studio): keep dense keyframes readable

* fix(ci): resolve timeline stack audit findings
2026-07-31 18:05:22 +02:00
Miguel Angel Simon Sierra d57039882f fix(studio): harden keyframe editing semantics 2026-07-28 00:13:46 +02:00
Miguel Ángel 2b65b4efce fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
2026-07-17 14:15:30 -04:00
ukimsanov 139cf568b4 feat(studio): add color grading inspector 2026-07-06 13:40:20 -07:00
Miguel Ángel 08c0a03510 test(studio): agent-browser e2e smoke for the design panel (#1912)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.

* fix(studio): surface persist failures with toast and guarded revert

- matched:false and persist errors toast, warn structurally, and revert the optimistic write
- reverts guarded by a per-property version counter so stale failures never stomp newer edits
- structural text-field edits refuse persist instead of writing escaped markup
- multi-field child edits persist via per-child ops; shared commit runner extracted

* fix(studio): revert data-attribute and html-attribute commits on persist failure

commitDataAttribute and handleDomHtmlAttributeCommit toasted on failure but
never reverted the optimistic attribute write, leaving the preview showing an
edit that never reached disk (the exact bug this PR closes for style commits).
Extracted into useDomEditAttributeCommits.ts (useDomEditTextCommits.ts was at
the file-size cap) and routed through runDomEditCommit with a per-target+
attribute version guard, mirroring handleDomStyleCommit.

* fix(studio): close coupled persist-hook review findings

Three findings from R2 review that must land together: a patch-rejection
toast doubled up with the generic persist-failure toast (StudioSaveHttpError
had no alreadyToasted marker), a failed prepareContent write (e.g. font-face
injection) reverted and re-toasted a change the server had already
persisted, and text-commit shouldRevert only rolled back on one narrow
error type instead of any persist failure.

* test(studio): cover persist-failure hook behavior

Regression tests for the persist failure paths: unresolvable targets,
no-op warns, rejected requests, revert races, structural-edit refusal,
and read/write failure toasts.

* test(studio): cover attribute-commit revert on persist failure

Regression tests for the data-attribute and html-attribute revert paths
added in #1910: unresolvable target, rejected request, success (no revert),
and a stale-failure-vs-newer-success race guarded by the per-attribute
version counter.

* test(studio): cover the patch-rejection and text-commit revert fixes

Adds the two persist-hook cases R2 flagged as untested: the
!patchResponse.ok HTTP-error path (previously only exercised via a
network-throw, which bypassed this branch) and handleDomTextCommit's
server-failure path. Also strengthens the prepareContent-write-failure
test to assert the already-persisted base patch is recorded, not
reverted, matching the coupled persist-hook fix.

* test(studio): agent-browser e2e smoke for the design panel

Standalone script driving selection plus one input per panel section
against a running preview, asserting disk persistence and reload survival.

* fix(studio): close smoke-test quality nits, add fault-injection coverage

Closes the R2/R3 findings on the design-panel e2e smoke script:

- Section lookup no longer matches h3 display text plus a manual tree
  walk (breaks on wording tweaks). Section now carries a stable
  data-panel-section attribute; the script queries by it directly.
- Fields are located by their sibling label (or, where none exists,
  by being the section's only input of that type) instead of by
  guessing the fixture's current value ahead of time.
- Fixed sleep(1400/2000/6000) waits replaced with polling on the
  actual condition (selection registered, section rendered, patch
  round-tripped, app booted). This surfaced a real bug while
  verifying: computing click coordinates right after a commit reused
  a stale preview-frame position from before the property panel's
  reflow, silently clicking the wrong spot — now waits for the
  frame's rect to stabilize first. Also found and fixed a disk-write
  race on the first commit of a run (patch fetch resolves before the
  server's file write lands).
- FAIL now dumps window.__patchLog for diagnosability.
- Added a fault-injection cell: the server rejects a patch and the
  panel must toast the rejection without persisting it or clobbering
  the prior committed value.

Verified by actually running the script with agent-browser against a
live preview (previously never exercised this way) — all 14 checks
pass across repeated clean runs.
2026-07-03 18:50:23 -07:00
Miguel Ángel 4d199c0f3a test(studio): add design-panel QA fixture and triage matrix (#1906)
Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.
2026-07-03 17:40:38 -07:00
Miguel Ángel fb2e21090f feat(studio): GSAP tween editing in Design panel (#1102)
* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
2026-05-28 19:16:34 -04:00