From a568c799333d490e48840734fb41409aa6e9d3df Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 27 Jul 2026 11:14:44 +0300 Subject: [PATCH] feat: add clip replacement/rename feature --- README.md | 11 ++ app.js | 328 +++++++++++++++++++++++++++++++++++++++++++++++++----- style.css | 10 ++ 3 files changed, 320 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 60ceaf2..5d1719e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,17 @@ same time. - **IN/OUT work area** — set markers with i and o (⇧I / ⇧O to clear). Enabling **Limit** constrains playback to the marked range and maps Home / End to the IN and OUT positions rather than the full timeline. t splits clips at the markers; ⇧t trims clips to the work (between marker in and marker out) area. - **Find & close gaps** — a gap is a stretch where every enabled track is empty (black frames). g jumps the playhead to the next shared gap (wraps; respects IN/OUT when both are set). ⇧G closes the gap under the playhead by pulling later clips left on all enabled tracks. - **Reset a property** — Ctrl/Cmd+click an inspector **label** to restore that effect/prop to its default (paired fields like Crop L/R reset together). Matching keyframes for the prop are cleared too; transition labels clear the in/out transition. +- **Replace media** — the inspector's **Source** button (any video/audio/image/svg + clip) swaps the underlying file while keeping position, trim, keyframes, + transitions and every effect. Pick another item already in the bin or + **Browse file…** to import and replace in one step. A video's linked L/R + audio companions are swapped along with it; a shorter replacement clamps the + trim to fit and toasts that it did so. +- **Multi-channel video audio** — a video with more than 2 audio channels gets + a linked audio clip per channel, not just L/R (5.1, 7.1…). Extra audio + tracks (A5, A6, …, capped at 16) are created automatically as needed; + replacing a clip's media re-syncs the linked channel clips to the new + source's channel count, adding/dropping extras and new tracks as needed. **Look** diff --git a/app.js b/app.js index 1afaac7..45b16d5 100644 --- a/app.js +++ b/app.js @@ -23,6 +23,9 @@ const TRACK_SIZE_PRESETS = { 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 } }, }; +// 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(). +const MAX_AUDIO_TRACKS = 16; const TRACK_SIZE_KEY = "fablecut-track-size"; const LAST_TRANS_KEY = { in: "fablecut-last-trans-in", out: "fablecut-last-trans-out" }; const DEFAULT_LAST_TRANS = { type: "fade", duration: 1 }; @@ -130,6 +133,63 @@ 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); + } + if (!audio.meterReady) { + for (const id of ids) audio.trackBus[id]?.connect(audio.master); + return; + } + // 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); + } + try { audio.meter.port.onmessage = null; } catch {} + try { audio.meter.disconnect(); } catch {} + audio.meter = null; + audio.meterReady = false; + installMeterWorklet(audio).catch(() => {}); +} /* ── User settings (localStorage; optional behavior toggles) ── */ const SETTINGS_KEY = "fablecut-settings"; @@ -406,6 +466,15 @@ function applyProject(data) { 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); // 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 @@ -593,7 +662,7 @@ function wavePeaksFor(c) { if (w instanceof Float32Array) return w; if (!w.max) return null; const ch = c.props?.audioChannel; - if ((ch === 0 || ch === 1) && w.channels?.[ch]) return w.channels[ch]; + if (Number.isInteger(ch) && w.channels?.[ch]) return w.channels[ch]; return w.max; } @@ -614,8 +683,9 @@ function mediaKindFromFile(file) { } async function importFiles(fileList) { const files = [...fileList]; - if (!files.length) return; + if (!files.length) return []; let added = 0, skipped = 0; + const addedMedia = []; for (const file of files) { const kind = mediaKindFromFile(file); if (!kind) { skipped++; continue; } @@ -644,6 +714,7 @@ async function importFiles(fileList) { } } catch { skipped++; continue; } project.media.push(m); + addedMedia.push(m); added++; } renderBin(); scheduleSave(); @@ -651,6 +722,7 @@ async function importFiles(fileList) { toast("Couldn't import — unsupported or unreadable file type"); else if (skipped) toast(`Imported ${added}, skipped ${skipped}`); + return addedMedia; } function renderBin() { @@ -952,10 +1024,74 @@ function addClipFromMedia(m, trackId, at) { }; project.clips.push(aL, aR); ensureWave(m); + reconcileAudioChannels(c); } selectClip(c.id); scheduleSave(); return c; } +/* Resolve (and cache) a media's real channel count via Web Audio decode — +