mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): an automation lane edit is refused on a locked clip and reports how it saved (#4533)
* fix(studio): an automation lane edit is refused on a locked clip and reports how it saved Lane drags, Delete and paste save through the timeline's gated clip or group save instead of the property panel's commit, so the host's canEdit applies and the commit resolves saved, refused or failed. A landed save refreshes the lane store and the panel's selection, as the panel's commit did. * fix(studio): a refused automation save puts the preview and the lane back A save that does not land (recording, no id, a failed write) reverts the live preview it was given, the lane drops a draft whose save came back refused or failed, the no-id and no-project failures toast like the others, and the panel refresh reads the current selection. The panel commit's unused coalesce option goes, and the lane value reuses automationAttrValue. * fix(studio): a failed save restores the preview it was given, not an older one Each timeline save claims the preview's before-value when it starts, so a gesture begun while it is in flight records its own and a later failed save cannot put back a value from before an earlier save landed. * chore(repo): allow the automation gesture key files this branch removes * fix(studio): a refused automation save leaves the lane store and paste mark as saved Reverting a live value re-syncs the lane store from the restored preview, so an overlapping save that landed first cannot leave the store holding a later value that failed. A paste whose save is refused or fails gives back the selection and paste chain it replaced. * fix(studio): a failed save settles on what the file holds and a refused paste marks nothing A save that does not land settles the preview and store on the file's value (the claimed before-value only when the file cannot be read), and leaves a gesture begun meanwhile alone apart from correcting its before-value, so two overlapping failed saves cannot leave either on a value that never landed. A paste selects and marks its span only once it lands and only over the selection it was pasted at. * fix(studio): a failed save reads the file only after writes queued to it land The settle read goes through the same per-path queue as the writes, so a save that fails while another is queued settles the preview, the store and any newer gesture's before-value on what that queued save wrote. * fix(studio): an older failed save never settles over a newer edit on the same lane Live-preview bookkeeping moves to createLiveLanes, one owner for both timeline saves: each lane key carries a generation that every live write and save start advances, and a save settles the preview and store (on its landed value, or on the file after a failure) only while it is still the newest. A paste marks its span only if it is still the latest paste. * test(studio): a newer live drag keeps its preview when an older save recovers * fix(studio): every finished save records what the file holds, even under a newer edit A save's settle now always writes the file's value (landed, or read back after a failure) into the lane store and a live gesture's before-value, and moves the preview only while nothing newer has touched the lane. The clip store is set from that value rather than copied from the preview. A paste marks its span only while its clip is still the selected one. * fix(studio): an older automation save never overrides a newer gesture on its lane A failed save's recovery read could settle after a newer save started, and the save's own optimistic preview patch and failure unwind ran over a newer drag. Every preview write a save makes now goes through the lane's ownership check, only the newest save records its value, and a refused drag hands the lane back to the newest pending save. * fix(studio): timeline attribute saves land in the order they start An attribute save read the file before joining its write queue, so an older save with a slow read could land after a newer one, overwrite it on disk and leave the lane showing the newer value. The save now joins the queue first and reads, patches and writes inside it. A save moves the preview only while it is the newest save and no drag is live, which replaces the per-lane generation. * refactor(studio): name the in-queue history write for the queue it needs * test(studio): pin save start order and a newer release's preview mid-flight * fix(studio): a drag keeps its points when an older save lands under it An older save that landed mid-drag wrote the lane store, the lane dropped its draft, and the release committed the older points. The lane now keeps its draft while a gesture is live. A save that cannot read the file back settles on the last value read or saved on its lane, never on an unsaved one, and an older save that lands records its value there even when a newer save is pending. * fix(studio): a lane's first failed save still puts its drag back With the claimed-value fallback gone, a first save on a lane that failed and could not read the file back settled on nothing, leaving the drag playing. A save begun with no other save pending now records the value under its drag as verified, since that is what the file last settled on. * fix(studio): only a before-value read with no save pending counts as saved The value under a drag was trusted when its save started with nothing pending, but it was read when the drag began, possibly over a paste that later failed. A lane now marks a before-value clean only when no save was pending as the drag began. A group lane's preview carries its store mirror, as its live writes do. * fix(studio): a lane's saved value never crosses into another project The lane bookkeeping outlives a project switch and its keys had no project, so a value saved in one project could settle into another project's clip with the same key when a save there failed and could not read the file back. Lane keys now carry the open project, and a save moves the preview or store only while the project it was made in is still open. * fix(studio): a failed save reads its file back from its own project A save that failed after a project switch read its recovery from the newly opened project and recorded that project's value as its own. Each save now captures its project when it starts and reads back from it. * fix(studio): a failed save never brings back a value an undo removed A lane kept its last verified value after its saves settled, so after an undo a save that failed and could not read the file back settled on the removed value. The value now lives only while a save on the lane is in flight, and each save records what its own in-queue file read found. The group preview's store mirror is no longer needed and is gone. * test(studio): pass the file-read callback in the direct attribute-save tests * fix(studio): a drag whose before-value a save settled counts as clean When a settle rewrites a live drag's before-value to a verified value, that value is known saved, so the drag's own save can fall back on it even when its read and read-back both fail. Adds a test for a failed save reading back from its own project while another save is in flight across a switch. * fix(studio): a save started in one composition never lands in another of the same project Live lanes were scoped to the project only, so a save held across a switch to another composition of that project settled its value into the new composition's preview (clip and group lanes) and store (group lane). Lanes are now scoped to the project and the composition open when the save starts. createLiveLanes moves to its own module for the 600-line cap. * fix(studio): an undo during or after a lane gesture stays undone when the next save fails A live gesture records the lane's value before it starts, and a later save that cannot read the file settles on that value. An undo mid-drag, or after a gesture that ended without saving, left that value stale, so the save brought back what the undo removed. Every undo and redo now re-reads each open lane's value from the files it restored. * fix(studio): undoing group automation clears it from the members' lanes at once The group-info cache only noticed a change when its MutationObserver callback ran, so the soft restore's store sync, which reads it synchronously, mirrored the pre-undo group automation onto the members. A read now counts mutations the observer has not delivered yet. * fix(studio): an undo reaches every lane and pending save it rewrote, in any composition The undo re-read only covered lanes in the open composition and live gestures. History is project-wide, so undoing from another composition left the rewritten composition's lanes on the pre-undo value, and a save released while the undo was in flight kept that value as its fallback. The re-read now covers every lane of the project on a restored path, including saves still pending, and an undone file it cannot read makes the value untrusted instead of dropped. A missing element reads as unreadable, the same as a save's read-back. * fix(studio): a cancelled fade, stretch or preset audition leaves no stale lane value behind A cancel put the gesture's start back through a live write, which left the lane's before-value in place until the next save trusted it, even after the property panel, the source editor or an agent had changed the file. The fade cancel, the edge-stretch cancel and leaving a preset audition now end through the lanes' revert, so a before-value exists only during a live gesture. * test(studio): pin that a group write is read back fresh in the same task * style(studio): drop two comments the names already carry * fix(studio): a failed group save reads its own file back; fade cancels work without revert - The group save's failure read-back used the file of whatever composition was loaded when the write failed. It now reads the file captured when the save started, as the clip save does. - A fade cancel writes its start back live before it reverts, like the other cancel sites, so a host without the optional revert callback still puts the fade back. - The edge-stretch cancel comment says what the code now does. - The save test harness passes the revert callbacks, as the app does; without them the cancelled-gesture tests exercised a host that lacks them. * refactor(studio): trim three long comments on live automation saves
This commit is contained in:
@@ -261,7 +261,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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
/**
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export function useFxAudition(
|
||||
() => () => {
|
||||
if (auditionBase.current) {
|
||||
transportRef.current?.(false);
|
||||
previewRef.current?.(auditionBase.current);
|
||||
previewRef.current?.(auditionBase.current, true);
|
||||
}
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface DomEditActionsValue extends Pick<
|
||||
| "handlePreviewCanvasPointerLeave"
|
||||
| "applyDomSelection"
|
||||
| "clearDomSelection"
|
||||
| "refreshDomEditSelectionFromPreview"
|
||||
| "handleDomStyleCommit"
|
||||
| "handleDomStyleCommitForSelection"
|
||||
| "handleDomAttributeCommit"
|
||||
@@ -155,6 +156,7 @@ export function DomEditProvider({
|
||||
handlePreviewCanvasPointerLeave,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
handleDomStyleCommit,
|
||||
handleDomStyleCommitForSelection,
|
||||
handleDomAttributeCommit,
|
||||
@@ -248,6 +250,7 @@ export function DomEditProvider({
|
||||
handlePreviewCanvasPointerLeave,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
handleDomStyleCommit,
|
||||
handleDomStyleCommitForSelection,
|
||||
handleDomAttributeCommit,
|
||||
@@ -322,6 +325,7 @@ export function DomEditProvider({
|
||||
handlePreviewCanvasPointerLeave,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
handleDomStyleCommit,
|
||||
handleDomStyleCommitForSelection,
|
||||
handleDomAttributeCommit,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
failedTimelineSave,
|
||||
projectForTimelineSave,
|
||||
type TimelineEditOutcome,
|
||||
} from "./timelineEditPermission";
|
||||
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
||||
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
||||
import { usePlayerStore } from "../player";
|
||||
@@ -8,8 +12,10 @@ import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
||||
import {
|
||||
buildPatchTarget,
|
||||
persistElementAttribute,
|
||||
readSavedAttribute,
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
import { useLiveLanes, type LiveLaneRestore, type LiveLaneSource } from "./liveLanes";
|
||||
import type {
|
||||
MutableRef,
|
||||
UseTimelineElementVisibilityEditingInput,
|
||||
@@ -146,6 +152,8 @@ interface SetAudioGroupAttributeInput {
|
||||
value: string | null;
|
||||
label: string;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
patchLive: (value: string | null) => void;
|
||||
onFileRead: (value: string | null) => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
||||
@@ -157,6 +165,28 @@ interface SetAudioGroupAttributeInput {
|
||||
* `createAudioGroupAndAssignMembers`'s save shape but for a single element
|
||||
* and attribute rather than a member-assignment sweep.
|
||||
*/
|
||||
function groupSaveTarget(
|
||||
previewIframe: HTMLIFrameElement | null,
|
||||
groupId: string,
|
||||
activeCompPath: string | null,
|
||||
) {
|
||||
const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
|
||||
return {
|
||||
targetPath: resolveGroupSourceFile(groupEl) || activeCompPath || "index.html",
|
||||
patchTarget: buildPatchTarget({ domId: groupId }),
|
||||
};
|
||||
}
|
||||
|
||||
function groupLiveSource(
|
||||
previewIframe: HTMLIFrameElement | null,
|
||||
groupId: string,
|
||||
activeCompPath: string | null,
|
||||
attr: string,
|
||||
): LiveLaneSource {
|
||||
const { targetPath, patchTarget } = groupSaveTarget(previewIframe, groupId, activeCompPath);
|
||||
return { path: targetPath, target: patchTarget, attr };
|
||||
}
|
||||
|
||||
async function setAudioGroupAttribute({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -165,6 +195,8 @@ async function setAudioGroupAttribute({
|
||||
value,
|
||||
label,
|
||||
previewIframe,
|
||||
patchLive,
|
||||
onFileRead,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
pendingTimelineEditPathRef,
|
||||
@@ -177,9 +209,7 @@ async function setAudioGroupAttribute({
|
||||
// means `readTagSnippetByTarget` finds nothing and every mute, fader move and
|
||||
// FX preset throws "Unable to patch element in index.html". Every sibling
|
||||
// timeline writer already routes `element.sourceFile || activeCompPath`.
|
||||
const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
|
||||
const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html";
|
||||
const patchTarget = buildPatchTarget({ domId: groupId });
|
||||
const { targetPath, patchTarget } = groupSaveTarget(previewIframe, groupId, activeCompPath);
|
||||
if (!patchTarget) return null;
|
||||
|
||||
return persistElementAttribute({
|
||||
@@ -192,7 +222,8 @@ async function setAudioGroupAttribute({
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
|
||||
patchLive,
|
||||
onFileRead,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,42 +252,43 @@ export function useSetAudioGroupAttribute({
|
||||
label: string,
|
||||
) => Promise<TimelineEditOutcome>;
|
||||
revertLive: (groupId: string, attr: string) => void;
|
||||
restoreLive: (restore: LiveLaneRestore) => void;
|
||||
} {
|
||||
const liveBeforeRef = useRef(new Map<string, string | null>());
|
||||
const liveLanes = useLiveLanes(projectIdRef, activeCompPath);
|
||||
const setLive = useCallback(
|
||||
(groupId: string, attr: string, value: string | null) => {
|
||||
const key = audioGroupAttributeLiveKey(groupId, attr);
|
||||
const target = previewIframeRef.current?.contentDocument?.getElementById(groupId);
|
||||
if (!liveBeforeRef.current.has(key)) {
|
||||
liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
|
||||
}
|
||||
liveLanes.preview(
|
||||
key,
|
||||
() => target?.getAttribute(attr) ?? null,
|
||||
groupLiveSource(previewIframeRef.current, groupId, activeCompPath, attr),
|
||||
);
|
||||
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
|
||||
// Live too, not just on commit: a fader drag is `setLive` per frame and
|
||||
// `setQuiet` once on release, so without this the strip's own readout
|
||||
// fights the drag.
|
||||
syncStoredGroupAttribute(groupId, attr, value);
|
||||
},
|
||||
[liveLanes, previewIframeRef, activeCompPath],
|
||||
);
|
||||
const laneApply = useCallback(
|
||||
(groupId: string, attr: string) => ({
|
||||
preview: (value: string | null) =>
|
||||
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value),
|
||||
store: (value: string | null) => syncStoredGroupAttribute(groupId, attr, value),
|
||||
}),
|
||||
[previewIframeRef],
|
||||
);
|
||||
const claimLive = useCallback(
|
||||
(groupId: string, attr: string, source: LiveLaneSource) =>
|
||||
liveLanes.claim(audioGroupAttributeLiveKey(groupId, attr), laneApply(groupId, attr), source),
|
||||
[liveLanes, laneApply],
|
||||
);
|
||||
const revertLive = useCallback(
|
||||
(groupId: string, attr: string) => {
|
||||
const key = audioGroupAttributeLiveKey(groupId, attr);
|
||||
if (!liveBeforeRef.current.has(key)) return;
|
||||
patchLiveGroupAttribute(
|
||||
previewIframeRef.current,
|
||||
groupId,
|
||||
attr,
|
||||
liveBeforeRef.current.get(key) ?? null,
|
||||
);
|
||||
syncStoredGroupAttribute(
|
||||
groupId,
|
||||
attr,
|
||||
previewIframeRef.current?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ??
|
||||
null,
|
||||
);
|
||||
liveBeforeRef.current.delete(key);
|
||||
},
|
||||
[previewIframeRef],
|
||||
(groupId: string, attr: string) =>
|
||||
liveLanes.revert(audioGroupAttributeLiveKey(groupId, attr), laneApply(groupId, attr)),
|
||||
[liveLanes, laneApply],
|
||||
);
|
||||
const setQuiet = useCallback(
|
||||
async (
|
||||
@@ -265,8 +297,19 @@ export function useSetAudioGroupAttribute({
|
||||
value: string | null,
|
||||
label: string,
|
||||
): Promise<TimelineEditOutcome> => {
|
||||
const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast);
|
||||
if (typeof pid !== "string") return pid;
|
||||
const project = projectIdRef.current;
|
||||
const pid = projectForTimelineSave(isRecordingRef?.current, project, showToast);
|
||||
// Settles on what the file holds, so overlapping saves that fail cannot leave
|
||||
// the preview or the store on a value that never landed.
|
||||
const source = groupLiveSource(previewIframeRef.current, groupId, activeCompPath, attr);
|
||||
const live = claimLive(groupId, attr, source);
|
||||
const unsaved = async (outcome: TimelineEditOutcome): Promise<TimelineEditOutcome> => {
|
||||
live.settle(
|
||||
await readSavedAttribute(project, source.path, source.target, attr, writeProjectFile),
|
||||
);
|
||||
return outcome;
|
||||
};
|
||||
if (typeof pid !== "string") return unsaved(pid);
|
||||
try {
|
||||
const written = await setAudioGroupAttribute({
|
||||
projectId: pid,
|
||||
@@ -276,33 +319,24 @@ export function useSetAudioGroupAttribute({
|
||||
value,
|
||||
label,
|
||||
previewIframe: previewIframeRef.current,
|
||||
patchLive: live.preview,
|
||||
onFileRead: live.read,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
|
||||
if (!written) return { status: "failed", reason: "This group has no id to save it by" };
|
||||
syncStoredGroupAttribute(groupId, attr, value);
|
||||
if (!written)
|
||||
return unsaved(failedTimelineSave("This group has no id to save it by", showToast));
|
||||
live.settle(value);
|
||||
return { status: "saved" };
|
||||
} catch (error) {
|
||||
// `persistElementAttribute` leaves the live DOM at the previous value
|
||||
// however it failed — it unwinds a failed save, and an unresolvable
|
||||
// target now throws before patching at all. But `setLive` mirrored the
|
||||
// in-progress value into the store on every drag frame — so without
|
||||
// this the fader reads 0.4 while
|
||||
// the preview and the file are both back at 1.0, and nothing re-parses
|
||||
// to correct it (a live patch causing no parse is this mirror's whole
|
||||
// premise). Re-mirror from the DOM, which is now authoritative again.
|
||||
const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
|
||||
syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
|
||||
console.error("[Timeline] Failed to set group attribute", error);
|
||||
const message = error instanceof Error ? error.message : "Failed to update group";
|
||||
showToast(message);
|
||||
liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
|
||||
return { status: "failed", reason: message };
|
||||
return unsaved(failedTimelineSave(message, showToast));
|
||||
}
|
||||
},
|
||||
[
|
||||
claimLive,
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
writeProjectFile,
|
||||
@@ -313,5 +347,5 @@ export function useSetAudioGroupAttribute({
|
||||
projectIdRef,
|
||||
],
|
||||
);
|
||||
return { setLive, setQuiet, revertLive };
|
||||
return { setLive, setQuiet, revertLive, restoreLive: liveLanes.restore };
|
||||
}
|
||||
|
||||
@@ -21,7 +21,15 @@ export function projectForTimelineSave(
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return { status: "refused", reason: "Cannot edit timeline while recording" };
|
||||
}
|
||||
return projectId ?? { status: "failed", reason: "No project is open" };
|
||||
return projectId ?? failedTimelineSave("No project is open", showToast);
|
||||
}
|
||||
|
||||
export function failedTimelineSave(
|
||||
reason: string,
|
||||
showToast: (message: string, tone?: "error" | "info") => void,
|
||||
): TimelineEditOutcome {
|
||||
showToast(reason);
|
||||
return { status: "failed", reason };
|
||||
}
|
||||
|
||||
function timelineEditRefusal(
|
||||
|
||||
@@ -452,6 +452,7 @@ describe("persistElementAttribute", () => {
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(),
|
||||
pendingTimelineEditPathRef: { current: new Set() },
|
||||
onFileRead: vi.fn(),
|
||||
patchLive,
|
||||
}),
|
||||
).rejects.toThrow("Unable to patch element in index.html");
|
||||
@@ -495,6 +496,7 @@ describe("persistElementAttribute — unwind value", () => {
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(),
|
||||
pendingTimelineEditPathRef: { current: new Set() },
|
||||
onFileRead: vi.fn(),
|
||||
// The live DOM is ALREADY at the new value when the commit runs — that
|
||||
// is what `setLive` does on every drag frame.
|
||||
patchLive: (v) => patched.push(v),
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
furthestClipEndFromSource,
|
||||
getTimelineElementIdentity,
|
||||
} from "../player/lib/timelineElementHelpers";
|
||||
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
||||
import {
|
||||
saveProjectFilesWithHistory,
|
||||
writeProjectFilesWithHistoryInQueue,
|
||||
type RecordEditInput,
|
||||
} from "../utils/studioFileHistory";
|
||||
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
||||
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { readFileContent } from "./timelineTimingSync";
|
||||
@@ -396,6 +401,31 @@ export async function persistTimelineBatchEdit(
|
||||
});
|
||||
}
|
||||
|
||||
/** What the file holds for `attr` once queued writes to it land; undefined when unreadable. */
|
||||
export async function readSavedAttribute(
|
||||
projectId: string | null,
|
||||
targetPath: string,
|
||||
patchTarget: PatchTarget | null,
|
||||
attr: string,
|
||||
writeFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
||||
): Promise<string | null | undefined> {
|
||||
if (!projectId || !patchTarget) return undefined;
|
||||
const html = await serializeStudioFileMutations(writeFile, [targetPath], () =>
|
||||
readFileContent(projectId, targetPath),
|
||||
).catch(() => null);
|
||||
return html === null ? undefined : readTargetAttribute(html, patchTarget, attr);
|
||||
}
|
||||
|
||||
/** What `html` holds for `attr` on the target; undefined when the target is not in it. */
|
||||
export function readTargetAttribute(
|
||||
html: string,
|
||||
patchTarget: PatchTarget,
|
||||
attr: string,
|
||||
): string | null | undefined {
|
||||
if (readTagSnippetByTarget(html, patchTarget) === undefined) return undefined;
|
||||
return readAttributeByTarget(html, patchTarget, attr) ?? null;
|
||||
}
|
||||
|
||||
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
||||
|
||||
export { patchDocumentRootDuration } from "./timelineEditingGsap";
|
||||
@@ -412,6 +442,8 @@ export interface PersistElementAttributeInput {
|
||||
pendingTimelineEditPathRef: { current: Set<string> };
|
||||
/** Write the attribute directly on the live preview DOM node. */
|
||||
patchLive: (value: string | null) => void;
|
||||
/** What the file held for the attribute, read inside the queue before this write. */
|
||||
onFileRead: (value: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -433,47 +465,40 @@ export async function persistElementAttribute({
|
||||
recordEdit,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive,
|
||||
onFileRead,
|
||||
}: PersistElementAttributeInput): Promise<string[]> {
|
||||
// Resolve the target BEFORE patching the live DOM. The optimistic patch used
|
||||
// to run first, and only the save was wrapped in the unwind — so an
|
||||
// unresolvable target threw with the live preview (and, through the callers'
|
||||
// catch, the store mirrored off it) holding a value that never reached disk.
|
||||
// The write then read as successful until a reload dropped it.
|
||||
const before = await readFileContent(projectId, targetPath);
|
||||
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
||||
throw new Error(`Unable to patch element in ${targetPath}`);
|
||||
}
|
||||
// The unwind value comes from the FILE, not from `readLive()`.
|
||||
//
|
||||
// Every live-write caller patches the DOM before committing — a fader drag is
|
||||
// `setLive` per frame, hovering a preset auditions the whole chain — so by the
|
||||
// time this runs the live DOM already holds the in-progress value. Reading it
|
||||
// here made `previousValue === value`, so the unwind below was a no-op, and
|
||||
// `setQuiet`'s catch (which deliberately re-mirrors the store from the live
|
||||
// DOM) then mirrored that same never-saved value. The group audibly had the
|
||||
// preset, the panel agreed, and a reload dropped it — the failure class the
|
||||
// target check above was added to close, still open on the live-write path.
|
||||
const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
|
||||
patchLive(value);
|
||||
// Joins the file's mutation queue before reading, so saves land in the order they start
|
||||
// and each patches what the save before it wrote. Resolve the target before patching
|
||||
// the live DOM, so an unresolvable target never leaves an unsaved preview.
|
||||
return serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
|
||||
const before = await readFileContent(projectId, targetPath);
|
||||
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
||||
throw new Error(`Unable to patch element in ${targetPath}`);
|
||||
}
|
||||
// Unwind to the file's value: live writers already patched the DOM, so `readLive()`
|
||||
// would equal `value` and a failed save would keep a never-saved preview.
|
||||
const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
|
||||
onFileRead(previousValue);
|
||||
patchLive(value);
|
||||
|
||||
const operation: PatchOperation = { type: "attribute", property: attr, value };
|
||||
const patched = applyPatchByTarget(before, patchTarget, operation);
|
||||
const operation: PatchOperation = { type: "attribute", property: attr, value };
|
||||
const patched = applyPatchByTarget(before, patchTarget, operation);
|
||||
|
||||
pendingTimelineEditPathRef.current.add(targetPath);
|
||||
try {
|
||||
const changedPaths = await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label,
|
||||
files: { [targetPath]: patched },
|
||||
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
return changedPaths;
|
||||
} catch (error) {
|
||||
// The optimistic live write already ran; unwind it on a save failure so
|
||||
// the preview doesn't show a value that never reached disk.
|
||||
patchLive(previousValue);
|
||||
throw error;
|
||||
}
|
||||
pendingTimelineEditPathRef.current.add(targetPath);
|
||||
try {
|
||||
return await writeProjectFilesWithHistoryInQueue({
|
||||
projectId,
|
||||
label,
|
||||
files: { [targetPath]: patched },
|
||||
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
} catch (error) {
|
||||
// The optimistic live write already ran; unwind it on a save failure so
|
||||
// the preview doesn't show a value that never reached disk.
|
||||
patchLive(previousValue);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,18 +7,25 @@
|
||||
* shared core `setAudioGroupAttribute` also uses.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import {
|
||||
buildPatchTarget,
|
||||
findTimelineElementInIframe,
|
||||
persistElementAttribute,
|
||||
readSavedAttribute,
|
||||
} from "./timelineEditingHelpers";
|
||||
import { useLiveLanes, type LiveLaneRestore, type LiveLaneSource } from "./liveLanes";
|
||||
import type {
|
||||
MutableRef,
|
||||
UseTimelineElementVisibilityEditingInput,
|
||||
} from "./timelineTrackVisibility";
|
||||
import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
|
||||
import {
|
||||
failedTimelineSave,
|
||||
projectForTimelineSave,
|
||||
type TimelineEditOutcome,
|
||||
} from "./timelineEditPermission";
|
||||
import { syncStoredElementAttribute } from "../player/lib/automationStoreSync";
|
||||
|
||||
function patchLiveElementAttribute(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
@@ -41,6 +48,22 @@ function elementAttributeLiveKey(
|
||||
return `${element.sourceFile || activeCompPath || "index.html"}\0${element.key ?? element.domId ?? element.id}\0${attr}`;
|
||||
}
|
||||
|
||||
function elementSaveTarget(element: TimelineElement, activeCompPath: string | null) {
|
||||
return {
|
||||
targetPath: element.sourceFile || activeCompPath || "index.html",
|
||||
patchTarget: buildPatchTarget(element),
|
||||
};
|
||||
}
|
||||
|
||||
function elementLiveSource(
|
||||
element: TimelineElement,
|
||||
activeCompPath: string | null,
|
||||
attr: string,
|
||||
): LiveLaneSource {
|
||||
const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
|
||||
return { path: targetPath, target: patchTarget, attr };
|
||||
}
|
||||
|
||||
interface SetElementAttributeInput {
|
||||
projectId: string;
|
||||
activeCompPath: string | null;
|
||||
@@ -48,7 +71,8 @@ interface SetElementAttributeInput {
|
||||
attr: string;
|
||||
value: string | null;
|
||||
label: string;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
patchLive: (value: string | null) => void;
|
||||
onFileRead: (value: string | null) => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: Parameters<typeof persistElementAttribute>[0]["recordEdit"];
|
||||
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
||||
@@ -61,13 +85,13 @@ async function setElementAttribute({
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
previewIframe,
|
||||
patchLive,
|
||||
onFileRead,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
pendingTimelineEditPathRef,
|
||||
}: SetElementAttributeInput): Promise<string[] | null> {
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
|
||||
if (!patchTarget) return null;
|
||||
|
||||
return persistElementAttribute({
|
||||
@@ -80,7 +104,8 @@ async function setElementAttribute({
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath),
|
||||
patchLive,
|
||||
onFileRead,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,33 +127,46 @@ export function useSetElementAttribute({
|
||||
label: string,
|
||||
) => Promise<TimelineEditOutcome>;
|
||||
revertLive: (element: TimelineElement, attr: string) => void;
|
||||
restoreLive: (restore: LiveLaneRestore) => void;
|
||||
} {
|
||||
const liveBeforeRef = useRef(new Map<string, string | null>());
|
||||
const liveLanes = useLiveLanes(projectIdRef, activeCompPath);
|
||||
const setLive = useCallback(
|
||||
(element: TimelineElement, attr: string, value: string | null) => {
|
||||
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
||||
const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
|
||||
if (!liveBeforeRef.current.has(key)) {
|
||||
liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
|
||||
}
|
||||
liveLanes.preview(
|
||||
key,
|
||||
() => target?.getAttribute(attr) ?? null,
|
||||
elementLiveSource(element, activeCompPath, attr),
|
||||
);
|
||||
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
|
||||
},
|
||||
[liveLanes, previewIframeRef, activeCompPath],
|
||||
);
|
||||
const laneApply = useCallback(
|
||||
(element: TimelineElement, attr: string) => ({
|
||||
preview: (value: string | null) =>
|
||||
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath),
|
||||
store: (value: string | null) => syncStoredElementAttribute(element, attr, value),
|
||||
}),
|
||||
[previewIframeRef, activeCompPath],
|
||||
);
|
||||
const claimLive = useCallback(
|
||||
(element: TimelineElement, attr: string) =>
|
||||
liveLanes.claim(
|
||||
elementAttributeLiveKey(element, activeCompPath, attr),
|
||||
laneApply(element, attr),
|
||||
elementLiveSource(element, activeCompPath, attr),
|
||||
),
|
||||
[liveLanes, laneApply, activeCompPath],
|
||||
);
|
||||
const revertLive = useCallback(
|
||||
(element: TimelineElement, attr: string) => {
|
||||
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
||||
if (!liveBeforeRef.current.has(key)) return;
|
||||
patchLiveElementAttribute(
|
||||
previewIframeRef.current,
|
||||
element,
|
||||
attr,
|
||||
liveBeforeRef.current.get(key) ?? null,
|
||||
activeCompPath,
|
||||
);
|
||||
liveBeforeRef.current.delete(key);
|
||||
},
|
||||
[previewIframeRef, activeCompPath],
|
||||
(element: TimelineElement, attr: string) =>
|
||||
liveLanes.revert(
|
||||
elementAttributeLiveKey(element, activeCompPath, attr),
|
||||
laneApply(element, attr),
|
||||
),
|
||||
[liveLanes, laneApply, activeCompPath],
|
||||
);
|
||||
const setQuiet = useCallback(
|
||||
async (
|
||||
@@ -137,9 +175,17 @@ export function useSetElementAttribute({
|
||||
value: string | null,
|
||||
label: string,
|
||||
): Promise<TimelineEditOutcome> => {
|
||||
const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast);
|
||||
if (typeof pid !== "string") return pid;
|
||||
const liveKey = elementAttributeLiveKey(element, activeCompPath, attr);
|
||||
const project = projectIdRef.current;
|
||||
const pid = projectForTimelineSave(isRecordingRef?.current, project, showToast);
|
||||
const live = claimLive(element, attr);
|
||||
const unsaved = async (outcome: TimelineEditOutcome): Promise<TimelineEditOutcome> => {
|
||||
const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath);
|
||||
live.settle(
|
||||
await readSavedAttribute(project, targetPath, patchTarget, attr, writeProjectFile),
|
||||
);
|
||||
return outcome;
|
||||
};
|
||||
if (typeof pid !== "string") return unsaved(pid);
|
||||
try {
|
||||
const written = await setElementAttribute({
|
||||
projectId: pid,
|
||||
@@ -148,25 +194,25 @@ export function useSetElementAttribute({
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
previewIframe: previewIframeRef.current,
|
||||
patchLive: live.preview,
|
||||
onFileRead: live.read,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
liveBeforeRef.current.delete(liveKey);
|
||||
if (written) return { status: "saved" };
|
||||
return { status: "failed", reason: "This clip has no id to save it by" };
|
||||
if (!written)
|
||||
return unsaved(failedTimelineSave("This clip has no id to save it by", showToast));
|
||||
live.settle(value);
|
||||
return { status: "saved" };
|
||||
} catch (error) {
|
||||
console.error("[Timeline] Failed to set element attribute", error);
|
||||
const message = error instanceof Error ? error.message : "Failed to update effect";
|
||||
showToast(message);
|
||||
liveBeforeRef.current.delete(liveKey);
|
||||
return { status: "failed", reason: message };
|
||||
return unsaved(failedTimelineSave(message, showToast));
|
||||
}
|
||||
},
|
||||
[
|
||||
claimLive,
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
pendingTimelineEditPathRef,
|
||||
@@ -175,5 +221,5 @@ export function useSetElementAttribute({
|
||||
projectIdRef,
|
||||
],
|
||||
);
|
||||
return { setLive, setQuiet, revertLive };
|
||||
return { setLive, setQuiet, revertLive, restoreLive: liveLanes.restore };
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ interface UseAppHotkeysParams {
|
||||
handleDuplicate: () => Promise<boolean>;
|
||||
onResetKeyframes: () => boolean;
|
||||
onDeleteSelectedKeyframes: () => void;
|
||||
onAfterUndoRedo?: () => void;
|
||||
onAfterUndoRedo?: UseEditHistoryActionsOptions["onAfterUndoRedo"];
|
||||
onToggleRecording?: () => void;
|
||||
/** Group the current multi-selection into a data-hf-group wrapper (⌘G). */
|
||||
onGroupSelection?: () => void;
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
});
|
||||
|
||||
const setup = (binding: Partial<AutomationLaneBinding>) => {
|
||||
const onCommit = vi.fn();
|
||||
const onCommit = vi.fn().mockResolvedValue(undefined);
|
||||
const automation = {
|
||||
version: 1,
|
||||
lanes: [
|
||||
@@ -102,7 +102,6 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
onCommit,
|
||||
onSelect: vi.fn(),
|
||||
readOnly: false,
|
||||
commitTargetKey: "bgm",
|
||||
selection: null,
|
||||
onRangeSelect: vi.fn(),
|
||||
onRangeClear: vi.fn(),
|
||||
@@ -230,7 +229,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
expect(entry?.points.map((p) => p.t)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => {
|
||||
it("Cmd+V with no selection pastes at the playhead and selects the pasted span", async () => {
|
||||
clearAutomationClipboard();
|
||||
// Duration wide enough that the playhead (5s) is not clamped down by the
|
||||
// 0..duration-span bound — this is a paste-at-playhead test, not a
|
||||
@@ -255,6 +254,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
expect(times).toContain(7); // + clipboard span 2
|
||||
|
||||
// Pasting again immediately should land right after the first paste.
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().automationSelection).toEqual({
|
||||
elementKey: "bgm",
|
||||
target: "volume",
|
||||
@@ -268,7 +268,124 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("chains a second Cmd+V after the first instead of overwriting it", () => {
|
||||
it("marks nothing when the paste's save is refused, even for two quick presses", async () => {
|
||||
clearAutomationClipboard();
|
||||
usePlayerStore.setState({
|
||||
elements: [{ ...bgmElement, duration: 10 }],
|
||||
selectedElementId: "bgm",
|
||||
});
|
||||
const original = wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
|
||||
usePlayerStore.getState().setAutomationSelection(original);
|
||||
const onCommit = vi.fn().mockResolvedValue({ status: "refused", reason: "Locked" });
|
||||
setup({ onCommit });
|
||||
combo("c");
|
||||
combo("v");
|
||||
combo("v");
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().automationSelection).toEqual(original);
|
||||
combo("v");
|
||||
const again = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
|
||||
(p: { t: number }) => p.t,
|
||||
);
|
||||
expect(again).toContain(2);
|
||||
expect(again).not.toContain(6);
|
||||
});
|
||||
|
||||
it.each([{ status: "saved" as const }, { status: "refused" as const, reason: "Locked" }])(
|
||||
"keeps a selection drawn while a paste was saving ($status)",
|
||||
async (outcome) => {
|
||||
clearAutomationClipboard();
|
||||
usePlayerStore.setState({
|
||||
elements: [{ ...bgmElement, duration: 10 }],
|
||||
selectedElementId: "bgm",
|
||||
});
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
||||
let land = () => {};
|
||||
const onCommit = vi.fn(
|
||||
() =>
|
||||
new Promise<typeof outcome>((resolve) => {
|
||||
land = () => resolve(outcome);
|
||||
}),
|
||||
);
|
||||
setup({ onCommit });
|
||||
combo("c");
|
||||
combo("v");
|
||||
const drawn = wholeAxis({ elementKey: "bgm", target: "volume", t0: 6, t1: 8 });
|
||||
usePlayerStore.getState().setAutomationSelection(drawn);
|
||||
land();
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().automationSelection).toEqual(drawn);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["in order", "reversed"])(
|
||||
"marks the latest paste when pastes on two clips resolve %s",
|
||||
async (order) => {
|
||||
clearAutomationClipboard();
|
||||
usePlayerStore.setState({
|
||||
elements: [
|
||||
{ ...bgmElement, duration: 10 },
|
||||
{ ...bgmElement, id: "vo", key: "vo", duration: 10 },
|
||||
],
|
||||
selectedElementId: "bgm",
|
||||
currentTime: 1,
|
||||
});
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
||||
const lands: Array<() => void> = [];
|
||||
const onCommit = vi.fn(
|
||||
() =>
|
||||
new Promise<{ status: "saved" }>((resolve) =>
|
||||
lands.push(() => resolve({ status: "saved" })),
|
||||
),
|
||||
);
|
||||
setup({ onCommit });
|
||||
combo("c");
|
||||
usePlayerStore.getState().clearAutomationSelection();
|
||||
combo("v");
|
||||
usePlayerStore.setState({ selectedElementId: "vo" });
|
||||
combo("v");
|
||||
const order_ = order === "in order" ? [0, 1] : [1, 0];
|
||||
for (const i of order_) lands[i]?.();
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().automationSelection?.elementKey).toBe("vo");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not mark a paste whose clip was left before it landed", async () => {
|
||||
clearAutomationClipboard();
|
||||
usePlayerStore.setState({
|
||||
elements: [
|
||||
{ ...bgmElement, duration: 10 },
|
||||
{ ...bgmElement, id: "vo", key: "vo", duration: 10 },
|
||||
],
|
||||
selectedElementId: "bgm",
|
||||
currentTime: 1,
|
||||
});
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
||||
let land = () => {};
|
||||
const onCommit = vi.fn(
|
||||
() =>
|
||||
new Promise<{ status: "saved" }>((resolve) => {
|
||||
land = () => resolve({ status: "saved" });
|
||||
}),
|
||||
);
|
||||
setup({ onCommit });
|
||||
combo("c");
|
||||
usePlayerStore.getState().clearAutomationSelection();
|
||||
combo("v");
|
||||
usePlayerStore.setState({ selectedElementId: "vo" });
|
||||
land();
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().automationSelection).toBeNull();
|
||||
});
|
||||
|
||||
it("chains a second Cmd+V after the first instead of overwriting it", async () => {
|
||||
// The regression this pins: paste leaves its own span selected, so anchoring
|
||||
// at sel.t0 unconditionally made every later press recompute the same atT.
|
||||
clearAutomationClipboard();
|
||||
@@ -288,6 +405,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
);
|
||||
expect(first).toContain(2);
|
||||
expect(first).toContain(4);
|
||||
await act(async () => {});
|
||||
|
||||
combo("v");
|
||||
const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map(
|
||||
@@ -295,6 +413,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
);
|
||||
expect(second).toContain(4);
|
||||
expect(second).toContain(6);
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().automationSelection).toEqual({
|
||||
elementKey: "bgm",
|
||||
target: "volume",
|
||||
@@ -308,7 +427,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
|
||||
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", async () => {
|
||||
// The playhead branch already clamps to duration - span; the
|
||||
// selection-start branch didn't, so pasting a 2s clip at a selection
|
||||
// sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
|
||||
@@ -334,6 +453,7 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
expect(t).toBeLessThanOrEqual(bgmElement.duration);
|
||||
}
|
||||
// Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
|
||||
await act(async () => {});
|
||||
expect(usePlayerStore.getState().automationSelection).toEqual({
|
||||
elementKey: "bgm",
|
||||
target: "volume",
|
||||
@@ -347,22 +467,6 @@ describe("useAutomationSelectionKeyboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to paste when the dom-edit layer would write to a different clip", () => {
|
||||
// selectedElementId says "bgm" but the commit channel is still on the
|
||||
// previously selected clip — writing here would serialize bgm's automation
|
||||
// onto that other clip and leave bgm untouched.
|
||||
clearAutomationClipboard();
|
||||
copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2);
|
||||
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }));
|
||||
const { onCommit } = setup({ commitTargetKey: "some-other-clip" });
|
||||
const e = combo("v");
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not paste from a playhead outside the clip", () => {
|
||||
// No selection on this clip, and the playhead is past its end — there is no
|
||||
// anchor. This used to collapse to the clip's own t=0.
|
||||
|
||||
@@ -108,7 +108,7 @@ function resolveDeleteWrite(
|
||||
state: PlayerState,
|
||||
lanes: UseAutomationLanesResult,
|
||||
sel: AutomationSelection,
|
||||
): { onCommit(next: HfAutomation): void; next: HfAutomation } | null {
|
||||
): { onCommit: AutomationLaneBinding["onCommit"]; next: HfAutomation } | null {
|
||||
const ctx = resolveSelectionContext(state, lanes, sel);
|
||||
if (!ctx) return null;
|
||||
const points = ctx.lane.points.filter((p) => !pointInSelection(p, sel));
|
||||
@@ -139,18 +139,7 @@ function pasteTargetName(
|
||||
|
||||
/**
|
||||
* Where Cmd+V lands, or null when nothing is selected, the clip's lanes are
|
||||
* read-only, it has no automation lane to fall back to, or the dom-edit layer
|
||||
* would write the result to a DIFFERENT clip.
|
||||
*
|
||||
* That last guard is the one with teeth. `binding.onCommit` persists through
|
||||
* handleDomAttributeQuietCommit, which targets whatever the dom-edit layer
|
||||
* currently has selected — not the element `bind()` was handed (see the
|
||||
* doc-comment on `onSelect` in useAutomationLanes). Selecting a clip in the
|
||||
* timeline sets `selectedElementId` synchronously but resolves the dom-edit
|
||||
* selection asynchronously, so clicking clip B and immediately pressing Cmd+V
|
||||
* would serialize B's automation onto A. Every other lane path is a pointer
|
||||
* gesture on the lane itself, which cannot run before the selection lands;
|
||||
* paste is the only one that can, so it refuses rather than write blind.
|
||||
* read-only, or it has no automation lane to fall back to.
|
||||
*/
|
||||
function resolvePasteTarget(
|
||||
state: PlayerState,
|
||||
@@ -169,7 +158,6 @@ function resolvePasteTarget(
|
||||
const elementKey = elementKeyOf(element);
|
||||
const binding = lanes.bind(element, true);
|
||||
if (binding.readOnly) return null;
|
||||
if (binding.commitTargetKey !== elementKey) return null;
|
||||
const target = pasteTargetName(binding, elementKey, sel);
|
||||
if (!target) return null;
|
||||
const range = resolveAutomationRange(target, binding.chain ?? undefined);
|
||||
@@ -225,6 +213,8 @@ function pasteAnchor(
|
||||
return clampNumber(raw, 0, element.duration - span);
|
||||
}
|
||||
|
||||
let latestPaste = 0;
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the active
|
||||
* selection or the playhead. Returns false (untouched event) when the chord
|
||||
@@ -252,12 +242,10 @@ function handlePaste(
|
||||
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points }));
|
||||
// Select the pasted span — the only feedback that it landed — and mark it, so
|
||||
// an immediate second Cmd+V recognises this selection as the paste's own and
|
||||
// chains right after it instead of overwriting it.
|
||||
// Full-height box over the pasted span: everything that landed is selected, so
|
||||
// Delete straight after a paste undoes it in one press.
|
||||
const saved = paste.binding.onCommit(
|
||||
withLane(paste.binding.automation, { target: paste.target, points }),
|
||||
);
|
||||
const seq = ++latestPaste;
|
||||
const mark = {
|
||||
elementKey: paste.elementKey,
|
||||
target: paste.target,
|
||||
@@ -266,8 +254,17 @@ function handlePaste(
|
||||
v0: paste.range.min,
|
||||
v1: paste.range.max,
|
||||
};
|
||||
state.setAutomationSelection(mark);
|
||||
markLastPaste(mark);
|
||||
// Once the latest paste lands, and only over the selection it was pasted at, select
|
||||
// and mark its full-height span: the feedback that it landed, what a second Cmd+V
|
||||
// chains after, and what Delete takes back in one press.
|
||||
void saved.then((outcome) => {
|
||||
if (outcome && outcome.status !== "saved") return;
|
||||
const current = usePlayerStore.getState();
|
||||
const moved = current.selectedElementId !== state.selectedElementId;
|
||||
if (seq !== latestPaste || moved || current.automationSelection !== sel) return;
|
||||
current.setAutomationSelection(mark);
|
||||
markLastPaste(mark);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -300,8 +297,7 @@ function handleCopy(
|
||||
*
|
||||
* Store and clipboard only, no lane binding, so the dispatcher can call it
|
||||
* without holding the binding factory. That leaves one accepted residual: the
|
||||
* predicate cannot see a read-only lane, an unresolvable target, or the
|
||||
* dom-edit target mismatch `resolvePasteTarget` guards, so in those rare cases
|
||||
* predicate cannot see a read-only lane or an unresolvable target, so in those rare cases
|
||||
* the keystroke is a no-op instead of falling through to clip copy/paste. A
|
||||
* dead key beats today's double write.
|
||||
*/
|
||||
@@ -333,7 +329,7 @@ function handleDelete(
|
||||
if (!write) return false;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
write.onCommit(write.next);
|
||||
void write.onCommit(write.next);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,15 +29,6 @@ interface DataAttributeCommitOptions {
|
||||
skipRefresh: boolean;
|
||||
refreshAfter?: boolean;
|
||||
onSettled?: (ok: boolean) => void;
|
||||
/**
|
||||
* Undo grouping for a gesture that spans several commits.
|
||||
*
|
||||
* Without it the key is derived from the prefix, attribute and element, and the
|
||||
* window is history's own 300ms — so a drag's moves and the release that ends it
|
||||
* landed in different entries, and a drag slower than the window split further.
|
||||
* A caller that knows a gesture is in progress passes one key for all of it.
|
||||
*/
|
||||
coalesce?: { key: string; ms: number };
|
||||
/**
|
||||
* Apply to the preview and stop there — no file write, no history entry.
|
||||
*
|
||||
@@ -150,9 +141,7 @@ export function useDomEditAttributeCommits({
|
||||
if (!domEditSelection) return;
|
||||
const iframe = previewIframeRef.current;
|
||||
const fullAttr = resolveFullAttrName(attr, true);
|
||||
const commitKey =
|
||||
options.coalesce?.key ??
|
||||
`${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
|
||||
const commitKey = `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
|
||||
const isLatestCommit = bumpDomEditCommitMapVersion(
|
||||
domAttributeCommitVersionRef.current,
|
||||
commitKey,
|
||||
@@ -184,7 +173,6 @@ export function useDomEditAttributeCommits({
|
||||
persistDomEditOperations(domEditSelection, [op], {
|
||||
label: options.label,
|
||||
coalesceKey: commitKey,
|
||||
...(options.coalesce ? { coalesceMs: options.coalesce.ms } : {}),
|
||||
skipRefresh: options.skipRefresh,
|
||||
}),
|
||||
shouldRevert: () => isLatestCommit(),
|
||||
@@ -338,14 +326,13 @@ export function useDomEditAttributeCommits({
|
||||
attr: string,
|
||||
value: string | null,
|
||||
onSettled?: (ok: boolean) => void,
|
||||
live?: { coalesce?: { key: string; ms: number }; previewOnly?: boolean },
|
||||
live?: { previewOnly?: boolean },
|
||||
) => {
|
||||
await commitDataAttribute(attr, value, {
|
||||
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
||||
coalescePrefix: "attr-live",
|
||||
skipRefresh: true,
|
||||
onSettled,
|
||||
...(live?.coalesce ? { coalesce: live.coalesce } : {}),
|
||||
...(live?.previewOnly ? { previewOnly: true } : {}),
|
||||
});
|
||||
},
|
||||
@@ -362,13 +349,12 @@ export function useDomEditAttributeCommits({
|
||||
* edit computes from a pre-edit value and appears to do nothing.
|
||||
*/
|
||||
const handleDomAttributeQuietCommit = useCallback(
|
||||
async (attr: string, value: string | null, coalesce?: { key: string; ms: number }) => {
|
||||
async (attr: string, value: string | null) => {
|
||||
await commitDataAttribute(attr, value, {
|
||||
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
||||
coalescePrefix: "attr-quiet",
|
||||
skipRefresh: true,
|
||||
refreshAfter: true,
|
||||
...(coalesce ? { coalesce } : {}),
|
||||
});
|
||||
},
|
||||
[commitDataAttribute],
|
||||
|
||||
@@ -511,6 +511,7 @@ export function useDomEditSession({
|
||||
handlePreviewCanvasPointerLeave,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
handleDomStyleCommit,
|
||||
handleDomStyleCommitForSelection,
|
||||
handleDomAttributeCommit,
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface UseEditHistoryActionsOptions {
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
|
||||
waitForPendingDomEditSaves: () => Promise<void>;
|
||||
onAfterUndoRedo?: () => void;
|
||||
onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
|
||||
/** Active composition path — decides whether undo/redo must resync the SDK session. */
|
||||
activeCompPath?: string | null;
|
||||
/** Reloads the SDK session after a revert of the active comp, past the self-write suppress window. */
|
||||
@@ -84,11 +84,12 @@ export function useEditHistoryActions({
|
||||
return;
|
||||
}
|
||||
if (result.ok && result.label) {
|
||||
onAfterUndoRedo?.();
|
||||
const restore = { paths: result.paths, files: result.files };
|
||||
onAfterUndoRedo?.(restore);
|
||||
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
||||
forceReloadSdkSession?.();
|
||||
}
|
||||
await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
|
||||
await syncHistoryPreviewAfterApply(restore);
|
||||
showToast(result.label, "info");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -436,7 +436,7 @@ export function useTimelineEditing({
|
||||
checkEditable,
|
||||
});
|
||||
|
||||
const { revertLive: revertElementFxLive, ...setElementFxAttribute } = useSetElementAttribute({
|
||||
const setElementFxAttribute = useSetElementAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
@@ -447,18 +447,16 @@ export function useTimelineEditing({
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
const { revertLive: revertAudioGroupLive, ...setAudioGroupAttribute } = useSetAudioGroupAttribute(
|
||||
{
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
},
|
||||
);
|
||||
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
|
||||
projectIdRef,
|
||||
@@ -550,7 +548,7 @@ export function useTimelineEditing({
|
||||
// no flat twin, only a domClipChildren entry, so both are checked.
|
||||
setQuiet: track(
|
||||
guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
|
||||
revertAudioGroupLive(groupId, attr);
|
||||
setAudioGroupAttribute.revertLive(groupId, attr);
|
||||
return refused(reason);
|
||||
}),
|
||||
),
|
||||
@@ -562,7 +560,7 @@ export function useTimelineEditing({
|
||||
(element) => [element],
|
||||
setElementFxAttribute.setQuiet,
|
||||
(reason, element, attr) => {
|
||||
revertElementFxLive(element, attr);
|
||||
setElementFxAttribute.revertLive(element, attr);
|
||||
return refused(reason);
|
||||
},
|
||||
),
|
||||
@@ -591,5 +589,9 @@ export function useTimelineEditing({
|
||||
handleTimelineGroupResize: track(
|
||||
guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize),
|
||||
),
|
||||
restoreLiveLanes: (restore: Parameters<typeof setElementFxAttribute.restoreLive>[0]) => {
|
||||
setElementFxAttribute.restoreLive(restore);
|
||||
setAudioGroupAttribute.restoreLive(restore);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,6 +338,24 @@ describe("TimelineAutomationLane", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the dragged position when the save does not land", async () => {
|
||||
const onCommit = vi.fn(async () => ({ status: "refused" as const, reason: "Locked" }));
|
||||
const { container } = render(
|
||||
<TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
|
||||
);
|
||||
const svg = container.querySelector("svg")!;
|
||||
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
|
||||
const before = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"));
|
||||
fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
|
||||
fire(svg, "pointermove", { clientX: 160, clientY: 40 });
|
||||
fire(svg, "pointerup", { clientX: 160, clientY: 40 });
|
||||
await act(async () => {});
|
||||
expect(Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"))).toBeCloseTo(
|
||||
before,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it("follows the prop again once the store catches up", () => {
|
||||
const { container, rerender } = renderRerenderable(
|
||||
<TimelineAutomationLane {...laneProps({ automation: ramp })} />,
|
||||
@@ -359,6 +377,25 @@ describe("TimelineAutomationLane", () => {
|
||||
expect(Number(circles[0]!.getAttribute("cx"))).toBeCloseTo(PAD + 300, 0);
|
||||
});
|
||||
|
||||
it("commits the dragged points when an older save lands mid-drag", () => {
|
||||
const onCommit = vi.fn(async (_next: HfAutomation) => ({ status: "saved" as const }));
|
||||
const { container, rerender } = renderRerenderable(
|
||||
<TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
|
||||
);
|
||||
const svg = container.querySelector("svg")!;
|
||||
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
|
||||
fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
|
||||
fire(svg, "pointermove", { clientX: 160, clientY: 40 });
|
||||
fire(svg, "pointerup", { clientX: 160, clientY: 40 });
|
||||
const older = onCommit.mock.calls[0]![0];
|
||||
fire(svg, "pointerdown", { clientX: 160, clientY: 40 });
|
||||
fire(svg, "pointermove", { clientX: 320, clientY: 40 });
|
||||
rerender(<TimelineAutomationLane {...laneProps({ automation: older, onCommit })} />);
|
||||
fire(svg, "pointerup", { clientX: 320, clientY: 40 });
|
||||
const released = onCommit.mock.calls[1]![0];
|
||||
expect(released.lanes[0]!.points[0]!.t).toBeGreaterThan(older.lanes[0]!.points[0]!.t + 1);
|
||||
});
|
||||
|
||||
it("keeps lane order when editing, so the view does not switch parameters", () => {
|
||||
// The displayed lane defaults to the first one. Moving the edited lane to
|
||||
// the end of the list swapped the lane out from under the pointer on the
|
||||
@@ -1627,6 +1664,7 @@ describe("TimelineAutomationLane stretch", () => {
|
||||
const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]
|
||||
?.points;
|
||||
expect(reverted).toEqual(stretchable.lanes[0]?.points);
|
||||
expect(props.onPreview.mock.calls.at(-1)?.[1]).toBe(true);
|
||||
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5, 0, 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type HfAutomationLane,
|
||||
type HfAutomationPoint,
|
||||
} from "@hyperframes/core/audio-automation";
|
||||
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
||||
import { envelopePath, fromUnit, laneFor, PAD_X, toUnit, withLane } from "./automationLaneGeometry";
|
||||
import { useAutomationLaneGestures } from "./useAutomationLaneGestures";
|
||||
import { AutomationValueInput } from "./AutomationValueInput";
|
||||
@@ -180,9 +181,9 @@ export interface TimelineAutomationLaneProps {
|
||||
/** Clip-local seconds of the playhead, or null when it is outside the clip. */
|
||||
playheadSec: number | null;
|
||||
/** Continuous write while dragging; does not persist. */
|
||||
onPreview(automation: HfAutomation): void;
|
||||
onPreview(automation: HfAutomation, ended?: boolean): void;
|
||||
/** Gesture-end write; this is the one that persists and lands in undo. */
|
||||
onCommit(automation: HfAutomation): void;
|
||||
onCommit(automation: HfAutomation): Promise<TimelineEditOutcome | void> | void;
|
||||
/**
|
||||
* Clip-local times a dragged point snaps to — the beat grid, shifted into this
|
||||
* clip's frame. Its own neighbouring points are added on top.
|
||||
@@ -241,14 +242,6 @@ export function TimelineAutomationLane({
|
||||
[draft, target, stored],
|
||||
);
|
||||
|
||||
// The draft is released when the automation it was drawn over actually
|
||||
// changes — the persisted edit landing, or an edit from elsewhere. Releasing
|
||||
// it merely because the drag ended would snap the point back to where it
|
||||
// started for as long as the write takes to come around.
|
||||
useEffect(() => {
|
||||
if (draft && draft.basedOn !== automation) setDraft(null);
|
||||
}, [automation, draft]);
|
||||
|
||||
// A different parameter is a different envelope; the draft does not carry over.
|
||||
useEffect(() => {
|
||||
setDraft(null);
|
||||
@@ -289,12 +282,16 @@ export function TimelineAutomationLane({
|
||||
);
|
||||
|
||||
const commitPoints = useCallback(
|
||||
(points: HfAutomationLane["points"], persist: boolean): void => {
|
||||
(points: HfAutomationLane["points"], persist: boolean, ended?: boolean): void => {
|
||||
// Draw from the draft immediately; the write is what eventually agrees.
|
||||
setDraft({ points, basedOn: automation });
|
||||
const next = withLane(automation, { target, points });
|
||||
if (persist) onCommit(next);
|
||||
else onPreview(next);
|
||||
if (!persist) return onPreview(next, ended);
|
||||
void Promise.resolve(onCommit(next)).then((outcome) => {
|
||||
if (outcome && outcome.status !== "saved") {
|
||||
setDraft((current) => (current?.points === points ? null : current));
|
||||
}
|
||||
});
|
||||
},
|
||||
[automation, target, onCommit, onPreview],
|
||||
);
|
||||
@@ -319,6 +316,7 @@ export function TimelineAutomationLane({
|
||||
duration,
|
||||
rangeSelection,
|
||||
});
|
||||
|
||||
const {
|
||||
dragIndex,
|
||||
curveIndex,
|
||||
@@ -329,6 +327,13 @@ export function TimelineAutomationLane({
|
||||
hint,
|
||||
editing,
|
||||
} = gestures;
|
||||
// Released when the automation it was drawn over changes, not on drag end (the point would
|
||||
// snap back until the write lands) and not under a live gesture (its release would commit
|
||||
// an older save's points).
|
||||
const gestureLive = [dragIndex, curveIndex, segmentDragIndex, edgeDrag].some((g) => g !== null);
|
||||
useEffect(() => {
|
||||
if (draft && draft.basedOn !== automation && !gestureLive) setDraft(null);
|
||||
}, [automation, draft, gestureLive]);
|
||||
|
||||
const removeAt = useCallback(
|
||||
(index: number): void => {
|
||||
|
||||
@@ -32,7 +32,6 @@ function mountSlot(binding: Partial<AutomationLaneBinding>) {
|
||||
onCommit: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
readOnly: false,
|
||||
commitTargetKey: "bgm",
|
||||
selection: null,
|
||||
onRangeSelect: vi.fn(),
|
||||
onRangeClear,
|
||||
@@ -96,7 +95,6 @@ const readingBind = (element: TimelineElement, isSelected: boolean): AutomationL
|
||||
onCommit: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
readOnly: !isSelected,
|
||||
commitTargetKey: null,
|
||||
selection: null,
|
||||
onRangeSelect: vi.fn(),
|
||||
onRangeClear: vi.fn(),
|
||||
|
||||
@@ -33,6 +33,7 @@ function render(
|
||||
const root = createRoot(host);
|
||||
const onSetElementAttributeLive = vi.fn();
|
||||
const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
|
||||
const onRevertElementAttributeLive = vi.fn();
|
||||
// Stands in for the TimelineClip button the handles live inside.
|
||||
const node = (
|
||||
<div data-testid="clip" onPointerDown={options.onClipPointerDown}>
|
||||
@@ -49,13 +50,25 @@ function render(
|
||||
options.provide === false ? (
|
||||
node
|
||||
) : (
|
||||
<TimelineEditProvider value={{ onSetElementAttributeLive, onSetElementAttributeQuiet }}>
|
||||
<TimelineEditProvider
|
||||
value={{
|
||||
onSetElementAttributeLive,
|
||||
onSetElementAttributeQuiet,
|
||||
onRevertElementAttributeLive,
|
||||
}}
|
||||
>
|
||||
{node}
|
||||
</TimelineEditProvider>
|
||||
),
|
||||
);
|
||||
});
|
||||
return { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet };
|
||||
return {
|
||||
host,
|
||||
root,
|
||||
onSetElementAttributeLive,
|
||||
onSetElementAttributeQuiet,
|
||||
onRevertElementAttributeLive,
|
||||
};
|
||||
}
|
||||
|
||||
function pointer(type: string, clientX: number, pointerId = 1) {
|
||||
@@ -146,14 +159,22 @@ describe("TimelineClipFades", () => {
|
||||
});
|
||||
|
||||
it("puts the live value back and writes nothing when the gesture is cancelled", () => {
|
||||
const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
|
||||
const {
|
||||
host,
|
||||
root,
|
||||
onSetElementAttributeLive,
|
||||
onRevertElementAttributeLive,
|
||||
onSetElementAttributeQuiet,
|
||||
} = render(clip);
|
||||
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
||||
if (!handle) throw new Error("expected a fade-in handle");
|
||||
armCapture(handle);
|
||||
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
||||
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
||||
act(() => handle.dispatchEvent(pointer("pointercancel", 300)));
|
||||
// Written back live for a host without the revert, then ended through the lanes' revert.
|
||||
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
|
||||
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
|
||||
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ interface TimelineClipFadesProps {
|
||||
|
||||
/** Corner dots that drag `data-fade-in` / `data-fade-out`; the wedge is the faded region. */
|
||||
export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineClipFadesProps) {
|
||||
const { onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
||||
const { onSetElementAttributeLive, onSetElementAttributeQuiet, onRevertElementAttributeLive } =
|
||||
useTimelineEditContextOptional();
|
||||
const canEdit = Boolean(onSetElementAttributeLive && onSetElementAttributeQuiet);
|
||||
|
||||
@@ -126,7 +126,10 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
|
||||
|
||||
/** Puts the live document back where the file has it and drops the draft. */
|
||||
const revertGesture = (g: Gesture) => {
|
||||
if (g.moved) onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
|
||||
if (g.moved) {
|
||||
onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
|
||||
onRevertElementAttributeLive?.(el, attrFor(g.edge));
|
||||
}
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ interface TimelineFxButtonChainProps {
|
||||
onOpenRack: () => void;
|
||||
fxChainRaw: string | undefined;
|
||||
onChainChange: (next: HfAudioFxChain) => void;
|
||||
onChainPreview?: (next: HfAudioFxChain) => void;
|
||||
onChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
|
||||
/** The clips this chain is heard through, so an audition starts where they
|
||||
* actually sound rather than from a playhead parked before the first. */
|
||||
auditionSpans?: readonly AuditionSpan[];
|
||||
|
||||
@@ -18,7 +18,7 @@ interface TimelineGroupHeaderProps {
|
||||
/** C1: the group's serialized `data-fx-chain`, when set. */
|
||||
fxChain?: string;
|
||||
onFxChainChange: (next: HfAudioFxChain) => void;
|
||||
onFxChainPreview?: (next: HfAudioFxChain) => void;
|
||||
onFxChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
|
||||
/** Member clips, so hovering a preset auditions where the group sounds. */
|
||||
auditionSpans?: readonly AuditionSpan[];
|
||||
onOpenFxRack: () => void;
|
||||
|
||||
@@ -84,7 +84,6 @@ function renderRow(
|
||||
onCommit: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
readOnly: true,
|
||||
commitTargetKey: null,
|
||||
selection: null,
|
||||
onRangeSelect: vi.fn(),
|
||||
onRangeClear: vi.fn(),
|
||||
|
||||
@@ -80,9 +80,8 @@ export function TimelineGroupRow({
|
||||
// The group wearing a clip's shape so the lane machinery can render it — see
|
||||
// `groupAutomationElement` for why that beats a second, parallel lane path.
|
||||
const groupElement = groupAutomationElement(group, compositionDuration);
|
||||
// The binder writes through the dom-edit selection, so a group lane is
|
||||
// editable exactly when the group is the selected element — which clicking
|
||||
// its name in the header does.
|
||||
// A group lane is editable exactly when the group is the selected element,
|
||||
// which clicking its name in the header does.
|
||||
const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
|
||||
const isGroupSelected = domSelection?.id === group.id;
|
||||
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
|
||||
@@ -90,14 +89,21 @@ export function TimelineGroupRow({
|
||||
// provider in read-only hosts (Timeline.test.ts asserts it), and the throwing
|
||||
// hook took the whole timeline down with it the moment a group existed —
|
||||
// not just this row.
|
||||
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
|
||||
useTimelineEditContextOptional();
|
||||
const {
|
||||
onSetAudioGroupAttributeLive,
|
||||
onSetAudioGroupAttributeQuiet,
|
||||
onRevertAudioGroupAttributeLive,
|
||||
} = useTimelineEditContextOptional();
|
||||
const domEditActions = useDomEditActionsContextOptional();
|
||||
const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget);
|
||||
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
|
||||
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean, ended = false) => {
|
||||
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
||||
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
|
||||
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
||||
if (!live) {
|
||||
void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
||||
return;
|
||||
}
|
||||
onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
|
||||
if (ended) onRevertAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR);
|
||||
};
|
||||
const openGroupFxRack = (automationTarget?: string) => {
|
||||
// Use the guarded timeline-selection path even though the bus is synthetic:
|
||||
@@ -157,7 +163,7 @@ export function TimelineGroupRow({
|
||||
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
||||
fxChain={group.fxChain}
|
||||
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
||||
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
||||
onFxChainPreview={(next, ended) => writeGroupFxChain(next, true, ended)}
|
||||
auditionSpans={memberElements}
|
||||
onOpenFxRack={() => openGroupFxRack()}
|
||||
// Same width as every other row's header. The group row needs a real
|
||||
@@ -169,8 +175,8 @@ export function TimelineGroupRow({
|
||||
theme={theme}
|
||||
/>
|
||||
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
||||
clip's: the binder writes through the dom-edit selection, so a lane is
|
||||
editable once the group is selected — which clicking its name does. */}
|
||||
clip's: a lane is editable once the group is selected, which clicking
|
||||
its name does. */}
|
||||
{/* The label column for those lanes, on the accent rail — inside the
|
||||
sticky column above, so they pin with the header. */}
|
||||
{isLaneOpen && (
|
||||
|
||||
@@ -214,8 +214,12 @@ export function TimelineTrackHeader({
|
||||
// track holding several ungrouped ones has no single chain — the design
|
||||
// doc refuses to build "N clips = N chains", so that case gets a pointer
|
||||
// at grouping (B6's normative rule) instead of a popover.
|
||||
const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
||||
useTimelineEditContextOptional();
|
||||
const {
|
||||
onGroupClips,
|
||||
onSetElementAttributeLive,
|
||||
onSetElementAttributeQuiet,
|
||||
onRevertElementAttributeLive,
|
||||
} = useTimelineEditContextOptional();
|
||||
const domEditActions = useDomEditActionsContextOptional();
|
||||
const singleAudioClip =
|
||||
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
|
||||
@@ -225,10 +229,19 @@ export function TimelineTrackHeader({
|
||||
// needs to be TOLD that, so it earns the button and a refusal.
|
||||
const isVideoWithAudioTrack =
|
||||
!isAudioTrack && trackElements.some((el) => el.tag.toLowerCase() === "video");
|
||||
const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
|
||||
const writeClipFxChain = (
|
||||
clip: TimelineElement,
|
||||
next: HfAudioFxChain,
|
||||
live: boolean,
|
||||
ended = false,
|
||||
) => {
|
||||
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
||||
if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
|
||||
else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
||||
if (!live) {
|
||||
void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
||||
return;
|
||||
}
|
||||
onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
|
||||
if (ended) onRevertElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR);
|
||||
};
|
||||
const openClipFxRack = (clip: TimelineElement) => {
|
||||
void domEditActions?.handleTimelineElementSelect(clip);
|
||||
@@ -309,7 +322,9 @@ export function TimelineTrackHeader({
|
||||
fxChainRaw={singleAudioClip.fxChain}
|
||||
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
|
||||
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
|
||||
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
|
||||
onChainPreview={(next, ended) =>
|
||||
writeClipFxChain(singleAudioClip, next, true, ended)
|
||||
}
|
||||
// Muted, an audition is silent — so the hover lifts the mute on
|
||||
// the running graph and puts it back on the way out, the same
|
||||
// borrow-and-return it already does with the playhead.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAutomationGestureKeys } from "./automationGestureKeys";
|
||||
|
||||
const keys = () => {
|
||||
let n = 0;
|
||||
return createAutomationGestureKeys(() => `g${++n}`);
|
||||
};
|
||||
|
||||
describe("createAutomationGestureKeys", () => {
|
||||
it("holds one key across every move of a drag", () => {
|
||||
// Each move persists; history merges same-key entries inside 300ms. Separate
|
||||
// keys, or a window that can expire mid-drag, is what made undo take back a
|
||||
// fragment of the move instead of the move.
|
||||
const g = keys();
|
||||
expect([g.live().key, g.live().key, g.live().key]).toEqual(["g1", "g1", "g1"]);
|
||||
});
|
||||
|
||||
it("never lets the window expire", () => {
|
||||
expect(keys().live().ms).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(keys().commit().ms).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("ends the gesture on the release, under the same key", () => {
|
||||
const g = keys();
|
||||
g.live();
|
||||
expect(g.commit().key).toBe("g1");
|
||||
});
|
||||
|
||||
it("starts a fresh key for the next drag", () => {
|
||||
const g = keys();
|
||||
g.live();
|
||||
g.commit();
|
||||
expect(g.live().key).toBe("g2");
|
||||
});
|
||||
|
||||
it("gives a standalone commit its own key", () => {
|
||||
// Deleting a range, pasting one, typing a value: one write, one step each.
|
||||
const g = keys();
|
||||
expect(g.commit().key).toBe("g1");
|
||||
expect(g.commit().key).toBe("g2");
|
||||
});
|
||||
|
||||
it("does not join a commit to a gesture that already ended", () => {
|
||||
const g = keys();
|
||||
g.live();
|
||||
g.commit();
|
||||
expect(g.commit().key).toBe("g2");
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Undo grouping for a lane gesture.
|
||||
*
|
||||
* Dragging a point persists on every move and once more on release. History
|
||||
* coalesces entries that share a key and land within 300ms of each other, and
|
||||
* these writes did neither: the moves and the release used different keys, and a
|
||||
* drag slower than the window split into several entries. Undo then took back
|
||||
* whatever fragment happened to be last, leaving the point near where the drag
|
||||
* had dropped it — which reads as undo not working at all.
|
||||
*
|
||||
* So a gesture mints one key and holds it: every move and the release that ends
|
||||
* it record under that key with no window to expire, and the next gesture gets a
|
||||
* fresh one so two drags never collapse into a single step.
|
||||
*/
|
||||
|
||||
export interface CoalesceHint {
|
||||
key: string;
|
||||
/** No expiry: a gesture is one step however long the pointer is held. */
|
||||
ms: number;
|
||||
}
|
||||
|
||||
export interface AutomationGestureKeys {
|
||||
/** A continuous write. Opens a gesture if one is not already open. */
|
||||
live(): CoalesceHint;
|
||||
/** The persisting write that ends a gesture — or a standalone edit. */
|
||||
commit(): CoalesceHint;
|
||||
}
|
||||
|
||||
export function createAutomationGestureKeys(
|
||||
mint: () => string = () => `automation-gesture:${++counter}`,
|
||||
): AutomationGestureKeys {
|
||||
let open: string | null = null;
|
||||
const hint = (key: string): CoalesceHint => ({ key, ms: Number.POSITIVE_INFINITY });
|
||||
return {
|
||||
live: () => {
|
||||
open ??= mint();
|
||||
return hint(open);
|
||||
},
|
||||
commit: () => {
|
||||
// A commit with no drag before it — a Delete, a paste, a typed value — is
|
||||
// its own step, so it mints rather than joining anything.
|
||||
const key = open ?? mint();
|
||||
open = null;
|
||||
return hint(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
@@ -16,12 +16,18 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
|
||||
const groupElements = new WeakSet<TimelineElement>();
|
||||
|
||||
export function isGroupAutomationElement(element: TimelineElement): boolean {
|
||||
return groupElements.has(element);
|
||||
}
|
||||
|
||||
/** The synthetic element's track number — the group row's own anchor. */
|
||||
export function groupAutomationElement(
|
||||
group: Pick<TimelineTrackGroupInfo, "id" | "label" | "automation" | "fxChain" | "anchorKey">,
|
||||
compositionDuration: number,
|
||||
): TimelineElement {
|
||||
return {
|
||||
const element: TimelineElement = {
|
||||
id: group.id,
|
||||
// The DOM id, so a write lands on the `<hf-audio-group>` and not on a clip.
|
||||
domId: group.id,
|
||||
@@ -34,4 +40,6 @@ export function groupAutomationElement(
|
||||
...(group.automation ? { automation: group.automation } : {}),
|
||||
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
||||
};
|
||||
groupElements.add(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface TimelineEditCallbacks {
|
||||
) => Promise<void> | void;
|
||||
/** B7's bus strip: live-write the group's own attribute while dragging. */
|
||||
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
|
||||
onRevertAudioGroupAttributeLive?: (groupId: string, attr: string) => void;
|
||||
/** ...and persist one undo entry on release. */
|
||||
onSetAudioGroupAttributeQuiet?: (
|
||||
groupId: string,
|
||||
@@ -106,6 +107,7 @@ export interface TimelineEditCallbacks {
|
||||
attr: string,
|
||||
value: string | null,
|
||||
) => void;
|
||||
onRevertElementAttributeLive?: (element: TimelineElement, attr: string) => void;
|
||||
onSetElementAttributeQuiet?: (
|
||||
element: TimelineElement,
|
||||
attr: string,
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface UseAutomationEdgeStretchInput {
|
||||
/** Pointer position as a clip-local time and a parameter value. */
|
||||
pointAt(clientX: number, clientY: number): { t: number; v: number };
|
||||
xOf(t: number): number;
|
||||
commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
|
||||
commitPoints(points: HfAutomationLane["points"], persist: boolean, ended?: boolean): void;
|
||||
/** Clamp bound for the dragged edge. */
|
||||
duration: number;
|
||||
readOnly?: boolean | undefined;
|
||||
@@ -252,9 +252,9 @@ export function useAutomationEdgeStretch({
|
||||
onHint(null);
|
||||
if (!crossed.current) return;
|
||||
crossed.current = false;
|
||||
// A live write is a preview, so putting the snapshot back through the same
|
||||
// channel is the whole revert — there is nothing persisted to undo.
|
||||
commitPoints(points, false);
|
||||
// Nothing was persisted: the snapshot goes back as the last live write, which
|
||||
// also ends the lane's gesture so its before-value does not outlive it.
|
||||
commitPoints(points, false, true);
|
||||
onRangeSelect?.(origin.t0, origin.t1, origin.v0, origin.v1);
|
||||
}, [drag, onHint, commitPoints, onRangeSelect]);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface UseAutomationLaneGesturesInput {
|
||||
pointAt(clientX: number, clientY: number): { t: number; v: number };
|
||||
xOf(t: number): number;
|
||||
yOf(v: number): number;
|
||||
commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
|
||||
commitPoints(points: HfAutomationLane["points"], persist: boolean, ended?: boolean): void;
|
||||
/** Clip-local times a dragged point snaps to, on top of its own neighbours. */
|
||||
snapTimes?: readonly number[] | undefined;
|
||||
readOnly?: boolean | undefined;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,15 @@
|
||||
* Reading lives in `automationLaneData`, shared with the row layout, which needs
|
||||
* the lane count to reserve height.
|
||||
*
|
||||
* Edits go to the *selected* element, because that is what the attribute commit
|
||||
* path targets. An unselected clip still draws its envelopes — they are just
|
||||
* read only, which is also what stops a stray drag from editing the wrong track.
|
||||
* Edits save through the timeline's gated save, as an effect preset does, so a
|
||||
* locked clip or group refuses them. Only the selected element is editable: an
|
||||
* unselected one still draws its envelopes read only, which is also what stops a
|
||||
* stray drag from editing the wrong track.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
HF_AUDIO_AUTOMATION_ATTR,
|
||||
serializeAutomation,
|
||||
type HfAutomation,
|
||||
type HfAutomationLane,
|
||||
} from "@hyperframes/core/audio-automation";
|
||||
@@ -22,22 +22,25 @@ import {
|
||||
useDomEditActionsContextOptional,
|
||||
useDomEditSelectionContextOptional,
|
||||
} from "../../contexts/DomEditContext";
|
||||
import { resolveTimelineIdForSelection } from "../../utils/studioHelpers";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { AutomationSelection } from "../store/automationSelectionSlice";
|
||||
import { automationAttrValue } from "../../components/editor/propertyPanelAutomation";
|
||||
import { elementAutomation, elementFxChain } from "./automationLaneData";
|
||||
import { createAutomationGestureKeys } from "./automationGestureKeys";
|
||||
import { isGroupAutomationElement } from "./groupAutomationElement";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
|
||||
export interface AutomationLaneBinding {
|
||||
automation: HfAutomation;
|
||||
/** One entry per lane, in draw order — each gets its own row. */
|
||||
lanes: HfAutomationLane[];
|
||||
chain: HfAudioFxChain | null;
|
||||
/** Continuous write while dragging; does not persist. */
|
||||
onPreview(next: HfAutomation): void;
|
||||
/** Continuous write while dragging; does not persist. `ended`: a cancel put the start back. */
|
||||
onPreview(next: HfAutomation, ended?: boolean): void;
|
||||
/** Gesture-end write; this is the one that persists and lands in undo. */
|
||||
onCommit(next: HfAutomation): void;
|
||||
onCommit(next: HfAutomation): Promise<TimelineEditOutcome | void>;
|
||||
/**
|
||||
* Select this clip, which is what makes its lanes editable. A lane calls this
|
||||
* instead of writing when it is read-only — pressing the lane is the only
|
||||
@@ -46,15 +49,6 @@ export interface AutomationLaneBinding {
|
||||
*/
|
||||
onSelect(): void;
|
||||
readOnly: boolean;
|
||||
/**
|
||||
* The timeline clip `onCommit`/`onPreview` will ACTUALLY persist to, which is
|
||||
* whatever the dom-edit layer has selected — not necessarily the element this
|
||||
* binding was made for (see `onSelect` below). Null outside an edit session or
|
||||
* when the dom-edit selection maps to no clip. Resolved exactly the way
|
||||
* applyDomSelection resolves it, so in a settled selection it equals the bound
|
||||
* element's key; it lags only in the window a non-gesture caller can hit.
|
||||
*/
|
||||
commitTargetKey: string | null;
|
||||
/** This element's active selection box, or null if none / it belongs to a
|
||||
* different element. */
|
||||
selection: AutomationSelection | null;
|
||||
@@ -69,80 +63,69 @@ export interface UseAutomationLanesResult {
|
||||
bind(element: TimelineElement, isSelected: boolean): AutomationLaneBinding;
|
||||
}
|
||||
|
||||
const AUTOMATION_LABEL = "Edit automation";
|
||||
|
||||
/** A group's lanes save on the group, gated by its members; a clip's on the clip. */
|
||||
function automationWriters(edit: TimelineEditCallbacks, element: TimelineElement) {
|
||||
if (isGroupAutomationElement(element)) {
|
||||
const { onSetAudioGroupAttributeLive: live, onSetAudioGroupAttributeQuiet: save } = edit;
|
||||
if (!live || !save) return null;
|
||||
return {
|
||||
live: (value: string | null) => live(element.id, HF_AUDIO_AUTOMATION_ATTR, value),
|
||||
revert: () => edit.onRevertAudioGroupAttributeLive?.(element.id, HF_AUDIO_AUTOMATION_ATTR),
|
||||
save: (value: string | null) =>
|
||||
save(element.id, HF_AUDIO_AUTOMATION_ATTR, value, AUTOMATION_LABEL),
|
||||
};
|
||||
}
|
||||
const { onSetElementAttributeLive: live, onSetElementAttributeQuiet: save } = edit;
|
||||
if (!live || !save) return null;
|
||||
return {
|
||||
live: (value: string | null) => live(element, HF_AUDIO_AUTOMATION_ATTR, value),
|
||||
revert: () => edit.onRevertElementAttributeLive?.(element, HF_AUDIO_AUTOMATION_ATTR),
|
||||
save: (value: string | null) =>
|
||||
save(element, HF_AUDIO_AUTOMATION_ATTR, value, AUTOMATION_LABEL),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAutomationLanes(): UseAutomationLanesResult {
|
||||
// One per hook instance, held across renders: a gesture spans many commits and
|
||||
// they all have to record under the same key for undo to take the whole drag.
|
||||
const gestureKeys = useRef(createAutomationGestureKeys());
|
||||
// Optional: the player also runs outside Studio, where there is no edit
|
||||
// session. There the lanes render read-only, which is the right fallback.
|
||||
const domEdit = useDomEditActionsContextOptional();
|
||||
const domEditSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const domEditSelectionRef = useDomEditSelectionContextOptional()?.domEditSelectionRef;
|
||||
const timelineEdit = useTimelineEditContextOptional();
|
||||
const automationSelection = usePlayerStore((s) => s.automationSelection);
|
||||
const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection);
|
||||
const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection);
|
||||
|
||||
// Read from the SAME render as the commit handlers below: both contexts update
|
||||
// in one commit, so a handler and this key can never describe different
|
||||
// moments. activeCompPath is not needed — resolveTimelineIdForSelection only
|
||||
// uses it as a fallback for a selection with no sourceFile of its own, and
|
||||
// DomEditSelection always carries one.
|
||||
const commitTargetKey = useMemo(
|
||||
() =>
|
||||
domEditSelection ? resolveTimelineIdForSelection(domEditSelection, elements, null) : null,
|
||||
[domEditSelection, elements],
|
||||
);
|
||||
|
||||
const bind = useCallback(
|
||||
(element: TimelineElement, isSelected: boolean): AutomationLaneBinding => {
|
||||
const chain = elementFxChain(element);
|
||||
const automation = elementAutomation(element);
|
||||
const elementKey = getTimelineElementIdentity(element);
|
||||
|
||||
const write = (next: HfAutomation, persist: boolean): void => {
|
||||
if (!domEdit || !isSelected) return;
|
||||
const value = next.lanes.length > 0 ? serializeAutomation(next) : "";
|
||||
// Every write of one gesture under one key, so undo takes back the whole
|
||||
// drag rather than the last fragment history happened to keep.
|
||||
const coalesce = persist ? gestureKeys.current.commit() : gestureKeys.current.live();
|
||||
// Quiet, not the refreshing commit: releasing a dragged point used to
|
||||
// reload the preview, which restarts every playing track — the same chop
|
||||
// the live write during the drag exists to avoid. Quiet still persists
|
||||
// and still resyncs the selection, so the next edit sees this one.
|
||||
if (persist) {
|
||||
void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value, coalesce);
|
||||
}
|
||||
// Dragging a point writes live: no preview refresh, so the composition
|
||||
// does not reload and restart playback on every pixel.
|
||||
else {
|
||||
// Preview only, because a gesture writes on every pointermove: the
|
||||
// preview and the running audio follow the pointer, and the release
|
||||
// below is the one write that reaches the file and the undo stack.
|
||||
void domEdit.handleDomAttributeLiveCommit(
|
||||
HF_AUDIO_AUTOMATION_ATTR,
|
||||
value || null,
|
||||
undefined,
|
||||
{
|
||||
coalesce,
|
||||
previewOnly: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
const writers = domEdit && isSelected ? automationWriters(timelineEdit, element) : null;
|
||||
|
||||
return {
|
||||
automation,
|
||||
lanes: automation.lanes,
|
||||
chain,
|
||||
onPreview: (next) => write(next, false),
|
||||
onCommit: (next) => write(next, true),
|
||||
// Deliberately not awaited before an edit: the commit handlers close
|
||||
// over the selection as it was when they were built, so writing in the
|
||||
// same tick would land on whichever element was selected before.
|
||||
// Selecting is its own gesture; the lane goes live after it.
|
||||
onPreview: (next, ended) => {
|
||||
writers?.live(automationAttrValue(next) || null);
|
||||
if (ended) writers?.revert();
|
||||
},
|
||||
onCommit: async (next) => {
|
||||
if (!writers) return;
|
||||
const outcome = await writers.save(automationAttrValue(next) || null);
|
||||
// The FX panel reads the selection snapshot; a stale one makes its next
|
||||
// edit start from the automation this one replaced.
|
||||
const selection = domEditSelectionRef?.current;
|
||||
if (selection) void domEdit?.refreshDomEditSelectionFromPreview(selection);
|
||||
return outcome;
|
||||
},
|
||||
// Deliberately not awaited before an edit: the lane goes live when the
|
||||
// selection lands and it re-renders as selected.
|
||||
onSelect: () => void domEdit?.handleTimelineElementSelect(element),
|
||||
readOnly: !domEdit || !isSelected,
|
||||
commitTargetKey: domEdit ? commitTargetKey : null,
|
||||
readOnly: !writers,
|
||||
selection: automationSelection?.elementKey === elementKey ? automationSelection : null,
|
||||
// Not gated on `isSelected`, unlike the writes above. A selection is
|
||||
// ephemeral store state, and the drag that draws one on a read-only lane is
|
||||
@@ -159,7 +142,8 @@ export function useAutomationLanes(): UseAutomationLanesResult {
|
||||
},
|
||||
[
|
||||
domEdit,
|
||||
commitTargetKey,
|
||||
domEditSelectionRef,
|
||||
timelineEdit,
|
||||
automationSelection,
|
||||
setAutomationSelection,
|
||||
clearAutomationSelection,
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* an envelope afterwards writes to the preview document and the source file, and the
|
||||
* store would go on holding the value it was born with until a reload.
|
||||
*
|
||||
* One reader, called from the two places a change lands: the resync every dom-edit
|
||||
* attribute commit already runs, and the soft restore an undo or redo applies. It
|
||||
* One reader, called where a change lands: the resync every dom-edit attribute
|
||||
* commit runs, and an undo or redo's soft restore (timeline saves record their own
|
||||
* value through syncStoredElementAttribute below). It
|
||||
* reads the preview rather than being told, because those callers know a file
|
||||
* changed, not which attribute — and because three separate writers shipped without
|
||||
* remembering to sync, which is what a single sink prevents.
|
||||
@@ -17,7 +18,7 @@ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
||||
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { groupInfoFor } from "./timelineGroupInfo";
|
||||
import { previewElementFinder } from "./timelineElementHelpers";
|
||||
import { getTimelineElementIdentity, previewElementFinder } from "./timelineElementHelpers";
|
||||
|
||||
/**
|
||||
* Re-read every element's automation and FX-chain attributes from the preview
|
||||
@@ -72,3 +73,29 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
|
||||
return changed ? { elements } : {};
|
||||
});
|
||||
}
|
||||
|
||||
const STORED_FIELD: Record<string, "automation" | "fxChain"> = {
|
||||
[HF_AUDIO_AUTOMATION_ATTR]: "automation",
|
||||
[HF_AUDIO_FX_ATTR]: "fxChain",
|
||||
};
|
||||
|
||||
/** Record a saved automation or FX-chain value on one element's stored copy. */
|
||||
export function syncStoredElementAttribute(
|
||||
target: TimelineElement,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): void {
|
||||
const field = STORED_FIELD[attr];
|
||||
if (!field) return;
|
||||
const key = getTimelineElementIdentity(target);
|
||||
const next = value ?? undefined;
|
||||
usePlayerStore.setState((state) => {
|
||||
let changed = false;
|
||||
const elements = state.elements.map((element) => {
|
||||
if (getTimelineElementIdentity(element) !== key || element[field] === next) return element;
|
||||
changed = true;
|
||||
return { ...element, [field]: next };
|
||||
});
|
||||
return changed ? { elements } : {};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "./timelineDOM";
|
||||
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
||||
import { computeResizePreview } from "../components/timelineClipDragPreview";
|
||||
import { invalidateGroupInfoCache } from "./timelineGroupInfo";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
||||
@@ -166,7 +165,7 @@ describe("group info cache", () => {
|
||||
// changes either. Without an explicit drop, a muted group could never be
|
||||
// unmuted: the header kept reading the cached `hidden: false` and re-wrote
|
||||
// `data-hidden` forever.
|
||||
it("re-reads group state after an invalidation", () => {
|
||||
it("re-reads group state written earlier in the same task", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
||||
@@ -175,15 +174,10 @@ describe("group info cache", () => {
|
||||
`);
|
||||
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
||||
|
||||
// No await: the observer has not delivered either write when the next read runs.
|
||||
doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(false); // still the cached scan
|
||||
|
||||
invalidateGroupInfoCache(doc);
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(true);
|
||||
|
||||
doc.getElementById("voiceover")?.removeAttribute("data-hidden");
|
||||
invalidateGroupInfoCache(doc);
|
||||
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,24 @@ const groupInfoCache = new WeakMap<
|
||||
|
||||
/** Bumped by every observed mutation to group state in a document. */
|
||||
const groupRevisions = new WeakMap<Document, number>();
|
||||
const groupObservers = new WeakSet<Document>();
|
||||
const groupObservers = new WeakMap<Document, MutationObserver>();
|
||||
|
||||
function countGroupChanges(doc: Document, records: readonly MutationRecord[]): void {
|
||||
// Only a group element added or removed counts: childList fires for every node a playing
|
||||
// preview inserts. Attribute records always count; the observer filter already narrowed them.
|
||||
const relevant = records.some(
|
||||
(record) =>
|
||||
record.type !== "childList" ||
|
||||
[...record.addedNodes, ...record.removedNodes].some(
|
||||
(node) =>
|
||||
node instanceof Element &&
|
||||
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
|
||||
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
|
||||
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
|
||||
),
|
||||
);
|
||||
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a document for any change to group state, so the cache expires itself.
|
||||
@@ -55,28 +72,8 @@ const groupObservers = new WeakSet<Document>();
|
||||
*/
|
||||
function observeGroupState(doc: Document): void {
|
||||
if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
|
||||
groupObservers.add(doc);
|
||||
const observer = new MutationObserver((records) => {
|
||||
// `childList` fires for EVERY node added or removed anywhere in the live
|
||||
// preview, which on a composition that churns nodes during playback
|
||||
// (SplitText, a typewriter, anything runtime-inserted) would expire this
|
||||
// cache permanently and put it back to one whole-tree scan per parse. Only
|
||||
// a group ELEMENT appearing or leaving actually changes the answer, so
|
||||
// childList records are filtered rather than trusted; attribute records
|
||||
// always count, because the filter below already narrowed them.
|
||||
const relevant = records.some(
|
||||
(record) =>
|
||||
record.type !== "childList" ||
|
||||
[...record.addedNodes, ...record.removedNodes].some(
|
||||
(node) =>
|
||||
node instanceof Element &&
|
||||
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
|
||||
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
|
||||
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
|
||||
),
|
||||
);
|
||||
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
||||
});
|
||||
const observer = new MutationObserver((records) => countGroupChanges(doc, records));
|
||||
groupObservers.set(doc, observer);
|
||||
observer.observe(doc.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
@@ -112,6 +109,9 @@ export function invalidateGroupInfoCache(doc: Document | null | undefined): void
|
||||
export function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
|
||||
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
||||
observeGroupState(doc);
|
||||
// A write earlier in this task has not reached the observer's callback yet.
|
||||
const unseen = groupObservers.get(doc)?.takeRecords();
|
||||
if (unseen?.length) countGroupChanges(doc, unseen);
|
||||
const revision = groupRevisions.get(doc) ?? 0;
|
||||
const cached = groupInfoCache.get(doc);
|
||||
let info = cached && cached.revision === revision ? cached.entries : undefined;
|
||||
|
||||
@@ -60,7 +60,15 @@ export async function readProjectFileContent(pid: string, path: string): Promise
|
||||
return data.content;
|
||||
}
|
||||
|
||||
export async function saveProjectFilesWithHistory({
|
||||
export async function saveProjectFilesWithHistory(
|
||||
input: SaveProjectFilesWithHistoryInput,
|
||||
): Promise<string[]> {
|
||||
return serializeStudioFileMutations(input.writeFile, Object.keys(input.files), () =>
|
||||
writeProjectFilesWithHistoryInQueue(input),
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeProjectFilesWithHistoryInQueue({
|
||||
label,
|
||||
coalesceKey,
|
||||
coalesceMs,
|
||||
@@ -70,39 +78,37 @@ export async function saveProjectFilesWithHistory({
|
||||
recordEdit,
|
||||
diskContent,
|
||||
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
|
||||
return serializeStudioFileMutations(writeFile, Object.keys(files), async () => {
|
||||
const snapshots: Record<string, { before: string; after: string }> = {};
|
||||
for (const [path, after] of Object.entries(files)) {
|
||||
const before = await readFile(path);
|
||||
if (before !== after) {
|
||||
snapshots[path] = { before, after };
|
||||
}
|
||||
const snapshots: Record<string, { before: string; after: string }> = {};
|
||||
for (const [path, after] of Object.entries(files)) {
|
||||
const before = await readFile(path);
|
||||
if (before !== after) {
|
||||
snapshots[path] = { before, after };
|
||||
}
|
||||
}
|
||||
|
||||
const changedPaths = Object.keys(snapshots);
|
||||
if (changedPaths.length === 0) return [];
|
||||
|
||||
const writtenPaths: string[] = [];
|
||||
try {
|
||||
for (const path of changedPaths) {
|
||||
await writeFile(path, snapshots[path].after, diskContent?.[path] ?? snapshots[path].before);
|
||||
writtenPaths.push(path);
|
||||
}
|
||||
|
||||
const changedPaths = Object.keys(snapshots);
|
||||
if (changedPaths.length === 0) return [];
|
||||
|
||||
const writtenPaths: string[] = [];
|
||||
await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });
|
||||
} catch (error) {
|
||||
try {
|
||||
for (const path of changedPaths) {
|
||||
await writeFile(path, snapshots[path].after, diskContent?.[path] ?? snapshots[path].before);
|
||||
writtenPaths.push(path);
|
||||
for (const path of writtenPaths.reverse()) {
|
||||
await writeFile(path, snapshots[path].before, snapshots[path].after);
|
||||
}
|
||||
|
||||
await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });
|
||||
} catch (error) {
|
||||
try {
|
||||
for (const path of writtenPaths.reverse()) {
|
||||
await writeFile(path, snapshots[path].before, snapshots[path].after);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
"Failed to save project files and rollback did not complete",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
"Failed to save project files and rollback did not complete",
|
||||
);
|
||||
}
|
||||
return changedPaths;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return changedPaths;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ const STORYBOARD_VIEW_REASON =
|
||||
"owner-directed removal of the Studio storyboard view; its only readers were deleted with it";
|
||||
|
||||
export const ALLOWED_DELETIONS = new Map([
|
||||
[
|
||||
"packages/studio/src/player/components/automationGestureKeys.ts",
|
||||
"automation-lane saves now persist once per gesture through the timeline save, so no caller needs a gesture undo key",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/player/components/automationGestureKeys.test.ts",
|
||||
"tests for the removed automation gesture undo keys",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/utils/editHistory.ts",
|
||||
"held only EditHistoryKind; recordEdit's kind was never sent to the project history, so it and every caller's copy go",
|
||||
|
||||
Reference in New Issue
Block a user