Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
165b02c
fix(studio): an automation lane edit is refused on a locked clip and …
miguel-heygen Sep 26, 2026
d9aee16
fix(studio): a refused automation save puts the preview and the lane …
miguel-heygen Sep 26, 2026
82d10f4
fix(studio): a failed save restores the preview it was given, not an …
miguel-heygen Sep 26, 2026
8c61888
chore(repo): allow the automation gesture key files this branch removes
miguel-heygen Sep 26, 2026
bd808b9
fix(studio): a refused automation save leaves the lane store and past…
miguel-heygen Sep 26, 2026
d0b3d75
fix(studio): a failed save settles on what the file holds and a refus…
miguel-heygen Sep 26, 2026
b71748f
fix(studio): a failed save reads the file only after writes queued to…
miguel-heygen Sep 26, 2026
3df671e
fix(studio): an older failed save never settles over a newer edit on …
miguel-heygen Sep 26, 2026
a8ec94a
test(studio): a newer live drag keeps its preview when an older save …
miguel-heygen Sep 26, 2026
6adb9f1
fix(studio): every finished save records what the file holds, even un…
miguel-heygen Sep 26, 2026
25d49f3
fix(studio): an older automation save never overrides a newer gesture…
miguel-heygen Sep 26, 2026
bcc57fb
fix(studio): timeline attribute saves land in the order they start
miguel-heygen Sep 26, 2026
4805b89
refactor(studio): name the in-queue history write for the queue it needs
miguel-heygen Sep 26, 2026
c2d8076
test(studio): pin save start order and a newer release's preview mid-…
miguel-heygen Sep 26, 2026
0d286fb
fix(studio): a drag keeps its points when an older save lands under it
miguel-heygen Sep 26, 2026
c5288a7
fix(studio): a lane's first failed save still puts its drag back
miguel-heygen Sep 26, 2026
b983d71
fix(studio): only a before-value read with no save pending counts as …
miguel-heygen Sep 26, 2026
3d87126
fix(studio): a lane's saved value never crosses into another project
miguel-heygen Sep 26, 2026
31769fb
fix(studio): a failed save reads its file back from its own project
miguel-heygen Sep 26, 2026
472a2f3
fix(studio): a failed save never brings back a value an undo removed
miguel-heygen Sep 26, 2026
f52d21c
test(studio): pass the file-read callback in the direct attribute-sav…
miguel-heygen Sep 26, 2026
7b6bffd
fix(studio): a drag whose before-value a save settled counts as clean
miguel-heygen Sep 26, 2026
a485199
fix(studio): a save started in one composition never lands in another…
miguel-heygen Sep 27, 2026
f428600
fix(studio): an undo during or after a lane gesture stays undone when…
miguel-heygen Sep 27, 2026
3102620
fix(studio): undoing group automation clears it from the members' lan…
miguel-heygen Sep 27, 2026
3c88a9f
fix(studio): an undo reaches every lane and pending save it rewrote, …
miguel-heygen Sep 27, 2026
c5732b7
fix(studio): a cancelled fade, stretch or preset audition leaves no s…
miguel-heygen Sep 27, 2026
bcbdd97
test(studio): pin that a group write is read back fresh in the same task
miguel-heygen Sep 27, 2026
e02674d
style(studio): drop two comments the names already carry
miguel-heygen Sep 27, 2026
4358375
fix(studio): a failed group save reads its own file back; fade cancel…
miguel-heygen Sep 27, 2026
f424851
refactor(studio): trim three long comments on live automation saves
miguel-heygen Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
handleDuplicate,
onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
onAfterUndoRedo: (restore) => {
invalidateGsapCacheRef.current();
timelineEditing.restoreLiveLanes(restore);
},
onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(),
onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
activeCompPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe("TimelineFxPopover", () => {
act(() => {
button?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN);
expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN, true);
});

it("Escape closes without letting the keystroke propagate past the popover", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface TimelineFxPopoverProps {
* selected clip's attribute — the caller resolves which). */
onChainChange: (next: HfAudioFxChain) => void;
/** Preview a hypothetical chain on the running graph without persisting. */
onChainPreview?: (next: HfAudioFxChain) => void;
onChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
onAuditionTransport?: (on: boolean) => void;
/** Select the target the way clicking it in the timeline does, and ensure
* the property panel's Audio FX group is expanded. */
Expand Down
6 changes: 3 additions & 3 deletions packages/studio/src/components/editor/useFxAudition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";

export function useFxAudition(
chain: HfAudioFxChain,
onChainPreview: ((chain: HfAudioFxChain) => void) | undefined,
onChainPreview: ((chain: HfAudioFxChain, ended?: boolean) => void) | undefined,
onAuditionTransport: ((on: boolean) => void) | undefined,
) {
/**
Expand Down Expand Up @@ -45,7 +45,7 @@ export function useFxAudition(
// Stop before reverting, for the mirror of that reason — the last thing
// heard should be the preset, not a frame of the chain coming back.
onAuditionTransport?.(false);
onChainPreview(auditionBase.current);
onChainPreview(auditionBase.current, true);
auditionBase.current = null;
}
},
Expand Down Expand Up @@ -96,7 +96,7 @@ export function useFxAudition(
() => () => {
if (auditionBase.current) {
transportRef.current?.(false);
previewRef.current?.(auditionBase.current);
previewRef.current?.(auditionBase.current, true);
}
},
[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface TimelineEditCallbackDeps {
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
setAudioGroupAttribute: {
setLive: (groupId: string, attr: string, value: string | null) => void;
revertLive?: (groupId: string, attr: string) => void;
setQuiet: (
groupId: string,
attr: string,
Expand All @@ -61,6 +62,7 @@ export interface TimelineEditCallbackDeps {
/** C1's single-clip FX write, addressed by the clip itself. */
setElementFxAttribute?: {
setLive: (element: TimelineElement, attr: string, value: string | null) => void;
revertLive?: (element: TimelineElement, attr: string) => void;
setQuiet: (
element: TimelineElement,
attr: string,
Expand Down Expand Up @@ -216,9 +218,11 @@ export function useTimelineEditCallbacks({
onToggleTrackHidden: handleToggleTrackHidden,
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
onRevertAudioGroupAttributeLive: setAudioGroupAttribute.revertLive,
onGroupClips: handleGroupClips,
onSetElementAttributeLive: setElementFxAttribute?.setLive,
onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
onRevertElementAttributeLive: setElementFxAttribute?.revertLive,
onBlockedEditAttempt: handleBlockedTimelineEdit,
onSplitElement: handleTimelineElementSplit,
onRazorSplit: handleRazorSplit,
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/contexts/DomEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface DomEditActionsValue extends Pick<
| "handlePreviewCanvasPointerLeave"
| "applyDomSelection"
| "clearDomSelection"
| "refreshDomEditSelectionFromPreview"
| "handleDomStyleCommit"
| "handleDomStyleCommitForSelection"
| "handleDomAttributeCommit"
Expand Down Expand Up @@ -155,6 +156,7 @@ export function DomEditProvider({
handlePreviewCanvasPointerLeave,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
handleDomStyleCommit,
handleDomStyleCommitForSelection,
handleDomAttributeCommit,
Expand Down Expand Up @@ -248,6 +250,7 @@ export function DomEditProvider({
handlePreviewCanvasPointerLeave,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
handleDomStyleCommit,
handleDomStyleCommitForSelection,
handleDomAttributeCommit,
Expand Down Expand Up @@ -322,6 +325,7 @@ export function DomEditProvider({
handlePreviewCanvasPointerLeave,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
handleDomStyleCommit,
handleDomStyleCommitForSelection,
handleDomAttributeCommit,
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/contexts/TimelineEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ export function TimelineEditProvider({
value.onToggleTrackHidden,
value.onSetAudioGroupAttributeLive,
value.onSetAudioGroupAttributeQuiet,
value.onRevertAudioGroupAttributeLive,
value.onGroupClips,
value.onSetElementAttributeLive,
value.onSetElementAttributeQuiet,
value.onRevertElementAttributeLive,
value.onBlockedEditAttempt,
value.onSplitElement,
value.onRazorSplit,
Expand Down
147 changes: 147 additions & 0 deletions packages/studio/src/hooks/liveLanes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { useRef, useState } from "react";
import { readTargetAttribute, type PatchTarget } from "./timelineEditingHelpers";

/** Where a lane's value lives in source, so an undo can re-read it. */
export interface LiveLaneSource {
path: string;
target: PatchTarget | null;
attr: string;
}

/** The files an undo or redo rewrote. */
export interface LiveLaneRestore {
paths?: string[];
files?: Record<string, { restored: string }>;
}

/** Per-lane live-preview state: the value before a gesture, pending saves, the last verified value. */
function createLiveLanes(project: () => string, composition: () => string) {
const before = new Map<string, string | null>();
const sources = new Map<string, LiveLaneSource>();
const pending = new Map<string, Set<number>>();
const verified = new Map<string, string | null>();
// Lanes whose before-value was read with no save pending, so it is what the file holds.
const clean = new Set<string>();
let saves = 0;
const take = (key: string): string | null | undefined => {
const claimed = before.has(key) ? (before.get(key) ?? null) : undefined;
before.delete(key);
clean.delete(key);
if (!pending.has(key)) sources.delete(key);
return claimed;
};
const overtaken = (key: string, save: number): boolean =>
[...(pending.get(key) ?? [])].some((newer) => newer > save);
const rebase = (key: string, value: string | null | undefined): void => {
if (value === undefined) {
clean.delete(key);
verified.delete(key);
return;
}
if (before.has(key)) before.set(key, value);
if (pending.has(key)) verified.set(key, value);
if (pending.has(key)) clean.delete(key);
else clean.add(key);
};
const scoped = (laneKey: string): string => `${project()}\n${composition()}\n${laneKey}`;
return {
preview(laneKey: string, readCurrent: () => string | null, source: LiveLaneSource): void {
const key = scoped(laneKey);
if (before.has(key)) return;
before.set(key, readCurrent());
sources.set(key, source);
if (!pending.has(key)) clean.add(key);
},
// A save. Its settle records what the file holds: `saved`, else the last value verified on
// this lane (a landed save, a read-back, or a clean before-value), never an unsaved one.
// Store and preview follow only while no newer save is pending and its composition is open,
// and the preview only while no drag is live.
claim(laneKey: string, apply: LiveLaneApply, source: LiveLaneSource): LiveLaneSave {
const key = scoped(laneKey);
const open = () => scoped(laneKey) === key;
const trusted = clean.has(key);
const claimed = take(key);
const mine = ++saves;
sources.set(key, source);
if (claimed !== undefined && trusted) verified.set(key, claimed);
pending.set(key, (pending.get(key) ?? new Set<number>()).add(mine));
const preview = (value: string | null) => {
if (open() && !before.has(key) && !overtaken(key, mine)) apply.preview(value);
};
return {
preview,
read(value) {
verified.set(key, value);
},
settle(saved) {
const inFlight = pending.get(key);
inFlight?.delete(mine);
if (saved !== undefined) verified.set(key, saved);
const value = verified.get(key);
// With no save left in flight the file can change under it (undo, an outside edit).
if (!inFlight?.size) {
pending.delete(key);
verified.delete(key);
if (!before.has(key)) sources.delete(key);
}
if (value === undefined || overtaken(key, mine) || !open()) return;
if (before.has(key)) {
before.set(key, value);
clean.add(key);
}
apply.store(value);
preview(value);
},
};
},
// A gesture refused before it saved: put its before-value back, which hands the lane
// back to any save still pending on it.
revert(laneKey: string, apply: LiveLaneApply): void {
const claimed = take(scoped(laneKey));
if (claimed === undefined) return;
apply.preview(claimed);
apply.store(claimed);
},
// An undo or redo rewrote files under the project's live gestures and pending saves, in any
// composition: each takes what the restored file holds, or stops trusting a value it cannot read.
restore({ paths = [], files }: LiveLaneRestore): void {
const inProject = `${project()}\n`;
for (const [key, source] of sources) {
if (key.startsWith(inProject) && paths.includes(source.path)) {
rebase(key, restoredValue(source, files));
}
}
},
};
}

function restoredValue({ path, target, attr }: LiveLaneSource, files: LiveLaneRestore["files"]) {
const html = files?.[path]?.restored;
return html === undefined || !target ? undefined : readTargetAttribute(html, target, attr);
}

/** A hook's live lanes, scoped to the project and composition open when each save starts. */
export function useLiveLanes(
projectIdRef: { readonly current: string | null },
activeCompPath: string | null,
) {
const compositionRef = useRef(activeCompPath);
compositionRef.current = activeCompPath;
const [lanes] = useState(() =>
createLiveLanes(
() => projectIdRef.current ?? "",
() => compositionRef.current ?? "",
),
);
return lanes;
}

interface LiveLaneSave {
preview: (value: string | null) => void;
read: (value: string | null) => void;
settle: (saved?: string | null) => void;
}
interface LiveLaneApply {
preview: (value: string | null) => void;
store: (value: string | null) => void;
}
Loading
Loading