Skip to content

feat: add clip replacement/rename feature - #45

Merged
ronak-create merged 2 commits into
ronak-create:mainfrom
PlkMarudny:clip-replacement
Jul 27, 2026
Merged

feat: add clip replacement/rename feature#45
ronak-create merged 2 commits into
ronak-create:mainfrom
PlkMarudny:clip-replacement

Conversation

@PlkMarudny

@PlkMarudny PlkMarudny commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

image

Clips can be now replaced on a timeline keeping the settings/effects applied.
In the inspector, there is a field name and source. Name can be changed from the default one inherited from a clip's name.
But clicking on source displays a select with compatible media listed (so a video can be only replaced with another video). All the effects applied to a clip are not affected. This way it is easy to produce a different version of a final composition.

Also, audio channels linking system ware redone from scratch, it is now more flexible, and the number of audio tracks is capped to 16.

Type of change

  • Bug fix
  • [|X] New feature (transition / preset / text anim / effect / API)
  • Docs
  • Refactor / internal

How was it verified?

  • node --check server.js && node --check app.js && node --check mcp-server.js passes
  • Opened the editor and confirmed the change in preview
  • Confirmed the change in an export (fast or realtime), if it affects rendering
  • Updated CLAUDE.md / README.md if the schema, props, or API changed

Checklist

  • No new runtime dependencies added
  • Preview and export render identically (single compositor)
  • Commits are focused and messages are descriptive

Summary by CodeRabbit

  • New Features
    • Added a clip inspector Source action to replace media while keeping timeline placement, trims, keyframes, transitions, and effects (including linked audio companions).
    • Added multi-channel audio handling for videos by creating linked per-channel audio lanes and resyncing them after media replacement.
    • Improved compatible media selection and a dedicated replacement file-browsing flow in the inspector.
  • Bug Fixes
    • Updated audio waveform peak selection and channel labels/badges for any integer channel count.
  • Documentation
    • Refreshed README feature documentation for the new media replacement and multi-channel behavior.
  • Style
    • Refined media picker option typography for better readability.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b20c02e4-3c4e-41be-8d4a-49647d8528df

📥 Commits

Reviewing files that changed from the base of the PR and between a568c79 and 5cd4e25.

📒 Files selected for processing (3)
  • README.md
  • app.js
  • style.css

📝 Walkthrough

Walkthrough

The editor now supports in-place clip media replacement and multichannel video audio. Replacement preserves timeline editing properties and linked companions, while audio channels can create dynamic lanes, display channel-specific labels, and update linked clips when media changes.

Changes

Media and multichannel audio

Layer / File(s) Summary
Dynamic audio lane infrastructure
app.js
Adds capped runtime creation of audio lanes, restores referenced lanes during project loading, wires new lanes into the audio graph, and applies consistent sizing to expanded tracks.
Linked channel reconciliation
app.js, README.md
Detects source channel counts, creates or removes linked per-channel clips, supports channel-specific labels and waveform peaks, and documents the behavior.
In-place media replacement
app.js, style.css
Adds inspector source selection, compatible bin and file replacement flows, linked media updates, trim clamping, channel reconciliation, and picker styling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: xusnitdinov

Sequence Diagram(s)

sequenceDiagram
  participant Inspector
  participant MediaPicker
  participant importFiles
  participant replaceClipMedia
  participant reconcileAudioChannels
  Inspector->>MediaPicker: Open source picker
  MediaPicker->>importFiles: Import replacement file
  MediaPicker->>replaceClipMedia: Apply selected media
  replaceClipMedia->>reconcileAudioChannels: Reconcile linked audio channels
  reconcileAudioChannels->>Inspector: Refresh clip and channel state
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: clip replacement and rename functionality.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
app.js (2)

1274-1285: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: clamp position and support Esc.

Math.min(...) alone can place the menu at negative offsets on small viewports, and there's no keyboard dismissal/focus handling for the floating menu.

♻️ Suggested tweak
-  menu.style.left = Math.round(Math.min(r.left, innerWidth - 220)) + "px";
-  menu.style.top = Math.round(Math.min(r.bottom + 4, innerHeight - 340)) + "px";
+  menu.style.left = Math.round(Math.max(4, Math.min(r.left, innerWidth - 220))) + "px";
+  menu.style.top = Math.round(Math.max(4, Math.min(r.bottom + 4, innerHeight - 340))) + "px";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` around lines 1274 - 1285, Update the floating media menu positioning
near the anchor.getBoundingClientRect() logic to clamp both left and top
coordinates to non-negative viewport offsets while retaining the existing
maximum bounds. Add Escape-key dismissal and appropriate focus handling for the
menu, reusing the existing close function and ensuring any added event listeners
are removed when it closes.

4653-4654: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Splitter reference is dropped here, contrary to the helper's contract.

connectChannelIsolated's doc comment says splitter must stay referenced. In the offline path the returned splitter is discarded; it survives only because src holds the outgoing connection. Worth holding the reference (or relaxing the comment) so the two paths agree.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` around lines 4653 - 4654, Update the offline connection branch around
connectChannelIsolated so the returned splitter remains strongly referenced for
the required lifetime, matching the helper’s contract and the other path; do not
discard the helper result before connecting its output to off.destination.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app.js`:
- Around line 1179-1200: Update the media-replacement flow around the target
selection and reconcileAudioChannels so replacing a linked audio companion
operates on its linkGroup’s video member, keeping all linked clips bound to the
same mediaId and channel layout. Preserve the existing cascade, trimming, undo,
and cache-release behavior while preventing companion-only replacement.
- Around line 168-192: Update addLiveAudioTrackBuses and the initial install
flow in installMeterWorklet so IDs added while the meter is loading are included
when the meter is eventually built. Preserve pending audioTrackIds by merging
newly added lanes with the install snapshot after _loading completes, or defer
the lane update until installation finishes, ensuring new buses are routed
through the meter rather than remaining connected only to audio.master.
- Line 1232: Update the replacement toast in the import flow to choose the
article based on all vowel-starting media kinds, including image and svg, so it
displays “an image” or “an svg” instead of “a image” or “a svg”; preserve the
existing message and audio handling.
- Around line 1064-1085: When removing clips in the channel-reconciliation loop,
also prune each removed clip ID from state.selId and state.selIds so selection
cannot retain dangling references. Apply this alongside the project.clips
filtering in the existing releaseClipEl removal path, while preserving selection
state for clips that remain.

---

Nitpick comments:
In `@app.js`:
- Around line 1274-1285: Update the floating media menu positioning near the
anchor.getBoundingClientRect() logic to clamp both left and top coordinates to
non-negative viewport offsets while retaining the existing maximum bounds. Add
Escape-key dismissal and appropriate focus handling for the menu, reusing the
existing close function and ensuring any added event listeners are removed when
it closes.
- Around line 4653-4654: Update the offline connection branch around
connectChannelIsolated so the returned splitter remains strongly referenced for
the required lifetime, matching the helper’s contract and the other path; do not
discard the helper result before connecting its output to off.destination.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a910a2fc-8dd5-4f67-b14a-3f3c72147752

📥 Commits

Reviewing files that changed from the base of the PR and between 8aac11f and a568c79.

📒 Files selected for processing (3)
  • README.md
  • app.js
  • style.css

Comment thread app.js
Comment on lines +168 to +192
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(() => {});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep run --pattern 'function installMeterWorklet($$$) { $$$ }' --lang javascript app.js

Repository: ronak-create/FableCut

Length of output: 2870


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== app.js relevant section =="
sed -n '136,196p' app.js

echo
echo "== installMeterWorklet and addLiveAudioTrackBuses call sites =="
rg -n "installMeterWorklet|addLiveAudioTrackBuses|meterReady|audioTrackIds" app.js

echo
echo "== structural verifier: early return ordering relative to async install =="
python3 - <<'PY'
from pathlib import Path
s = Path("app.js").read_text()
start = s.index("function addLiveAudioTrackBuses")
end = s.index("\nasync function installMeterWorklet")
fn = s[start:end]
checks = {
    "has_meterReady_false_return": "if (!audio.meterReady) {",
    "returns_before_install_call": "audio.meterReady = false;\n  installMeterWorklet(audio).catch",
    "install_start": "async function installMeterWorklet(audio)",
    "snapshots_trackIds_before_async": "const trackIds = audio.audioTrackIds.slice();",
    "early_loading_return": "if (audio.meterReady || !audio.ctx.audioWorklet || meterState._loading) return;",
}
for k, pat in checks.items():
    print(f"{k}: {'FOUND' if pat in fn or pat in s else 'MISSING'}")
print("concurrent_race_window_found:", all(k in ("early_loading_return", "snapshots_trackIds_before_async") and checks[k] in s for k in ["has_meterReady_false_return", "returns_before_install_call"]))
PY

Repository: ronak-create/FableCut

Length of output: 4063


Honor audioTrackIds when adding new live buses during initial install.

installMeterWorklet() exits while _loading is true and later builds the meter from an earlier audioTrackIds slice, while addLiveAudioTrackBuses() adds new lanes directly to master only when meterReady is false. Snapshot a merged track list in the initial install path on return from _loading, or queue the pending lane update until the current meter install completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` around lines 168 - 192, Update addLiveAudioTrackBuses and the initial
install flow in installMeterWorklet so IDs added while the meter is loading are
included when the meter is eventually built. Preserve pending audioTrackIds by
merging newly added lanes with the install snapshot after _loading completes, or
defer the lane update until installation finishes, ensuring new buses are routed
through the meter rather than remaining connected only to audio.master.

Comment thread app.js
Comment on lines +1064 to +1085
const have = project.clips.filter((c) => c.linkGroup === lg && c.kind === "audio");
let added = 0, removed = 0;
for (const c of have) {
if ((c.props?.audioChannel ?? 0) >= wantCh) {
releaseClipEl(c.id);
project.clips = project.clips.filter((x) => x !== c);
removed++;
}
}
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],
start: live.start, in: live.in, duration: live.duration, name: live.name,
props: { ...DEFAULT_PROPS, audioChannel: ch },
linkGroup: lg,
});
added++;
}
if (!added && !removed) return;
state.dirtyTimeline = true;
scheduleSave(); renderInspector();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Removed channel clips can leave a stale selection.

project.clips is filtered without pruning state.selId / state.selIds, so if a removed per-channel clip was selected the inspector/selection state keeps a dangling id until the next selection change.

♻️ Proposed fix
   if (!added && !removed) return;
+  if (removed) pruneSelection();
   state.dirtyTimeline = true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const have = project.clips.filter((c) => c.linkGroup === lg && c.kind === "audio");
let added = 0, removed = 0;
for (const c of have) {
if ((c.props?.audioChannel ?? 0) >= wantCh) {
releaseClipEl(c.id);
project.clips = project.clips.filter((x) => x !== c);
removed++;
}
}
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],
start: live.start, in: live.in, duration: live.duration, name: live.name,
props: { ...DEFAULT_PROPS, audioChannel: ch },
linkGroup: lg,
});
added++;
}
if (!added && !removed) return;
state.dirtyTimeline = true;
scheduleSave(); renderInspector();
const have = project.clips.filter((c) => c.linkGroup === lg && c.kind === "audio");
let added = 0, removed = 0;
for (const c of have) {
if ((c.props?.audioChannel ?? 0) >= wantCh) {
releaseClipEl(c.id);
project.clips = project.clips.filter((x) => x !== c);
removed++;
}
}
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],
start: live.start, in: live.in, duration: live.duration, name: live.name,
props: { ...DEFAULT_PROPS, audioChannel: ch },
linkGroup: lg,
});
added++;
}
if (!added && !removed) return;
if (removed) pruneSelection();
state.dirtyTimeline = true;
scheduleSave(); renderInspector();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` around lines 1064 - 1085, When removing clips in the
channel-reconciliation loop, also prune each removed clip ID from state.selId
and state.selIds so selection cannot retain dangling references. Apply this
alongside the project.clips filtering in the existing releaseClipEl removal
path, while preserving selection state for clips that remain.

Comment thread app.js
Comment on lines +1179 to +1200
if (!media || (clip.mediaId === media.id)) return;
pushUndo();
const targets = (clip.kind === "video" && clip.linkGroup)
? project.clips.filter((c) => c.linkGroup === clip.linkGroup)
: [clip];
let trimmed = false;
for (const c of targets) {
c.mediaId = media.id;
// the clip's cached <video>/<audio> element (and its Web Audio graph node)
// is keyed by clip id and still points at the old src — drop it so
// getClipEl() rebuilds it against the new media on the next sync/frame.
releaseClipEl(c.id);
if (media.duration == null) continue;
const speed = c.props?.speed || 1;
// in + duration×speed ≤ media.duration (see CLAUDE.md props reference)
const maxIn = Math.max(0, media.duration - 0.001);
if (c.in > maxIn) { c.in = maxIn; trimmed = true; }
const maxDur = Math.max(0.05, (media.duration - c.in) / speed);
if (c.duration > maxDur) { c.duration = maxDur; trimmed = true; }
}
if (media.kind === "video" || media.kind === "audio") ensureWave(media);
if (clip.kind === "video") reconcileAudioChannels(clip); // add/drop A3+ channel clips once decoded

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Replacing a linked audio companion desyncs the group.

Only video members cascade to the linkGroup. Selecting an A-lane companion of a video and replacing its source rebinds just that clip, leaving the group with two different mediaIds while props.audioChannel still indexes the old layout; a later reconcileAudioChannels on the video member will then add/remove clips against the video's channel count only. Consider redirecting to the group's video member (or blocking replacement on linked companions).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` around lines 1179 - 1200, Update the media-replacement flow around
the target selection and reconcileAudioChannels so replacing a linked audio
companion operates on its linkGroup’s video member, keeping all linked clips
bound to the same mediaId and channel layout. Preserve the existing cascade,
trimming, undo, and cache-release behavior while preventing companion-only
replacement.

Comment thread app.js
if (!c || !added.length) return;
const m = added.find((x) => x.kind === c.kind) || added[0];
if (m.kind === c.kind) replaceClipMedia(c, m);
else toast(`Imported, but can't replace a ${c.kind} clip with ${m.kind === "audio" ? "an" : "a"} ${m.kind}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wrong article for image/svg kinds.

m.kind === "audio" ? "an" : "a" yields "a image".

✏️ Proposed fix
-      else toast(`Imported, but can't replace a ${c.kind} clip with ${m.kind === "audio" ? "an" : "a"} ${m.kind}`);
+      else toast(`Imported, but can't replace a ${c.kind} clip with ${/^[aeiou]/.test(m.kind) ? "an" : "a"} ${m.kind}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.js` at line 1232, Update the replacement toast in the import flow to
choose the article based on all vowel-starting media kinds, including image and
svg, so it displays “an image” or “an svg” instead of “a image” or “a svg”;
preserve the existing message and audio handling.

@ronak-create
ronak-create merged commit f8616eb into ronak-create:main Jul 27, 2026
1 of 2 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Jul 28, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants