diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index ef217b46f8..23dc72ee3a 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -158,6 +158,12 @@ "types": "./dist/audioAutomation.d.ts", "environments": ["browser", "bun", "node"] }, + "./clip-fade": { + "source": "./src/clipFade.ts", + "runtime": "./dist/clipFade.js", + "types": "./dist/clipFade.d.ts", + "environments": ["browser", "bun", "node"] + }, "./audio-gain": { "source": "./src/audioGain.ts", "runtime": "./dist/audioGain.js", diff --git a/packages/core/package.json b/packages/core/package.json index 54364189b9..3b9961501a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -172,6 +172,12 @@ "import": "./src/audioAutomation.ts", "types": "./src/audioAutomation.ts" }, + "./clip-fade": { + "bun": "./src/clipFade.ts", + "node": "./dist/clipFade.js", + "import": "./src/clipFade.ts", + "types": "./src/clipFade.ts" + }, "./audio-gain": { "bun": "./src/audioGain.ts", "node": "./dist/audioGain.js", @@ -484,6 +490,10 @@ "import": "./dist/audioAutomation.js", "types": "./dist/audioAutomation.d.ts" }, + "./clip-fade": { + "import": "./dist/clipFade.js", + "types": "./dist/clipFade.d.ts" + }, "./audio-gain": { "import": "./dist/audioGain.js", "types": "./dist/audioGain.d.ts" diff --git a/packages/core/src/clipFade.test.ts b/packages/core/src/clipFade.test.ts new file mode 100644 index 0000000000..17d242da37 --- /dev/null +++ b/packages/core/src/clipFade.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + clipFadeFilter, + clipFadeLevelAt, + fadeEase, + parseClipFade, + type HfClipFade, +} from "./clipFade"; + +const attrs = (record: Record) => (name: string) => record[name] ?? null; +const FADE: HfClipFade = { fadeIn: 1, fadeOut: 2, curve: "linear" }; + +describe("parseClipFade", () => { + it("returns null for a clip that declares no fade", () => { + expect(parseClipFade(attrs({}))).toBeNull(); + expect(parseClipFade(attrs({ "data-fade-in": "0" }))).toBeNull(); + }); + + it("reads either end on its own", () => { + expect(parseClipFade(attrs({ "data-fade-in": "0.5" }))).toEqual({ + fadeIn: 0.5, + fadeOut: 0, + curve: "linear", + }); + expect(parseClipFade(attrs({ "data-fade-out": "1.25" }))).toEqual({ + fadeIn: 0, + fadeOut: 1.25, + curve: "linear", + }); + }); + + it("falls back to a straight ramp for a curve it does not know", () => { + const read = (curve: string) => + parseClipFade(attrs({ "data-fade-in": "1", "data-fade-curve": curve }))?.curve; + expect(read("smooth")).toBe("smooth"); + expect(read("SHARP")).toBe("sharp"); + expect(read("bezier-ish")).toBe("linear"); + }); + + it("ignores lengths that are not a positive number of seconds", () => { + expect(parseClipFade(attrs({ "data-fade-in": "-1" }))).toBeNull(); + expect(parseClipFade(attrs({ "data-fade-in": "soon" }))).toBeNull(); + }); +}); + +describe("fadeEase", () => { + it("pins both ends whatever the shape", () => { + for (const curve of ["linear", "smooth", "sharp"] as const) { + expect(fadeEase(0, curve)).toBe(0); + expect(fadeEase(1, curve)).toBe(1); + } + }); + + it("clamps progress outside the fade", () => { + expect(fadeEase(-5, "smooth")).toBe(0); + expect(fadeEase(5, "smooth")).toBe(1); + }); + + it("shapes the middle the way each curve is named", () => { + expect(fadeEase(0.5, "linear")).toBeCloseTo(0.5, 6); + // Smooth is symmetric about the midpoint, so it also passes through it. + expect(fadeEase(0.5, "smooth")).toBeCloseTo(0.5, 6); + expect(fadeEase(0.25, "smooth")).toBeLessThan(0.25); + // Sharp holds low then climbs late. + expect(fadeEase(0.5, "sharp")).toBeCloseTo(0.25, 6); + }); +}); + +describe("clipFadeLevelAt", () => { + it("is silent at the very first instant and full once the fade is done", () => { + expect(clipFadeLevelAt(FADE, 0, 10)).toBe(0); + expect(clipFadeLevelAt(FADE, 1, 10)).toBe(1); + expect(clipFadeLevelAt(FADE, 5, 10)).toBe(1); + }); + + it("falls back to silence at the clip's very end", () => { + expect(clipFadeLevelAt(FADE, 9, 10)).toBeCloseTo(0.5, 6); + expect(clipFadeLevelAt(FADE, 10, 10)).toBe(0); + }); + + it("never reports a level outside 0..1", () => { + for (const t of [-1, 0, 0.3, 5, 9.9, 10, 11]) { + const level = clipFadeLevelAt(FADE, t, 10); + expect(level).toBeGreaterThanOrEqual(0); + expect(level).toBeLessThanOrEqual(1); + } + }); + + it("shares a window too short for both fades instead of fighting over it", () => { + // 1s in + 2s out asked of a 1.5s clip becomes 0.5s + 1s, so the two meet at + // full level exactly once rather than overlapping into a dip. + expect(clipFadeLevelAt(FADE, 0, 1.5)).toBe(0); + expect(clipFadeLevelAt(FADE, 0.25, 1.5)).toBeCloseTo(0.5, 6); + expect(clipFadeLevelAt(FADE, 0.5, 1.5)).toBe(1); + expect(clipFadeLevelAt(FADE, 1, 1.5)).toBeCloseTo(0.5, 6); + expect(clipFadeLevelAt(FADE, 1.5, 1.5)).toBe(0); + }); + + it("only fades in when the clip has no end to fade out of", () => { + expect(clipFadeLevelAt(FADE, 0.5, Number.POSITIVE_INFINITY)).toBeCloseTo(0.5, 6); + expect(clipFadeLevelAt(FADE, 5000, Number.POSITIVE_INFINITY)).toBe(1); + }); + + it("holds a fade-out-only clip at full level until its tail", () => { + const out: HfClipFade = { fadeIn: 0, fadeOut: 2, curve: "linear" }; + expect(clipFadeLevelAt(out, 0, 10)).toBe(1); + expect(clipFadeLevelAt(out, 9, 10)).toBeCloseTo(0.5, 6); + }); +}); + +describe("clipFadeFilter", () => { + it("leaves a clip at full level carrying exactly what its author wrote", () => { + expect(clipFadeFilter("blur(2px)", 1)).toBe("blur(2px)"); + expect(clipFadeFilter("", 1)).toBe(""); + }); + + it("composes onto the authored filter rather than replacing it", () => { + expect(clipFadeFilter("blur(2px)", 0.5)).toBe("blur(2px) opacity(0.5000)"); + expect(clipFadeFilter("", 0.25)).toBe("opacity(0.2500)"); + }); +}); diff --git a/packages/core/src/clipFade.ts b/packages/core/src/clipFade.ts new file mode 100644 index 0000000000..8fdb6a6e05 --- /dev/null +++ b/packages/core/src/clipFade.ts @@ -0,0 +1,133 @@ +/** + * Clip fades: `data-fade-in` / `data-fade-out` on any timed element. + * + * A fade is declared, not animated. The author writes how long it lasts and the + * runtime attenuates the clip over that stretch of its own window — so a fade + * survives a trim, a move, and a re-render, and there is no tween to keep in + * sync with the clip's timing. + * + * Visual clips fade on opacity. Audio is deliberately NOT covered here: a fade + * on a sound is volume automation, it already has `data-automation` to live in, + * and putting it there keeps it editable as breakpoints rather than as one + * number. + */ + +export const HF_FADE_IN_ATTR = "data-fade-in"; +export const HF_FADE_OUT_ATTR = "data-fade-out"; +export const HF_FADE_CURVE_ATTR = "data-fade-curve"; + +/** + * The shape a fade takes across its length. + * + * - `linear` — a straight ramp. Predictable, and what a cut-to-black wants. + * - `smooth` — eases out of and into the extreme; the least noticeable fade. + * - `sharp` — holds near the extreme, then moves late. Reads as a "snap" fade. + */ +export type HfFadeCurve = "linear" | "smooth" | "sharp"; + +const FADE_CURVES: readonly HfFadeCurve[] = ["linear", "smooth", "sharp"]; + +export interface HfClipFade { + /** Seconds of fade at the clip's head. */ + fadeIn: number; + /** Seconds of fade at the clip's tail. */ + fadeOut: number; + curve: HfFadeCurve; +} + +/** Ease a 0..1 progress through the named curve. */ +export function fadeEase(progress: number, curve: HfFadeCurve): number { + const p = progress <= 0 ? 0 : progress >= 1 ? 1 : progress; + switch (curve) { + case "smooth": + return p * p * (3 - 2 * p); + case "sharp": + return p * p; + case "linear": + return p; + } +} + +function parseSeconds(raw: string | null | undefined): number { + if (raw == null) return 0; + const value = Number.parseFloat(raw); + return Number.isFinite(value) && value > 0 ? value : 0; +} + +function parseCurve(raw: string | null | undefined): HfFadeCurve { + const value = raw?.trim().toLowerCase(); + return FADE_CURVES.find((curve) => curve === value) ?? "linear"; +} + +/** + * Whether the element declares a fade at all, without parsing one. + * + * The runtime asks this of every timed element on every frame and almost none + * of them answer yes, so the common path stays two attribute lookups. + */ +export function hasClipFadeAttributes(hasAttribute: (name: string) => boolean): boolean { + return hasAttribute(HF_FADE_IN_ATTR) || hasAttribute(HF_FADE_OUT_ATTR); +} + +/** + * Read a clip's fade from its attributes, or null when it declares none. + * + * Takes an attribute reader rather than an element so the same parse runs + * against a DOM node in the runtime, a parsed node in the linter, and a plain + * record in a test. + */ +export function parseClipFade(getAttribute: (name: string) => string | null): HfClipFade | null { + const fadeIn = parseSeconds(getAttribute(HF_FADE_IN_ATTR)); + const fadeOut = parseSeconds(getAttribute(HF_FADE_OUT_ATTR)); + if (fadeIn <= 0 && fadeOut <= 0) return null; + return { fadeIn, fadeOut, curve: parseCurve(getAttribute(HF_FADE_CURVE_ATTR)) }; +} + +/** + * The clip's level at `elapsed` seconds into a window `duration` long: 1 at + * full, 0 at silence/transparent. + * + * Fades that would overlap share the window in proportion rather than fighting + * over it, so a clip trimmed shorter than its own fades still resolves to a + * clean in-and-out instead of jumping. An unbounded window (a clip with no + * duration) can only fade in — there is no end to fade out of. + */ +export function clipFadeLevelAt(fade: HfClipFade, elapsed: number, duration: number): number { + if (elapsed <= 0 && fade.fadeIn > 0) return 0; + const finite = Number.isFinite(duration) && duration > 0; + let { fadeIn, fadeOut } = fade; + if (finite && fadeIn + fadeOut > duration) { + const total = fadeIn + fadeOut; + fadeIn = (fadeIn / total) * duration; + fadeOut = (fadeOut / total) * duration; + } + if (!finite) fadeOut = 0; + + let level = 1; + if (fadeIn > 0 && elapsed < fadeIn) { + level = Math.min(level, fadeEase(elapsed / fadeIn, fade.curve)); + } + if (fadeOut > 0) { + const remaining = duration - elapsed; + if (remaining < fadeOut) { + level = Math.min(level, fadeEase(Math.max(0, remaining) / fadeOut, fade.curve)); + } + } + return level <= 0 ? 0 : level >= 1 ? 1 : level; +} + +/** + * The CSS `filter` a faded clip should carry, composed onto whatever filter the + * author wrote. `filter`, not `opacity`: opacity is the property animation + * engines drive, and a runtime that writes it every frame fights them for it — + * `filter: opacity()` multiplies with whatever they set instead. + * + * Returns the authored filter unchanged at full level, so a clip outside its + * fades carries exactly what its author gave it and nothing else. + */ +export function clipFadeFilter(authoredFilter: string, level: number): string { + const authored = authoredFilter.trim(); + if (level >= 1) return authored; + const opacity = `opacity(${Math.max(0, level).toFixed(4)})`; + return authored ? `${authored} ${opacity}` : opacity; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index f9260e0c92..49adf6c511 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2,6 +2,7 @@ import { installRuntimeControlBridge, postRuntimeMessage, setRuntimeProtocolFps } from "./bridge"; import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics"; import { injectCompositionCssVariables } from "./getVariables"; +import { clipFadeFilter, clipFadeLevelAt, hasClipFadeAttributes, parseClipFade } from "../clipFade"; import { createCssAdapter } from "./adapters/css"; import { createGsapAdapter } from "./adapters/gsap"; import { createAnimeJsAdapter } from "./adapters/animejs"; @@ -658,10 +659,18 @@ export function initSandboxRuntimeModular(): void { } }); - const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => { + /** + * The clip's own window, resolved exactly as visibility resolves it — the two + * must agree, because a fade running on a different window than the clip is + * visible for is a fade that clips or hangs. Null for nodes that are not + * timed content at all. + */ + const resolveTimedElementWindow = ( + rawNode: HTMLElement, + ): { start: number; end: number } | null => { const tag = rawNode.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { - return false; + return null; } const isMedia = tag === "video" || tag === "audio"; @@ -692,9 +701,13 @@ export function initSandboxRuntimeModular(): void { } const computedEnd = duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY; - return ( - currentTime >= start && (Number.isFinite(computedEnd) ? currentTime < computedEnd : true) - ); + return { start, end: computedEnd }; + }; + + const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => { + const span = resolveTimedElementWindow(rawNode); + if (!span) return false; + return currentTime >= span.start && (Number.isFinite(span.end) ? currentTime < span.end : true); }; const hasExternalCompositions = !!document.querySelector("[data-composition-src]"); @@ -1916,6 +1929,65 @@ export function initSandboxRuntimeModular(): void { }; const dataHiddenDisplayRestores = new WeakMap(); const dataHiddenDisplayNodes = new WeakSet(); + /** + * The inline `filter` each faded clip carried before the fade first touched + * it, captured on that first touch — which happens on the initial visibility + * pass, before the transport has advanced and before any tween has run. + */ + const authoredClipFilters = new WeakMap(); + const fadedClipNodes = new WeakSet(); + + /** + * Attenuate a clip across its declared fades. + * + * Writes `filter: opacity()`, never `opacity` itself: opacity is the property + * animation engines drive, and a runtime that rewrites it every frame fights + * them for it. A filter multiplies with whatever they set. Outside the fades + * the authored filter is restored exactly, so a clip that is not fading is + * left carrying only what its author gave it. + */ + const restoreAuthoredClipFilter = (rawNode: HTMLElement) => { + if (!fadedClipNodes.has(rawNode)) return; + const authored = authoredClipFilters.get(rawNode); + if (authored) rawNode.style.filter = authored; + else rawNode.style.removeProperty("filter"); + fadedClipNodes.delete(rawNode); + }; + + const applyClipFade = (rawNode: HTMLElement, currentTime: number, isVisible: boolean) => { + // Cheap gate first: this runs for every timed element on every frame, and + // almost none of them declare a fade. + if (!hasClipFadeAttributes((name) => rawNode.hasAttribute(name))) { + restoreAuthoredClipFilter(rawNode); + return; + } + // A clip outside its own window is already hidden; leaving a fade filter on + // it would show the author a style they never wrote. + if (!isVisible) { + restoreAuthoredClipFilter(rawNode); + return; + } + const fade = parseClipFade((name) => rawNode.getAttribute(name)); + if (!fade) { + restoreAuthoredClipFilter(rawNode); + return; + } + const span = resolveTimedElementWindow(rawNode); + if (!span) return; + if (!authoredClipFilters.has(rawNode)) { + authoredClipFilters.set(rawNode, rawNode.style.getPropertyValue("filter")); + } + const authored = authoredClipFilters.get(rawNode) ?? ""; + const level = clipFadeLevelAt(fade, currentTime - span.start, span.end - span.start); + const next = clipFadeFilter(authored, level); + if (next) { + rawNode.style.filter = next; + fadedClipNodes.add(rawNode); + } else { + rawNode.style.removeProperty("filter"); + fadedClipNodes.delete(rawNode); + } + }; const syncTimedElementVisibility = ( currentTime: number, @@ -1966,6 +2038,7 @@ export function initSandboxRuntimeModular(): void { } } rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; + applyClipFade(rawNode, currentTime, isVisibleNow); if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); } diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index d512f4c93a..345cbd1193 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -1,6 +1,11 @@ import { memo, type CSSProperties, type ReactNode } from "react"; import type { TimelineElement } from "../store/playerStore"; -import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme"; +import { + defaultTimelineTheme, + getClipHandleOpacity, + getTimelineTrackStyle, + type TimelineTheme, +} from "./timelineTheme"; import type { TimelineEditCapabilities } from "./timelineEditing"; import { isAudioTimelineElement } from "../../utils/timelineInspector"; import { timelineClipFocusId } from "./timelineNavigationIdentity"; @@ -33,7 +38,10 @@ interface TimelineClipProps { * 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; + fades?: Omit< + TimelineClipFadesProps, + "duration" | "pixelsPerSecond" | "width" | "showGrips" | "accent" + >; children?: ReactNode; } @@ -196,6 +204,7 @@ export const TimelineClip = memo(function TimelineClip({ pixelsPerSecond={pps} width={widthPx} showGrips={showHandles} + accent={getTimelineTrackStyle(el.tag).accent} /> )} {showLabel && {displayLabel}} diff --git a/packages/studio/src/player/components/TimelineClipFades.tsx b/packages/studio/src/player/components/TimelineClipFades.tsx index c5fea30da3..9171c53b91 100644 --- a/packages/studio/src/player/components/TimelineClipFades.tsx +++ b/packages/studio/src/player/components/TimelineClipFades.tsx @@ -6,6 +6,7 @@ import { MIN_FADE_SECONDS, type ClipFades, type FadeCurve, + type FadeSampler, } from "./clipFades"; /** @@ -26,6 +27,15 @@ export interface TimelineClipFadesProps { pixelsPerSecond: number; width: number; curve: FadeCurve; + /** How the level rises across the fade — the medium decides. */ + sample: FadeSampler; + /** + * The clip's accent. The same colour, weight and opacity the automation lane + * strokes an envelope with — a fade IS an envelope, and drawing the two alike + * is what makes the wedge on the clip and the curve in the expanded lane read + * as one line rather than two unrelated marks. + */ + accent: string; /** 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. */ @@ -55,6 +65,8 @@ export function TimelineClipFades({ pixelsPerSecond, width, curve, + sample, + accent, showGrips, readOnly, onPreview, @@ -181,7 +193,7 @@ export function TimelineClipFades({ const { line, fill } = fadeWedgePath({ edge, seconds, - curve, + sample, pixelsPerSecond, width, height: VIEW_HEIGHT, @@ -195,8 +207,9 @@ export function TimelineClipFades({ diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 9e839a29c4..0fd3f8bb51 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -27,7 +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 { useClipFadeBinder } from "./useClipFadeWriter"; import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { isTimelineClipActive } from "./useTimelineActiveClips"; @@ -104,6 +104,7 @@ export function TimelineLanes({ const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const automationLanes = useAutomationLanes(); + const bindClipFade = useClipFadeBinder(automationLanes); useAutomationSelectionKeyboard({ lanes: automationLanes }); const expandClips = usePlayerStore((s) => s.expandClips); const setClipExpanded = usePlayerStore((s) => s.setClipExpanded); @@ -392,9 +393,7 @@ export function TimelineLanes({ : 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 fadeBinding = bindClipFade(el, isSelected); const clipGestures = createClipGestureHandlers( el, elementKey, diff --git a/packages/studio/src/player/components/clipFadeBinding.test.ts b/packages/studio/src/player/components/clipFadeBinding.test.ts index 9d34b4309e..86ef6fe50e 100644 --- a/packages/studio/src/player/components/clipFadeBinding.test.ts +++ b/packages/studio/src/player/components/clipFadeBinding.test.ts @@ -2,103 +2,208 @@ 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"; +import { + nextFadeCurve, + readFadeCurve, + resolveClipFadeBinding, + type ClipFadeDeps, +} from "./clipFadeBinding"; + +const EMPTY: HfAutomation = { version: 1, lanes: [] }; function el(over: Partial = {}): TimelineElement { return { - id: "music", - key: "music", - tag: "audio", - src: "bgm.m4a", + id: "clip", + key: "clip", + tag: "div", start: 0, duration: 10, - track: 2, - domId: "music", + track: 0, + domId: "clip", ...over, }; } -function binder(automation: HfAutomation, readOnly = false) { +const audio = (over: Partial = {}) => + el({ id: "music", key: "music", tag: "audio", src: "bgm.m4a", ...over }); + +/** A deps bag with both paths stubbed, plus the spies to assert against. */ +function deps(options: { automation?: HfAutomation; readOnly?: boolean; selected?: boolean } = {}) { 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 writeAttribute = vi.fn(); + const updateElement = vi.fn(); + const bag: ClipFadeDeps = { + bindAutomation: () => + ({ + automation: options.automation ?? EMPTY, + lanes: (options.automation ?? EMPTY).lanes, + chain: null, + readOnly: options.readOnly ?? false, + onPreview, + onCommit, + }) as unknown as AutomationLaneBinding, + writeAttribute, + isSelected: () => options.selected ?? true, + updateElement, + }; + return { bag, onPreview, onCommit, writeAttribute, updateElement }; } -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(); +describe("resolveClipFadeBinding — which storage a clip uses", () => { + it("gives a visual clip the attribute path", () => { + const { bag, writeAttribute, onCommit } = deps(); + resolveClipFadeBinding(el(), bag)!.onCommit({ fadeIn: 2, fadeOut: 0 }); + expect(writeAttribute).toHaveBeenCalledWith("data-fade-in", "2", true, expect.anything()); + expect(onCommit).not.toHaveBeenCalled(); }); - 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 }, - ], - }, - ], + it("gives an audio clip the envelope path", () => { + const { bag, writeAttribute, onCommit } = deps(); + resolveClipFadeBinding(audio(), bag)!.onCommit({ fadeIn: 2, fadeOut: 0 }); + expect(volumeOf(onCommit.mock.calls[0]![0])).toEqual([ + [0, 0], + [2, 1], + ]); + expect(writeAttribute).not.toHaveBeenCalled(); + }); + + it("offers nothing on a clip with no length to fade across", () => { + const { bag } = deps(); + expect(resolveClipFadeBinding(el({ duration: 0 }), bag)).toBeUndefined(); + }); +}); + +describe("visual fades", () => { + it("reads the clip's declared attributes", () => { + const { bag } = deps(); + const fade = resolveClipFadeBinding( + el({ fadeIn: "1.5", fadeOut: "2", fadeCurve: "smooth" }), + bag, + )!; + expect(fade.fades).toEqual({ fadeIn: 1.5, fadeOut: 2 }); + expect(fade.curve).toBe("smooth"); + }); + + it("previews without persisting, and only writes the end that moved", () => { + const { bag, writeAttribute } = deps(); + const fade = resolveClipFadeBinding(el({ fadeIn: "1" }), bag)!; + fade.onPreview({ fadeIn: 2, fadeOut: 0 }); + expect(writeAttribute).toHaveBeenCalledTimes(1); + expect(writeAttribute).toHaveBeenCalledWith("data-fade-in", "2", false, expect.anything()); + }); + + it("removes the attribute when the fade is dragged back to nothing", () => { + const { bag, writeAttribute } = deps(); + resolveClipFadeBinding(el({ fadeIn: "2" }), bag)!.onCommit({ fadeIn: 0, fadeOut: 0 }); + expect(writeAttribute).toHaveBeenCalledWith("data-fade-in", null, true, expect.anything()); + }); + + it("leaves the curve attribute off while it is the default", () => { + const { bag, writeAttribute } = deps(); + resolveClipFadeBinding(el(), bag)!.onCommit({ fadeIn: 1, fadeOut: 0 }); + expect(writeAttribute).not.toHaveBeenCalledWith( + "data-fade-curve", + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it("writes the curve once it is stepped, and drops it back at linear", () => { + const stepped = deps(); + resolveClipFadeBinding(el({ fadeIn: "1" }), stepped.bag)!.onCycleCurve(); + expect(stepped.writeAttribute).toHaveBeenCalledWith( + "data-fade-curve", + "smooth", + true, + expect.anything(), + ); + + const back = deps(); + resolveClipFadeBinding(el({ fadeIn: "1", fadeCurve: "sharp" }), back.bag)!.onCycleCurve(); + expect(back.writeAttribute).toHaveBeenCalledWith( + "data-fade-curve", + null, + true, + expect.anything(), + ); + }); + + it("shares a clip too short for both fades rather than overlapping them", () => { + const { bag } = deps(); + const fade = resolveClipFadeBinding(el({ duration: 4, fadeIn: "3", fadeOut: "3" }), bag)!; + expect(fade.fades.fadeIn + fade.fades.fadeOut).toBeCloseTo(4, 6); + }); + + it("writes nothing for a clip that is not the selected one", () => { + const { bag, writeAttribute, updateElement } = deps({ selected: false }); + const fade = resolveClipFadeBinding(el(), bag)!; + expect(fade.readOnly).toBe(true); + fade.onCommit({ fadeIn: 1, fadeOut: 0 }); + expect(writeAttribute).not.toHaveBeenCalled(); + expect(updateElement).not.toHaveBeenCalled(); + }); + + it("applies the fade to the store too, so the grip reads back what it wrote", () => { + const { bag, updateElement } = deps(); + resolveClipFadeBinding(el(), bag)!.onCommit({ fadeIn: 2, fadeOut: 0 }); + expect(updateElement).toHaveBeenCalledWith("clip", { + fadeIn: "2", + fadeOut: undefined, + fadeCurve: undefined, }); - 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)!; + it("is read-only with no edit session at all", () => { + const { bag } = deps(); + const fade = resolveClipFadeBinding(el(), { ...bag, writeAttribute: undefined })!; + expect(fade.readOnly).toBe(true); + }); +}); +describe("audio fades", () => { + const withFade: HfAutomation = { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 0 }, + { t: 2, v: 1 }, + ], + }, + ], + }; + + it("reads the clip's existing envelope as its fades", () => { + const { bag } = deps({ automation: withFade }); + expect(resolveClipFadeBinding(audio(), bag)!.fades).toEqual({ fadeIn: 2, fadeOut: 0 }); + }); + + it("previews without persisting, and commits once", () => { + const { bag, onPreview, onCommit } = deps(); + const fade = resolveClipFadeBinding(audio(), bag)!; 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 }); + const { bag, onCommit } = deps({ automation: withFade }); + resolveClipFadeBinding(audio(), bag)!.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)!; + const { bag, onPreview, onCommit } = deps({ readOnly: true }); + const fade = resolveClipFadeBinding(audio(), bag)!; expect(fade.readOnly).toBe(true); fade.onPreview({ fadeIn: 1, fadeOut: 0 }); fade.onCommit({ fadeIn: 1, fadeOut: 0 }); @@ -107,19 +212,8 @@ describe("resolveClipFadeBinding", () => { }); 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)!; + const { bag, onCommit } = deps({ automation: withFade }); + const fade = resolveClipFadeBinding(audio(), bag)!; expect(fade.curve).toBe("linear"); fade.onCycleCurve(); const points = (onCommit.mock.calls[0]![0] as HfAutomation).lanes[0]!.points; @@ -132,9 +226,8 @@ describe("resolveClipFadeBinding", () => { }); describe("fade curves", () => { - it("names the curvature a fade was written with", () => { + it("names the curvature an audio 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. diff --git a/packages/studio/src/player/components/clipFadeBinding.ts b/packages/studio/src/player/components/clipFadeBinding.ts index aa75f7e2a5..6a2a6dacf7 100644 --- a/packages/studio/src/player/components/clipFadeBinding.ts +++ b/packages/studio/src/player/components/clipFadeBinding.ts @@ -1,66 +1,100 @@ +import { + HF_FADE_CURVE_ATTR, + HF_FADE_IN_ATTR, + HF_FADE_OUT_ATTR, + parseClipFade, +} from "@hyperframes/core/clip-fade"; import { laneFor, withLane } from "./automationLaneGeometry"; import { + audioFadeSampler, + clampClipFades, FADE_CURVES, + FADE_CURVE_ORDER, readClipFades, + visualFadeSampler, writeClipFades, type ClipFades, type FadeCurve, + type FadeSampler, } from "./clipFades"; import type { AutomationLaneBinding } from "./useAutomationLanes"; import type { TimelineElement } from "../store/playerStore"; import { isAudioTimelineElement } from "../../utils/timelineInspector"; +import { roundToCenti } from "../../utils/rounding"; /** - * Wiring the fade grips to the clip's volume envelope. + * Wiring the fade grips to whichever place the clip keeps its fade. + * + * One gesture, two storages, chosen by what the clip is: * - * 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** rides the volume envelope the automation lane already edits, so a + * fade drawn with the grip is there as breakpoints to refine by hand. + * - **Everything else** carries `data-fade-in` / `data-fade-out`, which the + * runtime applies (see `@hyperframes/core/clip-fade`). * - * Audio only for now. A visual clip fades on opacity, which lives in the - * composition's animation rather than in this envelope. + * Both are projections, not new state: each reads back exactly what it wrote. */ const VOLUME = "volume"; +/** Fade edits of one gesture fold into a single history entry. */ +const FADE_COALESCE_MS = 1200; export interface ClipFadeBinding { fades: ClipFades; curve: FadeCurve; + /** How the fade's level is drawn, which differs by medium. */ + sample: FadeSampler; readOnly: boolean; onPreview(next: ClipFades): void; onCommit(next: ClipFades): void; onCycleCurve(): void; } -/** Which named curve an envelope's fade was written with. */ +/** Which named curve an audio fade's envelope curvature corresponds to. */ 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"; + return FADE_CURVE_ORDER.find((key) => Math.abs(FADE_CURVES[key] - curvature) < 0.05) ?? "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]!; + return FADE_CURVE_ORDER[(FADE_CURVE_ORDER.indexOf(curve) + 1) % FADE_CURVE_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( +/** Writes one of the clip's own attributes; only valid for the selected clip. */ +export type FadeAttributeWriter = ( + attr: string, + value: string | null, + persist: boolean, + coalesce: { key: string; ms: number }, +) => void; + +export interface ClipFadeDeps { + /** The clip's automation binding — the audio path's read and write. */ + bindAutomation(element: TimelineElement): AutomationLaneBinding; + /** The visual path's write. Absent outside an edit session (read-only player). */ + writeAttribute?: FadeAttributeWriter; + /** True when dom-edit writes would land on THIS clip. */ + isSelected(element: TimelineElement): boolean; + /** + * Apply the new fade to the store as well as the file. + * + * The attribute write reaches the preview DOM and the file, but the timeline's + * own element list is only re-derived on a refresh the quiet commit skips — so + * without this the grip reads its own edit back as stale and the wedge + * disappears the moment the drag ends. + */ + updateElement(key: string, updates: Partial): void; +} + +const keyOf = (element: TimelineElement): string => element.key ?? element.id; +const seconds = (value: number): string | null => (value > 0 ? String(roundToCenti(value)) : null); + +/** Audio: the fade is the head and tail of the clip's volume envelope. */ +function audioFadeBinding( element: TimelineElement, - bind: (element: TimelineElement) => AutomationLaneBinding, -): ClipFadeBinding | undefined { - if (!isAudioTimelineElement(element)) return undefined; - const binding = bind(element); + binding: AutomationLaneBinding, +): ClipFadeBinding { const lane = laneFor(binding.automation, VOLUME); const fades = readClipFades(lane.points, element.duration); const curve = readFadeCurve(lane.points[0]?.curve); @@ -80,9 +114,110 @@ export function resolveClipFadeBinding( return { fades, curve, + sample: audioFadeSampler(curve), readOnly: binding.readOnly, onPreview: (next) => apply(next, curve, false), onCommit: (next) => apply(next, curve, true), onCycleCurve: () => apply(fades, nextFadeCurve(curve), true), }; } + +interface FadeState { + fades: ClipFades; + curve: FadeCurve; +} + +/** + * The curve attribute a fade should carry, or null for none: only worth writing + * while there is a fade to shape, and "linear" is the default — leaving it off + * keeps the markup quiet. + */ +const curveAttribute = ({ fades, curve }: FadeState): FadeCurve | null => + (fades.fadeIn > 0 || fades.fadeOut > 0) && curve !== "linear" ? curve : null; + +/** The attribute writes moving from one fade state to another implies. */ +function fadeAttributeWrites(from: FadeState, to: FadeState): Array<[string, string | null]> { + const writes: Array<[string, string | null]> = []; + if (to.fades.fadeIn !== from.fades.fadeIn) { + writes.push([HF_FADE_IN_ATTR, seconds(to.fades.fadeIn)]); + } + if (to.fades.fadeOut !== from.fades.fadeOut) { + writes.push([HF_FADE_OUT_ATTR, seconds(to.fades.fadeOut)]); + } + const next = curveAttribute(to); + if (next !== curveAttribute(from)) writes.push([HF_FADE_CURVE_ATTR, next]); + return writes; +} + +/** Everything else: the fade is two attributes the runtime reads. */ +function visualFadeBinding( + element: TimelineElement, + writeAttribute: FadeAttributeWriter | undefined, + isSelected: boolean, + updateElement: ClipFadeDeps["updateElement"], +): ClipFadeBinding { + const declared = parseClipFade((name) => { + if (name === HF_FADE_IN_ATTR) return element.fadeIn ?? null; + if (name === HF_FADE_OUT_ATTR) return element.fadeOut ?? null; + return element.fadeCurve ?? null; + }); + const fades = clampClipFades( + { fadeIn: declared?.fadeIn ?? 0, fadeOut: declared?.fadeOut ?? 0 }, + element.duration, + ); + const curve = declared?.curve ?? "linear"; + const readOnly = !writeAttribute || !isSelected; + + const apply = (next: ClipFades, shape: FadeCurve, persist: boolean) => { + if (!writeAttribute || readOnly) return; + const to = { fades: clampClipFades(next, element.duration), curve: shape }; + const coalesce = { key: `clip-fade:${keyOf(element)}`, ms: FADE_COALESCE_MS }; + for (const [attr, value] of fadeAttributeWrites({ fades, curve }, to)) { + writeAttribute(attr, value, persist, coalesce); + } + // On COMMIT only. Applying it during the drag would move the value each + // write compares against, so by release "nothing changed" and the persisted + // write never happens — the preview would fade and the file would not. + if (persist) { + updateElement(keyOf(element), { + fadeIn: seconds(to.fades.fadeIn) ?? undefined, + fadeOut: seconds(to.fades.fadeOut) ?? undefined, + fadeCurve: curveAttribute(to) ?? undefined, + }); + } + }; + + return { + fades, + curve, + sample: visualFadeSampler(curve), + readOnly, + onPreview: (next) => apply(next, curve, false), + onCommit: (next) => apply(next, curve, true), + onCycleCurve: () => apply(fades, nextFadeCurve(curve), true), + }; +} + +/** + * The fade binding for a clip. Every clip gets one — what differs is where the + * fade is kept. + * + * Called for every clip the timeline draws, so it stays a read off already + * parsed state plus a few closures. + */ +export function resolveClipFadeBinding( + element: TimelineElement, + deps: ClipFadeDeps, +): ClipFadeBinding | undefined { + // A clip with no length has no window to fade across. + if (!(element.duration > 0)) return undefined; + if (isAudioTimelineElement(element)) { + return audioFadeBinding(element, deps.bindAutomation(element)); + } + return visualFadeBinding( + element, + deps.writeAttribute, + deps.isSelected(element), + deps.updateElement, + ); +} diff --git a/packages/studio/src/player/components/clipFades.test.ts b/packages/studio/src/player/components/clipFades.test.ts index f80a934789..6bc197f513 100644 --- a/packages/studio/src/player/components/clipFades.test.ts +++ b/packages/studio/src/player/components/clipFades.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import type { HfAutomationPoint } from "@hyperframes/core/audio-automation"; import { + audioFadeSampler, clampClipFades, fadeWedgePath, + visualFadeSampler, MIN_FADE_SECONDS, NO_FADES, readClipFades, @@ -113,34 +115,67 @@ describe("clampClipFades", () => { 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; + const wedge = (edge: "in" | "out", curve: "linear" | "smooth" = "linear") => + fadeWedgePath({ + edge, + seconds: 2, + sample: visualFadeSampler(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 + expect(path.at(0)).toEqual([0, HEIGHT]); // silent, at the very start + expect(path.at(-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 + expect(path.at(0)).toEqual([WIDTH - 50, 0]); // still at full level, 2s from the end + expect(path.at(-1)).toEqual([WIDTH, HEIGHT]); // silent, exactly on the end + }); + + it("draws an audio fade with the envelope's own curvature, not the runtime easing", () => { + // Both are "smooth", but each medium plays a different shape, so each is + // drawn with the sampler of the thing that will actually play it. + const visual = points( + fadeWedgePath({ + edge: "in", + seconds: 2, + sample: visualFadeSampler("smooth"), + pixelsPerSecond: 25, + width: WIDTH, + height: HEIGHT, + }).line, + ); + const audio = points( + fadeWedgePath({ + edge: "in", + seconds: 2, + sample: audioFadeSampler("smooth"), + pixelsPerSecond: 25, + width: WIDTH, + height: HEIGHT, + }).line, + ); + expect(visual.length).toBeGreaterThan(5); + expect(audio.length).toBeGreaterThan(5); + expect(audio).not.toEqual(visual); }); 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); + // Sampled at a point away from the midpoint: every symmetric easing passes + // through the middle of a straight line, so the middle proves nothing. + // The curve leaves silence slowly, so it sits BELOW the line at a quarter in + // (larger y is quieter). + const quarter = points(wedge("in", "smooth")).find(([x]) => Math.abs(x - 12.5) < 1.1); + // smoothstep(0.25) = 0.15625, so it is still mostly transparent here. + expect(quarter?.[1]).toBeCloseTo((1 - 0.15625) * HEIGHT, 1); }); it("draws nothing for a fade of no length", () => { @@ -148,7 +183,7 @@ describe("fadeWedgePath", () => { fadeWedgePath({ edge: "in", seconds: 0, - curve: "linear", + sample: visualFadeSampler("linear"), pixelsPerSecond: 25, width: WIDTH, height: HEIGHT, @@ -160,17 +195,15 @@ describe("fadeWedgePath", () => { const { line, fill } = fadeWedgePath({ edge: "in", seconds: 2, - curve: "linear", + sample: visualFadeSampler("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], - ]); + expect(points(line).at(0)).toEqual([0, HEIGHT]); + expect(points(line).at(-1)).toEqual([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 0eb7c04fde..209ea1bb2f 100644 --- a/packages/studio/src/player/components/clipFades.ts +++ b/packages/studio/src/player/components/clipFades.ts @@ -3,31 +3,38 @@ import { type HfAutomationLane, type HfAutomationPoint, } from "@hyperframes/core/audio-automation"; +import { fadeEase, type HfFadeCurve } from "@hyperframes/core/clip-fade"; 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. + * Fade-handle math, shared by both kinds of clip. * - * 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. + * The two store a fade in the place their medium already keeps that kind of + * information, and this module is what lets one gesture drive both: + * + * - **Visual** clips carry `data-fade-in` / `data-fade-out`, which the runtime + * applies. The curve is one of the runtime's own easings. + * - **Audio** clips carry the fade as the leading and trailing segment of their + * volume envelope, so it stays editable as breakpoints afterwards. The curve + * is the envelope's own segment curvature. + * + * Everything below is about the lengths, which behave identically either way; + * only the sampler used to DRAW the fade differs, and that is passed in. */ -/** 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. */ +export type FadeCurve = HfFadeCurve; + +/** Envelope curvature that best matches each named curve, for audio fades. */ +export const FADE_CURVES: Record = { + /** Straight line. */ 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; +export const FADE_CURVE_ORDER: readonly FadeCurve[] = ["linear", "smooth", "sharp"]; /** Shortest fade the handle will write; below this it reads as "no fade". */ export const MIN_FADE_SECONDS = 0.05; @@ -50,10 +57,11 @@ 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. + * Read the fades out of a volume 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[], @@ -94,8 +102,9 @@ export function readClipFades( /** * 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. + * capped at the clip. Split in proportion when both are dragged past the middle, + * so a long fade-in shortens the room left for a fade-out rather than fighting + * it — the same rule the runtime applies when it plays them. */ export function clampClipFades(fades: ClipFades, duration: number): ClipFades { const fadeIn = Math.max(0, Math.min(fades.fadeIn, duration)); @@ -106,7 +115,6 @@ export function clampClipFades(fades: ClipFades, duration: number): ClipFades { 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 }, @@ -114,6 +122,30 @@ export function clampClipFades(fades: ClipFades, duration: number): ClipFades { ); } +/** How a fade's level rises across its length, for whichever medium draws it. */ +export type FadeSampler = (progress: number) => number; + +/** The sampler a VISUAL fade is drawn with: the runtime's own easing. */ +export function visualFadeSampler(curve: FadeCurve): FadeSampler { + return (progress) => fadeEase(progress, curve); +} + +/** The sampler an AUDIO fade is drawn with: the envelope's segment curvature. */ +export function audioFadeSampler(curve: FadeCurve): FadeSampler { + const curvature = FADE_CURVES[curve]; + const lane: HfAutomationLane = { + target: "volume", + points: [ + { t: 0, v: 0, curve: curvature || undefined }, + { t: 1, v: 1 }, + ], + }; + return (progress) => sampleAutomationLane(lane, progress, "linear"); +} + +/** Segments a wedge is drawn with; enough that any easing reads smooth. */ +const WEDGE_SAMPLES = 24; + /** * The two SVG paths a fade draws, as one pair so they cannot disagree: * @@ -122,64 +154,45 @@ export function clampClipFades(fades: ClipFades, duration: number): ClipFades { * 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; + sample: FadeSampler; pixelsPerSecond: number; width: number; height: number; }): { line: string; fill: string } { - const { edge, seconds, curve, pixelsPerSecond, width, height } = input; + const { edge, seconds, sample, 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. + // its end. The out wedge therefore begins `span` short of the right edge. const xAt = (progress: number) => edge === "in" ? span * progress : width - span * (1 - progress); - const steps = curvature === 0 ? 1 : WEDGE_SAMPLES; + // Always sampled, never "detect a straight line and shortcut it": every + // symmetric easing passes through 0.5 at its midpoint, so the obvious probe + // says smoothstep is a straight line and draws it as one. const points: string[] = []; - for (let i = 0; i <= steps; i += 1) { - const progress = i / steps; - const level = sampleAutomationLane(lane, seconds * progress, "linear"); + for (let i = 0; i <= WEDGE_SAMPLES; i += 1) { + const progress = i / WEDGE_SAMPLES; + // A fade-out is the same rise read backwards. + const level = edge === "in" ? sample(progress) : sample(1 - progress); 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. + // The fill closes through the clip's own corner. 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. + * Rewrite a volume 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. Audio only; a visual fade is two attributes, not an envelope. */ export function writeClipFades( points: readonly HfAutomationPoint[], diff --git a/packages/studio/src/player/components/useClipFadeWriter.ts b/packages/studio/src/player/components/useClipFadeWriter.ts new file mode 100644 index 0000000000..58eb1c28da --- /dev/null +++ b/packages/studio/src/player/components/useClipFadeWriter.ts @@ -0,0 +1,60 @@ +import { useCallback, useMemo } from "react"; +import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { + resolveClipFadeBinding, + type ClipFadeBinding, + type FadeAttributeWriter, +} from "./clipFadeBinding"; +import type { UseAutomationLanesResult } from "./useAutomationLanes"; + +/** + * The visual fade's write path: one attribute on the selected clip. + * + * Same two-speed shape the automation lane uses — a preview-only write on every + * pointer move so the composition follows the drag without reloading, and one + * persisted write on release. Both carry the gesture's coalesce key so a whole + * drag collapses into a single undo step. + * + * Undefined outside an edit session: the player runs without one, and there the + * grips render read-only. + */ +function useClipFadeWriter(): FadeAttributeWriter | undefined { + const domEdit = useDomEditActionsContextOptional(); + return useMemo(() => { + if (!domEdit) return undefined; + return (attr, value, persist, coalesce) => { + if (persist) { + void domEdit.handleDomAttributeQuietCommit(attr, value, coalesce); + return; + } + void domEdit.handleDomAttributeLiveCommit(attr, value, undefined, { + coalesce, + previewOnly: true, + }); + }; + }, [domEdit]); +} + +/** + * Bind a clip's fade grips, whichever storage its medium uses. Takes the + * timeline's own automation binder rather than making a second one, so an audio + * fade and a hand-dragged breakpoint share one gesture-key sequence and land in + * the same undo entry. + */ +export function useClipFadeBinder( + automationLanes: UseAutomationLanesResult, +): (element: TimelineElement, isSelected: boolean) => ClipFadeBinding | undefined { + const writeAttribute = useClipFadeWriter(); + const updateElement = usePlayerStore((s) => s.updateElement); + return useCallback( + (element, isSelected) => + resolveClipFadeBinding(element, { + bindAutomation: (target) => automationLanes.bind(target, isSelected), + writeAttribute, + isSelected: () => isSelected, + updateElement, + }), + [automationLanes, writeAttribute, updateElement], + ); +} diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index e109822ce9..0276d9b355 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -63,6 +63,16 @@ export { // TimelineElement factories // --------------------------------------------------------------------------- +/** Carry the clip's fade attributes verbatim; the grips rewrite the text. */ +function readFadeAttributes(entry: TimelineElement, el: Element): void { + const fadeIn = el.getAttribute("data-fade-in"); + if (fadeIn) entry.fadeIn = fadeIn; + const fadeOut = el.getAttribute("data-fade-out"); + if (fadeOut) entry.fadeOut = fadeOut; + const fadeCurve = el.getAttribute("data-fade-curve"); + if (fadeCurve) entry.fadeCurve = fadeCurve; +} + function resolveClipTag(clip: ClipManifestClip): string { return clip.tagName || clip.kind || "div"; } @@ -142,6 +152,7 @@ export function createTimelineElementFromManifestClip(params: { if (fxChain) entry.fxChain = fxChain; const automation = hostEl.getAttribute("data-automation"); if (automation) entry.automation = automation; + readFadeAttributes(entry, hostEl); entry.zIndex = readTimelineElementZIndex(hostEl); } if (clip.assetUrl) entry.src = clip.assetUrl; @@ -345,6 +356,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel if (domFxChain) entry.fxChain = domFxChain; const domAutomation = el.getAttribute("data-automation"); if (domAutomation) entry.automation = domAutomation; + readFadeAttributes(entry, el); if (el.hasAttribute("data-timeline-locked")) { entry.timelineLocked = true; diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 6bf39e57ac..bc27f048c0 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -21,7 +21,7 @@ import { createTrimPreviewSlice, type TrimPreviewSlice } from "./trimPreviewSlic export type { KeyframeCacheEntry } from "./keyframeSlice"; export { liveTime } from "./liveTime"; -import type { TimelineElement } from "./timelineElement"; +import type { EditableTimelineFields, TimelineElement } from "./timelineElement"; export type { TimelineElement }; export type ZoomMode = "fit" | "manual"; @@ -143,15 +143,7 @@ interface PlayerState setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void; /** Move the selection anchor within an active multi-selection without collapsing it. */ setSelectionAnchor: (id: string | null) => void; - updateElement: ( - elementId: string, - updates: Partial< - Pick< - TimelineElement, - "start" | "duration" | "track" | "zIndex" | "hasExplicitZIndex" | "playbackStart" | "hidden" - > - >, - ) => void; + updateElement: (elementId: string, updates: Partial) => void; setZoomMode: (mode: ZoomMode) => void; setManualZoomPercent: (percent: number) => void; bumpZEditVersion: () => void; diff --git a/packages/studio/src/player/store/timelineElement.ts b/packages/studio/src/player/store/timelineElement.ts index 104e969890..55ed9e9723 100644 --- a/packages/studio/src/player/store/timelineElement.ts +++ b/packages/studio/src/player/store/timelineElement.ts @@ -57,6 +57,15 @@ export interface TimelineElement { /** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */ fxChain?: string; automation?: string; + /** + * Verbatim `data-fade-in` / `data-fade-out` / `data-fade-curve`. Carried as + * written rather than parsed here: the fade grips read and rewrite the + * attributes, and round-tripping the text is what keeps the timeline, the + * file and the running composition on one source of truth. + */ + fadeIn?: string; + fadeOut?: string; + fadeCurve?: string; /** Path from data-composition-src — identifies sub-composition elements */ compositionSrc?: string; /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */ @@ -76,3 +85,24 @@ export interface TimelineElement { expandedParentStart?: number; expandedHostKey?: string; } + +/** + * The fields a timeline edit may write straight back onto an element. + * + * Optimistic application is the pattern every timing edit here uses: apply, + * then persist, then reassert — so the surface that can be applied that way is + * named once rather than re-listed at each writer. + */ +export type EditableTimelineFields = Pick< + TimelineElement, + | "start" + | "duration" + | "track" + | "zIndex" + | "hasExplicitZIndex" + | "playbackStart" + | "hidden" + | "fadeIn" + | "fadeOut" + | "fadeCurve" +>;