feat(studio): speed presets, voice, look, crop presets and freeze frame in the clip menu (3/8) (#4815)

* feat(core): add gentle ramp-in, ramp-out and slow-mo-middle speed presets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): add voice, look and crop clip-menu items and clip badges

Voice and Look are single-choice submenus writing data-fx-chain and
data-color-grading; None clears. Crop opens the canvas crop preset bar.
ClipBadges reads the live preview node and follows attribute edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): drop the unstyled badge classes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): add crop aspect presets to the canvas crop handles

Free, 16:9, 9:16, 1:1, 4:5, Reset and Done float above the clip opened from
the clip menu. Presets commit a centred inset clip-path; Reset removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(studio): read the committed crop from the inline clip-path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio-server): add freeze-frame file mutation

Extracts the frame under the playhead with ffmpeg (media time honours
data-media-start, playback rate and a rate lane) into assets/freeze/, then in
one write splits the video, inserts a 2 s <img> still with the video's box
styles, shifts the rest of the track and splits a data-link partner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio-server): import replaceFileAtomically from core atomic-file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): add freeze frame to the clip menu

Freeze frame on a video under the playhead posts the studio-server
freeze-frame mutation and records its before/after as one undo step.
onFreezeFrame is threaded through the timeline edit callbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio-server): keep the freeze still inside assets/freeze

The clip id from the project HTML went straight into ffmpeg's -y output
path, so an id with ../ segments wrote outside the project. The id is now
reduced to one safe filename component, the output is pinned inside
assets/freeze before extraction, and the still's src is built from that
name rather than from a filesystem path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio-server): give every freeze still its own immutable name

Two clip ids that sanitise alike (a.b / a_b), ids sharing an 80-character
prefix, or a second freeze of one clip at the same playhead all produced the
same still path, and ffmpeg's -y replaced the first still's pixels. The name
now carries a hash of the raw id and a per-extraction token, the route
refuses a name that already exists, and ffmpeg runs with -n so it can never
overwrite. The freeze media time reads data-playback-start before
data-media-start, as playback does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-10-02 03:08:50 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3c299acc7f
commit 000ccd426a
31 changed files with 2444 additions and 104 deletions
+34
View File
@@ -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);
}
});
});
+26
View File
@@ -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"];
@@ -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);
@@ -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 = `<div data-composition-id="main" data-start="0" data-duration="10">
<video id="talk" class="clip" src="talk.mp4" data-start="1" data-duration="6" data-media-start="2" data-track-index="0" data-link="L" style="left:10px;clip-path:inset(5px)"></video>
<img id="later" class="clip" src="x.png" data-start="8" data-duration="1" data-track-index="0">
<div id="other" class="clip" data-start="5" data-duration="2" data-track-index="2"></div>
<audio id="talk-audio" class="clip" src="talk.mp4" data-start="1" data-duration="6" data-media-start="2" data-track-index="1" data-link="L"></audio>
</div>`;
const at = (html: string, id: string) =>
parseHTML(`<html><body>${html}</body></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><body>${html}</body></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));
});
});
@@ -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<number>, 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;
}
@@ -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 = `<div data-composition-id="main" data-start="0" data-duration="6">
<video id="talk" class="clip" src="media/talk.mp4" data-start="0" data-duration="6" data-track-index="0"></video>
</div>`;
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,
) => `<div data-composition-id="main" data-start="0" data-duration="6">
<video id="${a}" class="clip" src="media/a.mp4" data-start="0" data-duration="6" data-track-index="0"></video>
<video id="${b}" class="clip" src="media/b.mp4" data-start="0" data-duration="6" data-track-index="1"></video>
</div>`;
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<Response>, 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);
});
});
});
@@ -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<Record<keyof FreezeFrameRequest, unknown>> = 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,
});
});
}
+1
View File
@@ -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}
@@ -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 (
@@ -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 (
<div
role="toolbar"
aria-label="Crop presets"
data-dom-edit-crop-bar="true"
className="pointer-events-auto absolute z-20 flex gap-1 rounded-md border border-white/15 bg-neutral-900/95 p-1 shadow-lg"
style={{ left, top: Math.max(4, top - 34), transform: "translateX(-50%)" }}
onPointerDown={(event) => event.stopPropagation()}
>
<button type="button" className={CHIP} title="Drag the edges freely">
Free
</button>
{CROP_ASPECT_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
className={CHIP}
onClick={() => onApply(centredCropInsets(elementWidth, elementHeight, preset.ratio))}
>
{preset.label}
</button>
))}
<button type="button" className={CHIP} onClick={() => onApply(null)}>
Reset
</button>
<button
type="button"
className="rounded-sm border border-studio-accent bg-studio-accent px-1.5 py-0.5 text-[10px] font-semibold text-black"
onClick={onDone}
>
Done
</button>
</div>
);
}
@@ -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<HTMLButtonElement>("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();
});
});
@@ -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<HTMLElement>) => {
const gesture = endCropGesture(event);
if (!gesture) return;
@@ -239,119 +255,131 @@ export function DomEditCropHandles({
if (!state.croppable) return null;
return (
<div
data-dom-edit-crop-frame="true"
className="pointer-events-none absolute"
style={{
left: frame.left,
top: frame.top,
width: frame.width,
height: frame.height,
transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
}}
>
{/* 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 && (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute"
style={{
left: cropRect.left,
top: cropRect.top,
width: cropRect.width,
height: cropRect.height,
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
}}
/>
</div>
)}
{/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid
shown while dragging. */}
<div
aria-hidden="true"
className="pointer-events-none absolute border border-dashed border-studio-accent"
style={{
left: cropRect.left,
top: cropRect.top,
width: cropRect.width,
height: cropRect.height,
}}
>
{dragging && (
<>
<div className="absolute inset-y-0 left-1/3 w-px bg-studio-accent/40" />
<div className="absolute inset-y-0 left-2/3 w-px bg-studio-accent/40" />
<div className="absolute inset-x-0 top-1/3 h-px bg-studio-accent/40" />
<div className="absolute inset-x-0 top-2/3 h-px bg-studio-accent/40" />
</>
)}
</div>
{/* 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 && (
<button
type="button"
aria-label="Reposition crop"
title="Reposition crop"
data-dom-edit-crop-handle="true"
className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
style={{
left: cropRect.left + (cropRect.width - repositionSize) / 2,
top: cropRect.top + (cropRect.height - repositionSize) / 2,
width: repositionSize,
height: repositionSize,
cursor: "move",
touchAction: "none",
}}
onPointerDown={(event) => startCropGesture("move", event)}
onPointerMove={updateCropGesture}
onPointerUp={finishCropGesture}
onPointerCancel={cancelCropGesture}
<>
{showPresetBar && onStyleCommit && (
<CropPresetBar
left={frame.left + frame.width / 2}
top={frame.top}
elementWidth={width}
elementHeight={height}
onApply={applyPresetInsets}
onDone={closePresetBar}
/>
)}
{/* 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 (
<div
data-dom-edit-crop-frame="true"
className="pointer-events-none absolute"
style={{
left: frame.left,
top: frame.top,
width: frame.width,
height: frame.height,
transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
}}
>
{/* 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 && (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute"
style={{
left: cropRect.left,
top: cropRect.top,
width: cropRect.width,
height: cropRect.height,
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
}}
/>
</div>
)}
{/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid
shown while dragging. */}
<div
aria-hidden="true"
className="pointer-events-none absolute border border-dashed border-studio-accent"
style={{
left: cropRect.left,
top: cropRect.top,
width: cropRect.width,
height: cropRect.height,
}}
>
{dragging && (
<>
<div className="absolute inset-y-0 left-1/3 w-px bg-studio-accent/40" />
<div className="absolute inset-y-0 left-2/3 w-px bg-studio-accent/40" />
<div className="absolute inset-x-0 top-1/3 h-px bg-studio-accent/40" />
<div className="absolute inset-x-0 top-2/3 h-px bg-studio-accent/40" />
</>
)}
</div>
{/* 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 && (
<button
key={edge}
type="button"
aria-label={`Crop ${edge}`}
title="Crop"
aria-label="Reposition crop"
title="Reposition crop"
data-dom-edit-crop-handle="true"
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
style={{
...layout.hit,
cursor: layout.cursor,
left: cropRect.left + (cropRect.width - repositionSize) / 2,
top: cropRect.top + (cropRect.height - repositionSize) / 2,
width: repositionSize,
height: repositionSize,
cursor: "move",
touchAction: "none",
}}
onPointerEnter={() => 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}
>
<span
className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
/>
)}
{/* 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 (
<button
key={edge}
type="button"
aria-label={`Crop ${edge}`}
title="Crop"
data-dom-edit-crop-handle="true"
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
style={{
...layout.pill,
opacity: revealed ? 1 : 0,
...layout.hit,
cursor: layout.cursor,
touchAction: "none",
}}
/>
</button>
);
})}
</div>
onPointerEnter={() => setHotEdge(edge)}
onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
onPointerDown={(event) => startCropGesture(edge, event)}
onPointerMove={updateCropGesture}
onPointerUp={finishCropGesture}
onPointerCancel={cancelCropGesture}
>
<span
className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
style={{
...layout.pill,
opacity: revealed ? 1 : 0,
}}
/>
</button>
);
})}
</div>
</>
);
}
@@ -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<CropPresetBarState>((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;
}
@@ -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"]);
});
});
@@ -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;
}
@@ -53,6 +53,7 @@ export interface TimelineEditCallbackDeps {
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | 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,
@@ -42,6 +42,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
onSplitElement: true,
onRazorSplit: true,
onRazorSplitAll: true,
onFreezeFrame: true,
onDeleteKeyframe: true,
onDeleteAllKeyframes: true,
onMoveKeyframeToPlayhead: true,
@@ -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");
});
});
+121
View File
@@ -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<void>;
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<Record<keyof FreezeFrameResponse, unknown>> = 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<string> {
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<FreezeFrameResponse> {
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<void>;
reloadPreview: () => void;
forceReloadSdkSession?: () => void;
isRecordingRef?: React.RefObject<boolean>;
}
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");
}
}, []);
}
+14 -1
View File
@@ -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 };
}
@@ -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(
@@ -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(<ClipBadges el={el} />));
}
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(
`<video id="talk" data-has-audio="true" data-link="talk-pair" style="clip-path: inset(10px)"></video>`,
);
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%");
});
});
@@ -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 (
<svg width="10" height="10" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 6h3l4-3v10l-4-3H2z" fill="currentColor" />
{muted ? (
<path d="M11 6l4 4M15 6l-4 4" stroke="currentColor" strokeWidth="1.4" />
) : (
<path
d="M11 5.5a3.5 3.5 0 0 1 0 5M12.5 3.5a6 6 0 0 1 0 9"
stroke="currentColor"
fill="none"
strokeWidth="1.4"
/>
)}
</svg>
);
}
function BadgeContent({ badge }: { badge: ClipBadge }) {
if (badge.kind === "link") return <span aria-hidden="true">🔗</span>;
if (badge.kind === "volume") {
const muted = badge.label === "Muted";
return (
<>
<SpeakerGlyph muted={muted} />
{muted ? null : <span>{badge.label}</span>}
</>
);
}
return <span>{badge.label}</span>;
}
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 (
<span
className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
data-testid="clip-badges"
>
{visible.map((badge) => (
<span key={badge.kind} className={BADGE_CLASS} title={badge.label} data-badge={badge.kind}>
<BadgeContent badge={badge} />
</span>
))}
{hidden.length > 0 && (
<span
className={`${BADGE_CLASS} pointer-events-auto`}
title={hidden.map((badge) => badge.label).join(", ")}
data-badge="more"
>
+{hidden.length}
</span>
)}
</span>
);
});
@@ -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({
<span>{splitLabel}</span>
<span className="text-neutral-500 text-[10px] ml-3">S</span>
</button>
<ClipMenuToolItems
group="time"
element={element}
currentTime={currentTime}
onClose={onClose}
/>
<div className="my-1 border-t border-neutral-700/60" />
</>
)}
@@ -198,6 +205,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
</>
)}
<ClipMenuToolItems
group="sound"
element={element}
currentTime={currentTime}
onClose={onClose}
/>
<ClipMenuToolItems
group="picture"
element={element}
currentTime={currentTime}
onClose={onClose}
/>
<button
type="button"
role="menuitem"
@@ -11,6 +11,7 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
import { rendersWaveform } from "./AudioWaveform";
import { ClipBadges } from "./ClipBadges";
interface TimelineClipProps {
el: TimelineElement;
@@ -204,6 +205,7 @@ export const TimelineClip = memo(function TimelineClip({
</div>
)}
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
{showLabel && !isGestureActor && <ClipBadges el={el} />}
{showDefaultText && (
<span className="timeline-clip__timecode">
{startLabel}-{endLabel}s
@@ -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(
<TimelineEditProvider value={{ onSetElementAttributeQuiet: setQuiet, onFreezeFrame: freeze }}>
<ClipMenuToolItems
group={group}
element={element}
currentTime={currentTime}
onClose={onClose}
/>
</TimelineEditProvider>,
);
});
return { setQuiet, onClose, freeze };
}
function openSubmenu(label: string) {
const row = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).find(
(button) => button.textContent?.startsWith(label),
);
act(() => row?.click());
}
function pick(text: string) {
const item = Array.from(
document.querySelectorAll<HTMLButtonElement>('[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<HTMLButtonElement>("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<HTMLButtonElement>('[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<HTMLButtonElement>('[role="menuitem"]')?.disabled).toBe(true);
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
renderItems("time", { ...video, tag: "img" });
expect(document.body.textContent).toBe("");
});
});
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement | null>(null);
const submenuRef = useRef<HTMLDivElement | null>(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) => (
<button
key={id ?? "none"}
type="button"
role="menuitemradio"
aria-checked={activeId === id}
className={ROW_CLASS}
onClick={() => onPick(id)}
>
<span className="flex items-center gap-1.5">
<span aria-hidden="true" className="w-3 text-center">
{activeId === id ? "✓" : ""}
</span>
{text}
</span>
</button>
);
return (
<div className="relative" onMouseEnter={() => show(false)} onMouseLeave={() => setOpen(false)}>
<button
ref={rowRef}
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={open}
className={ROW_CLASS}
onClick={() => show(true)}
onKeyDown={(event) => {
if (event.key !== "ArrowRight") return;
event.preventDefault();
show(true);
}}
>
<span>{label}</span>
<span className="text-neutral-500 text-[10px] ml-3">▸</span>
</button>
{open && (
<div
ref={submenuRef}
role="menu"
aria-label={label}
className="absolute top-0 z-10 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1"
style={{ width: SUBMENU_WIDTH, ...(flipLeft ? { right: "100%" } : { left: "100%" }) }}
onKeyDown={onSubmenuKeyDown}
>
{choiceRow(null, "None")}
{sections.map((section, index) => (
<div key={section.heading ?? index}>
{section.heading && (
<>
<div className="my-1 border-t border-neutral-700/60" />
<div className="px-3 py-1 text-[9px] uppercase tracking-wide text-neutral-500">
{section.heading}
</div>
</>
)}
{section.choices.map((choice) => choiceRow(choice.id, choice.label))}
</div>
))}
</div>
)}
</div>
);
}
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<ClipMenuToolItemsProps, "group">) {
const { onFreezeFrame } = useTimelineEditContextOptional();
if (!onFreezeFrame || element.tag.trim().toLowerCase() !== "video") return null;
const inside = currentTime > element.start && currentTime < element.start + element.duration;
return (
<button
type="button"
role="menuitem"
className={inside ? ROW_CLASS : DISABLED_ROW_CLASS}
disabled={!inside}
onClick={() => {
void onFreezeFrame(element, currentTime);
onClose();
}}
>
<span>{inside ? "Freeze frame" : "Freeze frame (move playhead inside clip)"}</span>
</button>
);
}
/** 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 <FreezeFrameItem {...props} />;
return <ClipMenuAttributeItems {...props} />;
}
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 (
<>
<ChoiceSubmenu
label="Voice"
sections={VOICE_SECTIONS}
activeId={activeVoicePreset(state.fxChain)}
onPick={(id) =>
write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset")
}
/>
<div className="my-1 border-t border-neutral-700/60" />
</>
);
}
if (!isPictureClip(state.tag)) return null;
return (
<>
<ChoiceSubmenu
label="Look"
sections={LOOK_SECTIONS}
activeId={activeLook(state.colorGrading)}
onPick={(id) => write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")}
/>
<button
type="button"
role="menuitem"
className={ROW_CLASS}
onClick={() => {
openCropBar({ hfId: element.hfId, id: element.domId ?? element.id });
onClose();
}}
>
<span>Crop</span>
</button>
<div className="my-1 border-t border-neutral-700/60" />
</>
);
}
@@ -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%"]);
});
});
@@ -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<string, unknown> {
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<ClipBadge | null> = [
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),
};
}
@@ -118,6 +118,7 @@ export interface TimelineEditCallbacks {
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
@@ -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<ClipToolState>(() => 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;
}