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 4b133692f1..483ec647aa 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..c224c36b8e --- /dev/null +++ b/packages/core/src/clipFade.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { + clampFadeCurve, + clipFadeFilter, + clipFadeLevelAt, + fadeCurveThroughMidpoint, + fadeEase, + parseClipFade, + type HfClipFade, +} from "./clipFade"; + +const attrs = (record: Record) => (name: string) => record[name] ?? null; +const FADE: HfClipFade = { fadeIn: 1, fadeOut: 2, curve: 0 }; + +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: 0, + }); + expect(parseClipFade(attrs({ "data-fade-out": "1.25" }))).toEqual({ + fadeIn: 0, + fadeOut: 1.25, + curve: 0, + }); + }); + + it("reads the bend as a number, and anything else as straight", () => { + const read = (curve: string) => + parseClipFade(attrs({ "data-fade-in": "1", "data-fade-curve": curve }))?.curve; + expect(read("-0.5")).toBe(-0.5); + expect(read("0.75")).toBe(0.75); + // Past the limit is clamped, not rejected: an over-bent fade still fades. + expect(read("-4")).toBe(-1); + expect(read("4")).toBe(1); + expect(read("smooth")).toBe(0); + expect(read("")).toBe(0); + }); + + 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 however far it is bent", () => { + for (const curve of [-1, -0.5, 0, 0.5, 1]) { + expect(fadeEase(0, curve)).toBe(0); + expect(fadeEase(1, curve)).toBe(1); + } + }); + + it("clamps progress outside the fade", () => { + expect(fadeEase(-5, -0.5)).toBe(0); + expect(fadeEase(5, -0.5)).toBe(1); + }); + + it("is a straight ramp at zero, and at a bend it cannot use", () => { + expect(fadeEase(0.25, 0)).toBeCloseTo(0.25, 6); + expect(fadeEase(0.5, 0)).toBeCloseTo(0.5, 6); + expect(fadeEase(0.5, Number.NaN)).toBeCloseTo(0.5, 6); + }); + + it("sags below the line when bent negative and bulges above when positive", () => { + expect(fadeEase(0.5, -0.5)).toBeCloseTo(0.25, 6); + expect(fadeEase(0.5, 0.5)).toBeCloseTo(Math.SQRT1_2, 6); + expect(fadeEase(0.5, -1)).toBeLessThan(fadeEase(0.5, -0.5)); + expect(fadeEase(0.5, 1)).toBeGreaterThan(fadeEase(0.5, 0.5)); + }); + + it("pairs off: bending one way exactly undoes the other", () => { + // The two directions are inverse functions, which is what makes dragging + // the line back through the middle land on straight instead of drifting. + for (const p of [0.1, 0.35, 0.5, 0.8]) { + for (const curve of [0.25, 0.5, 1]) { + expect(fadeEase(fadeEase(p, curve), -curve)).toBeCloseTo(p, 6); + } + } + }); + + it("stays monotonic across the whole range, so a fade never dips", () => { + for (const curve of [-1, -0.4, 0, 0.4, 1]) { + let previous = -1; + for (let step = 0; step <= 40; step++) { + const level = fadeEase(step / 40, curve); + expect(level).toBeGreaterThanOrEqual(previous); + previous = level; + } + } + }); + + it("clamps a bend past the limit rather than running away", () => { + expect(fadeEase(0.5, -50)).toBeCloseTo(fadeEase(0.5, -1), 12); + expect(clampFadeCurve(-50)).toBe(-1); + expect(clampFadeCurve(Number.NaN)).toBe(0); + }); +}); + +describe("fadeCurveThroughMidpoint", () => { + it("returns the bend whose curve passes through the dragged point", () => { + for (const level of [0.1, 0.25, 0.5, 0.7, 0.84]) { + const curve = fadeCurveThroughMidpoint(level); + expect(fadeEase(0.5, curve)).toBeCloseTo(level, 4); + } + }); + + it("is straight when dragged back onto the line", () => { + expect(fadeCurveThroughMidpoint(0.5)).toBeCloseTo(0, 9); + }); + + it("clamps a pointer dragged past what the range can express", () => { + // Beyond the reachable band the curve stops following rather than + // inverting: 0.5^k is bounded by the bend limit at both ends. + expect(fadeCurveThroughMidpoint(0.001)).toBe(-1); + expect(fadeCurveThroughMidpoint(0.999)).toBe(1); + }); +}); + +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: 0 }; + 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..4ba1532f3a --- /dev/null +++ b/packages/core/src/clipFade.ts @@ -0,0 +1,158 @@ +/** + * 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"; + +/** + * How far a fade may bend away from a straight ramp, either way. + * + * The limit is what keeps the shape a fade rather than a hold: at 1 the curve + * already spends most of its length near one extreme, and going further buys + * nothing an editor can see. + */ +export const FADE_CURVE_LIMIT = 1; + +/** A bend outside the range, or not a number at all, resolves to straight. */ +export function clampFadeCurve(curve: number): number { + if (!Number.isFinite(curve)) return 0; + return Math.max(-FADE_CURVE_LIMIT, Math.min(FADE_CURVE_LIMIT, curve)); +} + +export interface HfClipFade { + /** Seconds of fade at the clip's head. */ + fadeIn: number; + /** Seconds of fade at the clip's tail. */ + fadeOut: number; + /** How the fade bends. See {@link fadeEase}. */ + curve: number; +} + +/** + * Ease a 0..1 progress through a bend. + * + * `curve` is one number rather than a set of named shapes, because the shape is + * something you drag: Studio lets you pull the fade line itself and the curve + * has to follow the pointer to anywhere in between, not snap to the nearest of + * three presets. + * + * 0 is a straight ramp. A negative bend sags the line, so the fade starts + * slowly and finishes fast. A positive bend bulges it, so the fade starts fast + * and finishes slowly. Under it all is an exponent, `k = 2^(-2 · curve)`, which + * makes -0.5 exactly `p²` and +0.5 exactly `√p` and the two directions mirror + * images of each other. + */ +export function fadeEase(progress: number, curve: number): number { + const p = progress <= 0 ? 0 : progress >= 1 ? 1 : progress; + const bend = clampFadeCurve(curve); + if (bend === 0) return p; + return Math.pow(p, Math.pow(2, -2 * bend)); +} + +/** + * The bend whose curve passes through `level` at the halfway point, which is + * how a drag on the fade line resolves to a number: the curve follows the + * pointer instead of the pointer nudging an abstract parameter. + */ +export function fadeCurveThroughMidpoint(level: number): number { + const clamped = Math.max(1e-4, Math.min(1 - 1e-4, level)); + // level = 0.5^k ⇒ k = ln(level) / ln(0.5), and k = 2^(-2·bend). + const k = Math.log(clamped) / Math.log(0.5); + return clampFadeCurve(-Math.log2(k) / 2); +} + +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): number { + if (raw == null) return 0; + return clampFadeCurve(Number.parseFloat(raw)); +} + +/** + * 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 c5c291e9fb..ed85b24948 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); }