Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
121 changes: 121 additions & 0 deletions packages/core/src/clipFade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import {
clipFadeFilter,
clipFadeLevelAt,
fadeEase,
parseClipFade,
type HfClipFade,
} from "./clipFade";

const attrs = (record: Record<string, string>) => (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)");
});
});
133 changes: 133 additions & 0 deletions packages/core/src/clipFade.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading