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 <Player key={activeKey}> -- 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.
This commit is contained in:
Miguel Ángel
2026-09-12 12:56:00 -04:00
committed by GitHub
parent 51a88b9566
commit 4c6fa9a790
96 changed files with 1645 additions and 484 deletions
+1
View File
@@ -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";
+2
View File
@@ -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,
@@ -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();
});
@@ -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<typeof path>();
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), "<svg/>");
fs.writeFileSync(path.join(hooks.studioDir, "favicon.svg"), "<svg/>");
fs.writeFileSync(
path.join(hooks.studioDir, "index.html"),
"<html><head></head><body>Studio</body></html>",
);
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);
});
});
+92 -3
View File
@@ -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<typeof import("node:fs")>();
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<Uint8Array>[] = [];
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<ReadableStreamDefaultReader<Uint8Array>[]> {
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<Uint8Array>): Promise<string> =>
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"), "<html>before</html>");
server = createStudioServer({ projectDir });
const streams = await subscribe(2);
const written = "<html>after</html>";
const write = await server.app.request(
`/api/projects/${encodeURIComponent(basename(projectDir))}/files/index.html`,
{
method: "PUT",
headers: {
"If-Match": fileContentVersion("<html>before</html>"),
"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"), "<html>before</html>");
server = createStudioServer({ projectDir });
const streams = await subscribe(2);
writeFileSync(join(projectDir, "index.html"), "<html>agent</html>");
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("<html>agent</html>"));
}
});
});
+21 -9
View File
@@ -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 =
/<img\b[^>]*?\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("<head>", `<head>${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 };
@@ -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";
@@ -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;
+11 -1
View File
@@ -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<ColorGradingMediaElement, ColorGradingEntry>();
const trackedElements = new Set<ColorGradingMediaElement>();
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;
+16 -6
View File
@@ -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");
+130 -34
View File
@@ -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 = <C extends { timeline: RuntimeTimelineLike }>(
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<HTMLVideoElement | HTMLAudioElement>) =>
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<HTMLMediaElement>();
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<string, RuntimeTimelineLike | undefined>;
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<string, unknown> =>
@@ -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 {
@@ -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 = `
<div data-composition-id="main" data-root="true" data-start="0" data-duration="20">
<div class="clip" data-composition-id="captions" data-start="0" data-duration="20"></div>
</div>`;
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);
});
});
@@ -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<void>((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(`<video id="rogue" data-start="0" data-duration="5"></video>`);
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(`
<div data-composition-id="host" data-start="0" data-duration="10">
<video id="hosted" data-duration="5"></video>
</div>`);
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(`<video id="grade" data-start="0" data-duration="5"></video>`);
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(`<video id="graded" data-start="0" data-duration="5"></video>`);
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(`<video id="late" data-start="10" data-duration="5"></video>`);
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);
});
});
+4
View File
@@ -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;
@@ -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);
});
});
@@ -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();
}
@@ -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(
@@ -31,6 +31,19 @@ export interface AssetCodecFacts {
/** Server-root-relative URL pathname -> that asset's codec facts. */
export type MediaCodecMap = Record<string, AssetCodecFacts>;
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<string, AssetCodecFacts>;
* 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<string, string | null> = {
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<string, BrowserHostileCodec> = {
// 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:<area>] {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,
};
}
@@ -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<typeof vi.fn>;
}> {
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<typeof import("./mediaCodecMap.js")>("./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<void> {
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(
"<html><head></head><body></body></html>",
projectDir,
[{ html: "<html></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("<html><head></head></html>", projectDir, [
{ html: "<html></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("<html><head></head></html>", projectDir, [
{ html: "<html></html>" },
]);
await settle();
expect(resolveProxy).toHaveBeenCalledTimes(1);
});
});
@@ -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(/^\/+/, "")),
+1
View File
@@ -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";
@@ -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",
@@ -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<typeof import("../helpers/mediaCodecMap.js")>(
"../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");
@@ -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) {
-7
View File
@@ -104,7 +104,6 @@ export function StudioApp() {
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
});
const editHistory = usePersistentEditHistory({ projectId });
const domEditSaveTimestampRef = useRef(0);
const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
const pendingTimelineEditPathRef = useRef(new Set<string>());
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}
@@ -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}
>
<PropertyPanel
@@ -436,7 +430,6 @@ export function StudioRightPanel({
sdkSession={sdkSession}
publishSdkSession={publishSdkSession}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={recordEdit}
/>
) : layersPaneOpen && designPaneOpen && !STUDIO_FLAT_INSPECTOR_ENABLED ? (
@@ -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<number>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
@@ -30,7 +30,6 @@ function renderToolbar(onSnapChange = vi.fn()) {
function AppHotkeyHarness() {
const domEditSelectionRef = useRef<DomEditSelection | null>(null);
const clearDomSelectionRef = useRef<() => void>(() => undefined);
const domEditSaveTimestampRef = useRef(0);
const leftSidebarRef = useRef<LeftSidebarHandle | null>(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),
@@ -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", () => {
@@ -9,7 +9,7 @@ export {
type StudioPathOffsetSnapshot,
} from "./manualEditsTypes";
export { readStudioFileChangePath } from "./manualEditsParsing";
export { readFileChangeField, readStudioFileChangePath } from "./manualEditsParsing";
export {
beginStudioManualEditGesture,
@@ -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<string, unknown>;
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<string, unknown>;
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);
}
@@ -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<void>;
recordEdit: RecordEditFn;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
}) {
const [selfRefresh, setSelfRefresh] = useState(0);
const groups = useProjectCompositionVariables(
@@ -115,7 +113,6 @@ export function VariablesOtherCompositions({
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
});
const [editingKey, setEditingKey] = useState<string | null>(null);
@@ -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<number>;
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}
/>
</div>
</div>
@@ -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<number>;
waitForPendingDomEditSaves: () => Promise<void>;
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",
@@ -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(),
@@ -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<string, (event: unknown) => 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();
}
});
});
@@ -102,7 +102,6 @@ export interface GsapScriptCommitsParams {
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
};
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
onCacheInvalidate: () => void;
onFileContentChanged?: (path: string, content: string) => void;
@@ -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
@@ -143,7 +143,6 @@ interface CreateAudioGroupAndAssignMembersInput {
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
@@ -164,7 +163,6 @@ export async function createAudioGroupAndAssignMembers({
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
// 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,
@@ -45,7 +45,6 @@ function makeSetter() {
showToast: () => {},
writeProjectFile: async () => {},
recordEdit: async () => {},
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set<string>() },
previewIframeRef: { current: null },
};
@@ -143,7 +143,6 @@ interface SetAudioGroupAttributeInput {
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
@@ -163,7 +162,6 @@ async function setAudioGroupAttribute({
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: SetAudioGroupAttributeInput): Promise<string[]> {
// 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,
@@ -243,7 +243,6 @@ describe("persistTimelineBatchEdit", () => {
writes.push([path, content]);
},
recordEdit: async () => {},
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set<string>() },
};
}
@@ -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.
@@ -285,7 +285,6 @@ export interface PersistTimelineEditInput {
buildPatches: (original: string, target: PatchTarget) => string;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
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<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
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<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: { current: number };
pendingTimelineEditPathRef: { current: Set<string> };
/** 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<string[]> {
@@ -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
@@ -42,7 +42,6 @@ interface SetElementAttributeInput {
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: Parameters<typeof persistElementAttribute>[0]["recordEdit"];
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
@@ -56,7 +55,6 @@ async function setElementAttribute({
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: SetElementAttributeInput): Promise<string[]> {
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,
@@ -65,7 +65,6 @@ describe("toggleTimelineTrackHidden", () => {
const writes = new Map<string, string>();
const recordEdit = vi.fn();
const timestampRef = { current: 0 };
const pendingRef = { current: new Set<string>() };
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();
@@ -39,7 +39,6 @@ interface ToggleTimelineTrackHiddenInput {
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
@@ -57,7 +56,6 @@ interface SetElementsHiddenInput {
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
@@ -140,7 +138,6 @@ async function setElementsHidden({
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: SetElementsHiddenInput): Promise<string[]> {
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<string[]> {
// `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<string[]> {
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,
@@ -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),
@@ -70,7 +70,6 @@ function Harness() {
const [selectionRefreshed, setSelectionRefreshed] = useState(false);
const selectionRef = useRef<DomEditSelection | null>(parentWithTextChild());
const clearSelectionRef = useRef<() => void>(() => undefined);
const saveTimestampRef = useRef(0);
const leftSidebarRef = useRef<LeftSidebarHandle | null>(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),
+1 -7
View File
@@ -118,7 +118,6 @@ interface UseAppHotkeysParams {
readOptionalProjectFile: (path: string) => Promise<string>;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
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<void> => {
domEditSaveTimestampRef.current = Date.now();
await writeProjectFile(path, content);
},
[domEditSaveTimestampRef, writeProjectFile],
[writeProjectFile],
);
const serializeHistoryFiles = useCallback(
<T>(paths: readonly string[], task: () => Promise<T>) =>
@@ -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?.();
}
@@ -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 },
});
+1 -11
View File
@@ -25,7 +25,6 @@ interface UseClipboardOptions {
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
@@ -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<boolean> => {
const copied = handleCopy();
@@ -209,7 +209,6 @@ function renderDomEditCommits(
const recordEdit = vi.fn(async () => {});
const previewIframeRef: MutableRefObject<HTMLIFrameElement | null> = { current: iframe };
const projectIdRef: MutableRefObject<string | null> = options.projectIdRef ?? { current: "p1" };
const domEditSaveTimestampRef: MutableRefObject<number> = { 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 ?? [] },
+2 -21
View File
@@ -50,7 +50,6 @@ export interface UseDomEditCommitsParams {
showToast: (message: string, tone?: "error" | "info") => void;
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
fileTree: string[];
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
@@ -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,
@@ -84,7 +84,6 @@ function createSessionParams(
readProjectFile: async () => "",
writeProjectFile: async () => {},
updateEditingFileContent: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: async () => {} },
fileTree: [],
importedFontAssetsRef: { current: [] },
@@ -47,7 +47,6 @@ export interface UseDomEditSessionParams {
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
updateEditingFileContent: (path: string, content: string) => void;
domEditSaveTimestampRef: React.MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
fileTree: string[];
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
@@ -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,
@@ -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,
});
+2 -6
View File
@@ -23,7 +23,6 @@ interface UseEditorSaveOptions {
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
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<EditorSaveDrainResult> => {
@@ -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);
@@ -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,
@@ -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();
});
});
});
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 () => {
@@ -37,7 +37,6 @@ function useTestFileManager(projectId: string) {
projectId,
showToast: vi.fn(),
recordEdit: vi.fn(async () => {}),
domEditSaveTimestampRef: { current: 0 },
setRefreshKey: vi.fn(),
});
}
@@ -27,7 +27,6 @@ interface UseFileManagerOptions {
projectId: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
}
@@ -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,
});
@@ -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)}`),
{
@@ -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;
@@ -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<string, unknown>, 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,
@@ -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();
});
});
@@ -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
@@ -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<void>;
recordEdit: RecordEditFn;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
}
/**
@@ -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<void> => {
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],
);
}
@@ -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;
@@ -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,
});
@@ -66,7 +66,6 @@ describe("useRazorSplit mutation versions", () => {
writeProjectFile,
observeProjectFileVersion,
recordEdit,
domEditSaveTimestampRef: { current: 0 },
reloadPreview: vi.fn(),
}).handleRazorSplit;
return null;
@@ -15,7 +15,6 @@ interface UseRazorSplitOptions {
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
observeProjectFileVersion?: (path: string, version: string | null) => void;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
forceReloadSdkSession?: () => void;
isRecordingRef?: React.RefObject<boolean>;
@@ -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,
@@ -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);
});
});
+3 -11
View File
@@ -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;
}
@@ -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<string, { before: string; after: string }>;
}) => Promise<void>;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
/** 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<void> {
@@ -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,
],
@@ -29,7 +29,6 @@ interface UseTimelineAssetDropOpsOptions {
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRefObject<number>;
reloadPreview: () => void;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: RefObject<boolean>;
@@ -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,
@@ -20,7 +20,6 @@ interface UseTimelineDeleteOpsOptions {
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRefObject<number>;
reloadPreview: () => void;
isRecordingRef?: MutableRefObject<boolean>;
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,
@@ -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<string>() },
@@ -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<string>() },
@@ -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,
@@ -35,7 +35,6 @@ export interface UseTimelineEditingOptions {
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
observeProjectFileVersion?: (path: string, version: string | null) => void;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRefObject<number>;
reloadPreview: () => void;
previewIframeRef: RefObject<HTMLIFrameElement | null>;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
@@ -51,7 +51,6 @@ export interface TimelineGroupCommitOptions {
interface UseTimelineGroupEditingOptions {
activeCompPath: string | null;
domEditSaveTimestampRef: MutableRefObject<number>;
editQueueRef: MutableRefObject<Promise<unknown>>;
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,
@@ -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,
],
);
@@ -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<unknown> | null = null;
export function loadPlayerModule(): Promise<unknown> {
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<HTMLIFrameElement, PlayerProps>(
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.
@@ -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);
});
});
@@ -60,4 +60,11 @@ export type IframeWindow = Window & {
__timeline?: TimelineLike;
__timelines?: Record<string, TimelineLike>;
__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;
};
};
@@ -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", () => {
@@ -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;
@@ -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", () => {
@@ -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", () => {
@@ -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 = <T>(val: T): MutableRefObject<T> => ({ current: val });
const makeDeps = (overrides: Partial<Parameters<typeof sdkCutoverPersist>[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 };</script></div>
disk = content;
}),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
publishSession,
},
mutate,
@@ -485,7 +480,6 @@ window.__timelines = { main: tl };</script></div>
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 };</script></div>
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 };</script></div>
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 };</script></div>
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 };</script></div>
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 };</script></div>
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 = <T>(val: T): MutableRefObject<T> => ({ 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 = <T>(val: T): MutableRefObject<T> => ({ 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 = <T>(val: T): MutableRefObject<T> => ({ 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("<html>on-disk gsap bytes</html>"),
...candidateTestDeps(),
};
@@ -1046,8 +1028,6 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
});
describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
const makeRef = <T>(val: T): MutableRefObject<T> => ({ 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 = <T>(val: T): MutableRefObject<T> => ({ 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 = <T>(val: T): MutableRefObject<T> => ({ 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 = <T>(val: T): MutableRefObject<T> => ({ current: val });
const makeDeps = () => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
...candidateTestDeps(),
});
@@ -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<void>;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
refresh?: (after: string) => void;
compositionPath?: string | null;
readProjectFile?: (path: string) => Promise<string>;
@@ -153,7 +151,6 @@ async function rollbackWrite(
cause: Error,
): Promise<Error> {
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<Error | null> {
deps.domEditSaveTimestampRef.current = Date.now();
markSelfWrite(targetPath, after);
try {
await deps.writeProjectFile(targetPath, after, originalContent);
@@ -59,7 +59,6 @@ describe("persistSlideshowManifest — op construction", () => {
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile,
reloadPreview: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
};
}
@@ -16,7 +16,6 @@ export interface DomEditCommitBaseParams {
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: ProjectFileWriter;
domEditSaveTimestampRef: MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
projectIdRef: MutableRefObject<string | null>;
reloadPreview: () => void;
+14 -8
View File
@@ -108,11 +108,11 @@ function devProjectApi(): Plugin {
createStudioApi: (adapter: ReturnType<typeof createViteAdapter>) => {
fetch: (req: Request) => Promise<Response>;
};
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());
@@ -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 `<p>hi</p>` 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 `<hyperframes-player>` 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(<StudioApp/>)`. The entry is `<script type="module" src="/src/main.tsx">` (`packages/studio/index.html:11`), i.e. **deferred**: it runs after HTML parsing.
2. `packages/studio/src/App.tsx:453` — the entire shell is gated: `if (resolving || waitingForServer || !projectId) return <StudioSplash/>`. **[fact]** `useServerConnection` (`packages/studio/src/hooks/useServerConnection.ts:26-66`) contacts `/api/projects` **even when the hash already names a project** — the hash id is used, but only after the fetch settles.
3. `App.tsx:497` → `EditorShell` → `packages/studio/src/components/EditorShell.tsx` → `nle/PreviewPane.tsx:125` → `nle/NLEPreview.tsx:467` → `player/components/Player.tsx`.
4. `packages/studio/src/player/components/Player.tsx:158-282` is where the DOM work happens, inside `useMountEffect` (a plain `useEffect`, `hooks/useMountEffect.ts:15`):
- `:163` `previewSource = directUrl || buildProjectApiPath(projectId, "/preview")`
- `:170` `await import("@hyperframes/player")` — a **dynamic import**, deliberately deferred to the browser (comment at `:6-8`: a module-scope import registers a custom element and throws under SSR)
- `:174` `document.createElement("hyperframes-player")`
- `:273-274` `shader-capture-scale="1"`, `shader-loading="player"` — **hard-coded constants**
- `:281-282` `setAttribute("src", src)` then `container.appendChild(player)`
5. `packages/player/src/hyperframes-player.ts:220-236` — `attributeChangedCallback` for `src` **bails when `!isConnected`** (`:227`). So the navigation actually starts in `connectedCallback` (`:177-189`), on the `appendChild` at `Player.tsx:282`, via `prepareSrcForElement`.
6. `packages/player/src/iframe-dom.ts:39-55` — `createCompositionIframe()` builds the iframe in the element's **constructor** (`hyperframes-player.ts:121`), inside the shadow root. There is no seam to hand the element a pre-existing iframe.
**So the serial chain today is:** parse index.html → deferred module bundle → React mount → `/api/projects` round trip → shell commit → **3.04 s layout stall** → passive mount effects → dynamic import of the player chunk → `createElement` + `appendChild` → _now_ the preview request is sent.
### What the iframe `src` depends on, and when each input is known
| Input | Source | Known at HTML-parse time? |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- |
| project id | `location.hash` → `parseProjectIdFromHash` (`utils/projectRouting.ts:60-72`); the CLI always emits `#project/<name>` (`packages/cli/src/commands/preview.ts:1200`) | **Yes** |
| `__hf_shader_capture_scale=1`, `__hf_shader_loading=player` | hard-coded at `Player.tsx:273-274`, appended by `shader-options.ts:88-104` (`prepareSrcForElement`) in that order | **Yes** (constants) |
| `variables=` | `hooks/previewVariablesStore.ts:17-19`, applied at `Player.tsx:176`. **[fact]** The only setter is the Variables panel (`components/panels/VariablesPanel.tsx:271`); nothing hydrates it at boot | **Yes** (always absent on a cold open) |
| `directUrl` (drill-down / non-master comp) | `nle/useCompositionStack.ts:33,93,110`, chosen from `activeCompPath`, which is hydrated from the URL only after the **file tree** loads (`hooks/useHydrateActiveCompPathFromUrl.ts`) | **No** — needs React state + a second fetch |
| `_t=` cache-buster | `player/hooks/useTimelinePlayer.ts:457-476` (`refreshPlayer`) | N/A (reload only) |
| `_hfStudioRetry` | `Player.tsx:180-188` | N/A (retry only) |
| **`t=` (playhead restore)** | `utils/studioUrlState.ts:154` → applied as a **seek after load** (`hooks/useStudioUrlState.ts:272-313`), **never part of the iframe URL** | N/A |
**[fact]** The exact cold-open URL is therefore fully derivable from `location.hash` alone:
```
/api/projects/<encodeURIComponent(id)>/preview?__hf_shader_capture_scale=1&__hf_shader_loading=player
```
### `refreshKey` does _not_ remount the player
**[fact]** `nle/NLEContext.tsx:113-118` — a `refreshKey` bump calls `refreshPlayer()`, which mutates `iframe.src` in place (`useTimelinePlayer.ts:457-476`). The React element is not remounted. **[inference]** Any element that exists at the right place in the DOM survives every reload path; only a `directUrl` change remounts, because `activeKey = directUrl ?? projectId` keys the `<Player>` (`NLEPreview.tsx:130,468`).
### Why fonts is a dead end (question 2)
**[fact]** Search performed, not sampled: `grep -rn "@font-face|woff|fonts.googleapis|fonts.gstatic|document.fonts|FontFace" packages/studio/src packages/studio/index.html packages/studio/public`.
- The shell's body font is a **pure system stack**: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` (`packages/studio/src/styles/studio.css:19`); mono stacks at `:293,331,426` are also local-only.
- `packages/studio/index.html` has **no** stylesheet link, no preconnect, no font at all — one favicon and one module script.
- `packages/studio/tailwind.config.js` adds no `fontFamily` override, so Tailwind preflight's `ui-sans-serif, system-ui, …` applies. No `transformIndexHtml` plugin injects anything (`packages/studio/vite.config.ts:235`, plugins are `react()` and `devProjectApi()` only).
- The only remote fonts in Studio are **on-demand and post-mount**: `components/editor/propertyPanelFont.tsx:88-108` injects a Google Fonts `<link>` when a `FontFamilyField` renders with a family (`:211`) or the user picks one (`:376`); `fontCatalog.ts:131` builds those URLs with `&display=swap` already.
**Conclusion.** There is no web font in the shell's cold path, so `font-display`, `<link rel="preload">` for fonts, and deferring the "font-dependent subtree" have nothing to act on — the subtree is _all_ text, and the trace's `<p>hi</p>` repro says a single paragraph is enough. **This plan pulls the "stop serialising behind it" lever, not the "shrink it" lever**, because the stall is Chrome's `FontFaceSet` machinery on a loaded machine and we have no input to it.
---
## Options and tradeoffs
### The constraint that decides everything
**[fact]** The preview iframe is same-origin and must stay so: Studio reads `contentDocument` everywhere — `NLEPreview.tsx:66-82` (`readPreviewCompositionSize`), `Player.tsx:79-100` (`hasUnloadedAssets`), the DOM-edit session, the timeline adapters, `previewMessageRouter.ts:99-105`.
**[inference, load-bearing]** A same-origin iframe lives in the parent's renderer process and on the parent's main thread. **The warm frame's HTML parsing, JS and layout cannot run while the parent is inside a 3.04 s synchronous task.** What _can_ overlap is everything off-thread: the HTTP request, the server's work, and subresource fetches. Hence the realistic prize is the **0.74 s** between "iframe created" and "document commits", plus the composition's asset fetches — not the composition's own script/layout time. Cross-origin (which would give true parallelism via site isolation) is off the table: it breaks every `contentDocument` reader listed above.
Say the honest bound up front: **`max(stall, network+server part of composition load) + composition main-thread part`**, not `max(stall, composition load)`.
### (a) Static `<hyperframes-player>` in `index.html`, adopted by React
Two sub-variants, both **rejected**:
- **Real adoption (React re-parents the pre-made element into the preview pane).** **[fact]** Removing and re-inserting an iframe discards its nested browsing context; the document reloads. Re-parenting throws away exactly the work we started early. Dead on arrival.
- **Never re-parent: the player lives in a fixed-position layer that tracks the preview pane's rect.** Preserves the load, but breaks: preview-only fullscreen (`PreviewPane.tsx:88-96` calls `requestFullscreen()` on the pane container — a fixed overlay outside it would not be included), the zoom/pan stage transform (`NLEPreview.tsx:190-197,437-447`), the drag-drop hit testing (`usePreviewBlockDrop`), and the overlay z-stack. Large diff, permanent complexity, for a win we can get another way.
Also **[fact]**: a `<hyperframes-player src>` in `index.html` does nothing until the custom element is _defined_ and upgraded (`hyperframes-player.ts:227` bails when `!isConnected`… and an undefined element has no callbacks at all). That means shipping the player bundle as a second head script — more startup bytes on the main thread, for a start no earlier than a plain `<iframe>`.
### (a′) Throwaway warm subframe in `index.html` — **the one that works**
A ~15-line inline script at the top of `<body>` that reads `location.hash`, and if it names a project, appends a hidden `<iframe>` to the same URL the player will use. It is never moved and never adopted; it is removed once the real preview fires `load`.
- **[inference]** Chrome's HTTP cache is keyed by `(top-frame site, frame site, url)` **plus a "subframe document resource" boolean**. A warm _subframe navigation_ shares that key with the real subframe navigation; a top-frame `fetch()` does **not**. This is why (a′) beats (b) in the browser, and why it also warms the composition's **subresources** (runtime script, the gsap CDN tags injected at `packages/studio-server/src/routes/preview.ts:45-48`, images, media) — not just the HTML.
- **[fact]** The response is `Cache-Control: private, no-cache` + `ETag` (`routes/preview.ts:310-312`), so it _is_ stored and the real navigation revalidates. **[fact]** The 304 short-circuit (`:332-339`) happens **before** any bundling or disk write, so the second request is cheap.
- **[fact]** Message safety: a phantom frame cannot corrupt state. Studio filters by sender (`previewMessageRouter.ts:57` `isForeignSource`) and the player filters by `event.source !== frameWindow` (`packages/player/src/runtime-message-handler.ts:64`).
**Failure modes.** No project in the hash → script no-ops, `useServerConnection` picks `projects[0]` (`useServerConnection.ts:47-52`) and nothing is warmed (correct: warming the wrong project is worse than not warming). `refreshKey` bump → irrelevant; `refreshPlayer` mutates `iframe.src` with a `_t` buster and never remounts (`useTimelinePlayer.ts:470-475`). Shader flags change → they are constants at `Player.tsx:273-274`; if that ever stops being true the warm URL silently misses the cache — guarded by the parity test in U3. Non-master `activeCompPath` restored from the URL → the `<Player>` remounts on the new `directUrl` key and the warm work is wasted (no breakage, no win). `?view=storyboard` (`packages/cli/src/commands/preview.ts:1183`) → `EditorShell` is only `hidden`, still mounted, so the warm still pays. HMR → the warm frame is one-shot and already gone; nothing to re-adopt.
**Costs.** The composition executes twice (memory, double media decode) unless the warm frame is `display:none` — which suppresses rAF and rendering, so it parses and fetches but does not run the animation. Concurrent cold requests can double-bundle and race `persistHfIdsIfNeeded` (`routes/preview.ts:341-344`) — a pre-existing hazard for any double request, but this makes it routine.
### (b) `<link rel="preload">` / early `fetch()`
Cheap and risk-free, but **[inference]** likely does not warm the browser cache for the subframe navigation (cache-key boolean above), and does not warm subresources at all. What it _does_ warm is the server, which (d) does better and earlier.
### (c) Mount the preview column before the font-dependent panels
**[fact]** The stall is one layout of the whole document, and passive effects run **after** layout — `Player.tsx:158` is `useMountEffect` = `useEffect` (`useMountEffect.ts:15`). Reordering React children cannot get an effect in front of the first layout. Making it work would need a static import of `@hyperframes/player` (explicitly avoided, `Player.tsx:6-8`) _plus_ `useLayoutEffect`, and would still only recover the **0.11 s** between stall-end and iframe-creation in the trace. Not worth the SSR/registration risk on its own.
**[recommendation]** Keep one crumb of it: kick the chunk fetch at module scope (U4), so the dynamic import is already resolved when the effect runs.
### (d) Prewarm the server — found in the code, and probably the biggest single win
**[fact]** The first preview request pays a **cold module load of the compiler**, on both server paths:
- Vite dev: `packages/studio/vite.adapter.ts:127-138` — `server.ssrLoadModule("@hyperframes/core/compiler")`, memoised in `_bundler` after the first call.
- CLI bundle: `packages/cli/src/server/studioServer.ts:395-402` — `await import("@hyperframes/core/compiler")`.
Plus `transformPreviewHtml` dynamically importing `producer/services/deterministicFonts.js` (`vite.adapter.ts:249`), the project signature cache, and the media-codec probe cache (`routes/preview.ts:318`).
**[inference — the single most testable claim in this document]** Most of the measured **0.74 s** between iframe creation and document commit is that first-request server cost, not the network. If so, one `fetch()` from the CLI _before it spawns the browser_ removes it from the user's critical path entirely, with **zero** browser-side risk.
**[fact]** There is exactly one funnel for it: `openStudioBrowser` (`packages/cli/src/commands/preview.ts:1261-1273`), called from all four sites (`:546, :1399, :1624, :1646`). Warming _before_ the `noOpen` early-return also helps users who paste the URL.
---
## Recommendation and why
**Ship (d) first, measure, then ship (a′) only if the trace still shows a gap.**
1. **U1 — server prewarm.** ~5 lines in one function, no client change, no flag, no API surface. If the inference holds it deletes most of the 0.74 s for _every_ entry path including the project picker, and it does so before the browser process even exists — the most parallel possible placement.
2. **Re-trace.** If the browser's first preview request still takes > 300 ms to commit, or the composition's subresources dominate, ship **U2 (a′)**: the throwaway warm subframe. It is the only option that starts the real subframe request before React exists and warms the same cache partition the real frame will read.
3. **U4** (module-scope chunk kick) is a one-liner that shaves the remaining 0.11 s tail; take it whenever.
Rejected for this plan: (a) real adoption (re-parenting reloads the frame; the fixed-overlay variant breaks fullscreen and zoom), (b) top-frame `fetch` (weaker than both alternatives), (c) React reordering (cannot precede the first layout).
**[recommendation]** Do not chase `max(stall, composition load)` as literally stated — the same-thread constraint makes the composition's own script/layout time unrecoverable without a cross-origin frame, which the DOM-editing contract forbids. Target: **the preview document is committed before the stall ends**, so only the composition's ~0.45 s main-thread tail remains after it.
---
## Units of work
### U1 — Prewarm the preview route before the browser opens
**DoD:** After the CLI prints the Studio URL, the browser's first `GET /api/projects/<id>/preview` is served with the compiler already imported and the bundle already computed — verified by two consecutive `curl -w '%{time_total}'` calls where the _second_ is < 100 ms on the demo project, and by the first being the one that pays.
**Files:** `packages/cli/src/commands/preview.ts` (inside `openStudioBrowser`, `:1261`, before the `noOpen` return); optionally `packages/studio/vite.config.ts` (`configureServer`, `:67`) if the owner's cold-open path is the Vite dev server.
**Notes:** fire-and-forget, `.catch(() => {})`, no await, no output on failure. URL must be `/api/projects/${encodeURIComponent(projectName)}/preview` (server-side warming does not need the shader params — those do not change the bundle, only the query).
### U2 — Measure where the 0.74 s actually goes _(gate for U3)_
**DoD:** A one-run answer, written into this document, splitting the 0.74 s into server handler time vs. transfer vs. renderer dispatch — from `curl` timings against a cold server plus the `ResourceSendRequest` → `ResourceReceiveResponse` deltas already in the trace.
**Files:** none (measurement only).
### U3 — Parse-time warm subframe _(only if U2 leaves > 300 ms, or subresources dominate)_
**DoD:** On a cold load whose URL carries `#project/<id>`, the preview document request is in flight **before** the shell's first layout event starts, and the warm iframe is removed from the DOM within 1 s of the real preview's `load`.
**Files:** `packages/studio/index.html` (inline script, `display:none` iframe, `sandbox="allow-scripts allow-same-origin"` + `referrerpolicy="no-referrer"` to match `iframe-dom.ts:47-51`, opt-out on `?hfNoWarm`); `packages/studio/src/player/components/Player.tsx` (remove the warm node in `handleLoad`, `:196`); one new test.
**Test (drift guard, required):** a vitest that recomputes the expected URL from the real helpers — `prepareSrcForElement` (`shader-options.ts:139`) over an element carrying `shader-capture-scale="1"` / `shader-loading="player"` and `buildProjectApiPath(id, "/preview")` — and asserts the string embedded in `index.html` matches. If anyone changes the shader attributes, the warm URL stops matching the cache key and this test fails instead of the win silently evaporating.
### U4 — Start the player chunk fetch at module scope
**DoD:** In a cold trace, the request for the `@hyperframes/player` chunk appears before the shell's first layout, and `Player.test.ts` still passes unchanged.
**Files:** `packages/studio/src/player/components/Player.tsx:170` — hoist to a module-scope `const playerModule = typeof window === "undefined" ? null : import("@hyperframes/player")`, awaited in the effect. The `typeof window` guard preserves the SSR contract documented at `:6-8`.
---
## Verification
**[fact]** The rig already exists: `hyperframes preview --browser-path <chrome> --user-data-dir <fresh dir> --remote-debugging-port 9222` (`packages/cli/src/utils/openBrowser.ts:44-66`, validated at `packages/cli/src/commands/preview.ts:437-441`).
Procedure, per run (baseline and candidate, 5 runs each, same machine, same load, report the median and note idle vs. loaded):
1. Kill any running preview server. a **fresh** `--user-data-dir` per run; a warm profile has the preview HTML cached and hides the whole effect.
2. Start with `--no-open` so the browser launch is under the harness's control, and note the URL.
3. Throwaway node script (puppeteer-core is already a dependency of `@hyperframes/engine`): launch Chrome with the fresh profile, `page.tracing.start({ path: "<out-dir>/run-N.json", categories: ["devtools.timeline", "blink.user_timing", "disabled-by-default-devtools.timeline.frame"] })`, `page.goto(<deep link>)`, wait 15 s, `tracing.stop()`. Do not commit the script.
4. Extract four timestamps from the JSON: (i) `navigationStart` of the top frame; (ii) `ts` of the ≥ 1 s `Layout` event in the top frame (the stall) and its `dur`; (iii) `ts` of the `ResourceSendRequest` whose `args.data.url` ends in `/preview?__hf_shader_capture_scale=1&__hf_shader_loading=player`; (iv) `ts` of the `Layout` in the preview frame with ~1,602 objects.
**Pass criterion (all three):**
- **P1 — ordering:** (iii) < start of (ii). The preview request is sent before the stall begins.
- **P2 — overlap realised:** the preview frame's `commitLoad` occurs within 150 ms of the stall's end (today: ~850 ms after it).
- **P3 — user-visible:** median (iv) − (i) improves by **≥ 0.6 s** versus baseline on an idle machine, and by ≥ 0.6 s under the same synthetic load used for the 11.9 s run.
**Falsifier.** If **P1 holds but P2 and P3 do not**, the inference about what can overlap a long parent task is wrong for this case: the renderer serialised the subframe commit behind the parent anyway, the change buys nothing, and U3 must be reverted (keep U1, which is measured independently by its own `curl` DoD). If P3 improves by less than the U2-measured server cost, U1 did not land where we thought — re-open U2.
A prose argument that "it must be faster now" does not close this. Only the trace does.
---
## Invariant
> **On a cold Studio open whose URL names a project, the request for that project's preview document is already in flight before the shell's first layout task begins — the preview's load never waits on React.**
---
## Risks and rollback
| Risk | Where | Mitigation |
| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Double bundling / `persistHfIdsIfNeeded` write race when the warm request and the real navigation overlap while cold | `routes/preview.ts:341-344` | The 304 path (`:332-339`) precedes all writes, so only the _fully concurrent cold_ case doubles up. Pre-existing (any double refresh does this). Flag to the owner; do not fix here. |
| Warm URL drifts from the real URL, silently losing the cache hit | `Player.tsx:273-274` vs `index.html` | The U3 parity test fails loudly. |
| Phantom frame confuses timeline/player state | — | **[fact]** Both listeners filter by sender: `previewMessageRouter.ts:57`, `runtime-message-handler.ts:64`. |
| Extra memory / media decode from the warm frame | — | `display:none` (no rAF, no rendering); removed on the real `load`. |
| Warm request for a project that then changes (`activeCompPath`, project picker) | `useCompositionStack.ts:110`, `useServerConnection.ts:47` | Wasted work only; no user-visible breakage. |
| Public web-component behaviour changes | — | **[fact]** No unit touches `packages/player/**`. |
**Flag & rollback.** U1: no flag; revert = delete the `void fetch(...)` line. U3: kill switch `?hfNoWarm=1` in the URL (read by the inline script itself, so it works even if the bundle is broken); revert = delete the `<script>` block from `index.html` and the removal hook in `Player.tsx`. U4: revert one line. No migrations, no persisted state, nothing to un-ship on the server.
**Existing tests that cover this ground and will need attention:**
- `packages/studio/src/player/components/Player.test.ts` — mounts the real `Player` against a fake custom element and asserts **listener-before-`src` ordering** (`:117-129`) and clean unmount when detached (`:106-115`). U3's removal hook and U4's hoisted import both run through this file.
- `packages/studio/src/components/nle/NLEPreview.test.ts` — mocks `Player`; covers `getPreviewPlayerKey` / stage sizing. Unaffected unless the mount contract changes.
- `packages/studio-server/src/routes/preview.test.ts` — the route's ETag/304 behaviour, which U1 leans on. Add a case only if the warm changes route behaviour (it should not).
- `packages/cli/src/commands/preview.test.ts` — where U1's prewarm assertion belongs (`openStudioBrowser` issues the request before spawning, and does so even under `--no-open`).
---
## Open questions for the owner
1. **Which server is on the measured path** — `hyperframes preview` (CLI bundle, `studioServer.ts:395`) or the Vite dev server (`vite.adapter.ts:129`)? U1 lands in one place or two. _No default taken._
2. **Is a phantom double-execution of the composition acceptable** (memory, duplicate media fetch, agent-observable second request in logs)? Yes → U3 as specified. No → fall back to the weaker top-frame `fetch` warm.
3. **Should the warm follow a restored non-master `activeCompPath`?** It cannot be known at parse time (needs the file-tree fetch). Master-only, or add a URL param carrying the comp path so the warm can be exact?
4. **May the shell mount before `/api/projects` answers when the hash names a project** (`App.tsx:453`, `useServerConnection.ts:26`)? That moves the whole timeline earlier but weakens the dead-server detection this gate exists for. Out of scope here; it is the next-largest structural lever.
5. **What is the target number?** "First preview frame in under X s on the reference machine under load." P3 above is a delta, not an absolute. The absolute belongs to the owner.
6. **Is the 3.04 s stall itself worth escalating to Chrome / to the machine's owner?** It reproduces on `<p>hi</p>`; everything in this plan is a workaround for someone else's bug.
---
+4
View File
@@ -37,6 +37,10 @@ const BASE_FLAG = "--base";
* its own. Remove an entry once its deletion has landed on the base.
*/
export const ALLOWED_DELETIONS = new Map([
[
"packages/studio/src/hooks/useSdkSession.test.ts",
"tests only shouldReloadSdkSession, a function with no production callers removed with the preview reload fix",
],
[
"docs/catalog/components/ai-generation-canvas.mdx",
"owner-directed removal of the AI Generation Canvas catalog item and its generated documentation",