Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
116 lines
6.6 KiB
JavaScript
116 lines
6.6 KiB
JavaScript
// Audio lanes. A clip's own sound can be pulled onto a lane of its own ("separated"): the lane plays the sound pinned from
|
|
// that clip's picture, and the clip itself goes quiet. A separated lane follows its clip around the timeline.
|
|
import { segments, isVideoClip } from "./scenes.js";
|
|
|
|
const newId = () => "t" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
|
|
export const trackFor = (p, sceneId) => (p.audio_tracks || []).find((t) => t.kind === "separated" && t.scene_id === sceneId);
|
|
|
|
/** Does this clip have sound to pull out: a bin video clip, or a generated clip with a render. */
|
|
export const hasEmbeddedAudio = (sc, p) => {
|
|
if (!sc || sc.excluded || sc.audio_separated) return false;
|
|
if (isVideoClip(sc)) return Boolean(sc.source && sc.source.media_ref);
|
|
return Boolean(((p.scene_renders || {})[sc.id] || {}).media);
|
|
};
|
|
|
|
/** Pull a clip's sound onto its own lane. -> the lane, or false (nothing to pull, or already pulled). */
|
|
export function separate(p, sceneId) {
|
|
const at = (p.scenes || []).findIndex((s) => s.id === sceneId), sc = p.scenes[at];
|
|
if (!hasEmbeddedAudio(sc, p) || trackFor(p, sceneId)) return false;
|
|
const seg = segments(p).find((s) => s.kind === "scene" && s.id === sceneId);
|
|
const dur = sc.source_dur != null ? sc.source_dur : seg.dur;
|
|
const render = (p.scene_renders || {})[sceneId];
|
|
const video = isVideoClip(sc);
|
|
const inSec = video ? sc.source_in || 0 : (render.inSec || 0) + (sc.source_in || 0);
|
|
const lane = { id: newId(), kind: "separated", scene_id: sceneId, start_sec: seg.start, source_in_sec: inSec, source_dur: dur,
|
|
pinned_media: video ? null : JSON.parse(JSON.stringify(render.media)), pinned_bin_ref: video ? sc.source.media_ref : null,
|
|
pinned_in_sec: inSec, pinned_dur: dur, full_dur: dur, volume: sc.audio_volume != null ? sc.audio_volume : 1, label: `${video ? "V" : "S"}${at + 1} audio` };
|
|
p.audio_tracks = [...(p.audio_tracks || []), lane];
|
|
sc.audio_separated = true;
|
|
sc.audio_volume = 0;
|
|
return lane;
|
|
}
|
|
|
|
/** Take a lane away; a separated one gives its clip its sound (and volume) back. */
|
|
export function removeTrack(p, id) {
|
|
const t = (p.audio_tracks || []).find((x) => x.id === id);
|
|
if (!t) return false;
|
|
const sc = t.kind === "separated" && (p.scenes || []).find((s) => s.id === t.scene_id);
|
|
if (sc) { sc.audio_separated = false; if (t.volume != null) sc.audio_volume = t.volume; }
|
|
p.audio_tracks = p.audio_tracks.filter((x) => x.id !== id);
|
|
return true;
|
|
}
|
|
|
|
/** A separated lane sits where its clip sits. Run after every edit; changes nothing when nothing moved. */
|
|
export function syncSeparated(p) {
|
|
const tracks = p.audio_tracks || [];
|
|
if (!tracks.some((t) => t.kind === "separated")) return;
|
|
const segs = segments(p).filter((s) => s.kind === "scene");
|
|
const starts = new Map(segs.map((s) => [s.id, s.start])), durs = new Map(segs.map((s) => [s.id, s.dur]));
|
|
for (const t of tracks) {
|
|
if (t.kind !== "separated" || !starts.has(t.scene_id)) continue;
|
|
const sc = p.scenes.find((s) => s.id === t.scene_id);
|
|
if (sc.excluded && !sc.removed_from_plan) continue; // a clip removed from the plan still plays, so its sound still follows it
|
|
const offset = Math.max(-starts.get(t.scene_id), t.offset_sec || 0), start = starts.get(t.scene_id) + offset; // where the person dragged it, relative to its clip
|
|
if (Math.abs((t.start_sec || 0) - start) > 0.001) t.start_sec = start;
|
|
const room = durs.get(t.scene_id) - offset;
|
|
if (t.pinned_dur == null) continue;
|
|
const own = Math.min(t.user_dur != null ? t.user_dur : Infinity, t.full_dur != null ? t.full_dur : t.pinned_dur);
|
|
const want = Math.max(0.1, Math.min(own, room)); // never past the end of its picture, and back to its full length when the picture grows again
|
|
if (Math.abs(t.pinned_dur - want) > 0.001) { t.pinned_dur = want; t.source_dur = want; }
|
|
}
|
|
}
|
|
|
|
/** Slide a separated lane against its clip (it still follows the clip when the clip moves). */
|
|
export function moveLane(p, id, deltaSec) {
|
|
const t = (p.audio_tracks || []).find((x) => x.id === id);
|
|
if (!t || !deltaSec) return false;
|
|
if (t.kind !== "separated") { // a file laid over the cut: it simply starts later or earlier
|
|
const was = t.start_sec || 0;
|
|
t.start_sec = Math.max(0, was + deltaSec);
|
|
return t.start_sec !== was;
|
|
}
|
|
const seg = segments(p).find((s) => s.kind === "scene" && s.id === t.scene_id);
|
|
const was = t.offset_sec || 0;
|
|
t.offset_sec = Math.max(seg ? -seg.start : -Infinity, was + deltaSec); // never before 0: a slide past the edge must not be owed on the way back
|
|
return t.offset_sec !== was;
|
|
}
|
|
|
|
/** Trim a separated lane's sound. "in" cuts the head off (positive) or gives it back (negative, never before the sound begins);
|
|
* "out" sets the tail (never past the sound's own length). */
|
|
export function trimLane(p, id, edge, deltaSec) {
|
|
const t = (p.audio_tracks || []).find((x) => x.id === id);
|
|
if (!t || !deltaSec) return false;
|
|
if (t.kind !== "separated") { // a file laid over the cut: its own in-point and length
|
|
const dur = t.source_dur, inAt = t.source_in_sec || 0;
|
|
if (dur == null) return false;
|
|
if (edge === "out") { t.source_dur = Math.max(0.1, dur + deltaSec); return true; }
|
|
const d = Math.min(Math.max(deltaSec, -inAt, -(t.start_sec || 0)), dur - 0.1);
|
|
if (!d) return false;
|
|
t.source_in_sec = inAt + d; t.source_dur = dur - d; t.start_sec = (t.start_sec || 0) + d;
|
|
return true;
|
|
}
|
|
if (t.pinned_dur == null) return false;
|
|
const full = t.full_dur != null ? t.full_dur : t.pinned_dur;
|
|
if (edge === "out") { t.user_dur = Math.max(0.1, Math.min(full, t.pinned_dur + deltaSec)); return true; }
|
|
const d = Math.min(Math.max(deltaSec, -(t.pinned_in_sec || 0), -(t.start_sec || 0)), t.pinned_dur - 0.1); // nor earlier than the timeline's start
|
|
if (!d) return false;
|
|
t.pinned_in_sec = (t.pinned_in_sec || 0) + d; t.source_in_sec = t.pinned_in_sec;
|
|
t.full_dur = Math.max(0.1, full - d);
|
|
if (t.user_dur != null) t.user_dur = Math.max(0.1, t.user_dur - d);
|
|
t.offset_sec = (t.offset_sec || 0) + d;
|
|
return true;
|
|
}
|
|
|
|
/** Cut a file lane in two at `atSec` (timeline seconds); both halves keep playing what they did. -> the new (later) lane, or false. */
|
|
export function splitTrack(p, id, atSec) {
|
|
const t = (p.audio_tracks || []).find((x) => x.id === id);
|
|
if (!t || t.kind === "separated" || t.source_dur == null) return false;
|
|
const start = t.start_sec || 0, cut = atSec - start;
|
|
if (!(cut > 0.05 && cut < t.source_dur - 0.05)) return false;
|
|
const tail = { ...t, id: newId(), start_sec: atSec, source_in_sec: (t.source_in_sec || 0) + cut, source_dur: t.source_dur - cut };
|
|
t.source_dur = cut;
|
|
p.audio_tracks = [...p.audio_tracks, tail];
|
|
return tail;
|
|
}
|