Skip to content

Commit 720871d

Browse files
vanceingallsclaude
andcommitted
feat(studio): add freeze frame to the clip menu
Freeze frame on a video under the playhead posts the studio-server freeze-frame mutation and records its before/after as one undo step. onFreezeFrame is threaded through the timeline edit callbacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2681e27 commit 720871d

12 files changed

Lines changed: 279 additions & 12 deletions

‎packages/studio/src/App.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
548548
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
549549
handleRazorSplit={timelineEditing.handleRazorSplit}
550550
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
551+
handleFreezeFrame={timelineEditing.handleFreezeFrame}
551552
onCopyClip={handleCopy}
552553
onPasteClip={handlePaste}
553554
onDuplicateClip={handleDuplicate}

‎packages/studio/src/components/EditorShell.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export function EditorShell({
103103
handleTimelineElementSplit,
104104
handleRazorSplit,
105105
handleRazorSplitAll,
106+
handleFreezeFrame,
106107
onCopyClip,
107108
onPasteClip,
108109
onDuplicateClip,
@@ -163,6 +164,7 @@ export function EditorShell({
163164
handleTimelineElementSplit,
164165
handleRazorSplit,
165166
handleRazorSplitAll,
167+
handleFreezeFrame,
166168
});
167169

168170
return (

‎packages/studio/src/components/nle/useTimelineEditCallbacks.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface TimelineEditCallbackDeps {
5353
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
5454
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
5555
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
56+
handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
5657
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
5758
handleGroupClips?: (
5859
clipIds: readonly string[],
@@ -132,6 +133,7 @@ export function useTimelineEditCallbacks({
132133
handleTimelineElementSplit,
133134
handleRazorSplit,
134135
handleRazorSplitAll,
136+
handleFreezeFrame,
135137
handleGroupClips,
136138
setElementFxAttribute,
137139
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
@@ -227,6 +229,7 @@ export function useTimelineEditCallbacks({
227229
onSplitElement: handleTimelineElementSplit,
228230
onRazorSplit: handleRazorSplit,
229231
onRazorSplitAll: handleRazorSplitAll,
232+
onFreezeFrame: handleFreezeFrame,
230233
onDeleteAllKeyframes: (element, animationId) => {
231234
// Hold the element where it is (collapse keyframes to a static set) rather
232235
// than deleting the whole animation — deleting strands a stale GSAP base
@@ -423,6 +426,7 @@ export function useTimelineEditCallbacks({
423426
handleTimelineElementSplit,
424427
handleRazorSplit,
425428
handleRazorSplitAll,
429+
handleFreezeFrame,
426430
handleGsapRemoveAllKeyframes,
427431
resolveElementAnimations,
428432
resolveKeyframeTarget,

‎packages/studio/src/contexts/TimelineEditContext.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
4242
onSplitElement: true,
4343
onRazorSplit: true,
4444
onRazorSplitAll: true,
45+
onFreezeFrame: true,
4546
onDeleteKeyframe: true,
4647
onDeleteAllKeyframes: true,
4748
onMoveKeyframeToPlayhead: true,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

‎packages/studio/src/hooks/useRazorSplit.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { trackStudioRazorSplit } from "../telemetry/events";
66
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
77
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
88
import type { RecordEditInput } from "./timelineEditingHelpers";
9+
import { useFreezeFrame } from "./useFreezeFrame";
910

1011
interface UseRazorSplitOptions {
1112
projectId: string | null;
@@ -135,5 +136,17 @@ export function useRazorSplit({
135136
[isRecordingRef, runCut, showToast],
136137
);
137138

138-
return { handleRazorSplit, handleRazorSplitAll };
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 };
139152
}

‎packages/studio/src/hooks/useTimelineEditing.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,7 @@ export function useTimelineEditing({
492492
});
493493

494494
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
495-
496-
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
495+
const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
497496
projectId,
498497
activeCompPath,
499498
showToast,
@@ -575,6 +574,7 @@ export function useTimelineEditing({
575574
),
576575
handleTimelineElementSplit: trackedRazorSplit,
577576
handleRazorSplit: trackedRazorSplit,
577+
handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
578578
// Same selection the handler itself splits (useRazorSplit.ts).
579579
handleRazorSplitAll: track(
580580
guard(

‎packages/studio/src/player/components/ClipContextMenu.tsx‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ export const ClipContextMenu = memo(function ClipContextMenu({
145145
<span>{splitLabel}</span>
146146
<span className="text-neutral-500 text-[10px] ml-3">S</span>
147147
</button>
148+
<ClipMenuToolItems
149+
group="time"
150+
element={element}
151+
currentTime={currentTime}
152+
onClose={onClose}
153+
/>
148154
<div className="my-1 border-t border-neutral-700/60" />
149155
</>
150156
)}
@@ -199,8 +205,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
199205
</>
200206
)}
201207

202-
<ClipMenuToolItems group="sound" element={element} onClose={onClose} />
203-
<ClipMenuToolItems group="picture" element={element} onClose={onClose} />
208+
<ClipMenuToolItems
209+
group="sound"
210+
element={element}
211+
currentTime={currentTime}
212+
onClose={onClose}
213+
/>
214+
<ClipMenuToolItems
215+
group="picture"
216+
element={element}
217+
currentTime={currentTime}
218+
onClose={onClose}
219+
/>
204220
<button
205221
type="button"
206222
role="menuitem"

‎packages/studio/src/player/components/clipMenuToolItems.test.tsx‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,28 @@ const video: TimelineElement = {
3131
hasAudio: true,
3232
};
3333

34-
function renderItems(group: ClipMenuToolGroup, element: TimelineElement) {
34+
function renderItems(group: ClipMenuToolGroup, element: TimelineElement, currentTime = 2) {
3535
const setQuiet = vi.fn(
3636
async (_el: TimelineElement, _attr: string, _value: string | null, _label: string) => undefined,
3737
);
3838
const onClose = vi.fn();
39+
const freeze = vi.fn((_el: TimelineElement, _time: number) => undefined);
3940
const host = document.createElement("div");
4041
document.body.append(host);
4142
root = createRoot(host);
4243
act(() => {
4344
root?.render(
44-
<TimelineEditProvider value={{ onSetElementAttributeQuiet: setQuiet }}>
45-
<ClipMenuToolItems group={group} element={element} onClose={onClose} />
45+
<TimelineEditProvider value={{ onSetElementAttributeQuiet: setQuiet, onFreezeFrame: freeze }}>
46+
<ClipMenuToolItems
47+
group={group}
48+
element={element}
49+
currentTime={currentTime}
50+
onClose={onClose}
51+
/>
4652
</TimelineEditProvider>,
4753
);
4854
});
49-
return { setQuiet, onClose };
55+
return { setQuiet, onClose, freeze };
5056
}
5157

5258
function openSubmenu(label: string) {
@@ -112,4 +118,22 @@ describe("ClipMenuToolItems", () => {
112118
renderItems("picture", { ...video, tag: "audio" });
113119
expect(document.body.textContent).toBe("");
114120
});
121+
122+
it("Freeze frame calls the freeze mutation at the playhead on a video", () => {
123+
const { freeze } = renderItems("time", video, 3.2);
124+
const item = document.querySelector<HTMLButtonElement>('[role="menuitem"]');
125+
expect(item?.textContent).toBe("Freeze frame");
126+
act(() => item?.click());
127+
expect(freeze).toHaveBeenCalledWith(video, 3.2);
128+
});
129+
130+
it("Freeze frame is disabled outside the clip and absent on images", () => {
131+
renderItems("time", video, 9);
132+
expect(document.querySelector<HTMLButtonElement>('[role="menuitem"]')?.disabled).toBe(true);
133+
act(() => root?.unmount());
134+
root = null;
135+
document.body.innerHTML = "";
136+
renderItems("time", { ...video, tag: "img" });
137+
expect(document.body.textContent).toBe("");
138+
});
115139
});

0 commit comments

Comments
 (0)