|
| 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 | +} |
0 commit comments