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()}
+ >
+
+ Free
+
+ {CROP_ASPECT_PRESETS.map((preset) => (
+ onApply(centredCropInsets(elementWidth, elementHeight, preset.ratio))}
+ >
+ {preset.label}
+
+ ))}
+ onApply(null)}>
+ Reset
+
+
+ Done
+
+
+ );
+}
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. */}
- {dragging && (
- <>
-
-
-
-
- >
+ {/* 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 && (
+
)}
-
- {/* Reposition handle — a center circle shown only once cropped. Drag it to
- pan the crop window (which part of the element shows) without resizing
- the crop. It's a small, discrete target, so a body drag still MOVES. */}
- {hasCrop && repositionSize > 0 && (
-
startCropGesture("move", event)}
- onPointerMove={updateCropGesture}
- onPointerUp={finishCropGesture}
- onPointerCancel={cancelCropGesture}
- />
- )}
- {/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
- edge (via edgeHandleLayout) so they never overlap the element body:
- dragging the body always MOVES, only a handle crops. The pill is
- hover-revealed (or shown while dragging / once a crop exists) so the
- resting selection chrome stays uncluttered; the hit strip is always
- live, and the title names the affordance. */}
- {EDGES.map((edge) => {
- const layout = edgeHandleLayout(edge, cropRect);
- if (!layout) return null;
- const revealed = dragging || hasCrop || hotEdge === edge;
- return (
+ >
+ {dragging && (
+ <>
+
+
+
+
+ >
+ )}
+
+ {/* Reposition handle — a center circle shown only once cropped. Drag it to
+ pan the crop window (which part of the element shows) without resizing
+ the crop. It's a small, discrete target, so a body drag still MOVES. */}
+ {hasCrop && repositionSize > 0 && (
setHotEdge(edge)}
- onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
- onPointerDown={(event) => startCropGesture(edge, event)}
+ onPointerDown={(event) => startCropGesture("move", event)}
onPointerMove={updateCropGesture}
onPointerUp={finishCropGesture}
onPointerCancel={cancelCropGesture}
- >
-
+ )}
+ {/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
+ edge (via edgeHandleLayout) so they never overlap the element body:
+ dragging the body always MOVES, only a handle crops. The pill is
+ hover-revealed (or shown while dragging / once a crop exists) so the
+ resting selection chrome stays uncluttered; the hit strip is always
+ live, and the title names the affordance. */}
+ {EDGES.map((edge) => {
+ const layout = edgeHandleLayout(edge, cropRect);
+ if (!layout) return null;
+ const revealed = dragging || hasCrop || hotEdge === edge;
+ return (
+
-
- );
- })}
-
+ onPointerEnter={() => setHotEdge(edge)}
+ onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
+ onPointerDown={(event) => startCropGesture(edge, event)}
+ onPointerMove={updateCropGesture}
+ onPointerUp={finishCropGesture}
+ onPointerCancel={cancelCropGesture}
+ >
+
+
+ );
+ })}
+
+ >
);
}
diff --git a/packages/studio/src/components/editor/cropPresetStore.ts b/packages/studio/src/components/editor/cropPresetStore.ts
new file mode 100644
index 0000000000..44afd49515
--- /dev/null
+++ b/packages/studio/src/components/editor/cropPresetStore.ts
@@ -0,0 +1,33 @@
+import { create } from "zustand";
+
+export interface CropPresetBarTarget {
+ hfId?: string;
+ id?: string;
+}
+
+interface CropPresetBarState {
+ openFor: CropPresetBarTarget | null;
+ open: (target: CropPresetBarTarget) => void;
+ close: () => void;
+}
+
+/** Which clip the crop preset bar is open for; the canvas shows it while that element is selected. */
+export const useCropPresetBarStore = create((set) => ({
+ openFor: null,
+ open: (target) => set({ openFor: target }),
+ close: () => set({ openFor: null }),
+}));
+
+export function isCropBarTarget(element: Element, target: CropPresetBarTarget | null): boolean {
+ if (!target) return false;
+ const hfId = element.getAttribute("data-hf-id");
+ if (target.hfId && hfId) return target.hfId === hfId;
+ return Boolean(target.id) && element.id === target.id;
+}
+
+/** The crop lift is a stylesheet rule, so the inline clip-path is still the committed one. */
+export function committedClipPath(element: Element): string | null {
+ const style = element.getAttribute("style") ?? "";
+ const match = /(?:^|;)\s*clip-path\s*:\s*([^;]+)/i.exec(style);
+ return match?.[1]?.trim() ?? null;
+}
diff --git a/packages/studio/src/components/editor/cropPresets.test.ts b/packages/studio/src/components/editor/cropPresets.test.ts
new file mode 100644
index 0000000000..b190a36162
--- /dev/null
+++ b/packages/studio/src/components/editor/cropPresets.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { CROP_ASPECT_PRESETS, centredCropInsets } from "./cropPresets";
+
+describe("centredCropInsets", () => {
+ it("trims the sides of a wide frame to 9:16, centred", () => {
+ const insets = centredCropInsets(1920, 1080, 9 / 16);
+ expect(insets.top).toBe(0);
+ expect(insets.bottom).toBe(0);
+ expect(insets.left).toBeCloseTo((1920 - 607.5) / 2, 6);
+ expect(insets.right).toBeCloseTo(insets.left, 6);
+ });
+
+ it("trims top and bottom of a tall frame to 16:9", () => {
+ const insets = centredCropInsets(1080, 1920, 16 / 9);
+ expect(insets.left).toBe(0);
+ expect(insets.right).toBe(0);
+ expect(insets.top).toBeCloseTo((1920 - 607.5) / 2, 6);
+ expect(insets.bottom).toBeCloseTo(insets.top, 6);
+ });
+
+ it("leaves the visible box in the requested ratio", () => {
+ for (const [w, h] of [
+ [1920, 1080],
+ [1080, 1920],
+ [800, 800],
+ ]) {
+ for (const { ratio } of CROP_ASPECT_PRESETS) {
+ const i = centredCropInsets(w, h, ratio);
+ expect((w - i.left - i.right) / (h - i.top - i.bottom)).toBeCloseTo(ratio, 6);
+ }
+ }
+ });
+
+ it("is no crop when the frame already has the ratio", () => {
+ expect(centredCropInsets(1600, 900, 16 / 9)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
+ });
+
+ it("offers the four spec ratios", () => {
+ expect(CROP_ASPECT_PRESETS.map((p) => p.label)).toEqual(["16:9", "9:16", "1:1", "4:5"]);
+ });
+});
diff --git a/packages/studio/src/components/editor/cropPresets.ts b/packages/studio/src/components/editor/cropPresets.ts
new file mode 100644
index 0000000000..6efc36ab67
--- /dev/null
+++ b/packages/studio/src/components/editor/cropPresets.ts
@@ -0,0 +1,24 @@
+import type { ClipPathInsetSides } from "./clipPathHelpers";
+
+export const CROP_ASPECT_PRESETS = [
+ { label: "16:9", ratio: 16 / 9 },
+ { label: "9:16", ratio: 9 / 16 },
+ { label: "1:1", ratio: 1 },
+ { label: "4:5", ratio: 4 / 5 },
+] as const;
+
+/** Insets (element CSS px) that leave the largest centred box of `ratio` (width / height). */
+export function centredCropInsets(
+ width: number,
+ height: number,
+ ratio: number,
+): ClipPathInsetSides {
+ const none = { top: 0, right: 0, bottom: 0, left: 0 };
+ if (width <= 0 || height <= 0 || ratio <= 0) return none;
+ if (width / height > ratio) {
+ const side = (width - height * ratio) / 2;
+ return side > 1e-9 ? { ...none, left: side, right: side } : none;
+ }
+ const band = (height - width / ratio) / 2;
+ return band > 1e-9 ? { ...none, top: band, bottom: band } : none;
+}
diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts
index b965082061..8dae98bc5b 100644
--- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts
+++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts
@@ -53,6 +53,7 @@ export interface TimelineEditCallbackDeps {
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void;
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise | void;
handleRazorSplitAll: (splitTime: number) => Promise | void;
+ handleFreezeFrame?: (element: TimelineElement, time: number) => Promise | void;
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
handleGroupClips?: (
clipIds: readonly string[],
@@ -132,6 +133,7 @@ export function useTimelineEditCallbacks({
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
+ handleFreezeFrame,
handleGroupClips,
setElementFxAttribute,
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
@@ -227,6 +229,7 @@ export function useTimelineEditCallbacks({
onSplitElement: handleTimelineElementSplit,
onRazorSplit: handleRazorSplit,
onRazorSplitAll: handleRazorSplitAll,
+ onFreezeFrame: handleFreezeFrame,
onDeleteAllKeyframes: (element, animationId) => {
// Hold the element where it is (collapse keyframes to a static set) rather
// than deleting the whole animation — deleting strands a stale GSAP base
@@ -423,6 +426,7 @@ export function useTimelineEditCallbacks({
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
+ handleFreezeFrame,
handleGsapRemoveAllKeyframes,
resolveElementAnimations,
resolveKeyframeTarget,
diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx
index dd68331e48..67afa51a05 100644
--- a/packages/studio/src/contexts/TimelineEditContext.tsx
+++ b/packages/studio/src/contexts/TimelineEditContext.tsx
@@ -42,6 +42,7 @@ const EDIT_CALLBACK_KEY_SET: Record = {
onSplitElement: true,
onRazorSplit: true,
onRazorSplitAll: true,
+ onFreezeFrame: true,
onDeleteKeyframe: true,
onDeleteAllKeyframes: true,
onMoveKeyframeToPlayhead: true,
diff --git a/packages/studio/src/hooks/useFreezeFrame.test.ts b/packages/studio/src/hooks/useFreezeFrame.test.ts
new file mode 100644
index 0000000000..88aade718e
--- /dev/null
+++ b/packages/studio/src/hooks/useFreezeFrame.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { TimelineElement } from "../player";
+import { requestFreezeFrame } from "./useFreezeFrame";
+
+afterEach(() => vi.unstubAllGlobals());
+
+const element: TimelineElement = {
+ id: "talk",
+ domId: "talk",
+ tag: "video",
+ start: 5,
+ duration: 6,
+ track: 0,
+ parentCompositionStart: 4,
+};
+
+function stubFetch(freezeResponse: { status: number; body: unknown }) {
+ const calls: Array<{ url: string; body: unknown }> = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string, init?: { body?: string }) => {
+ calls.push({ url, body: init?.body ? JSON.parse(init.body) : null });
+ if (url.includes("/files/")) return new Response(JSON.stringify({ version: "v1" }));
+ return new Response(JSON.stringify(freezeResponse.body), { status: freezeResponse.status });
+ }),
+ );
+ return calls;
+}
+
+describe("requestFreezeFrame", () => {
+ it("posts the clip target, file version and an authored-time playhead", async () => {
+ const calls = stubFetch({ status: 200, body: { before: "a", after: "b", version: "v2" } });
+ const result = await requestFreezeFrame({
+ projectId: "p",
+ path: "scene.html",
+ element,
+ playhead: 7.5,
+ });
+ expect(result).toEqual({ before: "a", after: "b", version: "v2" });
+ const post = calls[1];
+ expect(post?.url).toContain("/file-mutations/freeze-frame");
+ expect(post?.body).toMatchObject({
+ path: "scene.html",
+ expectedVersion: "v1",
+ target: { id: "talk" },
+ playhead: 3.5,
+ });
+ });
+
+ it("surfaces the server's error", async () => {
+ stubFetch({ status: 400, body: { error: "Move the playhead inside a video clip to freeze" } });
+ await expect(
+ requestFreezeFrame({ projectId: "p", path: "index.html", element, playhead: 7 }),
+ ).rejects.toThrow("Move the playhead inside a video clip to freeze");
+ });
+});
diff --git a/packages/studio/src/hooks/useFreezeFrame.ts b/packages/studio/src/hooks/useFreezeFrame.ts
new file mode 100644
index 0000000000..4e3d85ac94
--- /dev/null
+++ b/packages/studio/src/hooks/useFreezeFrame.ts
@@ -0,0 +1,121 @@
+import { useCallback, useRef } from "react";
+import type { TimelineElement } from "../player";
+import { toAuthoredStart } from "../player/store/timelineElement";
+import { buildPatchTarget } from "../utils/timelineElementSplit";
+import { buildProjectApiPath } from "../utils/projectRouting";
+import { markStudioWriteToken } from "../utils/studioFileVersion";
+import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
+import type { RecordEditInput } from "../utils/studioFileHistory";
+
+type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise;
+
+interface FreezeFrameResponse {
+ before: string;
+ after: string;
+ version: string;
+}
+
+function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
+ if (typeof value !== "object" || value === null) return false;
+ const body: Partial> = value;
+ return (
+ typeof body.before === "string" &&
+ typeof body.after === "string" &&
+ typeof body.version === "string"
+ );
+}
+
+function errorOf(value: unknown): string | null {
+ if (typeof value !== "object" || value === null || !("error" in value)) return null;
+ return typeof value.error === "string" ? value.error : null;
+}
+
+async function readVersion(projectId: string, path: string): Promise {
+ const response = await fetch(
+ buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
+ );
+ const body: unknown = await response.json().catch(() => null);
+ const version =
+ typeof body === "object" && body !== null && "version" in body ? body.version : null;
+ if (!response.ok || typeof version !== "string") throw new Error(`Could not read ${path}`);
+ return version;
+}
+
+/** POST the freeze to studio-server, which extracts the still and writes the whole edit at once. */
+export async function requestFreezeFrame(input: {
+ projectId: string;
+ path: string;
+ element: TimelineElement;
+ playhead: number;
+}): Promise {
+ const target = buildPatchTarget(input.element);
+ if (!target) throw new Error("This clip has no id to freeze it by");
+ const expectedVersion = await readVersion(input.projectId, input.path);
+ const transactionToken = `freeze:${crypto.randomUUID()}`;
+ markStudioWriteToken(transactionToken);
+ const response = await fetch(
+ buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Hyperframes-Write-Token": transactionToken,
+ },
+ body: JSON.stringify({
+ path: input.path,
+ expectedVersion,
+ target,
+ playhead: toAuthoredStart(input.element, input.playhead),
+ transactionToken,
+ }),
+ },
+ );
+ const body: unknown = await response.json().catch(() => null);
+ if (!response.ok || !isFreezeFrameResponse(body)) {
+ throw new Error(errorOf(body) ?? `Freeze frame failed (${response.status})`);
+ }
+ return body;
+}
+
+interface UseFreezeFrameOptions {
+ projectId: string | null;
+ activeCompPath: string | null;
+ showToast: (message: string, tone?: "error" | "info") => void;
+ writeProjectFile: ProjectFileWriter;
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
+ recordEdit: (input: RecordEditInput) => Promise;
+ reloadPreview: () => void;
+ forceReloadSdkSession?: () => void;
+ isRecordingRef?: React.RefObject;
+}
+
+export function useFreezeFrame(options: UseFreezeFrameOptions) {
+ const optionsRef = useRef(options);
+ optionsRef.current = options;
+
+ return useCallback(async (element: TimelineElement, playhead: number) => {
+ const opts = optionsRef.current;
+ if (opts.isRecordingRef?.current) {
+ opts.showToast("Cannot edit timeline while recording", "error");
+ return;
+ }
+ const projectId = opts.projectId;
+ if (!projectId) return;
+ const path = element.sourceFile || opts.activeCompPath || "index.html";
+ try {
+ await serializeStudioFileMutations(opts.writeProjectFile, [path], async () => {
+ const result = await requestFreezeFrame({ projectId, path, element, playhead });
+ await opts.recordEdit({
+ label: "Freeze frame",
+ files: { [path]: { before: result.before, after: result.after } },
+ });
+ opts.observeProjectFileVersion?.(path, result.version);
+ });
+ opts.forceReloadSdkSession?.();
+ opts.reloadPreview();
+ opts.showToast(`Froze a 2 s still at ${playhead.toFixed(2)}s`, "info");
+ } catch (error) {
+ opts.showToast(error instanceof Error ? error.message : "Freeze frame failed", "error");
+ }
+ }, []);
+}
diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts
index 91bc44b2cf..d392063fc7 100644
--- a/packages/studio/src/hooks/useRazorSplit.ts
+++ b/packages/studio/src/hooks/useRazorSplit.ts
@@ -6,6 +6,7 @@ import { trackStudioRazorSplit } from "../telemetry/events";
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
import type { RecordEditInput } from "./timelineEditingHelpers";
+import { useFreezeFrame } from "./useFreezeFrame";
interface UseRazorSplitOptions {
projectId: string | null;
@@ -135,5 +136,17 @@ export function useRazorSplit({
[isRecordingRef, runCut, showToast],
);
- return { handleRazorSplit, handleRazorSplitAll };
+ const handleFreezeFrame = useFreezeFrame({
+ projectId,
+ activeCompPath,
+ showToast,
+ writeProjectFile,
+ observeProjectFileVersion,
+ recordEdit,
+ reloadPreview,
+ forceReloadSdkSession,
+ isRecordingRef,
+ });
+
+ return { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame };
}
diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts
index 929e22048b..8c6b69d711 100644
--- a/packages/studio/src/hooks/useTimelineEditing.ts
+++ b/packages/studio/src/hooks/useTimelineEditing.ts
@@ -492,8 +492,7 @@ export function useTimelineEditing({
});
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
-
- const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
+ const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
projectId,
activeCompPath,
showToast,
@@ -575,6 +574,7 @@ export function useTimelineEditing({
),
handleTimelineElementSplit: trackedRazorSplit,
handleRazorSplit: trackedRazorSplit,
+ handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
// Same selection the handler itself splits (useRazorSplit.ts).
handleRazorSplitAll: track(
guard(
diff --git a/packages/studio/src/player/components/ClipBadges.test.tsx b/packages/studio/src/player/components/ClipBadges.test.tsx
new file mode 100644
index 0000000000..e03e8c6617
--- /dev/null
+++ b/packages/studio/src/player/components/ClipBadges.test.tsx
@@ -0,0 +1,82 @@
+// @vitest-environment happy-dom
+
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import { usePreviewIframeStore } from "../store/previewIframeStore";
+import { ClipBadges } from "./ClipBadges";
+
+Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
+ configurable: true,
+ value: true,
+});
+
+let root: Root | null = null;
+
+afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ usePreviewIframeStore.getState().setIframe(null);
+ document.body.innerHTML = "";
+});
+
+const talk: TimelineElement = {
+ id: "talk",
+ domId: "talk",
+ tag: "video",
+ start: 0,
+ duration: 6,
+ track: 0,
+ hasAudio: true,
+};
+
+function render(el: TimelineElement) {
+ const host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+ act(() => root?.render( ));
+}
+
+const labels = () =>
+ Array.from(document.querySelectorAll("[data-badge]")).map(
+ (badge) => `${badge.getAttribute("data-badge")}:${badge.textContent}`,
+ );
+
+function mountPreview(html: string): Document {
+ const iframe = document.createElement("iframe");
+ document.body.append(iframe);
+ const doc = iframe.contentDocument;
+ if (!doc) throw new Error("no iframe document");
+ doc.body.innerHTML = html;
+ usePreviewIframeStore.getState().setIframe(iframe);
+ return doc;
+}
+
+describe("ClipBadges", () => {
+ it("renders nothing for an untouched clip", () => {
+ render(talk);
+ expect(document.querySelector("[data-testid='clip-badges']")).toBeNull();
+ });
+
+ it("falls back to the store's volume without a preview", () => {
+ render({ ...talk, volume: 1.8 });
+ expect(labels()).toEqual(["volume:180%"]);
+ });
+
+ it("reads the live node and follows an agent's attribute edit", async () => {
+ const doc = mountPreview(
+ ` `,
+ );
+ render(talk);
+ expect(labels()).toEqual(["link:🔗", "crop:Crop"]);
+ const node = doc.getElementById("talk");
+ await act(async () => {
+ node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}');
+ node?.setAttribute("data-volume", "0.6");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ expect(labels()).toEqual(["link:🔗", "look:Warm daylight", "crop:Crop", "more:+1"]);
+ expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%");
+ });
+});
diff --git a/packages/studio/src/player/components/ClipBadges.tsx b/packages/studio/src/player/components/ClipBadges.tsx
new file mode 100644
index 0000000000..9792ae8039
--- /dev/null
+++ b/packages/studio/src/player/components/ClipBadges.tsx
@@ -0,0 +1,67 @@
+import { memo } from "react";
+import type { TimelineElement } from "../store/playerStore";
+import { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs";
+import { useClipToolState } from "./useClipToolState";
+
+function SpeakerGlyph({ muted }: { muted: boolean }) {
+ return (
+
+
+ {muted ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function BadgeContent({ badge }: { badge: ClipBadge }) {
+ if (badge.kind === "link") return 🔗 ;
+ if (badge.kind === "volume") {
+ const muted = badge.label === "Muted";
+ return (
+ <>
+
+ {muted ? null : {badge.label} }
+ >
+ );
+ }
+ return {badge.label} ;
+}
+
+const BADGE_CLASS =
+ "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";
+
+/** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */
+export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) {
+ const state = useClipToolState(el);
+ const { visible, hidden } = splitVisibleBadges(readClipBadges(state));
+ if (visible.length === 0) return null;
+ return (
+
+ {visible.map((badge) => (
+
+
+
+ ))}
+ {hidden.length > 0 && (
+ badge.label).join(", ")}
+ data-badge="more"
+ >
+ +{hidden.length}
+
+ )}
+
+ );
+});
diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx
index c27c15a768..77f76a784d 100644
--- a/packages/studio/src/player/components/ClipContextMenu.tsx
+++ b/packages/studio/src/player/components/ClipContextMenu.tsx
@@ -5,6 +5,7 @@ import { canSplitElement } from "../../utils/timelineElementSplit";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { useMenuKeyboardNav } from "./menuKeyboardNav";
import type { TimelineClipMenuItem } from "./TimelineTypes";
+import { ClipMenuToolItems } from "./clipMenuToolItems";
interface ClipContextMenuProps {
x: number;
@@ -144,6 +145,12 @@ export const ClipContextMenu = memo(function ClipContextMenu({
{splitLabel}
S
+
>
)}
@@ -198,6 +205,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
>
)}
+
+
)}
{showLabel && {displayLabel} }
+ {showLabel && !isGestureActor && }
{showDefaultText && (
{startLabel}-{endLabel}s
diff --git a/packages/studio/src/player/components/clipMenuToolItems.test.tsx b/packages/studio/src/player/components/clipMenuToolItems.test.tsx
new file mode 100644
index 0000000000..72230c4b3c
--- /dev/null
+++ b/packages/studio/src/player/components/clipMenuToolItems.test.tsx
@@ -0,0 +1,139 @@
+// @vitest-environment happy-dom
+
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
+import { useCropPresetBarStore } from "../../components/editor/cropPresetStore";
+import { ClipMenuToolItems, type ClipMenuToolGroup } from "./clipMenuToolItems";
+
+Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
+ configurable: true,
+ value: true,
+});
+
+let root: Root | null = null;
+
+afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ document.body.innerHTML = "";
+ useCropPresetBarStore.getState().close();
+});
+
+const video: TimelineElement = {
+ id: "talk",
+ tag: "video",
+ start: 0,
+ duration: 6,
+ track: 0,
+ hasAudio: true,
+};
+
+function renderItems(group: ClipMenuToolGroup, element: TimelineElement, currentTime = 2) {
+ const setQuiet = vi.fn(
+ async (_el: TimelineElement, _attr: string, _value: string | null, _label: string) => undefined,
+ );
+ const onClose = vi.fn();
+ const freeze = vi.fn((_el: TimelineElement, _time: number) => undefined);
+ const host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+ act(() => {
+ root?.render(
+
+
+ ,
+ );
+ });
+ return { setQuiet, onClose, freeze };
+}
+
+function openSubmenu(label: string) {
+ const row = Array.from(document.querySelectorAll('[role="menuitem"]')).find(
+ (button) => button.textContent?.startsWith(label),
+ );
+ act(() => row?.click());
+}
+
+function pick(text: string) {
+ const item = Array.from(
+ document.querySelectorAll('[role="menuitemradio"]'),
+ ).find((button) => button.textContent?.endsWith(text));
+ act(() => item?.click());
+}
+
+describe("ClipMenuToolItems", () => {
+ it("Voice writes a single-choice data-fx-chain on a video with sound", () => {
+ const { setQuiet, onClose } = renderItems("sound", video);
+ openSubmenu("Voice");
+ pick("Clean");
+ const [element, attr, value] = setQuiet.mock.calls[0] ?? [];
+ expect(element).toBe(video);
+ expect(attr).toBe("data-fx-chain");
+ expect(String(value)).toContain('"fromPreset":"voice-clean"');
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("Voice None drops the attribute", () => {
+ const { setQuiet } = renderItems("sound", video);
+ openSubmenu("Voice");
+ pick("None");
+ expect(setQuiet.mock.calls[0]?.slice(1, 3)).toEqual(["data-fx-chain", null]);
+ });
+
+ it("has no Voice on a video without sound", () => {
+ renderItems("sound", { ...video, hasAudio: false });
+ expect(document.body.textContent).toBe("");
+ });
+
+ it("Look writes the preset form and None removes it", () => {
+ const { setQuiet } = renderItems("picture", video);
+ openSubmenu("Look");
+ pick("Warm daylight");
+ openSubmenu("Look");
+ pick("None");
+ expect(setQuiet.mock.calls.map((call) => call.slice(1, 3))).toEqual([
+ ["data-color-grading", '{"preset":"warm-daylight","intensity":1}'],
+ ["data-color-grading", null],
+ ]);
+ });
+
+ it("Crop opens the preset bar for this clip", () => {
+ renderItems("picture", video);
+ const crop = Array.from(document.querySelectorAll("button")).find(
+ (button) => button.textContent === "Crop",
+ );
+ act(() => crop?.click());
+ expect(useCropPresetBarStore.getState().openFor).toEqual({ hfId: undefined, id: "talk" });
+ });
+
+ it("offers no picture tools on an audio clip", () => {
+ renderItems("picture", { ...video, tag: "audio" });
+ expect(document.body.textContent).toBe("");
+ });
+
+ it("Freeze frame calls the freeze mutation at the playhead on a video", () => {
+ const { freeze } = renderItems("time", video, 3.2);
+ const item = document.querySelector('[role="menuitem"]');
+ expect(item?.textContent).toBe("Freeze frame");
+ act(() => item?.click());
+ expect(freeze).toHaveBeenCalledWith(video, 3.2);
+ });
+
+ it("Freeze frame is disabled outside the clip and absent on images", () => {
+ renderItems("time", video, 9);
+ expect(document.querySelector('[role="menuitem"]')?.disabled).toBe(true);
+ act(() => root?.unmount());
+ root = null;
+ document.body.innerHTML = "";
+ renderItems("time", { ...video, tag: "img" });
+ expect(document.body.textContent).toBe("");
+ });
+});
diff --git a/packages/studio/src/player/components/clipMenuToolItems.tsx b/packages/studio/src/player/components/clipMenuToolItems.tsx
new file mode 100644
index 0000000000..bb372f3251
--- /dev/null
+++ b/packages/studio/src/player/components/clipMenuToolItems.tsx
@@ -0,0 +1,235 @@
+import { useRef, useState, type KeyboardEvent } from "react";
+import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
+import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
+import type { TimelineElement } from "../store/playerStore";
+import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
+import { useCropPresetBarStore } from "../../components/editor/cropPresetStore";
+import {
+ CHARACTER_CHOICES,
+ LOOK_CHOICES,
+ VOICE_CHOICES,
+ activeLook,
+ activeVoicePreset,
+ chainWithVoicePreset,
+ lookAttrValue,
+ type ClipToolChoice,
+} from "./clipToolAttrs";
+import { useClipToolState } from "./useClipToolState";
+
+export type ClipMenuToolGroup = "time" | "sound" | "picture";
+
+interface ClipMenuToolItemsProps {
+ group: ClipMenuToolGroup;
+ element: TimelineElement;
+ currentTime: number;
+ onClose: () => void;
+}
+
+const ROW_CLASS =
+ "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer";
+const DISABLED_ROW_CLASS =
+ "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
+const SUBMENU_WIDTH = 170;
+
+interface ChoiceSection {
+ heading?: string;
+ choices: readonly ClipToolChoice[];
+}
+
+function focusSibling(menu: HTMLElement | null, step: number): void {
+ if (!menu) return;
+ const items = Array.from(menu.querySelectorAll('[role="menuitemradio"]'));
+ const index = items.findIndex((item) => item === document.activeElement);
+ items[(index + step + items.length) % items.length]?.focus();
+}
+
+function ChoiceSubmenu({
+ label,
+ sections,
+ activeId,
+ onPick,
+}: {
+ label: string;
+ sections: readonly ChoiceSection[];
+ activeId: string | null;
+ onPick: (id: string | null) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [flipLeft, setFlipLeft] = useState(false);
+ const rowRef = useRef(null);
+ const submenuRef = useRef(null);
+
+ const show = (focusFirst: boolean) => {
+ const rect = rowRef.current?.getBoundingClientRect();
+ setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
+ setOpen(true);
+ if (focusFirst) requestAnimationFrame(() => focusSibling(submenuRef.current, 1));
+ };
+
+ const onSubmenuKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ event.stopPropagation();
+ focusSibling(submenuRef.current, event.key === "ArrowDown" ? 1 : -1);
+ } else if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ event.stopPropagation();
+ setOpen(false);
+ rowRef.current?.focus();
+ }
+ };
+
+ const choiceRow = (id: string | null, text: string) => (
+ onPick(id)}
+ >
+
+
+ {activeId === id ? "✓" : ""}
+
+ {text}
+
+
+ );
+
+ return (
+ show(false)} onMouseLeave={() => setOpen(false)}>
+
show(true)}
+ onKeyDown={(event) => {
+ if (event.key !== "ArrowRight") return;
+ event.preventDefault();
+ show(true);
+ }}
+ >
+ {label}
+ ▸
+
+ {open && (
+
+ {choiceRow(null, "None")}
+ {sections.map((section, index) => (
+
+ {section.heading && (
+ <>
+
+
+ {section.heading}
+
+ >
+ )}
+ {section.choices.map((choice) => choiceRow(choice.id, choice.label))}
+
+ ))}
+
+ )}
+
+ );
+}
+
+const VOICE_SECTIONS: readonly ChoiceSection[] = [
+ { choices: VOICE_CHOICES },
+ { heading: "Character", choices: CHARACTER_CHOICES },
+];
+const LOOK_SECTIONS: readonly ChoiceSection[] = [{ choices: LOOK_CHOICES }];
+
+function isPictureClip(tag: string): boolean {
+ return tag === "video" || tag === "img";
+}
+
+function FreezeFrameItem({ element, currentTime, onClose }: Omit) {
+ const { onFreezeFrame } = useTimelineEditContextOptional();
+ if (!onFreezeFrame || element.tag.trim().toLowerCase() !== "video") return null;
+ const inside = currentTime > element.start && currentTime < element.start + element.duration;
+ return (
+ {
+ void onFreezeFrame(element, currentTime);
+ onClose();
+ }}
+ >
+ {inside ? "Freeze frame" : "Freeze frame (move playhead inside clip)"}
+
+ );
+}
+
+/** Freeze (time), Voice (sound), Look and Crop (picture); sound and picture end in their own divider. */
+export function ClipMenuToolItems(props: ClipMenuToolItemsProps) {
+ if (props.group === "time") return ;
+ return ;
+}
+
+function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsProps) {
+ const { onSetElementAttributeQuiet } = useTimelineEditContextOptional();
+ const state = useClipToolState(element);
+ const openCropBar = useCropPresetBarStore((s) => s.open);
+ if (!onSetElementAttributeQuiet) return null;
+
+ const write = (attr: string, value: string | null, label: string) => {
+ void onSetElementAttributeQuiet(element, attr, value, label);
+ onClose();
+ };
+
+ if (group === "sound") {
+ if (!state.hasSound) return null;
+ return (
+ <>
+
+ write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset")
+ }
+ />
+
+ >
+ );
+ }
+
+ if (!isPictureClip(state.tag)) return null;
+ return (
+ <>
+ write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")}
+ />
+ {
+ openCropBar({ hfId: element.hfId, id: element.domId ?? element.id });
+ onClose();
+ }}
+ >
+ Crop
+
+
+ >
+ );
+}
diff --git a/packages/studio/src/player/components/clipToolAttrs.test.ts b/packages/studio/src/player/components/clipToolAttrs.test.ts
new file mode 100644
index 0000000000..d54d061df8
--- /dev/null
+++ b/packages/studio/src/player/components/clipToolAttrs.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from "vitest";
+import { parseAudioFxChain, serializeAudioFxChain } from "@hyperframes/core/audio-fx";
+import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
+import {
+ activeLook,
+ activeVoicePreset,
+ chainWithVoicePreset,
+ hasCrop,
+ isDucked,
+ lookAttrValue,
+ readClipBadges,
+ splitVisibleBadges,
+ type ClipToolState,
+} from "./clipToolAttrs";
+
+const presetsIn = (raw: string | null) =>
+ raw ? [...new Set(parseAudioFxChain(raw).nodes.map((n) => n.fromPreset ?? n.type))] : [];
+
+const handNode = serializeAudioFxChain({
+ version: 1,
+ nodes: [{ id: "n1", type: "highpass", enabled: true, params: { frequency: 80 } }],
+});
+
+describe("voice presets", () => {
+ it("writes the chosen preset's nodes and reads it back", () => {
+ const raw = chainWithVoicePreset(null, "voice-clean");
+ expect(presetsIn(raw)).toEqual(["voice-clean"]);
+ expect(activeVoicePreset(raw)).toBe("voice-clean");
+ });
+
+ it("is single-choice: picking another menu preset replaces the first", () => {
+ const raw = chainWithVoicePreset(chainWithVoicePreset(null, "voice-clean"), "telephone");
+ expect(presetsIn(raw)).toEqual(["telephone"]);
+ });
+
+ it("None drops the attribute when the chain held only the preset", () => {
+ expect(chainWithVoicePreset(chainWithVoicePreset(null, "voice-warm"), null)).toBeNull();
+ });
+
+ it("None keeps nodes that are not from a menu preset", () => {
+ const withPreset = chainWithVoicePreset(handNode, "voice-broadcast");
+ expect(presetsIn(withPreset)).toEqual(["highpass", "voice-broadcast"]);
+ expect(chainWithVoicePreset(withPreset, null)).toBe(handNode);
+ });
+
+ it("re-applying the same preset keeps one copy", () => {
+ const once = chainWithVoicePreset(null, "megaphone");
+ const twice = chainWithVoicePreset(once, "megaphone");
+ expect(parseAudioFxChain(twice ?? "").nodes).toHaveLength(
+ parseAudioFxChain(once ?? "").nodes.length,
+ );
+ });
+});
+
+describe("looks", () => {
+ it("writes the minimal preset form the runtime resolves", () => {
+ const raw = lookAttrValue("warm-daylight");
+ expect(raw).toBe('{"preset":"warm-daylight","intensity":1}');
+ expect(normalizeHfColorGrading(JSON.parse(raw ?? ""))?.preset).toBe("warm-daylight");
+ expect(activeLook(raw)).toBe("warm-daylight");
+ });
+
+ it("None removes the attribute", () => {
+ expect(lookAttrValue(null)).toBeNull();
+ });
+
+ it("reads a full inspector-written grading and ignores garbage", () => {
+ expect(activeLook('{"preset":"mono-clean","intensity":0.4,"adjust":{"contrast":0.2}}')).toBe(
+ "mono-clean",
+ );
+ expect(activeLook("{nope")).toBeNull();
+ expect(activeLook('{"preset":"mono-clean","enabled":false}')).toBeNull();
+ });
+});
+
+describe("small readers", () => {
+ it("treats an inset of zero or none as no crop", () => {
+ expect(hasCrop("inset(10px 0px 10px 0px)")).toBe(true);
+ expect(hasCrop("inset(0px)")).toBe(false);
+ expect(hasCrop("none")).toBe(false);
+ expect(hasCrop(null)).toBe(false);
+ });
+
+ it("reads a carve as ducked unless it is switched off", () => {
+ expect(isDucked('{"sources":["vo"]}')).toBe(true);
+ expect(isDucked('{"enabled":false,"sources":["vo"]}')).toBe(false);
+ expect(isDucked(null)).toBe(false);
+ });
+});
+
+const baseState: ClipToolState = {
+ tag: "video",
+ hasSound: true,
+ volume: null,
+ muted: false,
+ fxChain: null,
+ automation: null,
+ colorGrading: null,
+ clipPath: null,
+ carve: null,
+ link: null,
+};
+
+const rampAutomation = JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "rate",
+ points: [
+ { t: 0, v: 0.5 },
+ { t: 2, v: 1 },
+ ],
+ },
+ ],
+});
+
+describe("readClipBadges", () => {
+ it("shows nothing for a plain clip at 100%", () => {
+ expect(readClipBadges(baseState)).toEqual([]);
+ });
+
+ it("lists every applied tool in the wireframe's order", () => {
+ const labels = readClipBadges({
+ ...baseState,
+ volume: 1.8,
+ fxChain: chainWithVoicePreset(null, "voice-clean"),
+ automation: rampAutomation,
+ colorGrading: lookAttrValue("warm-daylight"),
+ clipPath: "inset(0px 20px)",
+ carve: "{}",
+ link: "talk",
+ }).map((b) => b.label);
+ expect(labels).toEqual([
+ "Linked",
+ "Warm daylight",
+ "Voice: Clean",
+ "Ramp",
+ "Crop",
+ "Ducked",
+ "180%",
+ ]);
+ });
+
+ it("badges a muted audio clip but not muted b-roll", () => {
+ expect(readClipBadges({ ...baseState, tag: "audio", muted: true })[0]?.label).toBe("Muted");
+ expect(readClipBadges({ ...baseState, hasSound: false, muted: true })).toEqual([]);
+ });
+
+ it("caps at two visible badges plus the link badge", () => {
+ const badges = readClipBadges({
+ ...baseState,
+ link: "talk",
+ colorGrading: lookAttrValue("mono-clean"),
+ clipPath: "inset(5px)",
+ volume: 0.6,
+ });
+ const { visible, hidden } = splitVisibleBadges(badges);
+ expect(visible.map((b) => b.label)).toEqual(["Linked", "Mono", "Crop"]);
+ expect(hidden.map((b) => b.label)).toEqual(["60%"]);
+ });
+});
diff --git a/packages/studio/src/player/components/clipToolAttrs.ts b/packages/studio/src/player/components/clipToolAttrs.ts
new file mode 100644
index 0000000000..e7418b7073
--- /dev/null
+++ b/packages/studio/src/player/components/clipToolAttrs.ts
@@ -0,0 +1,187 @@
+/**
+ * The attribute each clip-menu tool writes, and what a clip's attributes say is applied.
+ * Pure over strings so the menu, the badges and an agent's hand edits all agree.
+ */
+
+import {
+ parseAudioFxChain,
+ serializeAudioFxChain,
+ type HfAudioFxChain,
+} from "@hyperframes/core/audio-fx";
+import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets";
+import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
+import { parseRateLane } from "@hyperframes/core/speed-ramp";
+import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset";
+
+export interface ClipToolChoice {
+ id: string;
+ label: string;
+}
+
+export const VOICE_CHOICES: readonly ClipToolChoice[] = [
+ { id: "voice-clean", label: "Clean" },
+ { id: "voice-broadcast", label: "Broadcast" },
+ { id: "voice-warm", label: "Warm" },
+];
+
+export const CHARACTER_CHOICES: readonly ClipToolChoice[] = [
+ { id: "telephone", label: "Telephone" },
+ { id: "radio-am", label: "AM Radio" },
+ { id: "megaphone", label: "Megaphone" },
+];
+
+export const LOOK_CHOICES: readonly ClipToolChoice[] = [
+ { id: "warm-daylight", label: "Warm daylight" },
+ { id: "clean-studio", label: "Clean studio" },
+ { id: "vintage-wash", label: "Vintage wash" },
+ { id: "mono-clean", label: "Mono" },
+ { id: "deep-contrast", label: "Deep contrast" },
+ { id: "home-movie-8mm", label: "Home movie" },
+];
+
+const VOICE_MENU_IDS = new Set([...VOICE_CHOICES, ...CHARACTER_CHOICES].map((c) => c.id));
+
+const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
+
+function parseChainOrEmpty(raw: string | null | undefined): HfAudioFxChain {
+ if (!raw) return EMPTY_CHAIN;
+ try {
+ return parseAudioFxChain(raw);
+ } catch {
+ return EMPTY_CHAIN;
+ }
+}
+
+function labelFor(choices: readonly ClipToolChoice[], id: string | null): string | null {
+ return choices.find((choice) => choice.id === id)?.label ?? null;
+}
+
+export function activeVoicePreset(rawChain: string | null | undefined): string | null {
+ const ids = activeAudioFxPresetIds(parseChainOrEmpty(rawChain));
+ return ids.find((id) => VOICE_MENU_IDS.has(id)) ?? null;
+}
+
+function voicePresetLabel(id: string | null): string | null {
+ return labelFor(VOICE_CHOICES, id) ?? labelFor(CHARACTER_CHOICES, id);
+}
+
+/**
+ * The `data-fx-chain` after choosing a voice preset, or `null` to drop the attribute.
+ * Single choice: the other menu presets' nodes go; carve, leveller and hand-added nodes stay.
+ */
+export function chainWithVoicePreset(
+ rawChain: string | null | undefined,
+ presetId: string | null,
+): string | null {
+ const chain = parseChainOrEmpty(rawChain);
+ const others = chain.nodes.filter(
+ (node) =>
+ !node.fromPreset || !VOICE_MENU_IDS.has(node.fromPreset) || node.fromPreset === presetId,
+ );
+ const kept: HfAudioFxChain = { ...chain, nodes: others };
+ const next = presetId ? (applyPresetToChain(kept, presetId, undefined) ?? kept) : kept;
+ return next.nodes.length > 0 ? serializeAudioFxChain(next) : null;
+}
+
+function parseJson(raw: string | null | undefined): unknown {
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+export function activeLook(rawGrading: string | null | undefined): string | null {
+ return normalizeHfColorGrading(parseJson(rawGrading))?.preset ?? null;
+}
+
+function lookLabel(id: string | null): string | null {
+ return labelFor(LOOK_CHOICES, id);
+}
+
+export function lookAttrValue(presetId: string | null): string | null {
+ return presetId ? JSON.stringify({ preset: presetId, intensity: 1 }) : null;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+export function isDucked(rawCarve: string | null | undefined): boolean {
+ const carve = parseJson(rawCarve);
+ return isRecord(carve) && carve["enabled"] !== false;
+}
+
+function hasRateRamp(rawAutomation: string | null | undefined): boolean {
+ return parseRateLane(rawAutomation) !== null;
+}
+
+const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i;
+
+export function hasCrop(clipPath: string | null | undefined): boolean {
+ const value = clipPath?.trim() ?? "";
+ return value !== "" && value !== "none" && !ZERO_INSET.test(value);
+}
+
+export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume";
+
+export interface ClipBadge {
+ kind: ClipBadgeKind;
+ label: string;
+}
+
+/** What a clip's attributes say is applied; the element's own attributes, as read off its node. */
+export interface ClipToolState {
+ tag: string;
+ hasSound: boolean;
+ volume: number | null;
+ muted: boolean;
+ fxChain: string | null;
+ automation: string | null;
+ colorGrading: string | null;
+ clipPath: string | null;
+ carve: string | null;
+ link: string | null;
+}
+
+function volumeBadge(state: ClipToolState): ClipBadge | null {
+ const isAudio = state.tag === "audio";
+ if (!isAudio && !state.hasSound) return null;
+ if (state.muted) return { kind: "volume", label: "Muted" };
+ const volume = state.volume ?? 1;
+ if (Math.abs(volume - 1) < 0.005) return null;
+ return { kind: "volume", label: `${Math.round(volume * 100)}%` };
+}
+
+/** Badges in the wireframe's order: look, voice, ramp, crop, ducked, volume. The link badge leads. */
+export function readClipBadges(state: ClipToolState): ClipBadge[] {
+ const look = lookLabel(activeLook(state.colorGrading));
+ const voice = voicePresetLabel(activeVoicePreset(state.fxChain));
+ const volume = volumeBadge(state);
+ const badges: Array = [
+ state.link ? { kind: "link", label: "Linked" } : null,
+ look ? { kind: "look", label: look } : null,
+ voice ? { kind: "voice", label: `Voice: ${voice}` } : null,
+ hasRateRamp(state.automation) ? { kind: "ramp", label: "Ramp" } : null,
+ hasCrop(state.clipPath) ? { kind: "crop", label: "Crop" } : null,
+ isDucked(state.carve) ? { kind: "ducked", label: "Ducked" } : null,
+ volume,
+ ];
+ return badges.filter((badge): badge is ClipBadge => badge !== null);
+}
+
+const MAX_VISIBLE_BADGES = 2;
+
+/** The link badge is always shown; the rest cap at two, then `+N`. */
+export function splitVisibleBadges(badges: readonly ClipBadge[]): {
+ visible: ClipBadge[];
+ hidden: ClipBadge[];
+} {
+ const link = badges.filter((badge) => badge.kind === "link");
+ const rest = badges.filter((badge) => badge.kind !== "link");
+ return {
+ visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)],
+ hidden: rest.slice(MAX_VISIBLE_BADGES),
+ };
+}
diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts
index 4d4ea90a15..3fa2a7f0fe 100644
--- a/packages/studio/src/player/components/timelineCallbacks.ts
+++ b/packages/studio/src/player/components/timelineCallbacks.ts
@@ -118,6 +118,7 @@ export interface TimelineEditCallbacks {
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise | void;
onRazorSplitAll?: (splitTime: number) => Promise | void;
+ onFreezeFrame?: (element: TimelineElement, time: number) => Promise | void;
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
diff --git a/packages/studio/src/player/components/useClipToolState.ts b/packages/studio/src/player/components/useClipToolState.ts
new file mode 100644
index 0000000000..364ce9585e
--- /dev/null
+++ b/packages/studio/src/player/components/useClipToolState.ts
@@ -0,0 +1,92 @@
+import { useEffect, useState } from "react";
+import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
+import { HF_AUDIO_CARVE_ATTR } from "@hyperframes/core/audio-carve";
+import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
+import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
+import type { TimelineElement } from "../store/playerStore";
+import { useLivePreviewIframe } from "../store/previewIframeStore";
+import { useStudioShellContextOptional } from "../../contexts/StudioContext";
+import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
+import { committedClipPath } from "../../components/editor/cropPresetStore";
+import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
+import type { ClipToolState } from "./clipToolAttrs";
+
+const LINK_ATTR = "data-link";
+
+const WATCHED_ATTRS = [
+ HF_AUDIO_FX_ATTR,
+ HF_AUDIO_AUTOMATION_ATTR,
+ HF_AUDIO_CARVE_ATTR,
+ HF_COLOR_GRADING_ATTR,
+ LINK_ATTR,
+ "data-volume",
+ "data-has-audio",
+ "muted",
+ "style",
+];
+
+function readVolume(node: Element): number | null {
+ const volume = Number.parseFloat(node.getAttribute("data-volume") ?? "");
+ return Number.isFinite(volume) ? volume : null;
+}
+
+function toolStateFromNode(node: Element): ClipToolState {
+ const tag = node.tagName.toLowerCase();
+ return {
+ tag,
+ hasSound: tag === "audio" || isAudibleVideoNode(node),
+ volume: readVolume(node),
+ muted: node.hasAttribute("muted"),
+ fxChain: node.getAttribute(HF_AUDIO_FX_ATTR),
+ automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR),
+ colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
+ clipPath: committedClipPath(node),
+ carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
+ link: node.getAttribute(LINK_ATTR),
+ };
+}
+
+function toolStateFromElement(el: TimelineElement): ClipToolState {
+ const tag = el.tag.trim().toLowerCase();
+ return {
+ tag,
+ hasSound: tag === "audio" || el.hasAudio === true,
+ volume: el.volume ?? null,
+ muted: el.muted === true,
+ fxChain: el.fxChain ?? null,
+ automation: el.automation ?? null,
+ colorGrading: null,
+ clipPath: null,
+ carve: null,
+ link: null,
+ };
+}
+
+/** Inline style changes every frame under a tween; only a changed reading re-renders. */
+function keepIfUnchanged(prev: ClipToolState, next: ClipToolState): ClipToolState {
+ return JSON.stringify(prev) === JSON.stringify(next) ? prev : next;
+}
+
+/** A clip's tool attributes as the live preview node holds them, kept current as they change. */
+export function useClipToolState(el: TimelineElement): ClipToolState {
+ const iframe = useLivePreviewIframe();
+ const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
+ const [state, setState] = useState(() => toolStateFromElement(el));
+
+ useEffect(() => {
+ const node = findTimelineElementInIframe(iframe, el, activeCompPath);
+ if (!node) {
+ setState(toolStateFromElement(el));
+ return;
+ }
+ const refresh = () => setState((prev) => keepIfUnchanged(prev, toolStateFromNode(node)));
+ refresh();
+ const view = node.ownerDocument.defaultView;
+ if (!view) return;
+ const observer = new view.MutationObserver(refresh);
+ observer.observe(node, { attributes: true, attributeFilter: WATCHED_ATTRS });
+ return () => observer.disconnect();
+ }, [iframe, el, activeCompPath]);
+
+ return state;
+}