From 616d7fd3376e10a2f1254b5fa7634e1cbb381a69 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 29 Sep 2026 17:01:16 -0700 Subject: [PATCH 01/10] feat(core): add gentle ramp-in, ramp-out and slow-mo-middle speed presets Co-Authored-By: Claude Opus 5.5 --- packages/core/src/speedRamp.test.ts | 34 +++++++++++++++++++++++++++++ packages/core/src/speedRamp.ts | 26 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/core/src/speedRamp.test.ts b/packages/core/src/speedRamp.test.ts index be3e89242b..61981bf13a 100644 --- a/packages/core/src/speedRamp.test.ts +++ b/packages/core/src/speedRamp.test.ts @@ -122,3 +122,37 @@ describe("presets", () => { } }); }); + +describe("gentle ramp presets", () => { + const speeds = (id: "ramp-in" | "ramp-out" | "slowmo-middle", duration: number) => + speedPresetLane(id, duration).points.map((p) => [p.t, p.v]); + + it("ramp-in eases from half speed up to normal", () => { + expect(speeds("ramp-in", 4)).toEqual([ + [0, 0.5], + [4, 1], + ]); + }); + + it("ramp-out eases from normal down to half speed", () => { + expect(speeds("ramp-out", 4)).toEqual([ + [0, 1], + [4, 0.5], + ]); + }); + + it("slowmo-middle holds 0.35x through the middle third and returns to normal", () => { + const lane = speedPresetLane("slowmo-middle", 6); + expect(rateAt(lane, 0)).toBe(1); + expect(rateAt(lane, 2)).toBeCloseTo(0.35, 5); + expect(rateAt(lane, 3)).toBeCloseTo(0.35, 5); + expect(rateAt(lane, 4)).toBeCloseTo(0.35, 5); + expect(rateAt(lane, 6)).toBe(1); + }); + + it("each consumes less source than the clip at normal speed", () => { + for (const id of ["ramp-in", "ramp-out", "slowmo-middle"] as const) { + expect(sourceTimeAt(speedPresetLane(id, 6), 6)).toBeLessThan(6); + } + }); +}); diff --git a/packages/core/src/speedRamp.ts b/packages/core/src/speedRamp.ts index 7d26df419c..c9c7e99afb 100644 --- a/packages/core/src/speedRamp.ts +++ b/packages/core/src/speedRamp.ts @@ -206,6 +206,32 @@ export const SPEED_PRESETS = [ [1, 0.3], ], }, + { + id: "ramp-in", + label: "Ramp in", + points: [ + [0, 0.5], + [1, 1], + ], + }, + { + id: "ramp-out", + label: "Ramp out", + points: [ + [0, 1], + [1, 0.5], + ], + }, + { + id: "slowmo-middle", + label: "Slow-mo middle", + points: [ + [0, 1], + [1 / 3, 0.35], + [2 / 3, 0.35], + [1, 1], + ], + }, ] as const satisfies readonly RatePreset[]; export type SpeedPresetId = (typeof SPEED_PRESETS)[number]["id"]; From c8dc3216d5e15a1088bf9d0953c1177fa7f59d72 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 29 Sep 2026 22:49:35 -0700 Subject: [PATCH 02/10] 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 --- .../src/components/editor/cropPresetStore.ts | 38 ++++ .../src/player/components/ClipBadges.test.tsx | 82 +++++++ .../src/player/components/ClipBadges.tsx | 67 ++++++ .../src/player/components/ClipContextMenu.tsx | 3 + .../src/player/components/TimelineClip.tsx | 2 + .../components/clipMenuToolItems.test.tsx | 115 ++++++++++ .../player/components/clipMenuToolItems.tsx | 207 ++++++++++++++++++ .../player/components/clipToolAttrs.test.ts | 161 ++++++++++++++ .../src/player/components/clipToolAttrs.ts | 187 ++++++++++++++++ .../src/player/components/useClipToolState.ts | 92 ++++++++ 10 files changed, 954 insertions(+) create mode 100644 packages/studio/src/components/editor/cropPresetStore.ts create mode 100644 packages/studio/src/player/components/ClipBadges.test.tsx create mode 100644 packages/studio/src/player/components/ClipBadges.tsx create mode 100644 packages/studio/src/player/components/clipMenuToolItems.test.tsx create mode 100644 packages/studio/src/player/components/clipMenuToolItems.tsx create mode 100644 packages/studio/src/player/components/clipToolAttrs.test.ts create mode 100644 packages/studio/src/player/components/clipToolAttrs.ts create mode 100644 packages/studio/src/player/components/useClipToolState.ts diff --git a/packages/studio/src/components/editor/cropPresetStore.ts b/packages/studio/src/components/editor/cropPresetStore.ts new file mode 100644 index 0000000000..5bf582bff8 --- /dev/null +++ b/packages/studio/src/components/editor/cropPresetStore.ts @@ -0,0 +1,38 @@ +import { create } from "zustand"; + +/** The clip the crop preset bar is open for, by timeline identity (`key ?? id`). */ +interface CropPresetBarState { + openFor: string | null; + open: (clipIdentity: string) => void; + close: () => void; +} + +export const useCropPresetBarStore = create((set) => ({ + openFor: null, + open: (clipIdentity) => set({ openFor: clipIdentity }), + close: () => set({ openFor: null }), +})); + +/** + * The committed clip-path of an element whose crop the canvas handles have lifted to `none` + * while it is selected, so readers of the live node still see the crop that will render. + */ +const liftedCrops = new WeakMap(); + +export function rememberLiftedCrop(element: Element, clipPath: string): void { + liftedCrops.set(element, clipPath); +} + +export function forgetLiftedCrop(element: Element): void { + liftedCrops.delete(element); +} + +export function committedClipPath(element: Element): string | null { + return liftedCrops.get(element) ?? readInlineClipPath(element); +} + +function readInlineClipPath(element: Element): string | null { + const style = element.getAttribute("style") ?? ""; + const match = /(?:^|;)\s*clip-path\s*:\s*([^;]+)/i.exec(style); + return match?.[1]?.trim() ?? null; +} diff --git a/packages/studio/src/player/components/ClipBadges.test.tsx b/packages/studio/src/player/components/ClipBadges.test.tsx new file mode 100644 index 0000000000..e03e8c6617 --- /dev/null +++ b/packages/studio/src/player/components/ClipBadges.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { usePreviewIframeStore } from "../store/previewIframeStore"; +import { ClipBadges } from "./ClipBadges"; + +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + configurable: true, + value: true, +}); + +let root: Root | null = null; + +afterEach(() => { + act(() => root?.unmount()); + root = null; + usePreviewIframeStore.getState().setIframe(null); + document.body.innerHTML = ""; +}); + +const talk: TimelineElement = { + id: "talk", + domId: "talk", + tag: "video", + start: 0, + duration: 6, + track: 0, + hasAudio: true, +}; + +function render(el: TimelineElement) { + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + act(() => root?.render()); +} + +const labels = () => + Array.from(document.querySelectorAll("[data-badge]")).map( + (badge) => `${badge.getAttribute("data-badge")}:${badge.textContent}`, + ); + +function mountPreview(html: string): Document { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument; + if (!doc) throw new Error("no iframe document"); + doc.body.innerHTML = html; + usePreviewIframeStore.getState().setIframe(iframe); + return doc; +} + +describe("ClipBadges", () => { + it("renders nothing for an untouched clip", () => { + render(talk); + expect(document.querySelector("[data-testid='clip-badges']")).toBeNull(); + }); + + it("falls back to the store's volume without a preview", () => { + render({ ...talk, volume: 1.8 }); + expect(labels()).toEqual(["volume:180%"]); + }); + + it("reads the live node and follows an agent's attribute edit", async () => { + const doc = mountPreview( + ``, + ); + render(talk); + expect(labels()).toEqual(["link:๐Ÿ”—", "crop:Crop"]); + const node = doc.getElementById("talk"); + await act(async () => { + node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}'); + node?.setAttribute("data-volume", "0.6"); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(labels()).toEqual(["link:๐Ÿ”—", "look:Warm daylight", "crop:Crop", "more:+1"]); + expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%"); + }); +}); diff --git a/packages/studio/src/player/components/ClipBadges.tsx b/packages/studio/src/player/components/ClipBadges.tsx new file mode 100644 index 0000000000..5cffb0cd9f --- /dev/null +++ b/packages/studio/src/player/components/ClipBadges.tsx @@ -0,0 +1,67 @@ +import { memo } from "react"; +import type { TimelineElement } from "../store/playerStore"; +import { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs"; +import { useClipToolState } from "./useClipToolState"; + +function SpeakerGlyph({ muted }: { muted: boolean }) { + return ( + + ); +} + +function BadgeContent({ badge }: { badge: ClipBadge }) { + if (badge.kind === "link") return ; + if (badge.kind === "volume") { + const muted = badge.label === "Muted"; + return ( + <> + + {muted ? null : {badge.label}} + + ); + } + return {badge.label}; +} + +const BADGE_CLASS = + "timeline-clip__badge inline-flex items-center gap-0.5 rounded-[3px] border border-white/20 bg-black/55 px-1 text-[9px] leading-[14px] text-white/90 whitespace-nowrap"; + +/** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */ +export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) { + const state = useClipToolState(el); + const { visible, hidden } = splitVisibleBadges(readClipBadges(state)); + if (visible.length === 0) return null; + return ( + + {visible.map((badge) => ( + + + + ))} + {hidden.length > 0 && ( + badge.label).join(", ")} + data-badge="more" + > + +{hidden.length} + + )} + + ); +}); diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index c27c15a768..49b4a0a78e 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -5,6 +5,7 @@ import { canSplitElement } from "../../utils/timelineElementSplit"; import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss"; import { useMenuKeyboardNav } from "./menuKeyboardNav"; import type { TimelineClipMenuItem } from "./TimelineTypes"; +import { ClipMenuToolItems } from "./clipMenuToolItems"; interface ClipContextMenuProps { x: number; @@ -198,6 +199,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({ )} + + + ); + + return ( +
show(false)} onMouseLeave={() => setOpen(false)}> + + {open && ( +
+ {choiceRow(null, "None")} + {sections.map((section, index) => ( +
+ {section.heading && ( + <> +
+
+ {section.heading} +
+ + )} + {section.choices.map((choice) => choiceRow(choice.id, choice.label))} +
+ ))} +
+ )} +
+ ); +} + +const VOICE_SECTIONS: readonly ChoiceSection[] = [ + { choices: VOICE_CHOICES }, + { heading: "Character", choices: CHARACTER_CHOICES }, +]; +const LOOK_SECTIONS: readonly ChoiceSection[] = [{ choices: LOOK_CHOICES }]; + +function isPictureClip(tag: string): boolean { + return tag === "video" || tag === "img"; +} + +/** Voice, Look and Crop for the clip menu; each group renders its own trailing divider when it has items. */ +export function ClipMenuToolItems({ group, element, onClose }: ClipMenuToolItemsProps) { + const { onSetElementAttributeQuiet } = useTimelineEditContextOptional(); + const state = useClipToolState(element); + const openCropBar = useCropPresetBarStore((s) => s.open); + if (!onSetElementAttributeQuiet) return null; + + const write = (attr: string, value: string | null, label: string) => { + void onSetElementAttributeQuiet(element, attr, value, label); + onClose(); + }; + + if (group === "sound") { + if (!state.hasSound) return null; + return ( + <> + + write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset") + } + /> +
+ + ); + } + + if (!isPictureClip(state.tag)) return null; + return ( + <> + write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")} + /> + +
+ + ); +} diff --git a/packages/studio/src/player/components/clipToolAttrs.test.ts b/packages/studio/src/player/components/clipToolAttrs.test.ts new file mode 100644 index 0000000000..d54d061df8 --- /dev/null +++ b/packages/studio/src/player/components/clipToolAttrs.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { parseAudioFxChain, serializeAudioFxChain } from "@hyperframes/core/audio-fx"; +import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; +import { + activeLook, + activeVoicePreset, + chainWithVoicePreset, + hasCrop, + isDucked, + lookAttrValue, + readClipBadges, + splitVisibleBadges, + type ClipToolState, +} from "./clipToolAttrs"; + +const presetsIn = (raw: string | null) => + raw ? [...new Set(parseAudioFxChain(raw).nodes.map((n) => n.fromPreset ?? n.type))] : []; + +const handNode = serializeAudioFxChain({ + version: 1, + nodes: [{ id: "n1", type: "highpass", enabled: true, params: { frequency: 80 } }], +}); + +describe("voice presets", () => { + it("writes the chosen preset's nodes and reads it back", () => { + const raw = chainWithVoicePreset(null, "voice-clean"); + expect(presetsIn(raw)).toEqual(["voice-clean"]); + expect(activeVoicePreset(raw)).toBe("voice-clean"); + }); + + it("is single-choice: picking another menu preset replaces the first", () => { + const raw = chainWithVoicePreset(chainWithVoicePreset(null, "voice-clean"), "telephone"); + expect(presetsIn(raw)).toEqual(["telephone"]); + }); + + it("None drops the attribute when the chain held only the preset", () => { + expect(chainWithVoicePreset(chainWithVoicePreset(null, "voice-warm"), null)).toBeNull(); + }); + + it("None keeps nodes that are not from a menu preset", () => { + const withPreset = chainWithVoicePreset(handNode, "voice-broadcast"); + expect(presetsIn(withPreset)).toEqual(["highpass", "voice-broadcast"]); + expect(chainWithVoicePreset(withPreset, null)).toBe(handNode); + }); + + it("re-applying the same preset keeps one copy", () => { + const once = chainWithVoicePreset(null, "megaphone"); + const twice = chainWithVoicePreset(once, "megaphone"); + expect(parseAudioFxChain(twice ?? "").nodes).toHaveLength( + parseAudioFxChain(once ?? "").nodes.length, + ); + }); +}); + +describe("looks", () => { + it("writes the minimal preset form the runtime resolves", () => { + const raw = lookAttrValue("warm-daylight"); + expect(raw).toBe('{"preset":"warm-daylight","intensity":1}'); + expect(normalizeHfColorGrading(JSON.parse(raw ?? ""))?.preset).toBe("warm-daylight"); + expect(activeLook(raw)).toBe("warm-daylight"); + }); + + it("None removes the attribute", () => { + expect(lookAttrValue(null)).toBeNull(); + }); + + it("reads a full inspector-written grading and ignores garbage", () => { + expect(activeLook('{"preset":"mono-clean","intensity":0.4,"adjust":{"contrast":0.2}}')).toBe( + "mono-clean", + ); + expect(activeLook("{nope")).toBeNull(); + expect(activeLook('{"preset":"mono-clean","enabled":false}')).toBeNull(); + }); +}); + +describe("small readers", () => { + it("treats an inset of zero or none as no crop", () => { + expect(hasCrop("inset(10px 0px 10px 0px)")).toBe(true); + expect(hasCrop("inset(0px)")).toBe(false); + expect(hasCrop("none")).toBe(false); + expect(hasCrop(null)).toBe(false); + }); + + it("reads a carve as ducked unless it is switched off", () => { + expect(isDucked('{"sources":["vo"]}')).toBe(true); + expect(isDucked('{"enabled":false,"sources":["vo"]}')).toBe(false); + expect(isDucked(null)).toBe(false); + }); +}); + +const baseState: ClipToolState = { + tag: "video", + hasSound: true, + volume: null, + muted: false, + fxChain: null, + automation: null, + colorGrading: null, + clipPath: null, + carve: null, + link: null, +}; + +const rampAutomation = JSON.stringify({ + version: 1, + lanes: [ + { + target: "rate", + points: [ + { t: 0, v: 0.5 }, + { t: 2, v: 1 }, + ], + }, + ], +}); + +describe("readClipBadges", () => { + it("shows nothing for a plain clip at 100%", () => { + expect(readClipBadges(baseState)).toEqual([]); + }); + + it("lists every applied tool in the wireframe's order", () => { + const labels = readClipBadges({ + ...baseState, + volume: 1.8, + fxChain: chainWithVoicePreset(null, "voice-clean"), + automation: rampAutomation, + colorGrading: lookAttrValue("warm-daylight"), + clipPath: "inset(0px 20px)", + carve: "{}", + link: "talk", + }).map((b) => b.label); + expect(labels).toEqual([ + "Linked", + "Warm daylight", + "Voice: Clean", + "Ramp", + "Crop", + "Ducked", + "180%", + ]); + }); + + it("badges a muted audio clip but not muted b-roll", () => { + expect(readClipBadges({ ...baseState, tag: "audio", muted: true })[0]?.label).toBe("Muted"); + expect(readClipBadges({ ...baseState, hasSound: false, muted: true })).toEqual([]); + }); + + it("caps at two visible badges plus the link badge", () => { + const badges = readClipBadges({ + ...baseState, + link: "talk", + colorGrading: lookAttrValue("mono-clean"), + clipPath: "inset(5px)", + volume: 0.6, + }); + const { visible, hidden } = splitVisibleBadges(badges); + expect(visible.map((b) => b.label)).toEqual(["Linked", "Mono", "Crop"]); + expect(hidden.map((b) => b.label)).toEqual(["60%"]); + }); +}); diff --git a/packages/studio/src/player/components/clipToolAttrs.ts b/packages/studio/src/player/components/clipToolAttrs.ts new file mode 100644 index 0000000000..e7418b7073 --- /dev/null +++ b/packages/studio/src/player/components/clipToolAttrs.ts @@ -0,0 +1,187 @@ +/** + * The attribute each clip-menu tool writes, and what a clip's attributes say is applied. + * Pure over strings so the menu, the badges and an agent's hand edits all agree. + */ + +import { + parseAudioFxChain, + serializeAudioFxChain, + type HfAudioFxChain, +} from "@hyperframes/core/audio-fx"; +import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets"; +import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; +import { parseRateLane } from "@hyperframes/core/speed-ramp"; +import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset"; + +export interface ClipToolChoice { + id: string; + label: string; +} + +export const VOICE_CHOICES: readonly ClipToolChoice[] = [ + { id: "voice-clean", label: "Clean" }, + { id: "voice-broadcast", label: "Broadcast" }, + { id: "voice-warm", label: "Warm" }, +]; + +export const CHARACTER_CHOICES: readonly ClipToolChoice[] = [ + { id: "telephone", label: "Telephone" }, + { id: "radio-am", label: "AM Radio" }, + { id: "megaphone", label: "Megaphone" }, +]; + +export const LOOK_CHOICES: readonly ClipToolChoice[] = [ + { id: "warm-daylight", label: "Warm daylight" }, + { id: "clean-studio", label: "Clean studio" }, + { id: "vintage-wash", label: "Vintage wash" }, + { id: "mono-clean", label: "Mono" }, + { id: "deep-contrast", label: "Deep contrast" }, + { id: "home-movie-8mm", label: "Home movie" }, +]; + +const VOICE_MENU_IDS = new Set([...VOICE_CHOICES, ...CHARACTER_CHOICES].map((c) => c.id)); + +const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] }; + +function parseChainOrEmpty(raw: string | null | undefined): HfAudioFxChain { + if (!raw) return EMPTY_CHAIN; + try { + return parseAudioFxChain(raw); + } catch { + return EMPTY_CHAIN; + } +} + +function labelFor(choices: readonly ClipToolChoice[], id: string | null): string | null { + return choices.find((choice) => choice.id === id)?.label ?? null; +} + +export function activeVoicePreset(rawChain: string | null | undefined): string | null { + const ids = activeAudioFxPresetIds(parseChainOrEmpty(rawChain)); + return ids.find((id) => VOICE_MENU_IDS.has(id)) ?? null; +} + +function voicePresetLabel(id: string | null): string | null { + return labelFor(VOICE_CHOICES, id) ?? labelFor(CHARACTER_CHOICES, id); +} + +/** + * The `data-fx-chain` after choosing a voice preset, or `null` to drop the attribute. + * Single choice: the other menu presets' nodes go; carve, leveller and hand-added nodes stay. + */ +export function chainWithVoicePreset( + rawChain: string | null | undefined, + presetId: string | null, +): string | null { + const chain = parseChainOrEmpty(rawChain); + const others = chain.nodes.filter( + (node) => + !node.fromPreset || !VOICE_MENU_IDS.has(node.fromPreset) || node.fromPreset === presetId, + ); + const kept: HfAudioFxChain = { ...chain, nodes: others }; + const next = presetId ? (applyPresetToChain(kept, presetId, undefined) ?? kept) : kept; + return next.nodes.length > 0 ? serializeAudioFxChain(next) : null; +} + +function parseJson(raw: string | null | undefined): unknown { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +export function activeLook(rawGrading: string | null | undefined): string | null { + return normalizeHfColorGrading(parseJson(rawGrading))?.preset ?? null; +} + +function lookLabel(id: string | null): string | null { + return labelFor(LOOK_CHOICES, id); +} + +export function lookAttrValue(presetId: string | null): string | null { + return presetId ? JSON.stringify({ preset: presetId, intensity: 1 }) : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isDucked(rawCarve: string | null | undefined): boolean { + const carve = parseJson(rawCarve); + return isRecord(carve) && carve["enabled"] !== false; +} + +function hasRateRamp(rawAutomation: string | null | undefined): boolean { + return parseRateLane(rawAutomation) !== null; +} + +const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i; + +export function hasCrop(clipPath: string | null | undefined): boolean { + const value = clipPath?.trim() ?? ""; + return value !== "" && value !== "none" && !ZERO_INSET.test(value); +} + +export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume"; + +export interface ClipBadge { + kind: ClipBadgeKind; + label: string; +} + +/** What a clip's attributes say is applied; the element's own attributes, as read off its node. */ +export interface ClipToolState { + tag: string; + hasSound: boolean; + volume: number | null; + muted: boolean; + fxChain: string | null; + automation: string | null; + colorGrading: string | null; + clipPath: string | null; + carve: string | null; + link: string | null; +} + +function volumeBadge(state: ClipToolState): ClipBadge | null { + const isAudio = state.tag === "audio"; + if (!isAudio && !state.hasSound) return null; + if (state.muted) return { kind: "volume", label: "Muted" }; + const volume = state.volume ?? 1; + if (Math.abs(volume - 1) < 0.005) return null; + return { kind: "volume", label: `${Math.round(volume * 100)}%` }; +} + +/** Badges in the wireframe's order: look, voice, ramp, crop, ducked, volume. The link badge leads. */ +export function readClipBadges(state: ClipToolState): ClipBadge[] { + const look = lookLabel(activeLook(state.colorGrading)); + const voice = voicePresetLabel(activeVoicePreset(state.fxChain)); + const volume = volumeBadge(state); + const badges: Array = [ + state.link ? { kind: "link", label: "Linked" } : null, + look ? { kind: "look", label: look } : null, + voice ? { kind: "voice", label: `Voice: ${voice}` } : null, + hasRateRamp(state.automation) ? { kind: "ramp", label: "Ramp" } : null, + hasCrop(state.clipPath) ? { kind: "crop", label: "Crop" } : null, + isDucked(state.carve) ? { kind: "ducked", label: "Ducked" } : null, + volume, + ]; + return badges.filter((badge): badge is ClipBadge => badge !== null); +} + +const MAX_VISIBLE_BADGES = 2; + +/** The link badge is always shown; the rest cap at two, then `+N`. */ +export function splitVisibleBadges(badges: readonly ClipBadge[]): { + visible: ClipBadge[]; + hidden: ClipBadge[]; +} { + const link = badges.filter((badge) => badge.kind === "link"); + const rest = badges.filter((badge) => badge.kind !== "link"); + return { + visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)], + hidden: rest.slice(MAX_VISIBLE_BADGES), + }; +} diff --git a/packages/studio/src/player/components/useClipToolState.ts b/packages/studio/src/player/components/useClipToolState.ts new file mode 100644 index 0000000000..364ce9585e --- /dev/null +++ b/packages/studio/src/player/components/useClipToolState.ts @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; +import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation"; +import { HF_AUDIO_CARVE_ATTR } from "@hyperframes/core/audio-carve"; +import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx"; +import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading"; +import type { TimelineElement } from "../store/playerStore"; +import { useLivePreviewIframe } from "../store/previewIframeStore"; +import { useStudioShellContextOptional } from "../../contexts/StudioContext"; +import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers"; +import { committedClipPath } from "../../components/editor/cropPresetStore"; +import { isAudibleVideoNode } from "../lib/timelineElementHelpers"; +import type { ClipToolState } from "./clipToolAttrs"; + +const LINK_ATTR = "data-link"; + +const WATCHED_ATTRS = [ + HF_AUDIO_FX_ATTR, + HF_AUDIO_AUTOMATION_ATTR, + HF_AUDIO_CARVE_ATTR, + HF_COLOR_GRADING_ATTR, + LINK_ATTR, + "data-volume", + "data-has-audio", + "muted", + "style", +]; + +function readVolume(node: Element): number | null { + const volume = Number.parseFloat(node.getAttribute("data-volume") ?? ""); + return Number.isFinite(volume) ? volume : null; +} + +function toolStateFromNode(node: Element): ClipToolState { + const tag = node.tagName.toLowerCase(); + return { + tag, + hasSound: tag === "audio" || isAudibleVideoNode(node), + volume: readVolume(node), + muted: node.hasAttribute("muted"), + fxChain: node.getAttribute(HF_AUDIO_FX_ATTR), + automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR), + colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR), + clipPath: committedClipPath(node), + carve: node.getAttribute(HF_AUDIO_CARVE_ATTR), + link: node.getAttribute(LINK_ATTR), + }; +} + +function toolStateFromElement(el: TimelineElement): ClipToolState { + const tag = el.tag.trim().toLowerCase(); + return { + tag, + hasSound: tag === "audio" || el.hasAudio === true, + volume: el.volume ?? null, + muted: el.muted === true, + fxChain: el.fxChain ?? null, + automation: el.automation ?? null, + colorGrading: null, + clipPath: null, + carve: null, + link: null, + }; +} + +/** Inline style changes every frame under a tween; only a changed reading re-renders. */ +function keepIfUnchanged(prev: ClipToolState, next: ClipToolState): ClipToolState { + return JSON.stringify(prev) === JSON.stringify(next) ? prev : next; +} + +/** A clip's tool attributes as the live preview node holds them, kept current as they change. */ +export function useClipToolState(el: TimelineElement): ClipToolState { + const iframe = useLivePreviewIframe(); + const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null; + const [state, setState] = useState(() => toolStateFromElement(el)); + + useEffect(() => { + const node = findTimelineElementInIframe(iframe, el, activeCompPath); + if (!node) { + setState(toolStateFromElement(el)); + return; + } + const refresh = () => setState((prev) => keepIfUnchanged(prev, toolStateFromNode(node))); + refresh(); + const view = node.ownerDocument.defaultView; + if (!view) return; + const observer = new view.MutationObserver(refresh); + observer.observe(node, { attributes: true, attributeFilter: WATCHED_ATTRS }); + return () => observer.disconnect(); + }, [iframe, el, activeCompPath]); + + return state; +} From 8dc4c95b15d44bf4811d97ee4623dcdca88efc09 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 17:33:34 -0700 Subject: [PATCH 03/10] fix(studio): drop the unstyled badge classes Co-Authored-By: Claude Opus 5.5 --- packages/studio/src/player/components/ClipBadges.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/ClipBadges.tsx b/packages/studio/src/player/components/ClipBadges.tsx index 5cffb0cd9f..9792ae8039 100644 --- a/packages/studio/src/player/components/ClipBadges.tsx +++ b/packages/studio/src/player/components/ClipBadges.tsx @@ -36,7 +36,7 @@ function BadgeContent({ badge }: { badge: ClipBadge }) { } const BADGE_CLASS = - "timeline-clip__badge 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"; + "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 }) { @@ -45,7 +45,7 @@ export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement if (visible.length === 0) return null; return ( {visible.map((badge) => ( From 8b5da6fb076d8465a832936c534d66140d8f7e8f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 29 Sep 2026 22:56:56 -0700 Subject: [PATCH 04/10] 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 --- .../src/components/editor/CropPresetBar.tsx | 69 ++++++ .../editor/DomEditCropHandles.test.tsx | 44 ++++ .../components/editor/DomEditCropHandles.tsx | 218 ++++++++++-------- .../src/components/editor/cropPresetStore.ts | 20 +- .../src/components/editor/cropPresets.test.ts | 41 ++++ .../src/components/editor/cropPresets.ts | 24 ++ .../components/clipMenuToolItems.test.tsx | 2 +- .../player/components/clipMenuToolItems.tsx | 2 +- 8 files changed, 319 insertions(+), 101 deletions(-) create mode 100644 packages/studio/src/components/editor/CropPresetBar.tsx create mode 100644 packages/studio/src/components/editor/cropPresets.test.ts create mode 100644 packages/studio/src/components/editor/cropPresets.ts diff --git a/packages/studio/src/components/editor/CropPresetBar.tsx b/packages/studio/src/components/editor/CropPresetBar.tsx new file mode 100644 index 0000000000..e830d40ab2 --- /dev/null +++ b/packages/studio/src/components/editor/CropPresetBar.tsx @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import type { ClipPathInsetSides } from "./clipPathHelpers"; +import { CROP_ASPECT_PRESETS, centredCropInsets } from "./cropPresets"; + +interface CropPresetBarProps { + left: number; + top: number; + elementWidth: number; + elementHeight: number; + /** Centred insets for a ratio, or `null` to remove the crop. */ + onApply: (insets: ClipPathInsetSides | null) => void; + onDone: () => void; +} + +const CHIP = + "rounded-sm border border-white/20 px-1.5 py-0.5 text-[10px] text-white/85 hover:bg-white/10"; + +/** Free ยท aspect presets ยท Reset ยท Done, floating above the element being cropped. */ +export function CropPresetBar({ + left, + top, + elementWidth, + elementHeight, + onApply, + onDone, +}: CropPresetBarProps) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onDone(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onDone]); + + return ( +
event.stopPropagation()} + > + + {CROP_ASPECT_PRESETS.map((preset) => ( + + ))} + + +
+ ); +} diff --git a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx index e4c191f742..ce934ebd96 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.test.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.test.tsx @@ -6,6 +6,7 @@ import type { DomEditSelection } from "./domEditing"; import type { OverlayRect } from "./domEditOverlayGeometry"; import { DomEditCropHandles } from "./DomEditCropHandles"; import { isElementCropLifted } from "./domEditOverlayCrop"; +import { useCropPresetBarStore } from "./cropPresetStore"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -272,3 +273,46 @@ describe("DomEditCropHandles leaves the corner resize dots free", () => { expect(handles().map((h) => h.label)).toEqual(["Crop top", "Crop bottom"]); }); }); + +describe("DomEditCropHandles preset bar", () => { + const click = (label: string) => { + const button = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === label, + ); + act(() => button?.click()); + }; + + afterEach(() => useCropPresetBarStore.getState().close()); + + it("shows only for the clip the menu opened it for", () => { + const a = makeEl("a", ""); + useCropPresetBarStore.getState().open({ id: "other" }); + render(a); + expect(document.querySelector("[data-dom-edit-crop-bar]")).toBeNull(); + }); + + it("commits a centred 1:1 crop, then Reset removes the clip-path", async () => { + const a = makeEl("a", ""); + const commits: string[] = []; + useCropPresetBarStore.getState().open({ id: "a" }); + const { root } = render(a, (_property, value) => { + commits.push(value); + }); + click("1:1"); + await act(async () => {}); + expect(commits[0]).toBe("inset(0px 50px 0px 50px)"); + click("Reset"); + await act(async () => {}); + expect(commits[1]).toBe(""); + act(() => root.unmount()); + expect(a.style.getPropertyValue("clip-path")).toBe(""); + }); + + it("Done closes the bar", () => { + const a = makeEl("a", ""); + useCropPresetBarStore.getState().open({ id: "a" }); + render(a); + click("Done"); + expect(useCropPresetBarStore.getState().openFor).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx index 67172d83e6..d1ac3cbfbf 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -15,6 +15,8 @@ import { } from "./domEditOverlayCrop"; import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers"; import { readCropFollowingResize } from "./cropResize"; +import { isCropBarTarget, useCropPresetBarStore } from "./cropPresetStore"; +import { CropPresetBar } from "./CropPresetBar"; interface CropGestureState { edge: CropEdge | "move"; @@ -119,6 +121,9 @@ export function DomEditCropHandles({ return { element, croppable: parsed !== null, insets: { top, right, bottom, left } }; }; const [state, setState] = useState(() => cropStateFor(selection.element)); + const presetBarTarget = useCropPresetBarStore((s) => s.openFor); + const closePresetBar = useCropPresetBarStore((s) => s.close); + const showPresetBar = isCropBarTarget(selection.element, presetBarTarget); // Re-sync when the selection targets a different element (reselect, or an // undo/redo that re-keys the node). @@ -223,10 +228,21 @@ export function DomEditCropHandles({ // the lift keeps it hidden while selected. A drag that ends where it started saves nothing. const value = buildInsetClipPathSides(gesture.insets, gesture.radius); if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return; + commitClipPath(value); + }; + + const commitClipPath = (value: string) => { const commit = onStyleCommit?.("clip-path", value); void Promise.resolve(commit).catch(() => undefined); }; + const applyPresetInsets = (insets: ClipPathInsetSides | null) => { + const next = insets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + setState((prev) => ({ ...prev, insets: next })); + const radius = readCropFollowingResize(selection.element)?.radius ?? 0; + commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius)); + }; + const cancelCropGesture = (event: ReactPointerEvent) => { const gesture = endCropGesture(event); if (!gesture) return; @@ -239,119 +255,131 @@ export function DomEditCropHandles({ if (!state.croppable) return null; return ( -
- {/* Dim the cropped-away area whenever the element is cropped and selected, - so the hidden content is visible (ghosted) without dragging. Clipped to - the element's own (rotated) box. */} - {hasCrop && ( -
-
-
+ <> + {showPresetBar && onStyleCommit && ( + )} - {/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid - shown while dragging. */}