Files
hyperframes/packages/studio/src/utils/timelineInspector.ts
T
Ular Kimsanov 920dad35eb feat(studio): timeline leaf helpers — audio inspector, zoom math, UI prefs (#2192)
What: extends three leaf modules to their final NLE-stack form, tests in the
same change: timelineInspector (isAudioTimelineElement, resolveBeatSourceTrack),
timelineZoom (zoom/pps math incl. computePinnedZoomPercent), and
studioUiPreferences (persisted editor prefs).

Why: leaf dependencies of the NLE timeline stack; landing them first keeps
the later glue PRs to wiring.

How: additive from the consumer side — every export main already uses is
unchanged (typecheck against main's consumers passes untouched); every new
export is exercised by a test in this PR.

Test plan: bunx vitest run on the three test files; tsc --noEmit in
packages/studio; fallow audit --base origin/main clean.
2026-07-11 20:20:29 -04:00

63 lines
2.6 KiB
TypeScript

import type { TimelineElement } from "../player";
const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narration"]);
const AUDIO_SOURCE_EXT_RE = /\.(aac|flac|m4a|mp3|ogg|opus|wav)(?:[?#].*)?$/i;
const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
export function isAudioTimelineElement(
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
): boolean {
if (!element) return false;
const tag = element.tag.trim().toLowerCase();
if (AUDIO_TIMELINE_TAGS.has(tag)) return true;
return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
}
/** True for the music track: an audio element with data-timeline-role="music",
* or — when no role is set — an id matching the music regex. Voiceover/other
* audio (explicit non-music role) is excluded. */
export function isMusicTrack(
element:
| Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole">
| null
| undefined,
): boolean {
if (!element) return false;
if (!isAudioTimelineElement(element)) return false;
if (element.timelineRole === "music") return true;
if (element.timelineRole && element.timelineRole !== "music") return false;
const id = element.domId ?? element.id ?? "";
return MUSIC_ID_RE.test(id);
}
/**
* Resolve the best audio source for beat analysis. An explicitly tagged or
* named music track wins; when none is present (e.g. an audio file dropped
* from Finder with a generic id), the LONGEST untagged audio clip is used as a
* fallback. Ties on duration resolve to the FIRST such clip encountered (the loop
* keeps the current best on `>` only), i.e. discovery/DOM order wins.
* Returns the element and whether it was found via the fallback path.
*
* The `isMusicTrack` predicate is unchanged so beat-snap and drag-exclusion
* logic remain unaffected by this fallback.
*/
export function resolveBeatSourceTrack(
elements: readonly Pick<
TimelineElement,
"tag" | "src" | "id" | "domId" | "timelineRole" | "duration"
>[],
): { element: (typeof elements)[number]; isFallback: boolean } | null {
const explicit = elements.find(isMusicTrack);
if (explicit) return { element: explicit, isFallback: false };
// Fallback: pick the longest audio clip (skipping explicitly non-music roles
// like "sfx" or "voiceover" to avoid triggering beat analysis on those).
let best: (typeof elements)[number] | null = null;
for (const el of elements) {
if (!isAudioTimelineElement(el)) continue;
if (el.timelineRole && el.timelineRole !== "music") continue;
if (!best || el.duration > best.duration) best = el;
}
return best ? { element: best, isFallback: true } : null;
}