@hyperframes/studio 0.8.107 → 0.8.108
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-h4mktSdm.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-D8GKc2D9.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-C842tcVa.js} +165 -165
- package/dist/assets/{index-zoqPmBF6.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-AOWVEVEU.js} +3 -503
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +11 -2
- package/dist/index.html +2 -2
- package/dist/index.js +12416 -11195
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +1 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +59 -0
- package/src/components/editor/mediaAudioEdits.ts +40 -11
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +7 -3
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/index.ts +1 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/TimelineClip.tsx +2 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -0
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
resolveAudioGroups,
|
|
21
21
|
resolveCarveSourceIds,
|
|
22
22
|
} from "@hyperframes/core/audio-groups";
|
|
23
|
+
import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
|
|
24
|
+
|
|
25
|
+
export function isCarveVoiceElement(el: Element | null): el is Element {
|
|
26
|
+
return el !== null && (el.tagName === "AUDIO" || isAudibleVideoNode(el));
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* An id for a new voiceover group, de-duped against every id already in the
|
|
@@ -130,7 +135,7 @@ function excludedFor(groupByMember: Map<string, { id: string }>, bedId?: string)
|
|
|
130
135
|
|
|
131
136
|
export function collectCarveCandidates(
|
|
132
137
|
doc: Document,
|
|
133
|
-
others: readonly
|
|
138
|
+
others: readonly Element[],
|
|
134
139
|
overlapsBed: (a: Element) => boolean,
|
|
135
140
|
/** The bed's own id, so neither it nor the group it belongs to is offered. */
|
|
136
141
|
bedId?: string,
|
|
@@ -141,7 +146,7 @@ export function collectCarveCandidates(
|
|
|
141
146
|
const excluded = excludedFor(groupByMember, bedId);
|
|
142
147
|
const offeredGroupIds = new Set<string>();
|
|
143
148
|
const described: CarveCandidate[] = [];
|
|
144
|
-
for (const a of others) {
|
|
149
|
+
for (const a of others.filter(isCarveVoiceElement)) {
|
|
145
150
|
const group = groupByMember.get(a.id);
|
|
146
151
|
if (excluded.has(a.id) || (group && excluded.has(group.id))) continue;
|
|
147
152
|
if (!group) {
|
|
@@ -161,7 +166,7 @@ export function collectCarveCandidates(
|
|
|
161
166
|
// iframe document, so the constructor is a different realm's and the
|
|
162
167
|
// instanceof is false for every one (mirrors resolveCarveVoices in
|
|
163
168
|
// useFxCarve.ts).
|
|
164
|
-
.filter((el): el is HTMLElement => el
|
|
169
|
+
.filter((el): el is HTMLElement => isCarveVoiceElement(el));
|
|
165
170
|
if (!members.some(overlapsBed)) continue;
|
|
166
171
|
offeredGroupIds.add(group.id);
|
|
167
172
|
described.push({
|
|
@@ -12,6 +12,8 @@ import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
|
|
|
12
12
|
import {
|
|
13
13
|
AudioMeterStrip,
|
|
14
14
|
type AudioMeterStripProps,
|
|
15
|
+
CLIP_LATCH_PEAK,
|
|
16
|
+
stepClipLatch,
|
|
15
17
|
evictGoneMeterState,
|
|
16
18
|
followMeterHook,
|
|
17
19
|
stepAndPaintStrips,
|
|
@@ -416,3 +418,54 @@ describe("stepAndPaintStrips", () => {
|
|
|
416
418
|
expect(state.has("loud")).toBe(true);
|
|
417
419
|
});
|
|
418
420
|
});
|
|
421
|
+
|
|
422
|
+
describe("CLIP light", () => {
|
|
423
|
+
const hookAt = (peak: number) => ({
|
|
424
|
+
start: vi.fn(),
|
|
425
|
+
stop: vi.fn(),
|
|
426
|
+
read: vi.fn(() => ({ master: { l: peak, r: 0 }, groups: {} })),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("latches at the -1 dBFS export ceiling, stays lit when the level drops, and resets on click", () => {
|
|
430
|
+
usePlayerStore.setState({ elements: [clip({})] });
|
|
431
|
+
const hook = hookAt(1);
|
|
432
|
+
setHook(hook);
|
|
433
|
+
const { host } = mount();
|
|
434
|
+
const light = host.querySelector<HTMLElement>("[data-testid=meter-clip-light]");
|
|
435
|
+
expect(light?.dataset.lit).toBe("false");
|
|
436
|
+
tick();
|
|
437
|
+
expect(light?.dataset.lit).toBe("true");
|
|
438
|
+
hook.read.mockReturnValue({ master: { l: 0.1, r: 0.1 }, groups: {} });
|
|
439
|
+
tick();
|
|
440
|
+
expect(light?.dataset.lit).toBe("true");
|
|
441
|
+
act(() => light?.click());
|
|
442
|
+
expect(light?.dataset.lit).toBe("false");
|
|
443
|
+
tick();
|
|
444
|
+
expect(light?.dataset.lit).toBe("false");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("stays dark in the red band just under the ceiling", () => {
|
|
448
|
+
usePlayerStore.setState({ elements: [clip({})] });
|
|
449
|
+
setHook(hookAt(10 ** (-1.2 / 20)));
|
|
450
|
+
const { host } = mount();
|
|
451
|
+
tick();
|
|
452
|
+
expect(host.querySelector<HTMLElement>("[data-testid=meter-clip-light]")?.dataset.lit).toBe(
|
|
453
|
+
"false",
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe("stepClipLatch", () => {
|
|
459
|
+
it("lights exactly at -1 dBFS on either channel, not below", () => {
|
|
460
|
+
expect(CLIP_LATCH_PEAK).toBeCloseTo(0.891251, 6);
|
|
461
|
+
expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK, r: 0 })).toBe(true);
|
|
462
|
+
expect(stepClipLatch(false, { l: 0, r: CLIP_LATCH_PEAK })).toBe(true);
|
|
463
|
+
expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK - 1e-6, r: 0 })).toBe(false);
|
|
464
|
+
expect(stepClipLatch(false, undefined)).toBe(false);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("holds once lit, whatever comes next", () => {
|
|
468
|
+
expect(stepClipLatch(true, { l: 0, r: 0 })).toBe(true);
|
|
469
|
+
expect(stepClipLatch(true, undefined)).toBe(true);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
useMemo,
|
|
6
6
|
useRef,
|
|
7
7
|
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
type ReactNode,
|
|
8
9
|
type Ref,
|
|
9
10
|
type RefObject,
|
|
10
11
|
} from "react";
|
|
@@ -20,6 +21,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
|
|
|
20
21
|
import { usePlayerStore } from "../../player";
|
|
21
22
|
import { clampNumber } from "../../utils/studioHelpers";
|
|
22
23
|
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
24
|
+
import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling";
|
|
23
25
|
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
24
26
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
25
27
|
import {
|
|
@@ -49,6 +51,24 @@ type StripBars = [Bars, Bars];
|
|
|
49
51
|
|
|
50
52
|
const MONITOR_LABEL = "Monitor";
|
|
51
53
|
|
|
54
|
+
/** The CLIP light latches at the export ceiling, stricter than RED_AT. */
|
|
55
|
+
export const CLIP_LATCH_PEAK = EXPORT_PEAK_CEILING;
|
|
56
|
+
|
|
57
|
+
export function stepClipLatch(latched: boolean, master: StereoLevel | undefined): boolean {
|
|
58
|
+
if (latched || !master) return latched;
|
|
59
|
+
return Math.max(master.l, master.r) >= CLIP_LATCH_PEAK;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function paintClipLight(el: HTMLElement | null, lit: boolean): void {
|
|
63
|
+
if (!el) return;
|
|
64
|
+
el.dataset.lit = String(lit);
|
|
65
|
+
el.style.setProperty(
|
|
66
|
+
"background-color",
|
|
67
|
+
lit ? "var(--color-red-500)" : "var(--color-neutral-800)",
|
|
68
|
+
);
|
|
69
|
+
el.style.setProperty("color", lit ? "var(--color-white)" : "var(--color-neutral-500)");
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
/** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */
|
|
53
73
|
const AMBER_AT = markFraction(-6);
|
|
54
74
|
const RED_AT = markFraction(-3);
|
|
@@ -178,10 +198,16 @@ export function stepAndPaintStrips(
|
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
|
|
201
|
+
interface ClipLatch {
|
|
202
|
+
lit: boolean;
|
|
203
|
+
light: HTMLElement | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
181
206
|
function useMeterLoop(
|
|
182
207
|
strips: Strip[],
|
|
183
208
|
bars: RefObject<Map<string | null, StripBars>>,
|
|
184
209
|
iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
|
|
210
|
+
clip: RefObject<ClipLatch>,
|
|
185
211
|
) {
|
|
186
212
|
const shell = useStudioShellContextOptional();
|
|
187
213
|
const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
|
|
@@ -200,6 +226,11 @@ function useMeterLoop(
|
|
|
200
226
|
last = now;
|
|
201
227
|
evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
|
|
202
228
|
stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
|
|
229
|
+
const lit = stepClipLatch(clip.current.lit, levels?.master);
|
|
230
|
+
if (lit !== clip.current.lit) {
|
|
231
|
+
clip.current.lit = lit;
|
|
232
|
+
paintClipLight(clip.current.light, lit);
|
|
233
|
+
}
|
|
203
234
|
};
|
|
204
235
|
raf = requestAnimationFrame(tick);
|
|
205
236
|
return () => {
|
|
@@ -210,7 +241,29 @@ function useMeterLoop(
|
|
|
210
241
|
// Preview iframe already gone.
|
|
211
242
|
}
|
|
212
243
|
};
|
|
213
|
-
}, [previewIframeRef, bars]);
|
|
244
|
+
}, [previewIframeRef, bars, clip]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ClipLight({ clip }: { clip: RefObject<ClipLatch> }) {
|
|
248
|
+
return (
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
data-testid="meter-clip-light"
|
|
252
|
+
data-lit="false"
|
|
253
|
+
title="Lights when the preview reaches −1 dBFS, the export ceiling. Click to reset."
|
|
254
|
+
ref={(el) => {
|
|
255
|
+
clip.current.light = el;
|
|
256
|
+
paintClipLight(el, clip.current.lit);
|
|
257
|
+
}}
|
|
258
|
+
onClick={() => {
|
|
259
|
+
clip.current.lit = false;
|
|
260
|
+
paintClipLight(clip.current.light, false);
|
|
261
|
+
}}
|
|
262
|
+
className="rounded-[2px] px-1 font-mono text-[9px] leading-[14px] font-bold tracking-wide"
|
|
263
|
+
>
|
|
264
|
+
CLIP
|
|
265
|
+
</button>
|
|
266
|
+
);
|
|
214
267
|
}
|
|
215
268
|
|
|
216
269
|
function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
|
|
@@ -341,11 +394,13 @@ function MeterStrip({
|
|
|
341
394
|
register,
|
|
342
395
|
onLive,
|
|
343
396
|
onCommit,
|
|
397
|
+
clipLight,
|
|
344
398
|
}: {
|
|
345
399
|
strip: Strip;
|
|
346
400
|
register: (id: string | null, bars: StripBars | null) => void;
|
|
347
401
|
onLive: (id: string | null, volume: number) => void;
|
|
348
402
|
onCommit: (id: string | null, volume: number) => void;
|
|
403
|
+
clipLight?: ReactNode;
|
|
349
404
|
}) {
|
|
350
405
|
const refs = [
|
|
351
406
|
useRef<HTMLDivElement>(null),
|
|
@@ -364,6 +419,7 @@ function MeterStrip({
|
|
|
364
419
|
}, [strip.id, register]);
|
|
365
420
|
return (
|
|
366
421
|
<div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
|
|
422
|
+
{clipLight}
|
|
367
423
|
<div
|
|
368
424
|
className="flex min-h-0 flex-1 items-stretch gap-1.5"
|
|
369
425
|
aria-label={`${strip.label} level`}
|
|
@@ -421,7 +477,8 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
|
|
|
421
477
|
else bars.current.delete(id);
|
|
422
478
|
}).current;
|
|
423
479
|
const { onLive, onCommit } = useVolumeHandlers();
|
|
424
|
-
|
|
480
|
+
const clip = useRef<ClipLatch>({ lit: false, light: null });
|
|
481
|
+
useMeterLoop(strips, bars, previewIframeRef, clip);
|
|
425
482
|
return (
|
|
426
483
|
<div
|
|
427
484
|
data-testid="audio-meter-strip"
|
|
@@ -434,6 +491,7 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
|
|
|
434
491
|
register={register}
|
|
435
492
|
onLive={onLive}
|
|
436
493
|
onCommit={onCommit}
|
|
494
|
+
clipLight={strip.id === null ? <ClipLight clip={clip} /> : undefined}
|
|
437
495
|
/>
|
|
438
496
|
))}
|
|
439
497
|
</div>
|
|
@@ -53,6 +53,7 @@ export interface TimelineEditCallbackDeps {
|
|
|
53
53
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
54
54
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
55
55
|
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
|
56
|
+
handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
56
57
|
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
|
|
57
58
|
handleGroupClips?: (
|
|
58
59
|
clipIds: readonly string[],
|
|
@@ -132,6 +133,7 @@ export function useTimelineEditCallbacks({
|
|
|
132
133
|
handleTimelineElementSplit,
|
|
133
134
|
handleRazorSplit,
|
|
134
135
|
handleRazorSplitAll,
|
|
136
|
+
handleFreezeFrame,
|
|
135
137
|
handleGroupClips,
|
|
136
138
|
setElementFxAttribute,
|
|
137
139
|
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
|
@@ -227,6 +229,7 @@ export function useTimelineEditCallbacks({
|
|
|
227
229
|
onSplitElement: handleTimelineElementSplit,
|
|
228
230
|
onRazorSplit: handleRazorSplit,
|
|
229
231
|
onRazorSplitAll: handleRazorSplitAll,
|
|
232
|
+
onFreezeFrame: handleFreezeFrame,
|
|
230
233
|
onDeleteAllKeyframes: (element, animationId) => {
|
|
231
234
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
232
235
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
@@ -423,6 +426,7 @@ export function useTimelineEditCallbacks({
|
|
|
423
426
|
handleTimelineElementSplit,
|
|
424
427
|
handleRazorSplit,
|
|
425
428
|
handleRazorSplitAll,
|
|
429
|
+
handleFreezeFrame,
|
|
426
430
|
handleGsapRemoveAllKeyframes,
|
|
427
431
|
resolveElementAnimations,
|
|
428
432
|
resolveKeyframeTarget,
|
|
@@ -42,6 +42,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
|
|
|
42
42
|
onSplitElement: true,
|
|
43
43
|
onRazorSplit: true,
|
|
44
44
|
onRazorSplitAll: true,
|
|
45
|
+
onFreezeFrame: true,
|
|
45
46
|
onDeleteKeyframe: true,
|
|
46
47
|
onDeleteAllKeyframes: true,
|
|
47
48
|
onMoveKeyframeToPlayhead: true,
|
|
@@ -206,7 +206,9 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
|
|
|
206
206
|
const s = await studio();
|
|
207
207
|
await s.edit();
|
|
208
208
|
resetNudgeKeys();
|
|
209
|
+
let finish!: () => void;
|
|
209
210
|
const save = vi.fn(async () => {
|
|
211
|
+
await new Promise<void>((resolve) => (finish = resolve));
|
|
210
212
|
writeFileSync(s.path, NUDGED);
|
|
211
213
|
await s.history().recordEdit({
|
|
212
214
|
label: "Move layer",
|
|
@@ -218,12 +220,51 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
|
|
|
218
220
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
219
221
|
});
|
|
220
222
|
|
|
223
|
+
const nudge = () => s.element("box").style.getPropertyValue("translate");
|
|
224
|
+
expect(nudge()).not.toBe("");
|
|
225
|
+
|
|
221
226
|
const undone = s.actions().undo();
|
|
222
227
|
expect(s.box()).toBe("50px");
|
|
228
|
+
expect(nudge()).toBe("");
|
|
229
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
230
|
+
expect(nudge()).toBe("");
|
|
231
|
+
finish();
|
|
223
232
|
await act(() => undone);
|
|
224
233
|
expect(save).toHaveBeenCalledTimes(1);
|
|
225
234
|
expect(s.file()).toBe(AFTER);
|
|
226
235
|
expect(s.box()).toBe("50px");
|
|
236
|
+
expect(nudge()).toBe("");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("an undo pressed while a nudge's save is queued shows the nudge undone in the key's own task", async () => {
|
|
240
|
+
const s = await studio();
|
|
241
|
+
await s.edit();
|
|
242
|
+
resetNudgeKeys();
|
|
243
|
+
let finish!: () => void;
|
|
244
|
+
const save = vi.fn(async () => {
|
|
245
|
+
await new Promise<void>((resolve) => (finish = resolve));
|
|
246
|
+
writeFileSync(s.path, NUDGED);
|
|
247
|
+
await s.history().recordEdit({
|
|
248
|
+
label: "Move layer",
|
|
249
|
+
files: { "index.html": { before: AFTER, after: NUDGED } },
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
253
|
+
act(() => {
|
|
254
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
255
|
+
});
|
|
256
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
257
|
+
const nudge = () => s.element("box").style.getPropertyValue("translate");
|
|
258
|
+
expect(nudge()).not.toBe("");
|
|
259
|
+
|
|
260
|
+
const undone = s.actions().undo();
|
|
261
|
+
expect(nudge()).toBe("");
|
|
262
|
+
expect(s.box()).toBe("50px");
|
|
263
|
+
finish();
|
|
264
|
+
await act(() => undone);
|
|
265
|
+
expect(s.file()).toBe(AFTER);
|
|
266
|
+
expect(nudge()).toBe("");
|
|
267
|
+
expect(s.box()).toBe("50px");
|
|
227
268
|
});
|
|
228
269
|
|
|
229
270
|
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
|
|
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react";
|
|
|
3
3
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
5
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
6
|
+
import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
6
7
|
|
|
7
8
|
interface HistoryResult {
|
|
8
9
|
ok: boolean;
|
|
@@ -74,7 +75,10 @@ export function useEditHistoryActions({
|
|
|
74
75
|
const noun = direction === "undo" ? "Undo" : "Redo";
|
|
75
76
|
// Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
|
|
76
77
|
const predicted = editHistory.predict?.(direction) ?? null;
|
|
77
|
-
const
|
|
78
|
+
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
79
|
+
const pendingEditShown =
|
|
80
|
+
!predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null;
|
|
81
|
+
const putBack = predictedShown ?? pendingEditShown;
|
|
78
82
|
let result: HistoryResult = { ok: false, reason: "failed" };
|
|
79
83
|
let serverSteppedShown = false;
|
|
80
84
|
try {
|
|
@@ -83,8 +87,10 @@ export function useEditHistoryActions({
|
|
|
83
87
|
readFile: readHistoryFile,
|
|
84
88
|
serialize: serializeHistoryFiles,
|
|
85
89
|
});
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
const stepped = Boolean(result.ok && result.label);
|
|
91
|
+
serverSteppedShown = predictedShown
|
|
92
|
+
? stepped && result.undoes === predicted?.id
|
|
93
|
+
: stepped && Boolean(pendingEditShown);
|
|
88
94
|
} finally {
|
|
89
95
|
if (putBack && !serverSteppedShown) putBack();
|
|
90
96
|
}
|
|
@@ -100,7 +106,10 @@ export function useEditHistoryActions({
|
|
|
100
106
|
return;
|
|
101
107
|
}
|
|
102
108
|
if (result.ok && result.label) {
|
|
103
|
-
const files =
|
|
109
|
+
const files =
|
|
110
|
+
serverSteppedShown && predictedShown
|
|
111
|
+
? fromShown(result.files, predicted!.files)
|
|
112
|
+
: result.files;
|
|
104
113
|
const restore = { paths: result.paths, files };
|
|
105
114
|
onAfterUndoRedo?.(restore);
|
|
106
115
|
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
@@ -19,6 +19,22 @@ afterEach(() => {
|
|
|
19
19
|
document.body.innerHTML = "";
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
// The runtime's element-picked message, posted by `from`.
|
|
23
|
+
const picked = (from: Window | null, selector: string) =>
|
|
24
|
+
act(() => {
|
|
25
|
+
window.dispatchEvent(
|
|
26
|
+
new MessageEvent("message", {
|
|
27
|
+
source: from,
|
|
28
|
+
data: {
|
|
29
|
+
source: "hf-preview",
|
|
30
|
+
type: "element-picked",
|
|
31
|
+
elementInfo: { selector, tagName: "h1" },
|
|
32
|
+
...runtimeProtocolMetadata(30),
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
22
38
|
// The preview page: the saved markup plus what the runtime adds to it.
|
|
23
39
|
function mountPreview(mounted = ""): HTMLIFrameElement {
|
|
24
40
|
const iframe = document.createElement("iframe");
|
|
@@ -61,19 +77,7 @@ function mountPicker(
|
|
|
61
77
|
}
|
|
62
78
|
root = createRoot(document.createElement("div"));
|
|
63
79
|
act(() => root?.render(React.createElement(Harness)));
|
|
64
|
-
|
|
65
|
-
window.dispatchEvent(
|
|
66
|
-
new MessageEvent("message", {
|
|
67
|
-
source: iframe.contentWindow,
|
|
68
|
-
data: {
|
|
69
|
-
source: "hf-preview",
|
|
70
|
-
type: "element-picked",
|
|
71
|
-
elementInfo: { selector, tagName: "h1" },
|
|
72
|
-
...runtimeProtocolMetadata(30),
|
|
73
|
-
},
|
|
74
|
-
}),
|
|
75
|
-
);
|
|
76
|
-
});
|
|
80
|
+
picked(iframe.contentWindow, selector);
|
|
77
81
|
const picker = () => api as ReturnType<typeof useElementPicker>;
|
|
78
82
|
return {
|
|
79
83
|
picker,
|
|
@@ -170,3 +174,48 @@ describe("an edit to a picked element without an id", () => {
|
|
|
170
174
|
expect(synced).toEqual([]);
|
|
171
175
|
});
|
|
172
176
|
});
|
|
177
|
+
|
|
178
|
+
describe("a pick across a reload that swaps the preview frame", () => {
|
|
179
|
+
function mountSwapping() {
|
|
180
|
+
const live = mountPreview();
|
|
181
|
+
const ref: { current: HTMLIFrameElement | null } = { current: live };
|
|
182
|
+
let api: ReturnType<typeof useElementPicker> | null = null;
|
|
183
|
+
function Harness() {
|
|
184
|
+
api = useElementPicker(ref);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
root = createRoot(document.createElement("div"));
|
|
188
|
+
act(() => root?.render(React.createElement(Harness)));
|
|
189
|
+
return { live, ref, picker: () => api as ReturnType<typeof useElementPicker> };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it("keeps a pick posted by the frame it was made in after a new frame took its place", () => {
|
|
193
|
+
const { live, ref, picker } = mountSwapping();
|
|
194
|
+
picked(live.contentWindow, "h1");
|
|
195
|
+
act(() => picker().clearPick());
|
|
196
|
+
// The reload promotes its frame before the old frame's pick message is handled.
|
|
197
|
+
const old = live.contentWindow;
|
|
198
|
+
ref.current = mountPreview();
|
|
199
|
+
live.remove();
|
|
200
|
+
picked(old, "h1.next");
|
|
201
|
+
expect(picker().pickedElement?.selector).toBe("h1.next");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not keep trusting a zoomed frame once the zoom is gone", () => {
|
|
205
|
+
const { picker } = mountSwapping();
|
|
206
|
+
const zoom = mountPreview();
|
|
207
|
+
act(() => picker().setActiveIframe(zoom));
|
|
208
|
+
picked(zoom.contentWindow, "h1");
|
|
209
|
+
expect(picker().pickedElement?.selector).toBe("h1");
|
|
210
|
+
act(() => picker().setActiveIframe(null));
|
|
211
|
+
picked(zoom.contentWindow, "h1.late");
|
|
212
|
+
expect(picker().pickedElement?.selector).toBe("h1");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("still ignores a frame it never showed", () => {
|
|
216
|
+
const { picker } = mountSwapping();
|
|
217
|
+
const stranger = mountPreview();
|
|
218
|
+
picked(stranger.contentWindow, "h1");
|
|
219
|
+
expect(picker().pickedElement).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -97,6 +97,8 @@ export function useElementPicker(
|
|
|
97
97
|
setPickedElement(null);
|
|
98
98
|
}, []);
|
|
99
99
|
|
|
100
|
+
const windowsThatWerePrimary = useRef(new WeakSet<MessageEventSource>());
|
|
101
|
+
|
|
100
102
|
// Listen for picker messages from the iframe
|
|
101
103
|
useMountEffect(() => {
|
|
102
104
|
// One guard per message field, then one branch per message type.
|
|
@@ -105,10 +107,16 @@ export function useElementPicker(
|
|
|
105
107
|
const data = e.data;
|
|
106
108
|
if (data?.source !== "hf-preview") return;
|
|
107
109
|
if (!acceptStudioRuntimeMessage(data)) return;
|
|
108
|
-
//
|
|
110
|
+
// The primary frame, the active override, or a primary frame a reload just swapped out.
|
|
109
111
|
const activeIframe = getActiveIframe();
|
|
110
112
|
if (!activeIframe) return;
|
|
111
|
-
if (e.source
|
|
113
|
+
if (!e.source) return;
|
|
114
|
+
if (e.source === iframeRef.current?.contentWindow)
|
|
115
|
+
windowsThatWerePrimary.current.add(e.source);
|
|
116
|
+
else if (
|
|
117
|
+
e.source !== activeIframe.contentWindow &&
|
|
118
|
+
!windowsThatWerePrimary.current.has(e.source)
|
|
119
|
+
)
|
|
112
120
|
return;
|
|
113
121
|
|
|
114
122
|
if (data.type === "element-picked" && data.elementInfo) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { requestFreezeFrame } from "./useFreezeFrame";
|
|
4
|
+
|
|
5
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
6
|
+
|
|
7
|
+
const element: TimelineElement = {
|
|
8
|
+
id: "talk",
|
|
9
|
+
domId: "talk",
|
|
10
|
+
tag: "video",
|
|
11
|
+
start: 5,
|
|
12
|
+
duration: 6,
|
|
13
|
+
track: 0,
|
|
14
|
+
parentCompositionStart: 4,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function stubFetch(freezeResponse: { status: number; body: unknown }) {
|
|
18
|
+
const calls: Array<{ url: string; body: unknown }> = [];
|
|
19
|
+
vi.stubGlobal(
|
|
20
|
+
"fetch",
|
|
21
|
+
vi.fn(async (url: string, init?: { body?: string }) => {
|
|
22
|
+
calls.push({ url, body: init?.body ? JSON.parse(init.body) : null });
|
|
23
|
+
if (url.includes("/files/")) return new Response(JSON.stringify({ version: "v1" }));
|
|
24
|
+
return new Response(JSON.stringify(freezeResponse.body), { status: freezeResponse.status });
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
return calls;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("requestFreezeFrame", () => {
|
|
31
|
+
it("posts the clip target, file version and an authored-time playhead", async () => {
|
|
32
|
+
const calls = stubFetch({ status: 200, body: { before: "a", after: "b", version: "v2" } });
|
|
33
|
+
const result = await requestFreezeFrame({
|
|
34
|
+
projectId: "p",
|
|
35
|
+
path: "scene.html",
|
|
36
|
+
element,
|
|
37
|
+
playhead: 7.5,
|
|
38
|
+
});
|
|
39
|
+
expect(result).toEqual({ before: "a", after: "b", version: "v2" });
|
|
40
|
+
const post = calls[1];
|
|
41
|
+
expect(post?.url).toContain("/file-mutations/freeze-frame");
|
|
42
|
+
expect(post?.body).toMatchObject({
|
|
43
|
+
path: "scene.html",
|
|
44
|
+
expectedVersion: "v1",
|
|
45
|
+
target: { id: "talk" },
|
|
46
|
+
playhead: 3.5,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("surfaces the server's error", async () => {
|
|
51
|
+
stubFetch({ status: 400, body: { error: "Move the playhead inside a video clip to freeze" } });
|
|
52
|
+
await expect(
|
|
53
|
+
requestFreezeFrame({ projectId: "p", path: "index.html", element, playhead: 7 }),
|
|
54
|
+
).rejects.toThrow("Move the playhead inside a video clip to freeze");
|
|
55
|
+
});
|
|
56
|
+
});
|