From 157497dcf192c85e765b35a60f212a9fc0378d94 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 19 Aug 2026 00:42:33 -0400 Subject: [PATCH 1/2] feat(studio): fade handles on the corners of an audio clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fading a clip meant expanding its automation lane, drawing a selection box over the stretch you wanted and picking a shape from a menu. Every other editor gives you a grip on the clip's corner; this adds one. Drag it in to set the fade, drag it back to the corner to remove it, and double-click it to step the shape through linear, smooth and sharp. The clip draws the resulting curve, sampled through the same interpolator that plays it back, so the wedge on screen is the fade you will hear. A fade is not stored as a fade: it is the leading and trailing segment of the clip's ordinary volume envelope, which is what makes the grip two-way — draw one, open the lane, and the breakpoints are there. Reading is deliberately conservative (a head segment counts only if it starts at the clip's first frame, at silence, and rises to full level) and writing rewrites only the head and tail, so a hand-drawn envelope survives being faded. Audio clips only. A visual clip fades on opacity, which lives in the composition's animation rather than in this envelope. --- .../src/player/components/TimelineClip.tsx | 17 ++ .../player/components/TimelineClipFades.tsx | 203 ++++++++++++++++ .../src/player/components/TimelineLanes.tsx | 7 + .../player/components/clipFadeBinding.test.ts | 149 ++++++++++++ .../src/player/components/clipFadeBinding.ts | 88 +++++++ .../src/player/components/clipFades.test.ts | 227 ++++++++++++++++++ .../studio/src/player/components/clipFades.ts | 206 ++++++++++++++++ 7 files changed, 897 insertions(+) create mode 100644 packages/studio/src/player/components/TimelineClipFades.tsx create mode 100644 packages/studio/src/player/components/clipFadeBinding.test.ts create mode 100644 packages/studio/src/player/components/clipFadeBinding.ts create mode 100644 packages/studio/src/player/components/clipFades.test.ts create mode 100644 packages/studio/src/player/components/clipFades.ts diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index b8279c3823..d512f4c93a 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -4,6 +4,7 @@ import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from " import type { TimelineEditCapabilities } from "./timelineEditing"; import { isAudioTimelineElement } from "../../utils/timelineInspector"; import { timelineClipFocusId } from "./timelineNavigationIdentity"; +import { TimelineClipFades, type TimelineClipFadesProps } from "./TimelineClipFades"; interface TimelineClipProps { el: TimelineElement; @@ -27,6 +28,12 @@ interface TimelineClipProps { onClick: (e: React.MouseEvent) => void; onDoubleClick: (e: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent) => void; + /** + * Fade grips for this clip, when it can carry a fade at all. Geometry is not + * part of it — the clip already knows its own width and length, and passing + * them in would be two owners for one number. + */ + fades?: Omit; children?: ReactNode; } @@ -53,6 +60,7 @@ export const TimelineClip = memo(function TimelineClip({ onClick, onDoubleClick, onContextMenu, + fades, children, }: TimelineClipProps) { const leftPx = el.start * pps; @@ -181,6 +189,15 @@ export const TimelineClip = memo(function TimelineClip({ /> )} + {fades && ( + + )} {showLabel && {displayLabel}} {showDefaultText && ( diff --git a/packages/studio/src/player/components/TimelineClipFades.tsx b/packages/studio/src/player/components/TimelineClipFades.tsx new file mode 100644 index 0000000000..5b269a611c --- /dev/null +++ b/packages/studio/src/player/components/TimelineClipFades.tsx @@ -0,0 +1,203 @@ +import { useCallback, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import { + clampClipFades, + fadeWedgePath, + MIN_FADE_SECONDS, + type ClipFades, + type FadeCurve, +} from "./clipFades"; + +/** + * The fade grips on a clip's top corners, and the wedges they draw. + * + * The gesture is deliberately local rather than folded into the timeline's drag + * coordinator: a fade has no lane to change, nothing to snap to and nothing to + * collide with, so all the machinery that gesture owns would sit unused. What + * it does need — a live preview on every move and one persisted write on + * release — the automation binding already provides. + */ + +export interface TimelineClipFadesProps { + /** Committed fades, as read back out of the clip's own envelope. */ + fades: ClipFades; + /** Clip length in seconds; the fades share it and cannot outrun it. */ + duration: number; + pixelsPerSecond: number; + width: number; + curve: FadeCurve; + /** Grips are hidden until the clip is worth aiming at. */ + showGrips: boolean; + /** True when this is not the selected clip, which is what makes it editable. */ + readOnly: boolean; + /** Double-clicking a grip steps the fade through its curve shapes. */ + onCycleCurve(): void; + /** Live during the drag: preview only, never persisted. */ + onPreview(next: ClipFades): void; + /** Once, on release. */ + onCommit(next: ClipFades): void; +} + +/** Side of the grip square, in px. Matches the trim handle's visual weight. */ +const GRIP = 9; + +/** + * Vertical units the wedge is drawn in. A clip's height is set by the row, and + * sometimes by `bottom` rather than a number, so the overlay draws in its own + * space and lets the SVG stretch it — the horizontal axis stays in real pixels, + * which is the axis a fade's length is read off. + */ +const VIEW_HEIGHT = 100; + +export function TimelineClipFades({ + fades, + duration, + pixelsPerSecond, + width, + curve, + showGrips, + readOnly, + onPreview, + onCommit, + onCycleCurve, +}: TimelineClipFadesProps) { + // While dragging, the drawn fades come from here: the committed value only + // catches up once the write lands, and the wedge has to track the pointer. + const [draft, setDraft] = useState(null); + const dragRef = useRef<{ edge: "in" | "out"; originX: number; from: ClipFades } | null>(null); + const shown = draft ?? fades; + + const onGripDown = useCallback( + (edge: "in" | "out", event: ReactPointerEvent) => { + if (event.button !== 0) return; + // The grip sits on top of the trim handle and inside the clip body; both + // would otherwise start their own gesture from this same press. NOT + // preventDefault: that suppresses the compatibility click events, and the + // double-click that cycles the curve is one of them. + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { edge, originX: event.clientX, from: fades }; + }, + [fades], + ); + + const resolveDrag = useCallback( + (clientX: number): ClipFades | null => { + const drag = dragRef.current; + if (!drag || pixelsPerSecond <= 0) return null; + // Both grips are dragged INTO the clip, so the out grip reads the + // opposite sign — its fade grows as the pointer travels left. + const travel = (clientX - drag.originX) / pixelsPerSecond; + const delta = drag.edge === "in" ? travel : -travel; + const next = + drag.edge === "in" + ? { ...drag.from, fadeIn: Math.max(0, drag.from.fadeIn + delta) } + : { ...drag.from, fadeOut: Math.max(0, drag.from.fadeOut + delta) }; + return clampClipFades(next, duration); + }, + [duration, pixelsPerSecond], + ); + + const onGripMove = useCallback( + (event: ReactPointerEvent) => { + const next = resolveDrag(event.clientX); + if (!next) return; + setDraft(next); + onPreview(next); + }, + [onPreview, resolveDrag], + ); + + const onGripUp = useCallback( + (event: ReactPointerEvent) => { + const next = resolveDrag(event.clientX); + const from = dragRef.current?.from; + dragRef.current = null; + setDraft(null); + // A press that moved nothing — the first half of a double-click, or a + // mis-aimed click — must not write the same fade back to the file. + if (next && from && (next.fadeIn !== from.fadeIn || next.fadeOut !== from.fadeOut)) { + onCommit(next); + } + }, + [onCommit, resolveDrag], + ); + + const gripFor = (edge: "in" | "out") => { + const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut; + const span = Math.min(seconds * pixelsPerSecond, width); + // Parked on the corner when there is no fade, which is where you grab to + // start one; otherwise it rides the top of the wedge it drew. + const x = edge === "in" ? span : width - span; + return ( +
= MIN_FADE_SECONDS ? `${seconds.toFixed(2)} seconds` : "No fade"} + data-clip-fade-grip={edge} + onPointerDown={(event) => onGripDown(edge, event)} + onPointerMove={onGripMove} + onPointerUp={onGripUp} + onPointerCancel={onGripUp} + onDoubleClick={(event) => { + event.stopPropagation(); + if (seconds > 0) onCycleCurve(); + }} + title={`Fade ${edge}: drag to set its length, double-click to change its ${curve} curve`} + style={{ + position: "absolute", + left: x - GRIP / 2, + top: 1, + width: GRIP, + height: GRIP, + borderRadius: 2, + background: "rgba(255,255,255,0.9)", + boxShadow: "0 0 0 1px rgba(0,0,0,0.5)", + cursor: "ew-resize", + zIndex: 6, + }} + /> + ); + }; + + return ( + <> + + {showGrips && !readOnly && (["in", "out"] as const).map(gripFor)} + + ); +} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 752359f6ba..9e839a29c4 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -27,6 +27,7 @@ import type { TimelineLanesProps } from "./timelineLaneProps"; import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; import { createClipGestureHandlers } from "./timelineClipGestureHandlers"; +import { resolveClipFadeBinding } from "./clipFadeBinding"; import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { isTimelineClipActive } from "./useTimelineActiveClips"; @@ -389,6 +390,11 @@ export function TimelineLanes({ const passengerOffsetPx = isPassenger ? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview) : 0; + // Fades ride the clip's own volume envelope; the binding is + // read-only until the clip is selected, exactly as its lanes are. + const fadeBinding = resolveClipFadeBinding(el, (target) => + automationLanes.bind(target, isSelected), + ); const clipGestures = createClipGestureHandlers( el, elementKey, @@ -436,6 +442,7 @@ export function TimelineLanes({ } onHoverStart={() => setHoveredClip(clipKey)} onHoverEnd={() => setHoveredClip(null)} + fades={fadeBinding} onResizeStart={clipGestures.onResizeStart} onPointerDown={clipGestures.onPointerDown} onClick={clipGestures.onClick} diff --git a/packages/studio/src/player/components/clipFadeBinding.test.ts b/packages/studio/src/player/components/clipFadeBinding.test.ts new file mode 100644 index 0000000000..9d34b4309e --- /dev/null +++ b/packages/studio/src/player/components/clipFadeBinding.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import type { HfAutomation } from "@hyperframes/core/audio-automation"; +import type { TimelineElement } from "../store/playerStore"; +import type { AutomationLaneBinding } from "./useAutomationLanes"; +import { nextFadeCurve, readFadeCurve, resolveClipFadeBinding } from "./clipFadeBinding"; + +function el(over: Partial = {}): TimelineElement { + return { + id: "music", + key: "music", + tag: "audio", + src: "bgm.m4a", + start: 0, + duration: 10, + track: 2, + domId: "music", + ...over, + }; +} + +function binder(automation: HfAutomation, readOnly = false) { + const onPreview = vi.fn(); + const onCommit = vi.fn(); + const bind = (): AutomationLaneBinding => + ({ + automation, + lanes: automation.lanes, + chain: null, + readOnly, + onPreview, + onCommit, + }) as unknown as AutomationLaneBinding; + return { bind, onPreview, onCommit }; +} + +const EMPTY: HfAutomation = { version: 1, lanes: [] }; +const volumeOf = (call: unknown) => + (call as HfAutomation).lanes.find((l) => l.target === "volume")?.points.map((p) => [p.t, p.v]); + +describe("resolveClipFadeBinding", () => { + it("offers no fades on a clip with no audio to fade", () => { + const { bind } = binder(EMPTY); + expect(resolveClipFadeBinding(el({ tag: "div", src: undefined }), bind)).toBeUndefined(); + }); + + it("reads the clip's existing envelope as its fades", () => { + const { bind } = binder({ + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 0 }, + { t: 2, v: 1 }, + ], + }, + ], + }); + expect(resolveClipFadeBinding(el(), bind)?.fades).toEqual({ fadeIn: 2, fadeOut: 0 }); + }); + + it("previews without persisting, and commits once", () => { + const { bind, onPreview, onCommit } = binder(EMPTY); + const fade = resolveClipFadeBinding(el(), bind)!; + + fade.onPreview({ fadeIn: 1, fadeOut: 0 }); + expect(onCommit).not.toHaveBeenCalled(); + expect(volumeOf(onPreview.mock.calls[0]![0])).toEqual([ + [0, 0], + [1, 1], + ]); + + fade.onCommit({ fadeIn: 1, fadeOut: 2 }); + expect(volumeOf(onCommit.mock.calls[0]![0])).toEqual([ + [0, 0], + [1, 1], + [8, 1], + [10, 0], + ]); + }); + + it("drops the lane entirely once the last fade is dragged away", () => { + const { bind, onCommit } = binder({ + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 0 }, + { t: 2, v: 1 }, + ], + }, + ], + }); + resolveClipFadeBinding(el(), bind)!.onCommit({ fadeIn: 0, fadeOut: 0 }); + expect((onCommit.mock.calls[0]![0] as HfAutomation).lanes).toEqual([]); + }); + + it("writes nothing through a read-only binding", () => { + const { bind, onPreview, onCommit } = binder(EMPTY, true); + const fade = resolveClipFadeBinding(el(), bind)!; + expect(fade.readOnly).toBe(true); + fade.onPreview({ fadeIn: 1, fadeOut: 0 }); + fade.onCommit({ fadeIn: 1, fadeOut: 0 }); + expect(onPreview).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("keeps the fade lengths when only the curve is stepped", () => { + const { bind, onCommit } = binder({ + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 0 }, + { t: 2, v: 1 }, + ], + }, + ], + }); + const fade = resolveClipFadeBinding(el(), bind)!; + expect(fade.curve).toBe("linear"); + fade.onCycleCurve(); + const points = (onCommit.mock.calls[0]![0] as HfAutomation).lanes[0]!.points; + expect(points.map((p) => [p.t, p.v])).toEqual([ + [0, 0], + [2, 1], + ]); + expect(points[0]!.curve).toBeCloseTo(0.35, 6); + }); +}); + +describe("fade curves", () => { + it("names the curvature a fade was written with", () => { + expect(readFadeCurve(undefined)).toBe("linear"); + expect(readFadeCurve(0)).toBe("linear"); + expect(readFadeCurve(0.35)).toBe("smooth"); + expect(readFadeCurve(-0.45)).toBe("sharp"); + // Something hand-authored that matches no shape reads as the plain one. + expect(readFadeCurve(0.9)).toBe("linear"); + }); + + it("cycles through every shape and back", () => { + expect(nextFadeCurve("linear")).toBe("smooth"); + expect(nextFadeCurve("smooth")).toBe("sharp"); + expect(nextFadeCurve("sharp")).toBe("linear"); + }); +}); diff --git a/packages/studio/src/player/components/clipFadeBinding.ts b/packages/studio/src/player/components/clipFadeBinding.ts new file mode 100644 index 0000000000..aa75f7e2a5 --- /dev/null +++ b/packages/studio/src/player/components/clipFadeBinding.ts @@ -0,0 +1,88 @@ +import { laneFor, withLane } from "./automationLaneGeometry"; +import { + FADE_CURVES, + readClipFades, + writeClipFades, + type ClipFades, + type FadeCurve, +} from "./clipFades"; +import type { AutomationLaneBinding } from "./useAutomationLanes"; +import type { TimelineElement } from "../store/playerStore"; +import { isAudioTimelineElement } from "../../utils/timelineInspector"; + +/** + * Wiring the fade grips to the clip's volume envelope. + * + * Fades ride the automation the lane UI already edits, so this is a projection + * of it, not a second store: read the volume lane's head and tail as fades, + * write them back through the same binding a dragged breakpoint uses. That is + * what makes the two agree — draw a fade with the grip, open the lane, and the + * points are there. + * + * Audio only for now. A visual clip fades on opacity, which lives in the + * composition's animation rather than in this envelope. + */ + +const VOLUME = "volume"; + +export interface ClipFadeBinding { + fades: ClipFades; + curve: FadeCurve; + readOnly: boolean; + onPreview(next: ClipFades): void; + onCommit(next: ClipFades): void; + onCycleCurve(): void; +} + +/** Which named curve an envelope's fade was written with. */ +export function readFadeCurve(curvature: number | undefined): FadeCurve { + if (!curvature) return "linear"; + const named = (Object.keys(FADE_CURVES) as FadeCurve[]).find( + (key) => Math.abs(FADE_CURVES[key] - curvature) < 0.05, + ); + return named ?? "linear"; +} + +/** The next shape a double-click on the grip moves to. */ +export function nextFadeCurve(curve: FadeCurve): FadeCurve { + const order = Object.keys(FADE_CURVES) as FadeCurve[]; + return order[(order.indexOf(curve) + 1) % order.length]!; +} + +/** + * The fade binding for a clip, or undefined when fades do not apply to it. + * + * `bind` is called for every clip the timeline draws, so it must stay cheap: + * everything here is a read off already-parsed automation plus two closures. + */ +export function resolveClipFadeBinding( + element: TimelineElement, + bind: (element: TimelineElement) => AutomationLaneBinding, +): ClipFadeBinding | undefined { + if (!isAudioTimelineElement(element)) return undefined; + const binding = bind(element); + const lane = laneFor(binding.automation, VOLUME); + const fades = readClipFades(lane.points, element.duration); + const curve = readFadeCurve(lane.points[0]?.curve); + + const apply = (next: ClipFades, shape: FadeCurve, persist: boolean) => { + if (binding.readOnly) return; + const points = writeClipFades(lane.points, element.duration, next, shape); + const automation = withLane(binding.automation, { target: VOLUME, points }); + // An envelope with no points left is no envelope: drop the lane so the clip + // goes back to carrying no automation attribute at all. + const lanes = automation.lanes.filter((l) => l.points.length > 0); + const value = { ...automation, lanes }; + if (persist) binding.onCommit(value); + else binding.onPreview(value); + }; + + return { + fades, + curve, + readOnly: binding.readOnly, + onPreview: (next) => apply(next, curve, false), + onCommit: (next) => apply(next, curve, true), + onCycleCurve: () => apply(fades, nextFadeCurve(curve), true), + }; +} diff --git a/packages/studio/src/player/components/clipFades.test.ts b/packages/studio/src/player/components/clipFades.test.ts new file mode 100644 index 0000000000..63c5f49c5b --- /dev/null +++ b/packages/studio/src/player/components/clipFades.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import type { HfAutomationPoint } from "@hyperframes/core/audio-automation"; +import { + clampClipFades, + fadeWedgePath, + MIN_FADE_SECONDS, + NO_FADES, + readClipFades, + writeClipFades, +} from "./clipFades"; + +const DURATION = 8; +const at = (points: HfAutomationPoint[]) => points.map((p) => [p.t, p.v]); + +describe("readClipFades", () => { + it("reads a head ramp from silence as a fade in", () => { + const points: HfAutomationPoint[] = [ + { t: 0, v: 0 }, + { t: 1.5, v: 1 }, + ]; + expect(readClipFades(points, DURATION)).toEqual({ fadeIn: 1.5, fadeOut: 0 }); + }); + + it("reads a tail ramp to silence as a fade out", () => { + const points: HfAutomationPoint[] = [ + { t: 6, v: 1 }, + { t: 8, v: 0 }, + ]; + expect(readClipFades(points, DURATION)).toEqual({ fadeIn: 0, fadeOut: 2 }); + }); + + it("reads both ends of a four-point envelope", () => { + const points: HfAutomationPoint[] = [ + { t: 0, v: 0 }, + { t: 1, v: 1 }, + { t: 6.5, v: 1 }, + { t: 8, v: 0 }, + ]; + expect(readClipFades(points, DURATION)).toEqual({ fadeIn: 1, fadeOut: 1.5 }); + }); + + it("claims nothing from an envelope that is not a fade", () => { + // A duck in the middle: starts and ends at full level. + const duck: HfAutomationPoint[] = [ + { t: 0, v: 1 }, + { t: 3, v: 0.3 }, + { t: 5, v: 0.3 }, + { t: 8, v: 1 }, + ]; + expect(readClipFades(duck, DURATION)).toEqual(NO_FADES); + // A head ramp that does not start at silence is somebody's automation. + expect( + readClipFades( + [ + { t: 0, v: 0.4 }, + { t: 2, v: 1 }, + ], + DURATION, + ), + ).toEqual(NO_FADES); + // A head ramp that does not start at the clip edge, likewise. + expect( + readClipFades( + [ + { t: 1, v: 0 }, + { t: 2, v: 1 }, + ], + DURATION, + ), + ).toEqual(NO_FADES); + }); + + it("ignores a fade shorter than the handle can write", () => { + const points: HfAutomationPoint[] = [ + { t: 0, v: 0 }, + { t: MIN_FADE_SECONDS / 2, v: 1 }, + ]; + expect(readClipFades(points, DURATION).fadeIn).toBe(0); + }); + + it("still reads a fade whose envelope outlived a shortening trim", () => { + // A 2s fade-out on an 8s clip, then trimmed to 7: the tail points still say + // 8, but the part of the fade still inside the clip is 1s of it. + const points: HfAutomationPoint[] = [ + { t: 6, v: 1 }, + { t: 8, v: 0 }, + ]; + expect(readClipFades(points, 7).fadeOut).toBeCloseTo(1, 6); + }); + + it("reads an empty or single-point envelope as no fades", () => { + expect(readClipFades([], DURATION)).toEqual(NO_FADES); + expect(readClipFades([{ t: 0, v: 1 }], DURATION)).toEqual(NO_FADES); + }); +}); + +describe("clampClipFades", () => { + it("keeps two fades from overlapping by sharing the clip between them", () => { + const clamped = clampClipFades({ fadeIn: 6, fadeOut: 6 }, DURATION); + expect(clamped.fadeIn + clamped.fadeOut).toBeCloseTo(DURATION, 6); + expect(clamped.fadeIn).toBeCloseTo(4, 6); + }); + + it("drops a fade dragged back below the minimum", () => { + expect(clampClipFades({ fadeIn: 0.01, fadeOut: 0 }, DURATION).fadeIn).toBe(0); + }); + + it("never exceeds the clip", () => { + expect(clampClipFades({ fadeIn: 99, fadeOut: 0 }, DURATION).fadeIn).toBe(DURATION); + }); +}); + +describe("fadeWedgePath", () => { + const WIDTH = 200; + const HEIGHT = 100; + const wedge = ( + edge: "in" | "out", + curve: Parameters[0]["curve"] = "linear", + ) => + fadeWedgePath({ edge, seconds: 2, curve, pixelsPerSecond: 25, width: WIDTH, height: HEIGHT }); + /** Every [x, y] the path visits, in order. */ + const points = (d: string) => + [...d.matchAll(/[ML] (-?[\d.]+) (-?[\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]); + + it("draws a fade in rising out of the clip's start", () => { + const path = points(wedge("in")); + expect(path[0]).toEqual([0, 0]); // the corner it shades from + expect(path[1]).toEqual([0, HEIGHT]); // silent, at the very start + expect(path[2]).toEqual([50, 0]); // full level, 2s in at 25px/s + }); + + it("draws a fade out falling INTO the clip's end, not out of it", () => { + const path = points(wedge("out")); + expect(path[0]).toEqual([WIDTH, 0]); + expect(path[1]).toEqual([WIDTH - 50, 0]); // still at full level, 2s from the end + expect(path[2]).toEqual([WIDTH, HEIGHT]); // silent, exactly on the end + }); + + it("samples a curved fade instead of drawing a straight line", () => { + expect(points(wedge("in", "smooth")).length).toBeGreaterThan(5); + // The curve leaves silence slowly, so it sits BELOW the straight line at the + // halfway point (larger y is quieter). + const mid = points(wedge("in", "smooth")).find(([x]) => Math.abs(x - 25) < 2); + expect(mid?.[1]).toBeGreaterThan(HEIGHT / 2); + }); + + it("draws nothing for a fade of no length", () => { + expect( + fadeWedgePath({ + edge: "in", + seconds: 0, + curve: "linear", + pixelsPerSecond: 25, + width: WIDTH, + height: HEIGHT, + }), + ).toBe(""); + }); +}); + +describe("writeClipFades", () => { + it("writes a head ramp that reads back as the same fade", () => { + const points = writeClipFades([], DURATION, { fadeIn: 1.5, fadeOut: 0 }); + expect(at(points)).toEqual([ + [0, 0], + [1.5, 1], + ]); + expect(readClipFades(points, DURATION)).toEqual({ fadeIn: 1.5, fadeOut: 0 }); + }); + + it("writes both ends in time order", () => { + const points = writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 2 }); + expect(at(points)).toEqual([ + [0, 0], + [1, 1], + [6, 1], + [8, 0], + ]); + expect(readClipFades(points, DURATION)).toEqual({ fadeIn: 1, fadeOut: 2 }); + }); + + it("carries the author's own points across a fade edit", () => { + const authored: HfAutomationPoint[] = [ + { t: 3, v: 0.4 }, + { t: 5, v: 0.9 }, + ]; + const points = writeClipFades(authored, DURATION, { fadeIn: 1, fadeOut: 1 }); + expect(at(points)).toEqual([ + [0, 0], + [1, 1], + [3, 0.4], + [5, 0.9], + [7, 1], + [8, 0], + ]); + }); + + it("replaces an existing fade rather than stacking a second one on it", () => { + const first = writeClipFades([], DURATION, { fadeIn: 2, fadeOut: 0 }); + const second = writeClipFades(first, DURATION, { fadeIn: 0.5, fadeOut: 0 }); + expect(at(second)).toEqual([ + [0, 0], + [0.5, 1], + ]); + }); + + it("removes the fade — and the whole envelope — when dragged back to zero", () => { + const faded = writeClipFades([], DURATION, { fadeIn: 2, fadeOut: 1 }); + expect(writeClipFades(faded, DURATION, NO_FADES)).toEqual([]); + }); + + it("leaves the author's points behind when the fades are removed", () => { + const authored: HfAutomationPoint[] = [{ t: 4, v: 0.5 }]; + const faded = writeClipFades(authored, DURATION, { fadeIn: 1, fadeOut: 1 }); + expect(at(writeClipFades(faded, DURATION, NO_FADES))).toEqual([[4, 0.5]]); + }); + + it("curves the segment leaving the fade's silent end", () => { + const smooth = writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 1 }, "smooth"); + expect(smooth[0]?.curve).toBeCloseTo(0.35, 6); + // The fade-out curves out of its full-level point, into silence. + expect(smooth[2]?.curve).toBeCloseTo(0.35, 6); + expect( + writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 0 }, "linear")[0]?.curve, + ).toBeUndefined(); + }); +}); diff --git a/packages/studio/src/player/components/clipFades.ts b/packages/studio/src/player/components/clipFades.ts new file mode 100644 index 0000000000..e6378b6960 --- /dev/null +++ b/packages/studio/src/player/components/clipFades.ts @@ -0,0 +1,206 @@ +import { + sampleAutomationLane, + type HfAutomationLane, + type HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; +import { roundToCenti } from "../../utils/rounding"; + +/** + * Fade-handle math: turning the grips on a clip's top corners into the volume + * envelope underneath them, and back. + * + * A fade is not stored as a fade — it is the leading and trailing segment of + * the clip's ordinary automation envelope. That is what makes the handle + * two-way: it reads the shape it wrote. It also means a hand-drawn envelope + * must survive being touched, so every write here rewrites ONLY the head and + * tail segments and carries whatever the author put between them across + * untouched. + */ + +/** Curve shapes a fade can take, and the segment curvature each one writes. */ +export const FADE_CURVES = { + /** Straight line: the default, and what a constant-power fade is not. */ + linear: 0, + /** Eases out of silence and into it — the usual choice for music. */ + smooth: 0.35, + /** Holds the level then drops late; useful under a voice. */ + sharp: -0.45, +} as const; + +export type FadeCurve = keyof typeof FADE_CURVES; + +/** Shortest fade the handle will write; below this it reads as "no fade". */ +export const MIN_FADE_SECONDS = 0.05; + +/** Values within this of the floor/ceiling count as silence / full level. */ +const LEVEL_EPSILON = 1e-3; +/** Times within this of the clip edge count as sitting on it. */ +const EDGE_EPSILON = 1e-3; + +export interface ClipFades { + /** Seconds of fade at the clip's head; 0 when there is none. */ + fadeIn: number; + /** Seconds of fade at the clip's tail; 0 when there is none. */ + fadeOut: number; +} + +export const NO_FADES: ClipFades = { fadeIn: 0, fadeOut: 0 }; + +const atFloor = (v: number, min: number) => Math.abs(v - min) <= LEVEL_EPSILON; +const atCeiling = (v: number, max: number) => Math.abs(v - max) <= LEVEL_EPSILON; + +/** + * Read the fades out of an envelope, conservatively: a head segment counts as a + * fade-in only when it starts at the clip's first frame, starts at silence, and + * rises to full level. Anything else is somebody's automation and is reported + * as no fade, so the handle never claims to own a curve it would flatten. + */ +export function readClipFades( + points: readonly HfAutomationPoint[], + duration: number, + min = 0, + max = 1, +): ClipFades { + if (points.length < 2 || duration <= 0) return NO_FADES; + const sorted = [...points].sort((a, b) => a.t - b.t); + const first = sorted[0]!; + const second = sorted[1]!; + const last = sorted[sorted.length - 1]!; + const penultimate = sorted[sorted.length - 2]!; + + const fadeIn = + first.t <= EDGE_EPSILON && atFloor(first.v, min) && atCeiling(second.v, max) + ? Math.max(0, second.t) + : 0; + // `>=`, not `≈`: trimming a clip shorter leaves its envelope addressed to the + // old length, and a fade that still reaches past the new end is a fade the + // author can still see and grab. Reporting it as gone would hide a curve the + // clip is still carrying. + const fadeOut = + last.t >= duration - EDGE_EPSILON && + atFloor(last.v, min) && + atCeiling(penultimate.v, max) && + // The same two points cannot be both fades; a two-point ramp is one or the + // other, and the head reading wins because it is the one the eye reads first. + !(fadeIn > 0 && sorted.length === 2) + ? Math.max(0, Math.min(duration, duration - penultimate.t)) + : 0; + + return { + fadeIn: fadeIn >= MIN_FADE_SECONDS ? roundToCenti(fadeIn) : 0, + fadeOut: fadeOut >= MIN_FADE_SECONDS ? roundToCenti(fadeOut) : 0, + }; +} + +/** + * The longest each fade may be: together they may not overlap, and each is + * capped at the clip. Split evenly when both are dragged past the middle, so a + * long fade-in shortens the room left for a fade-out rather than fighting it. + */ +export function clampClipFades(fades: ClipFades, duration: number): ClipFades { + const fadeIn = Math.max(0, Math.min(fades.fadeIn, duration)); + const fadeOut = Math.max(0, Math.min(fades.fadeOut, duration)); + if (fadeIn + fadeOut <= duration) { + return { + fadeIn: fadeIn >= MIN_FADE_SECONDS ? roundToCenti(fadeIn) : 0, + fadeOut: fadeOut >= MIN_FADE_SECONDS ? roundToCenti(fadeOut) : 0, + }; + } + // Overlapping: give each what it asked for, in proportion, so neither jumps. + const total = fadeIn + fadeOut; + return clampClipFades( + { fadeIn: (fadeIn / total) * duration, fadeOut: (fadeOut / total) * duration }, + duration, + ); +} + +/** + * The filled wedge a fade draws on the clip, in SVG path form: the region the + * fade takes AWAY, from the clip's corner to where the level reaches full. + * + * Sampled through the same interpolator the runtime plays back, so a curved + * fade is drawn as the curve it will sound like rather than a straight line + * standing in for one. + */ +export function fadeWedgePath(input: { + edge: "in" | "out"; + seconds: number; + curve: FadeCurve; + pixelsPerSecond: number; + width: number; + height: number; +}): string { + const { edge, seconds, curve, pixelsPerSecond, width, height } = input; + const span = Math.min(seconds * pixelsPerSecond, width); + if (span <= 0) return ""; + const curvature = FADE_CURVES[curve]; + const lane: HfAutomationLane = { + target: "volume", + points: + edge === "in" + ? [ + { t: 0, v: 0, curve: curvature || undefined }, + { t: seconds, v: 1 }, + ] + : [ + { t: 0, v: 1, curve: curvature || undefined }, + { t: seconds, v: 0 }, + ], + }; + // Both wedges are drawn left to right, which is the direction the level line + // is read in: a fade-in rises out of the clip's start, a fade-out falls into + // its end. The out wedge therefore begins `span` short of the right edge, not + // at it — drawing it from the edge inward mirrors the fade. + const xAt = (progress: number) => + edge === "in" ? span * progress : width - span * (1 - progress); + const steps = curvature === 0 ? 1 : WEDGE_SAMPLES; + const line: string[] = []; + for (let i = 0; i <= steps; i += 1) { + const progress = i / steps; + const level = sampleAutomationLane(lane, seconds * progress, "linear"); + line.push(`L ${xAt(progress).toFixed(2)} ${((1 - level) * height).toFixed(2)}`); + } + const corner = edge === "in" ? 0 : width; + return `M ${corner} 0 ${line.join(" ")} L ${corner} 0 Z`; +} + +/** Segments used to draw a curved wedge; a straight one needs no sampling. */ +const WEDGE_SAMPLES = 24; + +/** + * Rewrite the envelope's head and tail to match `fades`, keeping every point + * the author placed in between. Returns an empty list when there is nothing + * left to describe — the caller drops the lane rather than storing a flat line. + */ +export function writeClipFades( + points: readonly HfAutomationPoint[], + duration: number, + fades: ClipFades, + curve: FadeCurve = "linear", + min = 0, + max = 1, +): HfAutomationPoint[] { + const { fadeIn, fadeOut } = clampClipFades(fades, duration); + const existing = readClipFades(points, duration, min, max); + const curvature = FADE_CURVES[curve]; + + // Everything strictly between the two fades is the author's; the old fade + // points are not, so they are dropped by the same window. + const interiorStart = Math.max(existing.fadeIn, fadeIn); + const interiorEnd = duration - Math.max(existing.fadeOut, fadeOut); + const interior = [...points] + .sort((a, b) => a.t - b.t) + .filter((p) => p.t > interiorStart + EDGE_EPSILON && p.t < interiorEnd - EDGE_EPSILON); + + const next: HfAutomationPoint[] = []; + if (fadeIn > 0) { + next.push({ t: 0, v: min, curve: curvature || undefined }); + next.push({ t: roundToCenti(fadeIn), v: max }); + } + next.push(...interior); + if (fadeOut > 0) { + next.push({ t: roundToCenti(duration - fadeOut), v: max, curve: curvature || undefined }); + next.push({ t: roundToCenti(duration), v: min }); + } + return next; +} From d1c4fb103e43ee9efffaea00e183bf5e7f42464a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 19 Aug 2026 01:11:13 -0400 Subject: [PATCH 2/2] fix(studio): stop the fade wedge outlining its own straight edges The wedge was one closed path carrying both the fill and the stroke, so the fill's straight top and side got outlined too and the eye read a rectangle butted onto the curve rather than one level line. Split it: the level is an open path and the only thing stroked, the region it takes away is that same line closed back through the clip's corner and never stroked. Both come out of one function so they cannot drift apart, and the endpoints are pinned by a test. --- .../player/components/TimelineClipFades.tsx | 37 +++++++++++-------- .../src/player/components/clipFades.test.ts | 35 ++++++++++++++---- .../studio/src/player/components/clipFades.ts | 25 +++++++++---- 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/packages/studio/src/player/components/TimelineClipFades.tsx b/packages/studio/src/player/components/TimelineClipFades.tsx index 5b269a611c..c5fea30da3 100644 --- a/packages/studio/src/player/components/TimelineClipFades.tsx +++ b/packages/studio/src/player/components/TimelineClipFades.tsx @@ -178,22 +178,29 @@ export function TimelineClipFades({ {(["in", "out"] as const).map((edge) => { const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut; if (seconds <= 0) return null; + const { line, fill } = fadeWedgePath({ + edge, + seconds, + curve, + pixelsPerSecond, + width, + height: VIEW_HEIGHT, + }); + // Two paths, not one: stroking the closed wedge would outline the + // fill's straight top and side as well, which reads as a rectangle + // butted onto the curve instead of one continuous level line. return ( - + + + + ); })} diff --git a/packages/studio/src/player/components/clipFades.test.ts b/packages/studio/src/player/components/clipFades.test.ts index 63c5f49c5b..f80a934789 100644 --- a/packages/studio/src/player/components/clipFades.test.ts +++ b/packages/studio/src/player/components/clipFades.test.ts @@ -117,23 +117,22 @@ describe("fadeWedgePath", () => { edge: "in" | "out", curve: Parameters[0]["curve"] = "linear", ) => - fadeWedgePath({ edge, seconds: 2, curve, pixelsPerSecond: 25, width: WIDTH, height: HEIGHT }); + fadeWedgePath({ edge, seconds: 2, curve, pixelsPerSecond: 25, width: WIDTH, height: HEIGHT }) + .line; /** Every [x, y] the path visits, in order. */ const points = (d: string) => [...d.matchAll(/[ML] (-?[\d.]+) (-?[\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]); it("draws a fade in rising out of the clip's start", () => { const path = points(wedge("in")); - expect(path[0]).toEqual([0, 0]); // the corner it shades from - expect(path[1]).toEqual([0, HEIGHT]); // silent, at the very start - expect(path[2]).toEqual([50, 0]); // full level, 2s in at 25px/s + expect(path[0]).toEqual([0, HEIGHT]); // silent, at the very start + expect(path[1]).toEqual([50, 0]); // full level, 2s in at 25px/s }); it("draws a fade out falling INTO the clip's end, not out of it", () => { const path = points(wedge("out")); - expect(path[0]).toEqual([WIDTH, 0]); - expect(path[1]).toEqual([WIDTH - 50, 0]); // still at full level, 2s from the end - expect(path[2]).toEqual([WIDTH, HEIGHT]); // silent, exactly on the end + expect(path[0]).toEqual([WIDTH - 50, 0]); // still at full level, 2s from the end + expect(path[1]).toEqual([WIDTH, HEIGHT]); // silent, exactly on the end }); it("samples a curved fade instead of drawing a straight line", () => { @@ -154,7 +153,27 @@ describe("fadeWedgePath", () => { width: WIDTH, height: HEIGHT, }), - ).toBe(""); + ).toEqual({ line: "", fill: "" }); + }); + + it("keeps the stroked line open so the fill's closing edges are not outlined", () => { + const { line, fill } = fadeWedgePath({ + edge: "in", + seconds: 2, + curve: "linear", + pixelsPerSecond: 25, + width: WIDTH, + height: HEIGHT, + }); + // The line is the level and nothing else: no close, no corner. + expect(line).not.toContain("Z"); + expect(points(line)).toEqual([ + [0, HEIGHT], + [50, 0], + ]); + // The fill is that line closed back through the clip's corner. + expect(fill.startsWith(line)).toBe(true); + expect(fill.endsWith("L 0 0 Z")).toBe(true); }); }); diff --git a/packages/studio/src/player/components/clipFades.ts b/packages/studio/src/player/components/clipFades.ts index e6378b6960..0eb7c04fde 100644 --- a/packages/studio/src/player/components/clipFades.ts +++ b/packages/studio/src/player/components/clipFades.ts @@ -115,10 +115,15 @@ export function clampClipFades(fades: ClipFades, duration: number): ClipFades { } /** - * The filled wedge a fade draws on the clip, in SVG path form: the region the - * fade takes AWAY, from the clip's corner to where the level reaches full. + * The two SVG paths a fade draws, as one pair so they cannot disagree: * - * Sampled through the same interpolator the runtime plays back, so a curved + * - `line` is the level itself, and the only thing that gets stroked. It is an + * open path: stroking a closed wedge outlines the fill's straight top and + * side too, which reads as a rectangle butted onto the curve. + * - `fill` is that same line closed back to the clip's corner — the region the + * fade takes away — and is never stroked. + * + * Both are sampled through the interpolator the runtime plays back, so a curved * fade is drawn as the curve it will sound like rather than a straight line * standing in for one. */ @@ -129,10 +134,10 @@ export function fadeWedgePath(input: { pixelsPerSecond: number; width: number; height: number; -}): string { +}): { line: string; fill: string } { const { edge, seconds, curve, pixelsPerSecond, width, height } = input; const span = Math.min(seconds * pixelsPerSecond, width); - if (span <= 0) return ""; + if (span <= 0) return { line: "", fill: "" }; const curvature = FADE_CURVES[curve]; const lane: HfAutomationLane = { target: "volume", @@ -154,14 +159,18 @@ export function fadeWedgePath(input: { const xAt = (progress: number) => edge === "in" ? span * progress : width - span * (1 - progress); const steps = curvature === 0 ? 1 : WEDGE_SAMPLES; - const line: string[] = []; + const points: string[] = []; for (let i = 0; i <= steps; i += 1) { const progress = i / steps; const level = sampleAutomationLane(lane, seconds * progress, "linear"); - line.push(`L ${xAt(progress).toFixed(2)} ${((1 - level) * height).toFixed(2)}`); + points.push(`${xAt(progress).toFixed(2)} ${((1 - level) * height).toFixed(2)}`); } + const line = `M ${points.join(" L ")}`; + // The fill closes through the clip's own corner: up to the top for a fade-in, + // back along the top for a fade-out. Never stroked, so those closing edges + // stay invisible and only the level reads as a line. const corner = edge === "in" ? 0 : width; - return `M ${corner} 0 ${line.join(" ")} L ${corner} 0 Z`; + return { line, fill: `${line} L ${corner} 0 Z` }; } /** Segments used to draw a curved wedge; a straight one needs no sampling. */