diff --git a/packages/core/src/speedRamp.test.ts b/packages/core/src/speedRamp.test.ts index be3e89242b..61981bf13a 100644 --- a/packages/core/src/speedRamp.test.ts +++ b/packages/core/src/speedRamp.test.ts @@ -122,3 +122,37 @@ describe("presets", () => { } }); }); + +describe("gentle ramp presets", () => { + const speeds = (id: "ramp-in" | "ramp-out" | "slowmo-middle", duration: number) => + speedPresetLane(id, duration).points.map((p) => [p.t, p.v]); + + it("ramp-in eases from half speed up to normal", () => { + expect(speeds("ramp-in", 4)).toEqual([ + [0, 0.5], + [4, 1], + ]); + }); + + it("ramp-out eases from normal down to half speed", () => { + expect(speeds("ramp-out", 4)).toEqual([ + [0, 1], + [4, 0.5], + ]); + }); + + it("slowmo-middle holds 0.35x through the middle third and returns to normal", () => { + const lane = speedPresetLane("slowmo-middle", 6); + expect(rateAt(lane, 0)).toBe(1); + expect(rateAt(lane, 2)).toBeCloseTo(0.35, 5); + expect(rateAt(lane, 3)).toBeCloseTo(0.35, 5); + expect(rateAt(lane, 4)).toBeCloseTo(0.35, 5); + expect(rateAt(lane, 6)).toBe(1); + }); + + it("each consumes less source than the clip at normal speed", () => { + for (const id of ["ramp-in", "ramp-out", "slowmo-middle"] as const) { + expect(sourceTimeAt(speedPresetLane(id, 6), 6)).toBeLessThan(6); + } + }); +}); diff --git a/packages/core/src/speedRamp.ts b/packages/core/src/speedRamp.ts index 7d26df419c..c9c7e99afb 100644 --- a/packages/core/src/speedRamp.ts +++ b/packages/core/src/speedRamp.ts @@ -206,6 +206,32 @@ export const SPEED_PRESETS = [ [1, 0.3], ], }, + { + id: "ramp-in", + label: "Ramp in", + points: [ + [0, 0.5], + [1, 1], + ], + }, + { + id: "ramp-out", + label: "Ramp out", + points: [ + [0, 1], + [1, 0.5], + ], + }, + { + id: "slowmo-middle", + label: "Slow-mo middle", + points: [ + [0, 1], + [1 / 3, 0.35], + [2 / 3, 0.35], + [1, 1], + ], + }, ] as const satisfies readonly RatePreset[]; export type SpeedPresetId = (typeof SPEED_PRESETS)[number]["id"]; diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index 8ea882cf8f..36932d0480 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -8,6 +8,7 @@ import { registerRenderRoutes } from "./routes/render.js"; import { registerImageThumbnailRoutes } from "./routes/imageThumbnail.js"; import { registerThumbnailRoutes } from "./routes/thumbnail.js"; import { registerWaveformRoutes } from "./routes/waveform.js"; +import { registerFreezeFrameRoutes } from "./routes/freezeFrame.js"; import { registerFontRoutes } from "./routes/fonts.js"; import { registerRegistryRoutes } from "./routes/registry.js"; import { registerSelectionRoutes } from "./routes/selection.js"; @@ -61,6 +62,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { registerSelectionRoutes(api, adapter); registerMediaRoutes(api, adapter); registerWaveformRoutes(api, adapter); + registerFreezeFrameRoutes(api, adapter); registerFontRoutes(api); registerRegistryRoutes(api, adapter); registerGlobalAssetRoutes(api); diff --git a/packages/studio-server/src/helpers/freezeFrame.test.ts b/packages/studio-server/src/helpers/freezeFrame.test.ts new file mode 100644 index 0000000000..8767d1bc85 --- /dev/null +++ b/packages/studio-server/src/helpers/freezeFrame.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { parseHTML } from "linkedom"; +import { + applyFreezeFrameToHtml, + freezeExtractArgs, + freezeFrameMediaTime, + freezeStillFileName, + readFreezeSource, +} from "./freezeFrame.js"; + +const rampLane = JSON.stringify({ + version: 1, + lanes: [ + { + target: "rate", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 3 }, + ], + }, + ], +}); + +describe("freezeFrameMediaTime", () => { + it("is the in-point plus clip time at normal speed", () => { + expect( + freezeFrameMediaTime({ + clipStart: 1, + playhead: 3.2, + mediaStart: 5, + playbackRate: 1, + automation: null, + }), + ).toBeCloseTo(7.2, 6); + }); + + it("scales by a constant playback rate", () => { + expect( + freezeFrameMediaTime({ + clipStart: 0, + playhead: 2, + mediaStart: 1, + playbackRate: 2, + automation: null, + }), + ).toBeCloseTo(5, 6); + }); + + it("integrates a rate lane, which overrides the constant", () => { + // 1x to 3x over 2s consumes 2 * (3 - 1) / ln 3 source seconds. + expect( + freezeFrameMediaTime({ + clipStart: 0, + playhead: 2, + mediaStart: 0.5, + playbackRate: 4, + automation: rampLane, + }), + ).toBeCloseTo(0.5 + 3.641, 2); + }); +}); + +describe("freezeExtractArgs", () => { + it("seeks before the input and writes one frame, never over an existing file", () => { + expect(freezeExtractArgs("/p/a.mp4", 7.2004, "/p/assets/freeze/a-3200.png")).toEqual([ + "-n", + "-ss", + "7.2", + "-i", + "/p/a.mp4", + "-frames:v", + "1", + "/p/assets/freeze/a-3200.png", + ]); + }); +}); + +const project = `
+ + +
+ +
`; + +const at = (html: string, id: string) => + parseHTML(`${html}`).document.getElementById(id); +const timing = (html: string, id: string) => { + const el = at(html, id); + return [el?.getAttribute("data-start"), el?.getAttribute("data-duration")]; +}; + +describe("readFreezeSource", () => { + it("reads the frame under the playhead from the source attributes", () => { + expect(readFreezeSource(project, { id: "talk" }, 3.2)).toEqual({ + id: "talk", + src: "talk.mp4", + mediaTime: 4.2, + }); + }); + + it("reads the in-point as playback does: data-playback-start before data-media-start", () => { + const both = project.replace( + 'data-media-start="2"', + 'data-media-start="2" data-playback-start="5"', + ); + expect(readFreezeSource(both, { id: "talk" }, 3.2)?.mediaTime).toBe(7.2); + }); + + it("refuses a playhead outside the clip or a non-video", () => { + expect(readFreezeSource(project, { id: "talk" }, 0.5)).toBeNull(); + expect(readFreezeSource(project, { id: "later" }, 8.5)).toBeNull(); + }); +}); + +describe("applyFreezeFrameToHtml", () => { + const result = applyFreezeFrameToHtml(project, { + target: { id: "talk" }, + playhead: 3.2, + imageSrc: "assets/freeze/talk-3200.png", + }); + const html = result?.html ?? ""; + + it("splits the video and inserts a 2 s still with the video's box styles", () => { + expect(result?.freezeId).toBe("talk-freeze"); + expect(timing(html, "talk")).toEqual(["1", "2.2"]); + const still = at(html, "talk-freeze"); + expect(still?.tagName).toBe("IMG"); + expect(timing(html, "talk-freeze")).toEqual(["3.2", "2"]); + expect(still?.getAttribute("style")).toBe("left:10px;clip-path:inset(5px)"); + expect(still?.getAttribute("data-track-index")).toBe("0"); + expect(still?.getAttribute("data-timeline-label")).toBe("Freeze"); + expect(still?.hasAttribute("data-link")).toBe(false); + }); + + it("moves the right half and the rest of the track right by the hold", () => { + expect(timing(html, "talk-split")).toEqual(["5.2", "3.8"]); + expect(at(html, "talk-split")?.getAttribute("data-media-start")).toBe("4.2"); + expect(timing(html, "later")).toEqual(["10", "1"]); + expect(timing(html, "other")).toEqual(["5", "2"]); + }); + + it("splits the linked audio, leaving a silent gap, and links the right halves together", () => { + expect(timing(html, "talk-audio")).toEqual(["1", "2.2"]); + expect(timing(html, "talk-audio-split")).toEqual(["5.2", "3.8"]); + expect(at(html, "talk-split")?.getAttribute("data-link")).toBe("L-2"); + expect(at(html, "talk-audio-split")?.getAttribute("data-link")).toBe("L-2"); + expect(at(html, "talk-audio")?.getAttribute("data-link")).toBe("L"); + }); + + it("grows the root to fit the pushed clips", () => { + expect( + parseHTML(`${html}`) + .document.querySelector("[data-composition-id]") + ?.getAttribute("data-duration"), + ).toBe("11"); + }); + + it("returns null when the playhead is not inside the video", () => { + expect( + applyFreezeFrameToHtml(project, { target: { id: "talk" }, playhead: 9, imageSrc: "x.png" }), + ).toBeNull(); + }); +}); + +describe("freezeStillFileName", () => { + it("reduces a clip id to one safe filename component", () => { + expect(freezeStillFileName("talk", 2.5, "t0")).toMatch(/^talk-[0-9a-f]{10}-2500-t0\.png$/); + expect(freezeStillFileName("../../etc/x", 1, "t0")).toMatch( + /^______etc_x-[0-9a-f]{10}-1000-t0\.png$/, + ); + expect(freezeStillFileName("a\\b:c", 1, "t0")).toMatch(/^a_b_c-[0-9a-f]{10}-1000-t0\.png$/); + expect(freezeStillFileName("", 1, "t0")).toMatch(/^clip-[0-9a-f]{10}-1000-t0\.png$/); + }); + + it("keeps ids that sanitise or truncate alike apart", () => { + expect(freezeStillFileName("a.b", 2.5, "t0")).not.toBe(freezeStillFileName("a_b", 2.5, "t0")); + const prefix = "v".repeat(80); + expect(freezeStillFileName(`${prefix}1`, 2.5, "t0")).not.toBe( + freezeStillFileName(`${prefix}2`, 2.5, "t0"), + ); + }); + + it("names every extraction of one clip at one time differently", () => { + expect(freezeStillFileName("talk", 2.5)).not.toBe(freezeStillFileName("talk", 2.5)); + }); +}); diff --git a/packages/studio-server/src/helpers/freezeFrame.ts b/packages/studio-server/src/helpers/freezeFrame.ts new file mode 100644 index 0000000000..4630f5551e --- /dev/null +++ b/packages/studio-server/src/helpers/freezeFrame.ts @@ -0,0 +1,257 @@ +import { createHash, randomBytes } from "node:crypto"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; +import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration"; +import { resolveRateSpec, sourceTimeAt } from "@hyperframes/core/speed-ramp"; +import { + findTargetElement, + isHTMLElement, + parseSourceDocument, + splitElementInHtml, + type SourceMutationTarget, +} from "./sourceMutation.js"; + +const FREEZE_HOLD_SECONDS = 2; +const LINK_ATTR = "data-link"; +const EPSILON = 1e-3; + +const round3 = (value: number) => Math.round(value * 1000) / 1000; + +function numberAttr(el: Element, name: string): number | null { + const raw = el.getAttribute(name); + if (raw === null || raw.trim() === "") return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +function trackOf(el: Element): number { + return numberAttr(el, "data-track-index") ?? numberAttr(el, "data-layer") ?? 0; +} + +/** Media seconds shown at `playhead`: the clip's in-point plus the source its rate (or rate lane) has consumed. */ +export function freezeFrameMediaTime(input: { + clipStart: number; + playhead: number; + mediaStart: number; + playbackRate: number; + automation: string | null; +}): number { + const rate = resolveRateSpec(input.automation, input.playbackRate); + return input.mediaStart + sourceTimeAt(rate, Math.max(0, input.playhead - input.clipStart)); +} + +export function freezeExtractArgs(src: string, mediaTime: number, output: string): string[] { + return ["-n", "-ss", String(round3(mediaTime)), "-i", src, "-frames:v", "1", output]; +} + +export const randomStillToken = (): string => randomBytes(4).toString("hex"); + +export function freezeStillFileName( + clipId: string, + playhead: number, + token: string = randomStillToken(), +): string { + const stem = clipId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 48) || "clip"; + const idHash = createHash("sha256").update(clipId).digest("hex").slice(0, 10); + return `${stem}-${idHash}-${Math.round(playhead * 1000)}-${token}.png`; +} + +export interface FreezeSource { + id: string; + src: string; + mediaTime: number; +} + +/** The video to freeze and the media time under the playhead, read from the source file. */ +export function readFreezeSource( + html: string, + target: SourceMutationTarget, + playhead: number, +): FreezeSource | null { + const { document } = parseSourceDocument(html); + const el = findTargetElement(document, target); + if (!el || el.tagName.toLowerCase() !== "video") return null; + const src = el.getAttribute("src"); + const start = numberAttr(el, "data-start"); + const duration = numberAttr(el, "data-duration"); + if (!src || start === null || duration === null) return null; + if (playhead <= start + EPSILON || playhead >= start + duration - EPSILON) return null; + return { + id: el.getAttribute("id") || "clip", + src, + mediaTime: freezeFrameMediaTime({ + clipStart: start, + playhead, + mediaStart: readMediaOffsetSeconds((name) => el.getAttribute(name)), + playbackRate: numberAttr(el, "data-playback-rate") ?? 1, + automation: el.getAttribute("data-automation"), + }), + }; +} + +function targetOf(el: Element): SourceMutationTarget { + const hfId = el.getAttribute("data-hf-id"); + return { ...(hfId ? { hfId } : {}), id: el.getAttribute("id") }; +} + +function spans(el: Element, time: number): boolean { + const start = numberAttr(el, "data-start"); + const duration = numberAttr(el, "data-duration"); + if (start === null || duration === null) return false; + return time > start + EPSILON && time < start + duration - EPSILON; +} + +function uniqueId(document: Document, base: string): string { + let id = base; + for (let n = 2; document.getElementById(id); n++) id = `${base}-${n}`; + return id; +} + +function shiftTracks(document: Document, tracks: Set, from: number, by: number): void { + for (const el of document.querySelectorAll("[data-start]")) { + if (el.hasAttribute("data-composition-id") || !tracks.has(trackOf(el))) continue; + const start = numberAttr(el, "data-start"); + if (start === null || start < from - EPSILON) continue; + el.setAttribute("data-start", String(round3(start + by))); + } +} + +function growRootToFit(document: Document): void { + const root = document.querySelector("[data-composition-id]"); + const rootDuration = root ? numberAttr(root, "data-duration") : null; + if (!root || rootDuration === null) return; + let end = rootDuration; + for (const el of root.querySelectorAll("[data-start][data-duration]")) { + const start = numberAttr(el, "data-start"); + const duration = numberAttr(el, "data-duration"); + if (start !== null && duration !== null) end = Math.max(end, start + duration); + } + if (end > rootDuration + EPSILON) root.setAttribute("data-duration", String(round3(end))); +} + +const COPIED_PICTURE_ATTRS = ["class", "style", "data-color-grading"]; + +function buildStill( + document: Document, + video: Element, + input: { id: string; imageSrc: string; playhead: number; holdSeconds: number }, +): Element { + const img = document.createElement("img"); + img.setAttribute("id", input.id); + for (const name of COPIED_PICTURE_ATTRS) { + const value = video.getAttribute(name); + if (value !== null) img.setAttribute(name, value); + } + if (!img.classList.contains("clip")) img.classList.add("clip"); + img.setAttribute("src", input.imageSrc); + img.setAttribute("alt", ""); + img.setAttribute("data-start", String(round3(input.playhead))); + img.setAttribute("data-duration", String(input.holdSeconds)); + img.setAttribute("data-track-index", String(trackOf(video))); + img.setAttribute("data-timeline-label", "Freeze"); + return img; +} + +export interface FreezeFrameResult { + html: string; + freezeId: string; +} + +/** + * Split the video at the playhead, put a still of that frame in the gap and push the rest of its + * track (and a linked partner's, which is split too and left silent for the hold) right by the hold. + */ +function linkedPartners(document: Document, video: Element): Element[] { + const link = video.getAttribute(LINK_ATTR); + if (!link) return []; + return Array.from(document.querySelectorAll(`[${LINK_ATTR}]`)).filter( + (el) => el !== video && el.getAttribute(LINK_ATTR) === link, + ); +} + +/** Split every element at `time`, in order; the new right halves' ids, or null if one missed. */ +function splitAll( + html: string, + elements: readonly Element[], + time: number, +): { html: string; rightHalfIds: string[] } | null { + const rightHalfIds: string[] = []; + let next = html; + for (const el of elements) { + const split = splitElementInHtml( + next, + targetOf(el), + time, + `${el.getAttribute("id") || "clip"}-split`, + { + start: numberAttr(el, "data-start") ?? 0, + duration: numberAttr(el, "data-duration") ?? 0, + track: trackOf(el), + }, + ); + if (!split.matched || !split.newId) return null; + next = split.html; + rightHalfIds.push(split.newId); + } + return { html: next, rightHalfIds }; +} + +function relinkRightHalves(document: Document, link: string | null, ids: readonly string[]): void { + if (!link) return; + const rightLink = uniqueLink(document, link); + for (const id of ids) document.getElementById(id)?.setAttribute(LINK_ATTR, rightLink); +} + +/** + * Split the video at the playhead, put a still of that frame in the gap and push the rest of its + * track (and a linked partner's, which is split too and left silent for the hold) right by the hold. + */ +export function applyFreezeFrameToHtml( + html: string, + input: { + target: SourceMutationTarget; + playhead: number; + imageSrc: string; + holdSeconds?: number; + }, +): FreezeFrameResult | null { + const hold = input.holdSeconds ?? FREEZE_HOLD_SECONDS; + const { document: before } = parseSourceDocument(html); + const video = findTargetElement(before, input.target); + if (!video || !isHTMLElement(video) || !spans(video, input.playhead)) return null; + const partners = linkedPartners(before, video); + const tracks = new Set([trackOf(video), ...partners.map(trackOf)]); + const cut = splitAll( + html, + [video, ...partners.filter((el) => spans(el, input.playhead))], + input.playhead, + ); + if (!cut) return null; + + const { document, wrappedFragment } = parseSourceDocument(cut.html); + relinkRightHalves(document, video.getAttribute(LINK_ATTR), cut.rightHalfIds); + shiftTracks(document, tracks, input.playhead, hold); + const leftHalf = findTargetElement(document, input.target); + if (!leftHalf?.parentElement) return null; + const freezeId = uniqueId(document, `${video.getAttribute("id") || "clip"}-freeze`); + const still = buildStill(document, leftHalf, { + id: freezeId, + imageSrc: input.imageSrc, + playhead: input.playhead, + holdSeconds: hold, + }); + leftHalf.parentElement.insertBefore(still, leftHalf.nextSibling); + growRootToFit(document); + return { + html: ensureHfIds(wrappedFragment ? document.body.innerHTML || "" : document.toString()), + freezeId, + }; +} + +function uniqueLink(document: Document, base: string): string { + const taken = new Set( + Array.from(document.querySelectorAll(`[${LINK_ATTR}]`)).map((el) => el.getAttribute(LINK_ATTR)), + ); + let candidate = `${base}-2`; + for (let n = 3; taken.has(candidate); n++) candidate = `${base}-${n}`; + return candidate; +} diff --git a/packages/studio-server/src/routes/freezeFrame.test.ts b/packages/studio-server/src/routes/freezeFrame.test.ts new file mode 100644 index 0000000000..a123ebbb54 --- /dev/null +++ b/packages/studio-server/src/routes/freezeFrame.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { registerFreezeFrameRoutes, type FrameExtractor } from "./freezeFrame"; +import { fileContentVersion } from "../helpers/fileVersion"; +import type { StudioApiAdapter } from "../types"; + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const html = `
+ +
`; + +function setup( + extract: FrameExtractor, + file = { path: "index.html", html }, + stillToken?: () => string, +) { + const dir = mkdtempSync(join(tmpdir(), "hf-freeze-")); + tempDirs.push(dir); + mkdirSync(dirname(join(dir, file.path)), { recursive: true }); + writeFileSync(join(dir, file.path), file.html); + const adapter: StudioApiAdapter = { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => "/tmp/renders", + startRender: () => ({ id: "j", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }), + }; + const app = new Hono(); + registerFreezeFrameRoutes(app, adapter, extract, stillToken); + const post = (body: unknown) => + app.request("http://localhost/projects/demo/file-mutations/freeze-frame", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return { dir, post }; +} + +describe("freeze-frame route", () => { + it("extracts the frame under the playhead and writes split + still in one write", async () => { + const calls: string[][] = []; + const { dir, post } = setup(async (args) => { + calls.push(args); + return { ok: true }; + }); + const res = await post({ + path: "index.html", + expectedVersion: fileContentVersion(html), + target: { id: "talk" }, + playhead: 2.5, + }); + const body: { before?: string; after?: string; imageSrc?: string } = await res.json(); + expect(res.status).toBe(200); + const output = calls[0]?.at(-1) ?? ""; + expect(calls[0]).toEqual([ + "-n", + "-ss", + "2.5", + "-i", + join(dir, "media/talk.mp4"), + "-frames:v", + "1", + output, + ]); + expect(dirname(output)).toBe(join(dir, "assets/freeze")); + expect(basename(output)).toMatch(/^talk-[0-9a-f]{10}-2500-[0-9a-f]{8}\.png$/); + expect(body.imageSrc).toBe(`assets/freeze/${basename(output)}`); + expect(body.before).toBe(html); + expect(readFileSync(join(dir, "index.html"), "utf-8")).toBe(body.after); + expect(body.after).toContain('id="talk-freeze"'); + }); + + it("refuses a stale version without extracting", async () => { + const calls: string[][] = []; + const { post } = setup(async (args) => { + calls.push(args); + return { ok: true }; + }); + const res = await post({ + path: "index.html", + expectedVersion: "stale", + target: { id: "talk" }, + playhead: 1, + }); + expect(res.status).toBe(409); + expect(calls).toEqual([]); + }); + + it("leaves the file untouched when extraction fails", async () => { + const { dir, post } = setup(async () => ({ ok: false, error: "boom" })); + const res = await post({ + path: "index.html", + expectedVersion: fileContentVersion(html), + target: { id: "talk" }, + playhead: 1, + }); + expect(res.status).toBe(500); + expect(readFileSync(join(dir, "index.html"), "utf-8")).toBe(html); + }); + + it("keeps a traversal clip id inside assets/freeze, for the ffmpeg output and the still's src", async () => { + const evil = html.replace('id="talk"', 'id="../../../../outside/frame"'); + const calls: string[][] = []; + const { dir, post } = setup( + async (args) => { + calls.push(args); + return { ok: true }; + }, + { path: "scenes/a.html", html: evil.replace('src="media/', 'src="../media/') }, + ); + const res = await post({ + path: "scenes/a.html", + expectedVersion: fileContentVersion(evil.replace('src="media/', 'src="../media/')), + target: { id: "../../../../outside/frame" }, + playhead: 2.5, + }); + const body: { imageSrc?: string; after?: string } = await res.json(); + expect(res.status).toBe(200); + const output = calls[0]?.at(-1) ?? ""; + expect(basename(output)).toMatch(/^____________outside_frame-[0-9a-f]{10}-2500-/); + expect(dirname(output)).toBe(join(dir, "assets/freeze")); + expect(body.imageSrc).toBe(`../assets/freeze/${basename(output)}`); + expect(body.after).toContain(`src="../assets/freeze/${basename(output)}"`); + expect(existsSync(join(dir, "..", "outside"))).toBe(false); + }); + + describe("still identity", () => { + const twoVideos = ( + a: string, + b: string, + ) => `
+ + +
`; + + function writingExtractor(outputs: string[]): FrameExtractor { + return async (args) => { + const output = args.at(-1) ?? ""; + outputs.push(output); + await new Promise((resolve) => setTimeout(resolve, 5)); + try { + writeFileSync(output, `frame of ${args[4]} #${outputs.length}`, { + flag: args.includes("-y") ? "w" : "wx", + }); + return { ok: true }; + } catch (error) { + return { ok: false, error: String(error) }; + } + }; + } + + function freeze(post: (body: unknown) => Promise, dir: string, id: string) { + return post({ + path: "index.html", + expectedVersion: fileContentVersion(readFileSync(join(dir, "index.html"), "utf-8")), + target: { id }, + playhead: 2.5, + }); + } + + it("gives two ids that sanitise alike distinct stills and keeps the first one's pixels", async () => { + const outputs: string[] = []; + const source = twoVideos("a.b", "a_b"); + const { dir, post } = setup(writingExtractor(outputs), { path: "index.html", html: source }); + expect((await freeze(post, dir, "a.b")).status).toBe(200); + const first = readFileSync(outputs[0] ?? "", "utf-8"); + expect((await freeze(post, dir, "a_b")).status).toBe(200); + expect(outputs[1]).not.toBe(outputs[0]); + expect(readFileSync(outputs[0] ?? "", "utf-8")).toBe(first); + }); + + it("keeps ids sharing an 80-character prefix apart", async () => { + const outputs: string[] = []; + const prefix = "v".repeat(80); + const source = twoVideos(`${prefix}1`, `${prefix}2`); + const { dir, post } = setup(writingExtractor(outputs), { path: "index.html", html: source }); + expect((await freeze(post, dir, `${prefix}1`)).status).toBe(200); + expect((await freeze(post, dir, `${prefix}2`)).status).toBe(200); + expect(new Set(outputs).size).toBe(2); + }); + + it("writes a new still when the same clip is frozen again at the same time", async () => { + const outputs: string[] = []; + const { dir, post } = setup(writingExtractor(outputs)); + expect((await freeze(post, dir, "talk")).status).toBe(200); + const first = readFileSync(outputs[0] ?? "", "utf-8"); + writeFileSync(join(dir, "index.html"), html); + expect((await freeze(post, dir, "talk")).status).toBe(200); + expect(outputs[1]).not.toBe(outputs[0]); + expect(readFileSync(outputs[0] ?? "", "utf-8")).toBe(first); + }); + + it("gives concurrent requests distinct stills", async () => { + const outputs: string[] = []; + const { dir, post } = setup(writingExtractor(outputs)); + const results = await Promise.all([freeze(post, dir, "talk"), freeze(post, dir, "talk")]); + expect(results.map((res) => res.status).sort()).toEqual([200, 409]); + expect(new Set(outputs).size).toBe(2); + expect(outputs.map((output) => readFileSync(output, "utf-8"))).toEqual([ + expect.stringContaining("#"), + expect.stringContaining("#"), + ]); + }); + + it("refuses, without extracting, when the still's name is already taken", async () => { + const outputs: string[] = []; + const { dir, post } = setup(writingExtractor(outputs), undefined, () => "fixed"); + expect((await freeze(post, dir, "talk")).status).toBe(200); + const first = readFileSync(outputs[0] ?? "", "utf-8"); + writeFileSync(join(dir, "index.html"), html); + expect((await freeze(post, dir, "talk")).status).toBe(409); + expect(outputs).toHaveLength(1); + expect(readFileSync(outputs[0] ?? "", "utf-8")).toBe(first); + expect(readFileSync(join(dir, "index.html"), "utf-8")).toBe(html); + }); + }); +}); diff --git a/packages/studio-server/src/routes/freezeFrame.ts b/packages/studio-server/src/routes/freezeFrame.ts new file mode 100644 index 0000000000..8e479c7acd --- /dev/null +++ b/packages/studio-server/src/routes/freezeFrame.ts @@ -0,0 +1,187 @@ +import { execFile } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; +import type { Hono } from "hono"; +import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; +import type { StudioApiAdapter } from "../types.js"; +import { mkdirWithinProject, pinWithinProject } from "../helpers/safePath.js"; +import { replaceFileAtomically } from "@hyperframes/core/atomic-file"; +import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js"; +import { + createWriteToken, + fileContentVersion, + recordFileWriteReceipt, +} from "../helpers/fileVersion.js"; +import { + applyFreezeFrameToHtml, + freezeExtractArgs, + freezeStillFileName, + randomStillToken, + readFreezeSource, + type FreezeSource, +} from "../helpers/freezeFrame.js"; +import type { SourceMutationTarget } from "../helpers/sourceMutation.js"; + +export type FrameExtractor = (args: string[]) => Promise<{ ok: boolean; error?: string }>; + +const EXTRACT_TIMEOUT_MS = 30_000; + +const ffmpegExtractor: FrameExtractor = (args) => { + const ffmpeg = findFfBinary("ffmpeg", { configuredMustExist: true }); + if (!ffmpeg) return Promise.resolve({ ok: false, error: "ffmpeg not found" }); + return new Promise((resolvePromise) => { + execFile( + ffmpeg, + args, + { timeout: EXTRACT_TIMEOUT_MS, windowsHide: true }, + (error, _o, stderr) => + resolvePromise( + error ? { ok: false, error: String(stderr || error.message) } : { ok: true }, + ), + ); + }); +}; + +interface FreezeFrameRequest { + path: string; + expectedVersion: string; + target: SourceMutationTarget; + playhead: number; + transactionToken?: string; +} + +function isFreezeFrameRequest(value: unknown): value is FreezeFrameRequest { + if (typeof value !== "object" || value === null) return false; + const body: Partial> = value; + return ( + typeof body.path === "string" && + body.path.length > 0 && + typeof body.expectedVersion === "string" && + typeof body.target === "object" && + body.target !== null && + typeof body.playhead === "number" && + Number.isFinite(body.playhead) + ); +} + +const FREEZE_DIR = ["assets", "freeze"]; + +type Failure = { error: string; status: 403 | 404 | 409 | 500 }; + +function readExpected(absPath: string, expectedVersion: string): { content: string } | Failure { + let content: string; + try { + content = readFileSync(absPath, "utf-8"); + } catch { + return { error: "not found", status: 404 }; + } + return fileContentVersion(content) === expectedVersion + ? { content } + : { error: "file conflict", status: 409 }; +} + +async function extractStill( + projectDir: string, + absPath: string, + source: FreezeSource, + playhead: number, + tools: { extract: FrameExtractor; stillToken: () => string }, +): Promise<{ imageSrc: string } | Failure> { + const fileDir = dirname(absPath); + const mediaPath = pinWithinProject(projectDir, relative(projectDir, join(fileDir, source.src))); + if (!mediaPath) return { error: `forbidden media path: ${source.src}`, status: 403 }; + const freezeDir = join(projectDir, ...FREEZE_DIR); + const fileName = freezeStillFileName(source.id, playhead, tools.stillToken()); + mkdirWithinProject(projectDir, freezeDir); + const imagePath = pinWithinProject(projectDir, join(...FREEZE_DIR, fileName)); + if (!imagePath || dirname(imagePath) !== freezeDir) { + return { error: `forbidden freeze path: ${fileName}`, status: 403 }; + } + if (existsSync(imagePath)) + return { error: `freeze still already exists: ${fileName}`, status: 409 }; + const extracted = await tools.extract(freezeExtractArgs(mediaPath, source.mediaTime, imagePath)); + if (!extracted.ok) { + return { + error: `Could not extract the frame: ${extracted.error ?? "ffmpeg failed"}`, + status: 500, + }; + } + const depth = relative(projectDir, fileDir).split(sep).filter(Boolean).length; + return { imageSrc: `${"../".repeat(depth)}${FREEZE_DIR.join("/")}/${fileName}` }; +} + +function writeFolded( + projectDir: string, + absPath: string, + path: string, + before: string, + after: string, + options: { token?: string }, +): { version: string; writeToken: string; backupPath: string | null } | Failure { + if (readFileSync(absPath, "utf-8") !== before) { + return { error: `file conflict: ${path}`, status: 409 }; + } + const backup = snapshotBeforeWrite(projectDir, absPath); + if (backup.error) return { error: `Failed to create backup: ${backup.error}`, status: 500 }; + const writeToken = createWriteToken(options.token); + replaceFileAtomically(absPath, after, statSync(absPath).mode); + const version = fileContentVersion(after); + recordFileWriteReceipt(absPath, { path, version, writeToken, overwrote: before }); + return { version, writeToken, backupPath: backupPathForResponse(projectDir, backup.backupPath) }; +} + +/** POST: extract the frame under the playhead and fold split + still + shift into one file write. */ +export function registerFreezeFrameRoutes( + api: Hono, + adapter: StudioApiAdapter, + extract: FrameExtractor = ffmpegExtractor, + stillToken: () => string = randomStillToken, +): void { + // A straight line of request guards, each its own early return. + // fallow-ignore-next-line complexity + api.post("/projects/:id/file-mutations/freeze-frame", async (c) => { + const body: unknown = await c.req.json().catch(() => null); + if (!isFreezeFrameRequest(body)) { + return c.json({ error: "path, expectedVersion, target and playhead required" }, 400); + } + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + const absPath = pinWithinProject(project.dir, body.path); + if (!absPath) return c.json({ error: `forbidden path: ${body.path}` }, 403); + + const read = readExpected(absPath, body.expectedVersion); + if ("error" in read) return c.json({ error: `${read.error}: ${body.path}` }, read.status); + const before = read.content; + + const source = readFreezeSource(before, body.target, body.playhead); + if (!source) return c.json({ error: "Move the playhead inside a video clip to freeze" }, 400); + const still = await extractStill(project.dir, absPath, source, body.playhead, { + extract, + stillToken, + }); + if ("error" in still) return c.json({ error: still.error }, still.status); + const { imageSrc } = still; + const folded = applyFreezeFrameToHtml(before, { + target: body.target, + playhead: body.playhead, + imageSrc, + }); + if (!folded) return c.json({ error: "Freeze target was not found in the file" }, 400); + const written = writeFolded(project.dir, absPath, body.path, before, folded.html, { + token: body.transactionToken ?? c.req.header("X-Hyperframes-Write-Token"), + }); + if ("error" in written) return c.json({ error: written.error }, written.status); + const { version, writeToken, backupPath } = written; + return c.json({ + ok: true, + path: body.path, + before, + after: folded.html, + version, + writeToken, + backupPath, + freezeId: folded.freezeId, + imageSrc, + }); + }); +} diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 46d5c04b0b..2aeb005dbd 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -552,6 +552,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} handleRazorSplit={timelineEditing.handleRazorSplit} handleRazorSplitAll={timelineEditing.handleRazorSplitAll} + handleFreezeFrame={timelineEditing.handleFreezeFrame} onCopyClip={handleCopy} onPasteClip={handlePaste} onDuplicateClip={handleDuplicate} diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index f168d6a404..1bd2e60d38 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -103,6 +103,7 @@ export function EditorShell({ handleTimelineElementSplit, handleRazorSplit, handleRazorSplitAll, + handleFreezeFrame, onCopyClip, onPasteClip, onDuplicateClip, @@ -163,6 +164,7 @@ export function EditorShell({ handleTimelineElementSplit, handleRazorSplit, handleRazorSplitAll, + handleFreezeFrame, }); return ( diff --git a/packages/studio/src/components/editor/CropPresetBar.tsx b/packages/studio/src/components/editor/CropPresetBar.tsx new file mode 100644 index 0000000000..e830d40ab2 --- /dev/null +++ b/packages/studio/src/components/editor/CropPresetBar.tsx @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import type { ClipPathInsetSides } from "./clipPathHelpers"; +import { CROP_ASPECT_PRESETS, centredCropInsets } from "./cropPresets"; + +interface CropPresetBarProps { + left: number; + top: number; + elementWidth: number; + elementHeight: number; + /** Centred insets for a ratio, or `null` to remove the crop. */ + onApply: (insets: ClipPathInsetSides | null) => void; + onDone: () => void; +} + +const CHIP = + "rounded-sm border border-white/20 px-1.5 py-0.5 text-[10px] text-white/85 hover:bg-white/10"; + +/** Free · aspect presets · Reset · Done, floating above the element being cropped. */ +export function CropPresetBar({ + left, + top, + elementWidth, + elementHeight, + onApply, + onDone, +}: CropPresetBarProps) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onDone(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onDone]); + + return ( +
event.stopPropagation()} + > + + {CROP_ASPECT_PRESETS.map((preset) => ( + + ))} + + +
+ ); +} diff --git a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx index e4c191f742..ce934ebd96 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx @@ -6,6 +6,7 @@ import type { DomEditSelection } from "./domEditing"; import type { OverlayRect } from "./domEditOverlayGeometry"; import { DomEditCropHandles } from "./DomEditCropHandles"; import { isElementCropLifted } from "./domEditOverlayCrop"; +import { useCropPresetBarStore } from "./cropPresetStore"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -272,3 +273,46 @@ describe("DomEditCropHandles leaves the corner resize dots free", () => { expect(handles().map((h) => h.label)).toEqual(["Crop top", "Crop bottom"]); }); }); + +describe("DomEditCropHandles preset bar", () => { + const click = (label: string) => { + const button = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === label, + ); + act(() => button?.click()); + }; + + afterEach(() => useCropPresetBarStore.getState().close()); + + it("shows only for the clip the menu opened it for", () => { + const a = makeEl("a", ""); + useCropPresetBarStore.getState().open({ id: "other" }); + render(a); + expect(document.querySelector("[data-dom-edit-crop-bar]")).toBeNull(); + }); + + it("commits a centred 1:1 crop, then Reset removes the clip-path", async () => { + const a = makeEl("a", ""); + const commits: string[] = []; + useCropPresetBarStore.getState().open({ id: "a" }); + const { root } = render(a, (_property, value) => { + commits.push(value); + }); + click("1:1"); + await act(async () => {}); + expect(commits[0]).toBe("inset(0px 50px 0px 50px)"); + click("Reset"); + await act(async () => {}); + expect(commits[1]).toBe(""); + act(() => root.unmount()); + expect(a.style.getPropertyValue("clip-path")).toBe(""); + }); + + it("Done closes the bar", () => { + const a = makeEl("a", ""); + useCropPresetBarStore.getState().open({ id: "a" }); + render(a); + click("Done"); + expect(useCropPresetBarStore.getState().openFor).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx index 67172d83e6..d1ac3cbfbf 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -15,6 +15,8 @@ import { } from "./domEditOverlayCrop"; import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers"; import { readCropFollowingResize } from "./cropResize"; +import { isCropBarTarget, useCropPresetBarStore } from "./cropPresetStore"; +import { CropPresetBar } from "./CropPresetBar"; interface CropGestureState { edge: CropEdge | "move"; @@ -119,6 +121,9 @@ export function DomEditCropHandles({ return { element, croppable: parsed !== null, insets: { top, right, bottom, left } }; }; const [state, setState] = useState(() => cropStateFor(selection.element)); + const presetBarTarget = useCropPresetBarStore((s) => s.openFor); + const closePresetBar = useCropPresetBarStore((s) => s.close); + const showPresetBar = isCropBarTarget(selection.element, presetBarTarget); // Re-sync when the selection targets a different element (reselect, or an // undo/redo that re-keys the node). @@ -223,10 +228,21 @@ export function DomEditCropHandles({ // the lift keeps it hidden while selected. A drag that ends where it started saves nothing. const value = buildInsetClipPathSides(gesture.insets, gesture.radius); if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return; + commitClipPath(value); + }; + + const commitClipPath = (value: string) => { const commit = onStyleCommit?.("clip-path", value); void Promise.resolve(commit).catch(() => undefined); }; + const applyPresetInsets = (insets: ClipPathInsetSides | null) => { + const next = insets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + setState((prev) => ({ ...prev, insets: next })); + const radius = readCropFollowingResize(selection.element)?.radius ?? 0; + commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius)); + }; + const cancelCropGesture = (event: ReactPointerEvent) => { const gesture = endCropGesture(event); if (!gesture) return; @@ -239,119 +255,131 @@ export function DomEditCropHandles({ if (!state.croppable) return null; return ( -
- {/* Dim the cropped-away area whenever the element is cropped and selected, - so the hidden content is visible (ghosted) without dragging. Clipped to - the element's own (rotated) box. */} - {hasCrop && ( -
-
-
+ <> + {showPresetBar && onStyleCommit && ( + )} - {/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid - shown while dragging. */}