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); }