Files
hyperframes/packages/core/src/audioFade.ts
839ae426dd feat(studio): typed audio values, clip fades, audible video drops (#4250)
* feat(studio): typed audio values, clip fades, audible video drops

Audio rows in the media inspector (Volume, Speed, Media start) take a
typed value: click the readout, type "-6 dB", "1.5x" or "2.5s", Enter.
Speed presets are hidden on <audio> clips.

Clip-edge fades: data-fade-in / data-fade-out in seconds, honoured by the
preview runtime (per-tick gain), the render mixer (afade after volume) and
Studio (Fade in / Fade out rows, and drag dots at the top corners of audio
clips and audible videos). Linear on both sides so preview and render
agree; fades that outrun the clip scale to meet inside it.

Dropped videos with an audio stream now land with data-has-audio="true"
instead of muted, so the mixer keeps their sound. The media metadata
endpoint reports hasAudio from the ffprobe call it already makes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(audio): one owner for fades, dB, and afade-after-volume

Illegal fade values become no-fade at fadeGain; typed inspector input
still returns null so the readout stays red. Master and group mixes
share one filter chain with volume then afade. Fade-out stays anchored
to the trimmed clip end.

* fix(studio): share audio helpers and bound typed media values

* fix(studio): reserve sibling fade duration when editing

---------

Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-21 18:45:08 -04:00

66 lines
2.7 KiB
TypeScript

/** Clip-edge fades: `data-fade-in` / `data-fade-out` in seconds, linear, multiplied on the resolved level. */
export const HF_AUDIO_FADE_IN_ATTR = "data-fade-in";
export const HF_AUDIO_FADE_OUT_ATTR = "data-fade-out";
export const HF_AUDIO_FADE_IN_DATA_KEY = HF_AUDIO_FADE_IN_ATTR.slice("data-".length);
export const HF_AUDIO_FADE_OUT_DATA_KEY = HF_AUDIO_FADE_OUT_ATTR.slice("data-".length);
export interface AudioFades {
/** Seconds from the clip start over which gain rises 0 → 1. */
fadeIn: number;
/** Seconds before the clip end over which gain falls 1 → 0. */
fadeOut: number;
}
export const NO_FADES: Readonly<AudioFades> = Object.freeze({ fadeIn: 0, fadeOut: 0 });
function finiteFadeSeconds(value: number): number {
return Number.isFinite(value) && value > 0 ? value : 0;
}
/** Attribute text as seconds: finite and positive, else 0 (no fade). */
export function readFadeSeconds(raw: string | null | undefined): number {
if (raw == null || raw === "") return 0;
return finiteFadeSeconds(Number.parseFloat(raw));
}
export function readElementFades(el: { getAttribute(name: string): string | null }): AudioFades {
return {
fadeIn: readFadeSeconds(el.getAttribute(HF_AUDIO_FADE_IN_ATTR)),
fadeOut: readFadeSeconds(el.getAttribute(HF_AUDIO_FADE_OUT_ATTR)),
};
}
/** Scale fades that outrun the clip so they meet inside it. Infinite duration keeps them as authored. */
export function clampFadesToDuration(fades: AudioFades, duration: number): AudioFades {
const fadeIn = finiteFadeSeconds(fades.fadeIn);
const fadeOut = finiteFadeSeconds(fades.fadeOut);
if (!Number.isFinite(duration) || duration <= 0) return { fadeIn, fadeOut };
const total = fadeIn + fadeOut;
if (total <= duration) return { fadeIn, fadeOut };
const scale = duration / total;
return { fadeIn: fadeIn * scale, fadeOut: fadeOut * scale };
}
/** Gain at `elapsed` into a clip of `duration`. 1 where no fade runs; the two multiply where they meet. */
export function fadeGain(elapsed: number, duration: number, fades: AudioFades): number {
if (!Number.isFinite(elapsed)) return 1;
const { fadeIn, fadeOut } = clampFadesToDuration(fades, duration);
let gain = 1;
if (fadeIn > 0) gain *= clamp01(elapsed / fadeIn);
if (fadeOut > 0 && Number.isFinite(duration)) gain *= clamp01((duration - elapsed) / fadeOut);
return gain;
}
/** Attribute text Studio writes: up to 2 decimals, no trailing zeros. */
export function formatFadeSeconds(seconds: number): string {
const rounded = Math.round(finiteFadeSeconds(seconds) * 100) / 100;
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2).replace(/0+$/, "");
}
function clamp01(value: number): number {
if (!Number.isFinite(value) || value < 0) return 0;
return value > 1 ? 1 : value;
}