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 —
+