mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
co-authored by
Claude Opus 5.5
parent
3c299acc7f
commit
000ccd426a
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user