@hyperframes/studio 0.8.79 → 0.8.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
- package/dist/assets/{index-DBKySIo7.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-CJP92F4-.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-ORZXYB0k.js → index-J9HXe7mU.js} +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1491 -1177
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +5 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +81 -47
- package/src/hooks/timelineEditPermission.ts +9 -1
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +82 -36
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useTimelineEditing.ts +19 -16
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +23 -0
- package/src/player/components/Player.tsx +4 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +2 -0
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- package/src/player/lib/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
|
@@ -108,7 +108,7 @@ function resolveDeleteWrite(
|
|
|
108
108
|
state: PlayerState,
|
|
109
109
|
lanes: UseAutomationLanesResult,
|
|
110
110
|
sel: AutomationSelection,
|
|
111
|
-
): { onCommit
|
|
111
|
+
): { onCommit: AutomationLaneBinding["onCommit"]; next: HfAutomation } | null {
|
|
112
112
|
const ctx = resolveSelectionContext(state, lanes, sel);
|
|
113
113
|
if (!ctx) return null;
|
|
114
114
|
const points = ctx.lane.points.filter((p) => !pointInSelection(p, sel));
|
|
@@ -139,18 +139,7 @@ function pasteTargetName(
|
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
141
|
* Where Cmd+V lands, or null when nothing is selected, the clip's lanes are
|
|
142
|
-
* read-only, it has no automation lane to fall back to
|
|
143
|
-
* would write the result to a DIFFERENT clip.
|
|
144
|
-
*
|
|
145
|
-
* That last guard is the one with teeth. `binding.onCommit` persists through
|
|
146
|
-
* handleDomAttributeQuietCommit, which targets whatever the dom-edit layer
|
|
147
|
-
* currently has selected — not the element `bind()` was handed (see the
|
|
148
|
-
* doc-comment on `onSelect` in useAutomationLanes). Selecting a clip in the
|
|
149
|
-
* timeline sets `selectedElementId` synchronously but resolves the dom-edit
|
|
150
|
-
* selection asynchronously, so clicking clip B and immediately pressing Cmd+V
|
|
151
|
-
* would serialize B's automation onto A. Every other lane path is a pointer
|
|
152
|
-
* gesture on the lane itself, which cannot run before the selection lands;
|
|
153
|
-
* paste is the only one that can, so it refuses rather than write blind.
|
|
142
|
+
* read-only, or it has no automation lane to fall back to.
|
|
154
143
|
*/
|
|
155
144
|
function resolvePasteTarget(
|
|
156
145
|
state: PlayerState,
|
|
@@ -169,7 +158,6 @@ function resolvePasteTarget(
|
|
|
169
158
|
const elementKey = elementKeyOf(element);
|
|
170
159
|
const binding = lanes.bind(element, true);
|
|
171
160
|
if (binding.readOnly) return null;
|
|
172
|
-
if (binding.commitTargetKey !== elementKey) return null;
|
|
173
161
|
const target = pasteTargetName(binding, elementKey, sel);
|
|
174
162
|
if (!target) return null;
|
|
175
163
|
const range = resolveAutomationRange(target, binding.chain ?? undefined);
|
|
@@ -225,6 +213,8 @@ function pasteAnchor(
|
|
|
225
213
|
return clampNumber(raw, 0, element.duration - span);
|
|
226
214
|
}
|
|
227
215
|
|
|
216
|
+
let latestPaste = 0;
|
|
217
|
+
|
|
228
218
|
/**
|
|
229
219
|
* Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the active
|
|
230
220
|
* selection or the playhead. Returns false (untouched event) when the chord
|
|
@@ -252,12 +242,10 @@ function handlePaste(
|
|
|
252
242
|
|
|
253
243
|
e.preventDefault();
|
|
254
244
|
e.stopImmediatePropagation();
|
|
255
|
-
paste.binding.onCommit(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Full-height box over the pasted span: everything that landed is selected, so
|
|
260
|
-
// Delete straight after a paste undoes it in one press.
|
|
245
|
+
const saved = paste.binding.onCommit(
|
|
246
|
+
withLane(paste.binding.automation, { target: paste.target, points }),
|
|
247
|
+
);
|
|
248
|
+
const seq = ++latestPaste;
|
|
261
249
|
const mark = {
|
|
262
250
|
elementKey: paste.elementKey,
|
|
263
251
|
target: paste.target,
|
|
@@ -266,8 +254,17 @@ function handlePaste(
|
|
|
266
254
|
v0: paste.range.min,
|
|
267
255
|
v1: paste.range.max,
|
|
268
256
|
};
|
|
269
|
-
|
|
270
|
-
|
|
257
|
+
// Once the latest paste lands, and only over the selection it was pasted at, select
|
|
258
|
+
// and mark its full-height span: the feedback that it landed, what a second Cmd+V
|
|
259
|
+
// chains after, and what Delete takes back in one press.
|
|
260
|
+
void saved.then((outcome) => {
|
|
261
|
+
if (outcome && outcome.status !== "saved") return;
|
|
262
|
+
const current = usePlayerStore.getState();
|
|
263
|
+
const moved = current.selectedElementId !== state.selectedElementId;
|
|
264
|
+
if (seq !== latestPaste || moved || current.automationSelection !== sel) return;
|
|
265
|
+
current.setAutomationSelection(mark);
|
|
266
|
+
markLastPaste(mark);
|
|
267
|
+
});
|
|
271
268
|
return true;
|
|
272
269
|
}
|
|
273
270
|
|
|
@@ -300,8 +297,7 @@ function handleCopy(
|
|
|
300
297
|
*
|
|
301
298
|
* Store and clipboard only, no lane binding, so the dispatcher can call it
|
|
302
299
|
* without holding the binding factory. That leaves one accepted residual: the
|
|
303
|
-
* predicate cannot see a read-only lane
|
|
304
|
-
* dom-edit target mismatch `resolvePasteTarget` guards, so in those rare cases
|
|
300
|
+
* predicate cannot see a read-only lane or an unresolvable target, so in those rare cases
|
|
305
301
|
* the keystroke is a no-op instead of falling through to clip copy/paste. A
|
|
306
302
|
* dead key beats today's double write.
|
|
307
303
|
*/
|
|
@@ -333,7 +329,7 @@ function handleDelete(
|
|
|
333
329
|
if (!write) return false;
|
|
334
330
|
e.preventDefault();
|
|
335
331
|
e.stopImmediatePropagation();
|
|
336
|
-
write.onCommit(write.next);
|
|
332
|
+
void write.onCommit(write.next);
|
|
337
333
|
return true;
|
|
338
334
|
}
|
|
339
335
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
6
|
+
import { useBlockHandlers, type UseBlockHandlersResult } from "./useBlockHandlers";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
vi.mock("../utils/blockInstaller", () => ({
|
|
11
|
+
addBlockToProject: async (opts: { showToast: (message: string) => void }) => {
|
|
12
|
+
opts.showToast("Installing catalog items needs hyperframes preview");
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("useBlockHandlers", () => {
|
|
22
|
+
it("leaves only the error toast when an install fails", async () => {
|
|
23
|
+
const events: string[] = [];
|
|
24
|
+
let nextId = 0;
|
|
25
|
+
const showToast = (message: string) => {
|
|
26
|
+
events.push(`show ${message}`);
|
|
27
|
+
return ++nextId;
|
|
28
|
+
};
|
|
29
|
+
const dismissToast = (id: number) => events.push(`dismiss ${id}`);
|
|
30
|
+
let handlers: UseBlockHandlersResult | undefined;
|
|
31
|
+
function Harness() {
|
|
32
|
+
handlers = useBlockHandlers({
|
|
33
|
+
projectId: "demo",
|
|
34
|
+
blockCtxDeps: {
|
|
35
|
+
activeCompPath: null,
|
|
36
|
+
timelineElements: [],
|
|
37
|
+
readProjectFile: async () => "",
|
|
38
|
+
writeProjectFile: async () => {},
|
|
39
|
+
recordEdit: async () => {},
|
|
40
|
+
refreshFileTree: async () => {},
|
|
41
|
+
reloadPreview: () => {},
|
|
42
|
+
showToast,
|
|
43
|
+
dismissToast,
|
|
44
|
+
},
|
|
45
|
+
previewIframeRef: { current: null },
|
|
46
|
+
setRightCollapsed: () => {},
|
|
47
|
+
setRightPanelTab: () => {},
|
|
48
|
+
});
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const root = mountReactHarness(<Harness />);
|
|
52
|
+
|
|
53
|
+
await act(async () => {
|
|
54
|
+
handlers!.handleAddBlock("x-post");
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(events).toEqual([
|
|
59
|
+
"show Adding x-post…",
|
|
60
|
+
"show Installing catalog items needs hyperframes preview",
|
|
61
|
+
"dismiss 1",
|
|
62
|
+
]);
|
|
63
|
+
act(() => root.unmount());
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -23,7 +23,8 @@ interface BlockCtxDeps {
|
|
|
23
23
|
}) => Promise<void>;
|
|
24
24
|
refreshFileTree: () => Promise<void>;
|
|
25
25
|
reloadPreview: () => void;
|
|
26
|
-
showToast: (message: string, tone?: "error" | "info") =>
|
|
26
|
+
showToast: (message: string, tone?: "error" | "info") => number;
|
|
27
|
+
dismissToast: (id: number) => void;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
interface UseBlockHandlersParams {
|
|
@@ -87,21 +88,23 @@ export function useBlockHandlers({
|
|
|
87
88
|
// Block installs hit the server and end in a full preview reload; without a
|
|
88
89
|
// guard, repeat drops while one is in flight stack duplicate installs.
|
|
89
90
|
const installingBlockRef = useRef(false);
|
|
91
|
+
const { showToast, dismissToast } = blockCtxDeps;
|
|
90
92
|
const runBlockInstall = useCallback(
|
|
91
93
|
async <T>(blockName: string, install: () => Promise<T>): Promise<T | null> => {
|
|
92
94
|
if (installingBlockRef.current) {
|
|
93
|
-
|
|
95
|
+
showToast("A block is already installing — one moment…", "info");
|
|
94
96
|
return null;
|
|
95
97
|
}
|
|
96
98
|
installingBlockRef.current = true;
|
|
97
|
-
|
|
99
|
+
const progress = showToast(`Adding ${blockName}…`, "info");
|
|
98
100
|
try {
|
|
99
101
|
return await install();
|
|
100
102
|
} finally {
|
|
101
103
|
installingBlockRef.current = false;
|
|
104
|
+
dismissToast(progress);
|
|
102
105
|
}
|
|
103
106
|
},
|
|
104
|
-
[
|
|
107
|
+
[showToast, dismissToast],
|
|
105
108
|
);
|
|
106
109
|
|
|
107
110
|
const handleAddBlock = useCallback(
|
|
@@ -29,15 +29,6 @@ interface DataAttributeCommitOptions {
|
|
|
29
29
|
skipRefresh: boolean;
|
|
30
30
|
refreshAfter?: boolean;
|
|
31
31
|
onSettled?: (ok: boolean) => void;
|
|
32
|
-
/**
|
|
33
|
-
* Undo grouping for a gesture that spans several commits.
|
|
34
|
-
*
|
|
35
|
-
* Without it the key is derived from the prefix, attribute and element, and the
|
|
36
|
-
* window is history's own 300ms — so a drag's moves and the release that ends it
|
|
37
|
-
* landed in different entries, and a drag slower than the window split further.
|
|
38
|
-
* A caller that knows a gesture is in progress passes one key for all of it.
|
|
39
|
-
*/
|
|
40
|
-
coalesce?: { key: string; ms: number };
|
|
41
32
|
/**
|
|
42
33
|
* Apply to the preview and stop there — no file write, no history entry.
|
|
43
34
|
*
|
|
@@ -150,9 +141,7 @@ export function useDomEditAttributeCommits({
|
|
|
150
141
|
if (!domEditSelection) return;
|
|
151
142
|
const iframe = previewIframeRef.current;
|
|
152
143
|
const fullAttr = resolveFullAttrName(attr, true);
|
|
153
|
-
const commitKey =
|
|
154
|
-
options.coalesce?.key ??
|
|
155
|
-
`${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
|
|
144
|
+
const commitKey = `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
|
|
156
145
|
const isLatestCommit = bumpDomEditCommitMapVersion(
|
|
157
146
|
domAttributeCommitVersionRef.current,
|
|
158
147
|
commitKey,
|
|
@@ -184,7 +173,6 @@ export function useDomEditAttributeCommits({
|
|
|
184
173
|
persistDomEditOperations(domEditSelection, [op], {
|
|
185
174
|
label: options.label,
|
|
186
175
|
coalesceKey: commitKey,
|
|
187
|
-
...(options.coalesce ? { coalesceMs: options.coalesce.ms } : {}),
|
|
188
176
|
skipRefresh: options.skipRefresh,
|
|
189
177
|
}),
|
|
190
178
|
shouldRevert: () => isLatestCommit(),
|
|
@@ -338,14 +326,13 @@ export function useDomEditAttributeCommits({
|
|
|
338
326
|
attr: string,
|
|
339
327
|
value: string | null,
|
|
340
328
|
onSettled?: (ok: boolean) => void,
|
|
341
|
-
live?: {
|
|
329
|
+
live?: { previewOnly?: boolean },
|
|
342
330
|
) => {
|
|
343
331
|
await commitDataAttribute(attr, value, {
|
|
344
332
|
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
|
345
333
|
coalescePrefix: "attr-live",
|
|
346
334
|
skipRefresh: true,
|
|
347
335
|
onSettled,
|
|
348
|
-
...(live?.coalesce ? { coalesce: live.coalesce } : {}),
|
|
349
336
|
...(live?.previewOnly ? { previewOnly: true } : {}),
|
|
350
337
|
});
|
|
351
338
|
},
|
|
@@ -362,13 +349,12 @@ export function useDomEditAttributeCommits({
|
|
|
362
349
|
* edit computes from a pre-edit value and appears to do nothing.
|
|
363
350
|
*/
|
|
364
351
|
const handleDomAttributeQuietCommit = useCallback(
|
|
365
|
-
async (attr: string, value: string | null
|
|
352
|
+
async (attr: string, value: string | null) => {
|
|
366
353
|
await commitDataAttribute(attr, value, {
|
|
367
354
|
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
|
368
355
|
coalescePrefix: "attr-quiet",
|
|
369
356
|
skipRefresh: true,
|
|
370
357
|
refreshAfter: true,
|
|
371
|
-
...(coalesce ? { coalesce } : {}),
|
|
372
358
|
});
|
|
373
359
|
},
|
|
374
360
|
[commitDataAttribute],
|
|
@@ -33,7 +33,7 @@ export interface UseEditHistoryActionsOptions {
|
|
|
33
33
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
34
|
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
|
|
35
35
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
36
|
-
onAfterUndoRedo?: () => void;
|
|
36
|
+
onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
|
|
37
37
|
/** Active composition path — decides whether undo/redo must resync the SDK session. */
|
|
38
38
|
activeCompPath?: string | null;
|
|
39
39
|
/** Reloads the SDK session after a revert of the active comp, past the self-write suppress window. */
|
|
@@ -84,11 +84,12 @@ export function useEditHistoryActions({
|
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
86
|
if (result.ok && result.label) {
|
|
87
|
-
|
|
87
|
+
const restore = { paths: result.paths, files: result.files };
|
|
88
|
+
onAfterUndoRedo?.(restore);
|
|
88
89
|
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
89
90
|
forceReloadSdkSession?.();
|
|
90
91
|
}
|
|
91
|
-
await syncHistoryPreviewAfterApply(
|
|
92
|
+
await syncHistoryPreviewAfterApply(restore);
|
|
92
93
|
showToast(result.label, "info");
|
|
93
94
|
}
|
|
94
95
|
},
|
|
@@ -94,6 +94,55 @@ describe("external file change coordinator", () => {
|
|
|
94
94
|
expect(refreshFileTree).toHaveBeenCalledOnce();
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
it("refreshes the file tree but not Preview for a file the preview never loaded", async () => {
|
|
98
|
+
const order: string[] = [];
|
|
99
|
+
await mountCoordinator({
|
|
100
|
+
reloadPreview: () => order.push("preview"),
|
|
101
|
+
reloadSdkSession: () => order.push("sdk"),
|
|
102
|
+
refreshFileTree: () => {
|
|
103
|
+
order.push("tree");
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
await act(async () =>
|
|
107
|
+
handler?.({ path: "notes.md", content: "notes", version: "v1", affectsPreview: false }),
|
|
108
|
+
);
|
|
109
|
+
expect(order).toEqual(["sdk", "tree"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("still reloads Preview when a notes change replaces a waiting film change", async () => {
|
|
113
|
+
let release = () => {};
|
|
114
|
+
const inFlight = new Promise<void>((resolve) => (release = resolve));
|
|
115
|
+
const reloadPreview = vi.fn();
|
|
116
|
+
const reloadSdkSession = vi.fn();
|
|
117
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
118
|
+
let drains = 0;
|
|
119
|
+
await mountCoordinator({
|
|
120
|
+
reloadPreview,
|
|
121
|
+
reloadSdkSession,
|
|
122
|
+
onAcceptedPersistedFileChange,
|
|
123
|
+
drainPendingChanges: async () => {
|
|
124
|
+
if (drains++ === 0) await inFlight;
|
|
125
|
+
return { status: "clean" as const };
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
await act(async () => handler?.({ path: "index.html", content: "a", version: "v1" }));
|
|
129
|
+
await act(async () =>
|
|
130
|
+
handler?.({
|
|
131
|
+
path: "scene.html",
|
|
132
|
+
content: "b",
|
|
133
|
+
version: "v2",
|
|
134
|
+
affectedCompositions: ["scene.html"],
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
await act(async () =>
|
|
138
|
+
handler?.({ path: "notes.md", content: "c", version: "v3", affectsPreview: false }),
|
|
139
|
+
);
|
|
140
|
+
await act(async () => release());
|
|
141
|
+
expect(reloadPreview).toHaveBeenCalledTimes(2);
|
|
142
|
+
expect(reloadSdkSession).toHaveBeenLastCalledWith("scene.html");
|
|
143
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("scene.html", ["scene.html"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
97
146
|
it("does not refresh the tree for a suppressed self-write echo", async () => {
|
|
98
147
|
const refreshFileTree = vi.fn();
|
|
99
148
|
await mountCoordinator({ refreshFileTree });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
2
2
|
import {
|
|
3
3
|
readFileChangeAffectedCompositions,
|
|
4
|
+
readFileChangeAffectsPreview,
|
|
4
5
|
readFileChangeField,
|
|
5
6
|
readStudioFileChangePath,
|
|
6
7
|
} from "../components/editor/manualEdits";
|
|
@@ -231,9 +232,12 @@ export function useExternalFileChangeCoordinator({
|
|
|
231
232
|
}, [loadConflictSnapshot, projectId, recoveryFilePath]);
|
|
232
233
|
|
|
233
234
|
const reloadAcceptedGeneration = useCallback(
|
|
234
|
-
(path: string) => {
|
|
235
|
-
logReload("reload"
|
|
236
|
-
|
|
235
|
+
(path: string, affectsPreview = true) => {
|
|
236
|
+
logReload(affectsPreview ? "reload" : "file-tree only", {
|
|
237
|
+
path,
|
|
238
|
+
by: "external-change coordinator",
|
|
239
|
+
});
|
|
240
|
+
if (affectsPreview) reloadPreview();
|
|
237
241
|
reloadSdkSession(path);
|
|
238
242
|
// Fire-and-forget: a failed refresh leaves the tree as stale as it was,
|
|
239
243
|
// which is the status quo this exists to improve on, not a new failure
|
|
@@ -279,7 +283,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
279
283
|
if (!mountedRef.current || generation !== generationRef.current) return;
|
|
280
284
|
setBlocked(null);
|
|
281
285
|
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
|
|
282
|
-
reloadAcceptedGeneration(path);
|
|
286
|
+
reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
|
|
283
287
|
return;
|
|
284
288
|
}
|
|
285
289
|
const content = readFileChangeContent(payload);
|
|
@@ -410,7 +414,12 @@ export function useExternalFileChangeCoordinator({
|
|
|
410
414
|
return;
|
|
411
415
|
}
|
|
412
416
|
|
|
413
|
-
pendingPayloadRef.current
|
|
417
|
+
const waiting = pendingPayloadRef.current?.payload;
|
|
418
|
+
const waitingChangeOutranksThis =
|
|
419
|
+
waiting != null &&
|
|
420
|
+
readFileChangeAffectsPreview(waiting) &&
|
|
421
|
+
!readFileChangeAffectsPreview(payload);
|
|
422
|
+
if (!waitingChangeOutranksThis) pendingPayloadRef.current = { payload };
|
|
414
423
|
void startDrainLoop();
|
|
415
424
|
},
|
|
416
425
|
[projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
|
|
@@ -456,7 +465,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
456
465
|
await deleteConflictSnapshot?.(projectId, path);
|
|
457
466
|
setBlocked(null);
|
|
458
467
|
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(current.payload));
|
|
459
|
-
reloadAcceptedGeneration(path);
|
|
468
|
+
reloadAcceptedGeneration(path, readFileChangeAffectsPreview(current.payload));
|
|
460
469
|
},
|
|
461
470
|
[
|
|
462
471
|
deleteConflictSnapshot,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
playbackStartAttributeForElement,
|
|
12
12
|
persistTimelineEdit,
|
|
13
13
|
formatTimelineAttributeNumber,
|
|
14
|
+
formatTimelineMediaOffset,
|
|
14
15
|
extendRootDurationIfNeeded,
|
|
15
16
|
buildTimelineMoveTimingPatch,
|
|
16
17
|
buildTimelineResizeTimingPatch,
|
|
@@ -313,7 +314,7 @@ export function useTimelineEditing({
|
|
|
313
314
|
];
|
|
314
315
|
if (updates.playbackStart != null) {
|
|
315
316
|
const liveAttr = playbackStartAttributeForElement(element);
|
|
316
|
-
liveAttrs.push([liveAttr,
|
|
317
|
+
liveAttrs.push([liveAttr, formatTimelineMediaOffset(updates.playbackStart)]);
|
|
317
318
|
}
|
|
318
319
|
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
|
|
319
320
|
// Snapshot the duration BEFORE the optimistic updates below so a failed
|
|
@@ -436,7 +437,7 @@ export function useTimelineEditing({
|
|
|
436
437
|
checkEditable,
|
|
437
438
|
});
|
|
438
439
|
|
|
439
|
-
const
|
|
440
|
+
const setElementFxAttribute = useSetElementAttribute({
|
|
440
441
|
projectIdRef,
|
|
441
442
|
activeCompPath,
|
|
442
443
|
showToast,
|
|
@@ -447,18 +448,16 @@ export function useTimelineEditing({
|
|
|
447
448
|
isRecordingRef,
|
|
448
449
|
});
|
|
449
450
|
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
},
|
|
461
|
-
);
|
|
451
|
+
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
|
452
|
+
projectIdRef,
|
|
453
|
+
activeCompPath,
|
|
454
|
+
showToast,
|
|
455
|
+
writeProjectFile,
|
|
456
|
+
recordEdit,
|
|
457
|
+
previewIframeRef,
|
|
458
|
+
pendingTimelineEditPathRef,
|
|
459
|
+
isRecordingRef,
|
|
460
|
+
});
|
|
462
461
|
|
|
463
462
|
const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
|
|
464
463
|
projectIdRef,
|
|
@@ -550,7 +549,7 @@ export function useTimelineEditing({
|
|
|
550
549
|
// no flat twin, only a domClipChildren entry, so both are checked.
|
|
551
550
|
setQuiet: track(
|
|
552
551
|
guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
|
|
553
|
-
|
|
552
|
+
setAudioGroupAttribute.revertLive(groupId, attr);
|
|
554
553
|
return refused(reason);
|
|
555
554
|
}),
|
|
556
555
|
),
|
|
@@ -562,7 +561,7 @@ export function useTimelineEditing({
|
|
|
562
561
|
(element) => [element],
|
|
563
562
|
setElementFxAttribute.setQuiet,
|
|
564
563
|
(reason, element, attr) => {
|
|
565
|
-
|
|
564
|
+
setElementFxAttribute.revertLive(element, attr);
|
|
566
565
|
return refused(reason);
|
|
567
566
|
},
|
|
568
567
|
),
|
|
@@ -591,5 +590,9 @@ export function useTimelineEditing({
|
|
|
591
590
|
handleTimelineGroupResize: track(
|
|
592
591
|
guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize),
|
|
593
592
|
),
|
|
593
|
+
restoreLiveLanes: (restore: Parameters<typeof setElementFxAttribute.restoreLive>[0]) => {
|
|
594
|
+
setElementFxAttribute.restoreLive(restore);
|
|
595
|
+
setAudioGroupAttribute.restoreLive(restore);
|
|
596
|
+
},
|
|
594
597
|
};
|
|
595
598
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildTimelineResizeTimingPatch,
|
|
14
14
|
extendRootDurationIfNeeded,
|
|
15
15
|
formatTimelineAttributeNumber,
|
|
16
|
+
formatTimelineMediaOffset,
|
|
16
17
|
patchIframeDomTiming,
|
|
17
18
|
playbackStartAttributeForElement,
|
|
18
19
|
persistTimelineBatchEdit,
|
|
@@ -377,7 +378,7 @@ export function useTimelineGroupEditing({
|
|
|
377
378
|
];
|
|
378
379
|
if (change.playbackStart != null) {
|
|
379
380
|
const liveAttr = playbackStartAttributeForElement(change.element);
|
|
380
|
-
liveAttrs.push([liveAttr,
|
|
381
|
+
liveAttrs.push([liveAttr, formatTimelineMediaOffset(change.playbackStart)]);
|
|
381
382
|
}
|
|
382
383
|
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
|
|
383
384
|
}
|
package/src/hooks/useToast.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function useToast() {
|
|
|
50
50
|
);
|
|
51
51
|
|
|
52
52
|
const showToast = useCallback(
|
|
53
|
-
(message: string, tone: AppToast["tone"] = "error") => {
|
|
53
|
+
(message: string, tone: AppToast["tone"] = "error"): number => {
|
|
54
54
|
const id = nextToastId++;
|
|
55
55
|
setToasts((prev) => {
|
|
56
56
|
const next = [...prev, { id, message, tone }];
|
|
@@ -65,6 +65,7 @@ export function useToast() {
|
|
|
65
65
|
const timer = setTimeout(() => dismissToast(id), AUTO_DISMISS_MS);
|
|
66
66
|
timersRef.current.set(id, timer);
|
|
67
67
|
}
|
|
68
|
+
return id;
|
|
68
69
|
},
|
|
69
70
|
[clearTimer, dismissToast],
|
|
70
71
|
);
|
|
@@ -2,8 +2,8 @@ import { memo, useCallback, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
4
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
|
-
import {
|
|
6
|
-
import { computeThumbnailStrip
|
|
5
|
+
import { decodeImageThumbnail } from "../lib/thumbnailImageDecoder";
|
|
6
|
+
import { computeThumbnailStrip } from "./thumbnailUtils";
|
|
7
7
|
|
|
8
8
|
export interface ImageThumbnailProps {
|
|
9
9
|
imageSrc: string;
|
|
@@ -35,16 +35,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
|
|
|
35
35
|
kind: "image" as const,
|
|
36
36
|
priority,
|
|
37
37
|
rich,
|
|
38
|
-
load:
|
|
39
|
-
const aspect = await probeImageAspect(imageSrc, signal, true);
|
|
40
|
-
return {
|
|
41
|
-
value: { kind: "image" as const, url: imageSrc, aspect },
|
|
42
|
-
weight:
|
|
43
|
-
TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth *
|
|
44
|
-
TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight *
|
|
45
|
-
4,
|
|
46
|
-
};
|
|
47
|
-
},
|
|
38
|
+
load: (signal: AbortSignal) => decodeImageThumbnail(imageSrc, signal),
|
|
48
39
|
}),
|
|
49
40
|
[imageSrc, priority, projectId, rich, sessionEpoch],
|
|
50
41
|
);
|
|
@@ -277,6 +277,29 @@ describe("ready to show", () => {
|
|
|
277
277
|
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
+
it("can show a document whose player was ready and painted before its load event", async () => {
|
|
281
|
+
const onReadyToShowChange = vi.fn();
|
|
282
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
283
|
+
const el = Object.assign(player as TestHyperframesPlayer, { ready: true, painted: true });
|
|
284
|
+
|
|
285
|
+
act(() => {
|
|
286
|
+
el.dispatchEvent(new Event("ready"));
|
|
287
|
+
el.dispatchEvent(new Event("painted"));
|
|
288
|
+
el.iframeElement.dispatchEvent(new Event("load"));
|
|
289
|
+
});
|
|
290
|
+
await twoFrames();
|
|
291
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
|
|
292
|
+
|
|
293
|
+
for (const state of [
|
|
294
|
+
{ ready: false, painted: true },
|
|
295
|
+
{ ready: true, painted: false },
|
|
296
|
+
]) {
|
|
297
|
+
Object.assign(el, state);
|
|
298
|
+
act(() => void el.iframeElement.dispatchEvent(new Event("load")));
|
|
299
|
+
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
280
303
|
it("marks the preview booted when it can show and play, not at ready", async () => {
|
|
281
304
|
usePlayerStore.setState({ previewBooted: false });
|
|
282
305
|
const { player } = await mountPlayer({});
|
|
@@ -39,6 +39,8 @@ interface PlayerProps {
|
|
|
39
39
|
|
|
40
40
|
interface HyperframesPlayerElement extends HTMLElement {
|
|
41
41
|
iframeElement: HTMLIFrameElement;
|
|
42
|
+
ready?: boolean;
|
|
43
|
+
painted?: boolean;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const MEDIA_HAVE_FUTURE_DATA = 3;
|
|
@@ -245,8 +247,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
245
247
|
setLoaded(true);
|
|
246
248
|
setPreviewError(null);
|
|
247
249
|
setShaderTransitionLoading(false);
|
|
248
|
-
setCompositionLoading(
|
|
249
|
-
setPainted(
|
|
250
|
+
setCompositionLoading(!player.ready);
|
|
251
|
+
setPainted(Boolean(player.painted));
|
|
250
252
|
// Reveal animation on reload (hot-reload, composition switch)
|
|
251
253
|
if (loadCountRef.current > 1) {
|
|
252
254
|
container.classList.remove("preview-revealing");
|
|
@@ -338,6 +338,24 @@ describe("TimelineAutomationLane", () => {
|
|
|
338
338
|
);
|
|
339
339
|
});
|
|
340
340
|
|
|
341
|
+
it("drops the dragged position when the save does not land", async () => {
|
|
342
|
+
const onCommit = vi.fn(async () => ({ status: "refused" as const, reason: "Locked" }));
|
|
343
|
+
const { container } = render(
|
|
344
|
+
<TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
|
|
345
|
+
);
|
|
346
|
+
const svg = container.querySelector("svg")!;
|
|
347
|
+
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
|
|
348
|
+
const before = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"));
|
|
349
|
+
fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
|
|
350
|
+
fire(svg, "pointermove", { clientX: 160, clientY: 40 });
|
|
351
|
+
fire(svg, "pointerup", { clientX: 160, clientY: 40 });
|
|
352
|
+
await act(async () => {});
|
|
353
|
+
expect(Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"))).toBeCloseTo(
|
|
354
|
+
before,
|
|
355
|
+
5,
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
341
359
|
it("follows the prop again once the store catches up", () => {
|
|
342
360
|
const { container, rerender } = renderRerenderable(
|
|
343
361
|
<TimelineAutomationLane {...laneProps({ automation: ramp })} />,
|
|
@@ -359,6 +377,25 @@ describe("TimelineAutomationLane", () => {
|
|
|
359
377
|
expect(Number(circles[0]!.getAttribute("cx"))).toBeCloseTo(PAD + 300, 0);
|
|
360
378
|
});
|
|
361
379
|
|
|
380
|
+
it("commits the dragged points when an older save lands mid-drag", () => {
|
|
381
|
+
const onCommit = vi.fn(async (_next: HfAutomation) => ({ status: "saved" as const }));
|
|
382
|
+
const { container, rerender } = renderRerenderable(
|
|
383
|
+
<TimelineAutomationLane {...laneProps({ automation: ramp, onCommit })} />,
|
|
384
|
+
);
|
|
385
|
+
const svg = container.querySelector("svg")!;
|
|
386
|
+
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
|
|
387
|
+
fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
|
|
388
|
+
fire(svg, "pointermove", { clientX: 160, clientY: 40 });
|
|
389
|
+
fire(svg, "pointerup", { clientX: 160, clientY: 40 });
|
|
390
|
+
const older = onCommit.mock.calls[0]![0];
|
|
391
|
+
fire(svg, "pointerdown", { clientX: 160, clientY: 40 });
|
|
392
|
+
fire(svg, "pointermove", { clientX: 320, clientY: 40 });
|
|
393
|
+
rerender(<TimelineAutomationLane {...laneProps({ automation: older, onCommit })} />);
|
|
394
|
+
fire(svg, "pointerup", { clientX: 320, clientY: 40 });
|
|
395
|
+
const released = onCommit.mock.calls[1]![0];
|
|
396
|
+
expect(released.lanes[0]!.points[0]!.t).toBeGreaterThan(older.lanes[0]!.points[0]!.t + 1);
|
|
397
|
+
});
|
|
398
|
+
|
|
362
399
|
it("keeps lane order when editing, so the view does not switch parameters", () => {
|
|
363
400
|
// The displayed lane defaults to the first one. Moving the edited lane to
|
|
364
401
|
// the end of the list swapped the lane out from under the pointer on the
|
|
@@ -1627,6 +1664,7 @@ describe("TimelineAutomationLane stretch", () => {
|
|
|
1627
1664
|
const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]
|
|
1628
1665
|
?.points;
|
|
1629
1666
|
expect(reverted).toEqual(stretchable.lanes[0]?.points);
|
|
1667
|
+
expect(props.onPreview.mock.calls.at(-1)?.[1]).toBe(true);
|
|
1630
1668
|
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5, 0, 1);
|
|
1631
1669
|
});
|
|
1632
1670
|
|