diff --git a/docs/docs.json b/docs/docs.json
index fc27d745de..a4897d8ddf 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -212,6 +212,7 @@
"studio/canvas",
"studio/timeline",
"studio/trim-tools",
+ "studio/fades",
"studio/animation",
"studio/captions"
]
diff --git a/docs/studio/fades.mdx b/docs/studio/fades.mdx
new file mode 100644
index 0000000000..77791bb2c7
--- /dev/null
+++ b/docs/studio/fades.mdx
@@ -0,0 +1,127 @@
+---
+title: "Fade a clip in and out"
+description: "Draw fades on any clip in HyperFrames Studio, bend each ramp by dragging it, and author the same fade by hand."
+---
+
+Select a clip and two small square grips appear on its top corners. Drag one
+inward and it draws a fade: the clip comes up from nothing at the start, or goes
+away to nothing at the end.
+
+
+
+
+
+Every clip can fade, not only audio. What "fade" means depends on the clip:
+picture fades to nothing, sound fades to silence.
+
+## Draw a fade
+
+Grab the small square on a top corner and drag it towards the middle of the
+clip. The wedge follows as you drag, and the fade is written when you let go.
+New fades ease out by default, so they move quickly and settle softly into
+their end state.
+
+
+
+
+
+Drag a grip back to its corner to remove the fade. The whole gesture is one
+history entry, so a single undo takes the fade away.
+
+## Bend each ramp
+
+A straight fade is not always the one you want. Drag the dot sitting on a fade
+line to bend it, and the curve follows your pointer.
+
+
+
+
+
+- **Drag down** and the fade starts slowly and finishes fast. It holds near
+ nothing, then arrives late. Useful under a voice, where you want the music out
+ of the way early.
+- **Drag up** and the fade starts fast and finishes slowly. It gets most of the
+ way there immediately, then eases in. The least noticeable fade.
+- **Drag back through the middle** and it is exactly straight again.
+
+Each fade has its own dot and its own shape. Bending the one at the start leaves
+the one at the end exactly as it was. The dot only appears once there is a fade
+to bend; on a clip with none there is no line to pull, so start with the grips.
+
+## Where a fade is stored
+
+A fade is a value that moves across a clip's life, so it lives where every other
+moving value on a clip lives: the clip's own automation envelope. There is no
+fade attribute to learn. A picture's fade is a lane targeting `opacity`, a
+sound's is the `volume` lane it already had.
+
+```html
+
+ Hello
+
+```
+
+Four points: dark at 0s, full at 1.5s, holding until 6.8s, dark again at 8s.
+Times are the clip's own, not the timeline's, which is what lets a fade survive
+being moved or trimmed.
+
+| Field | Meaning |
+| --- | --- |
+| `target` | `opacity` for picture, `volume` for sound |
+| `t` | Seconds from the clip's start |
+| `v` | The level at that moment, 0 to 1 |
+| `curve` | Optional bend on the segment leaving this point, -1 to 1 |
+
+
+
+
+
+
+ The fade is applied as a CSS `filter`, not as `opacity`. Opacity is the
+ property animation engines drive, so a runtime writing it every frame would
+ fight your tweens for it. A filter multiplies with whatever they set, and it
+ composes onto whatever filter you wrote yourself. At full level the clip
+ carries no fade filter at all.
+
+
+## A fade is just an envelope
+
+Storing it this way means a fade is not a special case, and two useful things
+follow.
+
+**You can refine it by hand.** Expand the clip's automation lane and the fade is
+there as draggable breakpoints, on a video exactly as on music. The grips and
+the lane are two views of one thing.
+
+**You can stop it being a fade.** Drag a third point into the middle and it
+becomes an envelope: a dip under a voice, a flicker, a hold. Nothing has to be
+migrated, because it was always the same data.
+
+## Related topics
+
+- [Edit timing on the timeline](/studio/timeline)
+- [Trim with ripple, roll, slip, and slide](/studio/trim-tools)
diff --git a/docs/studio/timeline.mdx b/docs/studio/timeline.mdx
index 3b1819dec5..643a823f45 100644
--- a/docs/studio/timeline.mdx
+++ b/docs/studio/timeline.mdx
@@ -85,6 +85,7 @@ Move, trim, split, and many other timeline actions participate in Studio history
## Related topics
- [Trim with ripple, roll, slip, and slide](/studio/trim-tools)
+- [Fade a clip in and out](/studio/fades)
- [Edit the frame](/studio/canvas)
- [Edit animation and keyframes](/studio/animation)
- [Use Studio keyboard shortcuts](/studio/shortcuts)
diff --git a/docs/studio/trim-tools.mdx b/docs/studio/trim-tools.mdx
index 128c4a82d3..02f7132a0b 100644
--- a/docs/studio/trim-tools.mdx
+++ b/docs/studio/trim-tools.mdx
@@ -150,4 +150,5 @@ A tool tells you why instead of running a gesture with no effect:
## Related topics
- [Edit timing on the timeline](/studio/timeline)
+- [Fade a clip in and out](/studio/fades)
- [Use Studio keyboard shortcuts](/studio/shortcuts)
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/audioAutomation.ts b/packages/core/src/audioAutomation.ts
index ec873d6cdd..067b320411 100644
--- a/packages/core/src/audioAutomation.ts
+++ b/packages/core/src/audioAutomation.ts
@@ -80,14 +80,30 @@ export class AudioAutomationError extends Error {
export const VOLUME_TARGET = "volume";
+/**
+ * The picture's level, on the same footing as the sound's.
+ *
+ * A fade is a value that moves across a clip's life, which is what this whole
+ * module is for, so a fade on a picture belongs here rather than in attributes
+ * of its own. It also means one editor: a fade drawn on a video clip is the
+ * same two breakpoints, in the same lane UI, as a fade drawn on music.
+ *
+ * NOTE: this module is still called audioAutomation and still throws
+ * AudioAutomationError. The name is now too narrow. Renaming it touches 58
+ * files, so it is left for a change that does only that.
+ */
+export const OPACITY_TARGET = "opacity";
+
export type HfAutomationTarget =
| { kind: "volume" }
+ | { kind: "opacity" }
| { kind: "fx"; nodeId: string; param: string }
| { kind: "preset"; presetId: string };
/** Split a target string. Returns null for anything unrecognised. */
export function parseAutomationTarget(target: string): HfAutomationTarget | null {
if (target === VOLUME_TARGET) return { kind: "volume" };
+ if (target === OPACITY_TARGET) return { kind: "opacity" };
const parts = target.split(".");
// `fx.preset.` before the 3-part fx form, because it IS a 3-part fx form
// with a reserved node id — an effect can never be called "preset", since ids
@@ -159,6 +175,21 @@ export interface AutomationRange {
* automating a boosted clip silently discard the boost — and the panel
* disables the fader while a lane owns it, so there was no way back.
*/
+/**
+ * Opacity runs 0 to 1 and stops there. Unlike volume, which is allowed above
+ * unity because a quiet source sometimes needs lifting, there is nothing past
+ * fully opaque.
+ */
+export const OPACITY_RANGE: AutomationRange = {
+ min: 0,
+ max: 1,
+ default: 1,
+ step: 0.01,
+ unit: "",
+ label: "Opacity",
+ scale: "linear",
+};
+
export const VOLUME_RANGE: AutomationRange = {
min: 0,
max: MAX_AUDIO_GAIN,
@@ -181,6 +212,8 @@ export function resolveAutomationRange(
const parsed = parseAutomationTarget(target);
if (!parsed) return null;
if (parsed.kind === "volume") return VOLUME_RANGE;
+ // Not chain-dependent: the picture has a level whether or not there is audio.
+ if (parsed.kind === "opacity") return OPACITY_RANGE;
if (parsed.kind === "preset") {
// Only for a preset the chain actually carries, so a lane left behind by a
// removed preset resolves to nothing and is dropped at read time — the same
@@ -331,11 +364,18 @@ function normalizePoints(
* points of every lane and drop lanes that carry none. Range clamping and
* orphan removal need the chain and happen in `resolveAutomation`.
*/
+/** The range a target has on its own, before any chain is consulted. */
+function laneRangeWithoutChain(target: string): AutomationRange | null {
+ if (target === VOLUME_TARGET) return VOLUME_RANGE;
+ if (target === OPACITY_TARGET) return OPACITY_RANGE;
+ return null;
+}
+
export function normalizeAutomation(automation: HfAutomation): HfAutomation {
const lanes: HfAutomationLane[] = [];
for (const lane of automation.lanes) {
if (!parseAutomationTarget(lane.target)) continue;
- const range = lane.target === VOLUME_TARGET ? VOLUME_RANGE : null;
+ const range = laneRangeWithoutChain(lane.target);
const points = normalizePoints(lane.points ?? [], range);
if (points.length > 0) lanes.push({ target: lane.target, points });
}
diff --git a/packages/core/src/clipFade.test.ts b/packages/core/src/clipFade.test.ts
new file mode 100644
index 0000000000..a4c7648d5d
--- /dev/null
+++ b/packages/core/src/clipFade.test.ts
@@ -0,0 +1,217 @@
+import { describe, expect, it } from "vitest";
+import {
+ clipFadeFilter,
+ clipFadeLevelAt,
+ hasClipFadeAttributes,
+ opacityLane,
+ parseClipFade,
+} from "./clipFade";
+import { serializeAutomation, type HfAutomationPoint } from "./audioAutomation";
+
+const attrs = (record: Record) => (name: string) => record[name] ?? null;
+
+/** A clip whose picture ramps up over `seconds`, as authored markup would. */
+const fadeIn = (seconds: number, curve?: number): string =>
+ serializeAutomation({
+ version: 1,
+ lanes: [
+ {
+ target: "opacity",
+ points: [
+ { t: 0, v: 0, ...(curve ? { curve } : {}) },
+ { t: seconds, v: 1 },
+ ],
+ },
+ ],
+ });
+
+const lane = (points: HfAutomationPoint[]) => ({ target: "opacity", points });
+
+describe("parseClipFade", () => {
+ it("returns null for a clip with no automation at all", () => {
+ expect(parseClipFade(attrs({}))).toBeNull();
+ });
+
+ it("returns null when the automation is all about sound", () => {
+ const volumeOnly = serializeAutomation({
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 0 },
+ { t: 2, v: 1 },
+ ],
+ },
+ ],
+ });
+ expect(parseClipFade(attrs({ "data-automation": volumeOnly }))).toBeNull();
+ });
+
+ it("finds the opacity lane among the others", () => {
+ const both = serializeAutomation({
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 0 },
+ { t: 2, v: 1 },
+ ],
+ },
+ {
+ target: "opacity",
+ points: [
+ { t: 0, v: 0 },
+ { t: 1.5, v: 1 },
+ ],
+ },
+ ],
+ });
+ expect(parseClipFade(attrs({ "data-automation": both }))?.points).toEqual([
+ { t: 0, v: 0 },
+ { t: 1.5, v: 1 },
+ ]);
+ });
+
+ it("leaves the picture alone rather than hiding it when the envelope is broken", () => {
+ expect(parseClipFade(attrs({ "data-automation": "{not json" }))).toBeNull();
+ });
+
+ it("ignores an opacity lane with no points left in it", () => {
+ expect(opacityLane({ version: 1, lanes: [{ target: "opacity", points: [] }] })).toBeNull();
+ expect(opacityLane(null)).toBeNull();
+ });
+});
+
+describe("hasClipFadeAttributes", () => {
+ it("is one attribute lookup, because it runs on every clip every frame", () => {
+ const asked: string[] = [];
+ hasClipFadeAttributes((name) => {
+ asked.push(name);
+ return false;
+ });
+ expect(asked).toEqual(["data-automation"]);
+ });
+});
+
+describe("clipFadeLevelAt", () => {
+ it("rises across the ramp and holds full afterwards", () => {
+ const ramp = lane([
+ { t: 0, v: 0 },
+ { t: 2, v: 1 },
+ ]);
+ expect(clipFadeLevelAt(ramp, 0)).toBe(0);
+ expect(clipFadeLevelAt(ramp, 1)).toBeCloseTo(0.5, 6);
+ expect(clipFadeLevelAt(ramp, 2)).toBe(1);
+ expect(clipFadeLevelAt(ramp, 7)).toBe(1);
+ });
+
+ it("reads clip-local time, which is what lets a fade survive a move", () => {
+ // Nothing here knows where on the timeline the clip sits, so moving it
+ // cannot address the envelope at a moment that no longer exists.
+ const ramp = lane([
+ { t: 0, v: 0 },
+ { t: 2, v: 1 },
+ ]);
+ expect(clipFadeLevelAt(ramp, -5)).toBe(0);
+ });
+
+ it("holds the head level before the first point, so a late fade in is not a flash", () => {
+ const late = lane([
+ { t: 1, v: 0 },
+ { t: 3, v: 1 },
+ ]);
+ expect(clipFadeLevelAt(late, 0)).toBe(0);
+ expect(clipFadeLevelAt(late, 0.5)).toBe(0);
+ });
+
+ it("carries a fade in and a fade out in one envelope", () => {
+ const both = lane([
+ { t: 0, v: 0 },
+ { t: 1, v: 1 },
+ { t: 5, v: 1 },
+ { t: 6, v: 0 },
+ ]);
+ expect(clipFadeLevelAt(both, 0)).toBe(0);
+ expect(clipFadeLevelAt(both, 0.5)).toBeCloseTo(0.5, 6);
+ expect(clipFadeLevelAt(both, 3)).toBe(1);
+ expect(clipFadeLevelAt(both, 5.5)).toBeCloseTo(0.5, 6);
+ expect(clipFadeLevelAt(both, 6)).toBe(0);
+ });
+
+ it("bends each ramp on its own, because the bend lives on its own point", () => {
+ // A single shared curve could never say this: the head eases and the tail
+ // does not. The envelope has carried a bend per point all along.
+ const apart = lane([
+ { t: 0, v: 0, curve: 0.5 },
+ { t: 2, v: 1 },
+ { t: 6, v: 1 },
+ { t: 8, v: 0 },
+ ]);
+ const straight = lane([
+ { t: 0, v: 0 },
+ { t: 2, v: 1 },
+ { t: 6, v: 1 },
+ { t: 8, v: 0 },
+ ]);
+ expect(clipFadeLevelAt(apart, 1)).not.toBeCloseTo(clipFadeLevelAt(straight, 1), 3);
+ expect(clipFadeLevelAt(apart, 7)).toBeCloseTo(clipFadeLevelAt(straight, 7), 6);
+ });
+
+ it("stops a third point from being a special case", () => {
+ // Drop a point in the middle and it is no longer a fade, it is an envelope.
+ // Nothing here has to notice, which is the point of storing it this way.
+ const dip = lane([
+ { t: 0, v: 1 },
+ { t: 2, v: 0.2 },
+ { t: 4, v: 1 },
+ ]);
+ expect(clipFadeLevelAt(dip, 2)).toBeCloseTo(0.2, 6);
+ expect(clipFadeLevelAt(dip, 4)).toBe(1);
+ });
+
+ it("clamps whatever the envelope says into a level a filter can use", () => {
+ const wild = lane([
+ { t: 0, v: -3 },
+ { t: 1, v: 9 },
+ ]);
+ expect(clipFadeLevelAt(wild, 0)).toBe(0);
+ expect(clipFadeLevelAt(wild, 1)).toBe(1);
+ });
+});
+
+describe("clipFadeFilter", () => {
+ it("leaves the authored filter untouched at full level", () => {
+ expect(clipFadeFilter("blur(2px)", 1)).toBe("blur(2px)");
+ expect(clipFadeFilter("", 1)).toBe("");
+ });
+
+ it("composes onto whatever the author wrote rather than replacing it", () => {
+ expect(clipFadeFilter("blur(2px)", 0.5)).toBe("blur(2px) opacity(0.5000)");
+ });
+
+ it("is the only filter when the author wrote none", () => {
+ expect(clipFadeFilter("", 0.25)).toBe("opacity(0.2500)");
+ });
+
+ it("never emits a negative level", () => {
+ expect(clipFadeFilter("", -1)).toBe("opacity(0.0000)");
+ });
+});
+
+describe("the round trip an author would take", () => {
+ it("reads back a hand-written fade and plays it", () => {
+ const fade = parseClipFade(attrs({ "data-automation": fadeIn(1.5) }));
+ expect(fade).not.toBeNull();
+ expect(clipFadeLevelAt(fade!, 0)).toBe(0);
+ expect(clipFadeLevelAt(fade!, 0.75)).toBeCloseTo(0.5, 6);
+ expect(clipFadeLevelAt(fade!, 1.5)).toBe(1);
+ });
+
+ it("keeps a bend the author wrote on the point", () => {
+ const bent = parseClipFade(attrs({ "data-automation": fadeIn(2, 0.5) }));
+ expect(bent?.points[0]?.curve).toBeCloseTo(0.5, 6);
+ expect(clipFadeLevelAt(bent!, 1)).not.toBeCloseTo(0.5, 3);
+ });
+});
diff --git a/packages/core/src/clipFade.ts b/packages/core/src/clipFade.ts
new file mode 100644
index 0000000000..1c08deca09
--- /dev/null
+++ b/packages/core/src/clipFade.ts
@@ -0,0 +1,97 @@
+/**
+ * Clip fades: the picture's level over a clip's life.
+ *
+ * A fade is not an animation, it is a value that moves, so it lives where every
+ * other moving value on a clip lives: `data-automation`, in a lane targeting
+ * `opacity`. That is the whole storage. There is no `data-fade-in` and there
+ * never should be, because a fade is a two-point envelope and the framework
+ * already has envelopes.
+ *
+ * What this buys, beyond one fewer attribute to learn:
+ *
+ * - **A fade survives editing.** The points are data, not a tween, so a trim or
+ * a move cannot leave a fade addressed to a moment that no longer exists.
+ * - **A fade is not a special case.** Drag a third point into the middle and it
+ * stops being a fade and becomes an envelope, in the same lane, with no
+ * migration and nothing to reconcile.
+ * - **One editor.** The automation lane already draws and edits breakpoints for
+ * sound; picture gets it for free.
+ *
+ * Levels are applied as a `filter`, never as `opacity` — see `clipFadeFilter`.
+ */
+
+import {
+ OPACITY_TARGET,
+ parseAutomation,
+ sampleAutomationLane,
+ type HfAutomation,
+ type HfAutomationLane,
+} from "./audioAutomation";
+
+export const HF_AUTOMATION_ATTR = "data-automation";
+
+/** The opacity lane of a clip's automation, or null when it carries none. */
+export function opacityLane(automation: HfAutomation | null): HfAutomationLane | null {
+ if (!automation) return null;
+ const lane = automation.lanes.find((l) => l.target === OPACITY_TARGET);
+ return lane && lane.points.length > 0 ? lane : null;
+}
+
+/**
+ * Whether the element declares a picture level 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 a single attribute lookup.
+ */
+export function hasClipFadeAttributes(hasAttribute: (name: string) => boolean): boolean {
+ return hasAttribute(HF_AUTOMATION_ATTR);
+}
+
+/**
+ * Read a clip's opacity lane out of its automation attribute, or null when it
+ * has none. Takes a 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,
+): HfAutomationLane | null {
+ const raw = getAttribute(HF_AUTOMATION_ATTR);
+ if (!raw) return null;
+ try {
+ return opacityLane(parseAutomation(raw));
+ } catch {
+ // A malformed envelope is the audio path's problem to report; the picture
+ // just stays at full level rather than disappearing.
+ return null;
+ }
+}
+
+/**
+ * The clip's level at `elapsed` seconds into its own window: 1 fully visible,
+ * 0 gone.
+ *
+ * Times are clip-local, so the envelope is addressed to the clip and not to the
+ * timeline. That is what lets a fade survive a move: nothing in here knows
+ * where on the timeline the clip currently sits.
+ */
+export function clipFadeLevelAt(lane: HfAutomationLane, elapsed: number): number {
+ const level = sampleAutomationLane(lane, Math.max(0, elapsed), "linear");
+ 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..260b8ac245 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);
+ 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/FadeDiamond.tsx b/packages/studio/src/player/components/FadeDiamond.tsx
new file mode 100644
index 0000000000..2f8e7f29c0
--- /dev/null
+++ b/packages/studio/src/player/components/FadeDiamond.tsx
@@ -0,0 +1,101 @@
+import type { FadeSampler } from "./clipFades";
+
+/**
+ * The fade handle: a diamond with the fade's own curve drawn inside it.
+ *
+ * The glyph is a readout, not decoration. Bend the fade and the line inside the
+ * diamond bends with it, sampled from the same function that will play it, so
+ * the handle answers "what shape is this fade" without the reader having to
+ * trace a two-pixel line across a busy clip. It is also why one handle can own
+ * both gestures: the thing you are dragging shows you which one you changed.
+ *
+ * A diamond rather than a square or a circle because it reads as a point ON a
+ * curve, which is exactly what it is, and because nothing else on the clip bar
+ * is diamond-shaped: at a glance you can tell a fade handle from a trim handle
+ * without aiming at it.
+ */
+
+export interface FadeDiamondProps {
+ /** Diamond width and height, in px. */
+ size: number;
+ /** How the fade rises, for the curve drawn inside. */
+ sample: FadeSampler;
+ /** A fade-out is the same rise read backwards, so its glyph mirrors. */
+ edge: "in" | "out";
+ /** The clip's accent, which the curve is stroked in. */
+ accent: string;
+ /** Dimmed until the clip is worth aiming at. */
+ active: boolean;
+}
+
+/** Points along the inner curve; enough that any bend reads as a curve. */
+const SAMPLES = 12;
+
+/**
+ * The box the curve is drawn in, inset from the diamond's points where the
+ * shape is too narrow to show a line anyway.
+ *
+ * Wider than it is tall, deliberately. A square box draws a straight ramp along
+ * the diamond's own corners, at the same angle as the fade line passing behind
+ * it, and the glyph then reads as that line slicing through rather than as a
+ * picture of the shape. Flattened, the inside is always its own mark.
+ */
+const PAD_X = 0.24;
+const PAD_Y = 0.33;
+
+/**
+ * The curve, and the area under it, in the diamond's own 0..1 box.
+ *
+ * The fill is what stops a straight ramp reading as the fade line cutting the
+ * diamond in half: a line has two identical sides, a wedge has a bottom. It is
+ * also the same picture as the wedge on the clip, one shrunk into the handle
+ * that draws it.
+ */
+function innerCurve(sample: FadeSampler, edge: "in" | "out"): { line: string; fill: string } {
+ const spanX = 1 - PAD_X * 2;
+ const spanY = 1 - PAD_Y * 2;
+ const floor = 1 - PAD_Y;
+ const points: string[] = [];
+ for (let i = 0; i <= SAMPLES; i += 1) {
+ const progress = i / SAMPLES;
+ const level = edge === "in" ? sample(progress) : sample(1 - progress);
+ const x = PAD_X + spanX * progress;
+ const y = floor - spanY * level;
+ points.push(`${x.toFixed(3)} ${y.toFixed(3)}`);
+ }
+ const line = `M ${points.join(" L ")}`;
+ return { line, fill: `${line} L ${(1 - PAD_X).toFixed(3)} ${floor} L ${PAD_X} ${floor} Z` };
+}
+
+export function FadeDiamond({ size, sample, edge, accent, active }: FadeDiamondProps) {
+ const { line, fill } = innerCurve(sample, edge);
+ return (
+
+ );
+}
diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx
index 7249132f57..c6d18c66a5 100644
--- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { act } from "react";
import { describe, expect, it, vi } from "vitest";
+import { lanesOf, NARRATION_1_CHAIN, NARRATION_2_CHAIN } from "./automationLaneFixtures";
import { createRoot } from "react-dom/client";
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
@@ -57,24 +58,13 @@ function mountSlot(binding: Partial) {
return { onRangeClear };
}
-/** Two narration slices sharing a row, each with its own chain. */
-const chainOf = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
-const lanesOf = (...targets: string[]) =>
- JSON.stringify({
- version: 1,
- lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })),
- });
-
const narration1: TimelineElement = {
...element,
id: "narration-1",
key: "narration-1",
start: 0,
duration: 4,
- fxChain: chainOf([
- { type: "lowpass", id: "n1", params: { frequency: 8000, q: 0.7, poles: "2" } },
- { type: "peaking", id: "n2", params: { frequency: 1000, gain: -3, q: 1.4 } },
- ]),
+ fxChain: NARRATION_1_CHAIN,
automation: lanesOf("fx.n2.q"),
};
const narration2: TimelineElement = {
@@ -83,7 +73,7 @@ const narration2: TimelineElement = {
key: "narration-2",
start: 4,
duration: 4,
- fxChain: chainOf([{ type: "peaking", id: "n1", params: { frequency: 1000, gain: -6, q: 1.4 } }]),
+ fxChain: NARRATION_2_CHAIN,
automation: lanesOf("fx.n1.q", "volume"),
};
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..db7d33520d 100644
--- a/packages/studio/src/player/components/TimelineClipFades.tsx
+++ b/packages/studio/src/player/components/TimelineClipFades.tsx
@@ -2,20 +2,30 @@ import { useCallback, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import {
clampClipFades,
+ envelopeFadeSampler,
fadeWedgePath,
MIN_FADE_SECONDS,
+ type ClipFadeCurves,
type ClipFades,
- type FadeCurve,
} from "./clipFades";
+import {
+ bendFromPointer,
+ fadeHandlePosition,
+ lengthFromPointer,
+ resolveDragAxis,
+ type FadeDragAxis,
+} from "./clipFadeBendDrag";
+import { FadeDiamond } from "./FadeDiamond";
+import { CLIP_HANDLE_W } from "./timelineLayout";
/**
- * The fade grips on a clip's top corners, and the wedges they draw.
+ * The fade handles on a clip's top corners, and the wedges they draw.
*
* The gesture is deliberately local rather than folded into the timeline's drag
* coordinator: a fade has no lane to change, nothing to snap to and nothing to
* collide with, so all the machinery that gesture owns would sit unused. What
- * it does need — a live preview on every move and one persisted write on
- * release — the automation binding already provides.
+ * it does need, a live preview on every move and one persisted write on
+ * release, the automation binding already provides.
*/
export interface TimelineClipFadesProps {
@@ -25,148 +35,317 @@ export interface TimelineClipFadesProps {
duration: number;
pixelsPerSecond: number;
width: number;
- curve: FadeCurve;
- /** Grips are hidden until the clip is worth aiming at. */
+ /** How far each ramp is bent; 0 is a straight one. */
+ curves: ClipFadeCurves;
+ /** The curve an in-progress fade gesture will draw. */
+ curvesFor(next: ClipFades): ClipFadeCurves;
+ /**
+ * 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;
+ /** Handles 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. */
readOnly: boolean;
- /** Double-clicking a grip steps the fade through its curve shapes. */
- onCycleCurve(): void;
+ /** Dragging a handle up or down bends that fade. Live, then once on release. */
+ onBend(edge: "in" | "out", curve: number, persist: boolean): void;
/** Live during the drag: preview only, never persisted. */
onPreview(next: ClipFades): void;
/** Once, on release. */
onCommit(next: ClipFades): void;
}
-/** Side of the grip square, in px. Matches the trim handle's visual weight. */
-const GRIP = 9;
+/**
+ * One handle per fade, not two.
+ *
+ * A fade has two properties and they used to have a control each, which put
+ * four handles on a clip that is often eighty pixels wide, sitting on top of
+ * the thumbnails and the label and the trim handles. The diamond collapses each
+ * pair: it lives on the curve's midpoint, so sideways is the length and up and
+ * down is the bend, and both stay under the pointer because the midpoint is
+ * exactly the point those two gestures move. What you gain over two controls is
+ * not just space, it is that the handle draws the fade's own curve inside
+ * itself, so it says what shape it is rather than only where it is.
+ */
+const DIAMOND = 13;
+
+/**
+ * How far a handle is held off the clip's edge.
+ *
+ * The clip is `overflow: hidden` with a rounded corner, so a handle centred on
+ * the corner loses its outer half to the clip and another bite to the radius.
+ * Parking it fully inside is the difference between a handle and a smear.
+ */
+const HANDLE_INSET = 2;
+
+/** Keep a handle's box inside the clip it belongs to. */
+function insetWithin(position: number, size: number, extent: number): number {
+ const limit = Math.max(HANDLE_INSET, extent - size - HANDLE_INSET);
+ return Math.max(HANDLE_INSET, Math.min(position, limit));
+}
+
+/**
+ * Where a handle's centre parks when the clip has no fade yet.
+ *
+ * Not the very corner: the trim handle already owns that strip, and two
+ * controls sharing ten pixels means every grab is a coin toss over which one
+ * you got. Just inboard of it reads as the same corner without being the same
+ * pixels.
+ *
+ * It is also the spot that makes the first drag continuous. The handle is a
+ * midpoint, so parking its centre here is the same as claiming a fade twice
+ * this wide, and the moment the pointer moves the maths already agrees with
+ * where your finger is. Park it anywhere else and the fade jumps on contact.
+ */
+function parkedCentre(edge: "in" | "out", width: number): number {
+ const inboard = CLIP_HANDLE_W / 2;
+ return edge === "in" ? inboard : width - inboard;
+}
+
+/** What the bend reads as, for a title and for a screen reader. */
+function bendLabel(curve: number): string {
+ if (Math.abs(curve) < 0.02) return "Straight";
+ return curve < 0 ? "Starts slow, finishes fast" : "Starts fast, finishes slow";
+}
+
+/** What the handle says it is, to a pointer and to a screen reader. */
+function handleWording(edge: "in" | "out", seconds: number, drawn: boolean, shape: string) {
+ if (!drawn) {
+ return {
+ label: edge === "in" ? "Fade in" : "Fade out",
+ valueText: "No fade",
+ title: `Drag in to fade ${edge}.`,
+ };
+ }
+ const length = seconds.toFixed(2);
+ return {
+ label: edge === "in" ? "Fade in" : "Fade out",
+ valueText: `${length} seconds, ${shape.toLowerCase()}`,
+ title: `Fade ${edge}, ${length}s. Drag sideways for length, up or down to bend. ${shape}.`,
+ };
+}
/**
* Vertical units the wedge is drawn in. A clip's height is set by the row, and
* sometimes by `bottom` rather than a number, so the overlay draws in its own
- * space and lets the SVG stretch it — the horizontal axis stays in real pixels,
+ * space and lets the SVG stretch it. The horizontal axis stays in real pixels,
* which is the axis a fade's length is read off.
*/
const VIEW_HEIGHT = 100;
+/** What a drag needs to know about the clip it started on. */
+interface FadeDrag {
+ edge: "in" | "out";
+ originX: number;
+ originY: number;
+ /** The clip's own box, measured once on the way down. */
+ left: number;
+ top: number;
+ height: number;
+ /** Forced when there is no fade yet: nothing to bend until one exists. */
+ axis: FadeDragAxis | null;
+ from: ClipFades;
+}
+
export function TimelineClipFades({
fades,
duration,
pixelsPerSecond,
width,
- curve,
+ curves,
+ curvesFor,
+ accent,
showGrips,
readOnly,
onPreview,
onCommit,
- onCycleCurve,
+ onBend,
}: TimelineClipFadesProps) {
// While dragging, the drawn fades come from here: the committed value only
// catches up once the write lands, and the wedge has to track the pointer.
const [draft, setDraft] = useState(null);
- const dragRef = useRef<{ edge: "in" | "out"; originX: number; from: ClipFades } | null>(null);
+ // Keyed by edge, because only the ramp being dragged is in draft: the other
+ // one has to keep drawing its own committed shape underneath.
+ const [bendDraft, setBendDraft] = useState<{ edge: "in" | "out"; curve: number } | null>(null);
+ const dragRef = useRef(null);
+ const hostRef = useRef(null);
const shown = draft ?? fades;
+ const shownCurves = curvesFor(shown);
+ // The bend is previewed the same way the lengths are: the committed value
+ // only catches up once the write lands, and the line has to stay under the
+ // pointer until then.
+ const shownCurve = (edge: "in" | "out") =>
+ bendDraft?.edge === edge ? bendDraft.curve : edge === "in" ? shownCurves.in : shownCurves.out;
+ const shownSample = (edge: "in" | "out") => envelopeFadeSampler(shownCurve(edge));
- const onGripDown = useCallback(
+ const onHandleDown = useCallback(
(edge: "in" | "out", event: ReactPointerEvent) => {
if (event.button !== 0) return;
- // The grip sits on top of the trim handle and inside the clip body; both
- // would otherwise start their own gesture from this same press. NOT
- // preventDefault: that suppresses the compatibility click events, and the
- // double-click that cycles the curve is one of them.
+ // The handle sits on top of the trim handle and inside the clip body;
+ // both would otherwise start their own gesture from this same press.
event.stopPropagation();
+ const box = hostRef.current?.getBoundingClientRect();
+ if (!box || !(box.height > 0)) return;
event.currentTarget.setPointerCapture(event.pointerId);
- dragRef.current = { edge, originX: event.clientX, from: fades };
+ const seconds = edge === "in" ? fades.fadeIn : fades.fadeOut;
+ dragRef.current = {
+ edge,
+ originX: event.clientX,
+ originY: event.clientY,
+ left: box.left,
+ top: box.top,
+ height: box.height,
+ // A clip with no fade has no curve to pull on, so every direction draws
+ // one instead of half of them doing nothing.
+ axis: seconds >= MIN_FADE_SECONDS ? null : "length",
+ from: fades,
+ };
},
[fades],
);
- const resolveDrag = useCallback(
- (clientX: number): ClipFades | null => {
+ /**
+ * What the pointer is currently asking for, on whichever axis this drag
+ * locked to. Returns null until it has moved far enough to have one, which is
+ * what keeps a mis-aimed click from editing anything.
+ */
+ const resolve = useCallback(
+ (event: { clientX: number; clientY: number }) => {
const drag = dragRef.current;
- if (!drag || pixelsPerSecond <= 0) return null;
- // Both grips are dragged INTO the clip, so the out grip reads the
- // opposite sign — its fade grows as the pointer travels left.
- const travel = (clientX - drag.originX) / pixelsPerSecond;
- const delta = drag.edge === "in" ? travel : -travel;
+ if (!drag) return null;
+ drag.axis ??= resolveDragAxis(event.clientX - drag.originX, event.clientY - drag.originY);
+ if (!drag.axis) return null;
+ if (drag.axis === "bend") {
+ return {
+ axis: "bend" as const,
+ edge: drag.edge,
+ curve: bendFromPointer(event.clientY - drag.top, drag.height),
+ };
+ }
+ const seconds = lengthFromPointer({
+ edge: drag.edge,
+ offsetX: event.clientX - drag.left,
+ pixelsPerSecond,
+ width,
+ });
const next =
- drag.edge === "in"
- ? { ...drag.from, fadeIn: Math.max(0, drag.from.fadeIn + delta) }
- : { ...drag.from, fadeOut: Math.max(0, drag.from.fadeOut + delta) };
- return clampClipFades(next, duration);
+ drag.edge === "in" ? { ...drag.from, fadeIn: seconds } : { ...drag.from, fadeOut: seconds };
+ return { axis: "length" as const, fades: clampClipFades(next, duration) };
},
- [duration, pixelsPerSecond],
+ [duration, pixelsPerSecond, width],
);
- const onGripMove = useCallback(
+ const onHandleMove = useCallback(
(event: ReactPointerEvent) => {
- const next = resolveDrag(event.clientX);
+ const next = resolve(event);
if (!next) return;
- setDraft(next);
- onPreview(next);
+ if (next.axis === "bend") {
+ setBendDraft({ edge: next.edge, curve: next.curve });
+ onBend(next.edge, next.curve, false);
+ return;
+ }
+ setDraft(next.fades);
+ onPreview(next.fades);
},
- [onPreview, resolveDrag],
+ [onBend, onPreview, resolve],
);
- const onGripUp = useCallback(
+ const onHandleUp = useCallback(
(event: ReactPointerEvent) => {
- const next = resolveDrag(event.clientX);
- const from = dragRef.current?.from;
+ const next = resolve(event);
+ const drag = dragRef.current;
dragRef.current = null;
setDraft(null);
- // A press that moved nothing — the first half of a double-click, or a
- // mis-aimed click — must not write the same fade back to the file.
- if (next && from && (next.fadeIn !== from.fadeIn || next.fadeOut !== from.fadeOut)) {
- onCommit(next);
+ setBendDraft(null);
+ if (!next || !drag) return;
+ // A press that changed nothing must not write the same value back to the
+ // file: a mis-aimed click is not an edit.
+ if (next.axis === "bend") {
+ const was = next.edge === "in" ? curves.in : curves.out;
+ if (next.curve !== was) onBend(next.edge, next.curve, true);
+ return;
}
+ const changed =
+ next.fades.fadeIn !== drag.from.fadeIn || next.fades.fadeOut !== drag.from.fadeOut;
+ if (changed) onCommit(next.fades);
},
- [onCommit, resolveDrag],
+ [curves, onBend, onCommit, resolve],
);
- const gripFor = (edge: "in" | "out") => {
+ const handleFor = (edge: "in" | "out") => {
const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut;
- const span = Math.min(seconds * pixelsPerSecond, width);
- // Parked on the corner when there is no fade, which is where you grab to
- // start one; otherwise it rides the top of the wedge it drew.
- const x = edge === "in" ? span : width - span;
+ const drawn = seconds >= MIN_FADE_SECONDS;
+ const sampler = shownSample(edge);
+ const height = hostRef.current?.getBoundingClientRect().height ?? 0;
+ const at = fadeHandlePosition({
+ edge,
+ seconds,
+ pixelsPerSecond,
+ width,
+ height,
+ level: sampler(0.5),
+ });
+ // With no fade the handle waits on the corner, held fully inside the clip:
+ // there is no curve under it yet to ride.
+ const centreX = at?.x ?? parkedCentre(edge, width);
+ const centreY = at?.y ?? HANDLE_INSET + DIAMOND / 2;
+ const curve = shownCurve(edge);
+ const wording = handleWording(edge, seconds, drawn, bendLabel(curve));
return (
= MIN_FADE_SECONDS ? `${seconds.toFixed(2)} seconds` : "No fade"}
+ aria-valuetext={wording.valueText}
+ // Kept as two hooks on one element: the handle does both jobs now, and
+ // anything that reached for either of them still finds it.
data-clip-fade-grip={edge}
- onPointerDown={(event) => onGripDown(edge, event)}
- onPointerMove={onGripMove}
- onPointerUp={onGripUp}
- onPointerCancel={onGripUp}
- onDoubleClick={(event) => {
- event.stopPropagation();
- if (seconds > 0) onCycleCurve();
- }}
- title={`Fade ${edge}: drag to set its length, double-click to change its ${curve} curve`}
+ data-clip-fade-bend={edge}
+ data-clip-fade-curve={curve}
+ onPointerDown={(event) => onHandleDown(edge, event)}
+ onPointerMove={onHandleMove}
+ onPointerUp={onHandleUp}
+ onPointerCancel={onHandleUp}
+ title={wording.title}
+ // Quiet until you go for it: parked in the corner it shares space with
+ // the clip's own label, and a solid mark there reads as damage rather
+ // than as a handle.
+ className={`${drawn ? "opacity-90" : "opacity-60"} hover:opacity-100 transition-opacity`}
style={{
position: "absolute",
- left: x - GRIP / 2,
- top: 1,
- width: GRIP,
- height: GRIP,
- borderRadius: 2,
- background: "rgba(255,255,255,0.9)",
- boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
- cursor: "ew-resize",
+ left: insetWithin(centreX - DIAMOND / 2, DIAMOND, width),
+ // Clamped across, free up and down. A clip row is 42px and a bend at
+ // the limit puts the curve within 3px of the edge, so a handle held
+ // inside could not ride the curve past about half the bend range. The
+ // clip does crop it at the extremes; drifting out from under the
+ // pointer would be the worse of the two, because the whole gesture is
+ // that the curve goes where you put it.
+ top: centreY - DIAMOND / 2,
+ width: DIAMOND,
+ height: DIAMOND,
+ // Both axes do something, so neither arrow tells the truth on its own.
+ cursor: "move",
+ pointerEvents: "auto",
+ filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.55))",
zIndex: 6,
}}
- />
+ >
+
+
);
};
return (
- <>
+ // One positioned box owns the overlay so the handles have a parent whose
+ // box they can measure, and so they share the clip's coordinates.
+
@@ -204,7 +384,7 @@ export function TimelineClipFades({
);
})}
- {showGrips && !readOnly && (["in", "out"] as const).map(gripFor)}
- >
+ {showGrips && !readOnly && (["in", "out"] as const).map(handleFor)}
+
);
}
diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx
index 9e839a29c4..9272f0707f 100644
--- a/packages/studio/src/player/components/TimelineLanes.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.tsx
@@ -392,9 +392,9 @@ 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 = resolveClipFadeBinding(el, {
+ bindAutomation: (element) => automationLanes.bind(element, isSelected),
+ });
const clipGestures = createClipGestureHandlers(
el,
elementKey,
diff --git a/packages/studio/src/player/components/automationLaneData.test.ts b/packages/studio/src/player/components/automationLaneData.test.ts
index b1caf30474..0839425155 100644
--- a/packages/studio/src/player/components/automationLaneData.test.ts
+++ b/packages/studio/src/player/components/automationLaneData.test.ts
@@ -9,6 +9,7 @@ import {
elementFxChain,
} from "./automationLaneData";
import type { TimelineElement } from "../store/timelineElement";
+import { lanesOf, NARRATION_1_CHAIN, NARRATION_2_CHAIN } from "./automationLaneFixtures";
const el = (over: Partial = {}): TimelineElement => ({
id: "bgm",
@@ -208,30 +209,16 @@ describe("laneGroupKey", () => {
});
describe("groupAutomationLanes", () => {
- const chainOf = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
- const lanesOf = (...targets: string[]) =>
- JSON.stringify({
- version: 1,
- lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })),
- });
-
- // Two narration slices sharing a row. Each mints its own chain, so the node ids
- // collide across them while meaning different things.
const narration1 = el({
id: "narration-1",
key: "narration-1",
- fxChain: chainOf([
- { type: "lowpass", id: "n1", params: { frequency: 8000, q: 0.7, poles: "2" } },
- { type: "peaking", id: "n2", params: { frequency: 1000, gain: -3, q: 1.4 } },
- ]),
+ fxChain: NARRATION_1_CHAIN,
automation: lanesOf("fx.n2.q"),
});
const narration2 = el({
id: "narration-2",
key: "narration-2",
- fxChain: chainOf([
- { type: "peaking", id: "n1", params: { frequency: 1000, gain: -6, q: 1.4 } },
- ]),
+ fxChain: NARRATION_2_CHAIN,
automation: lanesOf("fx.n1.q", "volume"),
});
@@ -258,9 +245,16 @@ describe("groupAutomationLanes", () => {
expect(groups.map((g) => g.entries.length)).toEqual([1, 1]);
});
- it("ignores clips that are not audio, the way the reserved height does", () => {
- const video = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("volume") });
- expect(groupAutomationLanes([video])).toEqual([]);
+ it("draws a picture's opacity lane, the way the reserved height counts it", () => {
+ // A fade on a video is an opacity envelope and earns a row exactly as a
+ // volume one does. Gating this on audio is what used to hide it.
+ const titles = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("opacity") });
+ expect(groupAutomationLanes([titles]).map((g) => g.key)).toEqual(["Opacity"]);
+ });
+
+ it("still ignores a clip carrying no automation at all", () => {
+ const bare = el({ id: "bare", key: "bare", tag: "div" });
+ expect(groupAutomationLanes([bare])).toEqual([]);
});
it("skips a target that does not resolve against its clip's chain", () => {
diff --git a/packages/studio/src/player/components/automationLaneData.ts b/packages/studio/src/player/components/automationLaneData.ts
index 01a35df669..f127d9b7f2 100644
--- a/packages/studio/src/player/components/automationLaneData.ts
+++ b/packages/studio/src/player/components/automationLaneData.ts
@@ -22,7 +22,6 @@ import {
type HfAutomationLane,
} from "@hyperframes/core/audio-automation";
import { parseAudioFxChain, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
-import { isAudioTimelineElement } from "../../utils/timelineInspector";
import type { TimelineElement } from "../store/playerStore";
const EMPTY: HfAutomation = { version: 1, lanes: [] };
@@ -222,13 +221,16 @@ export interface AutomationLaneGroup {
* spectrum, top down), so the first clip to carry a property fixes its row and
* later clips only append properties nobody has shown yet.
*
- * Non-audio elements contribute nothing, matching `automationLaneCountOf` — the
- * row's reserved height and its drawn lanes have to count the same clips.
+ * Any clip carrying automation contributes, not only audio ones: a picture's
+ * opacity is an envelope like any other, so a fade on a video draws and edits
+ * in the same lane a fade on music does. A clip with no automation contributes
+ * nothing on its own, so no gate is needed for that. Matches
+ * `automationLaneCountOf`: the row's reserved height and its drawn lanes have
+ * to count the same clips.
*/
export function groupAutomationLanes(elements: readonly TimelineElement[]): AutomationLaneGroup[] {
const groups = new Map();
for (const element of elements) {
- if (!isAudioTimelineElement(element)) continue;
const chain = elementFxChain(element);
for (const lane of elementAutomationLanes(element)) {
const key = laneGroupKey(lane.target, chain);
diff --git a/packages/studio/src/player/components/automationLaneFixtures.ts b/packages/studio/src/player/components/automationLaneFixtures.ts
new file mode 100644
index 0000000000..67e6bb12b2
--- /dev/null
+++ b/packages/studio/src/player/components/automationLaneFixtures.ts
@@ -0,0 +1,28 @@
+/**
+ * The narration pair two automation tests both need.
+ *
+ * Two slices sharing a row, each minting its OWN fx chain, so the node ids
+ * collide across them while meaning different things. That collision is the
+ * thing under test in both files, which is why the fixture has to be identical
+ * in both and therefore has to live in one place.
+ */
+
+const chainOf = (nodes: unknown[]): string => JSON.stringify({ version: 1, nodes });
+
+export const lanesOf = (...targets: string[]): string =>
+ JSON.stringify({
+ version: 1,
+ lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })),
+ });
+
+/** A low-pass plus a 1 kHz peaking bell, whose Q the first slice automates. */
+export const NARRATION_1_CHAIN = chainOf([
+ { type: "lowpass", id: "n1", params: { frequency: 8000, q: 0.7, poles: "2" } },
+ { type: "peaking", id: "n2", params: { frequency: 1000, gain: -3, q: 1.4 } },
+]);
+
+/** The same 1 kHz bell, but minted first, so it takes the id the other gave
+ * its low-pass. Grouping by target alone would merge these two rows. */
+export const NARRATION_2_CHAIN = chainOf([
+ { type: "peaking", id: "n1", params: { frequency: 1000, gain: -6, q: 1.4 } },
+]);
diff --git a/packages/studio/src/player/components/clipFadeBendDrag.test.ts b/packages/studio/src/player/components/clipFadeBendDrag.test.ts
new file mode 100644
index 0000000000..b668495a25
--- /dev/null
+++ b/packages/studio/src/player/components/clipFadeBendDrag.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from "vitest";
+import {
+ bendFromPointer,
+ fadeHandlePosition,
+ lengthFromPointer,
+ resolveDragAxis,
+} from "./clipFadeBendDrag";
+import { envelopeFadeSampler } from "./clipFades";
+
+const HEIGHT = 40;
+
+describe("bendFromPointer", () => {
+ it("is straight when the pointer sits on the line", () => {
+ expect(bendFromPointer(HEIGHT / 2, HEIGHT)).toBeCloseTo(0, 6);
+ });
+
+ it("puts the curve under the pointer, which is the whole gesture", () => {
+ for (const offsetY of [6, 12, 20, 28, 34]) {
+ const bend = bendFromPointer(offsetY, HEIGHT);
+ const level = envelopeFadeSampler(bend)(0.5);
+ expect((1 - level) * HEIGHT).toBeCloseTo(offsetY, 0);
+ }
+ });
+
+ it("bends up for a fade that gets loud early and down for one that waits", () => {
+ // Smaller offsetY is higher on the screen, so the level halfway through is
+ // greater: the fade has already done most of its work.
+ expect(bendFromPointer(8, HEIGHT)).toBeGreaterThan(0);
+ expect(bendFromPointer(32, HEIGHT)).toBeLessThan(0);
+ });
+
+ it("stops following a pointer dragged past the range instead of inverting", () => {
+ expect(bendFromPointer(-200, HEIGHT)).toBe(1);
+ expect(bendFromPointer(400, HEIGHT)).toBe(-1);
+ });
+
+ it("reports straight rather than dividing by a clip with no height", () => {
+ expect(bendFromPointer(10, 0)).toBe(0);
+ });
+});
+
+describe("fadeHandlePosition", () => {
+ const base = { seconds: 2, pixelsPerSecond: 25, width: 200, height: HEIGHT };
+
+ it("sits halfway along a fade in, and halfway along a fade out", () => {
+ expect(fadeHandlePosition({ ...base, edge: "in", level: 0.5 })?.x).toBeCloseTo(25, 6);
+ expect(fadeHandlePosition({ ...base, edge: "out", level: 0.5 })?.x).toBeCloseTo(175, 6);
+ });
+
+ it("rides the curve, so the handle stays under the pointer while bending", () => {
+ for (const bend of [-1, -0.5, 0, 0.5, 1]) {
+ const level = envelopeFadeSampler(bend)(0.5);
+ const at = fadeHandlePosition({ ...base, edge: "in", level });
+ expect(at?.y).toBeCloseTo((1 - level) * HEIGHT, 6);
+ }
+ });
+
+ it("has nowhere to sit on a fade with no width or a clip with no height", () => {
+ expect(fadeHandlePosition({ ...base, edge: "in", level: 0.5, seconds: 0 })).toBeNull();
+ expect(fadeHandlePosition({ ...base, edge: "in", level: 0.5, height: 0 })).toBeNull();
+ });
+
+ it("never runs past a fade clamped to the clip's own width", () => {
+ const at = fadeHandlePosition({ ...base, edge: "in", level: 0.5, seconds: 999 });
+ expect(at?.x).toBeCloseTo(base.width / 2, 6);
+ });
+});
+
+describe("lengthFromPointer", () => {
+ const base = { pixelsPerSecond: 25, width: 200 };
+
+ it("puts the fade's midpoint exactly where the pointer is", () => {
+ // 50px in on a 25px/s timeline is a 100px fade, whose midpoint is 50px in.
+ for (const offsetX of [10, 50, 90]) {
+ const seconds = lengthFromPointer({ ...base, edge: "in", offsetX });
+ expect((seconds * base.pixelsPerSecond) / 2).toBeCloseTo(offsetX, 6);
+ }
+ });
+
+ it("measures a fade out from the clip's right edge", () => {
+ const seconds = lengthFromPointer({ ...base, edge: "out", offsetX: 150 });
+ expect(seconds).toBeCloseTo(4, 6);
+ expect(base.width - (seconds * base.pixelsPerSecond) / 2).toBeCloseTo(150, 6);
+ });
+
+ it("has no fade to give when the pointer is dragged past the clip's edge", () => {
+ expect(lengthFromPointer({ ...base, edge: "in", offsetX: -40 })).toBe(0);
+ expect(lengthFromPointer({ ...base, edge: "out", offsetX: 260 })).toBe(0);
+ });
+
+ it("reports nothing rather than dividing by a timeline with no zoom", () => {
+ expect(lengthFromPointer({ ...base, edge: "in", offsetX: 50, pixelsPerSecond: 0 })).toBe(0);
+ });
+});
+
+describe("resolveDragAxis", () => {
+ it("waits until the drag is big enough to mean something", () => {
+ expect(resolveDragAxis(0, 0)).toBeNull();
+ expect(resolveDragAxis(2, -2)).toBeNull();
+ });
+
+ it("reads a sideways drag as length and a vertical one as bend", () => {
+ expect(resolveDragAxis(12, 3)).toBe("length");
+ expect(resolveDragAxis(-12, 3)).toBe("length");
+ expect(resolveDragAxis(2, 12)).toBe("bend");
+ expect(resolveDragAxis(2, -12)).toBe("bend");
+ });
+
+ it("keeps a diagonal on one axis rather than changing both", () => {
+ expect(resolveDragAxis(20, 19)).toBe("length");
+ expect(resolveDragAxis(19, 20)).toBe("bend");
+ });
+});
+
+describe("the drag round-trips", () => {
+ it("lands the handle back where the pointer left it", () => {
+ // Inside the band the bend limit can express: levels from 0.5^4 to 0.5^0.25,
+ // which is roughly 6.4px to 37.5px down a 40px clip.
+ for (const offsetY of [8, 15, 25, 35]) {
+ const bend = bendFromPointer(offsetY, HEIGHT);
+ const at = fadeHandlePosition({
+ edge: "in",
+ seconds: 2,
+ pixelsPerSecond: 25,
+ width: 200,
+ height: HEIGHT,
+ level: envelopeFadeSampler(bend)(0.5),
+ });
+ expect(at?.y).toBeCloseTo(offsetY, 0);
+ }
+ });
+
+ it("parks the handle at the limit when the pointer goes further than a bend can", () => {
+ const at = (offsetY: number) =>
+ fadeHandlePosition({
+ edge: "in",
+ seconds: 2,
+ pixelsPerSecond: 25,
+ width: 200,
+ height: HEIGHT,
+ level: envelopeFadeSampler(bendFromPointer(offsetY, HEIGHT))(0.5),
+ })?.y;
+ // Dragged off the top of the clip and well past it: both stop in the same
+ // place rather than the curve flipping over.
+ expect(at(0)).toBeCloseTo(at(-500)!, 6);
+ expect(at(HEIGHT)).toBeCloseTo(at(500)!, 6);
+ });
+
+ it("keeps the handle under a pointer dragged sideways too", () => {
+ for (const offsetX of [15, 40, 80]) {
+ const seconds = lengthFromPointer({
+ edge: "in",
+ offsetX,
+ pixelsPerSecond: 25,
+ width: 200,
+ });
+ const at = fadeHandlePosition({
+ edge: "in",
+ seconds,
+ pixelsPerSecond: 25,
+ width: 200,
+ height: HEIGHT,
+ level: 0.5,
+ });
+ expect(at?.x).toBeCloseTo(offsetX, 6);
+ }
+ });
+});
diff --git a/packages/studio/src/player/components/clipFadeBendDrag.ts b/packages/studio/src/player/components/clipFadeBendDrag.ts
new file mode 100644
index 0000000000..b07c2df500
--- /dev/null
+++ b/packages/studio/src/player/components/clipFadeBendDrag.ts
@@ -0,0 +1,96 @@
+import { fadeCurveThroughMidpoint } from "./clipFades";
+
+/**
+ * Turning a pointer position into a fade.
+ *
+ * One handle owns both of a fade's properties, on the axis each one lives on:
+ * across is how long it lasts, up and down is how it bends. That works because
+ * the handle is defined as the curve's own MIDPOINT, which makes both gestures
+ * direct manipulation. Drag sideways and the midpoint is where you put it, so
+ * the fade is twice as long as the pointer travelled. Drag up and the curve has
+ * to pass through the pointer, so the bend is whatever produces that level
+ * halfway along.
+ *
+ * Anywhere else on the curve and one of the two would drift out from under the
+ * pointer as the shape changed.
+ *
+ * Kept apart from the component because the arithmetic is the whole gesture and
+ * it is worth testing without mounting a timeline to do it.
+ */
+
+/** Where the handle sits, in the clip's own pixel box. */
+export interface FadeHandlePosition {
+ x: number;
+ y: number;
+}
+
+/**
+ * The midpoint of a fade's curve, or null when there is no fade to sit on.
+ *
+ * A clip without one parks its handle on the corner instead, which is a layout
+ * question rather than a curve one, so the component owns that spot.
+ */
+export function fadeHandlePosition(input: {
+ edge: "in" | "out";
+ seconds: number;
+ pixelsPerSecond: number;
+ width: number;
+ height: number;
+ level: number;
+}): FadeHandlePosition | null {
+ const { edge, seconds, pixelsPerSecond, width, height, level } = input;
+ const span = Math.min(seconds * pixelsPerSecond, width);
+ if (span <= 0 || height <= 0) return null;
+ return { x: edge === "in" ? span / 2 : width - span / 2, y: (1 - level) * height };
+}
+
+/**
+ * The fade length a pointer at `offsetX` asks for, measured from the clip's own
+ * left edge.
+ *
+ * The handle is the midpoint, so the fade reaches twice as far as the pointer
+ * does. That is not a gain to tune, it is what keeps the handle exactly under
+ * the finger dragging it: put the midpoint here, and the fade ends over there.
+ */
+export function lengthFromPointer(input: {
+ edge: "in" | "out";
+ offsetX: number;
+ pixelsPerSecond: number;
+ width: number;
+}): number {
+ const { edge, offsetX, pixelsPerSecond, width } = input;
+ if (!(pixelsPerSecond > 0)) return 0;
+ const fromEdge = edge === "in" ? offsetX : width - offsetX;
+ return Math.max(0, (fromEdge * 2) / pixelsPerSecond);
+}
+
+/**
+ * The bend a pointer at `offsetY` asks for, given the clip's pixel height.
+ *
+ * Reading top-down pixels as a bottom-up level is the only conversion here, and
+ * it is the one that decides which way "drag up" bends the fade: up is a higher
+ * level halfway through, which is a fade that arrives early.
+ */
+export function bendFromPointer(offsetY: number, height: number): number {
+ if (!(height > 0)) return 0;
+ const level = 1 - offsetY / height;
+ return fadeCurveThroughMidpoint(level);
+}
+
+/** Which of the two properties a drag is changing. */
+export type FadeDragAxis = "length" | "bend";
+
+/** Travel, in px, before a drag commits to an axis. */
+const AXIS_LOCK_PX = 3;
+
+/**
+ * The axis a drag belongs to, or null while it is still too small to tell.
+ *
+ * Locked once and never revisited, because the handle is small and a real hand
+ * wanders: without the lock a drag along one axis picks up stray movement on
+ * the other, and silently changes a property nobody aimed at.
+ */
+export function resolveDragAxis(dx: number, dy: number): FadeDragAxis | null {
+ if (Math.abs(dx) < AXIS_LOCK_PX && Math.abs(dy) < AXIS_LOCK_PX) return null;
+ return Math.abs(dx) >= Math.abs(dy) ? "length" : "bend";
+}
diff --git a/packages/studio/src/player/components/clipFadeBinding.test.ts b/packages/studio/src/player/components/clipFadeBinding.test.ts
index 9d34b4309e..116e474ea0 100644
--- a/packages/studio/src/player/components/clipFadeBinding.test.ts
+++ b/packages/studio/src/player/components/clipFadeBinding.test.ts
@@ -2,103 +2,170 @@ 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 { 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 });
+
+function deps(options: { automation?: HfAutomation; readOnly?: 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 bag: ClipFadeDeps = {
+ bindAutomation: () =>
+ ({
+ automation: options.automation ?? EMPTY,
+ lanes: (options.automation ?? EMPTY).lanes,
+ chain: null,
+ readOnly: options.readOnly ?? false,
+ onPreview,
+ onCommit,
+ }) as unknown as AutomationLaneBinding,
+ };
+ return { bag, onPreview, onCommit };
}
-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();
- });
-
- 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 },
- ],
- },
- ],
+/** The points a write produced, for whichever lane the clip should have used. */
+const laneOf = (call: unknown, target: string) =>
+ (call as HfAutomation).lanes.find((l) => l.target === target)?.points.map((p) => [p.t, p.v]);
+
+const withLane = (target: string, points: { t: number; v: number; curve?: number }[]) =>
+ ({ version: 1, lanes: [{ target, points }] }) as HfAutomation;
+
+const STRAIGHT_OPACITY_FADE = withLane("opacity", [
+ { t: 0, v: 0 },
+ { t: 2, v: 1 },
+]);
+
+describe("which lane a clip's fade lives in", () => {
+ it("puts a picture's fade in the opacity lane", () => {
+ const { bag, onCommit } = deps();
+ resolveClipFadeBinding(el(), bag)!.onCommit({ fadeIn: 2, fadeOut: 0 });
+ expect(laneOf(onCommit.mock.calls[0]![0], "opacity")).toEqual([
+ [0, 0],
+ [2, 1],
+ ]);
+ });
+
+ it("puts a sound's fade in the volume lane", () => {
+ const { bag, onCommit } = deps();
+ resolveClipFadeBinding(audio(), bag)!.onCommit({ fadeIn: 2, fadeOut: 0 });
+ expect(laneOf(onCommit.mock.calls[0]![0], "volume")).toEqual([
+ [0, 0],
+ [2, 1],
+ ]);
+ });
+
+ it("writes no attribute of its own, on either kind of clip", () => {
+ // The whole storage is data-automation. If this binding ever grows a second
+ // way to record a fade, these two lanes stop being the source of truth.
+ const { bag, onCommit } = deps();
+ const binding = resolveClipFadeBinding(el(), bag)!;
+ expect(Object.keys(binding)).not.toContain("writeAttribute");
+ binding.onCommit({ fadeIn: 1, fadeOut: 0 });
+ expect(onCommit).toHaveBeenCalledTimes(1);
+ });
+
+ it("has no fade to offer a clip with no length", () => {
+ expect(resolveClipFadeBinding(el({ duration: 0 }), deps().bag)).toBeUndefined();
+ });
+});
+
+describe("reading a fade back", () => {
+ it("reads a picture's fade off its opacity lane", () => {
+ const { bag } = deps({
+ automation: withLane("opacity", [
+ { t: 0, v: 0 },
+ { t: 1.5, v: 1 },
+ { t: 8, v: 1 },
+ { t: 10, v: 0 },
+ ]),
});
- expect(resolveClipFadeBinding(el(), bind)?.fades).toEqual({ fadeIn: 2, fadeOut: 0 });
+ expect(resolveClipFadeBinding(el(), bag)!.fades).toEqual({ fadeIn: 1.5, fadeOut: 2 });
});
- it("previews without persisting, and commits once", () => {
- const { bind, onPreview, onCommit } = binder(EMPTY);
- const fade = resolveClipFadeBinding(el(), bind)!;
+ it("does not mistake a sound's volume lane for a picture's fade", () => {
+ const { bag } = deps({
+ automation: withLane("volume", [
+ { t: 0, v: 0 },
+ { t: 1.5, v: 1 },
+ ]),
+ });
+ // Same clip is a div, so it looks at opacity and finds nothing.
+ expect(resolveClipFadeBinding(el(), bag)!.fades).toEqual({ fadeIn: 0, fadeOut: 0 });
+ });
+
+ it("reads a bend off the point each ramp leaves", () => {
+ const { bag } = deps({
+ automation: withLane("opacity", [
+ { t: 0, v: 0, curve: 0.5 },
+ { t: 2, v: 1 },
+ { t: 8, v: 1, curve: -0.25 },
+ { t: 10, v: 0 },
+ ]),
+ });
+ // Stored curvature is the bend read from the other end; see readFadeCurve.
+ expect(resolveClipFadeBinding(el(), bag)!.curves).toEqual({ in: -0.5, out: 0.25 });
+ });
+});
+
+describe("writing a fade", () => {
+ it("gives new fade edges the default ease", () => {
+ const { bag, onCommit } = deps();
+ resolveClipFadeBinding(el(), bag)!.onCommit({ fadeIn: 1, fadeOut: 1 });
+
+ const points = (onCommit.mock.calls[0]![0] as HfAutomation).lanes[0]!.points;
+ expect(points[0]?.curve).toBeCloseTo(-0.5, 6);
+ expect(points[2]?.curve).toBeCloseTo(0.5, 6);
+ });
+ it("draws a new fade with the same default ease before it is committed", () => {
+ const binding = resolveClipFadeBinding(el(), deps().bag)!;
+ expect(binding.curvesFor({ fadeIn: 1, fadeOut: 1 })).toEqual({ in: 0.5, out: -0.5 });
+ });
+
+ it("keeps an existing straight fade straight when its length changes", () => {
+ const { bag, onCommit } = deps({ automation: STRAIGHT_OPACITY_FADE });
+ resolveClipFadeBinding(el(), bag)!.onCommit({ fadeIn: 1, fadeOut: 0 });
+
+ const points = (onCommit.mock.calls[0]![0] as HfAutomation).lanes[0]!.points;
+ expect(points[0]?.curve).toBeUndefined();
+ });
+
+ it("previews without persisting, and commits once", () => {
+ const { bag, onPreview, onCommit } = deps();
+ const fade = resolveClipFadeBinding(el(), bag)!;
fade.onPreview({ fadeIn: 1, fadeOut: 0 });
expect(onCommit).not.toHaveBeenCalled();
- expect(volumeOf(onPreview.mock.calls[0]![0])).toEqual([
+ expect(laneOf(onPreview.mock.calls[0]![0], "opacity")).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: STRAIGHT_OPACITY_FADE });
+ resolveClipFadeBinding(el(), 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(el(), bag)!;
expect(fade.readOnly).toBe(true);
fade.onPreview({ fadeIn: 1, fadeOut: 0 });
fade.onCommit({ fadeIn: 1, fadeOut: 0 });
@@ -106,44 +173,80 @@ describe("resolveClipFadeBinding", () => {
expect(onCommit).not.toHaveBeenCalled();
});
- 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 },
- ],
- },
- ],
+ it("keeps a point the author placed between the two fades", () => {
+ // The grips own the head and the tail. Everything in the middle is somebody
+ // else's envelope and has to survive a fade being redrawn.
+ const { bag, onCommit } = deps({
+ automation: withLane("opacity", [{ t: 5, v: 0.4 }]),
});
- const fade = resolveClipFadeBinding(el(), bind)!;
- expect(fade.curve).toBe("linear");
- fade.onCycleCurve();
- const points = (onCommit.mock.calls[0]![0] as HfAutomation).lanes[0]!.points;
- expect(points.map((p) => [p.t, p.v])).toEqual([
+ resolveClipFadeBinding(el(), bag)!.onCommit({ fadeIn: 1, fadeOut: 1 });
+ expect(laneOf(onCommit.mock.calls[0]![0], "opacity")).toEqual([
[0, 0],
- [2, 1],
+ [1, 1],
+ [5, 0.4],
+ [9, 1],
+ [10, 0],
]);
- expect(points[0]!.curve).toBeCloseTo(0.35, 6);
});
});
-describe("fade curves", () => {
- it("names the curvature a 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.
- expect(readFadeCurve(0.9)).toBe("linear");
+describe("the two ramps bend apart", () => {
+ const bothFades = () =>
+ deps({
+ automation: withLane("opacity", [
+ { t: 0, v: 0 },
+ { t: 1, v: 1 },
+ { t: 9, v: 1 },
+ { t: 10, v: 0 },
+ ]),
+ });
+
+ it("bends the head and leaves the tail alone", () => {
+ const { bag, onCommit } = bothFades();
+ resolveClipFadeBinding(el(), bag)!.onBend("in", -0.5, true);
+ const points = (onCommit.mock.calls[0]![0] as HfAutomation).lanes[0]!.points;
+ expect(points[0]?.curve).toBeCloseTo(0.5, 6);
+ expect(points[2]?.curve).toBeUndefined();
+ });
+
+ it("bends the tail and leaves the head alone", () => {
+ const { bag, onCommit } = bothFades();
+ resolveClipFadeBinding(el(), bag)!.onBend("out", 0.25, true);
+ const points = (onCommit.mock.calls[0]![0] as HfAutomation).lanes[0]!.points;
+ expect(points[0]?.curve).toBeUndefined();
+ expect(points[2]?.curve).toBeCloseTo(-0.25, 6);
+ });
+
+ it("previews a bend without persisting it", () => {
+ const { bag, onPreview, onCommit } = bothFades();
+ resolveClipFadeBinding(el(), bag)!.onBend("in", -0.3, false);
+ expect(onPreview).toHaveBeenCalledTimes(1);
+ expect(onCommit).not.toHaveBeenCalled();
});
+});
+
+describe("a second gesture sees the first one", () => {
+ it("draws a fade out without dropping the fade in", () => {
+ // The regression this guards: the quiet commit reaches the file and the
+ // preview but skips the refresh that re-derives the timeline. Draw a fade
+ // in, draw a fade out a moment later, and the second write was computed
+ // from a clip that still looked like it had none, which dropped the first.
+ // useAutomationLanes now catches the store up on persist; here we stand in
+ // for that by binding against what the first write produced.
+ const first = deps();
+ resolveClipFadeBinding(el(), first.bag)!.onCommit({ fadeIn: 1.5, fadeOut: 0 });
+ const afterFirst = first.onCommit.mock.calls[0]![0] as HfAutomation;
- it("cycles through every shape and back", () => {
- expect(nextFadeCurve("linear")).toBe("smooth");
- expect(nextFadeCurve("smooth")).toBe("sharp");
- expect(nextFadeCurve("sharp")).toBe("linear");
+ const second = deps({ automation: afterFirst });
+ const binding = resolveClipFadeBinding(el(), second.bag)!;
+ // The second gesture starts from the fade the first one drew.
+ expect(binding.fades).toEqual({ fadeIn: 1.5, fadeOut: 0 });
+ binding.onCommit({ fadeIn: 1.5, fadeOut: 2 });
+ expect(laneOf(second.onCommit.mock.calls[0]![0], "opacity")).toEqual([
+ [0, 0],
+ [1.5, 1],
+ [8, 1],
+ [10, 0],
+ ]);
});
});
diff --git a/packages/studio/src/player/components/clipFadeBinding.ts b/packages/studio/src/player/components/clipFadeBinding.ts
index aa75f7e2a5..57163d8376 100644
--- a/packages/studio/src/player/components/clipFadeBinding.ts
+++ b/packages/studio/src/player/components/clipFadeBinding.ts
@@ -1,74 +1,81 @@
+import { OPACITY_TARGET, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
import { laneFor, withLane } from "./automationLaneGeometry";
import {
- FADE_CURVES,
+ readClipFadeCurves,
readClipFades,
+ resolveClipFadeCurves,
writeClipFades,
+ type ClipFadeCurves,
type ClipFades,
- type FadeCurve,
} from "./clipFades";
import type { AutomationLaneBinding } from "./useAutomationLanes";
import type { TimelineElement } from "../store/playerStore";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
/**
- * Wiring the fade grips to the clip's volume envelope.
+ * Wiring the fade grips to the clip's own envelope.
*
- * 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.
+ * There is one storage and one gesture. A fade is a value that moves across a
+ * clip's life, which is what `data-automation` is for, so both media keep it
+ * there and only the lane differs: sound rides `volume`, picture rides
+ * `opacity`. Nothing here writes an attribute of its own.
*
- * Audio only for now. A visual clip fades on opacity, which lives in the
- * composition's animation rather than in this envelope.
+ * Two things fall out of that which are worth more than the tidiness:
+ *
+ * - A fade drawn with a grip is the same two breakpoints the automation lane
+ * edits, so it can be refined by hand afterwards, on a video as much as on
+ * music.
+ * - A third point dragged into the middle stops it being a fade and makes it an
+ * envelope, with nothing to migrate and no second representation to reconcile.
*/
-const VOLUME = "volume";
-
export interface ClipFadeBinding {
fades: ClipFades;
- curve: FadeCurve;
+ /** How far each ramp is bent; 0 is a straight one. */
+ curves: ClipFadeCurves;
+ /** The shape an in-progress fade gesture will draw. */
+ curvesFor(next: ClipFades): ClipFadeCurves;
readOnly: boolean;
onPreview(next: ClipFades): void;
onCommit(next: ClipFades): void;
- onCycleCurve(): void;
+ /** Live while a fade line is dragged, then once more on release. */
+ onBend(edge: "in" | "out", curve: number, persist: boolean): void;
}
-/** Which named curve an envelope's fade was written with. */
-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";
+export interface ClipFadeDeps {
+ /** The clip's automation binding, which is the whole read and write path. */
+ bindAutomation(element: TimelineElement): AutomationLaneBinding;
}
-/** 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]!;
-}
+/** Replace one edge's bend, leaving the other exactly as it was. */
+const withBend = (curves: ClipFadeCurves, edge: "in" | "out", curve: number): ClipFadeCurves =>
+ edge === "in" ? { ...curves, in: curve } : { ...curves, out: curve };
/**
- * The fade binding for a clip, or undefined when fades do not apply to it.
+ * The fade binding for a clip. Every clip gets one; what differs is the lane.
*
- * `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.
+ * 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,
- bind: (element: TimelineElement) => AutomationLaneBinding,
+ deps: ClipFadeDeps,
): ClipFadeBinding | undefined {
- if (!isAudioTimelineElement(element)) return undefined;
- const binding = bind(element);
- const lane = laneFor(binding.automation, VOLUME);
+ // A clip with no length has no window to fade across.
+ if (!(element.duration > 0)) return undefined;
+
+ const target = isAudioTimelineElement(element) ? VOLUME_TARGET : OPACITY_TARGET;
+ const binding = deps.bindAutomation(element);
+ const lane = laneFor(binding.automation, target);
const fades = readClipFades(lane.points, element.duration);
- const curve = readFadeCurve(lane.points[0]?.curve);
+ // Each ramp's curvature already lives on the point it leaves, so the envelope
+ // has carried two bends all along.
+ const curves: ClipFadeCurves = readClipFadeCurves(lane.points, fades);
- const apply = (next: ClipFades, shape: FadeCurve, persist: boolean) => {
+ const apply = (next: ClipFades, shape: ClipFadeCurves | undefined, persist: boolean) => {
if (binding.readOnly) return;
const points = writeClipFades(lane.points, element.duration, next, shape);
- const automation = withLane(binding.automation, { target: VOLUME, points });
+ const automation = withLane(binding.automation, { target, points });
// An envelope with no points left is no envelope: drop the lane so the clip
// goes back to carrying no automation attribute at all.
const lanes = automation.lanes.filter((l) => l.points.length > 0);
@@ -79,10 +86,11 @@ export function resolveClipFadeBinding(
return {
fades,
- curve,
+ curves,
+ curvesFor: (next) => resolveClipFadeCurves(lane.points, element.duration, next),
readOnly: binding.readOnly,
- onPreview: (next) => apply(next, curve, false),
- onCommit: (next) => apply(next, curve, true),
- onCycleCurve: () => apply(fades, nextFadeCurve(curve), true),
+ onPreview: (next) => apply(next, undefined, false),
+ onCommit: (next) => apply(next, undefined, true),
+ onBend: (edge, bend, persist) => apply(fades, withBend(curves, edge, bend), persist),
};
}
diff --git a/packages/studio/src/player/components/clipFades.test.ts b/packages/studio/src/player/components/clipFades.test.ts
index f80a934789..da6c5d01a6 100644
--- a/packages/studio/src/player/components/clipFades.test.ts
+++ b/packages/studio/src/player/components/clipFades.test.ts
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import type { HfAutomationPoint } from "@hyperframes/core/audio-automation";
import {
+ envelopeFadeSampler,
clampClipFades,
fadeWedgePath,
MIN_FADE_SECONDS,
NO_FADES,
+ readClipFadeCurves,
readClipFades,
+ readFadeCurve,
writeClipFades,
} from "./clipFades";
@@ -113,34 +116,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 = 0) =>
+ fadeWedgePath({
+ edge,
+ seconds: 2,
+ sample: envelopeFadeSampler(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 on exactly the line the picture fades along", () => {
+ // The two are stored in different places and sampled through different
+ // code, so this is the check that they still describe one shape: a bend of
+ // -0.5 has to look the same on a music clip as on a video clip.
+ for (const curve of [-1, -0.5, 0, 0.5, 1]) {
+ const wedgeFor = (sample: (p: number) => number) =>
+ points(
+ fadeWedgePath({
+ edge: "in",
+ seconds: 2,
+ sample,
+ pixelsPerSecond: 25,
+ width: WIDTH,
+ height: HEIGHT,
+ }).line,
+ );
+ const visual = wedgeFor(envelopeFadeSampler(curve));
+ const audio = wedgeFor(envelopeFadeSampler(curve));
+ expect(visual.length).toBeGreaterThan(5);
+ expect(audio).toHaveLength(visual.length);
+ for (const [index, [x, y]] of visual.entries()) {
+ expect(audio[index]![0]).toBeCloseTo(x, 1);
+ expect(audio[index]![1]).toBeCloseTo(y, 0);
+ }
+ }
});
- 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);
+ it("samples a bent fade instead of drawing a straight line", () => {
+ // A bend of -0.5 is p², so a quarter of the way in the level is 0.0625 and
+ // the line sits well below the straight one (larger y is quieter).
+ const quarter = points(wedge("in", -0.5)).find(([x]) => Math.abs(x - 12.5) < 1.1);
+ expect(quarter?.[1]).toBeCloseTo((1 - 0.0625) * HEIGHT, 1);
+
+ // Bent the other way it sits above the line by the same reasoning.
+ const bulged = points(wedge("in", 0.5)).find(([x]) => Math.abs(x - 12.5) < 1.1);
+ expect(bulged?.[1]).toBeCloseTo((1 - 0.5) * HEIGHT, 1);
});
it("draws nothing for a fade of no length", () => {
@@ -148,7 +184,7 @@ describe("fadeWedgePath", () => {
fadeWedgePath({
edge: "in",
seconds: 0,
- curve: "linear",
+ sample: envelopeFadeSampler(0),
pixelsPerSecond: 25,
width: WIDTH,
height: HEIGHT,
@@ -160,17 +196,15 @@ describe("fadeWedgePath", () => {
const { line, fill } = fadeWedgePath({
edge: "in",
seconds: 2,
- curve: "linear",
+ sample: envelopeFadeSampler(0),
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);
@@ -234,13 +268,93 @@ describe("writeClipFades", () => {
expect(at(writeClipFades(faded, DURATION, NO_FADES))).toEqual([[4, 0.5]]);
});
- it("curves the segment leaving the fade's silent end", () => {
- const smooth = writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 1 }, "smooth");
- expect(smooth[0]?.curve).toBeCloseTo(0.35, 6);
- // The fade-out curves out of its full-level point, into silence.
- expect(smooth[2]?.curve).toBeCloseTo(0.35, 6);
+ it("bends the segment leaving the fade's silent end", () => {
+ const bent = writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 1 }, { in: -0.5, out: -0.5 });
+ // The envelope stores the same bend with the opposite sign; see
+ // envelopeCurveForFade. Both ends carry it, each on the point it leaves.
+ expect(bent[0]?.curve).toBeCloseTo(0.5, 6);
+ expect(bent[2]?.curve).toBeCloseTo(0.5, 6);
+ // A straight fade writes no curvature at all rather than an explicit zero.
expect(
- writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 0 }, "linear")[0]?.curve,
+ writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 0 }, { in: 0, out: 0 })[0]?.curve,
).toBeUndefined();
});
+
+ it("gives each ramp its own curvature, on the point it leaves", () => {
+ const apart = writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 1 }, { in: -0.5, out: 0.25 });
+ expect(apart[0]?.curve).toBeCloseTo(0.5, 6);
+ expect(apart[2]?.curve).toBeCloseTo(-0.25, 6);
+ });
+
+ it("leaves a straight ramp bare even when the other one is bent", () => {
+ const half = writeClipFades([], DURATION, { fadeIn: 1, fadeOut: 1 }, { in: 0, out: -0.5 });
+ expect(half[0]?.curve).toBeUndefined();
+ expect(half[2]?.curve).toBeCloseTo(0.5, 6);
+ });
+});
+
+describe("readFadeCurve", () => {
+ it("is straight when the point carries no curvature", () => {
+ expect(readFadeCurve(undefined)).toBe(0);
+ expect(readFadeCurve(0)).toBe(0);
+ });
+
+ it("reads the stored curvature from the other end", () => {
+ expect(readFadeCurve(0.5)).toBe(-0.5);
+ expect(readFadeCurve(-0.5)).toBe(0.5);
+ });
+});
+
+describe("readClipFadeCurves", () => {
+ const curves = (points: HfAutomationPoint[]) =>
+ readClipFadeCurves(points, readClipFades(points, DURATION));
+
+ it("reads each ramp's bend off the point that ramp leaves", () => {
+ const points: HfAutomationPoint[] = [
+ { t: 0, v: 0, curve: 0.5 },
+ { t: 2, v: 1 },
+ { t: 6, v: 1, curve: -0.25 },
+ { t: DURATION, v: 0 },
+ ];
+ expect(curves(points)).toEqual({ in: -0.5, out: 0.25 });
+ });
+
+ it("does not hand a fade-out the fade-in's bend when there is no fade-out", () => {
+ // The regression: with only a fade-in the envelope has two points, so the
+ // one before last IS the fade-in's own start. Read positionally, the bend
+ // leaks across, and the next write stamps it onto a fade-out nobody bent.
+ const points: HfAutomationPoint[] = [
+ { t: 0, v: 0, curve: 1 },
+ { t: 3, v: 1 },
+ ];
+ expect(curves(points)).toEqual({ in: -1, out: 0 });
+ });
+
+ it("does not hand a fade-in the fade-out's bend when there is no fade-in", () => {
+ const points: HfAutomationPoint[] = [
+ { t: 5, v: 1, curve: 1 },
+ { t: DURATION, v: 0 },
+ ];
+ expect(curves(points)).toEqual({ in: 0, out: -1 });
+ });
+
+ it("reads straight off an envelope that is nobody's fade", () => {
+ expect(
+ curves([
+ { t: 2, v: 0.4, curve: 0.8 },
+ { t: 5, v: 0.9 },
+ ]),
+ ).toEqual({ in: 0, out: 0 });
+ expect(curves([])).toEqual({ in: 0, out: 0 });
+ });
+
+ it("reads points that arrive out of order", () => {
+ const points: HfAutomationPoint[] = [
+ { t: DURATION, v: 0 },
+ { t: 6, v: 1, curve: -0.25 },
+ { t: 2, v: 1 },
+ { t: 0, v: 0, curve: 0.5 },
+ ];
+ expect(curves(points)).toEqual({ in: -0.5, out: 0.25 });
+ });
});
diff --git a/packages/studio/src/player/components/clipFades.ts b/packages/studio/src/player/components/clipFades.ts
index 0eb7c04fde..6374b6060d 100644
--- a/packages/studio/src/player/components/clipFades.ts
+++ b/packages/studio/src/player/components/clipFades.ts
@@ -6,28 +6,59 @@ import {
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. */
- 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;
+/**
+ * How far a fade may bend away from a straight ramp, either way. Matches the
+ * envelope's own limit, since the bend IS an envelope curvature.
+ */
+const FADE_CURVE_LIMIT = 1;
-export type FadeCurve = keyof typeof FADE_CURVES;
+/** A bend outside the range, or not a number at all, resolves to straight. */
+function clampFadeCurve(curve: number): number {
+ if (!Number.isFinite(curve)) return 0;
+ return Math.max(-FADE_CURVE_LIMIT, Math.min(FADE_CURVE_LIMIT, curve));
+}
+
+/**
+ * The curvature to store for a bend.
+ *
+ * A bend and an envelope curvature are the same exponent read from opposite
+ * ends: the envelope applies `x^(2^(2·curve))`, and a fade that sags is spelled
+ * negative because down is down. One function owns the flip so the grips and
+ * the lane can never disagree about which way that is.
+ */
+function envelopeCurveForFade(curve: number): number {
+ // Rounded on the way in. A bend comes off a pointer, so it arrives with every
+ // digit a float can hold, and sixteen of them in the markup say nothing a
+ // reader or a diff can use.
+ return roundToCenti(-clampFadeCurve(curve));
+}
+
+/**
+ * The bend whose curve passes through `level` at the halfway point, which is
+ * how a drag on a 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);
+}
/** Shortest fade the handle will write; below this it reads as "no fade". */
export const MIN_FADE_SECONDS = 0.05;
@@ -44,16 +75,33 @@ export interface ClipFades {
fadeOut: number;
}
+/**
+ * The two bends, which are two values and not one.
+ *
+ * A fade in that creeps out of black and a fade out that drops away is an
+ * ordinary thing to ask for, so the ramps are shaped separately. Keyed by the
+ * edge they belong to, so a caller with an edge in hand cannot read the wrong
+ * one.
+ */
+export interface ClipFadeCurves {
+ in: number;
+ out: number;
+}
+
+/** New fades ease out toward their visible end state. Existing fades keep their stored shape. */
+const DEFAULT_FADE_CURVES: ClipFadeCurves = { in: 0.5, out: -0.5 };
+
export const NO_FADES: ClipFades = { fadeIn: 0, fadeOut: 0 };
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[],
@@ -92,10 +140,74 @@ export function readClipFades(
};
}
+/**
+ * The bend a stored curvature stands for.
+ *
+ * A fade and an envelope segment are the same exponent read from opposite ends:
+ * a bend of -0.5 sags the line, and the envelope spells that +0.5. This is the
+ * inverse of `envelopeCurveForFade`, and they sit together so the two
+ * directions of the flip can never drift apart.
+ */
+export function readFadeCurve(curvature: number | undefined): number {
+ return curvature ? -curvature : 0;
+}
+
+/**
+ * How far each of a clip's ramps is bent.
+ *
+ * A ramp's curvature lives on the point it leaves, so the numbers are just
+ * `points[0]` and the one before last. What matters is the guard: those
+ * positions only name a ramp's start when that ramp actually exists. A clip
+ * with a fade-in and no fade-out has two points, and the one before last IS the
+ * fade-in's own start, so reading it unguarded hands the fade-out a bend it
+ * never had. `fades` is the single owner of which ramps exist, so it is what
+ * decides whether there is a bend to read at all.
+ */
+export function readClipFadeCurves(
+ points: readonly HfAutomationPoint[],
+ fades: ClipFades,
+): ClipFadeCurves {
+ const sorted = [...points].sort((a, b) => a.t - b.t);
+ return {
+ in: fades.fadeIn > 0 ? readFadeCurve(sorted[0]?.curve) : 0,
+ out: fades.fadeOut > 0 ? readFadeCurve(sorted.at(-2)?.curve) : 0,
+ };
+}
+
+/**
+ * The curves a proposed fade gesture will use.
+ *
+ * Existing fades keep the curve already in their envelope. A newly created
+ * edge gets the default ease, so this is shared by the writer and the live
+ * overlay instead of letting them disagree during the first drag.
+ */
+export function resolveClipFadeCurves(
+ points: readonly HfAutomationPoint[],
+ duration: number,
+ fades: ClipFades,
+ curves?: ClipFadeCurves,
+ min = 0,
+ max = 1,
+): ClipFadeCurves {
+ const existing = readClipFades(points, duration, min, max);
+ const stored = curves ?? readClipFadeCurves(points, existing);
+ return {
+ in:
+ curves === undefined && existing.fadeIn === 0 && fades.fadeIn > 0
+ ? DEFAULT_FADE_CURVES.in
+ : stored.in,
+ out:
+ curves === undefined && existing.fadeOut === 0 && fades.fadeOut > 0
+ ? DEFAULT_FADE_CURVES.out
+ : stored.out,
+ };
+}
+
/**
* 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 +218,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 +225,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;
+
+/**
+ * How a fade of this bend rises, read out of the envelope it is stored in.
+ *
+ * One sampler, because there is now one storage: the wedge on a music clip and
+ * the wedge on a video clip are the same line drawn from the same data, not two
+ * lookalikes kept in step by hand.
+ */
+export function envelopeFadeSampler(curve: number): FadeSampler {
+ const lane: HfAutomationLane = {
+ target: "volume",
+ points: [
+ { t: 0, v: 0, curve: envelopeCurveForFade(curve) || 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,76 +257,66 @@ 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[],
duration: number,
fades: ClipFades,
- curve: FadeCurve = "linear",
+ curves?: ClipFadeCurves,
min = 0,
max = 1,
): HfAutomationPoint[] {
const { fadeIn, fadeOut } = clampClipFades(fades, duration);
const existing = readClipFades(points, duration, min, max);
- const curvature = FADE_CURVES[curve];
+ const resolvedCurves = resolveClipFadeCurves(
+ points,
+ duration,
+ { fadeIn, fadeOut },
+ curves,
+ min,
+ max,
+ );
+ const headCurvature = envelopeCurveForFade(resolvedCurves.in);
+ const tailCurvature = envelopeCurveForFade(resolvedCurves.out);
// Everything strictly between the two fades is the author's; the old fade
// points are not, so they are dropped by the same window.
@@ -203,12 +328,12 @@ export function writeClipFades(
const next: HfAutomationPoint[] = [];
if (fadeIn > 0) {
- next.push({ t: 0, v: min, curve: curvature || undefined });
+ next.push({ t: 0, v: min, curve: headCurvature || undefined });
next.push({ t: roundToCenti(fadeIn), v: max });
}
next.push(...interior);
if (fadeOut > 0) {
- next.push({ t: roundToCenti(duration - fadeOut), v: max, curve: curvature || undefined });
+ next.push({ t: roundToCenti(duration - fadeOut), v: max, curve: tailCurvature || undefined });
next.push({ t: roundToCenti(duration), v: min });
}
return next;
diff --git a/packages/studio/src/player/components/useAutomationLanes.ts b/packages/studio/src/player/components/useAutomationLanes.ts
index 66242dd747..fb3642904c 100644
--- a/packages/studio/src/player/components/useAutomationLanes.ts
+++ b/packages/studio/src/player/components/useAutomationLanes.ts
@@ -93,6 +93,8 @@ export function useAutomationLanes(): UseAutomationLanesResult {
[domEditSelection, elements],
);
+ const updateElement = usePlayerStore((s) => s.updateElement);
+
const bind = useCallback(
(element: TimelineElement, isSelected: boolean): AutomationLaneBinding => {
const chain = elementFxChain(element);
@@ -111,6 +113,13 @@ export function useAutomationLanes(): UseAutomationLanesResult {
// and still resyncs the selection, so the next edit sees this one.
if (persist) {
void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value, coalesce);
+ // And catch the store up in the same tick. The quiet commit reaches
+ // the file and the preview but deliberately skips the refresh that
+ // re-derives the timeline, so without this the next gesture reads the
+ // envelope as it was BEFORE this one: draw a fade in, draw a fade out
+ // a moment later, and the second write is computed from a clip that
+ // still looks like it has no fade, which drops the first.
+ updateElement(elementKey, { automation: value || undefined });
}
// Dragging a point writes live: no preview refresh, so the composition
// does not reload and restart playback on every pixel.
@@ -163,6 +172,7 @@ export function useAutomationLanes(): UseAutomationLanesResult {
automationSelection,
setAutomationSelection,
clearAutomationSelection,
+ updateElement,
],
);
diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts
index 7e91aa02d5..be99998e02 100644
--- a/packages/studio/src/player/components/useTimelineTrackLayout.ts
+++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts
@@ -1,7 +1,6 @@
import { useMemo, useRef } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { animationLaneGroups } from "./TimelinePropertyLanes";
-import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
@@ -38,12 +37,14 @@ export function trackShowsBeatStrip(
/**
* Automation lanes on one clip, or 0 for anything that is not audio.
*
- * An audio clip can be worth expanding without carrying a single tween, so this
- * counts toward whether a track has anything to disclose. A function rather than
- * a map so every caller reads the same cached parse and none can drift.
+ * A clip can be worth expanding without carrying a single tween, so this counts
+ * toward whether a track has anything to disclose. Not gated on audio: a fade on
+ * a picture is an opacity envelope, and it earns a lane exactly as a volume one
+ * does. A function rather than a map so every caller reads the same cached parse
+ * and none can drift.
*/
function automationLaneCountOf(element: TimelineElement): number {
- return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0;
+ return elementAutomationLanes(element).length;
}
/**
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index beb87d608c..61ebbdbae6 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -20,7 +20,7 @@ import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
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";
@@ -141,15 +141,7 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, Thumbnail
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..3be0dc617c 100644
--- a/packages/studio/src/player/store/timelineElement.ts
+++ b/packages/studio/src/player/store/timelineElement.ts
@@ -76,3 +76,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"
+ // Written back optimistically after an envelope commit, so the next gesture
+ // reads the edit it just made rather than the state before it.
+ | "automation"
+>;