@hyperframes/studio 0.8.123 → 0.8.125
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-yABdb6yk.js → hyperframes-player-CmIh-wq9.js} +1 -1
- package/dist/assets/{index-D77MIjSJ.js → index-4LRR6o2F.js} +1 -1
- package/dist/assets/{index-Dt7lWtY7.js → index-CrYoEZ5p.js} +139 -139
- package/dist/assets/{index-LrNlT5Tg.css → index-D11atkBt.css} +1 -1
- package/dist/assets/{index-DWjF2Kxw.js → index-yCK8osBg.js} +1 -1
- package/dist/index.d.ts +43 -35
- package/dist/index.html +2 -2
- package/dist/index.js +283 -143
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/OpenInDesktopButton.dom.test.tsx +27 -10
- package/src/components/OpenInDesktopButton.tsx +22 -15
- package/src/components/editor/domEditOverlayGestures.ts +2 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
- package/src/components/editor/dragUndoPaint.test.ts +170 -2
- package/src/components/editor/gestureUndoRevert.ts +104 -0
- package/src/components/editor/manualOffsetDrag.ts +1 -12
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
- package/src/hooks/gestureTransaction.test.ts +60 -0
- package/src/hooks/gestureTransaction.ts +5 -2
- package/src/hooks/gsapGestureOutcome.ts +9 -2
- package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
- package/src/hooks/gsapResizeIntercept.ts +55 -68
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +21 -25
- package/src/hooks/useGsapAwareGroupMove.ts +4 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
- package/src/hooks/useGsapScriptCommits.ts +12 -3
- package/src/player/components/AudibleVideoClipContent.test.tsx +67 -0
- package/src/player/components/AudibleVideoClipContent.tsx +5 -4
- package/src/player/components/AudioWaveform.tsx +2 -1
- package/src/player/components/ClipPeakMarks.tsx +5 -2
- package/src/player/components/OutOfSyncBadge.tsx +1 -1
- package/src/player/components/Timeline.trackPadding.test.tsx +1 -3
- package/src/player/components/TimelineCanvas.tsx +1 -0
- package/src/player/components/TimelineClip.filmstrip.test.tsx +27 -0
- package/src/player/components/TimelineClipFades.test.tsx +2 -1
- package/src/player/components/timelineClipDragPreview.test.ts +31 -0
- package/src/player/components/timelineClipDragPreview.ts +42 -37
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +11 -0
- package/src/styles/components.css +33 -3
- package/src/utils/studioPendingEdits.test.ts +52 -0
- package/src/utils/studioPendingEdits.ts +55 -10
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { act, type ReactNode } from "react";
|
|
6
|
+
import { compile } from "tailwindcss";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
9
|
+
import { AudibleVideoClipContent } from "./AudibleVideoClipContent";
|
|
10
|
+
|
|
11
|
+
const harness = createHappyDomRootHarness();
|
|
12
|
+
const STYLES_DIR = path.resolve(__dirname, "../../styles");
|
|
13
|
+
const TAILWIND_DIR = path.dirname(
|
|
14
|
+
createRequire(import.meta.url).resolve("tailwindcss/package.json"),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
async function studioCss() {
|
|
18
|
+
const load = async (id: string, base: string) => {
|
|
19
|
+
const file =
|
|
20
|
+
id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
|
|
21
|
+
return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
|
|
22
|
+
};
|
|
23
|
+
const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
|
|
24
|
+
base: STYLES_DIR,
|
|
25
|
+
loadStylesheet: load,
|
|
26
|
+
});
|
|
27
|
+
return compiled.build([]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function renderStrip(wave: ReactNode) {
|
|
31
|
+
const host = document.createElement("div");
|
|
32
|
+
document.body.appendChild(host);
|
|
33
|
+
await act(async () => {
|
|
34
|
+
harness
|
|
35
|
+
.mount(host)
|
|
36
|
+
.render(<AudibleVideoClipContent thumbnail={<span>frames</span>} waveform={wave} />);
|
|
37
|
+
});
|
|
38
|
+
return host;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("AudibleVideoClipContent", () => {
|
|
42
|
+
it("draws the sound on the audio clip's own surface, under half the clip", async () => {
|
|
43
|
+
const host = await renderStrip(<span>wave</span>);
|
|
44
|
+
const strip = host.querySelector<HTMLElement>('[data-testid="audible-video-wave"]');
|
|
45
|
+
expect(strip?.textContent).toBe("wave");
|
|
46
|
+
expect(strip?.style.backgroundColor).toBe("var(--timeline-clip-audio-bg)");
|
|
47
|
+
expect(strip?.style.height).toBe("50%");
|
|
48
|
+
expect(strip?.previousElementSibling?.getAttribute("style")).toContain("bottom: 50%");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps only the peak mark in the strip, above the waveform canvas layer", async () => {
|
|
52
|
+
const style = document.createElement("style");
|
|
53
|
+
style.textContent = await studioCss();
|
|
54
|
+
document.head.appendChild(style);
|
|
55
|
+
const host = await renderStrip(
|
|
56
|
+
<span data-peak-badge>
|
|
57
|
+
▲<span data-peak-text> peaks +0.7 dBFS</span>
|
|
58
|
+
</span>,
|
|
59
|
+
);
|
|
60
|
+
const badge = host.querySelector<HTMLElement>("[data-peak-badge]")!;
|
|
61
|
+
const text = host.querySelector<HTMLElement>("[data-peak-text]")!;
|
|
62
|
+
expect(getComputedStyle(text).display).toBe("none");
|
|
63
|
+
// The waveform canvas sits in a z-index 10 layer (AudioWaveform).
|
|
64
|
+
expect(Number(getComputedStyle(badge).zIndex)).toBeGreaterThan(10);
|
|
65
|
+
style.remove();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
/** Share of the clip height the sound strip takes under a video's thumbnails. */
|
|
4
|
-
const AUDIBLE_VIDEO_WAVE_SHARE = 0.
|
|
3
|
+
/** Share of the clip height the sound strip takes under a video's thumbnails: tall enough for the bars to read. */
|
|
4
|
+
const AUDIBLE_VIDEO_WAVE_SHARE = 0.5;
|
|
5
|
+
const AUDIO_CLIP_SURFACE = "var(--timeline-clip-audio-bg)";
|
|
5
6
|
|
|
6
7
|
/** A video that carries sound: thumbnails on top, its waveform strip along the bottom. */
|
|
7
8
|
export function AudibleVideoClipContent({
|
|
@@ -18,9 +19,9 @@ export function AudibleVideoClipContent({
|
|
|
18
19
|
{thumbnail}
|
|
19
20
|
</div>
|
|
20
21
|
<div
|
|
21
|
-
className="absolute inset-x-0 bottom-0
|
|
22
|
+
className="audible-video-wave absolute inset-x-0 bottom-0"
|
|
22
23
|
data-testid="audible-video-wave"
|
|
23
|
-
style={{ height: waveHeight }}
|
|
24
|
+
style={{ height: waveHeight, backgroundColor: AUDIO_CLIP_SURFACE }}
|
|
24
25
|
>
|
|
25
26
|
{waveform}
|
|
26
27
|
</div>
|
|
@@ -26,6 +26,7 @@ const BAR_STEP = 3;
|
|
|
26
26
|
|
|
27
27
|
export const rendersWaveform = (el: { tag: string }) => el.tag === "audio";
|
|
28
28
|
const FADE_GHOST_OPACITY = 0.27;
|
|
29
|
+
export const WAVEFORM_LAYER_Z = 10;
|
|
29
30
|
|
|
30
31
|
type BarGeometry = { x: number; width: number; height: number; gain: number };
|
|
31
32
|
|
|
@@ -236,7 +237,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
236
237
|
|
|
237
238
|
return (
|
|
238
239
|
<div ref={rootRef} className="absolute inset-0">
|
|
239
|
-
<div className="absolute inset-0 overflow-hidden" style={{ zIndex:
|
|
240
|
+
<div className="absolute inset-0 overflow-hidden" style={{ zIndex: WAVEFORM_LAYER_Z }}>
|
|
240
241
|
<canvas
|
|
241
242
|
ref={setCanvasRef}
|
|
242
243
|
className="absolute inset-x-0 bottom-0 w-full"
|
|
@@ -49,8 +49,11 @@ export function ClipPeakMarks({
|
|
|
49
49
|
}}
|
|
50
50
|
/>
|
|
51
51
|
))}
|
|
52
|
-
<span
|
|
53
|
-
|
|
52
|
+
<span
|
|
53
|
+
className="absolute top-0 right-1 font-mono text-[9px] leading-none text-red-400"
|
|
54
|
+
data-peak-badge
|
|
55
|
+
>
|
|
56
|
+
▲<span data-peak-text> peaks {dbText(peakDbfs)}</span>
|
|
54
57
|
</span>
|
|
55
58
|
</div>
|
|
56
59
|
)}
|
|
@@ -105,7 +105,7 @@ export const OutOfSyncBadge = memo(function OutOfSyncBadge({ el }: { el: Timelin
|
|
|
105
105
|
aria-label={`Out of sync with ${partnerName} by ${label} frames`}
|
|
106
106
|
title={`Out of sync with ${partnerName} by ${label} — click for Move or Slip into Sync`}
|
|
107
107
|
data-testid="out-of-sync-badge"
|
|
108
|
-
className="absolute bottom-0.5 left-1 z-[
|
|
108
|
+
className="absolute bottom-0.5 left-1 z-[12] cursor-pointer rounded-[3px] bg-red-600 px-1 font-mono text-[9px] leading-[14px] text-white tabular-nums"
|
|
109
109
|
onPointerDown={stop}
|
|
110
110
|
onDoubleClick={stop}
|
|
111
111
|
onClick={open}
|
|
@@ -99,9 +99,7 @@ describe("Timeline trackPadding", () => {
|
|
|
99
99
|
const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
|
|
100
100
|
pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
|
|
101
101
|
pointer(window, "pointermove", ROW1_MID);
|
|
102
|
-
const ghost =
|
|
103
|
-
host.querySelectorAll<HTMLElement>(".absolute.pointer-events-none"),
|
|
104
|
-
).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
|
|
102
|
+
const ghost = host.querySelector<HTMLElement>('[data-testid="timeline-drag-landing"]');
|
|
105
103
|
expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
|
|
106
104
|
pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
|
|
107
105
|
const lane = host.querySelector<HTMLElement>("[data-timeline-new-track-lane]");
|
|
@@ -134,6 +134,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
134
134
|
lane + snapped start), parallel to the ghost. Hidden in insert mode. */}
|
|
135
135
|
{draggedClip?.started && draggedClip.insertRow == null && draggedRowIndex >= 0 && (
|
|
136
136
|
<div
|
|
137
|
+
data-testid="timeline-drag-landing"
|
|
137
138
|
className="absolute pointer-events-none"
|
|
138
139
|
style={{
|
|
139
140
|
top: props.rowGeometry.getRowTop(draggedRowIndex) + CLIP_Y,
|
|
@@ -6,6 +6,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import { loadStylesheet, STYLES_DIR } from "../../styles/styleSources";
|
|
7
7
|
import { cleanupMounted, trackedRoot } from "../../components/ui/mountHost.testHelpers";
|
|
8
8
|
import { TimelineClip } from "./TimelineClip";
|
|
9
|
+
import { WAVEFORM_LAYER_Z } from "./AudioWaveform";
|
|
9
10
|
import { renderClipChildren } from "./timelineClipChildren";
|
|
10
11
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
11
12
|
|
|
@@ -23,6 +24,7 @@ beforeAll(async () => {
|
|
|
23
24
|
afterEach(() => {
|
|
24
25
|
cleanupMounted();
|
|
25
26
|
usePlayerStore.getState().setLintFindingsByElement(new Map());
|
|
27
|
+
usePlayerStore.getState().setElements([]);
|
|
26
28
|
document.body.innerHTML = "";
|
|
27
29
|
});
|
|
28
30
|
|
|
@@ -153,4 +155,29 @@ describe("Filmstrip clips", () => {
|
|
|
153
155
|
);
|
|
154
156
|
expect(css).toMatch(/\.timeline-clip:not\(\.is-audio\)\s*\{\s*border-width:\s*0;/);
|
|
155
157
|
});
|
|
158
|
+
|
|
159
|
+
const audioPillZ = () =>
|
|
160
|
+
Number(
|
|
161
|
+
css.match(/\.timeline-clip\.is-audio \.timeline-clip__label\s*\{[^}]*z-index:\s*(\d+)/)?.[1],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
it("draws an audio clip's name pill over its waveform", () => {
|
|
165
|
+
expect(audioPillZ()).toBeGreaterThan(WAVEFORM_LAYER_Z);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps the out-of-sync badge over the audio name pill", () => {
|
|
169
|
+
const audio = {
|
|
170
|
+
id: "clip",
|
|
171
|
+
domId: "clip",
|
|
172
|
+
label: "City",
|
|
173
|
+
tag: "audio",
|
|
174
|
+
start: 1.7,
|
|
175
|
+
duration: 4,
|
|
176
|
+
};
|
|
177
|
+
const synced = { ...audio, track: 1, playbackStart: 0, syncOrigin: "lk-1" };
|
|
178
|
+
const video = { ...synced, id: "talk", domId: "talk", tag: "video", start: 1, track: 0 };
|
|
179
|
+
usePlayerStore.getState().setElements([video, synced]);
|
|
180
|
+
const badge = render("audio", synced, false).querySelector('[data-testid="out-of-sync-badge"]');
|
|
181
|
+
expect(Number(badge?.className.match(/\bz-\[(\d+)\]/)?.[1])).toBeGreaterThan(audioPillZ());
|
|
182
|
+
});
|
|
156
183
|
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { WAVEFORM_LAYER_Z } from "./AudioWaveform";
|
|
6
7
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
8
|
import { TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
|
|
8
9
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
@@ -298,7 +299,7 @@ describe("TimelineClipFades", () => {
|
|
|
298
299
|
it("paints the handles above the clip's waveform and thumbnail layers", () => {
|
|
299
300
|
const { host } = render(clip);
|
|
300
301
|
const layer = host.querySelector<HTMLElement>('[data-testid="clip"]')?.firstElementChild;
|
|
301
|
-
expect(Number((layer as HTMLElement).style.zIndex)).toBeGreaterThan(
|
|
302
|
+
expect(Number((layer as HTMLElement).style.zIndex)).toBeGreaterThan(WAVEFORM_LAYER_Z);
|
|
302
303
|
});
|
|
303
304
|
|
|
304
305
|
it("splits a clip narrower than two targets between the handles", () => {
|
|
@@ -321,6 +321,20 @@ describe("guideIfSaved — a guide only where the clip saves", () => {
|
|
|
321
321
|
expect(guideIfSaved(el, { start: 2.217 }, edge, 1440)).toBeNull();
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
+
it("keeps the pointer's start when a move's snap would save off the target", () => {
|
|
325
|
+
// 1440 px/s: a 31/30 s clip whose end snaps to 3.25s would save at 2.22s and jump 4px on release.
|
|
326
|
+
const el = clip("a", 0, 1, 31 / 30, 0, "div");
|
|
327
|
+
const { drag } = horizontalDrag(el, 0.5, 0);
|
|
328
|
+
const context = {
|
|
329
|
+
...ctx(undefined, [el]),
|
|
330
|
+
pps: 1440,
|
|
331
|
+
buildSnapTargets: () => [{ time: 3.25, type: "clip-edge" as const }],
|
|
332
|
+
};
|
|
333
|
+
const x = drag.originClientX + 1.22 * 1440;
|
|
334
|
+
const next = computeDragPreview(drag, x, drag.originClientY, context);
|
|
335
|
+
expect(next).toMatchObject({ previewStart: 2.22, snapTime: null, snapType: null });
|
|
336
|
+
});
|
|
337
|
+
|
|
324
338
|
it("draws no guide for a move whose saved start misses the target", () => {
|
|
325
339
|
const nested = { ...moodboard, parentCompositionStart: 1 / 30 };
|
|
326
340
|
const { drag } = horizontalDrag(nested, 0.5, 0);
|
|
@@ -388,6 +402,23 @@ describe("computeResizePreview — composition source continuity", () => {
|
|
|
388
402
|
expect(result).toMatchObject({ previewStart: 1.03, snapTime: null, snapType: null });
|
|
389
403
|
});
|
|
390
404
|
|
|
405
|
+
it("keeps the pointer's end when a tail snap would save off the target", () => {
|
|
406
|
+
// 1440 px/s: snapping 1.125s + 2.12s to 3.25s needs a 2.125s duration, which saves 7px off.
|
|
407
|
+
const result = computeResizePreview(
|
|
408
|
+
{
|
|
409
|
+
element: clip("a", 0, 1.125, 2, 0, "div"),
|
|
410
|
+
edge: "end",
|
|
411
|
+
originClientX: 0,
|
|
412
|
+
previewStart: 1.125,
|
|
413
|
+
previewDuration: 2,
|
|
414
|
+
started: true,
|
|
415
|
+
},
|
|
416
|
+
173,
|
|
417
|
+
{ scroll: fakeScroll(), pps: 1440, buildSnapTargets: () => [{ time: 3.25, type: "grid" }] },
|
|
418
|
+
);
|
|
419
|
+
expect(result).toMatchObject({ previewDuration: 2.12, snapTime: null, snapType: null });
|
|
420
|
+
});
|
|
421
|
+
|
|
391
422
|
it("does not let a tail snap shrink a clip below the drag's minimum duration", () => {
|
|
392
423
|
const result = computeResizePreview(
|
|
393
424
|
{
|
|
@@ -241,7 +241,7 @@ export function computeDragPreview(
|
|
|
241
241
|
drag.element.key ?? drag.element.id,
|
|
242
242
|
!isMusicTrack(drag.element),
|
|
243
243
|
);
|
|
244
|
-
const
|
|
244
|
+
const snapped = snapMoveToTargets(
|
|
245
245
|
nextMove.start,
|
|
246
246
|
drag.element.duration,
|
|
247
247
|
targets,
|
|
@@ -251,6 +251,11 @@ export function computeDragPreview(
|
|
|
251
251
|
dragMaxStart + drag.element.duration,
|
|
252
252
|
ctx.gridStep,
|
|
253
253
|
);
|
|
254
|
+
// A snap the saved clip would miss jumps on release, so the clip stays where the pointer put it.
|
|
255
|
+
const target = snapTarget(snapped);
|
|
256
|
+
const missed =
|
|
257
|
+
target !== null && !guideIfSaved(drag.element, { start: snapped.start }, target, pps);
|
|
258
|
+
const snap = missed ? { start: nextMove.start, snapTime: null, snapType: null } : snapped;
|
|
254
259
|
// A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
|
|
255
260
|
const group = resolveGroupDrag(drag, ctx);
|
|
256
261
|
const dragKey = drag.element.key ?? drag.element.id;
|
|
@@ -268,11 +273,7 @@ export function computeDragPreview(
|
|
|
268
273
|
return { ...drag, started: true };
|
|
269
274
|
}
|
|
270
275
|
const { track: previewTrack, insertRow } = placement;
|
|
271
|
-
const
|
|
272
|
-
placement.start === snap.start && snap.snapTime !== null && snap.snapType !== null
|
|
273
|
-
? { time: snap.snapTime, type: snap.snapType }
|
|
274
|
-
: null;
|
|
275
|
-
const guide = guideIfSaved(drag.element, { start: snap.start }, target, pps);
|
|
276
|
+
const guide = placement.start === snap.start ? snapTarget(snap) : null;
|
|
276
277
|
return {
|
|
277
278
|
...drag,
|
|
278
279
|
started: true,
|
|
@@ -289,6 +290,9 @@ export function computeDragPreview(
|
|
|
289
290
|
};
|
|
290
291
|
}
|
|
291
292
|
|
|
293
|
+
const snapTarget = (s: { snapTime: number | null; snapType: TimelineSnapType | null }) =>
|
|
294
|
+
s.snapTime !== null && s.snapType !== null ? { time: s.snapTime, type: s.snapType } : null;
|
|
295
|
+
|
|
292
296
|
/** A snap's guide, kept only when the clip's edge as its file will save it is within a pixel of it. */
|
|
293
297
|
export function guideIfSaved(
|
|
294
298
|
element: TimelineElement,
|
|
@@ -392,37 +396,38 @@ export function computeResizePreview(
|
|
|
392
396
|
);
|
|
393
397
|
const gridStep = ctx.gridStep ?? 0;
|
|
394
398
|
let snap: TimelineSnapTarget | null = null;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
399
|
+
const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1);
|
|
400
|
+
if (resize.edge === "end") {
|
|
401
|
+
const edgeTime = nextResize.start + nextResize.duration;
|
|
402
|
+
const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs, gridStep);
|
|
403
|
+
// Stay within [start+minDuration, maxEnd] so the snap can't create a
|
|
404
|
+
// degenerate clip or run past the source/composition limit.
|
|
405
|
+
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
|
|
406
|
+
if (
|
|
407
|
+
target &&
|
|
408
|
+
snapped <= maxEnd + 1e-6 &&
|
|
409
|
+
snappedDuration >= resolveTimelineMinDuration() - 1e-6
|
|
410
|
+
) {
|
|
411
|
+
const onTarget =
|
|
412
|
+
snapped === edgeTime ? nextResize : { ...nextResize, duration: snappedDuration };
|
|
413
|
+
snap = guideIfSaved(resize.element, onTarget, target, pps);
|
|
414
|
+
if (snap) nextResize = onTarget;
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
const { time: snapped, target } = snapTimelineTime(
|
|
418
|
+
nextResize.start,
|
|
419
|
+
trimTargets,
|
|
420
|
+
snapSecs,
|
|
421
|
+
gridStep,
|
|
422
|
+
);
|
|
423
|
+
const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
|
|
424
|
+
const delta = snapped - nextResize.start;
|
|
425
|
+
const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration());
|
|
426
|
+
if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
|
|
427
|
+
const onTarget =
|
|
428
|
+
snapped === nextResize.start ? nextResize : applyClipStartTrimDelta(clip, delta);
|
|
429
|
+
snap = guideIfSaved(resize.element, onTarget, target, pps);
|
|
430
|
+
if (snap) nextResize = onTarget;
|
|
426
431
|
}
|
|
427
432
|
}
|
|
428
433
|
|
|
@@ -406,6 +406,17 @@ describe("useTimelinePlayer preview frame (a trim's dragged edge)", () => {
|
|
|
406
406
|
unmountWithAct(root);
|
|
407
407
|
});
|
|
408
408
|
|
|
409
|
+
it("reads the playing transport's own time, not the playhead's, under a preview frame", () => {
|
|
410
|
+
const { api, root, adapter } = renderAttachedTimelinePlayer();
|
|
411
|
+
seekWithAct(api, 2);
|
|
412
|
+
setStorePlaying();
|
|
413
|
+
previewFrame(5);
|
|
414
|
+
adapter.seek(7);
|
|
415
|
+
act(() => api.pause());
|
|
416
|
+
expect(usePlayerStore.getState().currentTime).toBe(7);
|
|
417
|
+
unmountWithAct(root);
|
|
418
|
+
});
|
|
419
|
+
|
|
409
420
|
it("leaves live playback alone", () => {
|
|
410
421
|
const { api, root, adapter } = renderAttachedTimelinePlayer();
|
|
411
422
|
seekWithAct(api, 2);
|
|
@@ -308,6 +308,22 @@
|
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/* A video's sound strip is too short for the peak readout: only the mark stays, on a chip clear of the clip's
|
|
312
|
+
rounded corner and above the waveform canvas layer (z 10), below the fade handles (z 30). */
|
|
313
|
+
.audible-video-wave [data-peak-text] {
|
|
314
|
+
display: none;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.audible-video-wave [data-peak-badge] {
|
|
318
|
+
top: 50%;
|
|
319
|
+
right: var(--timeline-clip-radius);
|
|
320
|
+
translate: 0 -50%;
|
|
321
|
+
padding: 1px 3px;
|
|
322
|
+
border-radius: 3px;
|
|
323
|
+
background: var(--timeline-clip-chip-audio-bg);
|
|
324
|
+
z-index: 20;
|
|
325
|
+
}
|
|
326
|
+
|
|
311
327
|
.timeline-clip.is-audio.is-hovered {
|
|
312
328
|
background-color: var(--timeline-clip-audio-bg-hover);
|
|
313
329
|
}
|
|
@@ -350,7 +366,6 @@
|
|
|
350
366
|
position: absolute;
|
|
351
367
|
top: 5px;
|
|
352
368
|
left: 6px;
|
|
353
|
-
z-index: 6;
|
|
354
369
|
width: fit-content;
|
|
355
370
|
max-width: calc(100% - 22px);
|
|
356
371
|
overflow: hidden;
|
|
@@ -411,6 +426,7 @@
|
|
|
411
426
|
.timeline-clip.is-audio .timeline-clip__label {
|
|
412
427
|
top: 11px;
|
|
413
428
|
left: 9px;
|
|
429
|
+
z-index: 11;
|
|
414
430
|
background: var(--timeline-clip-chip-audio-bg);
|
|
415
431
|
}
|
|
416
432
|
|
|
@@ -936,8 +952,8 @@
|
|
|
936
952
|
}
|
|
937
953
|
}
|
|
938
954
|
|
|
939
|
-
/* Framey on the header's "Edit with Framey" (OpenInDesktopButton.tsx): a
|
|
940
|
-
* the app while it opens. The eye follows the pointer from script. */
|
|
955
|
+
/* Framey on the header's "Edit with Framey" (OpenInDesktopButton.tsx): a pulse as the page opens, a wiggle
|
|
956
|
+
* on hover, a flight towards the app while it opens. The eye follows the pointer from script. */
|
|
941
957
|
@keyframes hf-framey-wiggle {
|
|
942
958
|
0%,
|
|
943
959
|
100% {
|
|
@@ -961,6 +977,19 @@
|
|
|
961
977
|
}
|
|
962
978
|
}
|
|
963
979
|
|
|
980
|
+
@keyframes hf-framey-pulse {
|
|
981
|
+
0% {
|
|
982
|
+
box-shadow: 0 0 0 0 color-mix(in oklch, var(--color-accent) 45%, transparent);
|
|
983
|
+
}
|
|
984
|
+
100% {
|
|
985
|
+
box-shadow: 0 0 0 8px transparent;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.hf-framey-trigger {
|
|
990
|
+
animation: hf-framey-pulse 1.6s ease-out 3;
|
|
991
|
+
}
|
|
992
|
+
|
|
964
993
|
.hf-framey {
|
|
965
994
|
transform-origin: 30% 30%;
|
|
966
995
|
}
|
|
@@ -978,6 +1007,7 @@
|
|
|
978
1007
|
}
|
|
979
1008
|
|
|
980
1009
|
@media (prefers-reduced-motion: reduce) {
|
|
1010
|
+
.hf-framey-trigger,
|
|
981
1011
|
.hf-framey-trigger:hover .hf-framey,
|
|
982
1012
|
.hf-framey-trigger[data-opening] .hf-framey {
|
|
983
1013
|
animation: none;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
addStudioPendingEditFlushListener,
|
|
5
|
+
adoptingStudioPendingEdit,
|
|
5
6
|
beginStudioPendingEdit,
|
|
6
7
|
flushStudioPendingEdits,
|
|
7
8
|
hasStudioPendingEdits,
|
|
@@ -312,3 +313,54 @@ describe("a drain of only the current edits", () => {
|
|
|
312
313
|
landLater();
|
|
313
314
|
});
|
|
314
315
|
});
|
|
316
|
+
|
|
317
|
+
describe("an edit undo painted back while it saves", () => {
|
|
318
|
+
function paintedBackEdit() {
|
|
319
|
+
const box = { look: "edited" };
|
|
320
|
+
const edit = beginStudioPendingEdit(() => {
|
|
321
|
+
const shown = box.look;
|
|
322
|
+
box.look = "start";
|
|
323
|
+
return () => (box.look = shown);
|
|
324
|
+
});
|
|
325
|
+
let landSave!: () => void;
|
|
326
|
+
const inFlight = edit.adopt(() => adoptingStudioPendingEdit())!;
|
|
327
|
+
edit.settle(new Promise<void>((resolve) => (landSave = resolve)));
|
|
328
|
+
const shown = paintBackNewestStudioPendingEdit()!;
|
|
329
|
+
return { box, inFlight, shown, landSave };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
it("draws on the edit as shown, then shows it undone again in the same task", () => {
|
|
333
|
+
const { box, inFlight, shown, landSave } = paintedBackEdit();
|
|
334
|
+
let drawnOver = "";
|
|
335
|
+
inFlight.drawKeepingUndone(() => {
|
|
336
|
+
drawnOver = box.look;
|
|
337
|
+
box.look += "+size";
|
|
338
|
+
});
|
|
339
|
+
expect(drawnOver).toBe("edited");
|
|
340
|
+
expect(box.look).toBe("start");
|
|
341
|
+
shown.showAgain();
|
|
342
|
+
expect(box.look).toBe("edited+size");
|
|
343
|
+
landSave();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("keeps a redraw until the edit is shown again", () => {
|
|
347
|
+
const { inFlight, shown, landSave } = paintedBackEdit();
|
|
348
|
+
const redraw = vi.fn();
|
|
349
|
+
inFlight.drawUnlessUndone(redraw);
|
|
350
|
+
expect(redraw).not.toHaveBeenCalled();
|
|
351
|
+
shown.showAgain();
|
|
352
|
+
expect(redraw).toHaveBeenCalledTimes(1);
|
|
353
|
+
landSave();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("counts an edit whose later write failed as saved once an earlier write landed, so undo still steps it", async () => {
|
|
358
|
+
const edit = beginStudioPendingEdit(() => () => undefined);
|
|
359
|
+
const committed = edit.adopt(async () => {
|
|
360
|
+
adoptingStudioPendingEdit()!.markSaved();
|
|
361
|
+
throw new Error("the crop save failed");
|
|
362
|
+
});
|
|
363
|
+
edit.settle(committed.catch(() => undefined));
|
|
364
|
+
const shown = paintBackNewestStudioPendingEdit()!;
|
|
365
|
+
await expect(shown.landed()).resolves.toBe(true);
|
|
366
|
+
});
|
|
@@ -14,11 +14,25 @@ export type StudioEditRevert = () => () => void;
|
|
|
14
14
|
interface PendingEdit {
|
|
15
15
|
revert: StudioEditRevert | null;
|
|
16
16
|
landed: () => Promise<boolean>;
|
|
17
|
+
redraws: Array<() => void>;
|
|
18
|
+
showAgain: (() => void) | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StudioEditInFlight {
|
|
22
|
+
reverted: () => boolean;
|
|
23
|
+
within: <T>(run: () => T) => T;
|
|
24
|
+
drawUnlessUndone: (draw: () => void) => void;
|
|
25
|
+
drawKeepingUndone: <T>(draw: () => T) => T;
|
|
26
|
+
markSaved: () => void;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
const pendingEdits = new Map<Promise<unknown>, PendingEdit>();
|
|
20
30
|
const NOT_SAVED = () => Promise.resolve(false);
|
|
21
|
-
let adopting =
|
|
31
|
+
let adopting: StudioEditInFlight | null = null;
|
|
32
|
+
|
|
33
|
+
export function adoptingStudioPendingEdit(): StudioEditInFlight | null {
|
|
34
|
+
return adopting;
|
|
35
|
+
}
|
|
22
36
|
|
|
23
37
|
function waitForPostBlurEffects(): Promise<void> {
|
|
24
38
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -73,7 +87,7 @@ export function trackStudioPendingEdit(
|
|
|
73
87
|
if (!result) return undefined;
|
|
74
88
|
const promise = Promise.resolve(result);
|
|
75
89
|
if (adopting) return promise;
|
|
76
|
-
pendingEdits.set(promise, { revert: null, landed: NOT_SAVED });
|
|
90
|
+
pendingEdits.set(promise, { revert: null, landed: NOT_SAVED, redraws: [], showAgain: null });
|
|
77
91
|
promise.then(
|
|
78
92
|
() => pendingEdits.delete(promise),
|
|
79
93
|
() => pendingEdits.delete(promise),
|
|
@@ -97,25 +111,49 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
|
|
|
97
111
|
const entry = pendingEdits.get(promise)!;
|
|
98
112
|
entry.revert = revert;
|
|
99
113
|
let landed = Promise.resolve(false);
|
|
114
|
+
let saved = false;
|
|
100
115
|
entry.landed = () => landed;
|
|
116
|
+
const inFlight: StudioEditInFlight = {
|
|
117
|
+
reverted: () => entry.revert === null && revert !== null,
|
|
118
|
+
within(run) {
|
|
119
|
+
const outer = adopting;
|
|
120
|
+
adopting = inFlight;
|
|
121
|
+
try {
|
|
122
|
+
return run();
|
|
123
|
+
} finally {
|
|
124
|
+
adopting = outer;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
drawUnlessUndone(draw) {
|
|
128
|
+
if (inFlight.reverted()) entry.redraws.push(draw);
|
|
129
|
+
else draw();
|
|
130
|
+
},
|
|
131
|
+
drawKeepingUndone(draw) {
|
|
132
|
+
if (!inFlight.reverted()) return draw();
|
|
133
|
+
entry.showAgain?.();
|
|
134
|
+
try {
|
|
135
|
+
return draw();
|
|
136
|
+
} finally {
|
|
137
|
+
entry.showAgain = revert!();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
markSaved: () => void (saved = true),
|
|
141
|
+
};
|
|
101
142
|
return {
|
|
102
143
|
settle,
|
|
103
|
-
reverted:
|
|
104
|
-
// Only what `start` registers synchronously is adopted; a
|
|
144
|
+
reverted: inFlight.reverted,
|
|
145
|
+
// Only what `start` registers synchronously is adopted; a later write joins only through `within`.
|
|
105
146
|
adopt<T>(start: () => T): T {
|
|
106
|
-
adopting = true;
|
|
107
147
|
try {
|
|
108
|
-
const committed = start
|
|
148
|
+
const committed = inFlight.within(start);
|
|
109
149
|
landed = Promise.resolve(committed).then(
|
|
110
150
|
() => true,
|
|
111
|
-
() =>
|
|
151
|
+
() => saved,
|
|
112
152
|
);
|
|
113
153
|
return committed;
|
|
114
154
|
} catch (error) {
|
|
115
155
|
settle();
|
|
116
156
|
throw error;
|
|
117
|
-
} finally {
|
|
118
|
-
adopting = false;
|
|
119
157
|
}
|
|
120
158
|
},
|
|
121
159
|
};
|
|
@@ -129,7 +167,14 @@ export function paintBackNewestStudioPendingEdit(): {
|
|
|
129
167
|
const revert = newest?.revert;
|
|
130
168
|
if (!newest || !revert) return null;
|
|
131
169
|
newest.revert = null;
|
|
132
|
-
|
|
170
|
+
newest.showAgain = revert();
|
|
171
|
+
return {
|
|
172
|
+
showAgain: () => {
|
|
173
|
+
newest.showAgain?.();
|
|
174
|
+
for (const redraw of newest.redraws.splice(0)) redraw();
|
|
175
|
+
},
|
|
176
|
+
landed: newest.landed,
|
|
177
|
+
};
|
|
133
178
|
}
|
|
134
179
|
|
|
135
180
|
export function revertNewestStudioPendingEdit(): (() => void) | null {
|