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.
This commit is contained in:
Ular Kimsanov
2026-07-11 20:20:29 -04:00
committed by GitHub
parent ae4946e624
commit 920dad35eb
6 changed files with 336 additions and 1 deletions
+32 -1
View File
@@ -4,7 +4,7 @@ const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narratio
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;
function isAudioTimelineElement(
export function isAudioTimelineElement(
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
): boolean {
if (!element) return false;
@@ -29,3 +29,34 @@ export function isMusicTrack(
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;
}