mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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.
63 lines
2.6 KiB
TypeScript
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;
|
|
}
|