From 4c6fa9a7906e3b099a943a3fff7edbc41573497b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 12 Sep 2026 12:56:00 -0400 Subject: [PATCH] fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910) * chore(studio): remove the write-only self-write timestamp ref `domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read nowhere. It used to feed a 2 s "suppress the watcher reload after our own write" window; that mechanism was replaced by content-hash identity in sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an SDK self-write echo from an undo landing in the same window). The reader went with that change, the writers did not, and three comments kept describing the timestamp as protection that no longer existed. Pure deletion: the ref, every prop and parameter that threaded it, every assignment, the five comments citing it, and the orphaned test helpers. No behaviour change; the studio suite, typecheck, lint and format are green. Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts. It looked undefined from inside the studio package, but shader-transitions installs it on the preview window (hyper-shader.ts) so soft reloads do not invalidate cached transitions. It is live. * docs(studio): plan for loading the preview during the shell's first layout * fix(runtime): a paused preview stays still after any seek Sub-compositions kept animating while the transport was paused and the Studio button showed play. A render-seek unpauses every sibling timeline so GSAP propagates the root's totalTime into them, and nothing paused them again; parented to the global ticker, they free-ran at 1x while the master timeline and the clock stayed stopped. The Studio reaches that seek path through its seek-driven fallback adapter right after a preview reload, which is why editing text was what set it off. Captured live in the user's preview: seven child timelines advancing 0.210s per 200ms sample, all gsapPaused=false, transport not playing. Three fixes under one invariant: while the clock is paused nothing runs, and the button never disagrees with the runtime. - The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters now returns every timeline it unpaused to paused in a finally. The second rearm after the child re-seek only ever changed the leaked state and is gone, with the comment that justified the leak by describing one caller. - The paused side of the transport tick policed nothing; it now stops any timed media element found running. A parked transport runs no ticks, so a capture-phase play listener wakes it. - Studio's canvas click resumed by setting the store flag alone; it now requests playback through the player so adapter, rAF loop and flag agree. Regression tests fail without each of the three changes. * perf(studio-server): stop re-encoding videos the browser already plays on open Opening a project kicked off a background transcode for every asset whose codec some browser might not decode. VP9 is on that list for Safari's sake, so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60 CPU-second re-encode across six cores on every open, concurrently with the browser's first layout, for an output nothing ever requested: across four recorded sessions the browser asked for the proxy zero times. One predicate was doing two jobs. "Could some browser fail on this" is a property of the asset and decides what gets injected into the page; "will this client request the substitute" decides whether to spend CPU before being asked. The codec table now carries an explicit prewarm flag: HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are injected but transcoded lazily by the existing ?hf-proxy= request, which already reports failure as a 502. Pre-warms requested and proxies served are now counted on the existing structured-stderr telemetry channel, because a speculative job with a 0% hit rate emits no error and had been invisible. A single lookup helper also closes a prototype-key gap where one path used Object.hasOwn and the other a bare index. Regression test: a VP9 and an HEVC asset in one composition yield exactly one pre-warm, for the HEVC. Fails on the previous gate. * perf(cli): serve the studio bundle compressed and cache hashed assets The 4.1MB studio bundle was served uncompressed with Cache-Control: no-store, so every open of the studio re-downloaded and re-parsed it. Vite names built assets with an 8-character content hash, so their bytes can never change: those now ship gzip-compressed (1.25MB on the wire, byte-identical after inflation) with a one-year immutable cache policy. Unhashed files under public/ keep no-store, and the HTML shell, which previously sent no Cache-Control at all, now sends no-store explicitly: it is the only thing that names the current hashed bundle, and it must keep revalidating for the immutable policy to be safe. The hash test only accepts the segment after the last hyphen and requires a digit, underscore or capital, so an ordinary hyphenated name like user-Guide-v2.js is not mistaken for a hash and served forever. * perf(cli): warm the preview route before the browser opens The browser's first request for a project's preview paid the server's cold compiler import and first bundle in the request path, after the studio shell's own first-layout stall, so both costs landed on the user's time-to-first-frame in series. The CLI now issues one fire-and-forget request for the preview route from openStudioBrowser, the single funnel for every launch path, before the --no-open early return so pasted URLs benefit too. Measured on the demo project: first client request 231ms before, 101ms after; the ETag 304 path afterwards is under 1ms. The fetch carries a 10s abort so an unsettled connection cannot keep an otherwise-finished CLI process alive, matching the package's existing convention. The shader query params the player appends are not part of the route's cache key, so the plain route URL warms the same entry. * perf(studio): request the player chunk before the shell's first layout The dynamic import of @hyperframes/player ran inside the preview's mount effect, which React schedules after the shell's first layout. On a cold open that layout stalls the main thread for seconds, so the chunk request waited behind it for no reason. The import is now kicked at module scope, behind a typeof window guard that preserves the documented SSR contract (the module registers a custom element at load), and the mount effect awaits the already-in-flight promise. Verified in the built bundle: the preload call sits at module top level, so the request goes out on bundle evaluation. * fix(studio): editing a text property no longer reloads the preview Every keystroke in the Design panel writes the composition file, the file watcher announces the change to the studio, and the studio decides whether the change was its own. Over the CLI's event stream that decision has never worked: the browser hands the handler a MessageEvent whose data is a JSON string, and three of the four payload readers (version, write token, content) only understood an already-parsed object, so they read every field as absent. An absent token means "someone else edited the file", and the studio hard-reloaded the preview iframe on its own edit, blanking it for seconds. The path reader alone knew how to unwrap the string, which is why the event was recognised well enough to reload and never well enough to suppress. The envelope is now decoded once, at the boundary, by one function that all three transports feed; the readers share one field accessor so they cannot diverge again. The production event-stream rung is extracted into an exported channel so a test can drive a real MessageEvent through the listener it registers, which was impossible before because vitest defines import.meta.hot and the selection never reached that rung under test. A second, smaller cause: the server attached the write receipt to the first subscriber only and deleted it on read, so any other listener saw an unlabelled change. Reads are now non-destructive with the TTL as the only eviction, scanning newest-first because identical bytes written twice inside the TTL (undo, retyping a value) share a version and the older token was already spent. The file version now ships with every event, receipt or not, so duplicate deliveries of one change dedupe instead of reloading once each. shouldReloadSdkSession had no production callers and a signature that invited an undecoded delivery straight back into this bug; it is removed. consumeFileWriteReceipt stays as a deprecated alias for one release. Regression tests: a Studio write delivered as a real SSE MessageEvent is suppressed; two subscribers of one watcher event reload once; a genuinely external write still reloads; a repeat of earlier bytes gets the newest token; a receipt past the TTL is not recognised. Each fails on the code before it. * fix(runtime): paused-time media playback is borrowed, not banned The paused-side enforcement added in the previous commit had no notion of provenance, so it stopped two features that legitimately play media while the transport clock is paused. Both were deterministic, not racy: the capture-phase `play` listener means the very play() that starts them wakes the transport that stops them. - The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a video while paused to render grading previews. It went dark on the first paused tick. - The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the music track for ~140 ms while paused so a playhead drag is audible. Same path killed it. A runtime-owned lease fixes both without weakening the enforcement. One owner: a WeakSet in the runtime closure, with lease/release published on the existing window.__hf surface for the Studio, which reaches the element across the iframe boundary and cannot call into the closure. The grading runtime is constructed with the pair directly. Both the cheap probe and the sync path skip leased elements while the clock is paused; during playback the transport owns everything again. Anything that plays while paused without a lease is, by definition, the defect the enforcement exists for, and is still stopped. Two corrections to the previous commit's reasoning: The old leak broke the render path too, deterministically, not only the preview. packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left unpaused therefore advanced by the full inter-frame delta into the captured frame. The finally added in the previous commit fixes that as well. Deleting the second activateSiblingTimelines is safe because nothing between frames reads a sibling's paused(), verified by grep over the deterministic adapters, syncTimedElementVisibility, the hf-timelines-built handler and __hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each child. The code comment now gives that reason. Tests, each proven non-vacuous by reverting the piece it guards: - a leased element survives repeated paused ticks and is stopped once released - the colour-grading preview survives, through the real init wiring - the scrub borrows the element and gives it back on stop - the existing test that an unleased element is still stopped keeps passing * test(runtime): a lease ends when the transport plays or the borrower stops Two assertions the review found not load-bearing. Dropping the isPlaying branch so leased media stayed exempt during playback left every test green; a leased out-of-window clip is now asserted stopped the moment the transport plays. Deleting the release in the grading stop closure also left the suite green, because the pause on the next line satisfied the assertion; a restart after stop is now asserted stopped, which only the release makes true. * test(studio): the event-stream channel must open /api/events * fix(studio-server): stop printing a proxy diagnostic line per clip on every render Review of the pre-warm commit found the diagnostic louder than the thing it diagnoses, and the two counters measuring different things under one name. The per-asset `prewarm_requested` line is now behind HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile clips printed fifty JSON lines into a clack-formatted terminal on every re-render. One summary line is written at process exit instead, using the same process.on("exit") shutdown hook as packages/cli/src/cli.ts. The counters now share a unit. prewarmsRequested counts per asset per render; proxyRequests counted every HTTP request, including 304s. It now increments once per resolveProxy call, after the ETag shortcut, so a revalidated repeat no longer reads as fresh demand. An unconditional Range refill still counts, and the docstring says so rather than claiming otherwise. The HEVC justification was false on macOS Chrome, which answers canPlayType for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed there only through the reactive zero-videoWidth path. prewarm stays true because Chrome on Windows/Linux and Firefox do need the substitute; the comment and test names now say "no cross-platform decode" instead of "browsers never decode it". Two test gaps closed. Nulling vp9's representativeMime left the suite green while making the client skip canPlayType and proxy on every browser, which would reinstate exactly the transcodes this work removed; the mimes are now pinned. The Object.prototype test passed with the hasOwn guard deleted, so it now goes through probeAssetCodec, the input that actually misbehaves without it. Both fail when the change is reverted. * perf(cli): stop gzipping the studio bundle on loopback Compression made the cold open slower on the only transport this server has. It binds 127.0.0.1 with no --host, and measured there the six bundle assets took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms against 3.1 ms. hono/compress is removed, which also retires the Vary header question it raised. If remote serving ever matters, compress at build time rather than per request. The cache policy is now decided by route instead of by filename. Reading a content hash out of a name cannot work: rollup's alphabet is base64url and includes a hyphen, so roughly one hashed file in ten was misread as unhashed, and the immutable header also leaked onto hand-authored public/ files served by the same handler. packages/studio/vite.config.ts sets neither build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed emits and every public/ file lands at the dist root. /assets/* is therefore immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic in between. The shell is no-cache rather than no-store. It carries no ETag, so both force the same full refetch, but no-store puts the document on Chrome's bfcache blocklist: leaving Studio and pressing Back would cold-boot the app instead of restoring it. * docs(runtime): say what the paused-media probe actually filters * revert(cli): drop the preview prewarm that held the CLI event loop The fire-and-forget warm added in c9b4263c7 keeps the CLI alive until it settles. cli.ts drains the event loop instead of calling process.exit, and two launch paths return straight into exit, so the warm delays them: 2660 ms with the warm suppressed, 8779 ms against a server answering in 6 s, 13293 ms against one that hangs. The 10 s abort bounds the hold, it does not remove it. This is the hazard already documented at preview.ts:1605-1609. Moving the warm into the server process was the obvious fix, but measurement says there is nothing there to warm. tsup bundles @hyperframes/core/compiler into cli.js -- bundleToSingleHtml is a plain inline function in the artifact -- so the await import() at studioServer.ts:397 resolves an already-evaluated namespace. A probe at listen time measures that import at 1 ms, and a paired run shows no gain: first request 256 ms without a compiler kick, 308 ms with one. The cold cost the plan attributed to the module load is somewhere else. Two claims in the reverted comment were also wrong: routes/preview.ts:332 is an ETag, not a bundle cache, and the player appends no shader params -- the only query param is variables, which IS part of the ETag. Reverts packages/cli to origin/main exactly. * fix(studio): let a remount retry the player chunk after a failed load 1037456aa hoisted the dynamic import to module scope and shared one promise across every mount. On rejection the .then never ran again: retryPreviewRef and previewError stayed null, compositionLoading stayed true, so the preview sat on an infinite spinner with the retry button at Player.tsx:462 unreachable, and remounting -- the recovery path that used to work -- awaited the same rejected promise. Memoize through a getter that clears the memo on rejection instead. The module-scope kick still fires the chunk request before the shell's first layout, and a remount performs a fresh import as it did before the hoist. Discloses what the earlier commit did not: the player barrel re-exports Player, so every studio module importing that barrel now evaluates this one and eagerly loads the real player bundle. That is 27 DOM-env test files; the full studio suite passes, 439 files and 4838 tests. * docs(studio-server): trim the proxy-counter comment to the repo's four-line cap * fix(studio): stop one failed player import poisoning every later mount The previous commit claimed a remount could retry the player chunk after a failed load. In a browser it cannot: the module map caches a failed fetch as an errored entry for the document's lifetime, so a repeated import of the same specifier rejects from cache without touching the network (measured in headless Chrome: three attempts, one request). Clearing the memo on rejection is still right, because it stops every later mount awaiting the same poisoned promise, but recovery is a page reload, and the comment now says so. The tests said what vitest does, not what the browser does. Two assertions survived their own mutation: removing the memoisation left the attempt counter unchanged because vitest caches a resolved mock, and deleting the module-scope kick, the optimisation this branch exists for, left the suite green. Both are now asserted directly: the kick has run once at import time before any mount, and every mount receives the identical promise. * chore(ci): allow the deletion of the dead sdk-session reload test * fix(runtime): the timeline resolver no longer unpauses children it cannot drive Exercising the branch in a real browser still showed sub-compositions free-running while paused, on a path no seek follows: the timeline resolver, which runs on every rebind (after an edit, on the periodic bind), unpaused every registered child BEFORE trying to nest it into the root. A child the root actually holds is driven by the paused root and is harmless; a child the root never takes stays on GSAP's global ticker, and unpaused there means running. The seek fix in this branch could not reach it, because a rebind is not a seek. The resolver now reads back which children the root holds and unpauses only those; the same rule applies to the composite fallback timelines. Standalone registry children stay paused, as the transport's per-child seek already requires. Regression test: a hosted registry child whose root cannot nest it stays paused across a forced rebind. Fails on the previous resolver. * test(runtime): pin that the resolver unpauses a child only once the root holds it The resolver's positive half had no fixture: nothing anywhere nested a candidate for real, so computing the held set before the add loop instead of after it left the suite green. A root that reports children only once added now distinguishes the two orders, and the unpause is asserted at the moment it happens, because the transport's first seek pauses every hosted child again straight after resolution. Also drops a cast that re-declared getChildren, which RuntimeTimelineLike already has. * fix(runtime): the paused side stops every clip the transport drives The paused-side enforcement scanned only media carrying its own data-start. A clip inside a composition inherits its timing from the host and has no data-start of its own, yet the transport plays it, so it could start while paused and run unopposed, exactly the hole the invariant was written to close. Reviewer probe: a hosted video with data-duration only kept running while paused; the same element started under __player.play(). "Media the transport drives" now has one definition, shared by the media cache and the paused-side probe, so neither can be narrower than the other. Regression test: hosted media with no data-start started while paused is stopped. Fails against the data-start-only scan. --- packages/cli/src/commands/play.test.ts | 1 + packages/cli/src/commands/play.ts | 2 + .../src/server/studioServer.file-race.test.ts | 13 +- .../server/studioServer.staticAssets.test.ts | 102 +++++++ packages/cli/src/server/studioServer.test.ts | 95 ++++++- packages/cli/src/server/studioServer.ts | 30 ++- .../cli/src/utils/staticProjectServer.test.ts | 1 + packages/cli/src/utils/staticProjectServer.ts | 2 + packages/core/src/runtime/colorGrading.ts | 12 +- packages/core/src/runtime/init.test.ts | 22 +- packages/core/src/runtime/init.ts | 164 +++++++++--- .../runtime/pausedTransportInvariant.test.ts | 137 ++++++++++ .../core/src/runtime/transportPark.test.ts | 141 ++++++++++ packages/core/src/runtime/window.d.ts | 4 + .../src/helpers/fileVersion.test.ts | 48 +++- .../studio-server/src/helpers/fileVersion.ts | 29 +- .../src/helpers/mediaCodecMap.test.ts | 71 ++++- .../src/helpers/mediaCodecMap.ts | 111 +++++++- .../src/helpers/mediaProxyPreview.test.ts | 113 ++++++++ .../src/helpers/mediaProxyPreview.ts | 20 +- packages/studio-server/src/index.ts | 1 + .../studio-server/src/routes/files.test.ts | 18 +- .../studio-server/src/routes/preview.test.ts | 39 ++- packages/studio-server/src/routes/preview.ts | 5 + packages/studio/src/App.tsx | 7 - .../src/components/StudioRightPanel.tsx | 7 - .../src/components/StudioRightPanel.types.ts | 2 - .../components/editor/SnapToolbar.test.tsx | 2 - .../src/components/editor/manualEdits.test.ts | 6 +- .../src/components/editor/manualEdits.ts | 2 +- .../components/editor/manualEditsParsing.ts | 33 +-- .../panels/VariablesOtherCompositions.tsx | 5 +- .../src/components/panels/VariablesPanel.tsx | 6 +- .../src/components/studioColorGradingScope.ts | 4 - .../src/hooks/elementLifecycleOpsTestUtils.ts | 1 - .../hooks/externalFileChangeOwnership.test.ts | 41 ++- .../studio/src/hooks/gsapScriptCommitTypes.ts | 1 - .../studio/src/hooks/sdkSelfWriteRegistry.ts | 2 +- .../src/hooks/timelineAudioGroupCreate.ts | 7 - .../hooks/timelineAudioGroupVolume.test.ts | 1 - .../src/hooks/timelineAudioGroupVolume.ts | 6 - .../src/hooks/timelineEditingHelpers.test.ts | 3 - .../src/hooks/timelineEditingHelpers.ts | 10 - .../src/hooks/timelineElementFxAttribute.ts | 6 - .../src/hooks/timelineTrackVisibility.test.ts | 17 -- .../src/hooks/timelineTrackVisibility.ts | 15 -- .../useAppHotkeys.previewForwarding.test.tsx | 1 - .../hooks/useAppHotkeys.textEditing.test.tsx | 2 - packages/studio/src/hooks/useAppHotkeys.ts | 8 +- .../useAudioGroupCarveAssignment.test.tsx | 1 - packages/studio/src/hooks/useClipboard.ts | 12 +- .../src/hooks/useDomEditCommits.test.tsx | 2 - .../studio/src/hooks/useDomEditCommits.ts | 23 +- .../src/hooks/useDomEditSession.test.tsx | 1 - .../studio/src/hooks/useDomEditSession.ts | 7 - .../studio/src/hooks/useEditorSave.test.tsx | 1 - packages/studio/src/hooks/useEditorSave.ts | 8 +- .../src/hooks/useElementLifecycleOps.ts | 3 - .../useExternalFileChangeCoordinator.test.tsx | 54 ++++ .../hooks/useExternalFileChangeCoordinator.ts | 62 +++-- .../useFileManager.projectOwnership.test.tsx | 1 - packages/studio/src/hooks/useFileManager.ts | 3 - packages/studio/src/hooks/useGroupCommits.ts | 2 - .../src/hooks/useGsapScriptCommits.test.tsx | 2 - .../studio/src/hooks/useGsapScriptCommits.ts | 7 +- .../src/hooks/usePreviewInteraction.test.ts | 19 +- .../studio/src/hooks/usePreviewInteraction.ts | 6 +- .../hooks/useProjectCompositionVariables.ts | 9 +- .../src/hooks/useRazorSplit.history.test.tsx | 2 - .../studio/src/hooks/useRazorSplit.test.ts | 2 - .../studio/src/hooks/useRazorSplit.test.tsx | 1 - packages/studio/src/hooks/useRazorSplit.ts | 6 - .../studio/src/hooks/useSdkSession.test.ts | 32 --- packages/studio/src/hooks/useSdkSession.ts | 14 +- .../studio/src/hooks/useSlideshowPersist.ts | 6 +- .../src/hooks/useTimelineAssetDropOps.ts | 6 - .../studio/src/hooks/useTimelineDeleteOps.ts | 4 - .../src/hooks/useTimelineEditing.test.tsx | 3 - .../studio/src/hooks/useTimelineEditing.ts | 16 -- .../src/hooks/useTimelineEditingTypes.ts | 1 - .../src/hooks/useTimelineGroupEditing.ts | 6 - .../studio/src/hooks/useVariablesPersist.ts | 3 - .../studio/src/player/components/Player.tsx | 23 +- .../components/playerModuleRetry.test.ts | 41 +++ .../studio/src/player/lib/playbackTypes.ts | 7 + .../player/lib/timelineIframeHelpers.test.ts | 30 +++ .../src/player/lib/timelineIframeHelpers.ts | 16 ++ .../src/utils/sdkCutover.familyGate.test.ts | 1 - .../studio/src/utils/sdkCutover.gate.test.ts | 1 - packages/studio/src/utils/sdkCutover.test.ts | 27 -- .../studio/src/utils/sdkEditTransaction.ts | 4 - .../src/utils/setSlideshowManifest.test.ts | 1 - .../studio/src/utils/studioFileHistory.ts | 1 - packages/studio/vite.config.ts | 22 +- ...preview-loads-during-shell-first-layout.md | 250 ++++++++++++++++++ scripts/check-no-main-deletions.mjs | 4 + 96 files changed, 1645 insertions(+), 484 deletions(-) create mode 100644 packages/cli/src/server/studioServer.staticAssets.test.ts create mode 100644 packages/core/src/runtime/pausedTransportInvariant.test.ts create mode 100644 packages/studio-server/src/helpers/mediaProxyPreview.test.ts delete mode 100644 packages/studio/src/hooks/useSdkSession.test.ts create mode 100644 packages/studio/src/player/components/playerModuleRetry.test.ts create mode 100644 plans/preview-loads-during-shell-first-layout.md diff --git a/packages/cli/src/commands/play.test.ts b/packages/cli/src/commands/play.test.ts index f4fec2413c..55c8a5ca88 100644 --- a/packages/cli/src/commands/play.test.ts +++ b/packages/cli/src/commands/play.test.ts @@ -64,6 +64,7 @@ const mediaMocks = vi.hoisted(() => ({ ), isProxyVariant: (value: string) => value === "h264" || value === "vp8", isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp8", + recordProxyRequest: () => {}, proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), resolveProxyVariantRequest: (request: "auto" | "h264" | "vp8", facts: { hasAlpha?: boolean }) => { const expected = facts.hasAlpha ? "vp8" : "h264"; diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index 1a02780c52..0be49f9f02 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -47,6 +47,7 @@ import { decideMediaProxyEligibility, isProxyVariantRequest, probeAssetCodec, + recordProxyRequest, resolveProxyVariantRequest, PROXY_VARIANT_CONFIG, } from "@hyperframes/studio-server/media-codec-map"; @@ -248,6 +249,7 @@ export async function registerCompositionRoute( if (!proxyVariant) { return ctx.text("Media proxy variant does not match asset", 422); } + recordProxyRequest(); const proxyPath = await resolveProxy(project.dir, filePath, proxyVariant); return buildRangeResponse( proxyPath, diff --git a/packages/cli/src/server/studioServer.file-race.test.ts b/packages/cli/src/server/studioServer.file-race.test.ts index 59512abb3f..18f74b0cce 100644 --- a/packages/cli/src/server/studioServer.file-race.test.ts +++ b/packages/cli/src/server/studioServer.file-race.test.ts @@ -148,16 +148,19 @@ describe("Studio bundle file reads", () => { }, ); + // /assets/ holds only Vite's content-hashed emits, so it is served immutable + // while the hand-authored public/ files keep revalidating. Cache policy + // itself is owned by studioServer.staticAssets.test.ts. it.each([ - ["assets/main.js", "text/javascript"], - ["icons/logo.svg", "image/svg+xml"], - ["favicon.svg", "image/svg+xml"], - ])("preserves %s MIME and cache headers", async (name, mime) => { + ["assets/main.js", "text/javascript", "public, max-age=31536000, immutable"], + ["icons/logo.svg", "image/svg+xml", "no-store"], + ["favicon.svg", "image/svg+xml", "no-store"], + ])("preserves %s MIME and cache headers", async (name, mime, cacheControl) => { fs.writeFileSync(path.join(hooks.studioDir, name), ""); const response = await server.app.request(`/${name}`); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain(mime); - expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("cache-control")).toBe(cacheControl); expect(await response.text()).toBe(""); expectClosed(); }); diff --git a/packages/cli/src/server/studioServer.staticAssets.test.ts b/packages/cli/src/server/studioServer.staticAssets.test.ts new file mode 100644 index 0000000000..39a5c11f87 --- /dev/null +++ b/packages/cli/src/server/studioServer.staticAssets.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { tmpdir } from "node:os"; +import { createStudioServer, type StudioServer } from "./studioServer.js"; + +/** + * Cache policy for the studio bundle, decided by ROUTE. Vite emits only + * content-hashed files under dist/assets, so that path can be immutable; + * public/ files (icons, favicon) keep their names across builds and the HTML + * shell names the current bundle, so both must revalidate. + */ + +const hooks = vi.hoisted(() => ({ studioDir: "" })); + +// The bundle directory is resolved from __dirname at server construction, so +// point that one `resolve(<...>/server, "studio")` call at a temp tree. +vi.mock("node:path", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolve: (...parts: string[]) => + hooks.studioDir && parts.length === 2 && parts[0]?.endsWith("server") && parts[1] === "studio" + ? hooks.studioDir + : actual.resolve(...parts), + }; +}); + +// A real filename from `bun run --filter @hyperframes/studio build`. +const HASHED_BUNDLE = "index-BRr1JoHX.js"; +const BUNDLE_BYTES = "export const studio = 1;"; +// Hand-authored, hyphenated, carrying capitals and digits — the shape no +// filename heuristic can separate from a rollup hash. It lives under /icons/, +// so the route answers correctly without having to. +const PUBLIC_ICON = "brand-Logo2026x.svg"; + +let root: string; +let server: StudioServer; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(tmpdir(), "hf-studio-assets-")); + hooks.studioDir = path.join(root, "studio"); + const projectDir = path.join(root, "project"); + fs.mkdirSync(projectDir); + fs.mkdirSync(path.join(hooks.studioDir, "assets"), { recursive: true }); + fs.mkdirSync(path.join(hooks.studioDir, "icons"), { recursive: true }); + fs.writeFileSync(path.join(hooks.studioDir, "assets", HASHED_BUNDLE), BUNDLE_BYTES); + fs.writeFileSync(path.join(hooks.studioDir, "icons", PUBLIC_ICON), ""); + fs.writeFileSync(path.join(hooks.studioDir, "favicon.svg"), ""); + fs.writeFileSync( + path.join(hooks.studioDir, "index.html"), + "Studio", + ); + server = createStudioServer({ projectDir }); +}); + +afterEach(() => { + server.watcher.close(); + fs.rmSync(root, { recursive: true, force: true }); + hooks.studioDir = ""; +}); + +describe("studio bundle cache policy", () => { + it("serves everything under /assets/ as immutable", async () => { + const response = await server.app.request(`/assets/${HASHED_BUNDLE}`); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable"); + }); + + it("does not make a public/ icon immutable, however hash-like its name", async () => { + const response = await server.app.request(`/icons/${PUBLIC_ICON}`); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("keeps the favicon revalidating", async () => { + const response = await server.app.request("/favicon.svg"); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("revalidates the HTML shell without evicting it from bfcache", async () => { + const response = await server.app.request("/"); + + expect(response.status).toBe(200); + // `no-cache`, not `no-store`: same refetch, but `no-store` would blocklist + // the document from Chrome's bfcache and cold-boot Studio on Back. + expect(response.headers.get("Cache-Control")).toBe("no-cache"); + }); + + it("sends the bundle uncompressed, which loopback measures faster", async () => { + const response = await server.app.request(`/assets/${HASHED_BUNDLE}`, { + headers: { "Accept-Encoding": "gzip, deflate, br" }, + }); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(await response.text()).toBe(BUNDLE_BYTES); + }); +}); diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index f7d1973f8e..b1e3d1ebb5 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -1,12 +1,30 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; +import { fileContentVersion } from "@hyperframes/studio-server"; import { loadHyperframeRuntimeSource } from "@hyperframes/core"; import { loadRuntimeSource } from "./runtimeSource.js"; import { findFFmpeg, findFFprobe } from "../browser/ffmpeg.js"; import { createStudioServer, type StudioServer } from "./studioServer.js"; +// Only `fs.watch` is replaced, so the SSE describe below can fire a file-change +// on demand; every other server test keeps reading and writing real files. +const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void }; +mockWatcher.close = vi.fn(); + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + const watch = vi.fn( + (_path: string, _options: unknown, onChange: (event: string, filename: string) => void) => { + mockWatcher.on("change", onChange); + return mockWatcher; + }, + ); + return { ...original, default: { ...original, watch }, watch }; +}); + // Every server-backed describe below wants the same two things: a throwaway // project directory, and a server whose watcher is closed afterwards. Three // copies of that got out of step, so it lives here once. @@ -19,7 +37,11 @@ function tmpProject(): string { return dir; } -afterEach(() => { +const openReaders: ReadableStreamDefaultReader[] = []; + +afterEach(async () => { + await Promise.all(openReaders.splice(0).map((reader) => reader.cancel().catch(() => {}))); + mockWatcher.removeAllListeners(); server?.watcher.close(); server = undefined; delete process.env.HYPERFRAMES_FFMPEG_PATH; @@ -181,3 +203,70 @@ describe("FFmpeg environment endpoint", () => { }, ); }); + +describe("Studio file-change SSE", () => { + /** Opens `count` `/api/events` connections and waits for each to register its listener. */ + async function subscribe(count: number): Promise[]> { + const responses = await Promise.all( + Array.from({ length: count }, () => server!.app.request("/api/events")), + ); + const streams = responses.map((response) => { + const reader = response.body!.getReader(); + openReaders.push(reader); + return reader; + }); + // streamSSE runs its callback after the Response resolves, so the listener + // each connection adds has to exist before the watcher fires. + await new Promise((resolve) => setTimeout(resolve, 20)); + return streams; + } + + const nextEvent = async (reader: ReadableStreamDefaultReader): Promise => + new TextDecoder().decode((await reader.read()).value); + + /** The version as it appears inside the JSON-encoded SSE data line. */ + const encodedVersion = (content: string): string => + fileContentVersion(content).replaceAll('"', '\\"'); + + it("labels a Studio write for every open subscriber, not just the first", async () => { + const projectDir = tmpProject(); + writeFileSync(join(projectDir, "index.html"), "before"); + server = createStudioServer({ projectDir }); + const streams = await subscribe(2); + + const written = "after"; + const write = await server.app.request( + `/api/projects/${encodeURIComponent(basename(projectDir))}/files/index.html`, + { + method: "PUT", + headers: { + "If-Match": fileContentVersion("before"), + "X-Hyperframes-Write-Token": "studio-write-1", + }, + body: written, + }, + ); + expect(write.status).toBe(200); + mockWatcher.emit("change", "change", "index.html"); + + for (const payload of await Promise.all(streams.map(nextEvent))) { + expect(payload).toContain("studio-write-1"); + expect(payload).toContain(encodedVersion(written)); + } + }); + + it("still reports an external write with a version so subscribers can dedupe it", async () => { + const projectDir = tmpProject(); + writeFileSync(join(projectDir, "index.html"), "before"); + server = createStudioServer({ projectDir }); + const streams = await subscribe(2); + + writeFileSync(join(projectDir, "index.html"), "agent"); + mockWatcher.emit("change", "change", "index.html"); + + for (const payload of await Promise.all(streams.map(nextEvent))) { + expect(payload).not.toContain("writeToken"); + expect(payload).toContain(encodedVersion("agent")); + } + }); +}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 596f534172..455aa59ccd 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -34,7 +34,7 @@ import { createStudioApi, createProjectSignature, createBackgroundRemovalJob, - consumeFileWriteReceipt, + identifyFileWrite, fileContentVersion, getMimeType, affectsProjectSignature, @@ -58,6 +58,12 @@ import { } from "../browser/gpuPolicy.js"; const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; + +// Vite emits only content-hashed files under dist/assets; hand-authored +// public/ files land at the dist root. The route is the signal because the +// filename is not: rollup's base64url hash may itself contain a hyphen. +const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; + const REMOTE_GIF_IMG_SRC_RE = /]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+\.gif(?:[?#][^"']*)?)["'][^>]*>/gi; @@ -782,9 +788,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { } catch { // A deletion has no current bytes to match against an API write receipt. } - const receipt = version ? consumeFileWriteReceipt(absPath, version) : null; + // `version` ships even when no receipt matches: it is the client's only + // identity for an unlabelled change, and without it every duplicate + // delivery of one watcher event drains and reloads again. + const receipt = version ? identifyFileWrite(absPath, version) : null; stream - .writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) }) + .writeSSE({ event: "file-change", data: JSON.stringify({ path, version, ...receipt }) }) .catch(() => {}); }; // Re-applied here because the watcher now also emits the signature @@ -874,17 +883,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }); // Studio SPA static files - const serveStudioStaticFile = (c: Context) => { + const serveStudioStaticFile = (cacheControl: string) => (c: Context) => { const filePath = resolve(studioDir, c.req.path.slice(1)); const content = readBundleFile(filePath); if (content === null) return c.text("not found", 404); return new Response(content, { - headers: { "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" }, + headers: { "Content-Type": getMimeType(filePath), "Cache-Control": cacheControl }, }); }; - app.get("/assets/*", serveStudioStaticFile); - app.get("/icons/*", serveStudioStaticFile); - app.get("/favicon.svg", serveStudioStaticFile); + app.get("/assets/*", serveStudioStaticFile(IMMUTABLE_CACHE_CONTROL)); + app.get("/icons/*", serveStudioStaticFile("no-store")); + app.get("/favicon.svg", serveStudioStaticFile("no-store")); // ── Runtime env injection ─────────────────────────────────────────────── // When the studio is served as a pre-built SPA, Vite `VITE_STUDIO_*` env @@ -980,7 +989,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { if (headScript) { html = html.replace("", `${headScript}`); } - return c.html(html); + // The shell names the current hashed bundle, so it always revalidates. + // `no-cache` not `no-store`: same refetch without an ETag, but `no-store` + // would blocklist the document from Chrome's bfcache. + return c.html(html, 200, { "Cache-Control": "no-cache" }); }); return { app, watcher, adapter }; diff --git a/packages/cli/src/utils/staticProjectServer.test.ts b/packages/cli/src/utils/staticProjectServer.test.ts index 700301bf0b..c00871e147 100644 --- a/packages/cli/src/utils/staticProjectServer.test.ts +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -71,6 +71,7 @@ vi.mock("@hyperframes/studio-server/media-codec-map", () => ({ decideMediaProxyEligibility: mocks.decideMediaProxyEligibility, isProxyVariant: (value: string) => value === "h264" || value === "vp8", isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp8", + recordProxyRequest: () => {}, proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"), resolveProxyVariantRequest: (request: "auto" | "h264" | "vp8", facts: { hasAlpha?: boolean }) => { const expected = facts.hasAlpha ? "vp8" : "h264"; diff --git a/packages/cli/src/utils/staticProjectServer.ts b/packages/cli/src/utils/staticProjectServer.ts index 6713df4203..8506097abb 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -13,6 +13,7 @@ import { decideMediaProxyEligibility, isProxyVariantRequest, probeAssetCodec, + recordProxyRequest, resolveProxyVariantRequest, PROXY_VARIANT_CONFIG, type ProxyVariantRequest, @@ -141,6 +142,7 @@ async function serveProxyRequest( res.end("media proxy variant does not match asset"); return; } + recordProxyRequest(); const proxyPath = await resolveProxy(projectDir, filePath, variant); // The await above can span a whole transcode; the client may be gone. if (res.writableEnded || res.destroyed) return; diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 2cdc857b5f..b10591ae7d 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -3480,7 +3480,13 @@ function makeCanvas(element: ColorGradingMediaElement): HTMLCanvasElement { return attachCanvas(document.createElement("canvas"), element); } -export function createColorGradingRuntime(): RuntimeColorGradingApi { +/** `pausedMediaLease` borrows an element's playback while the transport clock is + * stopped. Optional so this module stays constructible alone; without it the + * runtime's paused-side enforcement stops the preview. */ +export function createColorGradingRuntime(pausedMediaLease?: { + lease: (el: HTMLMediaElement) => void; + release: (el: HTMLMediaElement) => void; +}): RuntimeColorGradingApi { const entries = new WeakMap(); const trackedElements = new Set(); const idleRenderers: ColorGradingRenderer[] = []; @@ -3781,8 +3787,12 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi { if (element.ended || (Number.isFinite(element.duration) && time >= element.duration)) { element.currentTime = 0; } + // Borrowed before play(): the runtime stops anything running under a paused + // clock, and the capture-phase `play` listener makes that immediate. + pausedMediaLease?.lease(element); void element.play().catch(() => undefined); return () => { + pausedMediaLease?.release(element); element.pause(); element.loop = loop; element.muted = muted; diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 2bb0a83fdc..caee25f81e 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2011,6 +2011,16 @@ describe("initSandboxRuntimeModular", () => { tweet: tweetTimeline, }; + // Record what the children looked like AT the root seek — that is the moment + // the activation exists for, and the only moment it is observable now that + // the seek restores them. + const pausedDuringRootSeek: Array<[boolean, boolean]> = []; + const rootTotalTime = rootTimeline.totalTime!; + rootTimeline.totalTime = (time?: number, suppressEvents?: boolean) => { + pausedDuringRootSeek.push([hookTimeline.paused!(), tweetTimeline.paused!()]); + return rootTotalTime.call(rootTimeline, time, suppressEvents); + }; + initSandboxRuntimeModular(); const player = window.__player; @@ -2020,16 +2030,16 @@ describe("initSandboxRuntimeModular", () => { // children are added to a paused root timeline in GSAP) hookTimeline.paused!(true); tweetTimeline.paused!(true); + pausedDuringRootSeek.length = 0; // Seek to 0.5s — well within the hook's window [0.001, 2.001] player?.renderSeek(0.5); - // renderSeek should activate (unpause) all child timelines before - // seeking the root. Without the fix, children stay paused and GSAP's - // totalTime() propagation skips them, leaving elements at initial CSS - // state (opacity: 0). - expect(hookTimeline.paused!()).toBe(false); - expect(tweetTimeline.paused!()).toBe(false); + // renderSeek must activate (unpause) all child timelines before seeking the + // root. Without that, children stay paused and GSAP's totalTime() + // propagation skips them, leaving elements at initial CSS state (opacity: 0). + expect(pausedDuringRootSeek.length).toBeGreaterThan(0); + for (const pausedPair of pausedDuringRootSeek) expect(pausedPair).toEqual([false, false]); // The hook host should be visible at t=0.5 expect(hookHost.style.visibility).toBe("visible"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index fec231c865..b53883af10 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1248,6 +1248,20 @@ export function initSandboxRuntimeModular(): void { } return fallbackTimeline; }; + // Read back from the parent rather than trusting add(): a child the parent + // does not hold stays on GSAP's global ticker, where unpaused means free-running. + const nestedCandidates = ( + parent: RuntimeTimelineLike | null, + candidates: C[], + ): C[] => { + if (!parent || typeof parent.getChildren !== "function") return []; + try { + const held = parent.getChildren(true, true, true); + return Array.isArray(held) ? candidates.filter((c) => held.includes(c.timeline)) : []; + } catch { + return []; + } + }; const addMissingChildCandidatesToRootTimeline = ( rootTimeline: RuntimeTimelineLike, candidates: Array<{ @@ -1255,14 +1269,15 @@ export function initSandboxRuntimeModular(): void { timeline: RuntimeTimelineLike; durationSeconds: number; }>, - ): string[] => { + ): { addedIds: string[]; nested: typeof candidates } => { const rootWithChildren = rootTimeline as RuntimeTimelineLike & { getChildren?: (...args: unknown[]) => unknown[]; }; - if (typeof rootWithChildren.getChildren !== "function") return []; + const none = { addedIds: [], nested: [] }; + if (typeof rootWithChildren.getChildren !== "function") return none; try { const existingChildren = rootWithChildren.getChildren(true, true, true) ?? []; - if (!Array.isArray(existingChildren)) return []; + if (!Array.isArray(existingChildren)) return none; const addedIds: string[] = []; for (const candidate of candidates) { const alreadyIncluded = existingChildren.some((child) => child === candidate.timeline); @@ -1276,9 +1291,9 @@ export function initSandboxRuntimeModular(): void { swallow("runtime.init.site4", err); } } - return addedIds; + return { addedIds, nested: nestedCandidates(rootTimeline, candidates) }; } catch { - return []; + return none; } }; const rootCompositionNode = resolveRootCompositionElement(); @@ -1343,14 +1358,16 @@ export function initSandboxRuntimeModular(): void { } } }; - if (rootChildCandidates.length > 0) { - ensureChildCandidatesActive(rootChildCandidates); - } if (rootTimeline) { - const autoNestedChildren = + // Only a child the root actually holds may run unpaused: the paused root + // drives it. A standalone registry child is re-seeked by the transport and + // must stay paused, or it free-runs on the global ticker while paused. + const nesting = rootChildCandidates.length > 0 ? addMissingChildCandidatesToRootTimeline(rootTimeline, rootChildCandidates) - : []; + : { addedIds: [], nested: [] }; + const autoNestedChildren = nesting.addedIds; + ensureChildCandidatesActive(nesting.nested); // Mark children as bound so the polling loop stops re-resolving if ( rootChildCandidates.length > 0 || @@ -1377,6 +1394,7 @@ export function initSandboxRuntimeModular(): void { const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates); const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline); if (compositeTimeline && isUsableTimelineDuration(compositeDurationSeconds)) { + ensureChildCandidatesActive(nestedCandidates(compositeTimeline, rootChildCandidates)); return { timeline: compositeTimeline, selectedTimelineIds, @@ -1529,6 +1547,7 @@ export function initSandboxRuntimeModular(): void { const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates); const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline); if (compositeTimeline) { + ensureChildCandidatesActive(nestedCandidates(compositeTimeline, rootChildCandidates)); return { timeline: compositeTimeline, selectedTimelineIds, @@ -2398,12 +2417,17 @@ export function initSandboxRuntimeModular(): void { let lastSyncedMediaTimeSeconds: number | null = null; let mediaClipsInWindow: RuntimeMediaClip[] = []; + // The one definition of "media the transport drives": timed itself, or hosted + // by a composition whose timing it inherits. Both the media cache and the + // paused-side enforcement read it, so neither can be narrower than the other. + const isTransportManagedMedia = (element: HTMLMediaElement): boolean => + element.hasAttribute("data-start") || + Boolean(resolveMediaCompositionContext(element).compositionRoot); + const buildRuntimeMediaCache = (elements?: Array) => refreshRuntimeMediaCache({ elements, - shouldIncludeElement: (element) => - element.hasAttribute("data-start") || - Boolean(resolveMediaCompositionContext(element).compositionRoot), + shouldIncludeElement: isTransportManagedMedia, resolveStartSeconds: (element) => { return resolveAbsoluteMediaStartSeconds(element); }, @@ -2509,6 +2533,46 @@ export function initSandboxRuntimeModular(): void { return [...visiting]; }; + /** Elements whose paused-time playback is borrowed by a feature that legitimately + * runs media with the clock stopped: the grading preview, the Studio's scrub. + * Anything playing while paused without a lease is the defect the enforcement + * exists for. On `window.__hf` because the Studio is across the iframe boundary. */ + const pausedMediaLeases = new WeakSet(); + const leasePausedMedia = (el: HTMLMediaElement): void => { + pausedMediaLeases.add(el); + }; + const releasePausedMedia = (el: HTMLMediaElement): void => { + pausedMediaLeases.delete(el); + }; + window.__hf.leasePausedMedia = leasePausedMedia; + window.__hf.releasePausedMedia = releasePausedMedia; + + // Same predicate the media cache uses, so the paused side sees exactly the + // media the transport drives. Reads attributes only; no cache rebuild. + const hasRunningTimedMedia = (): boolean => { + for (const el of document.querySelectorAll("video, audio")) { + if ( + isMediaElement(el) && + !el.paused && + isTransportManagedMedia(el) && + !pausedMediaLeases.has(el) + ) { + return true; + } + } + return false; + }; + + // A parked transport runs no ticks, so the check above would never see a media + // element that starts while the preview sits idle. `play` does not bubble; + // capture-phase on the document reaches every element, including later ones + // (same reasoning as the media events watchCompositionTimingInputs binds). + const onMediaPlayWakeTransport = () => wakeTransport(); + document.addEventListener("play", onMediaPlayWakeTransport, true); + runtimeCleanupCallbacks.push(() => { + document.removeEventListener("play", onMediaPlayWakeTransport, true); + }); + const syncMediaForCurrentState = () => { // Scope 1 of 3 (see `withTimingResolver`). Closes before `syncRuntimeMedia`, // which may call `el.load()` and invalidate every cached duration. @@ -2535,11 +2599,19 @@ export function initSandboxRuntimeModular(): void { if (kf) clip.volumeKeyframes = kf; } + // A leased element is not the transport's to touch while the clock is paused; + // during playback the transport owns everything again. Filtered here rather + // than out of `mediaClips` so the in-window bookkeeping below still records it + // and the pass after its release visits it normally. + const syncedClips = state.isPlaying + ? mediaClips + : mediaClips.filter((clip) => !pausedMediaLeases.has(clip.el)); + const forceSync = state.mediaForceSyncNextTick; if (forceSync) state.mediaForceSyncNextTick = false; if (!state.nativeMediaSyncDisabled) { syncRuntimeMedia({ - clips: mediaClips, + clips: syncedClips, timeSeconds: state.currentTime, playing: state.isPlaying, playbackRate: state.playbackRate, @@ -2817,7 +2889,10 @@ export function initSandboxRuntimeModular(): void { state.currentTime, Array.from(document.querySelectorAll("video[data-start], img[data-start]")), ); - const colorGrading = createColorGradingRuntime(); + const colorGrading = createColorGradingRuntime({ + lease: leasePausedMedia, + release: releasePausedMedia, + }); colorGradingRuntime = colorGrading; registerRuntimeCleanup(() => { colorGrading.destroy(); @@ -3227,20 +3302,24 @@ export function initSandboxRuntimeModular(): void { // in the registry, not GSAP child tweens). Matches the naming convention in // player.ts:32 (forEachSiblingTimeline) and player.ts:89 (activateSiblingTimelines). // - // Unlike the player's seek path which re-pauses siblings after seeking, - // render-seek is one-frame-at-a-time with no transport tick between frames, - // so the residual unpaused state is harmless — the next call re-activates - // idempotently. - const activateSiblingTimelines = (masterTimeline: RuntimeTimelineLike) => { + // The rearm is a means, not a resting state: GSAP will not propagate the root's + // totalTime() into a paused child. Returns what it touched so the caller can + // re-pause it; a sibling parented to gsap.globalTimeline free-runs on the global + // ticker the moment it is left unpaused. Mirrors player.ts's seek helper. + const activateSiblingTimelines = (masterTimeline: RuntimeTimelineLike): RuntimeTimelineLike[] => { const timelines = (window.__timelines ?? {}) as Record; + const rearmed: RuntimeTimelineLike[] = []; for (const tl of Object.values(timelines)) { if (!tl || tl === masterTimeline) continue; + // Recorded before the call: a play() that throws can still have unpaused. + rearmed.push(tl); try { tl.play(); } catch (err) { swallow("runtime.init.activateSiblings", err); } } + return rearmed; }; const isObjectRecord = (value: unknown): value is Record => @@ -3307,24 +3386,33 @@ export function initSandboxRuntimeModular(): void { return false; }; + /** + * Borrow, seek, return. Siblings are unpaused only across the seek; restored in + * `finally`, because a throw mid-seek is when a leaked one starts free-running. + */ function seekTimelineAndAdapters( t: number, opts?: { activateChildren?: boolean; suppressEvents?: boolean }, ) { const tl = state.capturedTimeline; + // Critical for a sub-composition whose data-start is at or near 0: it is added + // to the root while the root is paused and may never receive an explicit + // play(), so without the rearm it holds its initial CSS state (opacity:0). + const rearmed = tl && opts?.activateChildren ? activateSiblingTimelines(tl) : []; + try { + seekRootChildrenAndAdapters(tl, t, opts); + } finally { + for (const sibling of rearmed) pauseTimelineIfPossible(sibling); + } + } + + function seekRootChildrenAndAdapters( + tl: RuntimeTimelineLike | null, + t: number, + opts?: { activateChildren?: boolean; suppressEvents?: boolean }, + ) { const suppressEvents = opts?.suppressEvents === true; if (tl) { - // When rendering frame-by-frame (activateChildren=true), ensure all - // sibling timelines are unpaused before seeking the root. GSAP - // does not propagate totalTime() to children that are internally - // paused, which leaves sub-compositions at their initial CSS state - // (typically opacity:0). This mirrors the activateSiblingTimelines - // call in player.ts renderSeek and is critical for sub-compositions - // whose data-start is at or near 0 — they are added to the root - // while it is paused and may never receive an explicit play(). - if (opts?.activateChildren) { - activateSiblingTimelines(tl); - } // #10: when data-duration exceeds the timeline's intrinsic length the // engine requests frames past the last tween. Seeking a paused GSAP // timeline past its end can revert from()-tweens to their empty initial @@ -3364,10 +3452,11 @@ export function initSandboxRuntimeModular(): void { // playback rate. Re-seek registered children below with their host's // explicit source-time contract. } + // A second `activateSiblingTimelines` used to follow this call. Dropping it is + // safe because nothing between frames READS a sibling's paused() — grepped over + // the deterministic adapters, syncTimedElementVisibility, the hf-timelines-built + // handler and __hfReseekGpu. It only ever moved the state the seek left behind. seekStandaloneRegisteredTimelines(t, opts); - if (tl && opts?.activateChildren) { - activateSiblingTimelines(tl); - } for (const adapter of state.deterministicAdapters) { if (adapter.name === "gsap" && tl) continue; try { @@ -3705,6 +3794,13 @@ export function initSandboxRuntimeModular(): void { if (clock.isPlaying()) { syncMediaForCurrentState(); + } else if (hasRunningTimedMedia()) { + // Nothing may run while the clock is paused, and the paused side used to + // police nothing. Dropping the cursor forces a full visit: the seek-window + // index skips an element that started outside the current window, which is + // exactly the one to stop. + lastSyncedMediaTimeSeconds = null; + syncMediaForCurrentState(); } postState(false); } finally { diff --git a/packages/core/src/runtime/pausedTransportInvariant.test.ts b/packages/core/src/runtime/pausedTransportInvariant.test.ts new file mode 100644 index 0000000000..1ced2d6346 --- /dev/null +++ b/packages/core/src/runtime/pausedTransportInvariant.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { initSandboxRuntimeModular } from "./init"; +import { + createMockTimeline, + installImmediateAnimationFrame, + resetRuntimeFixtureDom, +} from "./runtimeSeekFixture.test-helpers"; + +/** + * One invariant: while the transport clock is paused, nothing in the preview may + * be running — not a sibling composition timeline, not a media element. + */ + +const originalRequestAnimationFrame = window.requestAnimationFrame; +const originalCancelAnimationFrame = window.cancelAnimationFrame; + +/** A root composition, two sub-composition hosts, and the registry the runtime + * reads. `captions` has a host element (so the per-child source-time seek + * visits it); `overlay` is registry-only (reached solely by root propagation). + * Both are siblings under the invariant. */ +function mountNestedComposition() { + document.body.innerHTML = ` +
+
+
`; + const registry = { + main: createMockTimeline(20), + captions: createMockTimeline(20), + overlay: createMockTimeline(20), + }; + window.__timelines = registry; + return registry; +} + +describe("paused transport owns what is running", () => { + beforeEach(() => { + resetRuntimeFixtureDom(); + installImmediateAnimationFrame(); + }); + + afterEach(() => { + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + delete window.__timelines; + document.body.innerHTML = ""; + }); + + it("unpauses a child only once the root really holds it", () => { + // The paused root gates a nested child (GSAP skips a child with _ts=0), so the + // resolver must unpause it; the transport's first seek pauses it again, so the + // unpause is observable only as it happens. Held-ness is read AFTER the add + // loop: this root reports children only once added, which distinguishes the + // two orders. + const registry = mountNestedComposition(); + const held: unknown[] = []; + const unpausedWhileHeld: boolean[] = []; + registry.main.add = ((child: unknown) => { + held.push(child); + }) as typeof registry.main.add; + registry.main.getChildren = (() => [...held]) as typeof registry.main.getChildren; + const captionsPaused = registry.captions.paused!; + registry.captions.paused = ((value?: boolean) => { + if (value === false) unpausedWhileHeld.push(held.includes(registry.captions)); + return captionsPaused(value); + }) as typeof registry.captions.paused; + initSandboxRuntimeModular(); + + expect(held).toContain(registry.captions); + expect(unpausedWhileHeld).toEqual([true]); + // Registry-only, no host element: never a candidate, never unpaused. + expect(registry.overlay.paused!()).toBe(true); + }); + + it("keeps a registered child the root does not hold paused across a rebind", () => { + // The resolver used to unpause every registry child before nesting it. A + // rebind after an edit re-resolves with no seek behind it, so a child the + // root never actually holds (add() is a no-op here, as when GSAP refuses) + // was left free-running on the global ticker while the clock was paused. + const registry = mountNestedComposition(); + initSandboxRuntimeModular(); + expect(registry.captions.paused!()).toBe(true); + + window.__hfForceTimelineRebind!(); + + expect(window.__player!.isPlaying()).toBe(false); + expect(registry.captions.paused!()).toBe(true); + expect(registry.overlay.paused!()).toBe(true); + }); + + it("leaves every registered sibling timeline paused after a renderSeek", () => { + const registry = mountNestedComposition(); + initSandboxRuntimeModular(); + const player = window.__player; + expect(player).toBeDefined(); + + player!.renderSeek(2); + + expect(player!.isPlaying()).toBe(false); + expect(registry.captions.paused!()).toBe(true); + expect(registry.overlay.paused!()).toBe(true); + }); + + it("keeps them paused across the repeated renderSeeks the Studio fallback adapter issues", () => { + // createStaticSeekPlaybackAdapter drives playback by calling renderSeek from + // its own rAF loop. Every one of those seeks used to unpause the siblings, + // and one is enough — the Studio's own transport tick is running the whole + // time, so the residual state is never harmless there. + const registry = mountNestedComposition(); + initSandboxRuntimeModular(); + const player = window.__player; + + for (const t of [0, 0.5, 1, 1.5, 2]) { + player!.renderSeek(t); + expect([registry.captions.paused!(), registry.overlay.paused!()]).toEqual([true, true]); + } + }); + + it("still hands the root seek an unpaused sibling, so propagation reaches it", () => { + // The rearm is what this whole dance exists for: GSAP does not propagate + // totalTime() into a paused child. Restoring must not cost that. + const registry = mountNestedComposition(); + const seenDuringRootSeek: boolean[] = []; + const rootTotalTime = registry.main.totalTime!; + registry.main.totalTime = (time?: number, suppressEvents?: boolean) => { + seenDuringRootSeek.push(registry.overlay.paused!()); + return rootTotalTime.call(registry.main, time, suppressEvents); + }; + + initSandboxRuntimeModular(); + seenDuringRootSeek.length = 0; + window.__player!.renderSeek(3); + + expect(seenDuringRootSeek.length).toBeGreaterThan(0); + expect(seenDuringRootSeek.every((paused) => paused === false)).toBe(true); + expect(registry.overlay.paused!()).toBe(true); + }); +}); diff --git a/packages/core/src/runtime/transportPark.test.ts b/packages/core/src/runtime/transportPark.test.ts index 0bca9df1f1..b20bf99923 100644 --- a/packages/core/src/runtime/transportPark.test.ts +++ b/packages/core/src/runtime/transportPark.test.ts @@ -67,6 +67,28 @@ function createMockTimeline(duration: number): RuntimeTimelineLike { }; } +/** jsdom implements neither play() nor pause(), and `paused` is the only state the + * runtime reads. Returns a handle driving it the way a browser does, so a test can + * start the element and watch whether the transport stops it. */ +function stubMediaPlayback( + el: HTMLMediaElement, + durationSeconds: number, +): { start: () => void; isPaused: () => boolean } { + let paused = true; + Object.defineProperty(el, "duration", { value: durationSeconds, configurable: true }); + Object.defineProperty(el, "paused", { configurable: true, get: () => paused }); + el.pause = () => { + paused = true; + }; + el.play = () => { + paused = false; + // A real browser fires this, and firing it is what makes the defect deterministic. + el.dispatchEvent(new Event("play")); + return Promise.resolve(); + }; + return { start: () => void el.play(), isPaused: () => paused }; +} + /** MutationObserver records land in a microtask; nothing observes them sooner. */ const flushObservers = () => new Promise((resolve) => queueMicrotask(() => resolve())); @@ -420,4 +442,123 @@ describe("parked transport loop", () => { expect(before).toBeLessThan(20); expect(window.__player!.getDuration()).toBeCloseTo(20, 3); }); + + it("stops a media element that starts playing while parked and the clock is paused", () => { + // Nothing may run while the clock is paused. `play` does not bubble, so the + // wake comes from a capture-phase listener; without it the enforcement is + // unreachable exactly when it is needed, because a parked loop runs no ticks. + mount(``); + const media = stubMediaPlayback(document.getElementById("rogue") as HTMLVideoElement, 5); + initSandboxRuntimeModular(); + quiesce(); + expect(window.__player!.isPlaying()).toBe(false); + expect(raf.pending()).toBe(0); + + // Autoplay, a composition script, a restored bfcache state. + media.start(); + settle(); + + expect(media.isPaused()).toBe(true); + }); + + it("stops hosted media with no data-start of its own, which the transport also drives", () => { + // A clip inside a composition inherits its timing from the host. It is the + // transport's to play, so it is the transport's to stop; a probe keyed on + // data-start alone could not see it. + mount(` +
+ +
`); + const media = stubMediaPlayback(document.getElementById("hosted") as HTMLVideoElement, 5); + initSandboxRuntimeModular(); + quiesce(); + + media.start(); + settle(); + + expect(media.isPaused()).toBe(true); + }); + + it("leaves a LEASED element alone while paused, and stops it once released", () => { + // The colour-grading preview and the Studio's scrub audition both play media + // on purpose with the clock stopped. They borrow the element first; the + // enforcement is for anything that plays without borrowing. + mount(``); + const video = document.getElementById("grade") as HTMLVideoElement; + const media = stubMediaPlayback(video, 5); + initSandboxRuntimeModular(); + quiesce(); + + window.__hf!.leasePausedMedia!(video); + media.start(); + settle(); + // Several more ticks: a lease must survive more than the frame it was taken on. + for (let i = 0; i < 5; i += 1) { + window.__player!.seek(window.__player!.getTime()); + settle(); + } + expect(media.isPaused()).toBe(false); + + window.__hf!.releasePausedMedia!(video); + media.start(); + settle(); + expect(media.isPaused()).toBe(true); + }); + + it("does not stop the colour-grading preview, which plays on purpose while paused", () => { + // The end-to-end wiring, not the lease in isolation: init constructs the + // grading runtime with the lease, so startPreviewPlayback borrows before the + // play() whose own event wakes the transport that would otherwise stop it. + mount(``); + const video = document.getElementById("graded") as HTMLVideoElement; + const media = stubMediaPlayback(video, 5); + let currentTime = 0; + Object.defineProperty(video, "currentTime", { + configurable: true, + get: () => currentTime, + set: (next: number) => { + currentTime = next; + }, + }); + initSandboxRuntimeModular(); + quiesce(); + + const stop = window.__hf!.colorGrading!.startPreviewPlayback("graded"); + expect(typeof stop).toBe("function"); + settle(); + for (let beat = 0; beat < 5; beat += 1) { + vi.advanceTimersByTime(PARK_HEARTBEAT_MS); + settle(); + } + expect(media.isPaused()).toBe(false); + + stop!(); + expect(media.isPaused()).toBe(true); + // The stop closure must RELEASE, not just pause: a restart after it is an + // unleased play while paused, and the transport has to stop it. + media.start(); + settle(); + expect(media.isPaused()).toBe(true); + }); + + it("reclaims a leased element the moment the transport plays", () => { + // A lease is an exemption from the PAUSED-side enforcement only. Once the + // clock runs the transport owns every element again, so a leased clip that is + // outside the playhead's window is stopped like any other. + mount(``); + const video = document.getElementById("late") as HTMLVideoElement; + const media = stubMediaPlayback(video, 5); + initSandboxRuntimeModular(); + quiesce(); + + window.__hf!.leasePausedMedia!(video); + media.start(); + settle(); + expect(media.isPaused()).toBe(false); + + window.__player!.play(); + settle(); + expect(window.__player!.isPlaying()).toBe(true); + expect(media.isPaused()).toBe(true); + }); }); diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 4c1163d85a..80e6d46c73 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -46,6 +46,10 @@ declare global { onSwallowed?: (label: string, err: unknown) => void; seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void; duration?: number; + /** Borrow an element's playback while the transport clock is paused, so the + * runtime's paused-side enforcement leaves it alone. Always release. */ + leasePausedMedia?: (el: HTMLMediaElement) => void; + releasePausedMedia?: (el: HTMLMediaElement) => void; }; __playerReady?: boolean; __renderReady?: boolean; diff --git a/packages/studio-server/src/helpers/fileVersion.test.ts b/packages/studio-server/src/helpers/fileVersion.test.ts index 2ed4511476..6a8c3f8f7e 100644 --- a/packages/studio-server/src/helpers/fileVersion.test.ts +++ b/packages/studio-server/src/helpers/fileVersion.test.ts @@ -1,6 +1,6 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { - consumeFileWriteReceipt, + identifyFileWrite, fileContentVersion, recordFileWriteReceipt, resetFileWriteReceipts, @@ -15,7 +15,7 @@ describe("file versions and write receipts", () => { ); }); - it("attaches each API write identity to exactly one watcher echo", () => { + it("attaches one API write identity to every reader of the same watcher echo", () => { const receipt = { path: "index.html", version: fileContentVersion("after"), @@ -23,8 +23,32 @@ describe("file versions and write receipts", () => { }; recordFileWriteReceipt("/project/index.html", receipt); - expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toEqual(receipt); - expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toBeNull(); + // One watcher event fans out to every open SSE subscriber; a reader that + // removed the receipt would leave the rest reloading on Studio's own edit. + expect(identifyFileWrite("/project/index.html", receipt.version)).toEqual(receipt); + expect(identifyFileWrite("/project/index.html", receipt.version)).toEqual(receipt); + expect(identifyFileWrite("/project/index.html", fileContentVersion("other"))).toBeNull(); + }); + + it("stops recognising a write once the receipt TTL has passed", () => { + vi.useFakeTimers(); + try { + const receipt = { + path: "index.html", + version: fileContentVersion("after"), + writeToken: "write-1", + }; + recordFileWriteReceipt("/project/index.html", receipt); + + // The TTL is the ONLY thing that evicts a receipt now that reads leave it + // in place, so a watcher echo arriving after it must read as external. + vi.advanceTimersByTime(9_999); + expect(identifyFileWrite("/project/index.html", receipt.version)).toEqual(receipt); + vi.advanceTimersByTime(2); + expect(identifyFileWrite("/project/index.html", receipt.version)).toBeNull(); + } finally { + vi.useRealTimers(); + } }); it("matches the final debounced watcher version instead of receipt insertion order", () => { @@ -41,7 +65,17 @@ describe("file versions and write receipts", () => { recordFileWriteReceipt("/project/index.html", first); recordFileWriteReceipt("/project/index.html", last); - expect(consumeFileWriteReceipt("/project/index.html", last.version)).toEqual(last); - expect(consumeFileWriteReceipt("/project/index.html", first.version)).toEqual(first); + expect(identifyFileWrite("/project/index.html", last.version)).toEqual(last); + expect(identifyFileWrite("/project/index.html", first.version)).toEqual(first); + }); + + it("labels a repeat of earlier bytes with the newest token, not the spent one", () => { + const version = fileContentVersion("same bytes"); + const older = { path: "index.html", version, writeToken: "write-older" }; + const newer = { path: "index.html", version, writeToken: "write-newer" }; + recordFileWriteReceipt("/project/index.html", older); + recordFileWriteReceipt("/project/index.html", newer); + + expect(identifyFileWrite("/project/index.html", version)).toEqual(newer); }); }); diff --git a/packages/studio-server/src/helpers/fileVersion.ts b/packages/studio-server/src/helpers/fileVersion.ts index a9e795966c..6c99dc2041 100644 --- a/packages/studio-server/src/helpers/fileVersion.ts +++ b/packages/studio-server/src/helpers/fileVersion.ts @@ -32,8 +32,15 @@ export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceip receipts.set(absPath, current); } -/** Attach one API write's identity to the watcher echo for its exact bytes. */ -export function consumeFileWriteReceipt( +/** + * Attach one API write's identity to the watcher echo for its exact bytes. + * + * Reading is non-destructive: one watcher event fans out to every open SSE + * subscriber, and a receipt removed by the first reader leaves the rest seeing + * an unlabelled change and reloading the preview on Studio's own edit. Only the + * TTL removes a receipt. + */ +export function identifyFileWrite( absPath: string, expectedVersion: string, ): FileWriteReceipt | null { @@ -41,15 +48,29 @@ export function consumeFileWriteReceipt( const current = (receipts.get(absPath) ?? []).filter( (entry) => now - entry.recordedAt < RECEIPT_TTL_MS, ); - const receiptIndex = current.findIndex((entry) => entry.version === expectedVersion); - const receipt = receiptIndex === -1 ? null : (current.splice(receiptIndex, 1)[0] ?? null); if (current.length > 0) receipts.set(absPath, current); else receipts.delete(absPath); + // Newest match, not oldest: identical bytes written twice inside the TTL + // (undo, retyping a value) share a version, and the older token was already + // spent by the client on its own echo. The destructive read used to advance + // past it; scanning from the end keeps that cursor without the eviction. + let receipt: StoredReceipt | undefined; + for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) { + if (current[i]?.version === expectedVersion) receipt = current[i]; + } if (!receipt) return null; const { path, version, writeToken } = receipt; return { path, version, writeToken }; } +/** + * @deprecated Renamed to {@link identifyFileWrite}, which despite this alias's + * name does NOT consume the receipt: one watcher event fans out to every SSE + * subscriber, so a destructive read left all but the first reloading on Studio's + * own write. Kept for one release; call `identifyFileWrite` instead. + */ +export const consumeFileWriteReceipt = identifyFileWrite; + export function resetFileWriteReceipts(): void { receipts.clear(); } diff --git a/packages/studio-server/src/helpers/mediaCodecMap.test.ts b/packages/studio-server/src/helpers/mediaCodecMap.test.ts index 2f1d6de469..3025f878ee 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.test.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.test.ts @@ -11,6 +11,8 @@ import { proxyVariantFor, resolveProxyVariantRequest, scanProjectMediaCodecMap, + shouldPrewarmProxy, + type AssetCodecFacts, } from "./mediaCodecMap.js"; // Any real, existing file works as a stand-in ffprobe path — the runner @@ -92,7 +94,7 @@ describe("probeAssetCodec", () => { expect(facts).toEqual({ codecName: "hevc", browserHostile: true, - representativeMime: BROWSER_HOSTILE_CODECS.hevc, + representativeMime: BROWSER_HOSTILE_CODECS.hevc?.representativeMime, hasAlpha: false, }); }); @@ -122,7 +124,7 @@ describe("probeAssetCodec", () => { expect(facts).toEqual({ codecName: "vp9", browserHostile: true, - representativeMime: BROWSER_HOSTILE_CODECS.vp9, + representativeMime: BROWSER_HOSTILE_CODECS.vp9?.representativeMime, hasAlpha: false, }); }); @@ -172,6 +174,71 @@ describe("probeAssetCodec", () => { }); }); +describe("BROWSER_HOSTILE_CODECS representative mimes", () => { + // Pinned because nulling one is silent and expensive: the runtime skips + // `canPlayType` when the mime is null (packages/core/src/runtime/mediaProxy.ts, + // `maybeProxyProactively`) and proxies on EVERY browser, reinstating exactly + // the transcodes the pre-warm split removed. + it.each(["hevc", "av1", "vp9"])("keeps a canPlayType probe for %s", (codecName) => { + expect(typeof BROWSER_HOSTILE_CODECS[codecName]?.representativeMime).toBe("string"); + }); + + it("has no representative mime for prores, which is never decodable", () => { + expect(BROWSER_HOSTILE_CODECS.prores?.representativeMime).toBeNull(); + }); +}); + +describe("shouldPrewarmProxy", () => { + function facts(codecName: string): AssetCodecFacts { + return { codecName, browserHostile: true, representativeMime: null, hasAlpha: false }; + } + + it.each(["hevc", "prores"])( + "pre-warms %s, which has no cross-platform browser decode", + (codecName) => { + expect(shouldPrewarmProxy(facts(codecName))).toBe(true); + }, + ); + + it.each(["vp9", "av1"])( + "does not pre-warm %s, which every mainstream engine decodes itself", + (codecName) => { + expect(shouldPrewarmProxy(facts(codecName))).toBe(false); + }, + ); + + it("does not pre-warm a browser-safe codec", () => { + expect( + shouldPrewarmProxy({ + codecName: "h264", + browserHostile: false, + representativeMime: null, + hasAlpha: false, + }), + ).toBe(false); + }); + + // `shouldPrewarmProxy` alone cannot catch a missing `Object.hasOwn` guard — + // `.prewarm === true` is already strict against `Object.prototype.constructor`. + // The input that misbehaves goes through `codecFactsFor`, which would report + // an ordinary asset as browser-hostile. + it("does not treat a codec named after an Object.prototype key as hostile", async () => { + const project = tmpProject(); + const videoPath = join(project, "clip.mp4"); + writeFileSync(videoPath, "fake video bytes"); + + const facts = await probeAssetCodec(videoPath, makeRunner({ [videoPath]: "constructor" })); + + expect(facts).toEqual({ + codecName: "constructor", + browserHostile: false, + representativeMime: null, + hasAlpha: false, + }); + expect(shouldPrewarmProxy(facts!)).toBe(false); + }); +}); + describe("decideMediaProxyEligibility", () => { it("allows browser-hostile video with or without alpha", () => { expect( diff --git a/packages/studio-server/src/helpers/mediaCodecMap.ts b/packages/studio-server/src/helpers/mediaCodecMap.ts index cd2c7d8ac5..d0bba35da2 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.ts @@ -31,6 +31,19 @@ export interface AssetCodecFacts { /** Server-root-relative URL pathname -> that asset's codec facts. */ export type MediaCodecMap = Record; +export interface BrowserHostileCodec { + /** Coarse `canPlayType()` input; `null` when no representative mime exists + * (ProRes: browsers never decode it, so the runtime always proxies rather + * than probing `canPlayType`). */ + representativeMime: string | null; + /** + * Whether the server may transcode before any browser asks. True only where + * no cross-platform decode exists, so some client is sure to need the + * substitute; false where the first `?hf-proxy=` request can do it lazily. + */ + prewarm: boolean; +} + /** * Browser-hostile codec table v1. One exported constant so extending it is a * one-line change. `ffprobe` cannot emit exact RFC 6381 codec strings, so @@ -38,16 +51,92 @@ export type MediaCodecMap = Record; * positive costs one proxy transcode, never correctness; a false negative is * rescued by the runtime's reactive zero-videoWidth swap). */ -export const BROWSER_HOSTILE_CODECS: Record = { - hevc: 'video/mp4; codecs="hvc1.1.6.L120.B0"', - prores: null, - av1: 'video/mp4; codecs="av01.0.08M.08"', - // VP9 is browser-dependent: Chrome generally decodes it while Safari - // support varies. Treat it as conditional so canPlayType keeps the - // original where supported and transparently proxies it where unsupported. - vp9: 'video/webm; codecs="vp09.00.10.08"', +export const BROWSER_HOSTILE_CODECS: Record = { + // HEVC decode is platform-bound, not absent: macOS Chrome answers + // canPlayType with "probably" and keeps the source, while Chrome on + // Windows/Linux and Firefox everywhere need the substitute. + hevc: { representativeMime: 'video/mp4; codecs="hvc1.1.6.L120.B0"', prewarm: true }, + prores: { representativeMime: null, prewarm: true }, + // Every mainstream engine decodes AV1 and VP9, so canPlayType keeps the + // original and only the rare browser that cannot pays for a transcode. + av1: { representativeMime: 'video/mp4; codecs="av01.0.08M.08"', prewarm: false }, + vp9: { representativeMime: 'video/webm; codecs="vp09.00.10.08"', prewarm: false }, }; +/** `Object.hasOwn` rather than a bare index: a codec named `constructor` or + * `toString` would otherwise resolve against `Object.prototype`. */ +function hostileCodecEntry(codecName: string): BrowserHostileCodec | undefined { + return Object.hasOwn(BROWSER_HOSTILE_CODECS, codecName) + ? BROWSER_HOSTILE_CODECS[codecName] + : undefined; +} + +/** The pre-warm gate: true only for codecs with no cross-platform browser + * decode, so some client will ask. See `BrowserHostileCodec.prewarm`. */ +export function shouldPrewarmProxy(facts: AssetCodecFacts): boolean { + return hostileCodecEntry(facts.codecName)?.prewarm === true; +} + +// Per process, and deliberately in one unit — a call to `resolveProxy` — so +// the pair reads as a ratio. Summarised at exit on stderr, in the same +// `[hyperframes:] {json}` shape as `writeUrlDownloadTelemetry`. +const proxyDemand = { prewarmsRequested: 0, proxyRequests: 0 }; + +/** Snapshot of this process's pre-warm demand counters. */ +export function mediaProxyDemand(): { prewarmsRequested: number; proxyRequests: number } { + return { ...proxyDemand }; +} + +function writeDemandLine(event: "prewarm_requested" | "summary"): void { + try { + process.stderr.write( + `[hyperframes:media-proxy] ${JSON.stringify({ event, ...proxyDemand })}\n`, + ); + } catch { + // Observability must never change proxy correctness. + } +} + +/** Mirrors `isGpuProbeDebugEnabled` in packages/engine/src/utils/gpuEncoder.ts. */ +function isMediaProxyDebugEnabled(): boolean { + const value = process.env.HYPERFRAMES_DEBUG_MEDIA_PROXY; + return value === "1" || value === "true"; +} + +// Registered on the first pre-warm rather than at import, so a process that +// never pre-warms adds no handler and prints nothing. Sync-only, mirroring the +// `process.on("exit")` shutdown hooks in packages/cli/src/cli.ts:331 and +// packages/cli/src/commands/preview.ts:1329. +let summaryHookInstalled = false; + +/** + * One proxy asked for before any browser wanted it. "Requested", not + * "started": a warm cache makes `resolveProxy` a no-op and this counter cannot + * see that, so it is an upper bound on transcodes, not a measure of CPU. The + * number it does answer exactly is the one that decides policy — a nonzero + * count beside `proxyRequests: 0` means nothing ever redeemed the pre-warm. + * + * The per-asset line is debug-only: a composition with fifty hostile clips + * would otherwise print fifty JSON lines into a clack-formatted terminal on + * every re-render. The exit summary carries the same numbers unconditionally. + */ +export function recordProxyPrewarm(): void { + proxyDemand.prewarmsRequested++; + if (!summaryHookInstalled) { + summaryHookInstalled = true; + process.once("exit", () => writeDemandLine("summary")); + } + if (isMediaProxyDebugEnabled()) writeDemandLine("prewarm_requested"); +} + +/** One proxy resolved for a browser that asked, counted on the path that calls + * `resolveProxy` so it shares a unit with `prewarmsRequested`. A 304 does not + * count; an unconditional Range refill still does, so read it as zero versus + * nonzero. Never logged per event, only in the exit summary. */ +export function recordProxyRequest(): void { + proxyDemand.proxyRequests++; +} + export type ProxyVariant = "h264" | "vp8"; export type ProxyVariantRequest = ProxyVariant | "auto"; @@ -93,11 +182,11 @@ export function decideMediaProxyEligibility(facts: AssetCodecFacts | null): Medi } function codecFactsFor(codecName: string, hasAlpha: boolean): AssetCodecFacts { - const isHostile = Object.hasOwn(BROWSER_HOSTILE_CODECS, codecName); + const hostile = hostileCodecEntry(codecName); return { codecName, - browserHostile: isHostile, - representativeMime: isHostile ? (BROWSER_HOSTILE_CODECS[codecName] ?? null) : null, + browserHostile: hostile !== undefined, + representativeMime: hostile?.representativeMime ?? null, hasAlpha, }; } diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.test.ts b/packages/studio-server/src/helpers/mediaProxyPreview.test.ts new file mode 100644 index 0000000000..dd8a324035 --- /dev/null +++ b/packages/studio-server/src/helpers/mediaProxyPreview.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import type { AssetCodecFacts, MediaCodecMap } from "./mediaCodecMap.js"; + +/** + * The pre-warm gate. `browserHostile` marks an asset some browser could fail + * on; it is not a prediction that THIS browser will ask for the substitute. + * Pre-warming on the weaker predicate spends a full re-encode during the + * requesting browser's first layout for codecs it decodes natively. + */ + +const dirs: string[] = []; + +function tmpProject(): string { + const dir = mkdtempSync(join(tmpdir(), "hf-media-proxy-preview-test-")); + dirs.push(dir); + return dir; +} + +function hostile(codecName: string, representativeMime: string | null): AssetCodecFacts { + return { codecName, browserHostile: true, representativeMime, hasAlpha: false }; +} + +async function loadHelper(map: MediaCodecMap): Promise<{ + injectMediaCodecMapIntoHtml: typeof import("./mediaProxyPreview.js").injectMediaCodecMapIntoHtml; + mediaProxyDemand: typeof import("./mediaCodecMap.js").mediaProxyDemand; + resolveProxy: ReturnType; +}> { + vi.resetModules(); + const resolveProxy = vi.fn(async () => "/unused-prewarm-proxy-path"); + vi.doMock("./proxyTranscoder.js", () => ({ resolveProxy, PROXY_PARAMS_VERSION: "v1" })); + // The real codec table and pre-warm gate; only the ffprobe-backed scan is + // replaced, so this asserts the shipped policy rather than a copy of it. + vi.doMock("./mediaCodecMap.js", async () => ({ + ...(await vi.importActual("./mediaCodecMap.js")), + scanProjectMediaCodecMap: async () => map, + })); + const { injectMediaCodecMapIntoHtml } = await import("./mediaProxyPreview.js"); + const { mediaProxyDemand } = await import("./mediaCodecMap.js"); + return { injectMediaCodecMapIntoHtml, mediaProxyDemand, resolveProxy }; +} + +/** The pre-warm is fire-and-forget, so let its microtasks run before asserting. */ +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +afterEach(() => { + vi.resetModules(); + vi.doUnmock("./proxyTranscoder.js"); + vi.doUnmock("./mediaCodecMap.js"); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("injectMediaCodecMapIntoHtml pre-warm", () => { + it("pre-warms the codec with no cross-platform decode and leaves the widely-decoded one lazy", async () => { + const projectDir = tmpProject(); + const { injectMediaCodecMapIntoHtml, resolveProxy } = await loadHelper({ + "/videos/avatar.webm": hostile("vp9", 'video/webm; codecs="vp09.00.10.08"'), + "/videos/broll.mov": hostile("hevc", 'video/mp4; codecs="hvc1.1.6.L120.B0"'), + }); + + const html = await injectMediaCodecMapIntoHtml( + "", + projectDir, + [{ html: "" }], + ); + await settle(); + + expect(resolveProxy).toHaveBeenCalledTimes(1); + expect(resolveProxy).toHaveBeenCalledWith( + projectDir, + resolve(projectDir, "videos/broll.mov"), + "h264", + ); + // Both entries still reach the client: the browser-side canPlayType check + // owns whether a VP9 asset needs a proxy, and that contract is unchanged. + expect(html).toContain("/videos/avatar.webm"); + expect(html).toContain("/videos/broll.mov"); + }); + + it("counts the pre-warms it requests, and none for a widely-decoded codec", async () => { + const projectDir = tmpProject(); + const { injectMediaCodecMapIntoHtml, mediaProxyDemand } = await loadHelper({ + "/videos/avatar.webm": hostile("vp9", 'video/webm; codecs="vp09.00.10.08"'), + "/videos/broll.mov": hostile("hevc", 'video/mp4; codecs="hvc1.1.6.L120.B0"'), + }); + + await injectMediaCodecMapIntoHtml("", projectDir, [ + { html: "" }, + ]); + await settle(); + + expect(mediaProxyDemand()).toEqual({ prewarmsRequested: 1, proxyRequests: 0 }); + }); + + it("pre-warms ProRes, which no browser decodes at all", async () => { + const projectDir = tmpProject(); + const { injectMediaCodecMapIntoHtml, resolveProxy } = await loadHelper({ + "/videos/master.mov": hostile("prores", null), + }); + + await injectMediaCodecMapIntoHtml("", projectDir, [ + { html: "" }, + ]); + await settle(); + + expect(resolveProxy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.ts b/packages/studio-server/src/helpers/mediaProxyPreview.ts index f59b43585b..017ca093e5 100644 --- a/packages/studio-server/src/helpers/mediaProxyPreview.ts +++ b/packages/studio-server/src/helpers/mediaProxyPreview.ts @@ -3,7 +3,9 @@ import type { StudioApiAdapter } from "../types.js"; import { createMediaCodecProbeCache, proxyVariantFor, + recordProxyPrewarm, scanProjectMediaCodecMap, + shouldPrewarmProxy, type HtmlSourceLike, type MediaCodecMap, type MediaCodecProbeCache, @@ -68,12 +70,15 @@ function injectScriptTagIntoHead(html: string, scriptTag: string): string { /** * Injects `window.__HF_MEDIA_CODEC_MAP__` (the U1 codec-facts scan) into * served composition HTML, and fire-and-forget pre-warms `resolveProxy` for - * every browser-hostile entry so an element's proactive swap usually hits a - * warm cache (KTD: protects the per-origin connection budget under held - * responses). No second concurrency limiter here — the transcoder's own - * global bound throttles both pre-warm and element-triggered calls. - * Pre-warm failures are swallowed; an actual `?hf-proxy=` request surfaces - * them as a 502. Alpha-bearing entries pre-warm their VP8/WebM variant. + * every entry whose codec no browser decodes, so an element's proactive swap + * usually hits a warm cache (KTD: protects the per-origin connection budget + * under held responses). Conditionally hostile codecs are injected but NOT + * pre-warmed: the requesting browser usually plays them, and the transcode + * runs concurrently with its first layout. No second concurrency limiter here + * — the transcoder's own global bound throttles both pre-warm and + * element-triggered calls. Pre-warm failures are swallowed; an actual + * `?hf-proxy=` request surfaces them as a 502 and transcodes lazily. + * Alpha-bearing entries pre-warm their VP8/WebM variant. * * The single shared implementation for every auto-proxy surface — the studio * preview route (via `injectMediaCodecMap` below) and the CLI's composition / @@ -100,7 +105,8 @@ export async function injectMediaCodecMapIntoHtml( } if (Object.keys(map).length === 0) return html; for (const [rootRelativePathname, facts] of Object.entries(map)) { - if (!facts.browserHostile) continue; + if (!shouldPrewarmProxy(facts)) continue; + recordProxyPrewarm(); resolveProxy( projectDir, resolve(projectDir, rootRelativePathname.replace(/^\/+/, "")), diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 7802ad9758..395d2d8a8b 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -15,6 +15,7 @@ export type { PreviewApiAdapter } from "./helpers/mediaProxyPreview.js"; export { getMimeType, MIME_TYPES } from "./helpers/mime.js"; export { consumeFileWriteReceipt, + identifyFileWrite, fileContentVersion, type FileWriteReceipt, } from "./helpers/fileVersion.js"; diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index df98b9d2a8..4d4b6e686c 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -15,7 +15,7 @@ import { join } from "node:path"; import { commitElementPatchBatches, registerFileRoutes } from "./files"; import type { StudioApiAdapter } from "../types"; import { - consumeFileWriteReceipt, + identifyFileWrite, fileContentVersion, resetFileWriteReceipts, } from "../helpers/fileVersion"; @@ -150,7 +150,7 @@ describe("registerFileRoutes", () => { expect(result.after).toContain('data-duration="7"'); expect(result.after).toContain(`id="${result.hostId}"`); expect(result.version).toBe(fileContentVersion(result.after)); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"), result.version)).toEqual({ + expect(identifyFileWrite(join(projectDir, "index.html"), result.version)).toEqual({ path: "index.html", version: result.version, writeToken: "studio-insert-1", @@ -362,7 +362,7 @@ describe("registerFileRoutes", () => { expect(readFileSync(join(projectDir, payload.backupPath))).toEqual(before); expect(payload.version).toBe(fileContentVersion(after)); expect(response.headers.get("etag")).toBe(payload.version); - expect(consumeFileWriteReceipt(path, payload.version)).toEqual({ + expect(identifyFileWrite(path, payload.version)).toEqual({ path: "image.png", version: payload.version, writeToken: "binary-write", @@ -475,7 +475,7 @@ describe("registerFileRoutes", () => { expect(payload.version).toBe(fileContentVersion("after")); expect(payload.writeToken).toBe("studio-write-1"); expect(response.headers.get("etag")).toBe(payload.version); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"), payload.version!)).toEqual({ + expect(identifyFileWrite(join(projectDir, "index.html"), payload.version!)).toEqual({ path: "index.html", version: payload.version, writeToken: "studio-write-1", @@ -601,7 +601,7 @@ describe("registerFileRoutes", () => { expect(response.status).toBe(200); const version = fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8")); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({ + expect(identifyFileWrite(join(projectDir, "index.html"), version)).toEqual({ path: "index.html", version, writeToken: "studio-patch-1", @@ -647,7 +647,7 @@ describe("registerFileRoutes", () => { expect(payload.content).toContain('id="front" style="z-index: 1"'); expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(original); const version = fileContentVersion(payload.content!); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({ + expect(identifyFileWrite(join(projectDir, "index.html"), version)).toEqual({ path: "index.html", version, writeToken: "studio-layer-order-1", @@ -751,7 +751,7 @@ describe("registerFileRoutes", () => { expect(response.status).toBe(200); for (const file of payload.files) { const version = fileContentVersion(file.after); - expect(consumeFileWriteReceipt(join(projectDir, file.sourceFile), version)).toEqual({ + expect(identifyFileWrite(join(projectDir, file.sourceFile), version)).toEqual({ path: file.sourceFile, version, writeToken: "studio-group-drag-1", @@ -972,9 +972,7 @@ describe("registerFileRoutes", () => { expect(payload.files[0].after).toContain('id="a-split"'); expect(payload.files[0].after).toContain('id="b-split"'); expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(payload.files[0].after); - expect( - consumeFileWriteReceipt(join(projectDir, "index.html"), payload.files[0].version), - ).toEqual({ + expect(identifyFileWrite(join(projectDir, "index.html"), payload.files[0].version)).toEqual({ path: "index.html", version: payload.files[0].version, writeToken: "cut-test", diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 44d80f82f7..4d7fc378c6 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -748,7 +748,14 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { PROXY_PARAMS_VERSION: "v1", getProxyCachePath: () => "", })); - vi.doMock("../helpers/mediaCodecMap.js", () => ({ + // Spread the real module first so the pre-warm gate (`shouldPrewarmProxy` + // and its codec table) is the production one — a hand-written copy of that + // rule would let the table and this suite drift apart. The explicit keys + // below still replace everything that would touch ffprobe or ffmpeg. + vi.doMock("../helpers/mediaCodecMap.js", async () => ({ + ...(await vi.importActual( + "../helpers/mediaCodecMap.js", + )), scanProjectMediaCodecMap: opts.scanMapImpl ?? (async () => ({})), createMediaCodecProbeCache: () => new Map(), probeAssetCodec: @@ -863,6 +870,36 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { expect(resolveProxyMock).toHaveBeenCalledTimes(1); }); + it("counts one proxy request per resolved proxy, not per HTTP request", async () => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes"); + const resolveProxyMock = vi.fn(async () => { + const proxyPath = join(projectDir, "proxy.mp4"); + writeFileSync(proxyPath, "0123456789proxybytes"); + return proxyPath; + }); + const { registerPreviewRoutes: register } = await loadPreviewModule({ + resolveProxyImpl: resolveProxyMock, + }); + const { mediaProxyDemand } = await import("../helpers/mediaCodecMap.js"); + const before = mediaProxyDemand().proxyRequests; + + const app = new Hono(); + register(app, createAdapter(projectDir)); + + const first = await app.request( + "http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264", + ); + const etag = first.headers.get("ETag"); + // A 304 revalidation is the same asset already served; counting it would + // put this on a different scale from `prewarmsRequested`. + await app.request("http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264", { + headers: { "If-None-Match": etag! }, + }); + + expect(mediaProxyDemand().proxyRequests - before).toBe(1); + }); + it("returns 404 without transcoding when the asset is missing", async () => { const projectDir = createProjectDir(); const resolveProxyMock = vi.fn(async () => "should-not-be-called"); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index a2bfa7366d..753909522b 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -30,6 +30,7 @@ import { decideMediaProxyEligibility, isProxyVariantRequest, probeAssetCodec, + recordProxyRequest, resolveProxyVariantRequest, PROXY_VARIANT_CONFIG, type ProxyVariant, @@ -569,6 +570,10 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo let servedPath = file; let servedContentType = contentType; if (proxyVariant !== undefined) { + // Here, not at the eligibility gate above: one count per resolved proxy + // shares a unit with `prewarmsRequested`, and a revalidated repeat that + // 304s no longer counts as fresh demand. + recordProxyRequest(); try { servedPath = await resolveProxy(project.dir, file, proxyVariant); } catch (err) { diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4f915743f8..cb3c3ba5b9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -104,7 +104,6 @@ export function StudioApp() { rightPanelTab: initialUrlStateRef.current.rightPanelTab, }); const editHistory = usePersistentEditHistory({ projectId }); - const domEditSaveTimestampRef = useRef(0); const handleDomZIndexReorderCommitRef = useRef(null); const pendingTimelineEditPathRef = useRef(new Set()); const isGestureRecordingRef = useRef(false); @@ -113,7 +112,6 @@ export function StudioApp() { projectId, showToast, recordEdit: editHistory.recordEdit, - domEditSaveTimestampRef, setRefreshKey, }); const masterCompPath = useMemo( @@ -161,7 +159,6 @@ export function StudioApp() { writeProjectFile: fileManager.writeProjectFile, observeProjectFileVersion: fileManager.observeProjectFileVersion, recordEdit: editHistory.recordEdit, - domEditSaveTimestampRef, reloadPreview, previewIframeRef, pendingTimelineEditPathRef, @@ -224,7 +221,6 @@ export function StudioApp() { showToast, writeProjectFile: fileManager.writeProjectFile, recordEdit: editHistory.recordEdit, - domEditSaveTimestampRef, reloadPreview, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, handleDomEditElementDelete: domEditDeleteBridge, @@ -240,7 +236,6 @@ export function StudioApp() { readOptionalProjectFile: fileManager.readOptionalProjectFile, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, - domEditSaveTimestampRef, showToast, syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, @@ -281,7 +276,6 @@ export function StudioApp() { readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, updateEditingFileContent: fileManager.updateEditingFileContent, - domEditSaveTimestampRef, editHistory: { recordEdit: editHistory.recordEdit }, fileTree: fileManager.fileTree, importedFontAssetsRef: fileManager.importedFontAssetsRef, @@ -526,7 +520,6 @@ export function StudioApp() { publishSdkSession={sdkHandle.publish} forceReloadSdkSession={sdkHandle.forceReload} reloadPreview={reloadPreview} - domEditSaveTimestampRef={domEditSaveTimestampRef} recordEdit={editHistory.recordEdit} onToggleElementHidden={timelineEditing.handleToggleElementHidden} onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index e559af8c80..e52ed52229 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -42,7 +42,6 @@ export function StudioRightPanel({ publishSdkSession, forceReloadSdkSession, reloadPreview, - domEditSaveTimestampRef, recordEdit, onToggleElementHidden, onAutoGroupCarveSources, @@ -139,7 +138,6 @@ export function StudioRightPanel({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, publishSdkSession, }); @@ -152,7 +150,6 @@ export function StudioRightPanel({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, publishSdkSession, coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes", }); @@ -201,7 +198,6 @@ export function StudioRightPanel({ selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html", fileTree, projectId, - domEditSaveTimestampRef, waitForPendingDomEditSaves, readProjectFile, writeProjectFile, @@ -217,7 +213,6 @@ export function StudioRightPanel({ }), [ activeCompPath, - domEditSaveTimestampRef, domEditSelection?.sourceFile, fileTree, projectId, @@ -272,7 +267,6 @@ export function StudioRightPanel({ writeProjectFile={writeProjectFile} recordEdit={recordEdit} reloadPreview={reloadPreview} - domEditSaveTimestampRef={domEditSaveTimestampRef} forceReloadSharedSdkSession={forceReloadSdkSession} > ) : layersPaneOpen && designPaneOpen && !STUDIO_FLAT_INSPECTOR_ENABLED ? ( diff --git a/packages/studio/src/components/StudioRightPanel.types.ts b/packages/studio/src/components/StudioRightPanel.types.ts index 94a0b3975f..d3bb185ac1 100644 --- a/packages/studio/src/components/StudioRightPanel.types.ts +++ b/packages/studio/src/components/StudioRightPanel.types.ts @@ -6,7 +6,6 @@ * component's own diffs small and readable. */ -import type { MutableRefObject } from "react"; import type { StudioEditPersistenceProps } from "./panels/VariablesPanel"; import type { BlockParam } from "@hyperframes/core/registry"; import type { Composition } from "@hyperframes/sdk"; @@ -44,7 +43,6 @@ export interface StudioRightPanelProps extends StudioEditPersistenceProps { */ forceReloadSdkSession?: () => void; reloadPreview: () => void; - domEditSaveTimestampRef: MutableRefObject; recordEdit: (entry: { label: string; kind: EditHistoryKind; diff --git a/packages/studio/src/components/editor/SnapToolbar.test.tsx b/packages/studio/src/components/editor/SnapToolbar.test.tsx index f364ec7f1f..aa8650e5a5 100644 --- a/packages/studio/src/components/editor/SnapToolbar.test.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.test.tsx @@ -30,7 +30,6 @@ function renderToolbar(onSnapChange = vi.fn()) { function AppHotkeyHarness() { const domEditSelectionRef = useRef(null); const clearDomSelectionRef = useRef<() => void>(() => undefined); - const domEditSaveTimestampRef = useRef(0); const leftSidebarRef = useRef(null); useAppHotkeys({ @@ -47,7 +46,6 @@ function AppHotkeyHarness() { readOptionalProjectFile: vi.fn(async () => ""), readProjectFile: vi.fn(async () => ""), writeProjectFile: vi.fn(async () => undefined), - domEditSaveTimestampRef, showToast: vi.fn(), syncHistoryPreviewAfterApply: vi.fn(async () => undefined), waitForPendingDomEditSaves: vi.fn(async () => undefined), diff --git a/packages/studio/src/components/editor/manualEdits.test.ts b/packages/studio/src/components/editor/manualEdits.test.ts index 12a37192d4..a359bd9d9e 100644 --- a/packages/studio/src/components/editor/manualEdits.test.ts +++ b/packages/studio/src/components/editor/manualEdits.test.ts @@ -62,9 +62,9 @@ describe("studio manual edits", () => { expect(readStudioFileChangePath({ path: ".hyperframes/studio-manual-edits.json" })).toBe( ".hyperframes/studio-manual-edits.json", ); - expect(readStudioFileChangePath({ data: '{"path":"nested/file.html"}' })).toBe( - "nested/file.html", - ); + expect(readStudioFileChangePath({ filePath: "/nested/file.html" })).toBe("nested/file.html"); + // A still-wrapped delivery is a decoding bug upstream, never a path. + expect(readStudioFileChangePath({ data: { path: "nested/file.html" } })).toBeNull(); }); it("applies offsets through CSS translate longhand", () => { diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 61e735323d..f3c1b140fb 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -9,7 +9,7 @@ export { type StudioPathOffsetSnapshot, } from "./manualEditsTypes"; -export { readStudioFileChangePath } from "./manualEditsParsing"; +export { readFileChangeField, readStudioFileChangePath } from "./manualEditsParsing"; export { beginStudioManualEditGesture, diff --git a/packages/studio/src/components/editor/manualEditsParsing.ts b/packages/studio/src/components/editor/manualEditsParsing.ts index 3eda8d8ae8..457cd512c8 100644 --- a/packages/studio/src/components/editor/manualEditsParsing.ts +++ b/packages/studio/src/components/editor/manualEditsParsing.ts @@ -15,28 +15,19 @@ function normalizeStudioFileChangePath(path: string): string { .replace(/^\.?\//, ""); } -function readStudioFileChangePathFromValue(value: unknown): string | null { - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) return null; - if (trimmed.startsWith("{")) { - try { - return readStudioFileChangePathFromValue(JSON.parse(trimmed) as unknown); - } catch { - return normalizeStudioFileChangePath(trimmed); - } - } - return normalizeStudioFileChangePath(trimmed); - } - - if (!value || typeof value !== "object") return null; - const record = value as Record; - if (typeof record.path === "string") return normalizeStudioFileChangePath(record.path); - if (typeof record.filePath === "string") return normalizeStudioFileChangePath(record.filePath); - if ("data" in record) return readStudioFileChangePathFromValue(record.data); - return null; +/** + * Read one string field out of an ALREADY-DECODED file-change payload. Every + * reader of that payload goes through here, so no reader can disagree with its + * siblings about the shape. Decoding a raw delivery is the transport's job. + */ +export function readFileChangeField(payload: unknown, key: string): string | null { + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + const value = record[key]; + return typeof value === "string" ? value : null; } export function readStudioFileChangePath(payload: unknown): string | null { - return readStudioFileChangePathFromValue(payload); + const path = readFileChangeField(payload, "path") ?? readFileChangeField(payload, "filePath"); + return path === null ? null : normalizeStudioFileChangePath(path); } diff --git a/packages/studio/src/components/panels/VariablesOtherCompositions.tsx b/packages/studio/src/components/panels/VariablesOtherCompositions.tsx index 53419f9691..e780b8d142 100644 --- a/packages/studio/src/components/panels/VariablesOtherCompositions.tsx +++ b/packages/studio/src/components/panels/VariablesOtherCompositions.tsx @@ -6,7 +6,7 @@ * follow-up (values are per-composition-scope), so no preview control is shown. */ -import { useCallback, useState, type MutableRefObject } from "react"; +import { useCallback, useState } from "react"; import type { Composition, CompositionVariable } from "@hyperframes/sdk"; import { useEditVariablesInFile, @@ -92,7 +92,6 @@ export function VariablesOtherCompositions({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, }: { fileTree: string[]; excludePath: string; @@ -101,7 +100,6 @@ export function VariablesOtherCompositions({ writeProjectFile: (path: string, content: string) => Promise; recordEdit: RecordEditFn; reloadPreview: () => void; - domEditSaveTimestampRef: MutableRefObject; }) { const [selfRefresh, setSelfRefresh] = useState(0); const groups = useProjectCompositionVariables( @@ -115,7 +113,6 @@ export function VariablesOtherCompositions({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, }); const [editingKey, setEditingKey] = useState(null); diff --git a/packages/studio/src/components/panels/VariablesPanel.tsx b/packages/studio/src/components/panels/VariablesPanel.tsx index 27156847ee..490b62d474 100644 --- a/packages/studio/src/components/panels/VariablesPanel.tsx +++ b/packages/studio/src/components/panels/VariablesPanel.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import type { Composition, CompositionVariable, @@ -35,7 +35,6 @@ export interface StudioEditPersistenceProps { sdkSession: Composition | null; publishSdkSession: PublishSdkSession; reloadPreview: () => void; - domEditSaveTimestampRef: MutableRefObject; recordEdit: (entry: { label: string; kind: EditHistoryKind; @@ -253,7 +252,6 @@ export const VariablesPanel = memo(function VariablesPanel({ sdkSession, publishSdkSession, reloadPreview, - domEditSaveTimestampRef, recordEdit, }: VariablesPanelProps) { const { activeCompPath, showToast } = useStudioShellContext(); @@ -289,7 +287,6 @@ export const VariablesPanel = memo(function VariablesPanel({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, publishSdkSession, }); @@ -552,7 +549,6 @@ export const VariablesPanel = memo(function VariablesPanel({ writeProjectFile={writeProjectFile} recordEdit={recordEdit} reloadPreview={reloadPreview} - domEditSaveTimestampRef={domEditSaveTimestampRef} /> diff --git a/packages/studio/src/components/studioColorGradingScope.ts b/packages/studio/src/components/studioColorGradingScope.ts index eeb03c8871..46b36b6dc7 100644 --- a/packages/studio/src/components/studioColorGradingScope.ts +++ b/packages/studio/src/components/studioColorGradingScope.ts @@ -1,4 +1,3 @@ -import type { MutableRefObject } from "react"; import type { EditHistoryKind } from "../utils/editHistory"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch"; @@ -27,7 +26,6 @@ interface ApplyColorGradingScopeOptions { selectedSourceFile: string; fileTree: string[]; projectId: string; - domEditSaveTimestampRef: MutableRefObject; waitForPendingDomEditSaves: () => Promise; readProjectFile: ProjectFileReader; writeProjectFile: ProjectFileWriter; @@ -78,7 +76,6 @@ export async function applyColorGradingScopeUpdate({ selectedSourceFile, fileTree, projectId, - domEditSaveTimestampRef, waitForPendingDomEditSaves, readProjectFile, writeProjectFile, @@ -105,7 +102,6 @@ export async function applyColorGradingScopeUpdate({ return EMPTY_COLOR_GRADING_SCOPE_RESULT; } - domEditSaveTimestampRef.current = Date.now(); const changedPaths = await saveProjectFilesWithHistory({ projectId, label: value ? "Apply color grading" : "Clear color grading", diff --git a/packages/studio/src/hooks/elementLifecycleOpsTestUtils.ts b/packages/studio/src/hooks/elementLifecycleOpsTestUtils.ts index a0b86cc8f9..76a1905659 100644 --- a/packages/studio/src/hooks/elementLifecycleOpsTestUtils.ts +++ b/packages/studio/src/hooks/elementLifecycleOpsTestUtils.ts @@ -15,7 +15,6 @@ export function makeLifecycleOpsParams( activeCompPath: "index.html", showToast: vi.fn(), writeProjectFile: vi.fn(async () => {}), - domEditSaveTimestampRef: { current: 0 }, editHistory: { recordEdit: vi.fn(async () => {}) }, projectIdRef: { current: null }, reloadPreview: vi.fn(), diff --git a/packages/studio/src/hooks/externalFileChangeOwnership.test.ts b/packages/studio/src/hooks/externalFileChangeOwnership.test.ts index 4763cd5ee2..27dcdbc6bc 100644 --- a/packages/studio/src/hooks/externalFileChangeOwnership.test.ts +++ b/packages/studio/src/hooks/externalFileChangeOwnership.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { sseFileChangeChannel } from "./useExternalFileChangeCoordinator"; describe("external file-change subscription ownership", () => { it("has one subscriber instead of independent Preview and SDK listeners", () => { @@ -14,4 +15,42 @@ describe("external file-change subscription ownership", () => { expect(sdk).not.toContain('hot.on("hf:file-change"'); expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1); }); + + it("selects the SSE channel for production and hands it the shared handler", () => { + const coordinator = readFileSync( + new URL("./useExternalFileChangeCoordinator.ts", import.meta.url), + "utf8", + ); + // vitest defines `import.meta.hot`, so the selection is the one claim left to + // the source; the channel's own behaviour is exercised below. + expect(coordinator.match(/sseFileChangeChannel\(handler\)/g)).toHaveLength(1); + }); + + it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => { + const listeners = new Map void>(); + const close = vi.fn(); + const opened: string[] = []; + class FakeEventSource { + constructor(url: string) { + opened.push(url); + } + addEventListener(type: string, listener: (event: unknown) => void) { + listeners.set(type, listener); + } + close = close; + } + vi.stubGlobal("EventSource", FakeEventSource); + try { + const onDelivery = vi.fn(); + const stop = sseFileChangeChannel(onDelivery); + const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) }); + listeners.get("file-change")?.(event); + expect(opened).toEqual(["/api/events"]); + expect(onDelivery).toHaveBeenCalledWith(event); + stop(); + expect(close).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index 85071ab6d9..59394d6a15 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -102,7 +102,6 @@ export interface GsapScriptCommitsParams { files: Record; }) => Promise; }; - domEditSaveTimestampRef: React.MutableRefObject; reloadPreview: () => void; onCacheInvalidate: () => void; onFileContentChanged?: (path: string, content: string) => void; diff --git a/packages/studio/src/hooks/sdkSelfWriteRegistry.ts b/packages/studio/src/hooks/sdkSelfWriteRegistry.ts index 3b6976d1f9..91aef3df56 100644 --- a/packages/studio/src/hooks/sdkSelfWriteRegistry.ts +++ b/packages/studio/src/hooks/sdkSelfWriteRegistry.ts @@ -3,7 +3,7 @@ * external write (notably undo/redo) in the file-change reload-suppression path. * * The old suppression was purely time-based: any file-change within 2 s of the - * shared `domEditSaveTimestampRef` was swallowed. But BOTH an SDK cutover + * shared save timestamp was swallowed. But BOTH an SDK cutover * self-write AND an undo write set that same timestamp, so the window could not * tell "the echo of the bytes I just wrote" (suppress) from "the reverted bytes * an undo just wrote" (must reload). An undo that landed inside the window was diff --git a/packages/studio/src/hooks/timelineAudioGroupCreate.ts b/packages/studio/src/hooks/timelineAudioGroupCreate.ts index 939ca1ff02..3046d28b8c 100644 --- a/packages/studio/src/hooks/timelineAudioGroupCreate.ts +++ b/packages/studio/src/hooks/timelineAudioGroupCreate.ts @@ -143,7 +143,6 @@ interface CreateAudioGroupAndAssignMembersInput { previewIframe: HTMLIFrameElement | null; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: MutableRef; pendingTimelineEditPathRef: MutableRef>; } @@ -164,7 +163,6 @@ export async function createAudioGroupAndAssignMembers({ previewIframe, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }: CreateAudioGroupAndAssignMembersInput): Promise { // Throws rather than returning empty: the carve's auto-group awaits this and @@ -222,7 +220,6 @@ export async function createAudioGroupAndAssignMembers({ pendingTimelineEditPathRef.current.add(groupPath); } - domEditSaveTimestampRef.current = Date.now(); const changedPaths = await saveProjectFilesWithHistory({ projectId, label: groupLabel @@ -238,7 +235,6 @@ export async function createAudioGroupAndAssignMembers({ writeFile: writeProjectFile, recordEdit, }); - domEditSaveTimestampRef.current = Date.now(); for (const element of elements) { usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId }); } @@ -268,7 +264,6 @@ export function useAudioGroupCarveAssignment({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -314,7 +309,6 @@ export function useAudioGroupCarveAssignment({ previewIframe: previewIframeRef.current, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }); } catch (error) { @@ -335,7 +329,6 @@ export function useAudioGroupCarveAssignment({ previewIframeRef, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, isRecordingRef, showToast, diff --git a/packages/studio/src/hooks/timelineAudioGroupVolume.test.ts b/packages/studio/src/hooks/timelineAudioGroupVolume.test.ts index fd190adc7f..d92d9e8f6e 100644 --- a/packages/studio/src/hooks/timelineAudioGroupVolume.test.ts +++ b/packages/studio/src/hooks/timelineAudioGroupVolume.test.ts @@ -45,7 +45,6 @@ function makeSetter() { showToast: () => {}, writeProjectFile: async () => {}, recordEdit: async () => {}, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, previewIframeRef: { current: null }, }; diff --git a/packages/studio/src/hooks/timelineAudioGroupVolume.ts b/packages/studio/src/hooks/timelineAudioGroupVolume.ts index 4138369d3d..c3d21927af 100644 --- a/packages/studio/src/hooks/timelineAudioGroupVolume.ts +++ b/packages/studio/src/hooks/timelineAudioGroupVolume.ts @@ -143,7 +143,6 @@ interface SetAudioGroupAttributeInput { previewIframe: HTMLIFrameElement | null; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: MutableRef; pendingTimelineEditPathRef: MutableRef>; } @@ -163,7 +162,6 @@ async function setAudioGroupAttribute({ previewIframe, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }: SetAudioGroupAttributeInput): Promise { // The file that actually CONTAINS the group element, not just the active @@ -188,7 +186,6 @@ async function setAudioGroupAttribute({ label, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v), }); @@ -207,7 +204,6 @@ export function useSetAudioGroupAttribute({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -244,7 +240,6 @@ export function useSetAudioGroupAttribute({ previewIframe: previewIframeRef.current, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }); syncStoredGroupAttribute(groupId, attr, value); @@ -269,7 +264,6 @@ export function useSetAudioGroupAttribute({ previewIframeRef, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, isRecordingRef, showToast, diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index ca0ea7fea2..a9279e709d 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -243,7 +243,6 @@ describe("persistTimelineBatchEdit", () => { writes.push([path, content]); }, recordEdit: async () => {}, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }; } @@ -452,7 +451,6 @@ describe("persistElementAttribute", () => { label: "Set volume", writeProjectFile, recordEdit: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, patchLive, }), @@ -496,7 +494,6 @@ describe("persistElementAttribute — unwind value", () => { label: "Set volume", writeProjectFile, recordEdit: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, // The live DOM is ALREADY at the new value when the commit runs — that // is what `setLive` does on every drag frame. diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 7a94778752..cfd64d3988 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -285,7 +285,6 @@ export interface PersistTimelineEditInput { buildPatches: (original: string, target: PatchTarget) => string; writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; pendingTimelineEditPathRef: React.MutableRefObject>; coalesceKey?: string; } @@ -305,7 +304,6 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom } input.pendingTimelineEditPathRef.current.add(targetPath); - input.domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ projectId: input.projectId, label: input.label, @@ -316,7 +314,6 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom writeFile: input.writeProjectFile, recordEdit: input.recordEdit, }); - input.domEditSaveTimestampRef.current = Date.now(); } export interface PersistTimelineBatchChange { @@ -331,7 +328,6 @@ export interface PersistTimelineBatchEditInput { changes: PersistTimelineBatchChange[]; writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; pendingTimelineEditPathRef: React.MutableRefObject>; coalesceKey?: string; /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */ @@ -378,7 +374,6 @@ export async function persistTimelineBatchEdit( for (const targetPath of Object.keys(files)) { input.pendingTimelineEditPathRef.current.add(targetPath); } - input.domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ projectId: input.projectId, label: input.label, @@ -390,7 +385,6 @@ export async function persistTimelineBatchEdit( writeFile: input.writeProjectFile, recordEdit: input.recordEdit, }); - input.domEditSaveTimestampRef.current = Date.now(); } export { applyPatchByTarget, formatTimelineAttributeNumber }; @@ -406,7 +400,6 @@ export interface PersistElementAttributeInput { label: string; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: { current: number }; pendingTimelineEditPathRef: { current: Set }; /** Write the attribute directly on the live preview DOM node. */ patchLive: (value: string | null) => void; @@ -429,7 +422,6 @@ export async function persistElementAttribute({ label, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, patchLive, }: PersistElementAttributeInput): Promise { @@ -459,7 +451,6 @@ export async function persistElementAttribute({ const patched = applyPatchByTarget(before, patchTarget, operation); pendingTimelineEditPathRef.current.add(targetPath); - domEditSaveTimestampRef.current = Date.now(); try { const changedPaths = await saveProjectFilesWithHistory({ projectId, @@ -470,7 +461,6 @@ export async function persistElementAttribute({ writeFile: writeProjectFile, recordEdit, }); - domEditSaveTimestampRef.current = Date.now(); return changedPaths; } catch (error) { // The optimistic live write already ran; unwind it on a save failure so diff --git a/packages/studio/src/hooks/timelineElementFxAttribute.ts b/packages/studio/src/hooks/timelineElementFxAttribute.ts index 3292931539..a28e818b9c 100644 --- a/packages/studio/src/hooks/timelineElementFxAttribute.ts +++ b/packages/studio/src/hooks/timelineElementFxAttribute.ts @@ -42,7 +42,6 @@ interface SetElementAttributeInput { previewIframe: HTMLIFrameElement | null; writeProjectFile: (path: string, content: string) => Promise; recordEdit: Parameters[0]["recordEdit"]; - domEditSaveTimestampRef: MutableRef; pendingTimelineEditPathRef: MutableRef>; } @@ -56,7 +55,6 @@ async function setElementAttribute({ previewIframe, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }: SetElementAttributeInput): Promise { const targetPath = element.sourceFile || activeCompPath || "index.html"; @@ -72,7 +70,6 @@ async function setElementAttribute({ label, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath), }); @@ -84,7 +81,6 @@ export function useSetElementAttribute({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -122,7 +118,6 @@ export function useSetElementAttribute({ previewIframe: previewIframeRef.current, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }); } catch (error) { @@ -136,7 +131,6 @@ export function useSetElementAttribute({ previewIframeRef, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, isRecordingRef, showToast, diff --git a/packages/studio/src/hooks/timelineTrackVisibility.test.ts b/packages/studio/src/hooks/timelineTrackVisibility.test.ts index a6695d724d..800a224f3d 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.test.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.test.ts @@ -65,7 +65,6 @@ describe("toggleTimelineTrackHidden", () => { const writes = new Map(); const recordEdit = vi.fn(); - const timestampRef = { current: 0 }; const pendingRef = { current: new Set() }; await toggleTimelineTrackHidden({ @@ -83,7 +82,6 @@ describe("toggleTimelineTrackHidden", () => { writes.set(path, content); }, recordEdit, - domEditSaveTimestampRef: timestampRef, pendingTimelineEditPathRef: pendingRef, }); @@ -96,7 +94,6 @@ describe("toggleTimelineTrackHidden", () => { expect(writes.get("index.html")).toContain('id="skip" data-start="0" data-duration="2"'); expect(writes.get("scene.html")).toContain('data-hidden=""'); expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"])); - expect(timestampRef.current).toBeGreaterThan(0); expect(recordEdit).toHaveBeenCalledTimes(1); // Display row, not the raw key: track 0 is the first row, so it reads "1", // the same number the track header announces for that row. @@ -133,7 +130,6 @@ describe("toggleTimelineTrackHidden", () => { writes.set(path, content); }, recordEdit: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -164,7 +160,6 @@ describe("toggleTimelineTrackHidden", () => { previewIframe: null, writeProjectFile: async () => {}, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -191,7 +186,6 @@ describe("toggleTimelineTrackHidden", () => { previewIframe: null, writeProjectFile: async () => {}, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -215,7 +209,6 @@ describe("toggleTimelineTrackHidden", () => { previewIframe: null, writeProjectFile: async () => {}, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -246,7 +239,6 @@ describe("toggleTimelineTrackHidden", () => { previewIframe: null, writeProjectFile: async () => {}, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -272,7 +264,6 @@ describe("toggleTimelineTrackHidden", () => { previewIframe: null, writeProjectFile: async () => {}, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -303,7 +294,6 @@ describe("toggleTimelineTrackHidden", () => { previewIframe: null, writeProjectFile: async () => {}, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -359,7 +349,6 @@ describe("toggleTimelineElementHidden", () => { writes.set(path, content); }, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -417,7 +406,6 @@ describe("toggleTimelineElementHidden", () => { writes.push({ path, content }); }, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -484,7 +472,6 @@ describe("createAudioGroupAndAssignMembers", () => { writes.set(path, content); }, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -557,7 +544,6 @@ describe("createAudioGroupAndAssignMembers", () => { writes.set(path, content); }, recordEdit: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }); @@ -592,7 +578,6 @@ describe("createAudioGroupAndAssignMembers", () => { previewIframe: null, writeProjectFile: async () => {}, recordEdit, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }), ).rejects.toThrow("a group needs at least two"); @@ -612,7 +597,6 @@ describe("createAudioGroupAndAssignMembers", () => { previewIframe: null, writeProjectFile: async () => {}, recordEdit: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }), ).rejects.toThrow("Invalid audio group id"); @@ -641,7 +625,6 @@ describe("createAudioGroupAndAssignMembers", () => { previewIframe: iframe, writeProjectFile: async () => {}, recordEdit: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, }), ).rejects.toThrow(); diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts index 77d1d5249c..9b53bcd2c5 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.ts @@ -39,7 +39,6 @@ interface ToggleTimelineTrackHiddenInput { previewIframe: HTMLIFrameElement | null; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: MutableRef; pendingTimelineEditPathRef: MutableRef>; } @@ -57,7 +56,6 @@ interface SetElementsHiddenInput { previewIframe: HTMLIFrameElement | null; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: MutableRef; pendingTimelineEditPathRef: MutableRef>; } @@ -140,7 +138,6 @@ async function setElementsHidden({ previewIframe, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }: SetElementsHiddenInput): Promise { if (elements.length === 0) return []; @@ -176,7 +173,6 @@ async function setElementsHidden({ pendingTimelineEditPathRef.current.add(targetPath); } - domEditSaveTimestampRef.current = Date.now(); const changedPaths = await saveProjectFilesWithHistory({ projectId, label, @@ -190,7 +186,6 @@ async function setElementsHidden({ writeFile: writeProjectFile, recordEdit, }); - domEditSaveTimestampRef.current = Date.now(); for (const element of elements) { usePlayerStore.getState().updateElement(element.key ?? element.id, { hidden }); } @@ -215,7 +210,6 @@ export async function toggleTimelineTrackHidden({ previewIframe, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }: ToggleTimelineTrackHiddenInput): Promise { // `track` is the fractional sort key the callback needs; the history entry is @@ -245,7 +239,6 @@ export async function toggleTimelineTrackHidden({ previewIframe, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }); } @@ -259,7 +252,6 @@ export async function toggleTimelineElementHidden({ previewIframe, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }: ToggleTimelineElementHiddenInput): Promise { const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey); @@ -280,7 +272,6 @@ export async function toggleTimelineElementHidden({ previewIframe, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }); } @@ -291,7 +282,6 @@ export function useTimelineTrackVisibilityEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -325,7 +315,6 @@ export function useTimelineTrackVisibilityEditing({ previewIframe: previewIframeRef.current, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }); forceReloadSdkSession?.(); @@ -342,7 +331,6 @@ export function useTimelineTrackVisibilityEditing({ previewIframeRef, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, isRecordingRef, showToast, @@ -358,7 +346,6 @@ export function useTimelineElementVisibilityEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -394,7 +381,6 @@ export function useTimelineElementVisibilityEditing({ previewIframe: previewIframeRef.current, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, }); forceReloadSdkSession?.(); @@ -411,7 +397,6 @@ export function useTimelineElementVisibilityEditing({ previewIframeRef, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, isRecordingRef, showToast, diff --git a/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx b/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx index b348cceeb7..84aec62b84 100644 --- a/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx +++ b/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx @@ -43,7 +43,6 @@ function Harness() { readOptionalProjectFile: vi.fn(async () => ""), readProjectFile: vi.fn(async () => ""), writeProjectFile: vi.fn(async () => undefined), - domEditSaveTimestampRef: useRef(0), showToast: vi.fn(), syncHistoryPreviewAfterApply: vi.fn(async () => undefined), waitForPendingDomEditSaves: vi.fn(async () => undefined), diff --git a/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx b/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx index 446cff3365..40ce5e0d6c 100644 --- a/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx +++ b/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx @@ -70,7 +70,6 @@ function Harness() { const [selectionRefreshed, setSelectionRefreshed] = useState(false); const selectionRef = useRef(parentWithTextChild()); const clearSelectionRef = useRef<() => void>(() => undefined); - const saveTimestampRef = useRef(0); const leftSidebarRef = useRef(null); useAppHotkeys({ @@ -87,7 +86,6 @@ function Harness() { readOptionalProjectFile: vi.fn(async () => ""), readProjectFile: vi.fn(async () => ""), writeProjectFile: vi.fn(async () => undefined), - domEditSaveTimestampRef: saveTimestampRef, showToast: vi.fn(), syncHistoryPreviewAfterApply: vi.fn(async () => undefined), waitForPendingDomEditSaves: vi.fn(async () => undefined), diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 505f807904..7081a301da 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -118,7 +118,6 @@ interface UseAppHotkeysParams { readOptionalProjectFile: (path: string) => Promise; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; showToast: (message: string, tone?: "error" | "info") => void; syncHistoryPreviewAfterApply: (restore: { paths?: string[]; @@ -377,7 +376,6 @@ export function useAppHotkeys({ readOptionalProjectFile, readProjectFile, writeProjectFile, - domEditSaveTimestampRef, showToast, syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, @@ -405,10 +403,9 @@ export function useAppHotkeys({ ); const writeHistoryFile = useCallback( async (path: string, content: string): Promise => { - domEditSaveTimestampRef.current = Date.now(); await writeProjectFile(path, content); }, - [domEditSaveTimestampRef, writeProjectFile], + [writeProjectFile], ); const serializeHistoryFiles = useCallback( (paths: readonly string[], task: () => Promise) => @@ -455,9 +452,6 @@ export function useAppHotkeys({ onAfterUndoRedo?.(); // If the active composition was among the written files, force-reload // the SDK session so its in-memory doc matches the reverted content. - // writeHistoryFile sets domEditSaveTimestampRef which activates the - // 2 s suppress window — without this call the file-change event would - // be swallowed and the SDK session would stay on stale pre-undo content. if (activeCompPath && result.paths?.includes(activeCompPath)) { forceReloadSdkSession?.(); } diff --git a/packages/studio/src/hooks/useAudioGroupCarveAssignment.test.tsx b/packages/studio/src/hooks/useAudioGroupCarveAssignment.test.tsx index 159fab4a59..c0815e31b0 100644 --- a/packages/studio/src/hooks/useAudioGroupCarveAssignment.test.tsx +++ b/packages/studio/src/hooks/useAudioGroupCarveAssignment.test.tsx @@ -72,7 +72,6 @@ function renderAssign(writeProjectFile: (path: string, content: string) => Promi showToast, writeProjectFile, recordEdit: async () => {}, - domEditSaveTimestampRef: { current: 0 }, pendingTimelineEditPathRef: { current: new Set() }, previewIframeRef: { current: null }, }); diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index e25f983cc5..6baf87c7cf 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -25,7 +25,6 @@ interface UseClipboardOptions { showToast: (message: string, tone?: "error" | "info") => void; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; reloadPreview: () => void; handleTimelineElementDelete: (element: TimelineElement) => Promise; handleDomEditElementDelete: (selection: DomEditSelection) => Promise; @@ -55,7 +54,6 @@ export function useClipboard({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, reloadPreview, handleTimelineElementDelete, handleDomEditElementDelete, @@ -171,7 +169,6 @@ export function useClipboard({ ); } - domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ projectId: pid, label: payload.kind === "timeline-clip" ? "Paste clip" : "Paste element", @@ -188,14 +185,7 @@ export function useClipboard({ const message = error instanceof Error ? error.message : "Failed to paste"; showToast(message); } - }, [ - activeCompPath, - domEditSaveTimestampRef, - recordEdit, - reloadPreview, - showToast, - writeProjectFile, - ]); + }, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]); const handleCut = useCallback(async (): Promise => { const copied = handleCopy(); diff --git a/packages/studio/src/hooks/useDomEditCommits.test.tsx b/packages/studio/src/hooks/useDomEditCommits.test.tsx index b6738fa66e..54dfaeaf81 100644 --- a/packages/studio/src/hooks/useDomEditCommits.test.tsx +++ b/packages/studio/src/hooks/useDomEditCommits.test.tsx @@ -209,7 +209,6 @@ function renderDomEditCommits( const recordEdit = vi.fn(async () => {}); const previewIframeRef: MutableRefObject = { current: iframe }; const projectIdRef: MutableRefObject = options.projectIdRef ?? { current: "p1" }; - const domEditSaveTimestampRef: MutableRefObject = { current: 0 }; const reloadPreview = vi.fn(); function Probe() { @@ -219,7 +218,6 @@ function renderDomEditCommits( showToast, queueDomEditSave: options.queueDomEditSave ?? (async (save) => save()), writeProjectFile: options.writeProjectFile ?? (async () => {}), - domEditSaveTimestampRef, editHistory: { recordEdit }, fileTree: [], importedFontAssetsRef: { current: options.importedFontAssets ?? [] }, diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 7b77d62d4a..36dc21207b 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -50,7 +50,6 @@ export interface UseDomEditCommitsParams { showToast: (message: string, tone?: "error" | "info") => void; queueDomEditSave: (save: () => Promise) => Promise; writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; fileTree: string[]; importedFontAssetsRef: React.MutableRefObject; @@ -98,7 +97,6 @@ export function useDomEditCommits({ showToast, queueDomEditSave, writeProjectFile, - domEditSaveTimestampRef, editHistory, fileTree, importedFontAssetsRef, @@ -214,11 +212,6 @@ export function useDomEditCommits({ } } - // Mark the save timestamp before the file write so the SSE file-change - // handler suppresses the reload even if the event arrives before the - // response (the server writes the file and emits SSE during the fetch). - domEditSaveTimestampRef.current = Date.now(); - const patchResponse = await fetch( buildProjectApiPath(pid, `/file-mutations/patch-element/${encodeURIComponent(targetPath)}`), { @@ -315,7 +308,6 @@ export function useDomEditCommits({ editHistory, writeProjectFile, projectIdRef, - domEditSaveTimestampRef, reloadPreview, showToast, forceReloadSdkSession, @@ -359,7 +351,6 @@ export function useDomEditCommits({ ); } - domEditSaveTimestampRef.current = Date.now(); const atomicResult = await patchElementBatches(pid, batches); const allMatched = atomicResult.durable && atomicResult.files.every((result) => result.allMatched); @@ -385,8 +376,7 @@ export function useDomEditCommits({ // produces a visible blink. Skip the reload when the caller asked for it // AND the persist is provably in sync: style-only ops, every target // matched. Any unmatched patch means the live DOM now shows state disk - // doesn't hold — reload so the preview reconverges. (The SSE/file-watcher - // reload is independently suppressed by domEditSaveTimestampRef above.) + // doesn't hold — reload so the preview reconverges. const skipSafe = options.skipReload === true && batchesAreInlineStyleOnly(batches) && durable; if (!durable || (changed && !skipSafe)) reloadPreview(); @@ -411,15 +401,7 @@ export function useDomEditCommits({ throw error; }); }, - [ - domEditSaveTimestampRef, - editHistory, - forceReloadSdkSession, - projectIdRef, - queueDomEditSave, - reloadPreview, - showToast, - ], + [editHistory, forceReloadSdkSession, projectIdRef, queueDomEditSave, reloadPreview, showToast], ); // ── Text & style commits (delegated to useDomEditTextCommits) ── @@ -479,7 +461,6 @@ export function useDomEditCommits({ activeCompPath, showToast, writeProjectFile, - domEditSaveTimestampRef, editHistory, projectIdRef, reloadPreview, diff --git a/packages/studio/src/hooks/useDomEditSession.test.tsx b/packages/studio/src/hooks/useDomEditSession.test.tsx index ee82b7215b..9bfc359604 100644 --- a/packages/studio/src/hooks/useDomEditSession.test.tsx +++ b/packages/studio/src/hooks/useDomEditSession.test.tsx @@ -84,7 +84,6 @@ function createSessionParams( readProjectFile: async () => "", writeProjectFile: async () => {}, updateEditingFileContent: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, editHistory: { recordEdit: async () => {} }, fileTree: [], importedFontAssetsRef: { current: [] }, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index d3d650af07..6b09cf7395 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -47,7 +47,6 @@ export interface UseDomEditSessionParams { readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; updateEditingFileContent: (path: string, content: string) => void; - domEditSaveTimestampRef: React.MutableRefObject; editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; fileTree: string[]; importedFontAssetsRef: React.MutableRefObject; @@ -91,7 +90,6 @@ export function useDomEditSession({ readProjectFile, writeProjectFile, updateEditingFileContent, - domEditSaveTimestampRef, editHistory, fileTree, importedFontAssetsRef, @@ -211,7 +209,6 @@ export function useDomEditSession({ activeCompPath, previewIframeRef, editHistory, - domEditSaveTimestampRef, reloadPreview, onCacheInvalidate: bumpGsapCache, onFileContentChanged: updateEditingFileContent, @@ -249,7 +246,6 @@ export function useDomEditSession({ showToast, queueDomEditSave, writeProjectFile, - domEditSaveTimestampRef, editHistory, fileTree, importedFontAssetsRef, @@ -283,7 +279,6 @@ export function useDomEditSession({ editHistory, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, compositionPath: activeCompPath, readProjectFile, publishSession: publishSdkSession, @@ -298,7 +293,6 @@ export function useDomEditSession({ editHistory, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, compositionPath: activeCompPath, readProjectFile, publishSession: publishSdkSession, @@ -327,7 +321,6 @@ export function useDomEditSession({ activeCompPath, showToast, writeProjectFile, - domEditSaveTimestampRef, editHistory, projectIdRef, reloadPreview, diff --git a/packages/studio/src/hooks/useEditorSave.test.tsx b/packages/studio/src/hooks/useEditorSave.test.tsx index f122ef0484..f9a59c528d 100644 --- a/packages/studio/src/hooks/useEditorSave.test.tsx +++ b/packages/studio/src/hooks/useEditorSave.test.tsx @@ -27,7 +27,6 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) { readProjectFile: vi.fn(async () => "before"), writeProjectFile, recordEdit: vi.fn(async () => undefined), - domEditSaveTimestampRef: { current: 0 }, setRefreshKey: vi.fn(), showToast, }); diff --git a/packages/studio/src/hooks/useEditorSave.ts b/packages/studio/src/hooks/useEditorSave.ts index 7bb9486038..c2c5891bfd 100644 --- a/packages/studio/src/hooks/useEditorSave.ts +++ b/packages/studio/src/hooks/useEditorSave.ts @@ -23,7 +23,6 @@ interface UseEditorSaveOptions { readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; setRefreshKey: React.Dispatch>; showToast: (message: string, tone?: "error" | "info") => void; } @@ -53,7 +52,6 @@ export function useEditorSave({ readProjectFile, writeProjectFile, recordEdit, - domEditSaveTimestampRef, setRefreshKey, showToast, }: UseEditorSaveOptions): EditorSaveHandle { @@ -156,11 +154,10 @@ export function useEditorSave({ if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current); saveRafRef.current = requestAnimationFrame(() => { saveRafRef.current = null; - domEditSaveTimestampRef.current = Date.now(); void persistCandidate(candidate); }); }, - [domEditSaveTimestampRef, editingPathRef, projectIdRef, persistCandidate], + [editingPathRef, projectIdRef, persistCandidate], ); const flushPendingSave = useCallback(async (): Promise => { @@ -173,11 +170,10 @@ export function useEditorSave({ return inFlightRef.current; } if (candidate) { - domEditSaveTimestampRef.current = Date.now(); return persistCandidate(candidate); } return (await inFlightRef.current) ?? { status: "clean" }; - }, [domEditSaveTimestampRef, persistCandidate]); + }, [persistCandidate]); const discardPendingSave = useCallback(() => { if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index 464a05ed62..c5d9512e82 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -75,7 +75,6 @@ export function useElementLifecycleOps({ activeCompPath, showToast, writeProjectFile, - domEditSaveTimestampRef, editHistory, projectIdRef, reloadPreview, @@ -147,7 +146,6 @@ export function useElementLifecycleOps({ } } - domEditSaveTimestampRef.current = Date.now(); // One request for the whole selection. Removing members one at a time // cost a round trip and a rewrite of the file EACH, and a canvas // selection runs to hundreds of members — the file ended up correct, but @@ -220,7 +218,6 @@ export function useElementLifecycleOps({ [ activeCompPath, clearDomSelection, - domEditSaveTimestampRef, editHistory.recordEdit, onTrySdkDelete, onElementDeleted, diff --git a/packages/studio/src/hooks/useExternalFileChangeCoordinator.test.tsx b/packages/studio/src/hooks/useExternalFileChangeCoordinator.test.tsx index 91f1245af8..1a49fc00c7 100644 --- a/packages/studio/src/hooks/useExternalFileChangeCoordinator.test.tsx +++ b/packages/studio/src/hooks/useExternalFileChangeCoordinator.test.tsx @@ -322,4 +322,58 @@ describe("external file change coordinator", () => { expect(reloadPreview).toHaveBeenCalledTimes(2); expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2); }); + + // `hyperframes preview` serves file-change over SSE, where the delivery is a + // MessageEvent whose `data` is a JSON STRING. Driven through the test adapter + // because vitest defines `import.meta.hot`, so the EventSource rung is + // unreachable here, which is exactly why decoding is shared by all rungs. + describe("SSE-shaped deliveries", () => { + const sseDelivery = (payload: unknown) => + new MessageEvent("file-change", { data: JSON.stringify(payload) }); + + it("suppresses every reload for Studio's own write", async () => { + const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const })); + const reloadPreview = vi.fn(); + const onAcceptedPersistedFileChange = vi.fn(); + await mountCoordinator({ drainPendingChanges, reloadPreview, onAcceptedPersistedFileChange }); + markStudioWriteToken("studio-write-1"); + + await act(async () => + handler?.(sseDelivery({ path: "index.html", version: "v2", writeToken: "studio-write-1" })), + ); + + expect(drainPendingChanges).not.toHaveBeenCalled(); + expect(reloadPreview).not.toHaveBeenCalled(); + expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html"); + }); + + it("reloads once when one watcher event reaches two subscribers", async () => { + const reloadPreview = vi.fn(); + await mountCoordinator({ reloadPreview }); + + const external = { path: "index.html", version: "v2" }; + await act(async () => handler?.(sseDelivery(external))); + await act(async () => handler?.(sseDelivery(external))); + + expect(reloadPreview).toHaveBeenCalledOnce(); + }); + + it("still reloads for a genuinely external write", async () => { + const reloadPreview = vi.fn(); + await mountCoordinator({ reloadPreview }); + + await act(async () => handler?.(sseDelivery({ path: "index.html", version: "v9" }))); + + expect(reloadPreview).toHaveBeenCalledOnce(); + }); + + it("drops an unparseable delivery instead of throwing", async () => { + const reloadPreview = vi.fn(); + await mountCoordinator({ reloadPreview }); + + await act(async () => handler?.(new MessageEvent("file-change", { data: "not json" }))); + + expect(reloadPreview).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts index 04e956e829..e0835c2fe4 100644 --- a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts +++ b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; -import { readStudioFileChangePath } from "../components/editor/manualEdits"; +import { readFileChangeField, readStudioFileChangePath } from "../components/editor/manualEdits"; import { StudioFileConflictError } from "../utils/studioSaveDiagnostics"; import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage"; import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; @@ -81,25 +81,39 @@ function testHotAdapter(): HotTestAdapter | null { : null; } -function readFileChangeContent(payload: unknown): string | null { - if (!payload || typeof payload !== "object") return null; - const record = payload as Record; - if (typeof record.content === "string") return record.content; - return "data" in record ? readFileChangeContent(record.data) : null; +const readFileChangeContent = (payload: unknown) => readFileChangeField(payload, "content"); +const readFileChangeVersion = (payload: unknown) => readFileChangeField(payload, "version"); +const readFileChangeWriteToken = (payload: unknown) => readFileChangeField(payload, "writeToken"); + +/** + * Decode one delivery into the payload shape every reader above assumes. + * + * SSE delivers a `MessageEvent` whose `data` is a JSON string; the hot-reload + * transports deliver the payload object. This is the only place that difference + * exists: a reader handed an undecoded envelope reports every field as absent, + * which is indistinguishable from a field that is genuinely unset. + */ +function decodeFileChange(delivery: unknown): unknown { + // Structural, not `instanceof`: a polyfilled or cross-realm event must still decode. + const data = (delivery as { data?: unknown } | null)?.data; + if (typeof data !== "string") return delivery; + try { + return JSON.parse(data); + } catch { + logReload("file-change", { path: null, why: "unparseable payload" }); + return null; + } } -function readFileChangeVersion(payload: unknown): string | null { - if (!payload || typeof payload !== "object") return null; - const record = payload as Record; - if (typeof record.version === "string") return record.version; - return "data" in record ? readFileChangeVersion(record.data) : null; -} - -function readFileChangeWriteToken(payload: unknown): string | null { - if (!payload || typeof payload !== "object") return null; - const record = payload as Record; - if (typeof record.writeToken === "string") return record.writeToken; - return "data" in record ? readFileChangeWriteToken(record.data) : null; +/** + * The production transport. vitest defines `import.meta.hot`, so the selection + * below never reaches this rung under test; the rung itself is exported so a test + * can drive a real `MessageEvent` through the listener it registers. + */ +export function sseFileChangeChannel(onDelivery: (delivery: unknown) => void): () => void { + const eventSource = new EventSource("/api/events"); + eventSource.addEventListener("file-change", onDelivery); + return () => eventSource.close(); } function eventIdentity(path: string, payload: unknown): string | null { @@ -331,7 +345,10 @@ export function useExternalFileChangeCoordinator({ // fallow-ignore-next-line complexity (payload: unknown) => { const path = readStudioFileChangePath(payload); - if (!path || !projectId) return; + if (!path || !projectId) { + logReload("file-change", { path: null, why: path ? "no project" : "no path in payload" }); + return; + } pendingTimelineEditPathRef.current.delete(path); const content = readFileChangeContent(payload); @@ -362,7 +379,8 @@ export function useExternalFileChangeCoordinator({ ); useEffect(() => { - const handler = (payload?: unknown) => processChange(payload); + // One decoder for all three transports; the rungs only choose the channel. + const handler = (delivery?: unknown) => processChange(decodeFileChange(delivery)); const adapter = testHotAdapter(); if (adapter) { adapter.on("hf:file-change", handler); @@ -372,9 +390,7 @@ export function useExternalFileChangeCoordinator({ import.meta.hot.on("hf:file-change", handler); return () => import.meta.hot?.off?.("hf:file-change", handler); } - const eventSource = new EventSource("/api/events"); - eventSource.addEventListener("file-change", handler); - return () => eventSource.close(); + return sseFileChangeChannel(handler); }, [processChange]); const retry = useCallback(async () => { diff --git a/packages/studio/src/hooks/useFileManager.projectOwnership.test.tsx b/packages/studio/src/hooks/useFileManager.projectOwnership.test.tsx index 8d167fdd94..5acadbc33e 100644 --- a/packages/studio/src/hooks/useFileManager.projectOwnership.test.tsx +++ b/packages/studio/src/hooks/useFileManager.projectOwnership.test.tsx @@ -37,7 +37,6 @@ function useTestFileManager(projectId: string) { projectId, showToast: vi.fn(), recordEdit: vi.fn(async () => {}), - domEditSaveTimestampRef: { current: 0 }, setRefreshKey: vi.fn(), }); } diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index f2eb0201d5..b60d3f07c8 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -27,7 +27,6 @@ interface UseFileManagerOptions { projectId: string | null; showToast: (message: string, tone?: "error" | "info") => void; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; setRefreshKey: React.Dispatch>; } @@ -37,7 +36,6 @@ export function useFileManager({ projectId, showToast, recordEdit, - domEditSaveTimestampRef, setRefreshKey, }: UseFileManagerOptions) { // ── Shared refs ── @@ -198,7 +196,6 @@ export function useFileManager({ readProjectFile, writeProjectFile, recordEdit, - domEditSaveTimestampRef, setRefreshKey, showToast, }); diff --git a/packages/studio/src/hooks/useGroupCommits.ts b/packages/studio/src/hooks/useGroupCommits.ts index 532adff463..8cd94e7c18 100644 --- a/packages/studio/src/hooks/useGroupCommits.ts +++ b/packages/studio/src/hooks/useGroupCommits.ts @@ -64,7 +64,6 @@ async function commitStructuralMutation( UseGroupCommitsParams, | "writeProjectFile" | "editHistory" - | "domEditSaveTimestampRef" | "clearDomSelection" | "forceReloadSdkSession" | "reloadPreview" @@ -72,7 +71,6 @@ async function commitStructuralMutation( ): Promise<{ content?: string; groupId?: string }> { const originalContent = await readProjectFileContent(pid, targetPath); - deps.domEditSaveTimestampRef.current = Date.now(); const mutateResponse = await fetch( buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`), { diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index e9deae6ccf..e4ed1a27ca 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -379,7 +379,6 @@ function renderCommitHook( activeCompPath: "index.html", previewIframeRef: { current: FAKE_IFRAME }, editHistory: { recordEdit }, - domEditSaveTimestampRef: { current: 0 }, reloadPreview, onCacheInvalidate, onFileContentChanged, @@ -669,7 +668,6 @@ describe("runCommit — instantPatch wiring", () => { writeProjectFile, readProjectFile: vi.fn(async () => "BEFORE"), reloadPreview: deps.reloadPreview, - domEditSaveTimestampRef: { current: 0 }, }); await sdkWriteStarted; diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 8a902ce97a..1758db2051 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -313,7 +313,7 @@ function instantPatchesFor( // oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { const activeProjectId = projectIdRef.current; const activeCompPathRef = useRef(activeCompPath); activeCompPathRef.current = activeCompPath; @@ -348,7 +348,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra } return; } - if (previewIsActive) domEditSaveTimestampRef.current = Date.now(); await recordMutationEdit(targetPath, result, options); // The durable mutation belongs to the project captured when it was queued. // A later project must never receive its file state or preview refresh. @@ -365,7 +364,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra reloadPreview, onCacheInvalidate, }); - }, [projectIdRef, previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]); + }, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]); const runCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { const result = await runMutationRequest([mutation], options, showToast, () => @@ -494,7 +493,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra editHistory: { recordEdit: editHistory.recordEdit }, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, refresh: sdkRefresh, compositionPath: activeCompPath, readProjectFile: readProjectFileContent, @@ -505,7 +503,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra editHistory.recordEdit, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, sdkRefresh, activeCompPath, readProjectFileContent, diff --git a/packages/studio/src/hooks/usePreviewInteraction.test.ts b/packages/studio/src/hooks/usePreviewInteraction.test.ts index 6a5aa19739..84c668fa4c 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.test.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.test.ts @@ -224,12 +224,17 @@ describe("usePreviewInteraction", () => { cleanup(); }); - it("resumes playback when a click resolves to nothing (dead-zone / deselect)", async () => { - usePlayerStore.setState({ isPlaying: true }); + // The deselect resume asks the PLAYER to play; it must never move the store's + // flag on its own. The runtime really was paused (playerPause below), so a bare + // setIsPlaying(true) leaves the button reading "playing" over a stopped runtime + // with nothing able to reconcile the two. + it("requests a real resume when a click resolves to nothing (dead-zone / deselect)", async () => { + usePlayerStore.setState({ isPlaying: true, playbackRequest: null }); + const playerPause = vi.fn(); const applyDomSelection = vi.fn(); const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null); const { canvas, cleanup } = renderHarness({ - previewIframe: createPreviewIframe(vi.fn()), + previewIframe: createPreviewIframe(playerPause), resolveDomSelectionFromPreviewPoint, applyDomSelection, }); @@ -237,12 +242,15 @@ describe("usePreviewInteraction", () => { await dispatchMouseDown(canvas, {}); expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false }); - expect(usePlayerStore.getState().isPlaying).toBe(true); + expect(playerPause).toHaveBeenCalled(); + expect(usePlayerStore.getState().playbackRequest).toMatchObject({ playing: true }); + // Only the player's own play() may raise this, once the runtime is running. + expect(usePlayerStore.getState().isPlaying).toBe(false); cleanup(); }); it("does not resume playback on deselect when it was already paused", async () => { - usePlayerStore.setState({ isPlaying: false }); + usePlayerStore.setState({ isPlaying: false, playbackRequest: null }); const applyDomSelection = vi.fn(); const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null); const { canvas, cleanup } = renderHarness({ @@ -255,6 +263,7 @@ describe("usePreviewInteraction", () => { expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false }); expect(usePlayerStore.getState().isPlaying).toBe(false); + expect(usePlayerStore.getState().playbackRequest).toBeNull(); cleanup(); }); }); diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index 36ef4bf9b4..35b8e2dbc0 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -101,10 +101,10 @@ export function usePreviewInteraction({ const wasPlaying = usePlayerStore.getState().isPlaying; pausePreviewPlayback(); // A click that resolves to nothing (dead-zone / deselect) shouldn't leave - // playback paused — pausing before sampling only exists to keep the hit - // target stable while resolving; resume if nothing was selected. + // playback paused; the pause only keeps the hit target stable while resolving. + // Resume through requestPlayback so adapter, rAF loop and flag move together. const resumeIfNothingSelected = () => { - if (wasPlaying) usePlayerStore.getState().setIsPlaying(true); + if (wasPlaying) usePlayerStore.getState().requestPlayback(true); }; // Double-click a group → drill into it and select the child under the diff --git a/packages/studio/src/hooks/useProjectCompositionVariables.ts b/packages/studio/src/hooks/useProjectCompositionVariables.ts index f419a8b17b..cd621309d6 100644 --- a/packages/studio/src/hooks/useProjectCompositionVariables.ts +++ b/packages/studio/src/hooks/useProjectCompositionVariables.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type MutableRefObject } from "react"; +import { useCallback, useEffect, useState } from "react"; import { openComposition, type Composition, type CompositionVariable } from "@hyperframes/sdk"; import { persistSdkSerialize } from "../utils/sdkCutover"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -87,7 +87,6 @@ interface EditVariablesDeps { writeProjectFile: (path: string, content: string) => Promise; recordEdit: RecordEditFn; reloadPreview: () => void; - domEditSaveTimestampRef: MutableRefObject; } /** @@ -98,8 +97,7 @@ interface EditVariablesDeps { * but keyed on `path` rather than a live session. */ export function useEditVariablesInFile(deps: EditVariablesDeps) { - const { readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef } = - deps; + const { readProjectFile, writeProjectFile, recordEdit, reloadPreview } = deps; return useCallback( async (path: string, label: string, mutate: (session: Composition) => void): Promise => { const originalContent = await readProjectFile(path); @@ -119,13 +117,12 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) { editHistory: { recordEdit }, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, compositionPath: path, readProjectFile, }, { label }, ); }, - [readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef], + [readProjectFile, writeProjectFile, recordEdit, reloadPreview], ); } diff --git a/packages/studio/src/hooks/useRazorSplit.history.test.tsx b/packages/studio/src/hooks/useRazorSplit.history.test.tsx index c1ea6d74aa..ff3cc84270 100644 --- a/packages/studio/src/hooks/useRazorSplit.history.test.tsx +++ b/packages/studio/src/hooks/useRazorSplit.history.test.tsx @@ -117,7 +117,6 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}): disk[path] = content; }, recordEdit: store.recordEdit, - domEditSaveTimestampRef: { current: 0 }, reloadPreview: () => { if (!opts.previewStamp) return; const stamped = ensureHfIds(disk["index.html"]); @@ -258,7 +257,6 @@ function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness { disk[path] = content; }, recordEdit: store.recordEdit, - domEditSaveTimestampRef: { current: 0 }, reloadPreview: () => {}, }); splitAllRef.current = handleRazorSplitAll; diff --git a/packages/studio/src/hooks/useRazorSplit.test.ts b/packages/studio/src/hooks/useRazorSplit.test.ts index 02a33014a5..7e3472b096 100644 --- a/packages/studio/src/hooks/useRazorSplit.test.ts +++ b/packages/studio/src/hooks/useRazorSplit.test.ts @@ -88,7 +88,6 @@ function mountRazorSplit(): Harness { disk[path] = content; }, recordEdit: async () => {}, - domEditSaveTimestampRef: { current: 0 }, reloadPreview: () => {}, }); singleRef.current = handleRazorSplit; @@ -211,7 +210,6 @@ function mountRazorSplitWithHistory(): UndoHarness { disk[path] = content; }, recordEdit: (input) => store.recordEdit(input), - domEditSaveTimestampRef: { current: 0 }, reloadPreview: () => {}, forceReloadSdkSession, }); diff --git a/packages/studio/src/hooks/useRazorSplit.test.tsx b/packages/studio/src/hooks/useRazorSplit.test.tsx index a17ad13616..1d629b7f72 100644 --- a/packages/studio/src/hooks/useRazorSplit.test.tsx +++ b/packages/studio/src/hooks/useRazorSplit.test.tsx @@ -66,7 +66,6 @@ describe("useRazorSplit mutation versions", () => { writeProjectFile, observeProjectFileVersion, recordEdit, - domEditSaveTimestampRef: { current: 0 }, reloadPreview: vi.fn(), }).handleRazorSplit; return null; diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index b488807dc0..91bc44b2cf 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -15,7 +15,6 @@ interface UseRazorSplitOptions { writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; observeProjectFileVersion?: (path: string, version: string | null) => void; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; reloadPreview: () => void; forceReloadSdkSession?: () => void; isRecordingRef?: React.RefObject; @@ -28,7 +27,6 @@ export function useRazorSplit({ writeProjectFile, observeProjectFileVersion, recordEdit, - domEditSaveTimestampRef, reloadPreview, forceReloadSdkSession, isRecordingRef, @@ -62,9 +60,6 @@ export function useRazorSplit({ ? "Split timeline clip" : `Split ${requestedCount} clips at ${splitTime.toFixed(2)}s`; - // Server writes arrive through the watcher before React can refresh. Keep - // the existing short self-write window active for this owned transaction. - domEditSaveTimestampRef.current = Date.now(); const result = await runAtomicCutTransaction({ projectId: pid, intents, @@ -91,7 +86,6 @@ export function useRazorSplit({ }, [ activeCompPath, - domEditSaveTimestampRef, observeProjectFileVersion, recordEdit, showToast, diff --git a/packages/studio/src/hooks/useSdkSession.test.ts b/packages/studio/src/hooks/useSdkSession.test.ts deleted file mode 100644 index 27155fdd4d..0000000000 --- a/packages/studio/src/hooks/useSdkSession.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldReloadSdkSession } from "./useSdkSession"; - -// ── undo-sync contract ──────────────────────────────────────────────────────── -// useSdkSession exposes forceReload() so callers can bypass the 2 s self-write -// suppress window. useAppHotkeys calls forceReload() after a successful -// undo/redo that wrote the active composition path. Without it, the suppress -// window swallows the file-change event and the SDK session stays stale. -// -// The React hook internals (useState / useEffect) cannot be unit-tested without -// a full render environment; the correctness of the suppress-bypass path is -// covered by the integration tests in usePersistentEditHistory.test.ts -// (which verify undo writes the correct before-content to disk). -// ───────────────────────────────────────────────────────────────────────────── - -describe("shouldReloadSdkSession", () => { - it("reloads when the changed file is the active composition", () => { - expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true); - }); - - it("ignores changes to other files", () => { - expect(shouldReloadSdkSession({ path: "styles/main.css" }, "scenes/intro.html")).toBe(false); - }); - - it("ignores changes when no composition is active", () => { - expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, null)).toBe(false); - }); - - it("ignores payloads with no resolvable path", () => { - expect(shouldReloadSdkSession({}, "scenes/intro.html")).toBe(false); - }); -}); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 8cd5cea1c0..278598376a 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { openComposition } from "@hyperframes/sdk"; import type { Composition } from "@hyperframes/sdk"; -import { readStudioFileChangePath } from "../components/editor/manualEdits"; import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; import { trackStudioEvent } from "../utils/studioTelemetry"; import type { PublishSdkSession } from "../utils/sdkCutover"; @@ -29,15 +28,6 @@ async function readProjectFileOptional( return typeof data.content === "string" ? data.content : undefined; } -/** - * True when an external file-change payload targets the active composition and - * the SDK session must be re-opened to pick up the new content. - */ -export function shouldReloadSdkSession(payload: unknown, activeCompPath: string | null): boolean { - if (!activeCompPath) return false; - return readStudioFileChangePath(payload) === activeCompPath; -} - /** * Stage 7 Step 3a — SDK session wired to the active composition. * @@ -73,7 +63,9 @@ export interface SdkSessionHandle { /** * Force a session reload immediately, bypassing the self-write suppress * window. Call after undo/redo writes the active composition file so the - * SDK in-memory document reflects the reverted content. + * SDK in-memory document reflects the reverted content. Without it the + * window swallows the file-change and the session stays stale; the write + * side of that path is covered by usePersistentEditHistory.test.ts. */ forceReload: () => void; } diff --git a/packages/studio/src/hooks/useSlideshowPersist.ts b/packages/studio/src/hooks/useSlideshowPersist.ts index 5ccc56f31d..c679bddf73 100644 --- a/packages/studio/src/hooks/useSlideshowPersist.ts +++ b/packages/studio/src/hooks/useSlideshowPersist.ts @@ -1,4 +1,4 @@ -import { useCallback, type MutableRefObject } from "react"; +import { useCallback } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { SlideshowManifest } from "@hyperframes/core/slideshow"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -16,7 +16,6 @@ export interface UseSlideshowPersistParams { files: Record; }) => Promise; reloadPreview: () => void; - domEditSaveTimestampRef: MutableRefObject; /** Publish a fully persisted candidate SDK session. */ publishSdkSession?: PublishSdkSession; /** @@ -35,7 +34,6 @@ export function useSlideshowPersist({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, publishSdkSession, coalesceKey, }: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise { @@ -52,7 +50,6 @@ export function useSlideshowPersist({ editHistory: { recordEdit }, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, readProjectFile, publishSession: publishSdkSession, }, @@ -66,7 +63,6 @@ export function useSlideshowPersist({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, publishSdkSession, coalesceKey, ], diff --git a/packages/studio/src/hooks/useTimelineAssetDropOps.ts b/packages/studio/src/hooks/useTimelineAssetDropOps.ts index 24f6d60dd2..7788ebfa33 100644 --- a/packages/studio/src/hooks/useTimelineAssetDropOps.ts +++ b/packages/studio/src/hooks/useTimelineAssetDropOps.ts @@ -29,7 +29,6 @@ interface UseTimelineAssetDropOpsOptions { showToast: (message: string, tone?: "error" | "info") => void; writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: MutableRefObject; reloadPreview: () => void; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; isRecordingRef?: RefObject; @@ -44,7 +43,6 @@ export function useTimelineAssetDropOps({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, reloadPreview, uploadProjectFiles, isRecordingRef, @@ -109,7 +107,6 @@ export function useTimelineAssetDropOps({ }), ); - domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ projectId: pid, label: "Add timeline asset", @@ -135,7 +132,6 @@ export function useTimelineAssetDropOps({ showToast, timelineElements, writeProjectFile, - domEditSaveTimestampRef, reloadPreview, isRecordingRef, forceReloadSdkSession, @@ -198,7 +194,6 @@ export function useTimelineAssetDropOps({ resync: forceReloadSdkSession, refresh: reloadPreview, }); - domEditSaveTimestampRef.current = Date.now(); showToast("Composition added to the timeline.", "info"); } catch (error) { showToast( @@ -209,7 +204,6 @@ export function useTimelineAssetDropOps({ }, [ activeCompPath, - domEditSaveTimestampRef, forceReloadSdkSession, isRecordingRef, observeProjectFileVersion, diff --git a/packages/studio/src/hooks/useTimelineDeleteOps.ts b/packages/studio/src/hooks/useTimelineDeleteOps.ts index 505067f035..03a01f9869 100644 --- a/packages/studio/src/hooks/useTimelineDeleteOps.ts +++ b/packages/studio/src/hooks/useTimelineDeleteOps.ts @@ -20,7 +20,6 @@ interface UseTimelineDeleteOpsOptions { showToast: (message: string, tone?: "error" | "info") => void; writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: MutableRefObject; reloadPreview: () => void; isRecordingRef?: MutableRefObject; forceReloadSdkSession?: () => void; @@ -34,7 +33,6 @@ export function useTimelineDeleteOps({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, reloadPreview, isRecordingRef, forceReloadSdkSession, @@ -111,7 +109,6 @@ export function useTimelineDeleteOps({ usePlayerStore.getState().setDuration(deleteContentEnd); } - domEditSaveTimestampRef.current = Date.now(); try { await saveProjectFilesWithHistory({ projectId: pid, @@ -154,7 +151,6 @@ export function useTimelineDeleteOps({ showToast, timelineElements, writeProjectFile, - domEditSaveTimestampRef, reloadPreview, isRecordingRef, forceReloadSdkSession, diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index 18d81aa55d..5472392a44 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -147,7 +147,6 @@ function renderTimelineEditingHook(input: { showToast: input.showToast ?? vi.fn(), writeProjectFile: input.writeProjectFile ?? vi.fn(), recordEdit: input.recordEdit ?? vi.fn(), - domEditSaveTimestampRef: { current: 0 }, reloadPreview: input.reloadPreview ?? vi.fn(), previewIframeRef: { current: input.iframe }, pendingTimelineEditPathRef: { current: new Set() }, @@ -201,7 +200,6 @@ function renderTimelineEditingHookWithLifecycle(input: { activeCompPath: "index.html", showToast: vi.fn(), writeProjectFile: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, editHistory: { recordEdit: vi.fn() }, projectIdRef: { current: "p1" }, reloadPreview: vi.fn(), @@ -217,7 +215,6 @@ function renderTimelineEditingHookWithLifecycle(input: { showToast: vi.fn(), writeProjectFile: vi.fn(), recordEdit: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, reloadPreview: vi.fn(), previewIframeRef: { current: input.iframe }, pendingTimelineEditPathRef: { current: new Set() }, diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 070c284f46..85ade9c898 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -43,7 +43,6 @@ export function useTimelineEditing({ writeProjectFile, observeProjectFileVersion, recordEdit, - domEditSaveTimestampRef, reloadPreview, previewIframeRef, pendingTimelineEditPathRef, @@ -82,7 +81,6 @@ export function useTimelineEditing({ buildPatches, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, coalesceKey, }), @@ -99,7 +97,6 @@ export function useTimelineEditing({ activeCompPath, recordEdit, writeProjectFile, - domEditSaveTimestampRef, pendingTimelineEditPathRef, showToast, isRecordingRef, @@ -108,7 +105,6 @@ export function useTimelineEditing({ ); const groupEditing = useTimelineGroupEditing({ activeCompPath, - domEditSaveTimestampRef, editQueueRef, forceReloadSdkSession, invalidateGsapCache, @@ -211,7 +207,6 @@ export function useTimelineEditing({ editHistory: { recordEdit }, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, compositionPath: activeCompPath, // Capture on-disk bytes as the undo `before` so undoing a timing move // restores the file verbatim, not a normalized full-DOM re-emit. @@ -244,7 +239,6 @@ export function useTimelineEditing({ recordEdit, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, timelineElements, handleDomZIndexReorderCommitRef, showToast, @@ -317,7 +311,6 @@ export function useTimelineEditing({ editHistory: { recordEdit }, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, compositionPath: activeCompPath, // Capture on-disk bytes as the undo `before` so undoing a timing // resize restores the file verbatim, not a normalized full-DOM re-emit. @@ -346,7 +339,6 @@ export function useTimelineEditing({ recordEdit, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, showToast, invalidateGsapCache, ], @@ -359,7 +351,6 @@ export function useTimelineEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -372,7 +363,6 @@ export function useTimelineEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -385,7 +375,6 @@ export function useTimelineEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -397,7 +386,6 @@ export function useTimelineEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -409,7 +397,6 @@ export function useTimelineEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, previewIframeRef, pendingTimelineEditPathRef, isRecordingRef, @@ -422,7 +409,6 @@ export function useTimelineEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, reloadPreview, isRecordingRef, forceReloadSdkSession, @@ -437,7 +423,6 @@ export function useTimelineEditing({ showToast, writeProjectFile, recordEdit, - domEditSaveTimestampRef, reloadPreview, uploadProjectFiles, isRecordingRef, @@ -454,7 +439,6 @@ export function useTimelineEditing({ writeProjectFile, observeProjectFileVersion, recordEdit, - domEditSaveTimestampRef, reloadPreview, isRecordingRef, forceReloadSdkSession, diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index 2770d4df64..67212df12c 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -35,7 +35,6 @@ export interface UseTimelineEditingOptions { writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; observeProjectFileVersion?: (path: string, version: string | null) => void; recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: MutableRefObject; reloadPreview: () => void; previewIframeRef: RefObject; pendingTimelineEditPathRef: MutableRefObject>; diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts index 03abbb6930..d6faa74d56 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -51,7 +51,6 @@ export interface TimelineGroupCommitOptions { interface UseTimelineGroupEditingOptions { activeCompPath: string | null; - domEditSaveTimestampRef: MutableRefObject; editQueueRef: MutableRefObject>; forceReloadSdkSession?: () => void; invalidateGsapCache?: () => void; @@ -108,7 +107,6 @@ function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): bo export function useTimelineGroupEditing({ activeCompPath, - domEditSaveTimestampRef, editQueueRef, forceReloadSdkSession, invalidateGsapCache, @@ -160,7 +158,6 @@ export function useTimelineGroupEditing({ changes: batchChanges, writeProjectFile, recordEdit, - domEditSaveTimestampRef, pendingTimelineEditPathRef, coalesceKey, coalesceMs, @@ -169,7 +166,6 @@ export function useTimelineGroupEditing({ }, [ activeCompPath, - domEditSaveTimestampRef, forceReloadSdkSession, pendingTimelineEditPathRef, recordEdit, @@ -209,7 +205,6 @@ export function useTimelineGroupEditing({ editHistory: { recordEdit }, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, compositionPath: activeCompPath, readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), publishSession: publishSdkSession, @@ -225,7 +220,6 @@ export function useTimelineGroupEditing({ }, [ activeCompPath, - domEditSaveTimestampRef, projectIdRef, publishSdkSession, recordEdit, diff --git a/packages/studio/src/hooks/useVariablesPersist.ts b/packages/studio/src/hooks/useVariablesPersist.ts index 7d42a72487..559e3e2379 100644 --- a/packages/studio/src/hooks/useVariablesPersist.ts +++ b/packages/studio/src/hooks/useVariablesPersist.ts @@ -20,7 +20,6 @@ export function useVariablesPersist({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, publishSdkSession, }: UseVariablesPersistParams): ( label: string, @@ -39,7 +38,6 @@ export function useVariablesPersist({ editHistory: { recordEdit }, writeProjectFile, reloadPreview, - domEditSaveTimestampRef, compositionPath: activeCompPath, readProjectFile, publishSession: publishSdkSession, @@ -56,7 +54,6 @@ export function useVariablesPersist({ writeProjectFile, recordEdit, reloadPreview, - domEditSaveTimestampRef, publishSdkSession, ], ); diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 50ca3794ee..f76892f94a 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -4,9 +4,23 @@ import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readin import { useMountEffect } from "../../hooks/useMountEffect"; import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore"; import { HyperframesLoader } from "../../components/ui"; -// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement -// at module load, which throws under SSR. Defer the import to the mount effect -// so it only runs in the browser. +// Importing "@hyperframes/player" registers a class extending HTMLElement at +// module load, which throws under SSR, hence the dynamic import behind a +// `typeof window` guard. Kicking it here rather than in the mount effect puts +// the chunk request in flight before the shell's first layout. Clearing the memo +// on rejection stops one failure poisoning every later mount; the browser's +// module map still caches a failed fetch, so recovery is a page reload. +let playerModule: Promise | null = null; + +export function loadPlayerModule(): Promise { + playerModule ??= import("@hyperframes/player").catch((err: unknown) => { + playerModule = null; + throw err; + }); + return playerModule; +} + +if (typeof window !== "undefined") void loadPlayerModule().catch(() => {}); interface PlayerProps { projectId?: string; @@ -166,8 +180,7 @@ export const Player = forwardRef( let canceled = false; let cleanup: (() => void) | undefined; - // Dynamic import registers the custom element in the browser only. - import("@hyperframes/player").then(() => { + void loadPlayerModule().then(() => { if (canceled) return; // Create the web component imperatively to avoid JSX custom-element typing. diff --git a/packages/studio/src/player/components/playerModuleRetry.test.ts b/packages/studio/src/player/components/playerModuleRetry.test.ts new file mode 100644 index 0000000000..273fd81fad --- /dev/null +++ b/packages/studio/src/player/components/playerModuleRetry.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from "vitest"; + +// The module-scope kick in Player.tsx consumes the first attempt at import time, +// so this starts failing; the first test asserts that kick happened. +const state = vi.hoisted(() => ({ attempts: 0, failing: true })); + +vi.mock("@hyperframes/player", () => { + state.attempts += 1; + if (state.failing) throw new Error("chunk load failed"); + return {}; +}); + +import { loadPlayerModule } from "./Player"; + +describe("loadPlayerModule", () => { + it("kicks the import at module scope, before any mount", () => { + expect(state.attempts).toBe(1); + }); + + it("does not hand a later mount the promise a failed load already rejected", async () => { + // vitest rewraps a throwing mock factory, so assert the rejection, not its text. + await expect(loadPlayerModule()).rejects.toThrow(); + const failedAttempts = state.attempts; + + state.failing = false; + + await expect(loadPlayerModule()).resolves.toBeDefined(); + expect(state.attempts).toBeGreaterThan(failedAttempts); + }); + + it("hands every mount the same resolved promise", async () => { + state.failing = false; + const first = loadPlayerModule(); + await first; + + expect(loadPlayerModule()).toBe(first); + expect(loadPlayerModule()).toBe(first); + }); +}); diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index eb4aa62067..06e21c0b22 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -60,4 +60,11 @@ export type IframeWindow = Window & { __timeline?: TimelineLike; __timelines?: Record; __clipManifest?: ClipManifest; + /** Declared runtime-side in core's window.d.ts, which this package cannot see. + * Every member stays optional and is optional-called, so a runtime that + * predates the hook degrades to a no-op instead of throwing. */ + __hf?: { + leasePausedMedia?: (el: HTMLMediaElement) => void; + releasePausedMedia?: (el: HTMLMediaElement) => void; + }; }; diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts index 5af1eae57a..a2d21e8e3a 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts @@ -123,6 +123,36 @@ describe("scrubPreviewAudio", () => { expect(voiceover.play).not.toHaveBeenCalled(); stopScrubPreviewAudio(); }); + + /** A scrub audition is media running under a paused clock, which the runtime now + * stops on sight. So it borrows the element. That a leased element survives the + * tick is asserted runtime-side in core's `transportPark.test.ts`; here the + * contract is that the hook is called with the right element and given back. */ + it("borrows the element from the runtime for the audition and returns it on stop", () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const previewDoc = iframe.contentDocument; + if (!previewDoc?.body) throw new Error("expected an iframe document"); + + const music = previewDoc.createElement("audio"); + music.id = "music"; + music.play = vi.fn(async () => {}); + music.pause = vi.fn(); + previewDoc.body.append(music); + + const leasePausedMedia = vi.fn(); + const releasePausedMedia = vi.fn(); + (previewDoc.defaultView as IframeWindow).__hf = { leasePausedMedia, releasePausedMedia }; + + scrubPreviewAudio(iframe, 0.5, "music", 1); + + expect(leasePausedMedia).toHaveBeenCalledWith(music); + expect(releasePausedMedia).not.toHaveBeenCalled(); + + stopScrubPreviewAudio(); + + expect(releasePausedMedia).toHaveBeenCalledWith(music); + }); }); describe("applyPreviewAudioFlags", () => { diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index 6dbf5dc834..05c57a2a90 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -245,11 +245,24 @@ function resolveScrubAudioEl(doc: Document, musicId?: string | null): HTMLAudioE ); } +/** The runtime stops any media running under a paused clock, and a scrub audition + * IS media running under a paused clock, so it has to borrow the element. Every + * hop is optional: a runtime predating the hook must no-op, not throw. Wrapped in + * named calls so `applyScrub` does not carry the optional chains' branches. */ +function leaseScrubElement(el: HTMLAudioElement): void { + (el.ownerDocument.defaultView as IframeWindow | null)?.__hf?.leasePausedMedia?.(el); +} + +function releaseScrubElement(el: HTMLAudioElement): void { + (el.ownerDocument.defaultView as IframeWindow | null)?.__hf?.releasePausedMedia?.(el); +} + function applyScrub(el: HTMLAudioElement, audioFileTime: number, previewVolume: number): void { if (scrubAudioEl && scrubAudioEl !== el) stopScrubPreviewAudio(); if (scrubPrevMuted === null) scrubPrevMuted = el.muted; if (scrubPrevVolume === null) scrubPrevVolume = el.volume; scrubAudioEl = el; + leaseScrubElement(el); try { el.muted = false; el.volume = SCRUB_VOLUME * normalizePreviewVolume(previewVolume); @@ -296,6 +309,9 @@ export function stopScrubPreviewAudio(): void { const el = scrubAudioEl; scrubAudioEl = null; if (!el) return; + // `scrubStopTimer` guarantees this runs within ~140 ms of the last scrub, so + // the borrow cannot outlive the audition. + releaseScrubElement(el); try { el.pause(); if (scrubPrevMuted !== null) el.muted = scrubPrevMuted; diff --git a/packages/studio/src/utils/sdkCutover.familyGate.test.ts b/packages/studio/src/utils/sdkCutover.familyGate.test.ts index 049dcb415e..2046998b06 100644 --- a/packages/studio/src/utils/sdkCutover.familyGate.test.ts +++ b/packages/studio/src/utils/sdkCutover.familyGate.test.ts @@ -18,7 +18,6 @@ const deps = { editHistory: { recordEdit: vi.fn() }, writeProjectFile: vi.fn(), reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, } as never; describe("SDK family gate mapping", () => { diff --git a/packages/studio/src/utils/sdkCutover.gate.test.ts b/packages/studio/src/utils/sdkCutover.gate.test.ts index d5672ab9fc..c19085955b 100644 --- a/packages/studio/src/utils/sdkCutover.gate.test.ts +++ b/packages/studio/src/utils/sdkCutover.gate.test.ts @@ -28,7 +28,6 @@ const makeDeps = () => editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, }) as never; describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist declines", () => { diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 870145d80b..4815ac4bc4 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -14,7 +14,6 @@ import { import { openComposition } from "@hyperframes/sdk"; import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; import type { PatchOperation } from "./sourcePatcher"; -import type { MutableRefObject } from "react"; vi.mock("../components/editor/manualEditingAvailability", () => ({ STUDIO_SDK_CUTOVER_ENABLED: true, @@ -153,13 +152,10 @@ describe("shouldUseSdkCutover", () => { }); describe("sdkCutoverPersist", () => { - const makeRef = (val: T): MutableRefObject => ({ current: val }); - const makeDeps = (overrides: Partial[5]> = {}) => ({ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), - domEditSaveTimestampRef: makeRef(0), ...candidateTestDeps(), ...overrides, }); @@ -453,7 +449,6 @@ window.__timelines = { main: tl }; disk = content; }), reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, publishSession, }, mutate, @@ -485,7 +480,6 @@ window.__timelines = { main: tl }; editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile, reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, createCandidateSession: async (source) => { candidate = await openComposition(source, { history: false }); disposeCandidate = vi.spyOn(candidate, "dispose"); @@ -525,7 +519,6 @@ window.__timelines = { main: tl }; order.push("write"); }), reloadPreview: vi.fn(() => order.push("refresh")), - domEditSaveTimestampRef: { current: 0 }, publishSession: ({ candidate }) => { order.push("publish"); published = candidate; @@ -559,7 +552,6 @@ window.__timelines = { main: tl }; editHistory: { recordEdit }, writeProjectFile, reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, publishSession: ({ candidate }) => { published = candidate; throw new Error("cleanup after publish failed"); @@ -591,7 +583,6 @@ window.__timelines = { main: tl }; writeProjectFile, readProjectFile: vi.fn(async () => disk), reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, publishSession: ({ candidate }) => { published.push(candidate); return "published"; @@ -632,7 +623,6 @@ window.__timelines = { main: tl }; throw new Error("transient read failure"); }), reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, publishSession: vi.fn().mockReturnValue("published"), }; @@ -683,7 +673,6 @@ window.__timelines = { main: tl }; readProjectFile: vi.fn().mockResolvedValue(html), reloadPreview: vi.fn(), refresh, - domEditSaveTimestampRef: { current: 0 }, createCandidateSession: vi.fn().mockResolvedValue(candidateA), publishSession: ({ candidate, expectedSession, targetPath }) => { if (activePath !== targetPath || currentSession !== expectedSession) { @@ -724,7 +713,6 @@ describe("persistSdkSerialize — shared per-file transaction boundary", () => { }), readProjectFile: vi.fn(async () => disk.current), reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, }); it("rebases overlapping whole-file transforms on the latest committed bytes", async () => { @@ -814,12 +802,10 @@ describe("persistSdkSerialize — shared per-file transaction boundary", () => { }); describe("sdkDeletePersist", () => { - const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), - domEditSaveTimestampRef: makeRef(0), ...candidateTestDeps(), }); @@ -893,12 +879,10 @@ describe("sdkDeletePersist", () => { }); describe("sdkTimingPersist", () => { - const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), - domEditSaveTimestampRef: makeRef(0), ...candidateTestDeps(), }); @@ -1006,7 +990,6 @@ describe("sdkTimingPersist", () => { }); describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => { - const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeSession = () => ({ getElement: vi.fn().mockReturnValue({ id: "hf-box" }), @@ -1023,7 +1006,6 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => { editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), - domEditSaveTimestampRef: makeRef(0), readProjectFile: vi.fn().mockResolvedValue("on-disk gsap bytes"), ...candidateTestDeps(), }; @@ -1046,8 +1028,6 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => { }); describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => { - const makeRef = (val: T): MutableRefObject => ({ current: val }); - it("routes the read-modify-write through the shared file coordinator", async () => { const order: string[] = []; let writeResolve: (() => void) | null = null; @@ -1071,7 +1051,6 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => { return Promise.resolve(); }), reloadPreview: vi.fn(), - domEditSaveTimestampRef: makeRef(0), ...candidateTestDeps(), }; @@ -1116,12 +1095,10 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => { }); describe("sdkGsapTweenPersist", () => { - const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), - domEditSaveTimestampRef: makeRef(0), ...candidateTestDeps(), }); @@ -1232,12 +1209,10 @@ describe("sdkGsapTweenPersist", () => { }); describe("sdkGsapKeyframePersist", () => { - const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), - domEditSaveTimestampRef: makeRef(0), ...candidateTestDeps(), }); @@ -1303,12 +1278,10 @@ describe("sdkGsapKeyframePersist", () => { }); describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { - const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile: vi.fn().mockResolvedValue(undefined), reloadPreview: vi.fn(), - domEditSaveTimestampRef: makeRef(0), ...candidateTestDeps(), }); diff --git a/packages/studio/src/utils/sdkEditTransaction.ts b/packages/studio/src/utils/sdkEditTransaction.ts index a0cfb898f0..2ecf9df19b 100644 --- a/packages/studio/src/utils/sdkEditTransaction.ts +++ b/packages/studio/src/utils/sdkEditTransaction.ts @@ -1,4 +1,3 @@ -import type { MutableRefObject } from "react"; import { openComposition, type Composition } from "@hyperframes/sdk"; import type { EditHistoryKind } from "./editHistory"; import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry"; @@ -39,7 +38,6 @@ export interface CutoverDeps { */ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; reloadPreview: () => void; - domEditSaveTimestampRef: MutableRefObject; refresh?: (after: string) => void; compositionPath?: string | null; readProjectFile?: (path: string) => Promise; @@ -153,7 +151,6 @@ async function rollbackWrite( cause: Error, ): Promise { try { - deps.domEditSaveTimestampRef.current = Date.now(); markSelfWrite(targetPath, originalContent); await deps.writeProjectFile(targetPath, originalContent, expectedCurrentContent); return cause; @@ -172,7 +169,6 @@ async function writeAndRecord( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - deps.domEditSaveTimestampRef.current = Date.now(); markSelfWrite(targetPath, after); try { await deps.writeProjectFile(targetPath, after, originalContent); diff --git a/packages/studio/src/utils/setSlideshowManifest.test.ts b/packages/studio/src/utils/setSlideshowManifest.test.ts index 4cf6dd3498..9918ce8721 100644 --- a/packages/studio/src/utils/setSlideshowManifest.test.ts +++ b/packages/studio/src/utils/setSlideshowManifest.test.ts @@ -59,7 +59,6 @@ describe("persistSlideshowManifest — op construction", () => { editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, writeProjectFile, reloadPreview: vi.fn(), - domEditSaveTimestampRef: { current: 0 }, }; } diff --git a/packages/studio/src/utils/studioFileHistory.ts b/packages/studio/src/utils/studioFileHistory.ts index 7125221b88..675ec7c1d6 100644 --- a/packages/studio/src/utils/studioFileHistory.ts +++ b/packages/studio/src/utils/studioFileHistory.ts @@ -16,7 +16,6 @@ export interface DomEditCommitBaseParams { activeCompPath: string | null; showToast: (message: string, tone?: "error" | "info") => void; writeProjectFile: ProjectFileWriter; - domEditSaveTimestampRef: MutableRefObject; editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; projectIdRef: MutableRefObject; reloadPreview: () => void; diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 5fecdc02fe..a1664691b4 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -108,11 +108,11 @@ function devProjectApi(): Plugin { createStudioApi: (adapter: ReturnType) => { fetch: (req: Request) => Promise; }; - consumeFileWriteReceipt?: ( + identifyFileWrite: ( path: string, expectedVersion: string, ) => { path: string; version: string; writeToken: string } | null; - fileContentVersion?: (content: string) => string; + fileContentVersion: (content: string) => string; } | null = null; const getApi = async () => { if (!_api) { @@ -122,6 +122,13 @@ function devProjectApi(): Plugin { const mod = (await loadStudioServerDevModule(server, __dirname)) as NonNullable< typeof _studioServerModule >; + // The cast above is the only thing standing between a renamed export and + // a dev server that silently reports every Studio write as external. + for (const name of ["identifyFileWrite", "fileContentVersion"] as const) { + if (typeof mod[name] !== "function") { + throw new Error(`@hyperframes/studio-server dev module is missing ${name}()`); + } + } _studioServerModule = mod; const adapter = createViteAdapter(dataDir, server, signatureCache); _api = mod.createStudioApi(adapter); @@ -210,20 +217,19 @@ function devProjectApi(): Plugin { // so a write is only recognised as ours when the version agrees. Calling // this without the version could never match, which left every Studio // write looking external and reloaded the preview on each edit. + const studioServer = _studioServerModule; let version: string | null = null; try { - version = - _studioServerModule?.fileContentVersion?.(readFileSync(filePath, "utf-8")) ?? null; + version = studioServer?.fileContentVersion(readFileSync(filePath, "utf-8")) ?? null; } catch { // A deletion has no current bytes to match a write receipt against. } - const receipt = version - ? (_studioServerModule?.consumeFileWriteReceipt?.(filePath, version) ?? null) - : null; + const receipt = + version && studioServer ? studioServer.identifyFileWrite(filePath, version) : null; server.ws.send({ type: "custom", event: "hf:file-change", - data: receipt ?? { path: filePath }, + data: { path: filePath, version, ...receipt }, }); }); server.httpServer?.on("close", () => void projectWatcher.close()); diff --git a/plans/preview-loads-during-shell-first-layout.md b/plans/preview-loads-during-shell-first-layout.md new file mode 100644 index 0000000000..aa88fc134c --- /dev/null +++ b/plans/preview-loads-during-shell-first-layout.md @@ -0,0 +1,250 @@ +# Preview loads during the shell's first layout + +**Path:** `plans/preview-loads-during-shell-first-layout.md` +**Repo:** heygen-com/hyperframes · measured against v0.8.35 +**Status:** proposal, needs owner decisions (see Open questions) + +Every claim below is tagged **[fact]** (read from the code or the trace), **[inference]** (reasoning from those facts, falsifiable), or **[recommendation]**. + +--- + +## Problem + +**[fact — measured, not re-derived here]** On a cold open of a project, the Studio shell's first text layout is a single synchronous **3.04 s** task (`FontFaceSet::HandlePendingEventsAndPromisesSoon` → forced style+layout, 9 dirty objects, `preFCP`). Measured at 3.8 s idle, 11.9 s under machine load, ~20 s as experienced. It reproduces on a bare `

hi

` page in a fresh renderer: **it is Chrome plus machine load, not our CSS.** + +The preview iframe is created by React _after_ the shell renders, so the composition's own load (~1.5 s) is serialised behind the stall: + +``` ++0.00s studio shell commits ++0.21s Layout 3.04s (shell, 9 objects) ← stall ++3.36s preview iframe element created ++4.10s preview document commits (0.74s of server + network) ++4.53s preview layout 0.03s (1,602 objects) +``` + +Nine dirty objects taking 3.04 s is the tell: the cost is not proportional to our DOM. We cannot shrink it. We can stop paying it _and then_ paying for the preview. + +--- + +## Goal and non-goals + +**Goal.** Time-to-first-preview-frame is bounded by `max(stall, composition load)` rather than `stall + composition load`, by starting the composition's load before the stall begins. + +**Non-goals.** + +- Shrinking the 3.04 s stall. See "Why fonts is a dead end" — there is nothing of ours in it. +- Any change to the `` public API, its attributes, events, or lifecycle. **[fact]** None of the recommended units touch `packages/player/**` at all. +- Any change to deterministic render or export. The preview route (`/api/projects/:id/preview`) is already the studio-only path; the render path goes through the producer. +- Making the preview _itself_ faster (its 30 ms layout is not the problem). + +--- + +## Current flow (cited) + +### Where the element and the iframe are created + +1. `packages/studio/src/main.tsx:96` — `createRoot(#root).render()`. The entry is `