feat(core): make data-fade-curve a bend you can drag, not three presets

The shape of a fade is something an editor pulls into place, so it has to
be able to land anywhere between the extremes. Three named curves could not
do that: picking one meant a menu or a blind cycle, and the answer was
always one of three.

`data-fade-curve` is now a number in [-1, 1]. Zero is a straight ramp,
negative sags the line so the fade starts slow and finishes fast, positive
bulges it the other way. Underneath is a single exponent, k = 2^(-2·curve),
which makes -0.5 exactly p² and +0.5 exactly √p, keeps every bend monotonic
so a fade never dips, and makes the two directions inverse functions of each
other. That last property is what makes dragging the line back through the
middle land on straight instead of drifting past it.

`fadeCurveThroughMidpoint` inverts the relation: given the level a pointer
is sitting at halfway through the fade, it returns the bend whose curve
passes through that point. Studio's drag is then direct manipulation rather
than a pointer nudging an abstract parameter.

The named curves are dropped rather than kept as aliases. Nothing has
shipped with them, and one attribute with two spellings is one too many.
This commit is contained in:
Miguel Angel Simon Sierra
2026-08-20 16:07:24 -04:00
parent 025e3e3d10
commit fc06dea181
2 changed files with 121 additions and 39 deletions
+76 -19
View File
@@ -1,14 +1,16 @@
import { describe, expect, it } from "vitest";
import {
clampFadeCurve,
clipFadeFilter,
clipFadeLevelAt,
fadeCurveThroughMidpoint,
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" };
const FADE: HfClipFade = { fadeIn: 1, fadeOut: 2, curve: 0 };
describe("parseClipFade", () => {
it("returns null for a clip that declares no fade", () => {
@@ -20,21 +22,25 @@ describe("parseClipFade", () => {
expect(parseClipFade(attrs({ "data-fade-in": "0.5" }))).toEqual({
fadeIn: 0.5,
fadeOut: 0,
curve: "linear",
curve: 0,
});
expect(parseClipFade(attrs({ "data-fade-out": "1.25" }))).toEqual({
fadeIn: 0,
fadeOut: 1.25,
curve: "linear",
curve: 0,
});
});
it("falls back to a straight ramp for a curve it does not know", () => {
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("smooth")).toBe("smooth");
expect(read("SHARP")).toBe("sharp");
expect(read("bezier-ish")).toBe("linear");
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", () => {
@@ -44,25 +50,76 @@ describe("parseClipFade", () => {
});
describe("fadeEase", () => {
it("pins both ends whatever the shape", () => {
for (const curve of ["linear", "smooth", "sharp"] as const) {
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, "smooth")).toBe(0);
expect(fadeEase(5, "smooth")).toBe(1);
expect(fadeEase(-5, -0.5)).toBe(0);
expect(fadeEase(5, -0.5)).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);
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);
});
});
@@ -102,7 +159,7 @@ describe("clipFadeLevelAt", () => {
});
it("holds a fade-out-only clip at full level until its tail", () => {
const out: HfClipFade = { fadeIn: 0, fadeOut: 2, curve: "linear" };
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);
});
+45 -20
View File
@@ -17,35 +17,60 @@ 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.
* How far a fade may bend away from a straight ramp, either way.
*
* - `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.
* 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 type HfFadeCurve = "linear" | "smooth" | "sharp";
export const FADE_CURVE_LIMIT = 1;
const FADE_CURVES: readonly HfFadeCurve[] = ["linear", "smooth", "sharp"];
/** 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;
curve: HfFadeCurve;
/** How the fade bends. See {@link fadeEase}. */
curve: number;
}
/** Ease a 0..1 progress through the named curve. */
export function fadeEase(progress: number, curve: HfFadeCurve): 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;
switch (curve) {
case "smooth":
return p * p * (3 - 2 * p);
case "sharp":
return p * p;
case "linear":
return p;
}
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 {
@@ -54,9 +79,9 @@ function parseSeconds(raw: string | null | undefined): number {
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";
function parseCurve(raw: string | null | undefined): number {
if (raw == null) return 0;
return clampFadeCurve(Number.parseFloat(raw));
}
/**