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 instead of METER_SEGS separate @@ -3511,6 +3876,76 @@ function paintMeterSegs(entry, lit, hold) { } } } +const MASTER_METER_L = "M:L"; +const MASTER_METER_R = "M:R"; +const MASTER_METER_IDS = [MASTER_METER_L, MASTER_METER_R]; +function resetMasterMeterBallistics() { + meterState.master.dispL = meterState.master.dispR = METER_DB_MIN; + meterState.master.peakHoldL = meterState.master.peakHoldR = METER_DB_MIN; + meterState.master.peakHoldTL = meterState.master.peakHoldTR = 0; + meterState.lastLit[MASTER_METER_L] = meterState.lastLit[MASTER_METER_R] = -1; + meterState.lastHold[MASTER_METER_L] = meterState.lastHold[MASTER_METER_R] = -1; +} +/** Tap the post-meter stereo bus for true L/R master readings (matches headphones). */ +function disposeMasterAnalysers(audio) { + if (!audio?.masterSplit) return; + try { audio.meter?.disconnect(audio.masterSplit); } catch {} + try { audio.masterSplit.disconnect(); } catch {} + audio.masterSplit = null; + audio.masterAnalysers = null; +} +/** Disconnect meter + analysers; restore buses→master→speakers fallback. */ +function teardownMeterNode(audio) { + if (!audio) return; + disposeMasterAnalysers(audio); + if (audio.meter) { + try { audio.meter.port.onmessage = null; } catch {} + try { audio.meter.disconnect(); } catch {} + } + try { audio.master.disconnect(); } catch {} + try { audio.master.connect(audio.ctx.destination); } catch {} + try { audio.master.connect(audio.recDest); } catch {} +} +function installMasterAnalysers(audio, meterNode) { + disposeMasterAnalysers(audio); + const meter = meterNode || audio?.meter; + if (!meter) return; + const ctx = audio.ctx; + const split = ctx.createChannelSplitter(2); + const aL = ctx.createAnalyser(); + const aR = ctx.createAnalyser(); + const n = 2048; + aL.fftSize = n; + aR.fftSize = n; + aL.smoothingTimeConstant = 0; + aR.smoothingTimeConstant = 0; + meter.connect(split); + split.connect(aL, 0); + split.connect(aR, 1); + audio.masterSplit = split; + audio.masterAnalysers = { L: aL, R: aR, bufL: new Float32Array(n), bufR: new Float32Array(n) }; +} +function sampleMasterAnalysers() { + const a = runtime.audio?.masterAnalysers; + if (!a) return; + a.L.getFloatTimeDomainData(a.bufL); + a.R.getFloatTimeDomainData(a.bufR); + let sumL = 0, sumR = 0, pkL = 0, pkR = 0; + const n = a.bufL.length; + for (let i = 0; i < n; i++) { + const l = a.bufL[i], r = a.bufR[i]; + sumL += l * l; + sumR += r * r; + const al = l >= 0 ? l : -l, ar = r >= 0 ? r : -r; + if (al > pkL) pkL = al; + if (ar > pkR) pkR = ar; + } + const inv = 1 / Math.max(1, n); + meterState.master.rmsL = Math.sqrt(sumL * inv); + meterState.master.rmsR = Math.sqrt(sumR * inv); + meterState.master.peakL = pkL; + meterState.master.peakR = pkR; +} const meterState = { mode: (() => { try { @@ -3529,6 +3964,15 @@ const meterState = { lastLit: {}, // id -> last-painted lit/hold seg indices, to skip redundant DOM writes lastHold: {}, modeBtn: null, + tracksToggleBtn: null, + tracksExpanded: (() => { + try { return localStorage.getItem("fablecut-meter-tracks-expanded") !== "0"; } + catch { return true; } + })(), + master: { rmsL: 0, rmsR: 0, peakL: 0, peakR: 0, lufs: -70, + dispL: METER_DB_MIN, dispR: METER_DB_MIN, + peakHoldL: METER_DB_MIN, peakHoldR: METER_DB_MIN, + peakHoldTL: 0, peakHoldTR: 0 }, }; function audioMeterTracks() { return TRACKS.filter((t) => t.kind === "audio"); @@ -3547,47 +3991,87 @@ function cycleMeterMode(ev) { meterState.peakHold[id] = METER_DB_MIN; meterState.peakHoldT[id] = 0; } + resetMasterMeterBallistics(); +} +function syncMeterTracksExpandedUI() { + const root = $("vuMeter"); + if (!root) return; + root.classList.toggle("vu-tracks-open", meterState.tracksExpanded); + const btn = meterState.tracksToggleBtn; + if (btn) { + btn.textContent = meterState.tracksExpanded ? "▸" : "◂"; + btn.title = meterState.tracksExpanded ? "Hide track meters" : "Show track meters"; + btn.setAttribute("aria-expanded", meterState.tracksExpanded ? "true" : "false"); + } + if (meterState.tracksExpanded) { + for (const id of meterState.trackIds) { + meterState.lastLit[id] = -1; + meterState.lastHold[id] = -1; + } + } +} +function toggleMeterTracksExpanded(ev) { + if (ev) { ev.preventDefault(); ev.stopPropagation(); } + meterState.tracksExpanded = !meterState.tracksExpanded; + try { localStorage.setItem("fablecut-meter-tracks-expanded", meterState.tracksExpanded ? "1" : "0"); } catch {} + syncMeterTracksExpandedUI(); } async function installMeterWorklet(audio) { - if (audio.meterReady || !audio.ctx.audioWorklet || meterState._loading) return; + if (audio.meterReady || !audio.ctx.audioWorklet) return; + if (meterState._loading) { + meterState._reloadMeter = true; // sync tore down mid-install — retry in finally + return; + } meterState._loading = true; + meterState._reloadMeter = false; const trackIds = audio.audioTrackIds.slice(); + const nAudio = trackIds.length; + const nInputs = Math.max(1, nAudio + 1); // +1 = video/other spill on master + let meter = null; try { - await audio.ctx.audioWorklet.addModule("meter-worklet.js?v=2"); - const n = trackIds.length; - const meter = new AudioWorkletNode(audio.ctx, "fablecut-meter", { - numberOfInputs: n, + await audio.ctx.audioWorklet.addModule("meter-worklet.js?v=7"); + // Aborted by syncAudioGraphTracks while we were loading — retry fresh. + if (meterState._reloadMeter) return; + meter = new AudioWorkletNode(audio.ctx, "fablecut-meter", { + numberOfInputs: nInputs, numberOfOutputs: 1, outputChannelCount: [2], channelCount: 2, channelCountMode: "explicit", - processorOptions: { hopBlocks: 8, nTracks: n, trackIds }, + channelInterpretation: "discrete", + processorOptions: { hopBlocks: 8, nTracks: nInputs, nAudioTracks: nAudio, trackIds }, }); meter.port.onmessage = (ev) => { const msg = ev.data; if (!msg || msg.type !== "meter") return; - const ids = msg.trackIds || trackIds; - for (let i = 0; i < ids.length; i++) { - const id = ids[i]; + for (let i = 0; i < nAudio; i++) { + const id = trackIds[i]; + if (!id) continue; meterState.rms[id] = msg.rms[i] || 0; meterState.peak[id] = msg.peak[i] || 0; meterState.lufs[id] = msg.lufs[i] != null ? msg.lufs[i] : -70; } }; - // Reroute: trackBus → meter inputs → speakers/rec (no double via master) - for (const id of trackIds) { - const bus = audio.trackBus[id]; + // Connect outputs first so a wiring error never leaves the graph silent. + meter.connect(audio.ctx.destination); + meter.connect(audio.recDest); + + // Full mix: A-buses + master spill → meter (single summed path). + for (let i = 0; i < trackIds.length; i++) { + const bus = audio.trackBus[trackIds[i]]; + if (!bus) continue; try { bus.disconnect(); } catch {} - bus.connect(meter, 0, trackIds.indexOf(id)); + bus.connect(meter, 0, i); } - // master still carries video-track embedded audio (recDest already wired) try { audio.master.disconnect(audio.ctx.destination); } catch {} - audio.master.connect(audio.ctx.destination); - meter.connect(audio.ctx.destination); - meter.connect(audio.recDest); + try { audio.master.disconnect(audio.recDest); } catch {} + audio.master.connect(meter, 0, nAudio); + + installMasterAnalysers(audio, meter); audio.meter = meter; + meter = null; // ownership transferred — catch must not tear down live node audio.meterReady = true; meterState.trackIds = trackIds; for (const id of trackIds) { @@ -3598,11 +4082,30 @@ async function installMeterWorklet(audio) { meterState.peakHold[id] = METER_DB_MIN; meterState.peakHoldT[id] = 0; } + resetMasterMeterBallistics(); buildMeterDOM(); } catch (err) { console.warn("[FableCut] meter worklet unavailable:", err); + if (meter) { + try { meter.port.onmessage = null; } catch {} + try { meter.disconnect(); } catch {} + } + // Buses were disconnected above — restore direct master path. + for (const id of trackIds) { + const bus = audio.trackBus[id]; + if (!bus) continue; + try { bus.disconnect(); } catch {} + try { bus.connect(audio.master); } catch {} + } + try { audio.master.disconnect(); } catch {} + try { audio.master.connect(audio.ctx.destination); } catch {} + try { audio.master.connect(audio.recDest); } catch {} } finally { meterState._loading = false; + if (meterState._reloadMeter && !audio.meterReady) { + meterState._reloadMeter = false; + installMeterWorklet(audio).catch(() => {}); + } } } function buildMeterDOM() { @@ -3610,36 +4113,19 @@ function buildMeterDOM() { if (!root) return; const tracks = audioMeterTracks(); root.innerHTML = ""; - root.title = `Mode: ${METER_MODE_LABEL[meterState.mode]} — click to switch`; - - const modeBtn = document.createElement("button"); - modeBtn.type = "button"; - modeBtn.className = "vu-mode"; - modeBtn.textContent = METER_MODE_LABEL[meterState.mode]; - modeBtn.title = "Cycle RMS → LUFS → Peak"; - modeBtn.addEventListener("click", cycleMeterMode); - root.appendChild(modeBtn); - meterState.modeBtn = modeBtn; + root.classList.toggle("vu-tracks-open", meterState.tracksExpanded); const row = document.createElement("div"); row.className = "vu-channels"; - const scale = document.createElement("div"); - scale.className = "vu-scale"; - const span = METER_DB_MAX - METER_DB_MIN; - for (const db of METER_DB_MARKS) { - const tick = document.createElement("span"); - tick.className = "vu-scale-tick"; - tick.textContent = db === 0 ? "0" : String(db); - tick.style.top = ((METER_DB_MAX - db) / span * 100) + "%"; - scale.appendChild(tick); - } - row.appendChild(scale); + const tracksWrap = document.createElement("div"); + tracksWrap.className = "vu-tracks-wrap"; meterState.segs = {}; meterState.lastLit = {}; meterState.lastHold = {}; meterState.trackIds = tracks.map((t) => t.id); + for (const t of tracks) { if (meterState.disp[t.id] == null) { meterState.disp[t.id] = METER_DB_MIN; @@ -3662,15 +4148,88 @@ function buildMeterDOM() { label.textContent = t.id; col.appendChild(segsCv); col.appendChild(label); - row.appendChild(col); + tracksWrap.appendChild(col); + } + row.appendChild(tracksWrap); + + const scaleMaster = document.createElement("div"); + scaleMaster.className = "vu-scale-master"; + + const scale = document.createElement("div"); + scale.className = "vu-scale"; + for (const db of METER_DB_MARKS) { + const tick = document.createElement("span"); + tick.className = "vu-scale-tick"; + tick.textContent = db === 0 ? "0" : String(db); + tick.style.top = meterMarkTopPct(db) + "%"; + scale.appendChild(tick); + } + + const masterStack = document.createElement("div"); + masterStack.className = "vu-master-stack"; + + if (tracks.length) { + const tracksBtn = document.createElement("button"); + tracksBtn.type = "button"; + tracksBtn.className = "vu-tracks-toggle"; + tracksBtn.addEventListener("click", toggleMeterTracksExpanded); + masterStack.appendChild(tracksBtn); + meterState.tracksToggleBtn = tracksBtn; + } else { + meterState.tracksToggleBtn = null; } + + const modeBtn = document.createElement("button"); + modeBtn.type = "button"; + modeBtn.className = "vu-mode"; + modeBtn.textContent = METER_MODE_LABEL[meterState.mode]; + modeBtn.title = "Cycle RMS → LUFS → Peak"; + modeBtn.addEventListener("click", cycleMeterMode); + masterStack.appendChild(modeBtn); + meterState.modeBtn = modeBtn; + + const masterWrap = document.createElement("div"); + masterWrap.className = "vu-master"; + for (const ch of ["L", "R"]) { + const id = ch === "L" ? MASTER_METER_L : MASTER_METER_R; + const col = document.createElement("div"); + col.className = "vu-channel vu-master-ch"; + col.dataset.track = id; + const segsCv = document.createElement("canvas"); + segsCv.className = "vu-segs"; + const entry = makeMeterCanvas(segsCv); + meterState.segs[id] = entry; + paintMeterSegs(entry, 0, -1); + const label = document.createElement("span"); + label.className = "vu-label"; + label.textContent = ch; + col.appendChild(segsCv); + col.appendChild(label); + masterWrap.appendChild(col); + } + masterStack.appendChild(masterWrap); + scaleMaster.appendChild(masterStack); + scaleMaster.appendChild(scale); + row.appendChild(scaleMaster); + if (!tracks.length) masterStack.classList.add("vu-master-only"); root.appendChild(row); + syncMeterTracksExpandedUI(); } function rmsToDb(rms) { return rms > 1e-8 ? 20 * Math.log10(rms) : METER_DB_MIN; } function meterReadingDb(id) { const mode = meterState.mode; + if (id === MASTER_METER_L || id === MASTER_METER_R) { + const m = meterState.master; + const isL = id === MASTER_METER_L; + if (mode === "peak") return rmsToDb(isL ? m.peakL : m.peakR); + if (mode === "lufs") { + // Per-channel level for stereo master bars (worklet stereo LUFS is one combined value). + return rmsToDb(isL ? m.rmsL : m.rmsR); + } + return rmsToDb(isL ? m.rmsL : m.rmsR); + } if (mode === "peak") return rmsToDb(meterState.peak[id] || 0); if (mode === "lufs") { const v = meterState.lufs[id]; @@ -3678,10 +4237,58 @@ function meterReadingDb(id) { } return rmsToDb(meterState.rms[id] || 0); } +function updateMeterChannel(id, target, dt, attack, release, now) { + const curKey = id === MASTER_METER_L ? "dispL" : id === MASTER_METER_R ? "dispR" : null; + const holdKey = id === MASTER_METER_L ? "peakHoldL" : id === MASTER_METER_R ? "peakHoldR" : null; + const holdTKey = id === MASTER_METER_L ? "peakHoldTL" : id === MASTER_METER_R ? "peakHoldTR" : null; + let cur, peakHold, peakHoldT; + if (curKey) { + cur = meterState.master[curKey] ?? METER_DB_MIN; + peakHold = meterState.master[holdKey] ?? METER_DB_MIN; + peakHoldT = meterState.master[holdTKey] || 0; + } else { + cur = meterState.disp[id] ?? METER_DB_MIN; + peakHold = meterState.peakHold[id] ?? METER_DB_MIN; + peakHoldT = meterState.peakHoldT[id] || 0; + } + const a = target > cur ? attack : release; + const next = cur + (target - cur) * a; + if (curKey) meterState.master[curKey] = next; + else meterState.disp[id] = next; + + const pk = target; + if (pk >= peakHold) { + if (curKey) { + meterState.master[holdKey] = pk; + meterState.master[holdTKey] = now; + } else { + meterState.peakHold[id] = pk; + meterState.peakHoldT[id] = now; + } + peakHold = pk; + } else if (now - peakHoldT > 800) { + peakHold += (METER_DB_MIN - peakHold) * release; + if (curKey) meterState.master[holdKey] = peakHold; + else meterState.peakHold[id] = peakHold; + } + + const segs = meterState.segs[id]; + if (!segs) return; + const isMaster = id === MASTER_METER_L || id === MASTER_METER_R; + if (!isMaster && !meterState.tracksExpanded) return; + const level = (next - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN); + const lit = Math.round(clamp(level, 0, 1) * METER_SEGS); + const hold = Math.round(clamp( + (peakHold - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN), 0, 1 + ) * (METER_SEGS - 1)); + if (meterState.lastLit[id] === lit && meterState.lastHold[id] === hold) return; + meterState.lastLit[id] = lit; + meterState.lastHold[id] = hold; + paintMeterSegs(segs, lit, hold); +} function updateMeterUI(dt) { - const ids = meterState.trackIds; - if (!ids.length) return; const metering = (state.playing || state.audioHold) && runtime.audio?.meterReady; + if (metering) sampleMasterAnalysers(); const mode = meterState.mode; // Peak: snappy; LUFS already smoothed in-worklet (400 ms); RMS: classic VU feel const atkMs = mode === "peak" ? 0.005 : mode === "lufs" ? 0.04 : 0.015; @@ -3689,36 +4296,10 @@ function updateMeterUI(dt) { const attack = 1 - Math.exp(-dt / atkMs); const release = 1 - Math.exp(-dt / relMs); const now = performance.now(); - for (const id of ids) { - const target = metering ? meterReadingDb(id) : METER_DB_MIN; - const cur = meterState.disp[id] ?? METER_DB_MIN; - const a = target > cur ? attack : release; - const next = cur + (target - cur) * a; - meterState.disp[id] = next; - - // Hold tip follows the active mode reading (not always sample-peak) - const pk = target; - if (pk >= (meterState.peakHold[id] ?? METER_DB_MIN)) { - meterState.peakHold[id] = pk; - meterState.peakHoldT[id] = now; - } else if (now - (meterState.peakHoldT[id] || 0) > 800) { - meterState.peakHold[id] += (METER_DB_MIN - meterState.peakHold[id]) * release; - } - - const segs = meterState.segs[id]; - if (!segs) continue; - const level = (next - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN); - const lit = Math.round(clamp(level, 0, 1) * METER_SEGS); - const hold = Math.round(clamp( - ((meterState.peakHold[id] ?? METER_DB_MIN) - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN), 0, 1 - ) * (METER_SEGS - 1)); - // The ballistics above still run every frame (needed for smooth decay), - // but the canvas only needs repainting when the result actually differs — - // skips a redraw for most tracks most frames. - if (meterState.lastLit[id] === lit && meterState.lastHold[id] === hold) continue; - meterState.lastLit[id] = lit; - meterState.lastHold[id] = hold; - paintMeterSegs(segs, lit, hold); + const floor = METER_DB_MIN; + for (const id of [...meterState.trackIds, ...MASTER_METER_IDS]) { + const target = metering ? meterReadingDb(id) : floor; + updateMeterChannel(id, target, dt, attack, release, now); } } @@ -3758,8 +4339,8 @@ function disposeAudioHoldNode(n) { try { n.src.disconnect(); } catch { } try { if (n.src) n.src.buffer = null; } catch { } try { n.gain.disconnect(); } catch { } + if (n.panner) { try { n.panner.disconnect(); } catch { } } if (n.split) { try { n.split.disconnect(); } catch { } } - if (n.merge) { try { n.merge.disconnect(); } catch { } } } function stopAudioHoldNodes() { audioHoldGen++; @@ -3825,10 +4406,14 @@ function refreshAudioHold() { const g = audio.ctx.createGain(); g.gain.value = vol; const ch = c.props?.audioChannel; - const { out, split, merge } = connectChannelIsolated(audio.ctx, src, g, ch); + const nCh = Math.max(buf.numberOfChannels, Number.isInteger(ch) ? ch + 1 : 0, 2); + const { split } = connectChannelIsolated(audio.ctx, src, g, ch, nCh); + const panner = audio.ctx.createStereoPanner(); + panner.pan.value = clipPan(p.pan); + g.connect(panner); const bus = audio.trackBus[c.track] || audio.master; - out.connect(bus); - const node = { src, gain: g, split, merge }; + panner.connect(bus); + const node = { src, gain: g, panner, split }; try { src.start(0); } catch { disposeAudioHoldNode(node); return; } // Re-check after start: a newer refresh/stop may have run while we built the graph. if (gen !== audioHoldGen || !state.audioHold || state.playing) { @@ -3899,7 +4484,10 @@ function syncMedia() { if (Math.abs(el.currentTime - mt) > 0.25 * eff) { try { el.currentTime = mt; } catch {} } const vol = clamp(p.volume, 0, 4); const g = runtime.clipGain.get(c.id); - if (g) g.gain.value = vol; + if (g) { + g.gain.value = vol; + if (g._fcPanner) g._fcPanner.pan.value = clipPan(p.pan); + } else el.volume = clamp(vol, 0, 1); } else { if (!el.paused) el.pause(); @@ -5288,14 +5876,27 @@ async function renderAudioMix(dur) { for (const { c, buf } of sources) { const src = off.createBufferSource(); src.buffer = buf; const g = off.createGain(); + const panner = off.createStereoPanner(); + g.connect(panner); const n = Math.max(2, Math.ceil(c.duration * 30)); - const curve = new Float32Array(n); - for (let i = 0; i < n; i++) - curve[i] = clamp(evalProps(c, c.start + (i / (n - 1)) * c.duration).volume, 0, 4); - g.gain.setValueCurveAtTime(curve, Math.max(0, c.start), Math.max(0.01, c.duration)); + const volCurve = new Float32Array(n); + const panCurve = new Float32Array(n); + for (let i = 0; i < n; i++) { + const ep = evalProps(c, c.start + (i / (n - 1)) * c.duration); + volCurve[i] = clamp(ep.volume, 0, 4); + panCurve[i] = clipPan(ep.pan); + } + g.gain.setValueCurveAtTime(volCurve, Math.max(0, c.start), Math.max(0.01, c.duration)); + try { + panner.pan.setValueCurveAtTime(panCurve, Math.max(0, c.start), Math.max(0.01, c.duration)); + } catch { + panner.pan.value = panCurve[0] ?? 0; + } const ch = c.props?.audioChannel; - const { out } = connectChannelIsolated(off, src, g, ch); - out.connect(off.destination); + if (Number.isInteger(ch) && ch >= 0) + connectChannelIsolated(off, src, g, ch, Math.max(buf.numberOfChannels, ch + 1, 2)); + else src.connect(g); + panner.connect(off.destination); if (hasSpeedRamp(c)) { const rc = new Float32Array(n); for (let i = 0; i < n; i++) @@ -5909,10 +6510,7 @@ function syncTrackSizeButtons() { function applyTrackHeights() { const preset = TRACK_SIZE_PRESETS[state.trackSize] || TRACK_SIZE_PRESETS.l; for (const t of TRACKS) { - // tracks beyond the static set (e.g. A5+, auto-added for >4-channel audio) - // aren't named in the preset map — size them like the first track of their kind. - const h = preset.h[t.id] ?? preset.h[t.kind === "audio" ? "A1" : "V1"]; - if (h != null) t.h = h; + t.h = t.kind === "audio" ? preset.hAudio : preset.hVideo; } } /* Switch S/M/L track density, rebuild the timeline, and grow/shrink the pane diff --git a/index.html b/index.html index bfbd1b1..4ea4393 100644 --- a/index.html +++ b/index.html @@ -135,7 +135,13 @@
-
+
+
+ + +
+
+
@@ -212,6 +218,8 @@

Keyboard shortcuts

Alt+TAdd transition at playhead (in / out by half) DelDelete selected clip(s), or clear focused transition Drag empty areaMarquee-select clips + Right-click trackRemove empty track (keeps ≥1 video + ≥1 audio) + Track SSolo track (mute all others; click again to restore) Ctrl+click binImport media files Ctrl+click clipAdd / remove from selection Ctrl+A / EscSelect all / deselect diff --git a/mcp-server.js b/mcp-server.js index 08fac15..6c2b222 100644 --- a/mcp-server.js +++ b/mcp-server.js @@ -187,7 +187,7 @@ async function callTool(name, args) { // the UI persists default-valued props on every clip; hide them so the // compact view only shows what actually deviates const DEFAULTS = { - x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, speed: 1, + x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, pan: 0, speed: 1, blend: "normal", fit: "contain", cropL: 0, cropR: 0, cropT: 0, cropB: 0, cornerRadius: 0, flipH: false, flipV: false, filterPreset: "none", brightness: 100, contrast: 100, saturation: 100, hue: 0, temperature: 0, @@ -208,12 +208,19 @@ async function callTool(name, args) { const kept = {}; for (const [k, v] of Object.entries(o)) { if (kind !== "text" && k === "text" && v === "Title") continue; + // Always keep pan on linked stems — pan:0 (center) is meaningful and + // must not be stripped, or agents rebuilding from compact can lose it. + if (k === "pan" && Number.isInteger(o.audioChannel) && o.audioChannel >= 0) { + kept[k] = v; + continue; + } if (hex(DEFAULTS[k]) !== hex(v)) kept[k] = v; } return Object.keys(kept).length ? " " + JSON.stringify(kept) : ""; }; const lines = [ `"${doc.name}" ${doc.width}x${doc.height}@${doc.fps} rev:${doc.revision}` + + (doc.panSchema >= 1 ? " panSchema:1" : "") + (doc.background ? ` bg:${doc.background}` : "") + (doc.markers?.length ? ` markers:${doc.markers.length} [${doc.markers.slice(0, 12).map((m) => m.t).join(",")}${doc.markers.length > 12 ? ",…" : ""}]` : ""), `MEDIA (${doc.media.length}):`, @@ -343,6 +350,9 @@ async function callTool(name, args) { `Pass force:true only if the user explicitly wants those changes discarded.`); } doc.revision = Math.max(curRev + 1, (doc.revision || 0)); + // Agents often omit top-level flags they don't know about. Keep panSchema + // once established so a rewrite that drops pan:0 cannot re-trigger L/R migration. + if (!(doc.panSchema >= 1) && (cur.panSchema >= 1)) doc.panSchema = 1; writeProject(doc); lastReadRevision = doc.revision; return `Saved (revision ${doc.revision}). ${doc.clips.length} clip(s). The editor UI (if open at ${BASE}) has hot-reloaded.`; diff --git a/meter-worklet.js b/meter-worklet.js index 8be575c..47fc5f1 100644 --- a/meter-worklet.js +++ b/meter-worklet.js @@ -1,8 +1,7 @@ -/* FableCut per-track meter worklet. - Each input = one timeline audio track. Pass-through mix to stereo out. - Reports per-track: RMS, sample peak, and momentary LUFS (ITU-R BS.1770-4, - 400 ms, K-weighted stereo). */ -function shelfCoeffs(fs) { +/* FableCut per-track meter worklet + stereo program sum pass-through. + Inputs 0…nAudio−1 = A-track buses; input nAudio = video/other spill on master. + Pass-through sum → stereo out. Per-track RMS/LUFS/Peak via port; master L/R + is metered on the main thread (AnalyserNodes on the worklet output). */function shelfCoeffs(fs) { const f0 = 1681.974450955533; const G = 3.999843853973347; const Q = 0.7071752369554196; @@ -47,7 +46,7 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { const opts = (options && options.processorOptions) || {}; this._hopBlocks = Math.max(1, opts.hopBlocks || 8); this._nTracks = Math.max(1, opts.nTracks || 1); - this._ids = opts.trackIds || []; + this._nAudio = Math.max(0, opts.nAudioTracks ?? opts.nTracks ?? 1); this._block = 0; this._sumSq = new Float64Array(this._nTracks); this._peak = new Float64Array(this._nTracks); @@ -67,7 +66,8 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { this._hpfR.push(makeBiquad(hpf)); } - // Momentary = 400 ms of block mean-squares (BS.1770) + // Momentary = 400 ms of block mean-squares (BS.1770) — per A-track only; + // master L/R is sampled via AnalyserNodes on the post-meter output in app.js. const hopFrames = 128 * this._hopBlocks; this._lufsLen = Math.max(1, Math.round(0.4 * sampleRate / hopFrames)); this._lufsRing = []; @@ -83,16 +83,18 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { process(inputs, outputs) { const output = outputs[0]; const outL = output && output[0]; - const outR = output && (output[1] || output[0]); + const outR = output && output[1]; if (outL) outL.fill(0); - if (outR && outR !== outL) outR.fill(0); + if (outR) outR.fill(0); let frames = 0; for (let t = 0; t < this._nTracks; t++) { const chans = inputs[t]; const has = chans && chans[0]; const L = has ? chans[0] : null; - const R = has ? (chans[1] || chans[0]) : null; + // Never mirror L→R: post-pan buses are stereo; mono input = one channel only. + const hasR = has && chans.length > 1 && chans[1]; + const R = hasR ? chans[1] : null; const n = L ? L.length : (outL ? outL.length : 128); frames = n; @@ -105,7 +107,7 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { for (let i = 0; i < n; i++) { const l = L ? L[i] : 0; const r = R ? R[i] : 0; - const mono = (l + r) * 0.5; + const mono = R ? (l + r) * 0.5 : l; sum += mono * mono; const aL = l >= 0 ? l : -l; const aR = r >= 0 ? r : -r; @@ -117,7 +119,7 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { sumK += fl * fl + fr * fr; if (outL) outL[i] += l; - if (outR && outR !== outL) outR[i] += r; + if (outR) outR[i] += r; } this._sumSq[t] = sum; this._peak[t] = peak; @@ -129,10 +131,10 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { if (this._block >= this._hopBlocks) { const n = Math.max(1, this._frames); - const rms = new Array(this._nTracks); - const peak = new Array(this._nTracks); - const lufs = new Array(this._nTracks); - for (let t = 0; t < this._nTracks; t++) { + const rms = new Array(this._nAudio); + const peak = new Array(this._nAudio); + const lufs = new Array(this._nAudio); + for (let t = 0; t < this._nAudio; t++) { rms[t] = Math.sqrt(this._sumSq[t] / n); peak[t] = this._peak[t]; @@ -153,9 +155,9 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { this._peak[t] = 0; this._sumSqK[t] = 0; } + this.port.postMessage({ type: "meter", - trackIds: this._ids, rms, peak, lufs, diff --git a/style.css b/style.css index b145534..9bc17d3 100644 --- a/style.css +++ b/style.css @@ -842,7 +842,7 @@ body.track-size-s .clip.selected { /* ── Per-track VU (RMS / LUFS / Peak) ── */ .vu-meter { position: absolute; - right: 8px; + right: 2px; bottom: 10px; display: flex; flex-direction: column; @@ -850,15 +850,60 @@ body.track-size-s .clip.selected { gap: 4px; z-index: 4; pointer-events: auto; - padding: 5px 5px 4px; + padding: 5px 2px 4px 5px; background: #000a; user-select: none; } +.vu-channels { + display: flex; + align-items: flex-end; + gap: 0; +} +.vu-tracks-wrap { + display: none; + align-items: flex-end; + gap: 2px; +} +.vu-meter.vu-tracks-open .vu-tracks-wrap { display: flex; } +.vu-scale-master { + display: flex; + align-items: flex-end; + gap: 0; +} +.vu-master-stack { + display: grid; + grid-template-columns: auto 30px; + grid-template-rows: auto auto; + column-gap: 0; + row-gap: 1px; +} +.vu-tracks-toggle { + grid-row: 1; + grid-column: 1; + align-self: center; + display: flex; + align-items: center; + justify-content: center; + width: 10px; + height: 14px; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: #a8a8b0; + font: 700 14px/1 ui-sans-serif, system-ui, sans-serif; + cursor: pointer; + opacity: .9; +} +.vu-tracks-toggle:hover { opacity: 1; color: #fff; } .vu-mode { - display: block; - width: 100%; + grid-row: 1; + grid-column: 2; + box-sizing: border-box; + width: 30px; + justify-self: center; margin: 0; - padding: 2px 0 2px 14px; + padding: 0; border: 0; background: transparent; color: #c8c8d0; @@ -867,19 +912,29 @@ body.track-size-s .clip.selected { text-align: center; cursor: pointer; opacity: .85; + white-space: nowrap; + overflow: hidden; } .vu-mode:hover { opacity: 1; color: #fff; } -.vu-channels { +.vu-master { + grid-row: 2; + grid-column: 2; + box-sizing: border-box; + width: 30px; + justify-self: center; display: flex; - align-items: flex-start; - gap: 2px; + gap: 3px; + justify-content: center; + padding-left: 0; + margin-left: 0; + border-left: none; } .vu-scale { position: relative; /* Match .vu-segs height: 16×12px + 15×2px gap */ width: 16px; height: 222px; - margin-right: 0px; + flex-shrink: 0; pointer-events: none; } .vu-scale-tick { @@ -893,6 +948,14 @@ body.track-size-s .clip.selected { } .vu-scale-tick:first-child { transform: translateY(0); } .vu-scale-tick:last-child { transform: translateY(-100%); } +.vu-master-stack.vu-master-only { + grid-template-columns: 30px; +} +.vu-master-stack.vu-master-only .vu-mode, +.vu-master-stack.vu-master-only .vu-master { + grid-column: 1; +} +.vu-master .vu-label { color: #d4d4dc; } .vu-channel { display: flex; flex-direction: column; @@ -1215,20 +1278,47 @@ input[type=range] { } .track-headers { - width: 58px; + width: 68px; flex: none; border-right: 1px solid var(--border); - padding-top: 26px; + padding-top: 0; background: var(--panel); z-index: 2; overflow: hidden; + display: flex; + flex-direction: column; +} + +.track-head-tools { + height: 26px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + border-bottom: 1px solid var(--border); + box-sizing: border-box; +} + +.track-head-tools .btn { + padding: 1px 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.2; + min-width: 0; +} + +#trackHeadInner { + flex: 1; + min-height: 0; } .track-head { display: flex; align-items: center; - gap: 4px; - padding: 0 6px; + gap: 2px; + padding: 0 4px; border-bottom: 1px solid #232328; font-size: 11px; font-weight: 700; @@ -1236,10 +1326,87 @@ input[type=range] { letter-spacing: .5px; } +.track-head .track-id { + flex: 1; + min-width: 0; + max-width: 3ch; +} + +.track-solo { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 16px; + height: 16px; + margin: 0; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--dim); + font: inherit; + font-size: 9px; + font-weight: 800; + letter-spacing: 0; + line-height: 1; + cursor: pointer; +} + +.track-solo:hover { + color: #fff; + background: #ffffff12; +} + +.track-solo.on { + color: #111; + background: #ffd166; + border-color: #ffd166; +} + .track-head.disabled { opacity: .55; } +.track-head.solo:not(.disabled) { + opacity: 1; +} + +.ctx-menu { + position: fixed; + z-index: 10000; + min-width: 140px; + padding: 4px; + background: var(--panel, #1c1c22); + border: 1px solid var(--border, #2e2e36); + border-radius: 6px; + box-shadow: 0 8px 24px #00000088; +} + +.ctx-menu .ctx-item { + display: block; + width: 100%; + margin: 0; + padding: 6px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: #e8e8ec; + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.ctx-menu .ctx-item:hover:not(:disabled) { + background: #ffffff14; +} + +.ctx-menu .ctx-item:disabled { + opacity: .45; + cursor: default; +} + .track-toggle { display: inline-flex; align-items: center;