diff --git a/CHANGELOG.md b/CHANGELOG.md
index 803246a..b54cbf6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,8 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- Per-clip **stereo pan** (`props.pan`, −1…+1) on video/audio clips — inspector
+ slider + keyframes; preview, audio-hold, and Fast export all honor it. Linked
+ stems default to L `−1` / R `+1` / center for other channels; projects saved
+ before pan migrate once via `panSchema` (so omitting `pan: 0` later does not
+ re-hard-pan a centered stem). Compact MCP keeps `pan` on linked stems.
+- Stereo **master meter** (L/R) on the monitor — post-pan program sum in the
+ same RMS / LUFS / Peak modes as the per-track bars; A-tracks + video spill
+ route through one summed path when the meter worklet is active. Per-track
+ bars collapse via **◂** / **▸** beside the master strip (master L/R stay visible).
- Preview playback speed — a monitor toolbar toggle plus **J**/**K**/**L** shortcuts cycle the preview player through 1×, 1.5×, 2×, and 4× (L faster, J slower, K play/pause and reset to 1×). It rides on top of each clip's own speed and is forced back to 1× during export, so renders always come out at real time.
+### Fixed
+- Audio graph teardown on project reload — clip chains
+ (`MediaElementSource → splitter → gain → panner → bus`) are now fully
+ disconnected via `releaseClipEl` instead of only clearing the maps (which
+ left nodes wired to live track buses). Panner attach degrades gracefully if
+ `StereoPannerNode` is unavailable; inspector volume/pan changes refresh
+ audio-hold voices.
+
## [1.6.0] - 2026-07-14
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 4065cc2..a38f0bc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -157,6 +157,7 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`,
"width": 1280, "height": 720, "fps": 30, // canvas/export settings
"background": "#000000", // canvas color behind all clips (optional)
"revision": 7, // bump on every write!
+ "panSchema": 1, // 1 = pan-aware; omit only on pre-pan projects (UI migrates once)
"markers": [ { "t": 2.5 }, { "t": 5.0, "label": "drop" } ],
// ^ beat/cue markers: gold diamonds on the ruler, snap targets for clip edges.
"inPoint": 10.023, // in timeline marker; paired with outPoint sets the focus on the part of the timeline
@@ -166,7 +167,16 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`,
{ "id": "f_broll", "name": "B-roll", "parentId": null, "open": true }
],
"disabledTracks": [ "A2" ],
- // ^ optional — track ids (V4 V3 V2 V1 A1 A2 A3) omitted from preview/export when listed
+ // ^ optional — track ids (e.g. V4 V3 V2 V1 A1 A2 A3) omitted from preview/export when listed
+ "tracks": [ // optional — timeline lanes (default V3…V1 + A1…A4)
+ { "id": "V3", "kind": "video" }, // video: higher number drawn on top (V1 under V2 under V3…)
+ { "id": "V2", "kind": "video" },
+ { "id": "V1", "kind": "video" },
+ { "id": "A1", "kind": "audio" }, // audio: A1…An top→bottom; +V/+A in the UI appends Vn+1 / An+1
+ { "id": "A2", "kind": "audio" },
+ { "id": "A3", "kind": "audio" },
+ { "id": "A4", "kind": "audio" }
+ ],
"media": [
{ "id": "m_abc", "name": "intro.mp4", "kind": "video", // video|audio|image|svg
"src": "/media/intro.mp4", // path under ./media or ./library
@@ -178,12 +188,12 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`,
"id": "c_xyz", // unique string
"mediaId": "m_abc", // null for kind:"text" and kind:"adjust"
"kind": "video", // video | audio | image | svg | text | adjust
- "track": "V1", // V3 V2 V1 (top→bottom video) | A1 A2 A3 A4 (audio)
+ "track": "V1", // Vn…V1 (video) | A1…An (audio); extras via UI +V/+A or tracks[]
"start": 0, // timeline position, seconds
"in": 2.5, // offset into source media, seconds (0 for image/svg/text)
"duration": 5, // clip length on timeline, seconds
"name": "intro",
- "linkGroup": "lg_abc", // OPTIONAL — AV link: video + its L/R audio companions share one id
+ "linkGroup": "lg_abc", // OPTIONAL — AV link: video + per-channel audio stems share one id
"props": { /* all optional — see the props reference below */ },
// OPTIONAL — keyframe animation. Times are seconds RELATIVE TO CLIP START.
// "ease" sits on the DESTINATION keyframe of each segment:
@@ -250,6 +260,7 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`,
| prop | default | notes |
|---|---|---|
| `volume` | 1 | 0–2 |
+| `pan` | 0 | −1 (full left) … 0 (center) … +1 (full right). Linked stems default L `−1` / R `+1` / other `0`. Projects saved before pan migrate once on load (`panSchema`); keep `panSchema: 1` (and explicit `pan` on stems) when rewriting the document so a centered stem is not re-hard-panned. |
| `speed` | 1 | 0.25–4× playback rate. **Keyframable → speed ramps**: with `keyframes.speed` the engine time-remaps (media time = `in` + ∫speed dt), in preview and in the export audio mix. Static case: source window consumed = `duration × speed`, so `in + duration×speed ≤ media.duration`. |
**Text clips only:**
@@ -315,7 +326,7 @@ footage. Example: 0.3 s impact shake over everything =
`{kind:"adjust", track:"V3", duration:0.3, props:{shake:18}}`.
**Animatable props** (usable in `keyframes`): x, y, scale, rotation, opacity,
-volume, speed, brightness, contrast, saturation, hue, blur, grayscale, sepia,
+volume, pan, speed, brightness, contrast, saturation, hue, blur, grayscale, sepia,
invert, temperature, tint, vignette, cornerRadius, shake, rgbSplit, grain,
fontSize, letterSpacing, glow.
@@ -325,14 +336,19 @@ glitch (RGB split + jitter) · pop (overshoot scale — stickers/captions).
### Semantics
-- Rendering order: V1 is drawn first, then V2, V3 on top. SVG/image/text clips
- go on any V track (V2/V3 are handy overlay lanes).
-- **Audio tracks A1–A4** hold both standalone audio files *and* linked companions
+- Rendering order: V1 is drawn first, then V2, V3, … on top. SVG/image/text clips
+ go on any V track (higher V lanes are handy overlay lanes). Add lanes with the
+ timeline **+V** / **+A** buttons (or by listing them in `tracks`); up to 16 per kind.
+ Right-click an empty track header → **Remove track** (disabled if the lane has
+ clips or is the last video/audio track).
+- **Audio tracks A1–An** hold both standalone audio files *and* linked companions
for imported video. Dropping a video creates the picture on a V track
- (`props.volume: 0` so it isn't doubled) plus stereo L/R `kind:"audio"` clips
- on A1/A2 that share a `linkGroup` (and the same `mediaId` / timing). Standalone
- music/SFX also live on A1–A4. Linked partners move/trim/split together — edit
- timing on any member of the group; do not treat A-tracks as music-only.
+ (`props.volume: 0` so it isn't doubled) plus one `kind:"audio"` clip per source
+ channel on consecutive A-tracks that share a `linkGroup` (and the same
+ `mediaId` / timing). Each stem is mono-isolated then placed with `pan` (defaults
+ preserve stereo: L `−1`, R `+1`). Standalone music/SFX also live on A-tracks. Linked partners
+ move/trim/split together — edit timing on any member of the group; do not treat
+ A-tracks as music-only.
- Media is `fit`-ted to the canvas (default "contain"), then crop → scale/x/y/
rotation → flips apply.
- `props` keys are all optional — missing keys get the defaults above.
@@ -425,6 +441,8 @@ of previous durations.
**Music bed**: audio file on A1, `props.volume: 0.3`, trim with `in`/`duration`.
+**Center a mono stem**: linked or standalone audio clip with `props.pan: 0` (inspector **Pan** slider, −1…+1). Stereo video stems default to L `−1` / R `+1`; set A1 to `0` to center that channel in the mix.
+
**Cinematic grade**: `props.filterPreset: "teal-orange"` (tweak with
`temperature`/`vignette` on top).
diff --git a/README.md b/README.md
index a31d19e..ff16ed8 100644
--- a/README.md
+++ b/README.md
@@ -37,11 +37,22 @@ same time.
**Editing**
-- 3 video tracks + 4 audio tracks, drag/trim/split/snap, undo/redo
+- Video + audio tracks (default 3+4; add more with **+V** / **+A** in the track
+ header; right-click an empty header → **Remove track**), drag/trim/split/snap,
+ undo/redo
- **Settings** (cog in the top bar) — optional prefs stored in this browser via
`localStorage`. Enable **Link timeline and Project bin selection** so picking a
timeline clip highlights its media in Project, and clicking a Project item
selects every timeline clip that uses it (off by default).
+- Video + audio tracks (default 3+4), drag/trim/split/snap, undo/redo
+ - **+V** / **+A** in the track header add a video or audio lane (up to 16 each)
+ - Right-click an empty track header → **Remove track** (disabled if the lane
+ has clips, or if it would leave you with zero video/audio tracks)
+ - Track header **S** solos that lane (mutes all others); click again to restore
+ the previous mute state. Using the mute toggle while soloed exits solo.
+ - Dropping a video with more audio channels than A-tracks **adds the missing
+ lanes automatically** (up to 16) and toasts how many were added; each channel
+ becomes a linked stem on its own A-track
- **Direct manipulation on the monitor** — click a clip or title on the preview to
move, resize (corner handles), or rotate (top handle, Shift-snap) it directly
- **Timeline multi-select** — rubber-band marquee (drag on empty track area),
diff --git a/app.js b/app.js
index b412674..0fdb14f 100644
--- a/app.js
+++ b/app.js
@@ -7,21 +7,31 @@
"use strict";
/* ── Constants ─────────────────────────────────────────────────────────── */
-const TRACKS = [
- { id: "V3", kind: "video", h: 44, color: "#ffd166" },
- { id: "V2", kind: "video", h: 58, color: "#7b6cff" },
- { id: "V1", kind: "video", h: 58, color: "#4f8cff" },
- { id: "A1", kind: "audio", h: 42, color: "#7ec249" },
- { id: "A2", kind: "audio", h: 42, color: "#5a9e3a" },
- { id: "A3", kind: "audio", h: 42, color: "#4a8a2f" },
- { id: "A4", kind: "audio", h: 42, color: "#3a7226" },
+const VIDEO_TRACK_COLORS = ["#4f8cff", "#7b6cff", "#ffd166", "#ff6b9d", "#45d9c2", "#f4a261", "#e76f51", "#a8dadc"];
+const AUDIO_TRACK_COLORS = ["#7ec249", "#5a9e3a", "#4a8a2f", "#3a7226", "#2d5a1e", "#8fbc5a", "#6b9e3a", "#4d7a28"];
+const MAX_TRACKS_PER_KIND = 16;
+const DEFAULT_TRACK_DEFS = [
+ { id: "V3", kind: "video" },
+ { id: "V2", kind: "video" },
+ { id: "V1", kind: "video" },
+ { id: "A1", kind: "audio" },
+ { id: "A2", kind: "audio" },
+ { id: "A3", kind: "audio" },
+ { id: "A4", kind: "audio" },
];
+function makeTrack(id, kind) {
+ const n = Math.max(1, parseInt(String(id).slice(1), 10) || 1);
+ const colors = kind === "audio" ? AUDIO_TRACK_COLORS : VIDEO_TRACK_COLORS;
+ return { id, kind, h: kind === "audio" ? 42 : 58, color: colors[(n - 1) % colors.length] };
+}
+/** Live track list (top→bottom). Mutated by +V/+A; rebuilt from project.tracks on load. */
+let TRACKS = DEFAULT_TRACK_DEFS.map((d) => makeTrack(d.id, d.kind));
/* Three timeline density presets. L matches the original track heights (with thumbs).
S is compact solid-color rows; M is in between. */
const TRACK_SIZE_PRESETS = {
- s: { thumbs: false, h: { V3: 26, V2: 26, V1: 26, A1: 22, A2: 22, A3: 22, A4: 22 } },
- m: { thumbs: true, h: { V3: 36, V2: 44, V1: 44, A1: 32, A2: 32, A3: 32, A4: 32 } },
- l: { thumbs: true, h: { V3: 44, V2: 58, V1: 58, A1: 42, A2: 42, A3: 42, A4: 42 } },
+ s: { thumbs: false, hVideo: 26, hAudio: 22 },
+ m: { thumbs: true, hVideo: 44, hAudio: 32 },
+ l: { thumbs: true, hVideo: 58, hAudio: 42 },
};
// Generous cap on how many audio tracks can be auto-added for a multi-channel
// source (e.g. 7.1 surround = 8 channels) — see ensureAudioTrackCount().
@@ -37,7 +47,7 @@ const ZOOM_MAX = 300;
const TIMELINE_PAD_SEC = 15; // trailing empty seconds in the scrollable content
const DEFAULT_PROPS = {
- x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1,
+ x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, pan: 0,
speed: 1, // playback rate (video/audio)
brightness: 100, contrast: 100, saturation: 100, hue: 0,
blur: 0, grayscale: 0, sepia: 0, invert: 0,
@@ -63,7 +73,7 @@ const DEFAULT_PROPS = {
boxFit: false, // false = wrap at fixed fontSize; true = scale font to fit box
vAlign: "middle", // top | middle | bottom — vertical align of the text block in the box
};
-const ANIMATABLE = ["x", "y", "scale", "rotation", "opacity", "volume", "speed",
+const ANIMATABLE = ["x", "y", "scale", "rotation", "opacity", "volume", "pan", "speed",
"brightness", "contrast", "saturation", "hue", "blur", "grayscale", "sepia", "invert",
"temperature", "tint", "vignette", "cornerRadius", "shake", "rgbSplit", "grain",
"fontSize", "letterSpacing", "glow"];
@@ -133,63 +143,172 @@ const ASPECT_PRESETS = [
];
const WAVE_PEAKS_PER_SEC = 50;
const TRACK_IDS = new Set(TRACKS.map((t) => t.id));
-// Audio lanes available for a video's per-channel linked audio (index = props.audioChannel).
-// Starts as the 4 built-in tracks; ensureAudioTrackCount() grows both this and
-// TRACKS/TRACK_IDS at runtime for sources with more channels (5.1, 7.1, …).
-const AUDIO_TRACK_IDS = TRACKS.filter((t) => t.kind === "audio").map((t) => t.id);
-/* Add A5, A6, … until there are `need` audio tracks (capped at
- MAX_AUDIO_TRACKS), so multi-channel sources beyond stereo/quad (5.1, 7.1…)
- each get their own linked audio track. Returns how many were added. */
-function ensureAudioTrackCount(need) {
- need = Math.min(need, MAX_AUDIO_TRACKS);
- const palette = ["#7ec249", "#5a9e3a", "#4a8a2f", "#3a7226"];
- const newIds = [];
- while (AUDIO_TRACK_IDS.length < need) {
- const n = AUDIO_TRACK_IDS.length + 1;
- const id = "A" + n;
- const preset = TRACK_SIZE_PRESETS[state.trackSize] || TRACK_SIZE_PRESETS.l;
- TRACKS.push({ id, kind: "audio", h: preset.h.A1 || 42, color: palette[(n - 1) % palette.length] });
- TRACK_IDS.add(id);
- AUDIO_TRACK_IDS.push(id);
- newIds.push(id);
- }
- if (newIds.length) {
- buildTrackDOM();
- if (runtime.audio) addLiveAudioTrackBuses(newIds);
- }
- return newIds.length;
-}
-/* Wire newly-added tracks into an already-running audio graph (playback may
- have started before a multi-channel replace/add grew the track set). All
- buses are created up front, then the meter is reinstalled at most once —
- its AudioWorkletNode input count is fixed at creation, so adding several
- tracks in one go (e.g. 4 new lanes for a 7.1 source) must snapshot the
- full updated track list before rebuilding it, not one track at a time. */
-function addLiveAudioTrackBuses(ids) {
- const audio = runtime.audio;
- if (!audio) return;
- for (const id of ids) {
- if (audio.trackBus[id]) continue;
- audio.trackBus[id] = audio.ctx.createGain();
- audio.audioTrackIds.push(id);
+function syncTrackIds() {
+ TRACK_IDS.clear();
+ for (const t of TRACKS) TRACK_IDS.add(t.id);
+}
+function serializeTracks() {
+ return TRACKS.map(({ id, kind }) => ({ id, kind }));
+}
+function sortTracksInPlace() {
+ const vids = TRACKS.filter((t) => t.kind === "video")
+ .sort((a, b) => (parseInt(b.id.slice(1), 10) || 0) - (parseInt(a.id.slice(1), 10) || 0));
+ const auds = TRACKS.filter((t) => t.kind === "audio")
+ .sort((a, b) => (parseInt(a.id.slice(1), 10) || 0) - (parseInt(b.id.slice(1), 10) || 0));
+ TRACKS.length = 0;
+ TRACKS.push(...vids, ...auds);
+ syncTrackIds();
+}
+function applyTracksFromProject(defs) {
+ const raw = Array.isArray(defs) && defs.length
+ ? defs.filter((d) => d && d.id && (d.kind === "video" || d.kind === "audio"))
+ : DEFAULT_TRACK_DEFS;
+ const seen = new Set();
+ const list = raw.filter((d) => {
+ if (seen.has(d.id)) return false;
+ seen.add(d.id);
+ return true;
+ });
+ TRACKS.length = 0;
+ for (const d of list) TRACKS.push(makeTrack(d.id, d.kind === "audio" ? "audio" : "video"));
+ sortTracksInPlace();
+ applyTrackHeights();
+}
+/** Ensure every clip.track exists (agents may reference V4+ before the UI adds it). */
+function ensureTracksCoverClips() {
+ let added = false;
+ for (const c of project.clips) {
+ if (!c.track || TRACK_IDS.has(c.track)) continue;
+ const kind = c.kind === "audio" || /^A\d+$/i.test(c.track) ? "audio" : "video";
+ TRACKS.push(makeTrack(c.track, kind));
+ TRACK_IDS.add(c.track); // mark present before later clips (avoids duplicate makeTrack)
+ added = true;
}
- if (!audio.meterReady) {
- for (const id of ids) audio.trackBus[id]?.connect(audio.master);
- return;
+ if (added) {
+ sortTracksInPlace();
+ applyTrackHeights();
}
- // Feed every bus (old + new) through master directly before tearing down
- // the old meter — if the rebuild below fails, all tracks stay audible via
- // this fallback instead of feeding an orphaned, disconnected meter node.
- for (const id of Object.keys(audio.trackBus)) {
- try { audio.trackBus[id].disconnect(); } catch {}
- audio.trackBus[id].connect(audio.master);
+}
+function audioTrackIds() {
+ return TRACKS.filter((t) => t.kind === "audio").map((t) => t.id);
+}
+function nextTrackId(kind) {
+ const prefix = kind === "audio" ? "A" : "V";
+ let max = 0;
+ for (const t of TRACKS) {
+ if (t.kind !== kind) continue;
+ const n = parseInt(t.id.slice(1), 10);
+ if (n > max) max = n;
+ }
+ return prefix + (max + 1);
+}
+function addTimelineTrack(kind) {
+ const existing = TRACKS.filter((t) => t.kind === kind).length;
+ if (existing >= MAX_TRACKS_PER_KIND) {
+ toast(`Maximum ${MAX_TRACKS_PER_KIND} ${kind} tracks`);
+ return null;
+ }
+ const id = nextTrackId(kind);
+ const t = makeTrack(id, kind);
+ TRACKS.push(t);
+ sortTracksInPlace();
+ applyTrackHeights();
+ project.tracks = serializeTracks();
+ if (state.soloId && state.soloId !== id) {
+ state.disabledTracks.add(id);
+ project.disabledTracks = [...state.disabledTracks].sort();
}
- try { audio.meter.port.onmessage = null; } catch {}
- try { audio.meter.disconnect(); } catch {}
- audio.meter = null;
- audio.meterReady = false;
- installMeterWorklet(audio).catch(() => {});
+ if (kind === "audio") syncAudioGraphTracks();
+ buildTrackDOM();
+ syncAllTrackDisabledUI();
+ state.dirtyTimeline = true;
+ rebuildClips();
+ const h = setTimelineHeight(Math.max(
+ $("timelinePanel")?.getBoundingClientRect().height || 0,
+ defaultTimelineHeight()
+ ));
+ localStorage.setItem(TL_H_KEY, String(h));
+ scheduleSave();
+ return t;
+}
+function trackHasClips(trackId) {
+ return project.clips.some((c) => c.track === trackId);
+}
+function canRemoveTrack(trackId) {
+ const t = TRACKS.find((x) => x.id === trackId);
+ if (!t) return { ok: false, reason: "Unknown track" };
+ if (trackHasClips(trackId)) return { ok: false, reason: "Track has clips" };
+ if (TRACKS.filter((x) => x.kind === t.kind).length <= 1)
+ return { ok: false, reason: `Keep at least one ${t.kind} track` };
+ return { ok: true, reason: "" };
+}
+function removeTimelineTrack(trackId) {
+ const check = canRemoveTrack(trackId);
+ if (!check.ok) { toast(check.reason); return false; }
+ const wasAudio = TRACKS.find((t) => t.id === trackId)?.kind === "audio";
+ const idx = TRACKS.findIndex((t) => t.id === trackId);
+ if (idx < 0) return false;
+ if (state.soloId === trackId) clearTrackSolo({ restore: true });
+ TRACKS.splice(idx, 1);
+ syncTrackIds();
+ if (state.disabledTracks.has(trackId)) {
+ state.disabledTracks.delete(trackId);
+ project.disabledTracks = [...state.disabledTracks].sort();
+ }
+ if (Array.isArray(state.soloRestore)) {
+ state.soloRestore = state.soloRestore.filter((id) => id !== trackId);
+ }
+ project.tracks = serializeTracks();
+ if (wasAudio) syncAudioGraphTracks();
+ buildTrackDOM();
+ syncAllTrackDisabledUI();
+ state.dirtyTimeline = true;
+ rebuildClips();
+ scheduleSave();
+ return true;
}
+/* Lightweight right-click menu for track headers. */
+let trackCtxMenu = null;
+function hideTrackCtxMenu() {
+ if (trackCtxMenu) { trackCtxMenu.remove(); trackCtxMenu = null; }
+}
+function showTrackCtxMenu(clientX, clientY, track) {
+ hideTrackCtxMenu();
+ const check = canRemoveTrack(track.id);
+ const menu = document.createElement("div");
+ menu.className = "ctx-menu";
+ menu.id = "trackCtxMenu";
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "ctx-item";
+ btn.textContent = "Remove track";
+ if (!check.ok) {
+ btn.disabled = true;
+ btn.title = check.reason;
+ } else {
+ btn.addEventListener("click", () => {
+ hideTrackCtxMenu();
+ removeTimelineTrack(track.id);
+ });
+ }
+ menu.appendChild(btn);
+ document.body.appendChild(menu);
+ trackCtxMenu = menu;
+ const pad = 6;
+ const w = menu.offsetWidth, h = menu.offsetHeight;
+ let x = clientX, y = clientY;
+ if (x + w + pad > window.innerWidth) x = window.innerWidth - w - pad;
+ if (y + h + pad > window.innerHeight) y = window.innerHeight - h - pad;
+ menu.style.left = Math.max(pad, x) + "px";
+ menu.style.top = Math.max(pad, y) + "px";
+ btn.focus();
+}
+document.addEventListener("pointerdown", (e) => {
+ if (trackCtxMenu && !trackCtxMenu.contains(e.target)) hideTrackCtxMenu();
+}, true);
+document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") hideTrackCtxMenu();
+}, true);
/* ── User settings (localStorage; optional behavior toggles) ── */
const SETTINGS_KEY = "fablecut-settings";
@@ -234,7 +353,9 @@ const project = {
markers: [], // {t, label?} — beat/cue markers on the ruler; snap targets
inPoint: null, // timeline work-area IN (seconds), or null
outPoint: null, // timeline work-area OUT (seconds), or null
- disabledTracks: [], // track ids (V4…A3) hidden from preview/export when listed
+ disabledTracks: [], // track ids omitted from preview/export when listed
+ tracks: null, // optional [{id, kind}] — null means default V3…V1 + A1…A4
+ panSchema: 1, // 1 = pan-aware; gates one-time L/R stem migration on load
};
const state = {
time: 0, playing: false, pps: 60, snap: true,
@@ -252,6 +373,8 @@ const state = {
workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT
binTab: "project", // project | elements | sfx | svg
disabledTracks: new Set(), // mirror of project.disabledTracks for fast lookup
+ soloId: null, // track id when solo is active, else null
+ soloRestore: null, // disabledTracks snapshot taken when solo engaged
transFocus: null, // "in" | "out" — inspector transition row highlighted
kfGraphs: new Set(), // animatable prop keys with open monitor graphs
};
@@ -265,27 +388,62 @@ function isTrackEnabled(id) {
}
function syncTrackDisabledUI(id) {
const on = isTrackEnabled(id);
+ const solo = state.soloId === id;
const head = els.trackHeaders.querySelector(`.track-head[data-track="${id}"]`);
const row = els.tracks.querySelector(`.track[data-track="${id}"]`);
if (head) {
head.classList.toggle("disabled", !on);
+ head.classList.toggle("solo", solo);
const btn = head.querySelector(".track-toggle");
if (btn) {
btn.setAttribute("aria-pressed", on ? "true" : "false");
btn.title = on ? "Disable track" : "Enable track";
}
+ const sBtn = head.querySelector(".track-solo");
+ if (sBtn) {
+ sBtn.setAttribute("aria-pressed", solo ? "true" : "false");
+ sBtn.classList.toggle("on", solo);
+ sBtn.title = solo ? "Unsolo track" : "Solo track (mute all others)";
+ }
+ }
+ if (row) {
+ row.classList.toggle("disabled", !on);
+ row.classList.toggle("solo", solo);
}
- if (row) row.classList.toggle("disabled", !on);
}
function syncAllTrackDisabledUI() {
for (const t of TRACKS) syncTrackDisabledUI(t.id);
}
+function clearTrackSolo({ restore = false } = {}) {
+ if (!state.soloId) return;
+ if (restore && Array.isArray(state.soloRestore)) {
+ state.disabledTracks = new Set(state.soloRestore.filter((id) => TRACK_IDS.has(id)));
+ project.disabledTracks = [...state.disabledTracks].sort();
+ }
+ state.soloId = null;
+ state.soloRestore = null;
+}
+function toggleTrackSolo(id) {
+ if (!TRACK_IDS.has(id)) return;
+ if (state.soloId === id) {
+ clearTrackSolo({ restore: true });
+ } else {
+ if (!state.soloId) state.soloRestore = [...state.disabledTracks];
+ state.soloId = id;
+ state.disabledTracks = new Set(TRACKS.filter((t) => t.id !== id).map((t) => t.id));
+ project.disabledTracks = [...state.disabledTracks].sort();
+ }
+ syncAllTrackDisabledUI();
+ scheduleSave();
+}
function toggleTrackEnabled(id) {
if (!TRACK_IDS.has(id)) return;
+ // Manual mute exits solo without restoring the pre-solo snapshot
+ if (state.soloId) clearTrackSolo({ restore: false });
if (state.disabledTracks.has(id)) state.disabledTracks.delete(id);
else state.disabledTracks.add(id);
project.disabledTracks = [...state.disabledTracks].sort();
- syncTrackDisabledUI(id);
+ syncAllTrackDisabledUI();
scheduleSave();
}
const runtime = {
@@ -570,7 +728,6 @@ function startFolderRename(folderId) {
}
function applyProject(data) {
const wa = normalizeWorkArea(data.inPoint, data.outPoint);
- const disabledTracks = normalizeDisabledTracks(data.disabledTracks);
Object.assign(project, {
name: data.name || "Untitled Project",
width: data.width || 1280, height: data.height || 720, fps: data.fps || 30,
@@ -582,39 +739,51 @@ function applyProject(data) {
markers: (data.markers || []).filter((m) => m && isFinite(m.t)).sort((a, b) => a.t - b.t),
inPoint: wa.inPoint,
outPoint: wa.outPoint,
- disabledTracks,
+ disabledTracks: [],
+ tracks: null,
});
+ applyTracksFromProject(data.tracks);
+ ensureTracksCoverClips();
+ project.tracks = serializeTracks();
+ const disabledTracks = normalizeDisabledTracks(data.disabledTracks);
+ project.disabledTracks = disabledTracks;
const folderIds = new Set(project.folders.map((f) => f.id));
for (const m of project.media) {
if (m.folderId && !folderIds.has(m.folderId)) m.folderId = null;
}
state.disabledTracks = new Set(disabledTracks);
+ state.soloId = null;
+ state.soloRestore = null;
+ // One-shot migration for projects saved before props.pan existed. Gated by
+ // panSchema so a later write that omits pan:0 (compact MCP / agent rebuild)
+ // does not re-hard-pan a deliberately centered stem.
+ const migratePan = !(data.panSchema >= 1);
for (const c of project.clips) {
- c.props = { ...DEFAULT_PROPS, ...(c.props || {}) };
+ const raw = c.props || {};
+ c.props = { ...DEFAULT_PROPS, ...raw };
+ if (migratePan && !Object.hasOwn(raw, "pan") && Number.isInteger(raw.audioChannel) && raw.audioChannel >= 0)
+ c.props.pan = defaultPanForChannel(raw.audioChannel);
if (c.keyframes) for (const arr of Object.values(c.keyframes))
if (Array.isArray(arr)) arr.sort((a, b) => a.t - b.t);
if (c.kind === "text") ensureFont(c.props.font);
}
- // TRACKS isn't persisted — any extra audio lanes (A5+, from a multi-channel
- // source) only exist as clip.track references on disk. Recreate them so
- // those clips don't silently vanish from the timeline on reload.
- let maxAudioTrack = AUDIO_TRACK_IDS.length;
- for (const c of project.clips) {
- const am = /^A(\d+)$/.exec(c.track || "");
- if (am) maxAudioTrack = Math.max(maxAudioTrack, +am[1]);
- }
- if (maxAudioTrack > AUDIO_TRACK_IDS.length) ensureAudioTrackCount(maxAudioTrack);
+ project.panSchema = 1;
+ if (migratePan) scheduleSave(); // persist marker + migrated stem pans
// AV links aren't always on disk (older saves / agents) — rebuild from matching timing.
relinkClips();
// reset runtime playback elements so they rebuild against new data
if (state.audioHold) setAudioHold(false);
else stopAudioHoldNodes();
- for (const el of runtime.clipEls.values()) { try { el.pause(); el.src = ""; } catch { } }
- runtime.clipEls.clear(); runtime.clipGain.clear();
+ // Tear down each clip's Web Audio chain (src→split→gain→panner→bus) before
+ // clearing the maps — otherwise nodes stay wired to live track buses and leak.
+ for (const id of new Set([...runtime.clipEls.keys(), ...runtime.clipGain.keys()]))
+ releaseClipEl(id);
+ if (runtime.audio) syncAudioGraphTracks();
els.preview.width = project.width; els.preview.height = project.height;
els.monitorRes.textContent = `${project.width} × ${project.height} · ${project.fps}fps`;
syncAspectSel();
pruneSelection(); // keep the selection across external reloads where possible
+ buildTrackDOM();
state.dirtyTimeline = true;
renderBin(); renderInspector();
updateWorkArea();
@@ -643,8 +812,10 @@ function projectJSON() {
const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks } = project;
return {
name, width, height, fps, background, revision,
+ panSchema: 1,
folders: (folders || []).map(({ id, name, parentId, open }) =>
({ id, name, parentId: parentId || null, open: open !== false })),
+ tracks: serializeTracks(),
media: media.filter((m) => !m.transient).map(({ id, name, kind, src, duration, width, height, folderId }) =>
({ id, name, kind, src, duration, width, height, folderId: folderId || null })),
clips: clips.map(({ id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut, linkedId, linkGroup }) => {
@@ -804,7 +975,7 @@ function wavePeaksFor(c) {
if (w instanceof Float32Array) return w;
if (!w.max) return null;
const ch = c.props?.audioChannel;
- if (Number.isInteger(ch) && w.channels?.[ch]) return w.channels[ch];
+ if (Number.isInteger(ch) && ch >= 0 && w.channels?.[ch]) return w.channels[ch];
return w.max;
}
@@ -1213,7 +1384,7 @@ function linkedClip(c) {
return c?.linkedId ? getClip(c.linkedId) : null;
}
/* Expand a clip list so each AV-linked partner is included once.
- Supports N-way `linkGroup` (video + L + R) and legacy pairwise `linkedId`. */
+ Supports N-way `linkGroup` (video + per-channel stems) and legacy pairwise `linkedId`. */
function withLinked(clips) {
const out = new Map();
const groups = new Set();
@@ -1246,7 +1417,7 @@ function syncLinkedTiming(c) {
}
/* Rebuild AV linkGroups after load. Unlinking isn't supported, so any video +
audio clips that share mediaId and the same start/in/duration belong together
- (e.g. picture + L/R stems from one file). Legacy pairwise linkedId is cleared
+ (e.g. picture + L/R/C stems from one file). Legacy pairwise linkedId is cleared
in favor of linkGroup. */
function relinkClips() {
const near = (a, b) => Math.abs((+a || 0) - (+b || 0)) < 1e-3;
@@ -1275,6 +1446,93 @@ function relinkClips() {
}
}
+/* Discrete-channel labels for linked stems (WAV / Web Audio order). */
+const CHANNEL_SHORT = ["L", "R", "C", "LFE", "Ls", "Rs", "Lb", "Rb"];
+const CHANNEL_LONG = ["Left", "Right", "Center", "LFE", "Surround L", "Surround R", "Back L", "Back R"];
+function audioChannelShort(ch) {
+ if (!Number.isInteger(ch) || ch < 0) return "";
+ return CHANNEL_SHORT[ch] || `Ch${ch + 1}`;
+}
+function audioChannelLong(ch) {
+ if (!Number.isInteger(ch) || ch < 0) return null;
+ return CHANNEL_LONG[ch] || `Channel ${ch + 1}`;
+}
+/** Default stereo pan for an isolated linked stem (L −1, R +1, else center). */
+function defaultPanForChannel(ch) {
+ if (ch === 0) return -1;
+ if (ch === 1) return 1;
+ return 0;
+}
+function clipPan(v) { return clamp(+v || 0, -1, 1); }
+/** How many linked A-track stems we can create for a media item. */
+function linkedAudioChannelCount(m) {
+ const n = Math.max(1, m.channels | 0);
+ return Math.min(n, audioTrackIds().length);
+}
+/** Grow A-tracks to at least `need` (capped at MAX_TRACKS_PER_KIND). Returns how many were added. */
+function ensureAudioTrackCount(need) {
+ need = Math.min(Math.max(0, need | 0), MAX_TRACKS_PER_KIND);
+ let added = 0;
+ while (audioTrackIds().length < need) {
+ if (TRACKS.filter((t) => t.kind === "audio").length >= MAX_TRACKS_PER_KIND) break;
+ const id = nextTrackId("audio");
+ TRACKS.push(makeTrack(id, "audio"));
+ if (state.soloId && state.soloId !== id) state.disabledTracks.add(id);
+ added++;
+ }
+ if (!added) return 0;
+ sortTracksInPlace();
+ applyTrackHeights();
+ project.tracks = serializeTracks();
+ if (state.disabledTracks.size) project.disabledTracks = [...state.disabledTracks].sort();
+ syncAudioGraphTracks();
+ buildTrackDOM();
+ syncAllTrackDisabledUI();
+ state.dirtyTimeline = true;
+ rebuildClips();
+ const h = setTimelineHeight(Math.max(
+ $("timelinePanel")?.getBoundingClientRect().height || 0,
+ defaultTimelineHeight()
+ ));
+ localStorage.setItem(TL_H_KEY, String(h));
+ scheduleSave();
+ return added;
+}
+/** Attach one audio clip per source channel (A1…An), sharing the video's linkGroup. */
+function attachLinkedAudioChannels(videoClip, m, nCh) {
+ if (!videoClip?.linkGroup || !getClip(videoClip.id)) return [];
+ const lg = videoClip.linkGroup;
+ // Drop any prior stems for this group (e.g. stereo placeholder → 3.0 upgrade).
+ const doomed = project.clips.filter((x) => x.linkGroup === lg && x.kind === "audio");
+ for (const c of doomed) releaseClipEl(c.id);
+ project.clips = project.clips.filter((x) => !(x.linkGroup === lg && x.kind === "audio"));
+ const ids = audioTrackIds();
+ const n = Math.min(Math.max(1, nCh | 0), ids.length);
+ const out = [];
+ for (let ch = 0; ch < n; ch++) {
+ const a = {
+ id: "c_" + uid(), mediaId: m.id, kind: "audio", track: ids[ch],
+ start: videoClip.start, in: videoClip.in, duration: videoClip.duration,
+ name: videoClip.name,
+ props: { ...DEFAULT_PROPS, audioChannel: ch, pan: defaultPanForChannel(ch) },
+ linkGroup: lg,
+ };
+ if (videoClip.props?.speed != null) a.props.speed = videoClip.props.speed;
+ project.clips.push(a);
+ out.push(a);
+ }
+ return out;
+}
+/** Tap one source channel into a mono gain (pan places it in the stereo field). */
+function connectIsolatedChannel(ctx, srcNode, gainNode, ch, nCh) {
+ const outputs = Math.max(2, nCh | 0, (ch | 0) + 1);
+ const splitter = ctx.createChannelSplitter(outputs);
+ srcNode.connect(splitter);
+ splitter.connect(gainNode, ch);
+ gainNode._fcSplit = splitter;
+ gainNode._fcChannel = ch;
+}
+
function addClipFromMedia(m, trackId, at) {
pushUndo();
const kind = m.kind;
@@ -1290,25 +1548,32 @@ function addClipFromMedia(m, trackId, at) {
props: { ...DEFAULT_PROPS },
};
project.clips.push(c);
- // Video+audio: picture on a V track; stereo L/R as separate linked clips on A1/A2.
+ // Video+audio: picture on a V track; one linked stem per source channel on A-tracks.
// Mute the video clip so audio isn't doubled.
if (kind === "video") {
c.props.volume = 0;
- const lg = "lg_" + uid();
- c.linkGroup = lg;
- const aL = {
- id: "c_" + uid(), mediaId: m.id, kind: "audio", track: "A1",
- start, in: 0, duration, name,
- props: { ...DEFAULT_PROPS, audioChannel: 0 },
- linkGroup: lg,
- };
- const aR = {
- id: "c_" + uid(), mediaId: m.id, kind: "audio", track: "A2",
- start, in: 0, duration, name,
- props: { ...DEFAULT_PROPS, audioChannel: 1 },
- linkGroup: lg,
+ c.linkGroup = "lg_" + uid();
+ const finish = (nCh) => {
+ if (!getClip(c.id) || !c.linkGroup) return;
+ m.channels = nCh;
+ const want = Math.min(Math.max(1, nCh | 0), MAX_TRACKS_PER_KIND);
+ const added = ensureAudioTrackCount(want);
+ if (added) {
+ toast(added === 1
+ ? `Added an audio track for ${nCh}-channel audio`
+ : `Added ${added} audio tracks for ${nCh}-channel audio`);
+ }
+ attachLinkedAudioChannels(c, m, linkedAudioChannelCount(m));
+ state.dirtyTimeline = true;
+ scheduleSave();
+ renderInspector();
};
- project.clips.push(aL, aR);
+ if (m.channels > 0) {
+ finish(m.channels);
+ } else {
+ getAudioBuffer(m).then((buf) => finish(buf.numberOfChannels))
+ .catch(() => finish(2)); // unknown → stereo fallback
+ }
ensureWave(m);
reconcileAudioChannels(c);
}
@@ -1343,10 +1608,11 @@ async function reconcileAudioChannels(videoClip) {
const live = getClip(videoClip.id);
if (!live || live.mediaId !== mediaId) return; // superseded by a newer add/replace
const lg = live.linkGroup;
- const tracksBefore = AUDIO_TRACK_IDS.length;
+ const tracksBefore = audioTrackIds().length;
if (chCount > tracksBefore) ensureAudioTrackCount(chCount);
- const newTracks = AUDIO_TRACK_IDS.length - tracksBefore;
- const wantCh = Math.min(chCount, AUDIO_TRACK_IDS.length);
+ const ids = audioTrackIds();
+ const newTracks = ids.length - tracksBefore;
+ const wantCh = Math.min(chCount, ids.length);
const have = project.clips.filter((c) => c.linkGroup === lg && c.kind === "audio");
let added = 0, removed = 0;
for (const c of have) {
@@ -1359,17 +1625,20 @@ async function reconcileAudioChannels(videoClip) {
for (let ch = 0; ch < wantCh; ch++) {
if (have.some((c) => c.props?.audioChannel === ch)) continue;
project.clips.push({
- id: "c_" + uid(), mediaId, kind: "audio", track: AUDIO_TRACK_IDS[ch],
+ id: "c_" + uid(), mediaId, kind: "audio", track: ids[ch],
start: live.start, in: live.in, duration: live.duration, name: live.name,
- props: { ...DEFAULT_PROPS, audioChannel: ch },
+ props: { ...DEFAULT_PROPS, audioChannel: ch, pan: defaultPanForChannel(ch) },
linkGroup: lg,
});
added++;
}
- if (!added && !removed) return;
+ if (!added && !removed) {
+ if (newTracks > 0) scheduleSave();
+ return;
+ }
state.dirtyTimeline = true;
scheduleSave(); renderInspector();
- if (chCount > AUDIO_TRACK_IDS.length)
+ if (chCount > ids.length)
toast(`${m.name}: ${chCount} audio channels, only ${MAX_AUDIO_TRACKS} tracks supported — extra channel(s) dropped`);
else if (added)
toast(newTracks
@@ -1971,30 +2240,46 @@ function trackToggleIcon(kind) {
``;
}
function buildTrackDOM() {
- els.trackHeaders.innerHTML = "";
+ let inner = $("trackHeadInner");
+ if (!inner) {
+ inner = document.createElement("div");
+ inner.id = "trackHeadInner";
+ els.trackHeaders.appendChild(inner);
+ }
+ inner.innerHTML = "";
els.tracks.innerHTML = "";
- const inner = document.createElement("div");
- inner.id = "trackHeadInner";
- els.trackHeaders.appendChild(inner);
for (const t of TRACKS) {
const on = isTrackEnabled(t.id);
+ const solo = state.soloId === t.id;
const h = document.createElement("div");
- h.className = "track-head" + (on ? "" : " disabled");
+ h.className = "track-head" + (on ? "" : " disabled") + (solo ? " solo" : "");
h.dataset.track = t.id;
h.style.height = t.h + "px";
h.innerHTML =
`` +
- `${t.id}`;
+ `${escapeHtml(t.id)}` +
+ ``;
h.querySelector(".track-toggle").addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
toggleTrackEnabled(t.id);
});
+ h.querySelector(".track-solo").addEventListener("click", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ toggleTrackSolo(t.id);
+ });
+ h.addEventListener("contextmenu", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ showTrackCtxMenu(ev.clientX, ev.clientY, t);
+ });
inner.appendChild(h);
const row = document.createElement("div");
- row.className = "track" + (on ? "" : " disabled");
+ row.className = "track" + (on ? "" : " disabled") + (solo ? " solo" : "");
row.dataset.track = t.id;
row.style.height = t.h + "px";
els.tracks.appendChild(row);
@@ -2100,9 +2385,10 @@ function rebuildClips() {
if (hasWave) body += ``;
const badge = (c.keyframes && Object.keys(c.keyframes).length ? "◆ " : "") +
(c.transitionIn || c.transitionOut ? "⇄ " : "");
- const chN = c.props?.audioChannel;
- const chTag = chN === 0 ? "L · " : chN === 1 ? "R · "
- : Number.isInteger(chN) ? `Ch${chN + 1} · ` : "";
+ const chTag = (() => {
+ const s = audioChannelShort(c.props?.audioChannel);
+ return s ? s + " · " : "";
+ })();
const label = c.kind === "text" ? "T · " + (c.props.text || "").split("\n")[0]
: c.kind === "adjust" ? "FX · " + (c.name || "")
: c.kind === "audio" ? chTag + (c.name || "")
@@ -2802,6 +3088,8 @@ function zoomToRange(t0, t1) {
}
els.zoomSlider.addEventListener("input", () => setZoom(+els.zoomSlider.value));
$("btnZoomFit").addEventListener("click", zoomToFit);
+$("btnAddV").addEventListener("click", () => addTimelineTrack("video"));
+$("btnAddA").addEventListener("click", () => addTimelineTrack("audio"));
els.timelineScroll.addEventListener("wheel", (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
@@ -2986,12 +3274,11 @@ function renderInspector(lite) {
`;
}
if (c.kind === "video" || c.kind === "audio") {
- const chIdx = c.props?.audioChannel;
- const chLabel = chIdx === 0 ? "Left" : chIdx === 1 ? "Right"
- : Number.isInteger(chIdx) ? `Channel ${chIdx + 1}` : null;
+ const chLabel = audioChannelLong(c.props?.audioChannel);
html += `
Audio / Time
${chLabel ? row("Channel", `${chLabel}`) : ""}
${slider("volume", 0, 2, 0.01, p.volume)}
+ ${slider("pan", -1, 1, 0.01, p.pan)}
${slider("speed", 0.25, 4, 0.05, p.speed, "×")}
`;
}
@@ -3118,6 +3405,7 @@ function renderInspector(lite) {
else { c.props[k] = v; if (k === "text") state.dirtyTimeline = true; }
const valEl = els.inspector.querySelector(`[data-val="${k}"]`);
if (valEl) valEl.textContent = input.value;
+ if (state.audioHold && (k === "volume" || k === "pan")) scheduleAudioHoldRefresh();
scheduleSave();
});
input.addEventListener("focus", () => pushUndo(), { once: true });
@@ -3201,7 +3489,7 @@ function renderInspector(lite) {
/* ── Keyframe graphs (program-monitor left gutter) ── */
const KF_GRAPH_LABEL = {
x: "Pos X", y: "Pos Y", scale: "Scale", rotation: "Rotation", opacity: "Opacity",
- volume: "Volume", speed: "Speed", brightness: "Bright", contrast: "Contrast",
+ volume: "Volume", pan: "Pan", speed: "Speed", brightness: "Bright", contrast: "Contrast",
saturation: "Sat", hue: "Hue", blur: "Blur", grayscale: "Gray", sepia: "Sepia",
invert: "Invert", temperature: "Temp", tint: "Tint", vignette: "Vignette",
cornerRadius: "Radius", shake: "Shake", rgbSplit: "RGB", grain: "Grain",
@@ -3380,21 +3668,38 @@ function releaseClipEl(id) {
if (el) { try { el.pause(); el.src = ""; } catch { } runtime.clipEls.delete(id); }
const g = runtime.clipGain.get(id);
if (g) {
+ const out = g._fcOut || g;
+ try { out.disconnect(); } catch {}
+ out._fcBus = null;
+ if (g._fcPanner && g._fcPanner !== out) { try { g._fcPanner.disconnect(); } catch {} }
try { g.disconnect(); } catch {}
- if (g._fcOut) { try { g._fcOut.disconnect(); } catch {} }
if (g._fcSplit) { try { g._fcSplit.disconnect(); } catch {} }
+ if (g._fcSrc) { try { g._fcSrc.disconnect(); } catch {} }
+ g._fcOut = g._fcPanner = g._fcSplit = g._fcSrc = null;
runtime.clipGain.delete(id);
}
}
+/** Configure an A-track bus so stereo panner output stays L/R through the meter. */
+function configureTrackBus(g) {
+ g.channelCount = 2;
+ g.channelCountMode = "explicit";
+ g.channelInterpretation = "discrete";
+}
+/** Master spill bus (V-track / direct video audio) — same stereo rules as A-buses. */
+function configureMasterBus(g) {
+ configureTrackBus(g);
+}
function ensureAudio() {
if (runtime.audio) return runtime.audio;
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const master = ctx.createGain();
+ configureMasterBus(master);
const recDest = ctx.createMediaStreamDestination();
- const audioTracks = TRACKS.filter((t) => t.kind === "audio");
+ const ids = audioTrackIds();
const trackBus = {};
- for (const t of audioTracks) {
- trackBus[t.id] = ctx.createGain();
+ for (const id of ids) {
+ trackBus[id] = ctx.createGain();
+ configureTrackBus(trackBus[id]);
}
// Until the worklet is ready, audio-track buses and master both feed speakers.
master.connect(ctx.destination);
@@ -3404,7 +3709,7 @@ function ensureAudio() {
}
runtime.audio = {
ctx, master, recDest, trackBus,
- audioTrackIds: audioTracks.map((t) => t.id),
+ audioTrackIds: ids.slice(),
meter: null, meterReady: false,
};
installMeterWorklet(runtime.audio).catch(() => {});
@@ -3414,42 +3719,98 @@ function ensureAudio() {
}
return runtime.audio;
}
-/** Route `src -> g -> (channel-isolated ->) out` on `ctx`. When `ch` is 0/1,
- * `src` is split to stereo, only channel `ch` is fed through `g`, then
- * re-merged onto the same side — used to isolate one stereo audio stem onto
- * a single track bus. Returns the node to connect downstream (`g` when no
- * isolation applies) plus the split/merge nodes (null when unused) so callers
- * can track them for later disconnect/dispose. Shared by hookAudio,
- * refreshAudioHold and the offline export mixdown. */
-function connectChannelIsolated(ctx, src, g, ch) {
- if (ch === 0 || ch === 1) {
- const split = ctx.createChannelSplitter(2);
- const merge = ctx.createChannelMerger(2);
- src.connect(split);
- split.connect(g, ch);
- g.connect(merge, 0, ch);
- return { out: merge, split, merge };
+/** Route `src → gain → (optional channel split) → stereoPanner → bus`.
+ * When `ch` is set, only that source channel feeds the gain (mono); pan places
+ * it in the stereo field. Returns split node when used (for dispose). */
+function connectChannelIsolated(ctx, src, g, ch, nCh) {
+ if (Number.isInteger(ch) && ch >= 0) {
+ connectIsolatedChannel(ctx, src, g, ch, nCh);
+ return { split: g._fcSplit };
}
src.connect(g);
- return { out: g, split: null, merge: null };
+ return { split: null };
+}
+/** Gain → StereoPanner; panner becomes `_fcOut` for routing.
+ * Falls back to gain-as-out when StereoPannerNode is unavailable.
+ * Keep default `speakers` interpretation — `discrete` leaves mono on L at pan 0. */
+function attachClipPanner(ctx, g) {
+ try {
+ const panner = ctx.createStereoPanner();
+ g.connect(panner);
+ g._fcPanner = panner;
+ g._fcOut = panner;
+ return panner;
+ } catch {
+ g._fcPanner = null;
+ g._fcOut = g;
+ return null;
+ }
+}
+/** Create missing A-track buses and rebuild the meter when the track list changes. */
+function syncAudioGraphTracks() {
+ const audio = runtime.audio;
+ if (!audio) return;
+ const ids = audioTrackIds();
+ const idSet = new Set(ids);
+ // Drop buses for removed A-tracks (independent of meter state).
+ for (const id of Object.keys(audio.trackBus)) {
+ if (idSet.has(id)) continue;
+ try { audio.trackBus[id].disconnect(); } catch { }
+ delete audio.trackBus[id];
+ }
+ for (const id of ids) {
+ if (!audio.trackBus[id]) audio.trackBus[id] = audio.ctx.createGain();
+ configureTrackBus(audio.trackBus[id]);
+ }
+ audio.audioTrackIds = ids.slice();
+ // Tear down meter so installMeterWorklet can rebuild with the new input count.
+ if (audio.meter) {
+ teardownMeterNode(audio);
+ audio.meter = null;
+ }
+ audio.meterReady = false;
+ // Allow a new install even if a previous addModule is still in flight — that
+ // call's finally will see _reloadMeter and run again.
+ meterState._reloadMeter = true;
+ // Always wire current buses → master so re-added tracks stay audible if meter install fails.
+ for (const id of ids) {
+ const g = audio.trackBus[id];
+ try { g.disconnect(); } catch { }
+ try { g.connect(audio.master); } catch { }
+ }
+ installMeterWorklet(audio).catch(() => {});
+ // Re-route clip gains onto (possibly new) buses
+ for (const c of project.clips) {
+ if (c.kind === "audio" || c.kind === "video") routeClipGain(c);
+ }
}
function hookAudio(c, el) {
if (!runtime.audio || runtime.clipGain.has(c.id)) return;
if (c.kind !== "video" && c.kind !== "audio") return;
try {
const ctx = runtime.audio.ctx;
+ // createMediaElementSource irreversibly diverts element audio into the
+ // graph — register the gain first so releaseClipEl can always tear it down
+ // even if a later step throws.
const src = ctx.createMediaElementSource(el);
const g = ctx.createGain();
+ g._fcSrc = src;
+ runtime.clipGain.set(c.id, g);
const ch = c.props?.audioChannel;
- const { split, merge } = connectChannelIsolated(ctx, src, g, ch);
- if (split) {
- g._fcSplit = split;
- g._fcOut = merge;
- g._fcChannel = ch;
+ if (Number.isInteger(ch) && ch >= 0) {
+ const m = getMedia(c.mediaId);
+ const nCh = Math.max(m?.channels || 0, ch + 1, 2);
+ try { src.channelInterpretation = "discrete"; } catch { }
+ connectChannelIsolated(ctx, src, g, ch, nCh);
+ } else {
+ src.connect(g);
}
- runtime.clipGain.set(c.id, g);
+ attachClipPanner(ctx, g); // degrades to gain-as-out if StereoPanner fails
routeClipGain(c);
- } catch {}
+ } catch {
+ // Element source was created but graph setup failed — keep the map entry
+ // so a later releaseClipEl can disconnect whatever did get wired.
+ }
}
/** Reconnect a clip's gain to the correct track bus (or master for video tracks). */
function routeClipGain(c) {
@@ -3459,6 +3820,7 @@ function routeClipGain(c) {
const out = g._fcOut || g;
if (out._fcBus === bus) return;
try { out.disconnect(); } catch {}
+ out._fcBus = null;
out.connect(bus);
out._fcBus = bus;
}
@@ -3469,6 +3831,9 @@ const METER_DB_MIN = -48;
const METER_DB_MAX = 0;
/** Scale tick marks shown beside the meter bars (dBFS). */
const METER_DB_MARKS = [0, -6, -12, -24, -36, -48];
+function meterMarkTopPct(db) {
+ return ((METER_DB_MAX - db) / (METER_DB_MAX - METER_DB_MIN)) * 100;
+}
const METER_MODES = ["rms", "lufs", "peak"];
const METER_MODE_LABEL = { rms: "RMS", lufs: "LUFS", peak: "PEAK" };
/* Each channel's segment ladder is one