mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
939efa3f9139f2dce465d313372201ce8edab570
62
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b4d5abd7b2 |
fix(studio): capture storyboard tiles at review density (#3371)
* fix(studio): capture storyboard tiles at source resolution * fix(studio): bound storyboard tile captures |
||
|
|
ec0b23f3ce |
fix(studio): make Delete remove the whole canvas selection (#3339)
* fix(studio): delete every clip in the selection, not just the first Select all in the timeline, press Delete, and one clip disappeared while the rest stayed — still drawn as selected. The Delete hotkey built the selection set correctly and then called `elements.find(...)`, which stops at the first match, and handed that single element to a handler that deletes exactly one. The comment above it claimed the handler "expands a clip that is part of the multi-selection into an atomic delete of the whole selection (single undo)" — no such expansion existed anywhere; `useTimelineEditing` never read `selectedElementIds`. `handleTimelineElementsDelete` takes the whole selection and removes every element before saving once, so the delete is a single history entry and a single undo — what the comment already promised. The hotkey layer now takes only that plural handler, since it never deletes one element in isolation; the singular entry point stays for the context menu and clip chrome. The store drops every deleted key and clears the marquee set, rather than leaving a selection drawn around clips that no longer exist. Elements whose `sourceFile` is not the composition being edited are dropped from the pass rather than written to the wrong file. Also removes the preview's double-click-to-reset-zoom. It was a document-level capture listener, so any double-click anywhere over the viewport snapped the zoom back to fit — including double-clicks meant for the content under it. The explicit reset control beside the zoom HUD stays. Reproduced by test: restoring `elements.find` reds the new marquee case. * fix(studio): delete every canvas element in the selection, not just the primary Selecting several elements on the canvas and pressing Delete removed one of them and left the rest — still drawn as selected. The delete path only ever took the primary selection; the marquee group it belongs to was ignored. Expand the session-level delete through the group ref, the same way the other group commits already do, and let the lifecycle op remove every member under a single save so one Undo restores the whole selection. * fix(studio): let the canvas selection own Delete instead of its timeline mirror Marquee-selecting elements on the canvas and pressing Delete removed a fraction of them. The hotkey routed to the timeline delete whenever the timeline store held anything, and the timeline's copy of a canvas selection is derived and lossy by construction — a member with no timeline row of its own is dropped from it. Selecting 73 elements published 14 ids, so 14 went and 59 stayed, still drawn as selected. The canvas selection is what the user drew the marquee around, so it owns Delete whenever it holds something; the timeline path stays as the fallback for rows with no canvas node to select. Both paths already remove through the same endpoint, so this is one addressing scheme replacing two. That makes the canvas delete the path a Delete press normally takes, so it picks up the same mid-recording refusal the timeline delete has. * fix(studio): let the marquee see the whole document, not the first 80 elements Dragging a marquee over the entire canvas selected a fraction of what it covered, so Delete left most of the page behind. The hit test sourced its candidates from the layers-panel collector, which stops after 80 items — a budget for how many rows that panel is willing to render, silently reused as if it described the document. Everything past the 80th element in document order was unselectable no matter where the user dragged. The off-canvas indicators were reading the same truncated list. The cap now belongs to the panel that wants it; the collector returns everything. To pay for that, the marquee measures its candidates once when the drag passes the threshold instead of re-reading layout for every element on every pointer-move: unbounded plus per-move stalled the tab outright, and the iframe DOM does not mutate mid-drag, so one pass stays true for the gesture. On a captured page: one marquee, one Delete, 734 elements down to 81. * fix(studio): report a no-op delete instead of claiming the elements went A target the file no longer holds answers `changed: false`, which is normal for a member nested inside another member already removed. Every target answering that is not — it means the preview is describing a document the file does not have, so each removal misses and the file is written back untouched. The toast still said "Deleted 503 elements. Use Undo to restore them." That is how a delete that did nothing at all looked from the outside: press Delete, the page stays, nothing on screen explains it. Say the preview is out of date and reload it instead. * fix(studio): keep the canvas hotkeys alive across preview reloads Pressing Delete with a canvas selection did nothing at all — no removal, no toast, nothing on screen to explain it. A keypress goes to whichever document has focus, and clicking the canvas puts focus inside the preview iframe, so the app's hotkeys have to be forwarded there. They were, but only from the iframe element's ref callback, which fires when the element mounts. A preview reload keeps the same element, so the callback never runs again, and keeps the same WindowProxy, so the forwarder's identity check saw no change and skipped re-attaching — while the inner window holding the listeners had been replaced. After the first reload the canvas had no app hotkeys left. Undo and redo kept working because their forwarder re-attaches on every load, which is why this read as "only Delete is broken". Fold the app handler into that per-load forwarder so both attach in the same place, on every load, and drop the mount-only one. Window only: the history pair also listens on the document, and capture listeners on both would run the app handler twice per press. * perf(studio): stop re-probing every restored selection member on load The hash carries the whole canvas selection, and restoring it asked the server whether each member still exists in the source — one request per member, awaited one after another. A marquee over a captured page puts hundreds of members in the URL, so every later load of that URL spent hundreds of serial round trips rebuilding the selection before the canvas answered anything, keypresses included. The marquee that produced those members already skips the probe. Restoring them skips it too; only the primary, whose panel reads the flag, still pays for one. * fix(studio): delete a canvas selection in one pass and say the key landed Reproduced with a real, focus-routed keypress instead of a synthetic one: the press does reach the handler and the delete does run to completion, but at hundreds of members it takes seconds during which the canvas is unchanged and nothing acknowledges the key. Silence for that long is indistinguishable from Delete being broken, and pressing it again or reloading mid-flight lands in a worse state. Two things, one per cause. The removal now sends the whole selection in a single request against a new remove-elements route, which reads the file once, drops every member and writes once — it was a round trip AND a full rewrite of the file per element. And a multi-element delete announces itself before the work starts, so the press is visibly acknowledged instead of leaving the canvas looking untouched until it finishes. Measured on a captured page, 84 members: 933ms of serial round trips against 84 rewrites, down to 583ms and one. * refactor(studio): narrow the SDK delete targets instead of asserting them The batch SDK path guarded on every member having an hfId and then asserted it away per member. Narrow once into a string list so the guard and the values come from the same place, and drop a threaded content variable that never changed — the SDK owns the document it edits, so every member is removed against the same starting content. Also mounts the new forwarding test through the existing harness rather than repeating its setup. * fix(studio): stop Delete acting on a canvas selection the user replaced Two things the reordered Delete arbitration got wrong, both found in review. A clip with no canvas node left the canvas selection pointing at whatever was picked before it, and the canvas branch wins whenever that ref is non-null — so selecting an audio clip and pressing Delete removed the previously selected canvas element and left the clip, right after the toast said the clip was not in the preview. The timeline fallback the comment described could not be reached. Clearing that selection has to stay quiet: the clear is announced to the timeline, so echoing it would deselect the clip that was just picked. Expanding the primary to the marquee group also moved out of the delete handler and up to the Delete key. Cut copies the primary alone, so expanding for every caller put one element on the clipboard and removed every other member with it — undo brought them back, paste restored one. The rule is a named function now, so the two callers can differ without either guessing. Also throttles the off-canvas indicator rebuild, which the cap had been hiding. It walks every element in the preview and reads layout for each — measured at 6.5ms on an 825-element captured page against a 16.7ms frame — and what marks it dirty is a MutationObserver on inline style, which is how animation writes. * fix(studio): hold the canvas selection inside the timeline selection The stale-canvas-selection defect survived at the second writer. The store-driven sync bails when a member has not resolved yet and returned without touching the canvas, so a pick with no canvas node at all left the previous selection in place — and Delete acts on the canvas first, so it deleted that. Reachable from the sidebar audio and asset reveals and from an asset drop, none of which go through the handler already fixed. Clearing on every bail would be wrong: the bail exists for a member whose node is not ready, which a later run resolves, and clearing there would flicker. Only a canvas anchor that resolves OUTSIDE the current selection goes, which is the state that is dangerous rather than merely unfinished. Quietly, for the same reason as the first writer: announcing would deselect the clip just picked. The invariant is named now, since Delete depends on it: the canvas selection never points outside the current timeline selection. Also drops the x-hf-removed header, which nothing read and whose comment promised a partial-vs-no-op distinction the response cannot make, and pins the indicator throttle that was measured but uncovered. |
||
|
|
b1b368d0f0 | fix(studio): preserve media offsets when splitting clips (#3272) | ||
|
|
56d8df65ca |
docs(skills): add /hyperframes-audio, and key the waveform cache by file (#3211)
* feat(studio): show every automated knob at the playhead, and carve as one module An automated parameter has two values: the number sitting in the chain, which is only the seed a lane replaced, and the number the envelope is on right now. The second is the true one, so the panel shows it — on the carve rack's readouts and on every effect's own fader and number field. A rack that showed the seed stood still while the carve was audibly working. Off the clip it keeps sampling rather than falling back to the stored number: a lane holds its first value backwards and its last forwards, so before the clip starts it already knows what it will open on, and the stored seed is a value nothing will ever play. Showing it made the fader jump the moment the clip came under the playhead. The playhead comes off the liveTime channel, throttled to 30 Hz — the RAF loop deliberately keeps frames out of the store, so a panel watching only the store would sit still for a whole take. PropertyPanel had that subscription inline; it is now one shared hook with two callers. Readouts reserve the width their parameter can need rather than what its current value takes, because an updating value one character narrower shunted everything after it sideways 30 times a second. The carve's effects are presented as one module: an author switched on a carve, and the peaking filters plus the level stage are how it is built, not six things to remove one at a time. Opening it lists every member's settings as readouts, since strength is what sets them. No carve control is offered on a track another track already carves against — that track is the voice, not the bed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio-server): key the waveform cache on the file, not just its path Two takes written to the same path returned the first one's waveform, so a re-recorded track drew the shape of the audio it replaced. The key now carries size and mtime, which is enough to notice the bytes changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(engine): render audio FX in an OfflineAudioContext Reads `data-fx-chain` off an audio element and runs the chain over the trimmed WAV before volume automation is baked in — effects should see the raw signal, and the envelope belongs on their output. The processing happens in an OfflineAudioContext inside the headless browser the engine already drives, running the same graph builders the studio previews with. That is the point of the approach: one implementation per effect, so the render agreeing with the preview is a property of the architecture rather than a tolerance to police. Reimplementing each effect as an FFmpeg filter would mean two implementations to keep in step, and for the dynamics processors and modulated delays there is no filter that behaves the same way. `build:audio-fx-runtime` bundles the graph builders into an injectable IIFE, following the same pattern as the existing runtime artifacts, so the browser runs exactly the code the studio does. The page loads from a file:// URL rather than about:blank because AudioWorklet is only exposed in a secure context — the compressor, limiter, gate and bitcrush processors would otherwise fail to register with an opaque error. file:// qualifies and needs no listening socket. The chain is serialised into the attribute the way colour grading carries its config, so there is no side-car file to resolve or lose. An FX failure is fatal for the whole mix rather than a per-track soft failure. Every other audio failure mode degrades gracefully — the track drops, siblings continue — but substituting the dry signal for a processed one ships a render that sounds plausible and is not what the author set up. Since the per-element work races under Promise.all, an internal AbortController chained off the caller's signal aborts in-flight siblings before workDir is removed. * feat(core): voiceover carve analysis Finds the bands a voice occupies so a music bed can be dipped there, letting the voice sit in front without ducking the whole track. Carve is a relationship between two tracks rather than an effect on one, so it stays out of the FX chain. What it emits is an ordinary chain of peaking filters, so a carve composes with whatever else is on the track and needs no separate rendering path. Selection is weighted toward intelligibility rather than raw voice energy. Ranking purely by power lands on the fundamental almost every time, because that is where a voice is loudest — but the masking that actually hurts a voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights toward 1-3 kHz. Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB across these bands — it falls off roughly 6 dB per octave above the fundamental — so a weighting has to be on that scale to move anything at all. A multiplicative weight of `1 - bias + bias * shaped` is bounded below by `1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7 default, 3 dB at 0.5. That is no influence against a real voice — every bias short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the outcome the bias exists to prevent, while looking decisive against a fixture whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth up to 30 dB at full strength, and relative cut depths come from a dB difference rather than a ratio of weighted linear powers. The bias reweights ranking without overriding the spectrum — a band the voice has no energy in is not worth carving, and scores -Infinity rather than competing — so a strongly low-pitched voice can still select low at full bias. What the tests hold is that biasing never selects lower than the unbiased ranking, that the DEFAULT bias reaches the presence region on a voice with a realistic tilt, and that bias 0 still follows raw power exactly. Includes a radix-2 FFT rather than a dependency; one Welch-style averaged spectrum over third-octave bands does not justify pulling in a DSP library. * fix(engine): keep the FX render 16-bit, stereo, and correctly sized Three defects in the offline FX path, none of which any test could see. **Float output silently disabled sample-accurate volume automation.** The writer emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope into the samples and accepts only 16-bit PCM, returning null otherwise. So enabling any effect downgraded that track to the ffmpeg expression path — capped at 32 straight segments, quantising a curved envelope, and on a dense one falling back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test asserts the baker accepts the writer's own output and actually fades it. **Everything was folded to mono.** `prepareAudioTrack` goes out of its way to emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo rematrix — and this folded it, then wrote one channel. So adding a single peaking EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed stereo. Channels now travel as one plane each, through an OfflineAudioContext of the same width, and come back interleaved. **Small results decoded the wrong length.** `new Float32Array(buf.buffer)` discards byteOffset and byteLength, and Node pools small allocations: a 400-byte payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples decoded as 2048 samples of unrelated memory — and the empty-result guard could not see it. The reader has the mirror-image fix: a float data chunk on an odd boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now copies instead of throwing RangeError on an unaligned view. The tail limitation is now stated rather than mis-stated: the context is exactly as long as the input, so a reverb or delay still ringing is cut there. The old comment claimed the opposite. How far a tail may run past a clip's end changes the clip's length in the mix, so it is a product decision, not one to make here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(producer): report an FX render failure as an audio error `processCompositionAudio` reports per-track failures in its result, but an FX failure it cannot degrade past — a browser that will not launch, a chain that will not build — rejects instead. `runAudioStage` had no try, so that rejection escaped to the orchestrator as an unclassified pipeline exception, losing the stage/owner/retryable classification this stage exists to attach, and skipping its abort check on the way out. It now lands in `audioError` alongside every other cause, while an abort still keeps its own shape rather than being reported as an audio problem. Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh clone typechecks packages/engine without building first. The bundle is built from the stub, and the stub changes three times across this stack — so the artifact differs per branch and would conflict on every restack. Its model, position-edits-render-inline.ts, is committed only because it is stable. Building before testing is this monorepo's existing contract (studio's tests need core's dist too), so the gap is not specific to audio FX and is better closed by a build ordering gate than by committing a per-branch artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(engine): skip the browser FX render cases when there is no browser CI's `Test` job was red on this PR with four failures, all the same cause: Failed to launch the browser process: spawn /home/runner/.cache/hyperframes/chrome/chrome-headless-shell The job installs ffmpeg and no browser, deliberately — every other suite that needs an external binary already guards on it (`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming a Chrome, so they failed on an absent dependency rather than on anything about the code. Guards on `resolveHeadlessShellPath()` — the same resolver `acquireBrowser` launches through, so the check cannot drift from the thing it guards the way a hard-coded cache path would. A configured path that does not exist throws; that is caught and read as "cannot run here". Checked both directions rather than just the green one: with a browser all 11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a missing binary exactly 3 skip and the other 8 still run. A guard that silently skipped everything would have looked identical in CI. They keep their value where it exists — every developer machine, and any job that has run `hyperframes browser ensure`. Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five days and several force-pushes stale. None of the 17 open repo alerts are in files this PR changes; it re-runs on this push. * chore(engine): suppress the temp-file alert with the reason it is safe CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file (high) — the one new alert on #3021, and the reason its CodeQL check is red. It is a false positive, and the comment says why rather than just silencing it: `path` is always inside a directory made by `mkdtempSync`, never a name assembled directly under `tmpdir()`. Both callers are covered — the browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`, and the render output goes to the producer work dir, itself `mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the random suffix and creates the directory 0700 in one syscall, so the predictable filename inside it cannot be pre-created or symlinked by another user, which is the attack the rule is about. The analyzer sees the dataflow reach `tmpdir()` and not the mkdtemp in between. Suppressed inline rather than dismissed in the UI, so the justification lives next to the code and the rule stays live for anything added later in this file. Matches the repo's existing convention — `planV2.ts:222` carries an `lgtm[js/insecure-temporary-file]` for a different reason on the same rule. Correcting myself: I first reported this alert as not real, having intersected the PR's files against the default-branch alert list, which does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns it straight away. * test(engine): probe ffmpeg and Chrome instead of assuming them Two failures on #3021's Test job, both about the environment rather than the code under test. **Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to `execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()` resolves — every other ffmpeg-dependent suite in this package already goes through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)` so a contributor without ffmpeg skips rather than fails. **The browser guard trusted the wrong thing.** It asked `resolveHeadlessShellPath()` and treated a returned path as "a browser is here". CI's cache holds a chrome-headless-shell that resolves and then fails to spawn — a partial download is indistinguishable from a working one by `existsSync`, which is all that resolver checks. So the three browser cases ran anyway and failed on the launch. It now runs `--version` and requires exit 0, which is the same probe the ffmpeg suites use: ask the binary, do not infer from the filesystem. Checked both directions rather than just the green one. With a working browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed at a binary that exits non-zero — CI's exact situation — exactly 3 skip and the other 8 still run. A guard that quietly skipped everything would have looked identical on the CI summary. * feat(core): register the audio-fx-rack canary at 0% Lands the rollout switch dark, per the registry's own procedure: "Start at percentage: 0 and merge that — a canary at 0 is dead code you can land safely and ramp without a code review." Declared at the bottom of the stack so every branch above can read it. The gate itself goes in at wa-4-fx-panel, where the rack first appears. Scope is deliberate and stated in the description: it gates the AUTHORING surface only. A composition that already carries `data-fx-chain` still plays and renders it. A canary should stage who can REACH a feature, not make an attribute somebody already wrote silently inert — an agent that writes a chain through the skill would otherwise produce a file whose audio processing vanishes with no error. * feat(studio): audio FX panel generated from the registry Controls for the whole chain: add, remove, reorder, bypass, and every knob each effect declares. Nothing in the panel knows what a compressor is. The registry supplies each parameter's range, step, unit and scale and the panel renders what it finds, so adding an effect or a knob upstream needs no change here, and the panel cannot offer a value the renderer would reject — a typed-in figure is clamped into the declared range on the way through. Frequency and time controls span three or four decades, so those declare a log scale and the slider maps exponentially; a linear slider would spend most of its travel somewhere useless. Reorder is a first-class control because chain order changes the sound: a reverb before a compressor is not the same as after. Carve gets its own block rather than an entry in the add menu, with a picker for the voice track to listen to. It processes this track based on another one, which is how a sidechain control works — it lives on the track that changes, and names the source. * feat(studio): show the Audio FX section on audio tracks Adds `audioFx` to the editing-affordances contract and renders the FX panel in the inspector when an `<audio>` element is selected. The section is audio-only. A `<video>` carries its sound on a separate `<audio>` element, so an FX chain on the video would have nothing to process. Chain and carve settings are written straight back onto the element as serialised attributes, the way colour grading carries its config, so persistence is an ordinary attribute write and needs no new server route. A chain that cannot be parsed renders as empty rather than breaking the panel, and the attribute is left untouched until the user changes something. The collapsed group summarises what is on the track ("2 effects + carve") so the state is visible without expanding it. Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED defaults to true, so the flat inspector is what actually renders. * refactor(studio): lift audioFxSummary out of PropertyPanelFlat `PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap, so the required File size check is red — the sole reason this PR is blocked. The review says as much: "mechanical fix (~5 min), not a design problem. Code itself is LGTM." Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later branch creates for it. Deliberately the smallest cut that clears the cap rather than the whole `AudioFxGroup` extraction: every later commit in the stack edits AudioFxGroup, so moving it here would collide with each of them, while almost nothing touches this function. 595 lines. * feat(core,studio): hear the FX chain in preview, and run the carve analysis Splices an element's FX chain into the playback graph so preview stops being silent about effects, and wires the carve button that was previously inert. The chain goes between the decoded source and its gain stage: effects see the raw signal and volume automation rides on their output, matching the order the offline render uses. Since preview and render call the same graph builders, what is heard while scrubbing is what gets written. The splice lives in the transport rather than on the `<audio>` element. The transport plays each track from a decoded AudioBuffer and mutes the element to avoid doubling, so capturing the element with createMediaElementSource would have processed a stream nothing is listening to — it looked like it worked because the call succeeded, and the audio was unchanged. A chain that cannot be built plays dry rather than silencing the track, which is the right failure in preview: the author keeps working and hears the source. The render still refuses, because shipping the dry signal there would be wrong. Carve now analyses for real: it decodes the chosen voice track, ranks its bands and writes the resulting peaking filters onto this track. Generated nodes are tagged `fromCarve`, so re-running replaces the previous carve instead of stacking another set on top of hand-added effects. Known limitation: the graph is built when a source is scheduled, so a knob turned mid-playback takes effect on the next play or seek rather than immediately. Live re-parameterisation needs the transport to hold the handle and forward updates. * fix(studio,core): stop parameter drags from restarting playback Dragging a knob wrote the chain through the persisting attribute path on every input event. That path refreshes the preview, which reloads the composition and reschedules audio — so a single drag reloaded dozens of times and playback stuttered the whole way. Drags now go through `onSetAttributeLive`, the same path colour grading uses for scrubs: it coalesces undo entries and sets `skipRefresh`, so no reload happens. The persisting write fires once, when the gesture ends — pointer-up or blur for a slider, Enter or blur for a typed value. A select commits immediately since there is no drag to wait for. While dragging, the control is driven from local state. Waiting for the value to round-trip through the element attribute made the knob lag behind the pointer. For the change to be audible without a reload, the graph now follows the attribute: the chain installed by the transport observes the element and re-parameterises itself in place, so a value change lands on the next 128-sample quantum. A shape change (effect added, bypassed, pole count) cannot be patched into a running graph, so it still waits for the next schedule rather than cutting the audio mid-play. The regression test drags a slider through several values and asserts the persisting handler is untouched until release. * feat(studio): put the audio FX rack behind its canary Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered at 0% — so the whole 47-PR stack can land without showing anyone a feature that has not been measured yet. The gate sits on the AUTHORING surface and nowhere else. The runtime and the render still honour a `data-fx-chain` already on an element, so a composition written through the skill or by `carve.mjs` keeps its processing rather than going silently dry for anyone outside the cohort. A canary should stage who can REACH a feature, not make an attribute somebody already wrote stop working with no error. Gated at the panel rather than in `resolveEditingSections`: the affordance resolver is a pure function in core describing what an element CAN support, and rollout state is not a property of an `<audio>` tag. Pinned the 0% with a test, and checked it fails at 25 — a ramp should have to break something that says "this ships dark" out loud. One gap, stated rather than papered over: the gate itself has no unit test. I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness never renders the Audio FX group for its audio fixture even with the gate removed — so the test passed for the wrong reason in the off case and could not pass at all in the on case. A test that cannot fail for the right reason is worse than none. Verifying the gate needs the panel harness to mount that section first, which is its own change. * fix(core): register FX worklets before building nodes that need them An AudioWorkletNode cannot be constructed before its processor is registered — it throws, and the surrounding chain is lost with it. `attachElementFxChain` built the chain first and only then called `ensureAudioFxWorklets`, so every worklet-backed effect (compressor, limiter, gate, bitcrush) threw on construction and the track fell back to dry. Instrumenting the preview showed `hf-compressor: InvalidStateError` with addModule never called at all. When the module has not landed yet the track now plays dry and the graph is swapped in once registration resolves, so the effect arrives a moment late instead of never. Registration is also tracked per context rather than in one module-level promise. A processor registered on one AudioContext does not exist on another, so the shared promise made every context after the first believe it was ready when it was not — the studio's transport owns its own context, which is exactly that case. With the worklets actually running, the compressor's per-sample log10 and pow became real audio-thread work. Samples below the knee have a gain of exactly unity and need neither, so the envelope is now compared in the linear domain and the transcendentals only run for samples that are actually being compressed. * refactor(studio): split the FX node row out of FxSection Clears the health findings the FX stack left behind: the chain-node render callback was a 70-line closure over half of FxSection's state, and the two reorder arrows were the same button written twice. Also drops two exports with no consumers, and registers the audio FX runtime stub as an entry point — it is bundled by file path, so nothing imports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): automation envelope model for audio tracks Adds the data model behind Ableton-style automation lanes: breakpoint envelopes over track volume or one knob of one effect in the track's FX chain, stored on the element as `data-automation`. Times are clip-local, so an envelope travels with the clip when it moves — the clip-envelope model rather than arrangement automation. `sampleAutomationLane` is the single interpolator. The lane drawing, the preview scheduler and the render bake all call it, so the picture and the sound cannot disagree about the curve. Log-scaled parameters interpolate in log space, matching what their own knob already promises. FX nodes gain a stable `id`, minted by count rather than randomly so the document is the same on every machine. Lanes address nodes by id, so reordering a chain never re-points a lane at a different effect, and a lane whose effect was deleted is dropped rather than left to reattach. Also warns when a track carries both a volume lane and a GSAP volume tween, since only the lane is heard and the tween silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(studio): lift the audio FX group out of PropertyPanelFlat `PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so the required File size check was red — the sole reason #3014 and #3022 are blocked. Both reviews say the same thing: "mechanical fix, not a design problem. Code itself is LGTM." Moves `AudioFxGroup` and `audioFxSummary` into `propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them anyway — done here so the file is under the cap from the point it first crosses it, rather than ten branches later. 533 lines now. The four audio imports it no longer needs go with it. Not fixed here: three `FxSection carve` tests fail on this branch with "Cannot read properties of undefined (reading 'toFixed')". Confirmed pre-existing by stashing this change and re-running — that is the separate `Test` failure the review also flags. * feat(core): expose the AudioParams behind automatable FX knobs Marks the knobs an automation lane can drive and has each graph builder hand back the AudioParam behind them, so a scheduler can write to a running effect without knowing what the effect is. A knob is not always one AudioParam. A wet/dry mix is two gains moving in opposition, and a knob in milliseconds drives a delay time in seconds, so each target carries the mapping out of the knob's own declared unit. What stays unautomatable is stated where it is decided: a WaveShaper curve, a convolution impulse and a one-pole filter's coefficients are all rebuilt wholesale rather than scheduled, and the four worklet effects take values by postMessage rather than through AudioParams. The registry flag is written by hand, so a test builds every effect and checks the exposure both ways — nothing flagged is missing, nothing exposed is unflagged. A flag that lied would offer a lane that silently did nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): play automation envelopes in preview Schedules each lane onto the AudioParams behind its knob using native ramps and value curves. Nothing evaluates the envelope per frame: it is handed to the audio thread once, so it stays sample-accurate however busy the main thread is, and the offline render will schedule it the same way. Timing comes from the transport, so an envelope survives seeking into the middle of a clip, a clip that has not started yet, and a playback rate that compresses clip seconds into context seconds. A straight line is only scheduled as a ramp when nothing bends it — no curvature, a linear parameter scale, and no unit mapping. Log-scaled parameters and mapped ones are sampled instead, since a delay knob in milliseconds and a wet/dry pair moving in opposition are not linear in the parameter they drive. Lanes with nowhere to write are skipped rather than reported: a one-pole filter exposes no frequency param, and the worklet effects expose none at all. Editing an envelope mid-playback re-aims it at the live playhead rather than restarting the track. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): make the volume lane audible in preview The envelope was scheduled onto the transport's gain AudioParam, but the runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked value — so it was erased within a frame. Volume automation was correct in the render and inaudible while previewing. The lane now feeds the per-tick path where the probed volume keyframes already sit, checked ahead of them so the two cannot fight, and the transport no longer schedules volume at all: one mechanism instead of two racing. The cost is honest — in preview the level steps per tick rather than per sample, exactly as the existing keyframe path does. The render still bakes it into the PCM sample-accurately, and FX parameters are still scheduled on their own AudioParams, since nothing rewrites those. Parsed lanes are cached by attribute text: the runtime asks once per tick per track, and parsing there would run the JSON parser 60 times a second for a value that only changes on an edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(engine): bake automation envelopes into the render The offline render schedules FX lanes with the same scheduler preview uses, inside the OfflineAudioContext that already runs the same graph builders. The input WAV is the clip's own audio from its first sample, so clip-local time is offline time and the envelope needs no offset. Volume lanes take the existing PCM bake rather than a second mechanism: the lane is converted to keyframes, so a straight fade stays two of them and only a bent segment is sampled — the baker interpolates linearly and would otherwise quietly straighten the curve. A volume lane supersedes keyframes probed from the timeline, which `lint` already warns about. A browser test sweeps a lowpass from below a 2 kHz tone to well above it and measures both ends. Parsing the envelope is not the same as scheduling it, and only running the real thing tells the two apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): apply chain edits to the running graph A structural edit — an effect added, removed, bypassed, or a filter's pole count switched — was dropped. `buildFxChain`'s update reports false when the change is not merely new values, and the attribute observer ignored that, so the edit only took hold when the persisting write reloaded the composition. That reload restarted every playing track, which is what was heard as the audio chopping. The graph is now swapped in place: the old effects are detached, the new ones built and connected between the same source and gain, and any lanes re-scheduled onto the new nodes. The source node is never touched, so playback does not restart. A track with no chain is watched too, rather than wired through and forgotten, so adding its first effect is heard the same way. That means the function always returns a disposer instead of null for the empty case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): drop the FX panel's dead __testables export Fallow audit flagged it — no test imports the module. * fix(core,studio): clear the remaining Fallow audit findings on the FX panel - Split FxSection's per-node row into FxNodeRow + FxNodeControls so the CRAP score (31.6, threshold 30) splits across two smaller units instead of moving wholesale with one extraction. - Dedupe the repeated "open the add menu, read its items" block in propertyPanelFxSection.test.tsx into openAddMenuItems(). - Merge build-audio-fx-runtime.ts and build-position-edits-render.ts into one build-inline-artifact.ts, config-selected by CLI arg — the two scripts were a byte-for-byte clone save for names. - Exempt canary.test.ts's rawFnv (a deliberate independent reimplementation used to cross-check canaryBucket, per its own docstring) and the property-panel test files' shared renderInto/mount scaffolding (pre-existing across 9 files, 2 outside this stack) in .fallowrc.jsonc, consistent with this file's existing exemptions for the same class of intentional/pre-existing duplication. * fix(ci): allowlist the build-script consolidation in the no-main-deletions guard build-audio-fx-runtime.ts and build-position-edits-render.ts were merged into build-inline-artifact.ts to kill a fallow duplication finding; the deletion guard flagged that as an accidental loss since main still has both originals. * fix(core): dedupe the wet/dry mix math between delayFeedback and chorusLfo Both effect builders set wet.gain to the mix and dry.gain to its complement in identical two-line blocks; fallow kept re-flagging it as a 10-line clone on every unrelated change. Extracted setWetDryMix. * fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge An earlier merge with main brought this deleted file back (git's merge/delete handling on an unchanged-on-one-side file); package.json already points at build-inline-artifact.ts, so it sat unreachable and duplicating that file's config, both of which fallow flagged. * fix(studio): pull TimelineLanes under the 600-line cap TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer gestures (resize-start, pointer-down move-arm, click/razor-split) into createClipGestureHandlers — one factory call per rendered clip instead of ~120 lines of inline handler bodies in the render loop. 529 lines now. * fix(studio): split the extracted pointerdown handler under the CRAP threshold Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts concentrated it into two functions fallow flagged (onPointerDown at CRAP 63.6, onResizeStart at 31.6). Split the decision logic (which gesture a pointerdown implies) into a pure resolvePointerDownAction, then split its own intent-blocking check into isIntentBlocked. onResizeStart's guard moved into canStartResize. Every function now scores under 30. * fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the stack removed the last use of the type here without removing the import. * fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a preview-only commit that useDomEditAttributeCommits.ts never grew — backported that option support from its own later commit so the two sides of the API agree. The paste path and its tests were missing the box selection's v0/v1 bounds a sibling commit added to AutomationSelection. The FX panel's carve controls still edited the six mechanism numbers (maxCutDb, bands, intelligibilityBias) after carveProfile() collapsed authoring to one Strength knob, so those fields no longer existed on HfCarveSettings; UI now edits strength, and analyseCarveBands is called with carveProfile(strength). Also closes fallow's complexity, dead-code and duplication findings on this PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math) and useAutomationRangeDrag.ts (the marquee-select gesture) out of useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a resolver into named functions, dropped an export nothing outside its file used, and shared a step-simplifier between audioCarve's two envelope builders. The edge-stretch vs. box-select priority test in TimelineAutomationLane.test was still pinning the pre-box-select rule (edge wins over a point sitting on it) that a sibling commit deliberately reversed — a point inside the box is now selected content, so grabbing it drags the group instead. Updated the test to the shipped rule instead of the old one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): cap the via conic's weight so an edge-clamped via point can't NaN A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to (0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0. viaConic divided by that zero to get an infinite weight, and shapeVia turned Infinity into NaN a few steps later (Infinity - Infinity in the quadratic coefficient). NaN reaching setValueCurveAtTime silences the automated parameter for the rest of the render. Capped the weight at 1e6 instead of leaving it unbounded — past that point the arc already reads as touching the via point, so nothing visible is lost. Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`, since NaN fails the original comparison and fell through it. Review by Miga (PR #3208). * fix(studio-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(studio): widen PropertyPanel's resetModules render timeout again The 20s margin (already once widened for the same reason) is timing out in CI's full-monorepo Test run — the resetModules()+fresh-import render this test needs is uncached and competes with every other package's test suite for the same worker pool, and the same test passes in well under 2s standalone. Went to 45s rather than re-tuning to whatever number happens to clear the current CI load, since that number moves every time CI gains a package. * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
636dc042a7 |
feat(core): sanitize rich text on the way into a composition (#3141)
* feat(core): sanitize rich text on the way into a composition Studio's patch vocabulary was inline-style, attribute, html-attribute and text-content. text-content assigns textContent, and the text-field model escapes markup on the way out and refuses a change in child structure, so a styled span had no route into a composition file. Adds a rich-text operation with one, guarded by a single sanitizer called on both ends of the trip: in the browser so the preview shows what will be saved, and on the server because that is where the file is written. Tags and style properties are a small allowlist, and an unexpected tag loses its formatting rather than its words. Spans an edit adds get their ids in the same write, so a follow-up write cannot race it. No UI yet — this is the persistence contract the editor is built on. * fix(core): document and test the sanitizer boundary * fix(core): harden rich text sanitizer traversal |
||
|
|
bea32b8aae |
fix(studio): stop a Studio edit from reloading the preview (#3137)
* fix(studio): stop a Studio edit from reloading the preview as if it were external Every mutation route wrote the file without leaving a write receipt, so the watcher's broadcast of Studio's own edit arrived with no identity on it. The external-change coordinator could not tell that echo from an agent or an editor writing the file behind Studio's back, so it took the safe branch and did a full iframe reload. That reload hides the stage for the length of the reload, which is what the flash after a text edit was. Every mutation write now goes through one helper that records the receipt, and the client claims the write before the request goes out rather than after it: the server writes and the watcher fires while the request is still in flight, so a token marked from the response can arrive after the echo it was meant to match. Reproduced in the browser before and after, with the reload path traced end to end. Before, a patch-element write logged `token: null` then a reload from the coordinator; after, the same write logs the token and `suppressed: own write token`, with no reload. Adds `hf-reload-debug` (localStorage, off by default) alongside the existing `hf-resize-debug`: it records each file-change decision and its reason, plus the stack of whoever asked for a full reload. * fix(studio): claim the timeline and caption writes too, not just the DOM ones The receipt only helps when the client marked the token it sent, and the GSAP mutation writers never sent one. A drag commits through gsap-mutations, so the server minted a token the client had never seen, the change came back looking like someone else's, and the preview did the full reload the receipt was meant to prevent. Same one-line claim on both GSAP mutation writers, the timing sync's mutation call, and the caption auto-save PUT. The rollback call stays deliberately unclaimed and says why: it runs because a mutation did not converge, so the preview is on bytes nobody can vouch for and the reload is the point. Verified live: a drag-shaped update-properties on the timeline now logs `suppressed: own write token` with no reload, where it logged a coordinator reload before. * refactor(studio): keep timelineTimingSync under the size cap Claiming the timeline writes pushed this file one line past the 600-line gate. Same change as the branch made later, landed with the commit that caused it. * fix(studio): cover remaining write receipt paths * fix(studio): preserve batch write receipts * fix(cli): emit every file in a watcher burst |
||
|
|
96861cbafc |
perf(studio-server): coordinate cancelable thumbnail generation (#2720)
* perf(studio): schedule adaptive timeline thumbnails * perf(studio): bound thumbnail decoding resources * perf(studio): virtualize timeline thumbnail media * perf(studio): prioritize timeline thumbnail work * perf(studio-server): coordinate cancelable thumbnail generation --------- Co-authored-by: Codex <codex@local> |
||
|
|
d0dbf11ef5 |
fix(producer,studio-server): finish the ffprobe argv sweep, pin the contract
The previous commit claimed "all nine now terminate their options". That was false: `producer/src/utils/audioRegression.ts:307` still passed the path bare, and it is production source used by the regression harness. A repo-wide audit found two more in studio-server (`mediaValidation.ts`, `mediaMetadata.ts`) — their current callers pass absolute paths, so they were defence-in-depth rather than live bugs, but the exhaustiveness claim should be true rather than narrowed. Eleven sites total, all terminated. Adds a SOURCE-level contract test, which is the gap that let this happen twice. #2740 fixed one of ten sites and shipped a regression asserting the argv of that single site, so CI reported the class closed while nine invocations still parsed `-intro.mp4` as an option. A per-site unit test has the same blind spot for site twelve; scanning the tree does not. The test also asserts its own coverage list has not shrunk. Verification: engine 1300, lint 511, core 1431, studio-server 398, cli init/webmAlphaCheck/whisper 146, producer utils 51, audioPadTrim 18. Removing any single terminator fails the contract test by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
71fd96bbf1 |
Merge pull request #2854 from heygen-com/feat/canary-rollouts
feat(core): percentage-based canary rollouts + calibration experiment |
||
|
|
4e7fcf7f2a |
fix(core): resolve sub-composition sibling asset paths everywhere (#2994)
Extends the studio-preview fix to the render path and the asset-discovery utilities, which share the same resolver and had the same defect. `rewriteAssetPath` takes an optional `assetExists` probe. A plain relative ref authored in a sub-composition (`_shared.css`, `clip.mp4`) is re-pointed at the composition's own directory when that sibling exists on disk; project-root refs with no sibling (the registry's `assets/logo.png` convention) stay as authored. Callers that can see the filesystem supply the probe, so the module stays free of node:fs. Also fixes a second defect in the inliner: `<head>` <link> hrefs and external script srcs are hoisted into the root document but never went through the rewrite at all, so even the documented `../` form escaped the project and 404'd at render time. Wired into the preview bundler, the producer compiler, the studio preview builder, the HEVC preview lint, the project lint's asset scans, publish proxy baking, and media-treatment source resolution. |
||
|
|
cbd8a77d16 |
fix(studio): resolve sibling asset paths in sub-composition previews (#2983)
Fixes #2956 ## What A composition in a subdirectory that references a **sibling** file (`<link rel="stylesheet" href="_shared.css">`, not `../_shared.css`) is now resolved against the composition's own directory when building the standalone sub-composition preview page. ## Why The preview page borrows the project-root `<base href="/api/projects/:id/preview/">`, but the path rewriter only rewrote `../`-prefixed paths. So `design/styleframes/frame-01.html` referencing `_shared.css` was served unrewritten and the browser requested `/preview/_shared.css` → **404**. With its stylesheet missing, the frame renders unstyled: `body` has no background, and the thumbnail generator's transparent-body fallback paints it `#1c2028`. Result: dark navy thumbnail with unreadable dark text on every styleframe in the Board view. The report attributed this to project scale (~21 sibling files). It is not scale-related: a 2-file project reproduces identically, and the same file moved to the project root renders correctly. The trigger is **composition-in-a-subdirectory + relative sibling asset ref**. Reproduced before the fix (single `curl` against the thumbnail endpoint, plus a direct headless capture of the preview URL): ``` HTTP 404 http://localhost:5190/api/projects/big/preview/_shared.css body bg: rgb(28, 32, 40) ``` ## How `resolvePreviewAssetPath` in `packages/studio-server/src/helpers/subComposition.ts`, applied through the single rewrite pass all three dispatch branches (template / full-doc / fragment) already share, so `src`, `href`, inline `style` urls, and `<style>` blocks all get the same rule: 1. `../` paths keep resolving against the composition dir (unchanged, shared with the producer's inliner so preview and render agree). 2. Any other relative path is re-pointed at the composition's directory **only when that sibling file exists on disk**. The disk check is what keeps the two conventions apart: registry blocks are installed into a subdirectory but reference project-root assets (`assets/logo.png`), which are already correct under the root base and have no sibling on disk, so they are left untouched. Not changed: the `#1c2028` transparent-body fallback in the thumbnail generator. It is correct for genuinely transparent compositions; the illegibility was a downstream symptom of the 404. ## Test plan - [x] Unit tests added/updated — two tests in `subComposition.test.ts`: a red-first regression guard for the sibling `<link>` / `<img>` / `url()` case, and a guard that project-root-relative refs with no sibling on disk stay untouched. - [x] Manual testing performed — reproduced the dark thumbnail on a generated project (21-file and 2-file variants both reproduce), then confirmed the same URL renders the white-to-lavender gradient with legible text after the fix, with no 404 in the network log. - [x] `packages/studio-server` suite green: 29 files / 402 tests. Lint, format, typecheck clean. - [ ] Documentation updated — n/a |
||
|
|
1d01b9f2cf | fix(studio): exclude generated caches from project metadata | ||
|
|
6f0df2640b |
fix(cli,studio,core): close five R5 telemetry and canary findings
- A long-lived preview cached its telemetry posture in two places (readConfig and shouldTrack). Running `telemetry disable` in another terminal left it resolving canaries and injecting the CLI id for hours. Both caches are now dropped together at a request boundary. - Studio minted and shipped a telemetry id for every render regardless of the browser profile's opt-out, and the server emitted the outcome under CLI policy, which cannot see localStorage or DNT. The browser now sends an explicit telemetryOptOut, distinct from an old client's omission. - Any non-empty HYPERFRAMES_PREVIEW_HOST disabled the DNS-rebinding guard, so even a loopback bind accepted a hostile Host. The guard now holds for loopback binds and, on a LAN bind, admits only names this machine answers on. - sunsetAfter had no reader of the current date. A scheduled workflow runs scripts/check-canary-sunset.ts weekly, so a failure lands on the rollout's owner rather than on an unrelated PR author. - The install-state seed memo outlived `rm -rf ~/.hyperframes`, resurrecting a cleared cohort. Removed; it only saved a read on a readConfig cache miss. Docs updated for the Host rule and the 100% exclusion carve-out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5a6e4b1a8f |
refactor(lint): consolidate asset-src placeholder skip into a shared predicate (#2894)
* refactor(lint): route asset-src skips through a shared isUnresolvedAssetPlaceholder predicate Follow-up to the templating-token fix. The __UPPER__ + templating-token skip was copy-pasted across the asset-src sites and had drifted: two non-lint sites carried only the __UPPER__ half, and htmlCompiler's comment still claimed it "matches lint's skip" after lint's skip became a superset. Extract one isUnresolvedAssetPlaceholder(rawSrc) in @hyperframes/parsers/asset-resolution (both placeholder shapes, checked on the raw value) and route every site through it: the four project.ts lint sites, hevcPreviewLint, and the two previously-missed post-substitution sites (studio-server mediaCodecMap, producer htmlCompiler). Remote/inline handling stays per-site (audio uses a narrower check). Behavior-preserving for the lint sites (full suite green); the two non-lint sites are post-substitution so they don't false-positive today, but now share one definition and can't drift again. Adds unit tests for the predicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(parsers): refresh hasUnresolvedTemplatingToken aside for the shared predicate The parenthetical said the __UPPER__ shape keeps its own inline check at each call site; this branch folded it into isUnresolvedAssetPlaceholder, so point there instead. Addresses review nit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d57039882f | fix(studio): harden keyframe editing semantics | ||
|
|
7778c093b6 |
fix(preview): serve external symlink assets (#2764)
## What Allow Studio Preview to serve an asset reached through a project-local symlink whose target is in a shared directory outside the project, including browser-hostile video assets that need an authoring proxy. ## Why Preview rejected these assets with a 404 while the renderer accepted the same path. The initial static-route fix still failed for HEVC, ProRes, AV1, and VP9 assets because the proxy transcoder rejected the external target. ## How Use lexical project-root containment for the read-only static asset route and proxy source request. The transcoder canonicalizes the target for ffmpeg and includes that identity in its cache key, while keeping the proxy cache inside the project. Composition source paths retain canonical containment because preview can persist their data-hf-id values. ## Test plan - [x] Unit tests added/updated - [x] `bun run --cwd packages/studio-server test` (397 tests) - [x] Studio Server typecheck, oxlint, and oxfmt - [x] External-symlinked hostile-video proxy route regression - [x] Static-route traversal regression - [ ] Documentation updated (not applicable) |
||
|
|
562544f68d | refactor(core): own edit protocol contract | ||
|
|
8dd7e3dfc5 | fix(studio): keep atomic Acorn splits Recast-free | ||
|
|
022e260ac3 | fix(studio): read writer environment explicitly | ||
|
|
710a8f1db2 | fix(studio): select writer for atomic cuts | ||
|
|
43788c6dda | refactor(studio): gate Acorn writer migration | ||
|
|
e64a22893a |
fix(studio): preserve alpha proxy playback (#2625)
* fix(studio): preserve alpha proxy playback * test(cli): pin VP8 alpha proxy pre-resolution * fix(studio): emit textarea field-sizing CSS * test(studio): verify textarea CSS generation |
||
|
|
2577aaffeb |
fix(studio): ignore stale failed sidecars for existing renders (#2621)
* fix(studio): ignore stale failed sidecars for existing renders * style: format stale render metadata test |
||
|
|
2b65b4efce |
fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity * feat(studio): drag compositions into the timeline * fix(studio): collapse expanded composition move aliases * fix(studio): make timeline cuts atomic * fix(studio): group inspector gesture history * test(studio): cover masked text selection * fix(studio): harden composition timeline reliability * fix(studio): satisfy CI source gates * fix(studio): harden composition mutation requests |
||
|
|
1ddf9cc331 |
Merge pull request #2563 from heygen-com/via/thumbnail-id-escape
fix(studio,runtime): CSS.escape ids so digit-leading selectors don't crash |
||
|
|
e8371a7acc |
feat(media): alpha-capable authoring proxies (#2598)
* feat(media): alpha-capable authoring proxies Alpha sources were refused a proxy before the codec map ever asked whether the browser could decode them, so a ProRes 4444 alpha file (which no browser previews at all) rendered black forever, while an alpha WebM (which previews fine) was already covered by the browser-safe check on the next line. The alpha veto earned nothing and cost the one case that needed help. Alpha is now a target-codec choice rather than a veto: alpha sources transcode to VP9 + yuva420p in WebM, everything else keeps the existing H.264/MP4 path byte for byte. Only files no browser can preview are proxied, which is the rule the runtime already followed everywhere else. WebM cannot carry AAC, so the VP9 path uses Opus and drops the MP4-only faststart flag. PROXY_PARAMS_VERSION moves to v3 so clients stop serving the previously cached proxies. Safari does not decode VP9 alpha and still shows black for alpha sources, as it does today: this is better on Chromium and Firefox and no worse anywhere. * fix(media): infer proxy variant for rescue * fix(media): preserve alpha proxy hardening after restack |
||
|
|
74b4f1e8c3 |
feat(cli): serve proxies from play and the static project server (#2593)
* feat(studio-server): serve H.264 proxies from the preview route Wires the codec manifest and the transcoder into the preview surface: the route negotiates a proxy via a query param and serves it through the existing range and ETag machinery, composition HTML carries a codec map for the runtime, and hostile assets pre-warm so a first play does not wait on a cold transcode. Exposes the three subpath exports the CLI surfaces consume upstack. Drops the TEMP fallow entry added with the transcoder: it has real importers now. * fix(studio-server): publish media proxy exports * fix(parsers): scan HTML comments linearly * feat(cli): let projects opt out of automatic proxying Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and forwards the resolved value into the studio and preview servers and the vite adapter. Lands before the runtime slice that turns auto-proxying on, so the switch exists before there is any behavior to switch off. * fix(cli): align media config schema * feat(core): swap undecodable video to its proxy at runtime Adds the browser-side half: before first load the runtime consults the injected codec map and swaps a hostile source to its proxy, and if a video still reports zero decodable width it rescues it reactively. An HEVC file carrying AAC fires no error event, so zero videoWidth, not the error event, is the reliable signal. Audio elements and alpha sources are never proxied, render mode never proxies, and each swap evicts the element's stale sync state and reports once. This completes the loop: auto-proxying is live for preview and studio from here. The opt-out (media.autoProxy, --no-proxy) shipped in the previous slice. * feat(cli): serve proxies from play, present, and the static project server Adds proxy negotiation to the CLI-side servers and gives play byte-range serving it never had, so a swapped video can seek. The static project server behind check, snapshot, compare and friends injects the codec map once, so all of its callers inherit the behavior; snapshot forwards its own proxy flag. * fix(cli): serve proxies for camera formats |
||
|
|
67eab59f44 |
feat(studio-server): serve H.264 proxies from the preview route (#2590)
* feat(studio-server): serve H.264 proxies from the preview route Wires the codec manifest and the transcoder into the preview surface: the route negotiates a proxy via a query param and serves it through the existing range and ETag machinery, composition HTML carries a codec map for the runtime, and hostile assets pre-warm so a first play does not wait on a cold transcode. Exposes the three subpath exports the CLI surfaces consume upstack. Drops the TEMP fallow entry added with the transcoder: it has real importers now. * fix(studio-server): publish media proxy exports * fix(parsers): scan HTML comments linearly |
||
|
|
9d148d288a |
feat(studio-server): transcode bounded H.264 proxies on demand (#2589)
* feat(studio-server): bound the proxy cache with LRU accounting Adds cache accounting and bounded cleanup for transcoded proxies, and keeps .transcode-cache out of git. Standalone: the transcoder consumes it next. * feat(studio-server): transcode bounded H.264 proxies on demand Adds the proxy transcoder: a bounded work queue with per-key dedupe, a hard kill ceiling, TTL'd failure memory so a broken asset is not retried forever, and pixel/color normalization for browser playback. Writes through a temp name and renames on success, so a cache entry is never partial. Carries a TEMP fallow ignoreExports entry: this module lands below its consumers, so a per-PR audit sees its exports as unused until the preview weld arrives. The entry is dropped there. |
||
|
|
1d3d20450d |
feat(studio-server): bound the proxy cache with LRU accounting (#2588)
Adds cache accounting and bounded cleanup for transcoded proxies, and keeps .transcode-cache out of git. Standalone: the transcoder consumes it next. |
||
|
|
9ca1e17101 |
feat(studio-server): probe media codec facts for proxy decisions (#2587)
* feat(studio-server): probe media codec facts for proxy decisions Adds the codec manifest: one ffprobe-backed answer to what codec an asset uses, whether a browser can decode it, and whether it carries alpha. Migrates the existing prober to the shared ff-binaries resolver and to async execFile so a scan pool runs off the event loop. No consumer yet; the preview weld wires it up later in the stack. * fix(studio-server): honor injected ffprobe runners |
||
|
|
7980479083 | fix: preserve shared FFmpeg resolver behavior | ||
|
|
9bbdcc4ec9 |
fix(studio,runtime): CSS.escape ids so digit-leading selectors don't crash
The runtime picker built raw `#${id}` selectors while its sibling
attribute-selector branches (data-composition-id, data-composition-src,
data-track-index) already CSS.escape'd their values. When a user
composition has an element with a digit-leading id (e.g. `id="0"`),
the picker emits the selector `#0` which is invalid per the CSS spec —
downstream `document.querySelector` throws SyntaxError.
Same failure mode reached the Studio thumbnail: getElementScreenshotClip
called `document.querySelectorAll(selector)` unguarded, so an invalid
selector bubbling out of page.evaluate failed the whole thumbnail and
returned 500 to the browser (broken thumbnail image).
Fixes:
- packages/core/src/runtime/picker.ts — CSS.escape the id, matching the
sibling branches on lines 100/102/104.
- packages/studio-server/src/helpers/screenshotClip.ts — catch
SyntaxError from an invalid selector and return undefined so the
caller falls back to a full-page screenshot, so the user still sees
a thumbnail instead of a broken image.
Regression tests for both.
Reported via #hf-cli-feedback (Slack ts=1784218060, darwin/arm64,
CLI 0.7.60): "digit-leading worker IDs broke Studio thumbnail
querySelectorAll".
— Via
|
||
|
|
ff26e5f2c8 |
Merge pull request #2529 from heygen-com/via/resolution-portrait-fix
fix(cli): accept portrait aspects for --resolution alias flag |
||
|
|
2d398ed274 |
fix(cli): wire aspect-agnostic resolution through cloudrun/lambda/batch + preflight recompute
Addresses R2 CHANGES_REQUESTED from Miga + Rames on PR #2529: 1. Sibling-surface gap (blocker): `hyperframes cloudrun render{,-batch}`, `hyperframes lambda render{,-batch}` all advertised the same tier-only aliases (`1080p` / `hd` / `4k` / `uhd`) but normalized them to `landscape` and never set `outputResolutionAspectAgnostic`. The distributed plumbing PR #2529 added received `undefined` from those callers, so portrait `1080p` still hit the original aspect-mismatch on Cloud Run / Lambda. Fix: introduce `resolveResolutionFlagPair` in `@hyperframes/parsers` (the single source of truth for the two-step normalize + aspect-agnostic detect) and route every distributed entrypoint through a shared `parseOutputResolutionFlag` CLI util so the alias signal now reaches `SerializableDistributedRenderConfig`. Studio Server keeps its canonical-only HTTP contract; that intent is now pinned in tests. 2. Preflight recompute (hardening): the earlier "downgrade aspect-mismatch" preflight cleared un-remapped mismatches, so IG 4:5 (non-preset aspect, no sibling) and portrait-4K comp + `--resolution 1080p` (remap + downsample) both slipped through to fail late in `resolveDeviceScaleFactor`. Now `checkRenderResolutionPreflight` computes the effective preset via `suggestMatchingPreset` (mirroring the compile stage's `adaptAspectAgnosticResolution`) and re-checks against that — only genuinely-fixable mismatches clear early. New tests pin both regressed input classes. 3. Docker forwarding boundary test (Miga's important #2): pinned `1080p` survives verbatim as `--resolution 1080p` in the Docker args so the in-container CLI can re-run `isAspectAgnosticResolutionAlias`. 4. Doc-nit (Miga): parsers/src/types.ts no longer references the nonexistent `resolveResolutionForComposition` — points at the actual remap helpers. Fallow: cloudrun.ts / lambda.ts share 390 lines of pre-existing structural symmetry (parallel AWS + GCP dispatchers), and lambda/render.ts + render-batch.ts declare parallel RenderArgs interfaces. Both re-flagged after threading the aspect-agnostic field through each surface; ignored with justification in .fallowrc.jsonc. lambda.ts's `run` and lambda/render.ts's `waitForCompletion` are pre-existing CRAP-score hotspots untouched by this PR — added under health.ignore. Co-Authored-By: Claude <noreply@anthropic.com> — Via |
||
|
|
21cb722ebd |
refactor(core): unify composition contract (#2157)
* refactor(core): unify composition contract * fix(parsers): parse start expressions linearly |
||
|
|
2417293dab |
fix(studio): enforce optimistic file concurrency (#2156)
* fix(studio): enforce optimistic file concurrency * fix(studio): harden conditional file writes * fix(studio): honor explicit file preconditions * test(producer): allow zero-ms encode timing |
||
|
|
42055296ee | fix(studio): make sdk cutover transactional (#2155) | ||
|
|
b9be0b2625 |
feat(skills,studio,media-use): the intent layer, review loop, and user memory — BRIEF.md, companion mode, recipes; /website-to-video folds into /product-launch-video (#2133)
* feat(studio,cli): per-frame board comments, self-refreshing storyboard, status-aware preview landing Per-frame comment boxes on the storyboard board batch into .hyperframes/frame-comments.json (a resubmit wins per frame; unconsumed comments on other frames are kept). Submitted-but-unconsumed comments stay visible — a toolbar banner plus a per-tile echo — until the agent consumes the file; the banner also says what to do next (reply anything in the agent chat). The board keeps itself current: GET /projects/:id/signature exposes the watcher-cached project signature, the storyboard payload carries the signature it was derived from, and the view polls at 2s (hidden tabs skipped, re-checked on visibility), refetching in place with no loading flash. Posters bake the signature into their URL so tiles fill in as sketches land and a poster that failed mid-write retries on the next version; the empty state upgrades itself when STORYBOARD.md appears, and its handoff prompt now points the agent at the review loop and uses the parser's real status vocabulary (outline, not planned). preview lands the browser on the storyboard view while the board is the review surface — any frame built, or pure planning (srcs declared, none on disk yet) — and on the timeline once the video is assembled. * feat(skills): the review loop — plan, sketch, build as one shared process hyperframes-core/references/review-loop.md is the single source for the three-pass collaborative review: the plan proposed on a live board (§ 1), wireframe sketches marked built with one layout question (§ 2 — real words on plain blocks, run no CLI; a confirmed board is itself a valid deliverable when the user asked for a storyboard, not a video), the build dressing confirmed layouts (§ 3, worker or inline), and the final look (§ 4). Autonomous runs skip every gate and keep one question before render. The three narrative workflows' Steps 3/4/6 collapse to references plus their sketch stand-ins (captured-asset blocks for product-launch-video, plain code panels for pr-to-video); the confirmed-sketch handoff stays in each frame-worker prompt. general-video plans on a board for multi-scene narrative pieces in collaborative mode — its sketch pass is layout-before-animation with the user watching. The router treats "I want a storyboard" as a process request rather than a route, and closes exploratory intake by recommending a route plus how the run will review. The supporting contracts land next door: the comments channel (silent submit, one reply picks it up, check the file before the words) in brief-contract § 1; the sidecar schema and the built status rung in storyboard-format; the mode question asked first and alone in the three workflows' Step 0. * feat(media-use): user memory — remembered preferences and frozen recipes Two tiers of memory on media-use's existing two-tier storage split. Preferences (lightweight): confirmed brief answers — destination, aspect, language, mode, voice, style preset — recorded to the project's .media/preferences.json (committed, the team inherits it) and promoted to the personal ~/.media/preferences.json once the same value is confirmed in two different projects (a sightings ledger accumulates the cross-project evidence user-side, since project files can't see each other). prefs.mjs get/record; merge reads project-over-user; a changed value restarts its provenance. Recipes (heavyweight): one approved run frozen as a named, versioned bundle — frame.md, the storyboard skeleton (structure kept: durations, transitions, srcs, Video direction; statuses reset to outline; content blanked to per-frame fill-ins naming the beat's role), and the confirmed brief values. Named folders, not content hashes: re-freezing bumps version and archives <name>@v<N>; a freeze is already confirmed, so it promotes to the user tier immediately. recipe.mjs freeze/list/use, plus resolve --type recipe --entity <name> delegating like grade/lut. 16 new node --test cases; the media-use lib suite is 168/168. * feat(skills): wire user memory into the brief and the review loop brief-contract § 2 gains Remembered defaults: read the merged preferences before Round 2 and let a remembered value become the recommended option with a receipt naming its source project. Memory changes the default, never the question — every ask-marked field still gets asked, and what the request says this time beats what was picked last time. Record only what the user actually confirmed (a defaulted voice nobody chose is not an answer; a "go" that accepts the recommended defaults is). The first record announces itself once; after that the receipts carry the reminder. In autonomous mode a remembered value becomes the decided value, receipt included. The three narrative workflows read the remembered defaults before Round 2, record the confirmed answers at the Step 0 gate, record the chosen preset at the Step 2 gate (pr-to-video excepted — its preset is fixed), and fall back to the remembered voice when the request names none. general-video's discovery reads the same defaults. Recipes wire in at both ends: Step 0 checks for a matching recipe before the mode question — one question, plural-aware, and adopting one fills the brief, skips the design step, and drafts the storyboard from the frozen skeleton while every review gate still runs. The review loop's final look (§ 4) offers the freeze once after approval, and the confirmation teaches the recall phrase — the name is something the system reminds the user of, never something they must remember. The router recognizes a named recipe or "like last time" as a route. * docs(skills): the sketch pass names check, not the deprecated validate * feat(skills): intent-layer references — process, route briefs, capability menu, BRIEF.md format * feat(media-use): brief skeleton as the recipe's fourth artifact; flow/storyboard preference keys * feat(skills): the intent layer conducts every brief — workflows execute BRIEF.md * feat(skills): retire the mode preference key; sync catalog surfaces for intent layer * refactor(skills): dedupe router vs intent-layer guidance — one owner per rule * feat(skills): the design ask — own spec, pick by eye from showcases, or defer * docs(skills): the design ask says the honest line on capture routes * feat(skills): product-launch-video absorbs website-to-video as the tour angle * refactor(skills): keep product-launch-video pristine — a tour is brief intent, not a pipeline branch * feat(skills): production loop + genre lenses; general-video goes freeform (route yours, laws hold) * refactor(skills): /hyperframes is the front door - route tables and scope lists leave the workflows * docs(skills): review-loop pass across skill catalog * fix(cli): pass project dir to openStudioBrowser in background-server path * feat(skills): add pitch-round reference - verbalized sampling concept gate * feat(skills): wire pitch round into intent layer - completeness triage + route eligibility * feat(skills): editorial capability recommendations, handoff disciplines, menu-probe split * feat(skills): pitches carry their machinery; source-only-formed requests pitch the telling * feat(skills): companion goes director - ceiling treatment plus blueprint/rule citation discipline * fix(scripts): sandbox npx-leak guard - private npm global prefix keeps npx on the branch CLI * chore(skills): resync manifest hash after formatter pass reflowed general-video tables * fix(skills): recipe freeze reads workflow from BRIEF.md; style_preset records require workflow scope Two holes found by a live companion-run freeze: the agent-supplied --workflow contradicted the run's actual workflow (recipe.json said faceless-explainer, brief-skeleton said general-video), and the style_preset lookup missed because the preference had been recorded under the bare key. - freezeRecipe resolves the workflow from BRIEF.md frontmatter; the flag is a fallback for briefless projects and a contradicting flag is ignored (noted). - recordPreference refuses a bare style_preset — the scoped key is the only writable shape; freeze tolerates legacy bare records via read fallback. - review-loop § 4 / media-use SKILL / brief-format wording follow the machinery. |
||
|
|
017183ad66 |
fix(cli): keep render filename timestamp local (#2470)
* fix(cli): keep render filename date and time local * fix(render): share local output timestamps |
||
|
|
89db718899 |
feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order) (#2380)
* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides
Track order = default paint order; authored z = advanced override.
- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
timeline lane move — closest track in the action's direction that is free
over the clip's whole span, else a new lane adjacent to the crossed
neighbor; temporal-overlap scope (default pending product sign-off, see
module doc); visual zone only; same-file reference scoping; persistTrack
via the shared authored-space rules. null for non-clips (menu stays
z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
persists the lane move through the same machinery as a timeline lane drag
(optimistic store update, authoredTrack refresh, rollback); inserts reuse
commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
entry (test proves it over the real history reducer). The mirror never
triggers the lane->z stacking sync, so it cannot fight the z values the
action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
contradicts lane order among temporally-overlapping same-context visual
neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
a 'z' badge — authored z overrides are surfaced instead of silently
disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
(600-line cap).
* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists
Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).
zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.
* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu
- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
skip the GSAP fallback round-trip and the preview reload entirely — the
renderer never reads data-track-index, and the live DOM patch + optimistic
store update cover the UI. Mixed batches keep current behavior. Kills the
canvas blink on mirrored Bring/Send actions (live-verified: an
iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
directional arrow for Forward/Backward; pierced two-layer stack for
Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
clip and every clip after it on that lane left by the clicked gap's width;
leading gaps count, so a single clip with empty space before it compacts
to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
in timelineGaps.ts; persists through the drag path's atomic batch move
(one undo per action); refuses when a clip that must shift is locked;
items disable when there is nothing to close.
* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order
Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.
The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.
Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).
Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.
* fix(studio): bound forward/backward mirror to a one-element step
User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.
* feat(studio): permanent gap-menu rows with hover and click-select gap highlights
- TrackGapContextMenu always renders both rows; an inapplicable action dims
with a tooltip ("No gap here" / lock reason / "No gaps on this track")
instead of vanishing into a one-item menu. Width badge only when a gap
exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
timeline: the single gap for Close gap, every current gap (leading included)
for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
present-state intervals (epsilon-tolerant, overlap-safe), distinct from
resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
(suppressed for marquee multi-selection and during drags; the gap-menu hover
wins on its own lane). Derivation lives in useTimelineGapHighlights with the
pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
+ clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
plumbing (ResizeObserver width + shortcut-hint sync) into
useTimelineScrollViewport, behavior unchanged.
* feat(studio): stronger capcut-style timeline zoom steps
One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.
* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits
Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.
- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
the canvas menu's realization core via resolveZOrderReposition (one
between-z write when a strict gap exists, band-safe scoped renumber
otherwise) instead of computeReorderZValues' all-sibling stamp — that
helper is deleted, completing the #2347 unification follow-up. The drop
then mirrors into a timeline lane move through the same machinery as the
canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
strictly between its NEW paint neighbors' lanes — nearest clip siblings in
the desired render order, decorations skipped — else a track insert at
that boundary; audio zone never crossed). Both writes share one
per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
factored into useMirrorLaneMoveCommit and reused by the new
useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
bump no refreshKey, so the panel's z-sorted order went stale while paused.
handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
rollback; the panel re-collects on it. Verified live: the panel re-sorts
the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
hidden at the current frame (animation-parked opacity, non-clip
display/visibility hides, hidden ancestors) temporarily forces the chain
visible with live inline styles — exact priors restored on deselect, on
another reveal, on play, and on unmount; never persisted (file diff == 0
verified live). Clips keep the existing seek-into-window behavior; the
override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.
Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.
* feat(studio): full-track selection highlight, borderless gap hover strips
- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
gap, inter-clip gaps, and the open space after the last clip to the rendered
end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
derivation). Still click-only: any drag/resize suppresses the strips, and a
marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
nudged to 0.18 alpha to keep the same visual weight.
* feat(studio): selected layer paints on top via a reader-transparent z lift
Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:
- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
writes a far-top inline z; a static element gets a layout-preserving
position:relative with its prior parked in data-hf-reveal-prior-pos. Only
the RENDERER sees the lift: all three studio z readers
(readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
the parked prior while the attribute is present, so the canvas z-menu, the
zOverride badge, the lane mirror, the stacking sync, and the panel sort
keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
play / unmount, each property only while it still holds the value the
override wrote (a later real edit is never clobbered). File diff == 0
verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
reads the parked position for its persist-position:relative static check,
then drops the attributes) — the committed z becomes the truth and the
later restore is a guarded no-op.
* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path
Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:
1. Master-view path gate: activeCompPath is NULL at the master view, so the
'paths[0] === activeCompPath' eligibility check could never match the
index.html restore and every default-view undo full-reloaded at the first
gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
element's innerHTML, but the composition root wraps every clip — any child
change re-detected at the root rejected the restore. Change detection now
compares only each element's OWN attribute surface; structure/text
integrity is still guaranteed by the normalize-residual whole-doc pass
(text nodes, added/removed elements, and un-identified attrs all remain
after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
fell outside the diff entirely. Identity is now id OR data-hf-id, with the
live sync resolving either.
Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.
Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.
* feat(studio): left breathing pad before t=0, double zoom sensitivity again
TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.
- The lanes and the ruler realize it as a plain flow spacer between the
sticky gutter cell and the time-mapped content div, so every
content-relative computation (clip left = t*pps, beat lines, lane-menu
time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
gap strips, drop placeholder, snap guide, range highlight, marquee clip
rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
center exactly under the pointer.
Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.
* fix(studio): left pad renders as true empty space, not lane surface
The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.
* fix(studio): no vertical line in the ruler band before 00:00
The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.
* refactor(studio): remove the timeline z-override badge
User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.
* fix(studio): collision-free expanded child lanes and host-window gap floors
Review findings D1 (blocker) and 4.
- D1: buildChildElements assigned expanded children synthetic display rows as
`host.track + index` — integers that can EQUAL a real clip's lane in another
file (host on 0 with two children puts child #2 on 1). Lane grouping merges
purely by track number, so the collision fused clips from different source
files into one display lane, and lane-scoped actions (the gap menu) then
batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
between the host's lane and the next integer — structurally unable to
collide with any normalized lane, while still rendering as ordered rows
under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
display time is host-anchored — close/compact could drag it before its host
window and persist a wrong (even negative) local time. All gap functions
now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
expandedParentStart for child lanes — single-origin per lane post-D1),
threaded through the menu model, hover highlights, selected-lane strips,
and both commits. Close-gap shifts clamp at the gap's own left edge.
* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity
Review findings 1, 2, and 3.
- Finding 1: buildTrackInsertEdits normalized the FULL display set and
persisted every shifted clip — writing host-lane numbers into OTHER
composition files when expanded children were showing. The renumber write
set is now the edited element's own source file (the sanctioned multi-write
converges one FILE to lane space, never neighbors' files); foreign clips
keep their authored tracks and re-derive display lanes. The locked-clip
refusal scopes the same way. Expanded-origin elements refuse the insert
outright (a new lane is a host-space renumber, meaningless in the child's
file), and the mirrors restrict an expanded child's lane candidates to its
own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
(persisting the sibling's authored track) but can never land on a host lane
with no same-file occupant. authoredTrackForLane's offset fallback rounds:
fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
file can contain several CSS stacking contexts and leaf z is only
comparable within one. Both resolvers now scope by samePaintScope — same
source file AND same stackingContextId (the file check also stops null root
contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
duplicate class selectors (.sub) resolved to occurrence 0 — a different
clip. The key now carries getSelectorIndex, matching how z-reorder entries
derive theirs.
* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists
Review findings 5 and 7.
- Finding 5: commitDomEditPatchBatches resolved successfully even when the
server matched NO patch target — the z write never reached disk (the
preview reloads to reconverge) yet the lane mirror still ran, desyncing
track order from what actually paints. The commit now resolves a durability
report ({allMatched, changed}; the save queue and commit types are generic
over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
rides the timeline/SDK path — two queues, so a second rapid gesture's z
write could land BETWEEN the first gesture's z and lane phases. Every
z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
through runZLaneGesture: a single module-level tail that serializes the
COMPLETE two-phase transaction, with unit tests for ordering, the
durability gate, and queue resilience to failed gestures. The timeline
lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).
* fix(studio): multi-clip GSAP batch mutations roll back on late failure
Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.
* fix(studio): scope mirror inserts to their lane zone
* fix(studio): unify source-scoped clip identity
* fix(studio): isolate track insert topology
* fix(studio): harden timeline paint synchronization
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
|
||
|
|
df29fa7a5e |
feat(studio): revamps Studio + improves code quality (#2291)
* feat(studio): glue API coexistence layer for the NLE swap What: extends 21 glue files so the OLD timeline/canvas engine and the NEW NLE components type-check side by side: playerStore (multi-select setters, zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/ timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props gain optional callbacks/params, contexts gain *Optional hooks, and TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting both engines' change shapes. patchDocumentRootDuration's test rides along. Why: this is the keystone that dissolves the old "welded glue" problem — every symbol the NLE components need is ADDED next to what the old engine still uses, so the engine components and the swaps can land as separate reviewable PRs. How: 15 authored intermediate files (main content + additive symbols; no behavior changes — new fields optional, new callbacks unused until wired) plus 6 files whose final content is already purely additive. New exports without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed by the app-shell swap. Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines compile); bunx vitest run (full suite green incl. the 6 new patchDocumentRootDuration tests); fallow audit clean. * feat(studio): timeline interaction hooks and lanes component (unwired) What: the timeline-side wiring layer, unwired: TimelineLanes (the lane renderer driving drag/resize/marquee), timelineMarquee (+tests), useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning, useTimelineEditingDrops. Why: everything between the pure drag math and <Timeline> itself; the timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these. How: new files, tsc-clean against the coexistence layer. Unwired components carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): NLE shell assembly (unwired) What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. * feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine What: flips the timeline glue to its final form (23 files): Timeline and TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag drives preview/commit through the new drag engine, range selection goes multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9 old-engine files this orphans (group drag, marquee selection, snap targets, layer gutter, selection overlays + their suites) — each is compile- or gate-forced by this swap, verified by probe. Why: second swap step; timeline-only, canvas and App untouched. How: modified files to final content + forced deletions. playerStore/timelineEditing/timelineCallbacks stay at their coexistence form until the app swap (the old App still runs on them). Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean. * feat(studio): clip thumbnail modules What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode with SVG/AVIF format fallbacks and rounded-corner clipping — plus VideoThumbnail updates. Why: the decode layer for timeline clip thumbnails, ahead of the visual refresh that renders them. How: new modules + one modified file; purely presentational. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. * feat(studio): assets/blocks panel behaviors + preview helpers What: blocks tab install flow, right-panel and global drag-overlay polish, music beat analysis and clip-content rendering hooks, and the preview-helper utilities backing asset preview. Why: completes the studio NLE stack on top of the visual refresh. How: modified files only (kept as one PR: splitting further would produce sub-150-LOC fragments of interdependent panel glue). Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc --noEmit; fallow audit clean. * fix(studio): restore timeline playback loop * fix(studio): restore missing GSAP helpers module * refactor(studio): split timeline GSAP helpers * style(studio): keep timeline helper under size limit * fix(studio): restore timeline overlays module * fix(studio): remove stale GSAP import * fix(studio): restore canonical timeline dependencies * style(studio): format restored timeline helpers * style(studio): satisfy helper line limit * fix(studio): repair rebuilt timeline integration * feat(studio): complete rebuilt NLE cutover * fix(studio): guard project and timeline race boundaries * fix(studio): preserve graded resize and crop geometry * fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local * fix(studio): treat duration-0 tweens as static holds and settle resize position before persist Instant holds (to()/fromTo() with duration 0) were classified as animated tweens by every commit route, so resizing or rotating them converted the hold into a corrupt duration-0 keyframes tween (new value at 0%, old at 100%) that GSAP drops; panel edits appended a losing set. A shared isInstantHold() now routes them through the static replace-in-place path, and percentage math guards zero-duration windows. Separately, anchored-corner resizes painted 3-5 frames at the new size but old position while the offset persist round-tripped the server. The commit path now applies the corrected GSAP position synchronously before awaiting the offset persist, mirroring the scale route's settle. * feat(studio): gesture-transaction seam with commit observability Introduce runGestureTransaction — one owner for a gesture commit's settle -> persist -> record lifecycle. It settles the live DOM synchronously before any async persist, folds every mutation into one undo entry via a per-transaction coalesceKey, restores pre-gesture state exactly once on failure, and asserts (dev console) + reports (PostHog: commit_transaction / commit_invariant_violation / commit_transaction_failed) that a persist never changes pixels. The box-size resize path is migrated onto it; the ad hoc per-route coalesceKey/reload handling is removed. Extract the resize draft-rect math into resizeDraft.ts to keep the gesture-handler file under the size cap. Also: keep url_hash telemetry to the route slug only (drop the query string, which carried the user's selected element id/selector), and gate the [hf-resize] diagnostics behind localStorage hf-resize-debug so they ship as opt-in tracing rather than console noise. * fix(studio): transaction owns the undo label The coalesced history entry took the last sub-mutation's label, so a resize surfaced as "Move layer" (the offset persist) in undo/redo. The seam now stamps tx.label on every wrapped mutation, so the folded entry reads as the gesture. * fix(studio): atomic static size/position commits (no data loss) Static resize/position holds updated an existing set via delete+add — two undo entries, and a delete that succeeded before a failed add lost the hold on disk. Use one in-place update-properties mutation when a set exists (one undo entry, no partial-failure window). The keyframed-hold heal that can't be expressed as a property update now adds before it deletes, so any single failure leaves a recoverable duplicate, never a lost hold. Transaction-owned commits are tracked via a WeakSet so the heal path never double-wraps an already-wrapped gesture. * fix(core): restore timed-clip visibility after a forced timeline rebind __hfForceTimelineRebind force-rendered the re-registered timeline but never re-ran the per-[data-start] visibility pass, so after undo or soft reload every clip rendered regardless of its time window until a full page reload. Extract the visibility loop into syncTimedElementVisibility and call it from both syncMediaForCurrentState (unchanged) and the rebind. * fix(studio): atomic z-order/keyframe/split commits, one undo entry each Three edit-commit paths hardened onto the one-transaction invariant: - Z-order reorder (useElementLifecycleOps): N per-element writes now fold into one undo entry (coalesceMs Infinity) and, on a failed persist, restore already-written files to disk so no partial reorder survives. - Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate convert phase no longer full-reloads the preview (skipReload), killing the black-flash remount; convert + edit share one coalesce key = one undo entry. - Razor split-all (useRazorSplit): snapshot before the batch and restore on any failure, so a mid-batch error never leaves un-revertable partial splits. Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams, readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit logic across these paths. Commit options thread as one partial object rather than field-by-field. Test setup extracted into colocated helpers. * fix(studio): fold multi-step edits into one undo entry; guard text revert - Gesture recording (useGestureCommit): the per-property-group commits now share one coalesce key and only the last reloads, so a recording is one undo entry and one preview reload instead of up to four. - Delete selected keyframes (deleteSelectedKeyframes, split out of timelineEditingHelpers): N removals fold into one coalesced undo entry with a single reload. - Text-field commit (useDomEditTextCommits): commitDomTextFields now uses the same version-guarded revert as handleDomTextCommit, so a stale failed commit can no longer stomp a newer successful one. * feat(studio): batch a gesture's mutations into one atomic server write A transaction that emits N mutations previously did N sequential POSTs, each rewriting the file and soft-reloading — the root of the multi-phase persist window. Add a gsap-mutations-batch endpoint that validates every mutation up front, applies them in one in-memory rewrite chain, and writes the file once (all-or-nothing: an invalid entry rejects the whole batch, no partial write). The seam buffers a transaction's commits and, when more than one targets the same file, dispatches a single batch — one write, one history entry, one reload. The batch capability rides on the existing commit-function reference; no option fields are threaded through callers. * fix(studio): soften off-canvas indicator outline to 30% opacity The dashed off-canvas selection outline at 60% was noisy with many protruding elements on screen; drop the resting opacity to 30% (hover still restores full opacity so it stays discoverable). * fix(studio): drop off-canvas indicator outline to 10% opacity Follow-up to the 30% softening — 10% resting opacity reads much calmer with many protruding elements; hover still restores full opacity. * fix(studio): gate [hf-commit] console traces to dev only The start/settled/persisted/restore lifecycle traces logged on every gesture commit in all environments — console noise for end users. Route them through a dev-only traceCommit helper (matching the pixel-violation error's existing DEV gate). The commit_* PostHog events stay always on; they are the production observability, the console lines are a dev aid. * fix(studio): count actual reloads, not softReload requests, in commit telemetry A resize's size and offset persists both request softReload; the seam counted each request, so a batched gesture reported reload_count 2 even though the batch is one write and one reload. Compute the count from what dispatchBufferedCommits actually did — one for a batch, the request count for the sequential fallback. * fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop - Hover overlay applied the element's rotation only to the selection chrome, not the hover box; it now rotates about center like the selection, via a shared orientedGroupAwareOverlayRect router (one owner for rotation-aware overlay geometry across hover/selection/off-canvas). - Off-canvas indicator was axis-aligned; it now rotates with the element and inverse-rotates the canvas-exclusion clip into the element's local frame, so the protruding-sliver clip stays correct for rotated elements. - Crop commit re-lifted the element only in the commit's .then(), so one frame painted the cropped state (the flicker). Re-lift synchronously right after onStyleCommit (which applies the clip before its first await), so the cropped state never paints; the persisted file value is unchanged. * fix(studio): address code-review findings across the commit-hardening campaign Correctness (would ship green, bite under latency): - Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert folds into one undo entry instead of splitting past the 300ms default. - The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the field); multi-keyframe delete and convert coalesce correctly when SDK-routed. - Razor split-all's rollback is guarded so a failing restore can't swallow the error toast that tells the user the split failed. Simplification (single source of truth / no dead flexibility): - Decompose resolveResizeDraftRect (drops a fallow-ignore suppression). - Delegate the third readProjectFileContent copy to the shared helper. - Inline setPatchFromUpdateProperties (its only caller passes one mutation). - One toSdkPersistOptions translates gesture overrides to SDK options. - Bundle the reorder-rollback deps into one object (was 7-9 positional args). - Dedupe the 'last group reloads' ternary; type gesture options as CommitMutationOptions; drop a Map+array wrapper around a single write. * feat(studio): atomic z-order reorder via batch patch-element endpoint Z-order reorder issued N per-element inline-style patches (one server write each), so a mid-chain failure could leave a partial reorder on disk. Add a patch-elements-batch endpoint that validates every patch, folds them over the file in one in-memory rewrite, and writes once (all-or-nothing; unsafe input rejects with no write). The reorder now sends one batch per source file and records one undo entry. Because a failed atomic write persists nothing, the interim disk-write-back rollback (restoreReorderedFile / restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure rolls back only live DOM/store state. Closes the last disk-atomicity gap. * fix(studio): razor-split undo no longer silently no-ops The split clone was written to disk without a data-hf-id, so the split endpoint recorded that unstamped HTML as the undo entry's afterHash. The next reloadPreview() ran the preview route's ensureHfIds write-back, which minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk hash no longer matched afterHash and editHistory's content-mismatch guard silently refused the undo (no write, no network, no error). Stamp the split output via ensureHfIds in splitElementInHtml before it is written/returned, so the preview write-back is a no-op and the recorded afterHash always equals the final on-disk bytes. Fixes at the source rather than relaxing the mismatch guard. Corrects the stale comment that credited forceReloadSdkSession. * feat(studio): closed-hand grab cursor on the rotate handle The rotate handle used the default arrow cursor; show a grabbing (closed-hand) cursor on hover to signal it's grabbed and dragged to rotate. * fix(studio): dropping a dragged element over another no longer selects it A moved drag's release fired the box click, which re-selected whatever now sat under the pointer via the hover cache — so dropping an element over a higher-z one selected the drop target instead of keeping the dragged element selected. The drag-move branch now suppresses the next box click, mirroring the resize branch. * fix(studio): group drag is one undo entry, not one per element Dragging a multi-selected group committed each member's position write as its own undo entry, so reverting took N Cmd+Z presses. Force a shared coalesceKey (infinite window) across every member's commit so they fold into a single undo entry, like the other multi-step commit paths. * fix(studio): undo of a split no longer leaves a ghost clip in the timeline The file and the composition iframe revert correctly on undo, but the timeline panel kept a ghost node for the split clone. The element-merge that repopulates the timeline preserves elements the fresh scan dropped — intended for enriched sub-composition children a bare DOM re-scan misses, but it also preserved a genuinely-removed TOP-LEVEL element (the split clone after undo), leaving a phantom clip. Restrict the preserve to elements with a compositionSrc (the enriched sub-comp children); a top-level element missing from the fresh scan was truly removed. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com> |
||
|
|
c15819fe88 |
feat(studio-server): files route extensions
What: the studio-server files route at its final NLE-stack form, with its test suite (25 tests). Why: standalone package seam — the server-side dependency of the studio asset workflow, reviewable in isolation. How: additive route behavior; existing route consumers unchanged. Test plan: bunx vitest run src/routes/files.test.ts in packages/studio-server; tsc --noEmit in packages/studio-server; fallow audit clean. |
||
|
|
a8f86e653d |
Merge pull request #2068 from heygen-com/worktree-fix-timeline-zindex-reorder
feat(studio): lane-model timeline — vertical drag restacks via z-index |
||
|
|
267b289bb8 |
feat(studio): bind selected element properties to variables
Ninth PR of the template-variables stack: the promote-a-property gesture.
Select an element on the canvas/timeline, open the Variables tab, and the
panel offers per-property bind actions.
- "Bind selected" card in the Variables panel, built from the selection:
image/media source (img/video/audio), text, text color, background, and
font. Each action declares a variable whose default is the element's
CURRENT value (promoting never changes the render — computed rgb colors
convert to hex, the first computed font family becomes the font default)
and writes the declarative binding the runtime resolves: data-var-src /
data-var-text attributes or `<prop>: var(--id)` styles. Declare + bind
run as one batched schema edit (one undo step); binding to an
already-declared id skips the declare and just binds.
- guarded to selections from the composition the session models — a
selection in another source file never writes bindings into this one.
- core: extract readVariablesForElement into runtime/variableScope.ts,
shared by color grading and the declarative bindings (was duplicated).
- fix(studio-server): buildSubCompositionHtml's extractElementAttrs
rebuilt html/body attributes without HTML-escaping values, shredding
quote-bearing attributes — data-composition-variables (a JSON array)
came out as mangled bogus attributes, so getVariables() silently
returned {} on every /preview/comp/* page (no declared defaults, no
runtime bindings). Pre-existing bug surfaced by live-testing this
feature; regression test added.
Verified end-to-end in a live session: select headline → Bind text color
→ declaration + var(--headline-color) written to disk → override in the
panel → runtime applies the custom prop and the element renders the
override.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
2e0b884521 |
feat(studio-server): preview variable injection + render variables forwarding
Fourth PR of the template-variables Studio stack — the HTTP plumbing.
- preview routes (/preview and /preview/comp/*) accept
?variables=<url-encoded json> and inject
`window.__hfVariables = {...}` into <head>, before the runtime and any
composition script — the exact global the engine sets via
evaluateOnNewDocument at render time, so preview-with-values cannot
diverge from render output. Values are escaped against </script>
breakout, malformed payloads 400 instead of silently previewing
defaults, and the ETag is salted with a hash of the payload so cached
previews revalidate when values change.
- POST /projects/:id/render accepts variables ({variableId: value}) and
forwards them through StudioApiAdapter.startRender into the producer's
RenderConfig.variables — the same channel `hyperframes render
--variables` uses. Wired in both adapters (CLI embedded server + vite
dev adapter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e4c4d2e15d |
fix(studio-server): address PR #2097 review feedback on relative-timing resolver
Documents the shared-pattern context (3rd copy of "resolve relative data-start", after runtime startResolver.ts and the SDK's own getElementTimings) and explains when the raw parseFloat fallback in resolveStart's else branch can actually fire (a malformed grammar string with a leading number). Adds a test pinning the "reference target exists but its own timing is unresolvable" branch, which existing tests didn't cover (only "target doesn't exist" was tested). Cross-checked the negative-offset clamp concern raised in review: the SDK's own resolveReferenceStart (session.ts) also clamps to Math.max(0, ...), so this stays consistent with its sibling — no code change needed there. |
||
|
|
fbd21d5709 |
fix(studio-server): previewAdapter getElementTimings ignores relative data-start refs
Same bug class as the SDK's getElementTimings fix (#2092): data-start can be a relative-reference expression ("intro", "intro + 2"), not just an absolute number. The old code did a raw parseFloat on it, so any reference silently resolved to undefined instead of an actual time. Also: this function never read data-duration at all (only data-start/data-end literally), so a reference to a duration-authored (not end-authored) clip was unresolvable regardless of the parseFloat bug — resolving a reference needs the target's END, which for a duration-authored clip requires start+duration. Both fixed together via the shared parseStartExpression grammar parser (@hyperframes/core/runtime/start-expression), with the same cycle-guard pattern as the SDK fix. Reference resolution against other elements is scoped to this file's existing findById (bare data-hf-id lookup). 6 new tests: duration-based end resolution, relative reference (with and without offset), missing target, and a mutual-cycle termination check. |
||
|
|
9f6c20e482 |
perf(studio): grow composition duration live on extend, no preview remount
Extending a clip past the video end used to force the server-fallback path that fully remounts the preview iframe (the SDK fast path can't express the root composition's data-duration, and the runtime bakes+drops data-duration at load so it can't be patched live). On a large comp that remount is a visible hitch. Add a runtime control-bridge action set-root-duration -> clock.setDuration, so the studio can grow the transport length in place. On an extend the studio now posts it (and patches the clip's own timing live) instead of reloading; it only reloads when a GSAP source rewrite actually happened (the gsap-mutation endpoints now report a mutated flag). Non-animated extends — the common case — commit as fast as a normal edit. Verified: bridge dispatch + studio no-reload/post-message paths unit- tested; core/studio/studio-server typecheck + suites green; the built runtime artifact carries the handler; E2E confirms the extend no longer remounts the preview and still persists. |
||
|
|
037266e72b |
feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI - Highlight clips visible at the playhead in the primary color; others share one neutral color - Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels - Per-track eye toggle and a per-element hide button in the design panel - Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom - Sticky gutter so track controls stay visible while scrolling WYSIWYG visibility (data-hidden) - Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview - HTML stays the source of truth; hide state persists and round-trips on reload Split several studio files to stay under the 600-line cap; pure relocations, no behavior change. |