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..c5fea30da3 --- /dev/null +++ b/packages/studio/src/player/components/TimelineClipFades.tsx @@ -0,0 +1,210 @@ +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..f80a934789 --- /dev/null +++ b/packages/studio/src/player/components/clipFades.test.ts @@ -0,0 +1,246 @@ +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 }) + .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, 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 - 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", () => { + 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, + }), + ).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); + }); +}); + +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..0eb7c04fde --- /dev/null +++ b/packages/studio/src/player/components/clipFades.ts @@ -0,0 +1,215 @@ +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 two SVG paths a fade draws, as one pair so they cannot disagree: + * + * - `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. + */ +export function fadeWedgePath(input: { + edge: "in" | "out"; + seconds: number; + curve: FadeCurve; + pixelsPerSecond: number; + width: number; + height: number; +}): { line: string; fill: string } { + const { edge, seconds, curve, pixelsPerSecond, width, height } = input; + const span = Math.min(seconds * pixelsPerSecond, width); + if (span <= 0) return { line: "", fill: "" }; + 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 points: string[] = []; + for (let i = 0; i <= steps; i += 1) { + const progress = i / steps; + const level = sampleAutomationLane(lane, seconds * progress, "linear"); + 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 { line, fill: `${line} 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; +}