@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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
4
|
+
import { buildPatchTarget } from "../utils/timelineElementSplit";
|
|
5
|
+
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
6
|
+
import { markStudioWriteToken } from "../utils/studioFileVersion";
|
|
7
|
+
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
8
|
+
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
9
|
+
|
|
10
|
+
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
interface FreezeFrameResponse {
|
|
13
|
+
before: string;
|
|
14
|
+
after: string;
|
|
15
|
+
version: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
|
|
19
|
+
if (typeof value !== "object" || value === null) return false;
|
|
20
|
+
const body: Partial<Record<keyof FreezeFrameResponse, unknown>> = value;
|
|
21
|
+
return (
|
|
22
|
+
typeof body.before === "string" &&
|
|
23
|
+
typeof body.after === "string" &&
|
|
24
|
+
typeof body.version === "string"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function errorOf(value: unknown): string | null {
|
|
29
|
+
if (typeof value !== "object" || value === null || !("error" in value)) return null;
|
|
30
|
+
return typeof value.error === "string" ? value.error : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readVersion(projectId: string, path: string): Promise<string> {
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
|
|
36
|
+
);
|
|
37
|
+
const body: unknown = await response.json().catch(() => null);
|
|
38
|
+
const version =
|
|
39
|
+
typeof body === "object" && body !== null && "version" in body ? body.version : null;
|
|
40
|
+
if (!response.ok || typeof version !== "string") throw new Error(`Could not read ${path}`);
|
|
41
|
+
return version;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** POST the freeze to studio-server, which extracts the still and writes the whole edit at once. */
|
|
45
|
+
export async function requestFreezeFrame(input: {
|
|
46
|
+
projectId: string;
|
|
47
|
+
path: string;
|
|
48
|
+
element: TimelineElement;
|
|
49
|
+
playhead: number;
|
|
50
|
+
}): Promise<FreezeFrameResponse> {
|
|
51
|
+
const target = buildPatchTarget(input.element);
|
|
52
|
+
if (!target) throw new Error("This clip has no id to freeze it by");
|
|
53
|
+
const expectedVersion = await readVersion(input.projectId, input.path);
|
|
54
|
+
const transactionToken = `freeze:${crypto.randomUUID()}`;
|
|
55
|
+
markStudioWriteToken(transactionToken);
|
|
56
|
+
const response = await fetch(
|
|
57
|
+
buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
|
|
58
|
+
{
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"X-Hyperframes-Write-Token": transactionToken,
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
path: input.path,
|
|
66
|
+
expectedVersion,
|
|
67
|
+
target,
|
|
68
|
+
playhead: toAuthoredStart(input.element, input.playhead),
|
|
69
|
+
transactionToken,
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
const body: unknown = await response.json().catch(() => null);
|
|
74
|
+
if (!response.ok || !isFreezeFrameResponse(body)) {
|
|
75
|
+
throw new Error(errorOf(body) ?? `Freeze frame failed (${response.status})`);
|
|
76
|
+
}
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface UseFreezeFrameOptions {
|
|
81
|
+
projectId: string | null;
|
|
82
|
+
activeCompPath: string | null;
|
|
83
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
84
|
+
writeProjectFile: ProjectFileWriter;
|
|
85
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
86
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
87
|
+
reloadPreview: () => void;
|
|
88
|
+
forceReloadSdkSession?: () => void;
|
|
89
|
+
isRecordingRef?: React.RefObject<boolean>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useFreezeFrame(options: UseFreezeFrameOptions) {
|
|
93
|
+
const optionsRef = useRef(options);
|
|
94
|
+
optionsRef.current = options;
|
|
95
|
+
|
|
96
|
+
return useCallback(async (element: TimelineElement, playhead: number) => {
|
|
97
|
+
const opts = optionsRef.current;
|
|
98
|
+
if (opts.isRecordingRef?.current) {
|
|
99
|
+
opts.showToast("Cannot edit timeline while recording", "error");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const projectId = opts.projectId;
|
|
103
|
+
if (!projectId) return;
|
|
104
|
+
const path = element.sourceFile || opts.activeCompPath || "index.html";
|
|
105
|
+
try {
|
|
106
|
+
await serializeStudioFileMutations(opts.writeProjectFile, [path], async () => {
|
|
107
|
+
const result = await requestFreezeFrame({ projectId, path, element, playhead });
|
|
108
|
+
await opts.recordEdit({
|
|
109
|
+
label: "Freeze frame",
|
|
110
|
+
files: { [path]: { before: result.before, after: result.after } },
|
|
111
|
+
});
|
|
112
|
+
opts.observeProjectFileVersion?.(path, result.version);
|
|
113
|
+
});
|
|
114
|
+
opts.forceReloadSdkSession?.();
|
|
115
|
+
opts.reloadPreview();
|
|
116
|
+
opts.showToast(`Froze a 2 s still at ${playhead.toFixed(2)}s`, "info");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
opts.showToast(error instanceof Error ? error.message : "Freeze frame failed", "error");
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
}
|
|
@@ -243,8 +243,14 @@ it("a step that cannot reach the server says so", async () => {
|
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
-
it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
|
|
246
|
+
it("predicts a step from what this tab wrote as soon as its save ends, and not while a claim or step may have moved the history", async () => {
|
|
247
247
|
const { hook, save, readFile } = await studio();
|
|
248
|
+
const real = globalThis.fetch;
|
|
249
|
+
const slowView = (url: string, init?: RequestInit) =>
|
|
250
|
+
url.endsWith("/history")
|
|
251
|
+
? new Promise((r) => setTimeout(r, 50)).then(() => real(url, init))
|
|
252
|
+
: real(url, init);
|
|
253
|
+
vi.stubGlobal("fetch", slowView);
|
|
248
254
|
save("B");
|
|
249
255
|
const claim = hook().recordEdit({
|
|
250
256
|
label: "Moved Title",
|
|
@@ -252,11 +258,8 @@ it("predicts a step from what this tab wrote, and not while a claim or step may
|
|
|
252
258
|
});
|
|
253
259
|
expect(hook().predict("undo")).toBeNull();
|
|
254
260
|
await act(() => claim);
|
|
255
|
-
const predicted =
|
|
256
|
-
|
|
257
|
-
expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
258
|
-
return next!;
|
|
259
|
-
});
|
|
261
|
+
const predicted = hook().predict("undo")!;
|
|
262
|
+
expect(predicted?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
260
263
|
|
|
261
264
|
hook().noteOutsideChange();
|
|
262
265
|
expect(hook().predict("undo")).toBeNull();
|
|
@@ -223,7 +223,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
223
223
|
const claimed = claimHeld(reply, label);
|
|
224
224
|
if (claimed) own.remember(claimed, files);
|
|
225
225
|
heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
|
|
226
|
-
|
|
226
|
+
await refresh();
|
|
227
227
|
},
|
|
228
228
|
[projectId, refresh, own],
|
|
229
229
|
);
|
|
@@ -6,6 +6,7 @@ import { trackStudioRazorSplit } from "../telemetry/events";
|
|
|
6
6
|
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
|
|
7
7
|
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
|
|
8
8
|
import type { RecordEditInput } from "./timelineEditingHelpers";
|
|
9
|
+
import { useFreezeFrame } from "./useFreezeFrame";
|
|
9
10
|
|
|
10
11
|
interface UseRazorSplitOptions {
|
|
11
12
|
projectId: string | null;
|
|
@@ -135,5 +136,17 @@ export function useRazorSplit({
|
|
|
135
136
|
[isRecordingRef, runCut, showToast],
|
|
136
137
|
);
|
|
137
138
|
|
|
138
|
-
|
|
139
|
+
const handleFreezeFrame = useFreezeFrame({
|
|
140
|
+
projectId,
|
|
141
|
+
activeCompPath,
|
|
142
|
+
showToast,
|
|
143
|
+
writeProjectFile,
|
|
144
|
+
observeProjectFileVersion,
|
|
145
|
+
recordEdit,
|
|
146
|
+
reloadPreview,
|
|
147
|
+
forceReloadSdkSession,
|
|
148
|
+
isRecordingRef,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame };
|
|
139
152
|
}
|
|
@@ -5,6 +5,14 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
7
7
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
8
|
+
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
|
|
9
|
+
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
|
|
10
|
+
|
|
11
|
+
function unwrapPeakMarks(node: ReactNode): ReactNode {
|
|
12
|
+
return isValidElement<{ children?: ReactNode }>(node) && node.type === ClipPeakMarks
|
|
13
|
+
? node.props.children
|
|
14
|
+
: node;
|
|
15
|
+
}
|
|
8
16
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
9
17
|
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
|
|
10
18
|
import { buildCompositionThumbnailUrl } from "../player/components/CompositionThumbnail";
|
|
@@ -93,9 +101,51 @@ describe("useRenderClipContent", () => {
|
|
|
93
101
|
root.render(React.createElement(Harness));
|
|
94
102
|
});
|
|
95
103
|
act(() => root.unmount());
|
|
104
|
+
return unwrapPeakMarks(content);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderRaw(el: TimelineElement): ReactNode {
|
|
108
|
+
const host = document.createElement("div");
|
|
109
|
+
document.body.append(host);
|
|
110
|
+
const root = createRoot(host);
|
|
111
|
+
let content: ReactNode = null;
|
|
112
|
+
function Harness() {
|
|
113
|
+
const render = useRenderClipContent({
|
|
114
|
+
projectIdRef: { current: "my-project" },
|
|
115
|
+
compIdToSrc: new Map(),
|
|
116
|
+
activePreviewUrl: null,
|
|
117
|
+
effectiveTimelineDuration: 12,
|
|
118
|
+
});
|
|
119
|
+
content = render(el, { clip: "#222", label: "#fff" });
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
act(() => root.render(React.createElement(Harness)));
|
|
123
|
+
act(() => root.unmount());
|
|
96
124
|
return content;
|
|
97
125
|
}
|
|
98
126
|
|
|
127
|
+
it("wraps a clip's waveform in peak marks at its own gain over its played source window", () => {
|
|
128
|
+
const content = renderRaw({
|
|
129
|
+
id: "voiceover",
|
|
130
|
+
tag: "audio",
|
|
131
|
+
start: 1,
|
|
132
|
+
duration: 4,
|
|
133
|
+
track: 1,
|
|
134
|
+
src: "assets/voiceover.mp3",
|
|
135
|
+
playbackStart: 2,
|
|
136
|
+
playbackRate: 1.5,
|
|
137
|
+
volume: 2,
|
|
138
|
+
});
|
|
139
|
+
expect(isValidElement(content) && content.type).toBe(ClipPeakMarks);
|
|
140
|
+
if (isValidElement<Record<string, unknown>>(content)) {
|
|
141
|
+
expect(content.props).toMatchObject({
|
|
142
|
+
peaksUrl: "/api/projects/my-project/peaks/assets/voiceover.mp3",
|
|
143
|
+
sourceWindow: { mediaStart: 2, sourceSpan: 6 },
|
|
144
|
+
gain: 2,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
99
149
|
it("renders audio clips as waveforms even when a composition preview URL is active", () => {
|
|
100
150
|
const content = renderClipContent({
|
|
101
151
|
id: "voiceover",
|
|
@@ -176,6 +226,58 @@ describe("useRenderClipContent", () => {
|
|
|
176
226
|
}
|
|
177
227
|
});
|
|
178
228
|
|
|
229
|
+
it("draws a waveform strip under the thumbnails of a video with sound", () => {
|
|
230
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
231
|
+
const content = renderClipContent(
|
|
232
|
+
{
|
|
233
|
+
id: "talk",
|
|
234
|
+
tag: "video",
|
|
235
|
+
start: 0,
|
|
236
|
+
duration: 4,
|
|
237
|
+
track: 0,
|
|
238
|
+
src: "talk.mp4",
|
|
239
|
+
hasAudio: true,
|
|
240
|
+
},
|
|
241
|
+
null,
|
|
242
|
+
);
|
|
243
|
+
expect(isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)).toBe(true);
|
|
244
|
+
if (!isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)) return;
|
|
245
|
+
expect(content.type).toBe(AudibleVideoClipContent);
|
|
246
|
+
const { thumbnail } = content.props;
|
|
247
|
+
const waveform = unwrapPeakMarks(content.props.waveform);
|
|
248
|
+
expect(isValidElement(thumbnail) && thumbnail.type).toBe(VideoThumbnail);
|
|
249
|
+
expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe(
|
|
250
|
+
"/api/projects/my-project/waveform/talk.mp4",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("keeps a muted or silent video to thumbnails only", () => {
|
|
255
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
256
|
+
for (const extra of [{ hasAudio: true, muted: true }, { hasAudio: false }]) {
|
|
257
|
+
const content = renderClipContent(
|
|
258
|
+
{ id: "b", tag: "video", start: 0, duration: 4, track: 0, src: "b.mp4", ...extra },
|
|
259
|
+
null,
|
|
260
|
+
);
|
|
261
|
+
expect(isValidElement(content) && content.type).toBe(VideoThumbnail);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("still shows the sound strip when thumbnails are off", () => {
|
|
266
|
+
const content = renderClipContent(
|
|
267
|
+
{
|
|
268
|
+
id: "talk",
|
|
269
|
+
tag: "video",
|
|
270
|
+
start: 0,
|
|
271
|
+
duration: 4,
|
|
272
|
+
track: 0,
|
|
273
|
+
src: "talk.mp4",
|
|
274
|
+
hasAudio: true,
|
|
275
|
+
},
|
|
276
|
+
null,
|
|
277
|
+
);
|
|
278
|
+
expect(isValidElement(content) && content.type).toBe(AudibleVideoClipContent);
|
|
279
|
+
});
|
|
280
|
+
|
|
179
281
|
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
|
180
282
|
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
181
283
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
|
+
import { resolvePreviewRelative } from "../utils/previewRelativePath";
|
|
2
3
|
import { useCallback, type ReactNode } from "react";
|
|
3
4
|
import { createElement } from "react";
|
|
4
5
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
@@ -11,6 +12,9 @@ import type { TimelineClipRenderContext } from "../player/components/TimelineTyp
|
|
|
11
12
|
import { audioPillFlags } from "../player/components/audioClipLink";
|
|
12
13
|
import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
|
|
13
14
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
15
|
+
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
|
|
16
|
+
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
|
|
17
|
+
import { clipHasSound } from "../player/components/clipMenuNormalize";
|
|
14
18
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
15
19
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
16
20
|
import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
|
|
@@ -33,24 +37,6 @@ export function normalizeCompositionSrc(
|
|
|
33
37
|
return compSrc;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/** Resolve a media src to its project-relative preview path, or null. */
|
|
37
|
-
function resolvePreviewRelative(
|
|
38
|
-
src: string | undefined,
|
|
39
|
-
pid: string,
|
|
40
|
-
origin: string,
|
|
41
|
-
): string | null {
|
|
42
|
-
if (!src) return null;
|
|
43
|
-
try {
|
|
44
|
-
const parsed = new URL(src, origin);
|
|
45
|
-
const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
|
|
46
|
-
return parsed.pathname.startsWith(base)
|
|
47
|
-
? decodeURIComponent(parsed.pathname.slice(base.length))
|
|
48
|
-
: null;
|
|
49
|
-
} catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
40
|
/**
|
|
55
41
|
* The trimmed source slice as start/end fractions (0–1) of the source, so the
|
|
56
42
|
* waveform can window its peaks to the clip edges. Undefined when the source
|
|
@@ -88,7 +74,7 @@ function renderAudioClip(
|
|
|
88
74
|
? buildProjectApiPath(pid, `/waveform/${encodedRelative}`)
|
|
89
75
|
: undefined;
|
|
90
76
|
const { start, end } = trimFractions(el);
|
|
91
|
-
|
|
77
|
+
const waveform = createElement(AudioWaveform, {
|
|
92
78
|
audioUrl,
|
|
93
79
|
waveformUrl,
|
|
94
80
|
label: "",
|
|
@@ -100,6 +86,27 @@ function renderAudioClip(
|
|
|
100
86
|
priority: context.priority,
|
|
101
87
|
...audioPillFlags(el, elements),
|
|
102
88
|
});
|
|
89
|
+
return createElement(
|
|
90
|
+
ClipPeakMarks,
|
|
91
|
+
{
|
|
92
|
+
peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined,
|
|
93
|
+
sourceWindow: {
|
|
94
|
+
mediaStart: el.playbackStart ?? 0,
|
|
95
|
+
sourceSpan: el.duration * (el.playbackRate ?? 1),
|
|
96
|
+
},
|
|
97
|
+
gain: el.volume ?? 1,
|
|
98
|
+
},
|
|
99
|
+
waveform,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function withSoundStrip(
|
|
104
|
+
el: TimelineElement,
|
|
105
|
+
thumbnail: ReactNode,
|
|
106
|
+
waveform: () => ReactNode,
|
|
107
|
+
): ReactNode {
|
|
108
|
+
if (el.tag !== "video" || !clipHasSound(el)) return thumbnail;
|
|
109
|
+
return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() });
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
export interface UseRenderClipContentOptions {
|
|
@@ -134,10 +141,9 @@ export function useRenderClipContent({
|
|
|
134
141
|
|
|
135
142
|
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
|
|
136
143
|
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
144
|
+
const waveform = () => renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
|
|
137
145
|
if (effectiveMode === "hidden") {
|
|
138
|
-
return rendersWaveform(el)
|
|
139
|
-
? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
|
|
140
|
-
: null;
|
|
146
|
+
return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform);
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
let compSrc = el.compositionSrc;
|
|
@@ -222,7 +228,7 @@ export function useRenderClipContent({
|
|
|
222
228
|
rich: context.rich,
|
|
223
229
|
});
|
|
224
230
|
}
|
|
225
|
-
|
|
231
|
+
const thumbnail = createElement(VideoThumbnail, {
|
|
226
232
|
videoSrc: mediaSrc,
|
|
227
233
|
label: "",
|
|
228
234
|
labelColor: style.label,
|
|
@@ -233,6 +239,7 @@ export function useRenderClipContent({
|
|
|
233
239
|
sessionEpoch,
|
|
234
240
|
priority: context.priority,
|
|
235
241
|
});
|
|
242
|
+
return withSoundStrip(el, thumbnail, waveform);
|
|
236
243
|
}
|
|
237
244
|
|
|
238
245
|
if (htmlPreviewEligible) {
|
|
@@ -492,8 +492,7 @@ export function useTimelineEditing({
|
|
|
492
492
|
});
|
|
493
493
|
|
|
494
494
|
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
|
|
495
|
-
|
|
496
|
-
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
|
495
|
+
const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
|
|
497
496
|
projectId,
|
|
498
497
|
activeCompPath,
|
|
499
498
|
showToast,
|
|
@@ -575,6 +574,7 @@ export function useTimelineEditing({
|
|
|
575
574
|
),
|
|
576
575
|
handleTimelineElementSplit: trackedRazorSplit,
|
|
577
576
|
handleRazorSplit: trackedRazorSplit,
|
|
577
|
+
handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
|
|
578
578
|
// Same selection the handler itself splits (useRazorSplit.ts).
|
|
579
579
|
handleRazorSplitAll: track(
|
|
580
580
|
guard(
|
package/src/index.ts
CHANGED
|
@@ -175,6 +175,7 @@ export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
|
|
|
175
175
|
export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
|
|
176
176
|
export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
|
|
177
177
|
export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
|
|
178
|
+
export { useAudioMetersVisible } from "./utils/audioMeterVisibility";
|
|
178
179
|
export { useClipboard } from "./hooks/useClipboard";
|
|
179
180
|
export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
180
181
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Share of the clip height the sound strip takes under a video's thumbnails. */
|
|
4
|
+
const AUDIBLE_VIDEO_WAVE_SHARE = 0.38;
|
|
5
|
+
|
|
6
|
+
/** A video that carries sound: thumbnails on top, its waveform strip along the bottom. */
|
|
7
|
+
export function AudibleVideoClipContent({
|
|
8
|
+
thumbnail,
|
|
9
|
+
waveform,
|
|
10
|
+
}: {
|
|
11
|
+
thumbnail: ReactNode;
|
|
12
|
+
waveform: ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
const waveHeight = `${AUDIBLE_VIDEO_WAVE_SHARE * 100}%`;
|
|
15
|
+
return (
|
|
16
|
+
<div className="relative h-full w-full" data-testid="audible-video-clip">
|
|
17
|
+
<div className="absolute inset-x-0 top-0" style={{ bottom: waveHeight }}>
|
|
18
|
+
{thumbnail}
|
|
19
|
+
</div>
|
|
20
|
+
<div
|
|
21
|
+
className="absolute inset-x-0 bottom-0 bg-black/30"
|
|
22
|
+
data-testid="audible-video-wave"
|
|
23
|
+
style={{ height: waveHeight }}
|
|
24
|
+
>
|
|
25
|
+
{waveform}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { usePreviewIframeStore } from "../store/previewIframeStore";
|
|
8
|
+
import { ClipBadges } from "./ClipBadges";
|
|
9
|
+
|
|
10
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let root: Root | null = null;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
act(() => root?.unmount());
|
|
19
|
+
root = null;
|
|
20
|
+
usePreviewIframeStore.getState().setIframe(null);
|
|
21
|
+
document.body.innerHTML = "";
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const talk: TimelineElement = {
|
|
25
|
+
id: "talk",
|
|
26
|
+
domId: "talk",
|
|
27
|
+
tag: "video",
|
|
28
|
+
start: 0,
|
|
29
|
+
duration: 6,
|
|
30
|
+
track: 0,
|
|
31
|
+
hasAudio: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function render(el: TimelineElement) {
|
|
35
|
+
const host = document.createElement("div");
|
|
36
|
+
document.body.append(host);
|
|
37
|
+
root = createRoot(host);
|
|
38
|
+
act(() => root?.render(<ClipBadges el={el} />));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const labels = () =>
|
|
42
|
+
Array.from(document.querySelectorAll("[data-badge]")).map(
|
|
43
|
+
(badge) => `${badge.getAttribute("data-badge")}:${badge.textContent}`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
function mountPreview(html: string): Document {
|
|
47
|
+
const iframe = document.createElement("iframe");
|
|
48
|
+
document.body.append(iframe);
|
|
49
|
+
const doc = iframe.contentDocument;
|
|
50
|
+
if (!doc) throw new Error("no iframe document");
|
|
51
|
+
doc.body.innerHTML = html;
|
|
52
|
+
usePreviewIframeStore.getState().setIframe(iframe);
|
|
53
|
+
return doc;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("ClipBadges", () => {
|
|
57
|
+
it("renders nothing for an untouched clip", () => {
|
|
58
|
+
render(talk);
|
|
59
|
+
expect(document.querySelector("[data-testid='clip-badges']")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls back to the store's volume without a preview", () => {
|
|
63
|
+
render({ ...talk, volume: 1.8 });
|
|
64
|
+
expect(labels()).toEqual(["volume:180%"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reads the live node and follows an agent's attribute edit", async () => {
|
|
68
|
+
const doc = mountPreview(
|
|
69
|
+
`<video id="talk" data-has-audio="true" data-link="talk-pair" style="clip-path: inset(10px)"></video>`,
|
|
70
|
+
);
|
|
71
|
+
render(talk);
|
|
72
|
+
expect(labels()).toEqual(["link:🔗", "crop:Crop"]);
|
|
73
|
+
const node = doc.getElementById("talk");
|
|
74
|
+
await act(async () => {
|
|
75
|
+
node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}');
|
|
76
|
+
node?.setAttribute("data-volume", "0.6");
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
|
+
});
|
|
79
|
+
expect(labels()).toEqual(["link:🔗", "look:Warm daylight", "crop:Crop", "more:+1"]);
|
|
80
|
+
expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs";
|
|
4
|
+
import { useClipToolState } from "./useClipToolState";
|
|
5
|
+
|
|
6
|
+
function SpeakerGlyph({ muted }: { muted: boolean }) {
|
|
7
|
+
return (
|
|
8
|
+
<svg width="10" height="10" viewBox="0 0 16 16" aria-hidden="true">
|
|
9
|
+
<path d="M2 6h3l4-3v10l-4-3H2z" fill="currentColor" />
|
|
10
|
+
{muted ? (
|
|
11
|
+
<path d="M11 6l4 4M15 6l-4 4" stroke="currentColor" strokeWidth="1.4" />
|
|
12
|
+
) : (
|
|
13
|
+
<path
|
|
14
|
+
d="M11 5.5a3.5 3.5 0 0 1 0 5M12.5 3.5a6 6 0 0 1 0 9"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
fill="none"
|
|
17
|
+
strokeWidth="1.4"
|
|
18
|
+
/>
|
|
19
|
+
)}
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function BadgeContent({ badge }: { badge: ClipBadge }) {
|
|
25
|
+
if (badge.kind === "link") return <span aria-hidden="true">🔗</span>;
|
|
26
|
+
if (badge.kind === "volume") {
|
|
27
|
+
const muted = badge.label === "Muted";
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<SpeakerGlyph muted={muted} />
|
|
31
|
+
{muted ? null : <span>{badge.label}</span>}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return <span>{badge.label}</span>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BADGE_CLASS =
|
|
39
|
+
"inline-flex items-center gap-0.5 rounded-[3px] border border-white/20 bg-black/55 px-1 text-[9px] leading-[14px] text-white/90 whitespace-nowrap";
|
|
40
|
+
|
|
41
|
+
/** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */
|
|
42
|
+
export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) {
|
|
43
|
+
const state = useClipToolState(el);
|
|
44
|
+
const { visible, hidden } = splitVisibleBadges(readClipBadges(state));
|
|
45
|
+
if (visible.length === 0) return null;
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
|
|
49
|
+
data-testid="clip-badges"
|
|
50
|
+
>
|
|
51
|
+
{visible.map((badge) => (
|
|
52
|
+
<span key={badge.kind} className={BADGE_CLASS} title={badge.label} data-badge={badge.kind}>
|
|
53
|
+
<BadgeContent badge={badge} />
|
|
54
|
+
</span>
|
|
55
|
+
))}
|
|
56
|
+
{hidden.length > 0 && (
|
|
57
|
+
<span
|
|
58
|
+
className={`${BADGE_CLASS} pointer-events-auto`}
|
|
59
|
+
title={hidden.map((badge) => badge.label).join(", ")}
|
|
60
|
+
data-badge="more"
|
|
61
|
+
>
|
|
62
|
+
+{hidden.length}
|
|
63
|
+
</span>
|
|
64
|
+
)}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
});
|
|
@@ -5,6 +5,8 @@ import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
6
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
7
7
|
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
8
|
+
import { ClipMenuToolItems } from "./clipMenuToolItems";
|
|
9
|
+
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
8
10
|
|
|
9
11
|
interface ClipContextMenuProps {
|
|
10
12
|
x: number;
|
|
@@ -144,10 +146,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
144
146
|
<span>{splitLabel}</span>
|
|
145
147
|
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
|
146
148
|
</button>
|
|
149
|
+
<ClipMenuToolItems
|
|
150
|
+
group="time"
|
|
151
|
+
element={element}
|
|
152
|
+
currentTime={currentTime}
|
|
153
|
+
onClose={onClose}
|
|
154
|
+
/>
|
|
147
155
|
<div className="my-1 border-t border-neutral-700/60" />
|
|
148
156
|
</>
|
|
149
157
|
)}
|
|
150
158
|
|
|
159
|
+
<ClipMenuAudioItems element={element} onClose={onClose} />
|
|
160
|
+
|
|
151
161
|
{(onCopy || onPaste || onDuplicate) && (
|
|
152
162
|
<>
|
|
153
163
|
{onCopy && (
|
|
@@ -198,6 +208,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
198
208
|
</>
|
|
199
209
|
)}
|
|
200
210
|
|
|
211
|
+
<ClipMenuToolItems
|
|
212
|
+
group="sound"
|
|
213
|
+
element={element}
|
|
214
|
+
currentTime={currentTime}
|
|
215
|
+
onClose={onClose}
|
|
216
|
+
/>
|
|
217
|
+
<ClipMenuToolItems
|
|
218
|
+
group="picture"
|
|
219
|
+
element={element}
|
|
220
|
+
currentTime={currentTime}
|
|
221
|
+
onClose={onClose}
|
|
222
|
+
/>
|
|
201
223
|
<button
|
|
202
224
|
type="button"
|
|
203
225
|
role="menuitem"
|