@hyperframes/studio 0.8.78 → 0.8.79
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-BdnQGwG5.js → hyperframes-player-Bx_Kw4Ey.js} +3 -3
- package/dist/assets/{index-C_n__Uh3.js → index-CJP92F4-.js} +1 -1
- package/dist/assets/{index-CX_4MYbk.js → index-DBKySIo7.js} +187 -187
- package/dist/assets/{index-DdpuHaGN.js → index-ORZXYB0k.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +22 -15
- package/dist/index.html +1 -1
- package/dist/index.js +452 -256
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -2
- package/src/hooks/timelineAudioGroupVolume.ts +21 -11
- package/src/hooks/timelineEditPermission.ts +47 -13
- package/src/hooks/timelineElementFxAttribute.ts +19 -13
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +42 -33
- package/src/index.ts +1 -0
- package/src/player/components/Player.test.ts +5 -0
- package/src/player/components/Player.tsx +1 -0
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/timelineCallbacks.ts +3 -2
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
resolveSourceFile,
|
|
5
|
+
applyPatch,
|
|
6
|
+
applyPatchByTarget,
|
|
7
|
+
findTagByTarget,
|
|
8
|
+
type PatchOperation,
|
|
9
|
+
} from "../utils/sourcePatcher";
|
|
4
10
|
import {
|
|
5
11
|
acceptStudioRuntimeMessage,
|
|
6
12
|
postRuntimeControlMessage,
|
|
7
13
|
} from "../player/lib/runtimeProtocol";
|
|
14
|
+
import { compositionPathOfPreviewUrl } from "../player/components/CompositionThumbnail";
|
|
15
|
+
import { getSourceFileForElement } from "../components/editor/domEditingDom";
|
|
8
16
|
|
|
9
17
|
export interface PickedElement {
|
|
10
18
|
id: string | null;
|
|
@@ -91,6 +99,8 @@ export function useElementPicker(
|
|
|
91
99
|
|
|
92
100
|
// Listen for picker messages from the iframe
|
|
93
101
|
useMountEffect(() => {
|
|
102
|
+
// One guard per message field, then one branch per message type.
|
|
103
|
+
// fallow-ignore-next-line complexity
|
|
94
104
|
const handleMessage = (e: MessageEvent) => {
|
|
95
105
|
const data = e.data;
|
|
96
106
|
if (data?.source !== "hf-preview") return;
|
|
@@ -101,40 +111,13 @@ export function useElementPicker(
|
|
|
101
111
|
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow)
|
|
102
112
|
return;
|
|
103
113
|
|
|
104
|
-
if (data.type === "element-picked") {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const styles = readComputedStyles(activeIframe, el.selector);
|
|
108
|
-
setPickedElement({
|
|
109
|
-
id: el.id ?? null,
|
|
110
|
-
tagName: el.tagName ?? "div",
|
|
111
|
-
selector: el.selector ?? "",
|
|
112
|
-
label: el.label ?? el.tagName ?? "Element",
|
|
113
|
-
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
114
|
-
textContent: el.textContent ?? null,
|
|
115
|
-
src: el.src ?? null,
|
|
116
|
-
dataAttributes: el.dataAttributes ?? {},
|
|
117
|
-
computedStyles: styles,
|
|
118
|
-
});
|
|
119
|
-
setIsPickMode(false);
|
|
120
|
-
}
|
|
114
|
+
if (data.type === "element-picked" && data.elementInfo) {
|
|
115
|
+
setPickedElement(toPickedElement(data.elementInfo, activeIframe));
|
|
116
|
+
setIsPickMode(false);
|
|
121
117
|
} else if (data.type === "element-pick-candidates") {
|
|
122
118
|
// Multiple candidates at click point — pick the first one
|
|
123
119
|
const el = data.candidates?.[data.selectedIndex ?? 0];
|
|
124
|
-
if (el)
|
|
125
|
-
const styles = readComputedStyles(activeIframe, el.selector);
|
|
126
|
-
setPickedElement({
|
|
127
|
-
id: el.id ?? null,
|
|
128
|
-
tagName: el.tagName ?? "div",
|
|
129
|
-
selector: el.selector ?? "",
|
|
130
|
-
label: el.label ?? el.tagName ?? "Element",
|
|
131
|
-
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
132
|
-
textContent: el.textContent ?? null,
|
|
133
|
-
src: el.src ?? null,
|
|
134
|
-
dataAttributes: el.dataAttributes ?? {},
|
|
135
|
-
computedStyles: styles,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
120
|
+
if (el) setPickedElement(toPickedElement(el, activeIframe));
|
|
138
121
|
}
|
|
139
122
|
|
|
140
123
|
if (data.type === "pick-mode-cancelled") {
|
|
@@ -149,29 +132,26 @@ export function useElementPicker(
|
|
|
149
132
|
// Ref for options to avoid stale closures in debounced callback
|
|
150
133
|
const optionsRef = useRef(options);
|
|
151
134
|
optionsRef.current = options;
|
|
135
|
+
const pendingWritesRef = useRef(new Map<string, PendingWrite>());
|
|
136
|
+
settlePendingWrites(pendingWritesRef.current, options?.workspaceFiles);
|
|
152
137
|
|
|
153
138
|
// Sync immediately (not debounced) — save on every change for reliability
|
|
154
139
|
const syncToSource = useCallback(
|
|
155
|
-
(
|
|
156
|
-
elementId: string,
|
|
157
|
-
selector: string,
|
|
158
|
-
op: {
|
|
159
|
-
type: "inline-style" | "attribute" | "text-content";
|
|
160
|
-
property: string;
|
|
161
|
-
value: string;
|
|
162
|
-
},
|
|
163
|
-
) => {
|
|
140
|
+
(picked: PickedElement, live: HTMLElement, iframe: HTMLIFrameElement, op: PatchOperation) => {
|
|
164
141
|
const opts = optionsRef.current;
|
|
165
|
-
if (!opts?.workspaceFiles || !opts.onSyncFiles
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
142
|
+
if (!opts?.workspaceFiles || !opts.onSyncFiles) return;
|
|
143
|
+
// No id: the preview's hf-id names the element, in the file it was served from.
|
|
144
|
+
const hfId = live.getAttribute("data-hf-id");
|
|
145
|
+
const pending = pendingWritesRef.current;
|
|
146
|
+
const files = withPendingWrites(opts.workspaceFiles, pending);
|
|
147
|
+
const patch = picked.id
|
|
148
|
+
? patchById(files, picked.id, picked.selector, op)
|
|
149
|
+
: hfId
|
|
150
|
+
? patchByHfId(files, hfId, ownSourceFile(live, iframe), op)
|
|
151
|
+
: null;
|
|
152
|
+
if (!patch || patch.after === patch.before) return;
|
|
153
|
+
recordPendingWrite(pending, patch.path, opts.workspaceFiles[patch.path], patch.after);
|
|
154
|
+
opts.onSyncFiles({ [patch.path]: patch.after });
|
|
175
155
|
},
|
|
176
156
|
[],
|
|
177
157
|
);
|
|
@@ -193,32 +173,11 @@ export function useElementPicker(
|
|
|
193
173
|
}
|
|
194
174
|
: null,
|
|
195
175
|
);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
property: prop,
|
|
202
|
-
value,
|
|
203
|
-
});
|
|
204
|
-
} else {
|
|
205
|
-
// No ID — save the full composition HTML from the iframe
|
|
206
|
-
// This captures ALL inline style changes, not just the targeted one
|
|
207
|
-
try {
|
|
208
|
-
const fullHtml = activeIframe.contentDocument?.documentElement.outerHTML;
|
|
209
|
-
if (fullHtml && optionsRef.current?.onSyncFiles) {
|
|
210
|
-
// Determine which file this iframe represents
|
|
211
|
-
const src = activeIframe.getAttribute("src") ?? "";
|
|
212
|
-
const compMatch = src.match(/\/comp\/(.+?)(?:\?|$)/);
|
|
213
|
-
const filePath = compMatch ? compMatch[1] : "index.html";
|
|
214
|
-
optionsRef.current.onSyncFiles({
|
|
215
|
-
[filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}`,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
} catch {
|
|
219
|
-
/* cross-origin */
|
|
220
|
-
}
|
|
221
|
-
}
|
|
176
|
+
syncToSource(pickedElement, el, activeIframe, {
|
|
177
|
+
type: "inline-style",
|
|
178
|
+
property: prop,
|
|
179
|
+
value,
|
|
180
|
+
});
|
|
222
181
|
}
|
|
223
182
|
} catch {
|
|
224
183
|
/* cross-origin */
|
|
@@ -244,14 +203,11 @@ export function useElementPicker(
|
|
|
244
203
|
}
|
|
245
204
|
: null,
|
|
246
205
|
);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
value,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
206
|
+
syncToSource(pickedElement, el as HTMLElement, activeIframe, {
|
|
207
|
+
type: "attribute",
|
|
208
|
+
property: attr,
|
|
209
|
+
value,
|
|
210
|
+
});
|
|
255
211
|
}
|
|
256
212
|
} catch {
|
|
257
213
|
/* cross-origin */
|
|
@@ -270,14 +226,11 @@ export function useElementPicker(
|
|
|
270
226
|
if (el) {
|
|
271
227
|
el.textContent = text;
|
|
272
228
|
setPickedElement((prev) => (prev ? { ...prev, textContent: text } : null));
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
value: text,
|
|
279
|
-
});
|
|
280
|
-
}
|
|
229
|
+
syncToSource(pickedElement, el as HTMLElement, activeIframe, {
|
|
230
|
+
type: "text-content",
|
|
231
|
+
property: "textContent",
|
|
232
|
+
value: text,
|
|
233
|
+
});
|
|
281
234
|
}
|
|
282
235
|
} catch {
|
|
283
236
|
/* cross-origin */
|
|
@@ -305,6 +258,101 @@ export function useElementPicker(
|
|
|
305
258
|
};
|
|
306
259
|
}
|
|
307
260
|
|
|
261
|
+
type PickedElementInfo = Partial<Omit<PickedElement, "computedStyles">>;
|
|
262
|
+
|
|
263
|
+
// workspaceFiles lags this hook's own writes until the host rerenders.
|
|
264
|
+
interface PendingWrite {
|
|
265
|
+
hostSource: string | undefined;
|
|
266
|
+
writes: string[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function settlePendingWrites(
|
|
270
|
+
pending: Map<string, PendingWrite>,
|
|
271
|
+
files: Record<string, string> | undefined,
|
|
272
|
+
): void {
|
|
273
|
+
for (const [path, write] of pending) {
|
|
274
|
+
const hostSource = files?.[path];
|
|
275
|
+
if (hostSource === write.hostSource) continue;
|
|
276
|
+
const caughtUpTo = hostSource === undefined ? -1 : write.writes.indexOf(hostSource);
|
|
277
|
+
if (caughtUpTo < 0 || caughtUpTo === write.writes.length - 1) {
|
|
278
|
+
pending.delete(path);
|
|
279
|
+
} else {
|
|
280
|
+
write.hostSource = hostSource;
|
|
281
|
+
write.writes = write.writes.slice(caughtUpTo + 1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function withPendingWrites(
|
|
287
|
+
files: Record<string, string>,
|
|
288
|
+
pending: Map<string, PendingWrite>,
|
|
289
|
+
): Record<string, string> {
|
|
290
|
+
const merged = { ...files };
|
|
291
|
+
for (const [path, write] of pending) merged[path] = write.writes.at(-1) ?? merged[path];
|
|
292
|
+
return merged;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function recordPendingWrite(
|
|
296
|
+
pending: Map<string, PendingWrite>,
|
|
297
|
+
path: string,
|
|
298
|
+
hostSource: string | undefined,
|
|
299
|
+
after: string,
|
|
300
|
+
): void {
|
|
301
|
+
const write = pending.get(path) ?? { hostSource, writes: [] };
|
|
302
|
+
write.writes.push(after);
|
|
303
|
+
pending.set(path, write);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// A default per field of the runtime's element info.
|
|
307
|
+
// fallow-ignore-next-line complexity
|
|
308
|
+
function toPickedElement(el: PickedElementInfo, iframe: HTMLIFrameElement): PickedElement {
|
|
309
|
+
return {
|
|
310
|
+
id: el.id ?? null,
|
|
311
|
+
tagName: el.tagName ?? "div",
|
|
312
|
+
selector: el.selector ?? "",
|
|
313
|
+
label: el.label ?? el.tagName ?? "Element",
|
|
314
|
+
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
315
|
+
textContent: el.textContent ?? null,
|
|
316
|
+
src: el.src ?? null,
|
|
317
|
+
dataAttributes: el.dataAttributes ?? {},
|
|
318
|
+
computedStyles: readComputedStyles(iframe, el.selector ?? ""),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface SourcePatch {
|
|
323
|
+
path: string;
|
|
324
|
+
before: string;
|
|
325
|
+
after: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function patchById(
|
|
329
|
+
files: Record<string, string>,
|
|
330
|
+
id: string,
|
|
331
|
+
selector: string,
|
|
332
|
+
op: PatchOperation,
|
|
333
|
+
): SourcePatch | null {
|
|
334
|
+
const path = resolveSourceFile(id, selector, files);
|
|
335
|
+
const before = path ? files[path] : undefined;
|
|
336
|
+
return path && before ? { path, before, after: applyPatch(before, id, op) } : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function ownSourceFile(live: HTMLElement, iframe: HTMLIFrameElement): string {
|
|
340
|
+
const previewed = compositionPathOfPreviewUrl(iframe.getAttribute("src") ?? "");
|
|
341
|
+
return getSourceFileForElement(live, previewed).sourceFile;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function patchByHfId(
|
|
345
|
+
files: Record<string, string>,
|
|
346
|
+
hfId: string,
|
|
347
|
+
ownFile: string,
|
|
348
|
+
op: PatchOperation,
|
|
349
|
+
): SourcePatch | null {
|
|
350
|
+
const matches = Object.keys(files).filter((file) => findTagByTarget(files[file] ?? "", { hfId }));
|
|
351
|
+
const path = matches.includes(ownFile) ? ownFile : matches.length === 1 ? matches[0] : undefined;
|
|
352
|
+
const before = path ? files[path] : undefined;
|
|
353
|
+
return path && before ? { path, before, after: applyPatchByTarget(before, { hfId }, op) } : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
308
356
|
/** Read a subset of computed styles from an element in the iframe */
|
|
309
357
|
function readComputedStyles(iframe: HTMLIFrameElement, selector: string): Record<string, string> {
|
|
310
358
|
const styles: Record<string, string> = {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { liveTime, usePlayerStore } from "../player";
|
|
7
|
+
import { useFrameCapture } from "./useFrameCapture";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
|
|
11
|
+
let renders = 0;
|
|
12
|
+
let capture: ReturnType<typeof useFrameCapture>;
|
|
13
|
+
|
|
14
|
+
function Harness() {
|
|
15
|
+
renders += 1;
|
|
16
|
+
capture = useFrameCapture({
|
|
17
|
+
projectId: "p",
|
|
18
|
+
activeCompPath: null,
|
|
19
|
+
showToast: () => {},
|
|
20
|
+
waitForPendingDomEditSaves: async () => {},
|
|
21
|
+
});
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const shownTime = () => new URL(capture.captureFrameHref).searchParams.get("t");
|
|
26
|
+
|
|
27
|
+
let host: HTMLDivElement;
|
|
28
|
+
let root: Root;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
usePlayerStore.setState({ isPlaying: false, currentTime: 2 });
|
|
32
|
+
renders = 0;
|
|
33
|
+
host = document.createElement("div");
|
|
34
|
+
document.body.append(host);
|
|
35
|
+
root = createRoot(host);
|
|
36
|
+
await act(async () => root.render(React.createElement(Harness)));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await act(async () => root.unmount());
|
|
41
|
+
host.remove();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("useFrameCapture", () => {
|
|
45
|
+
it("does not re-render on playback frames", () => {
|
|
46
|
+
usePlayerStore.setState({ isPlaying: true });
|
|
47
|
+
const before = renders;
|
|
48
|
+
act(() => {
|
|
49
|
+
for (let i = 1; i <= 30; i++) liveTime.notify(2 + i / 60);
|
|
50
|
+
});
|
|
51
|
+
expect(renders).toBe(before);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("points the capture at the live playhead while playing", () => {
|
|
55
|
+
usePlayerStore.setState({ isPlaying: true });
|
|
56
|
+
act(() => liveTime.notify(7.5));
|
|
57
|
+
act(() => capture.refreshCaptureFrameTime());
|
|
58
|
+
expect(shownTime()).toBe("7.500");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("points the capture at the store time while paused", () => {
|
|
62
|
+
act(() => liveTime.notify(7.5));
|
|
63
|
+
usePlayerStore.setState({ isPlaying: false, currentTime: 3 });
|
|
64
|
+
act(() => capture.refreshCaptureFrameTime());
|
|
65
|
+
expect(shownTime()).toBe("3.000");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -16,19 +16,28 @@ export function useFrameCapture({
|
|
|
16
16
|
showToast,
|
|
17
17
|
waitForPendingDomEditSaves,
|
|
18
18
|
}: UseFrameCaptureParams) {
|
|
19
|
-
const [captureFrameTime, setCaptureFrameTime] = useState(
|
|
19
|
+
const [captureFrameTime, setCaptureFrameTime] = useState(
|
|
20
|
+
() => usePlayerStore.getState().currentTime,
|
|
21
|
+
);
|
|
20
22
|
const [capturing, setCapturing] = useState(false);
|
|
21
23
|
const capturingRef = useRef(false);
|
|
24
|
+
const livePlayheadRef = useRef(captureFrameTime);
|
|
22
25
|
|
|
23
|
-
useMountEffect(() =>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
useMountEffect(() =>
|
|
27
|
+
liveTime.subscribe((time) => {
|
|
28
|
+
livePlayheadRef.current = time;
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
27
31
|
|
|
28
|
-
const
|
|
29
|
-
|
|
32
|
+
const playheadTime = useCallback(() => {
|
|
33
|
+
const { isPlaying, currentTime } = usePlayerStore.getState();
|
|
34
|
+
return isPlaying ? livePlayheadRef.current : currentTime;
|
|
30
35
|
}, []);
|
|
31
36
|
|
|
37
|
+
const refreshCaptureFrameTime = useCallback(() => {
|
|
38
|
+
setCaptureFrameTime(playheadTime());
|
|
39
|
+
}, [playheadTime]);
|
|
40
|
+
|
|
32
41
|
const handleCaptureFrameClick = useCallback(
|
|
33
42
|
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
34
43
|
if (!projectId) return;
|
|
@@ -39,7 +48,7 @@ export function useFrameCapture({
|
|
|
39
48
|
capturingRef.current = true;
|
|
40
49
|
setCapturing(true);
|
|
41
50
|
try {
|
|
42
|
-
const time =
|
|
51
|
+
const time = playheadTime();
|
|
43
52
|
setCaptureFrameTime(time);
|
|
44
53
|
await Promise.race([
|
|
45
54
|
waitForPendingDomEditSaves(),
|
|
@@ -91,7 +100,7 @@ export function useFrameCapture({
|
|
|
91
100
|
setCapturing(false);
|
|
92
101
|
}
|
|
93
102
|
},
|
|
94
|
-
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
|
103
|
+
[activeCompPath, playheadTime, projectId, showToast, waitForPendingDomEditSaves],
|
|
95
104
|
);
|
|
96
105
|
|
|
97
106
|
const captureFrameHref = projectId
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
2
3
|
import { act, createElement } from "react";
|
|
3
4
|
import { createRoot } from "react-dom/client";
|
|
4
5
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -103,8 +104,7 @@ it("a drag's edits under one key undo as one step, even before the drag goes idl
|
|
|
103
104
|
}),
|
|
104
105
|
);
|
|
105
106
|
}
|
|
106
|
-
|
|
107
|
-
expect(hook().undoLabel).toBeUndefined();
|
|
107
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Dragged Title"));
|
|
108
108
|
const undone = await act(() => hook().undo({ readFile }));
|
|
109
109
|
expect(undone).toMatchObject({
|
|
110
110
|
ok: true,
|
|
@@ -120,11 +120,11 @@ function redoneAt(view: HistoryView): number | null {
|
|
|
120
120
|
return view.entries.find((entry) => entry.id === undo?.undoes)?.endedAt ?? null;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/** Studio's undo over the server's project history: an edit claims what it wrote; Cmd+Z steps
|
|
123
|
+
/** Studio's undo over the server's project history: an edit claims what it wrote; Cmd+Z steps the person's. */
|
|
124
124
|
export function usePersistentEditHistory({ projectId }: UsePersistentEditHistoryOptions) {
|
|
125
125
|
const [view, setView] = useState<HistoryView>(EMPTY);
|
|
126
126
|
const [loaded, setLoaded] = useState(false);
|
|
127
|
-
// A coalescing claim
|
|
127
|
+
// A coalescing claim the server just took, until `refresh()` brings its entry into `view`.
|
|
128
128
|
const heldClaimRef = useRef<{ paths: string[]; at: number } | null>(null);
|
|
129
129
|
const projectIdRef = useRef(projectId);
|
|
130
130
|
projectIdRef.current = projectId;
|
|
@@ -131,6 +131,7 @@ function renderTimelineEditingHook(input: {
|
|
|
131
131
|
elementsDelete: ReturnType<typeof useTimelineEditing>["handleTimelineElementsDelete"];
|
|
132
132
|
handleAutoGroupCarveSources: ReturnType<typeof useTimelineEditing>["handleAutoGroupCarveSources"];
|
|
133
133
|
setAudioGroupAttribute: ReturnType<typeof useTimelineEditing>["setAudioGroupAttribute"];
|
|
134
|
+
setElementFxAttribute: ReturnType<typeof useTimelineEditing>["setElementFxAttribute"];
|
|
134
135
|
unmount: () => void;
|
|
135
136
|
} {
|
|
136
137
|
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
|
@@ -146,6 +147,7 @@ function renderTimelineEditingHook(input: {
|
|
|
146
147
|
let setAudioGroupAttribute:
|
|
147
148
|
| ReturnType<typeof useTimelineEditing>["setAudioGroupAttribute"]
|
|
148
149
|
| null = null;
|
|
150
|
+
let latest: ReturnType<typeof useTimelineEditing> | null = null;
|
|
149
151
|
|
|
150
152
|
function Harness() {
|
|
151
153
|
const commitRef = useRef(input.onZIndexCommit);
|
|
@@ -176,6 +178,7 @@ function renderTimelineEditingHook(input: {
|
|
|
176
178
|
elementsDelete = hook.handleTimelineElementsDelete;
|
|
177
179
|
handleAutoGroupCarveSources = hook.handleAutoGroupCarveSources;
|
|
178
180
|
setAudioGroupAttribute = hook.setAudioGroupAttribute;
|
|
181
|
+
latest = hook;
|
|
179
182
|
return null;
|
|
180
183
|
}
|
|
181
184
|
|
|
@@ -197,6 +200,8 @@ function renderTimelineEditingHook(input: {
|
|
|
197
200
|
elementsDelete,
|
|
198
201
|
handleAutoGroupCarveSources,
|
|
199
202
|
setAudioGroupAttribute,
|
|
203
|
+
setElementFxAttribute: (latest as unknown as ReturnType<typeof useTimelineEditing>)
|
|
204
|
+
.setElementFxAttribute,
|
|
200
205
|
unmount,
|
|
201
206
|
};
|
|
202
207
|
}
|
|
@@ -1910,3 +1915,57 @@ describe("useTimelineEditing: handler identity is stable across renders", () =>
|
|
|
1910
1915
|
unmount();
|
|
1911
1916
|
});
|
|
1912
1917
|
});
|
|
1918
|
+
|
|
1919
|
+
describe("useTimelineEditing effect saves report what happened", () => {
|
|
1920
|
+
const LOCKED = { blocked: true as const, reason: "Reserved by an agent" };
|
|
1921
|
+
|
|
1922
|
+
it("resolves a saved clip effect as saved", async () => {
|
|
1923
|
+
const { clip, setElementFxAttribute, writeProjectFile, unmount } = setupSingleClipHarness();
|
|
1924
|
+
let outcome: unknown;
|
|
1925
|
+
await act(async () => {
|
|
1926
|
+
outcome = await setElementFxAttribute.setQuiet(clip, "data-hf-audio-fx", "echo", "Apply");
|
|
1927
|
+
});
|
|
1928
|
+
expect(outcome).toEqual({ status: "saved" });
|
|
1929
|
+
expect(writeProjectFile).toHaveBeenCalled();
|
|
1930
|
+
unmount();
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
it("resolves a clip effect on a locked clip as refused, with the host's reason", async () => {
|
|
1934
|
+
const { clip, setElementFxAttribute, writeProjectFile, unmount } = setupSingleClipHarness({
|
|
1935
|
+
canEdit: () => LOCKED,
|
|
1936
|
+
});
|
|
1937
|
+
let outcome: unknown;
|
|
1938
|
+
await act(async () => {
|
|
1939
|
+
outcome = await setElementFxAttribute.setQuiet(clip, "data-hf-audio-fx", "echo", "Apply");
|
|
1940
|
+
});
|
|
1941
|
+
expect(outcome).toEqual({ status: "refused", reason: "Reserved by an agent" });
|
|
1942
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1943
|
+
unmount();
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
it("resolves a clip effect whose write fails as failed", async () => {
|
|
1947
|
+
const { clip, setElementFxAttribute, writeProjectFile, unmount } = setupSingleClipHarness();
|
|
1948
|
+
writeProjectFile.mockRejectedValue(new Error("disk full"));
|
|
1949
|
+
let outcome: unknown;
|
|
1950
|
+
await act(async () => {
|
|
1951
|
+
outcome = await setElementFxAttribute.setQuiet(clip, "data-hf-audio-fx", "echo", "Apply");
|
|
1952
|
+
});
|
|
1953
|
+
expect(outcome).toEqual({ status: "failed", reason: expect.stringContaining("disk full") });
|
|
1954
|
+
unmount();
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
it("resolves a group audio effect on a locked group as refused", async () => {
|
|
1958
|
+
const member = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
1959
|
+
usePlayerStore.getState().setElements([{ ...member, audioGroup: "hf-group" }]);
|
|
1960
|
+
const { setAudioGroupAttribute, writeProjectFile, unmount } = setupSingleClipHarness({
|
|
1961
|
+
canEdit: () => LOCKED,
|
|
1962
|
+
});
|
|
1963
|
+
let outcome: unknown;
|
|
1964
|
+
await act(async () => {
|
|
1965
|
+
outcome = await setAudioGroupAttribute.setQuiet("hf-group", "data-volume", "0.5", "Volume");
|
|
1966
|
+
});
|
|
1967
|
+
expect(outcome).toEqual({ status: "refused", reason: "Reserved by an agent" });
|
|
1968
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1969
|
+
unmount();
|
|
1970
|
+
});
|
|
1971
|
+
});
|
|
@@ -33,15 +33,19 @@ import {
|
|
|
33
33
|
} from "./timelineTrackVisibility";
|
|
34
34
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
35
35
|
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
useTimelineEditGate,
|
|
38
|
+
useTimelineEditRefusal,
|
|
39
|
+
type TimelineEditOutcome,
|
|
40
|
+
} from "./timelineEditPermission";
|
|
37
41
|
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
38
42
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
39
43
|
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
40
44
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
41
45
|
|
|
42
|
-
type GuardedTimelineHandler = (...args: never[]) => Promise<
|
|
46
|
+
type GuardedTimelineHandler = (...args: never[]) => Promise<unknown>;
|
|
43
47
|
type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
|
|
44
|
-
type GuardedTimelineRefusal = (...args: never[]) =>
|
|
48
|
+
type GuardedTimelineRefusal = (reason: string, ...args: never[]) => unknown;
|
|
45
49
|
|
|
46
50
|
interface GuardedTimelineEntry {
|
|
47
51
|
resolveTargets: GuardedTimelineResolver;
|
|
@@ -74,17 +78,18 @@ export function useTimelineEditing({
|
|
|
74
78
|
const editQueueRef = useRef(Promise.resolve());
|
|
75
79
|
const track = useTrackPendingTimelineEdit();
|
|
76
80
|
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
77
|
-
const
|
|
78
|
-
|
|
81
|
+
const refuseEdit = useTimelineEditRefusal(canEdit, showToast);
|
|
82
|
+
const refuseEditRef = useRef(refuseEdit);
|
|
83
|
+
refuseEditRef.current = refuseEdit;
|
|
79
84
|
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
80
85
|
// Refuses (no call, no write, no history entry) when any target is
|
|
81
86
|
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
82
87
|
// track() — so a fresh closure here doesn't defeat track's own cache.
|
|
83
88
|
const guard = useCallback(
|
|
84
|
-
<H extends (...args: never[]) => Promise<
|
|
89
|
+
<H extends (...args: never[]) => Promise<unknown>>(
|
|
85
90
|
resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
|
|
86
91
|
fn: H,
|
|
87
|
-
onRefused?: (...args: Parameters<H>) =>
|
|
92
|
+
onRefused?: (reason: string, ...args: Parameters<H>) => Awaited<ReturnType<H>>,
|
|
88
93
|
): H => {
|
|
89
94
|
const key = fn as unknown as GuardedTimelineHandler;
|
|
90
95
|
const cached = guardedRef.current.get(key);
|
|
@@ -97,10 +102,9 @@ export function useTimelineEditing({
|
|
|
97
102
|
entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
98
103
|
entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
99
104
|
entry.wrapped = ((...args: Parameters<H>) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return Promise.resolve();
|
|
103
|
-
}
|
|
105
|
+
const reason = refuseEditRef.current(entry.resolveTargets(...(args as never[])));
|
|
106
|
+
if (reason !== null)
|
|
107
|
+
return Promise.resolve(entry.onRefused?.(reason, ...(args as never[])));
|
|
104
108
|
return fn(...args);
|
|
105
109
|
}) as H as unknown as GuardedTimelineHandler;
|
|
106
110
|
guardedRef.current.set(key, entry);
|
|
@@ -500,6 +504,25 @@ export function useTimelineEditing({
|
|
|
500
504
|
forceReloadSdkSession,
|
|
501
505
|
});
|
|
502
506
|
|
|
507
|
+
const refused = (reason: string): TimelineEditOutcome => ({ status: "refused", reason });
|
|
508
|
+
const audioGroupMembers = (groupId: string): TimelineElement[] => {
|
|
509
|
+
const state = usePlayerStore.getState();
|
|
510
|
+
const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
|
|
511
|
+
const domMembers = state.domClipChildren
|
|
512
|
+
.filter((child) => child.audioGroup === groupId)
|
|
513
|
+
.map(
|
|
514
|
+
(child): TimelineElement => ({
|
|
515
|
+
id: child.id,
|
|
516
|
+
domId: child.id,
|
|
517
|
+
tag: "div",
|
|
518
|
+
start: 0,
|
|
519
|
+
duration: 0,
|
|
520
|
+
track: -1,
|
|
521
|
+
}),
|
|
522
|
+
);
|
|
523
|
+
return [...flatMembers, ...domMembers];
|
|
524
|
+
};
|
|
525
|
+
|
|
503
526
|
// Every write-handler is tracked here, the one place all hand edits
|
|
504
527
|
// converge, so undo never races a write; canEdit gates the same point.
|
|
505
528
|
// Coverage boundary: see the PR body, not every kind resolves an element.
|
|
@@ -526,27 +549,10 @@ export function useTimelineEditing({
|
|
|
526
549
|
// (timelineAudioGroupVolume.ts): a sub-composition's group members have
|
|
527
550
|
// no flat twin, only a domClipChildren entry, so both are checked.
|
|
528
551
|
setQuiet: track(
|
|
529
|
-
guard(
|
|
530
|
-
(groupId)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const domMembers = state.domClipChildren
|
|
534
|
-
.filter((child) => child.audioGroup === groupId)
|
|
535
|
-
.map(
|
|
536
|
-
(child): TimelineElement => ({
|
|
537
|
-
id: child.id,
|
|
538
|
-
domId: child.id,
|
|
539
|
-
tag: "div",
|
|
540
|
-
start: 0,
|
|
541
|
-
duration: 0,
|
|
542
|
-
track: -1,
|
|
543
|
-
}),
|
|
544
|
-
);
|
|
545
|
-
return [...flatMembers, ...domMembers];
|
|
546
|
-
},
|
|
547
|
-
setAudioGroupAttribute.setQuiet,
|
|
548
|
-
(groupId, attr) => revertAudioGroupLive(groupId, attr),
|
|
549
|
-
),
|
|
552
|
+
guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => {
|
|
553
|
+
revertAudioGroupLive(groupId, attr);
|
|
554
|
+
return refused(reason);
|
|
555
|
+
}),
|
|
550
556
|
),
|
|
551
557
|
},
|
|
552
558
|
setElementFxAttribute: {
|
|
@@ -555,7 +561,10 @@ export function useTimelineEditing({
|
|
|
555
561
|
guard(
|
|
556
562
|
(element) => [element],
|
|
557
563
|
setElementFxAttribute.setQuiet,
|
|
558
|
-
(element, attr) =>
|
|
564
|
+
(reason, element, attr) => {
|
|
565
|
+
revertElementFxLive(element, attr);
|
|
566
|
+
return refused(reason);
|
|
567
|
+
},
|
|
559
568
|
),
|
|
560
569
|
),
|
|
561
570
|
},
|
package/src/index.ts
CHANGED
|
@@ -162,6 +162,7 @@ export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
|
|
|
162
162
|
export type {
|
|
163
163
|
CanEditTimelineElement,
|
|
164
164
|
TimelineEditPermission,
|
|
165
|
+
TimelineEditOutcome,
|
|
165
166
|
} from "./hooks/timelineEditPermission";
|
|
166
167
|
export { useEditHistoryActions } from "./hooks/useEditHistoryActions";
|
|
167
168
|
export type {
|