Movie Editor: audio editing — per-clip volume, inserted tracks, original toggle

- Per-clip original audio gain (Scene.audio_volume; inspector slider, 0 mutes). Applied at
  render (volume filter) and in the live preview (video.volume, clamped 0..1).
- Project-level audio mix: keep_original_audio toggle (drop the generated LTXAV audio) and
  audio_tracks[] — inserted background tracks from the Media bin, each with start offset +
  volume. Mixed at render via adelay + amix(normalize=0), trimmed to the montage length.
- All four cases work: lower/boost a clip, add one or many tracks, keep original + tracks,
  original-only, tracks-only, or fully silent (no audio stream mapped).
- Render route resolves track media to files (media.path_for), appends them as inputs after
  the clips, and maps [aout] only when there's audio. renderFinal flushes the project save
  first (tracks/toggle live on the project, read from disk at render).
- Inspector: per-scene Clip audio + Project Audio section (toggle, track cards, add-from-bin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-08 01:03:44 +03:00
co-authored by Claude Opus 4.8
parent aeded5cafa
commit 54d58a1e6b
6 changed files with 173 additions and 26 deletions
+10
View File
@@ -60,6 +60,8 @@ class Scene:
# Per-scene post-decode video effects applied to this clip (pixel ops), keys:
# {blur: 0..1, fade_in: sec, fade_out: sec, zoom: "none"|"in"|"out"}.
effects: dict = field(default_factory=dict)
# Gain applied to this clip's ORIGINAL (LTXAV) audio at render. 1.0 = unchanged, 0 = mute.
audio_volume: float = 1.0
# Forward-compat per-scene knobs (uniform values still win in V1).
frames: Optional[int] = None
fps: Optional[int] = None
@@ -90,6 +92,7 @@ class Scene:
transition_frames=d.get("transition_frames"),
video_transition=str(d.get("video_transition", "")),
effects=dict(d.get("effects") or {}),
audio_volume=float(d.get("audio_volume", 1.0)),
frames=d.get("frames"),
fps=d.get("fps"),
frames_mode=str(d.get("frames_mode") or "timeline"),
@@ -145,6 +148,11 @@ class Project:
# corresponding slot == "funpack"). Keys match ComfyUI widget/input names exactly.
studio_inputs: dict = field(default_factory=dict)
sampler_inputs: dict = field(default_factory=dict)
# Audio editing (render-time mix). keep_original_audio=False drops the per-clip LTXAV
# audio entirely. audio_tracks = inserted tracks mixed over the montage, each:
# {id, media_ref, start_sec, volume, label}. With no tracks and original off → silent.
keep_original_audio: bool = True
audio_tracks: list = field(default_factory=list)
# Per-project pipeline config: the configured loader/node slots + linked inputs
# (same shape as the global models.json). Empty {"slots": []} falls back to the
# global default at build/read time; the editor seeds new projects from it.
@@ -172,6 +180,8 @@ class Project:
sampler_slot=str(d.get("sampler_slot", "funpack")),
studio_inputs=dict(d.get("studio_inputs") or {}),
sampler_inputs=dict(d.get("sampler_inputs") or {}),
keep_original_audio=bool(d.get("keep_original_audio", True)),
audio_tracks=list(d.get("audio_tracks") or []),
models=dict(d.get("models") or {"slots": []}),
created_at=float(d.get("created_at", time.time())),
updated_at=float(d.get("updated_at", time.time())),
+51
View File
@@ -198,6 +198,15 @@
body.append(el("div", "insp-hint", "Crossfades overlap the two clips, so the montage gets a little shorter."));
}
// ── Clip audio (original LTXAV audio gain)
const auTag = el("div", "insp-tag"); auTag.textContent = "Clip audio"; body.append(auTag);
const vol = el("input"); vol.type = "range"; vol.min = "0"; vol.max = "2"; vol.step = "0.05";
vol.value = scene.audio_volume != null ? scene.audio_volume : 1; vol.dataset.k = "sc-vol";
const volPct = Math.round((scene.audio_volume != null ? scene.audio_volume : 1) * 100);
vol.oninput = () => S.patchSceneQuiet(scene.id, { audio_volume: parseFloat(vol.value) });
body.append(field(`Original volume (${volPct}%)`, vol));
body.append(el("div", "insp-hint", "0% mutes this clip. Add background tracks & toggle original audio in Project settings."));
const p = st.project;
const lenRow = el("div", "fields-row");
lenRow.append(lengthControl(scene, "frames"));
@@ -254,6 +263,48 @@
neg.oninput = () => S.patchProjectQuiet({ negative_prompt: neg.value });
body.append(field("Negative prompt", neg));
// ── Audio (render-time mix) ───────────────────────────────────────────────────
const audTag = el("div", "insp-tag"); audTag.textContent = "Audio"; body.append(audTag);
const keepRow = el("div", "insp-block");
const keepLbl = el("label", "chk"); const keepCb = el("input"); keepCb.type = "checkbox";
keepCb.checked = p.keep_original_audio !== false;
keepCb.onchange = () => S.patchProject({ keep_original_audio: keepCb.checked });
keepLbl.append(keepCb); keepLbl.append(el("span", null, "Keep original (generated) audio"));
keepRow.append(keepLbl); body.append(keepRow);
if (p.keep_original_audio === false)
body.append(el("div", "insp-hint", "Original audio is OFF — the render uses only inserted tracks below (silent if none)."));
// inserted tracks
(p.audio_tracks || []).forEach((t, i) => {
const asset = (st.mediaBin || []).find((m) => m.id === t.media_ref);
const card = el("div", "aud-track");
const head = el("div", "aud-track-head");
head.append(el("span", "aud-track-name", (asset && asset.name) || t.label || `Track ${i + 1}`));
const rm = el("button", "ic-btn danger", "✕"); rm.title = "Remove track";
rm.onclick = () => S.removeAudioTrack(t.id);
head.append(rm); card.append(head);
const r = el("div", "fields-row");
r.append(numberField("Start (s)", t.start_sec || 0, (v) => S.updateAudioTrack(t.id, { start_sec: v }, true), "aud-st-" + t.id));
const volWrap = el("label", "field");
volWrap.append(el("span", "field-label", `Volume (${Math.round((t.volume != null ? t.volume : 1) * 100)}%)`));
const tv = el("input"); tv.type = "range"; tv.min = "0"; tv.max = "2"; tv.step = "0.05";
tv.value = t.volume != null ? t.volume : 1; tv.dataset.k = "aud-vol-" + t.id;
tv.oninput = () => S.updateAudioTrack(t.id, { volume: parseFloat(tv.value) }, true);
volWrap.append(tv); r.append(volWrap);
card.append(r);
body.append(card);
});
// add a track from the media bin
const audioAssets = (st.mediaBin || []).filter((m) => m.kind === "audio" || /\.(mp3|wav|m4a|aac|ogg|flac)$/i.test(m.name || ""));
const addRow = el("div", "insp-block");
const addSel = el("select"); addSel.dataset.k = "aud-add";
addSel.append(new Option(audioAssets.length ? "— add audio track… —" : "— upload audio to the Media bin first —", ""));
audioAssets.forEach((m) => addSel.append(new Option(m.name || m.id, m.id)));
addSel.onchange = () => { if (addSel.value) { S.addAudioTrack(addSel.value, 0); addSel.value = ""; } };
addRow.append(field("Add background track", addSel)); body.append(addRow);
body.append(el("div", "insp-hint", "Inserted tracks are mixed at render only (not in the live preview). Per-clip original volume is on each scene."));
const slots = (st.models?.slots || []);
// Conditioning slot selector + its settings panel
+5 -1
View File
@@ -50,7 +50,7 @@
for (const sc of (p.scenes || [])) {
const dur = sFrames(sc) / sFps(sc);
const r = sr[sc.id];
if (r && r.media) out.push({ media: r.media, sceneId: sc.id, startSec: acc, durationSec: dur, inSec: r.inSec || 0, fx: sc.effects || {} });
if (r && r.media) out.push({ media: r.media, sceneId: sc.id, startSec: acc, durationSec: dur, inSec: r.inSec || 0, fx: sc.effects || {}, vol: sc.audio_volume != null ? sc.audio_volume : 1 });
acc += dur; // timeline advances even over ungenerated scenes (gaps)
}
return out;
@@ -126,6 +126,10 @@
if (fi > 0 && within < fi) op = Math.max(0, within / fi);
if (fo > 0 && dur > 0 && within > dur - fo) op = Math.min(op, Math.max(0, (dur - within) / fo));
v.style.opacity = op < 1 ? op.toFixed(3) : "";
// audio: per-clip original volume, muted when the project drops original audio.
const keepOrig = (S.get().project || {}).keep_original_audio !== false;
const vol = (clip && clip.vol != null) ? clip.vol : 1;
v.volume = keepOrig ? Math.max(0, Math.min(1, vol)) : 0;
}
// Show `clip` at `offset` seconds into the clip; play if requested. Seeks to the clip's
+29
View File
@@ -259,6 +259,31 @@
notify(); scheduleSave();
}
// ── audio editing ─────────────────────────────────────────────────────────────
function _uid() { return "a" + Math.random().toString(36).slice(2, 9) + Date.now().toString(36); }
function addAudioTrack(mediaId, startSec) {
if (!state.project || !mediaId) return;
const asset = (state.mediaBin || []).find((m) => m.id === mediaId);
state.project.audio_tracks = state.project.audio_tracks || [];
state.project.audio_tracks.push({
id: _uid(), media_ref: mediaId, start_sec: +(startSec || 0),
volume: 1.0, label: (asset && asset.name) || "track",
});
notify(); scheduleSave();
}
function updateAudioTrack(id, patch, quiet) {
const t = (state.project?.audio_tracks || []).find((x) => x.id === id);
if (!t) return;
Object.assign(t, patch);
notify(); quiet ? scheduleSaveSilent() : scheduleSave();
}
function removeAudioTrack(id) {
if (!state.project) return;
state.project.audio_tracks = (state.project.audio_tracks || []).filter((x) => x.id !== id);
notify(); scheduleSave();
}
// ── sync scenes from preview (distribute parsed anchor/transitions back) ──────
function syncFromPreview() {
if (!state.project || !state.preview) return;
@@ -562,6 +587,7 @@
h: (sc.height != null ? sc.height : p.height) || null,
transition: sc.video_transition || "",
tdur: tFrames > 0 ? tFrames / fps : 0,
volume: sc.audio_volume != null ? sc.audio_volume : 1,
});
}
return out;
@@ -588,7 +614,9 @@
filename: c.media.filename, subfolder: c.media.subfolder || "", type: c.media.type || "output",
in: +c.inSec.toFixed(3), dur: +c.durationSec.toFixed(3),
fx: c.fx, fps: c.fps, w: c.w, h: c.h, transition: c.transition, tdur: +(c.tdur || 0).toFixed(3),
volume: c.volume,
}));
await flushSave(); // audio tracks / keep-original live on the project — render reads it from disk
set({ gen: { state: "running", promptId: null, media: [], msg: `Stitching ${clips.length} clip(s)…` } });
try {
const r = await API.renderFinal(state.project.id, clips);
@@ -707,6 +735,7 @@
get, set, subscribe, init,
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, moveScene, moveSceneTo, scene,
addAudioTrack, updateAudioTrack, removeAudioTrack,
resizeScene, splitScene, snapFrames,
refreshPreview, syncFromPreview, applyGlobalPrompt, generate, generateMontage, renderFinal, exportSelected, interrupt, loadModels, loadImageTargets, setModelInput, setModelLink,
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, setStudioInput, setStudioInputNow,
+3
View File
@@ -387,6 +387,9 @@ body.tl-reordering { cursor: grabbing; user-select: none; }
.tl-dd-val { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; }
.tl-dd-val[hidden] { display: none; }
.tl-dd-val input { width: 78px; }
.aud-track { border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin: 6px 0; background: #1a1711; }
.aud-track-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.aud-track-name { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.clip.selected { border-color: var(--teal); box-shadow: inset 0 0 0 1px var(--teal-soft); z-index: 3; }
.clip.selected::before { background: var(--teal); opacity: 1; }
.clip.excluded { opacity: .42; }
+75 -25
View File
@@ -160,15 +160,22 @@ _XFADE_MAP = {
}
def _build_render_filter(clips: list) -> str:
def _build_render_filter(clips: list, tracks: Optional[list] = None,
keep_original: bool = True, base_input: int = 0) -> tuple[str, bool]:
"""ffmpeg filter_complex for the final stitch.
Per clip: apply blur / fade in-out / Ken-Burns zoom (scale eval=frame so the scale
animates over the clip; zoom-in crops the overflow, zoom-out pads into the box). Then
fold the clips left-to-right: a seam with a video_transition uses xfade (overlap, so the
total shortens by the transition length) + acrossfade for audio; otherwise a hard-cut
concat. All pure pixel ops — nothing re-encodes to latent. Returns labels [vout]/[aout].
Video — per clip: normalize to one canvas, then blur / fade in-out / virtual Ken-Burns
zoom (zoompan, fixed output size). Fold left-to-right with xfade (overlap) or concat.
Audio — per-clip original audio gets its volume; when keep_original the per-clip streams
are folded alongside the video (acrossfade at crossfades, concat otherwise) into [aorig].
Extra `tracks` (inserted audio, input index base_input+j) are delayed to their start and
volume-scaled, then everything is amixed into [aout]. keep_original=False drops the
original LTXAV audio entirely; with no tracks either, the output is silent (no [aout]).
Returns (filter_complex, has_audio).
"""
tracks = tracks or []
n = len(clips)
parts: list[str] = []
@@ -211,9 +218,14 @@ def _build_render_filter(clips: list) -> str:
vf.append("format=yuv420p")
vf.append("setsar=1")
parts.append(f"[{i}:v:0]{','.join(vf)}[v{i}]")
parts.append(f"[{i}:a:0]aformat=sample_fmts=fltp:channel_layouts=stereo[a{i}]")
if keep_original:
vol = float(c.get("volume", 1.0))
af = "aformat=sample_fmts=fltp:channel_layouts=stereo"
if abs(vol - 1.0) > 1e-3:
af += f",volume={max(0.0, vol):.3f}"
parts.append(f"[{i}:a:0]{af}[a{i}]")
# Fold the processed streams together.
# Fold video (and the original audio when kept) left-to-right.
acc_v, acc_a = "[v0]", "[a0]"
acc_dur = float(clips[0].get("dur") or 0) or 0.0
for i in range(1, n):
@@ -223,20 +235,45 @@ def _build_render_filter(clips: list) -> str:
dur_i = float(clips[i].get("dur") or 0) or 0.0
if trans in _XFADE_MAP and td > 0 and acc_dur > td:
off = max(0.0, acc_dur - td)
ov, oa = f"[vx{i}]", f"[ax{i}]"
ov = f"[vx{i}]"
parts.append(f"{acc_v}[v{i}]xfade=transition={_XFADE_MAP[trans]}:duration={td:.3f}:offset={off:.3f}{ov}")
parts.append(f"{acc_a}[a{i}]acrossfade=d={td:.3f}{oa}")
acc_v, acc_a, acc_dur = ov, oa, acc_dur + dur_i - td
if keep_original:
oa = f"[ax{i}]"; parts.append(f"{acc_a}[a{i}]acrossfade=d={td:.3f}{oa}"); acc_a = oa
acc_v, acc_dur = ov, acc_dur + dur_i - td
else:
ov, oa = f"[vc{i}]", f"[ac{i}]"
ov = f"[vc{i}]"
parts.append(f"{acc_v}[v{i}]concat=n=2:v=1:a=0{ov}")
parts.append(f"{acc_a}[a{i}]concat=n=2:v=0:a=1{oa}")
acc_v, acc_a, acc_dur = ov, oa, acc_dur + dur_i
if keep_original:
oa = f"[ac{i}]"; parts.append(f"{acc_a}[a{i}]concat=n=2:v=0:a=1{oa}"); acc_a = oa
acc_v, acc_dur = ov, acc_dur + dur_i
# Final relabel to [vout]/[aout].
parts.append(f"{acc_v}null[vout]")
parts.append(f"{acc_a}anull[aout]")
return ";".join(parts)
total = max(0.01, acc_dur)
# Assemble the audio mix: original (if kept) + each inserted track (delayed/volume'd).
mix: list[str] = []
if keep_original and n > 0:
mix.append(acc_a)
for j, t in enumerate(tracks):
idx = base_input + j
start = float(t.get("start_sec") or 0)
tvol = float(t.get("volume", 1.0))
ms = int(max(0.0, start) * 1000)
lbl = f"[at{j}]"
chain = f"[{idx}:a:0]aformat=sample_fmts=fltp:channel_layouts=stereo,volume={max(0.0, tvol):.3f}"
if ms > 0:
chain += f",adelay={ms}|{ms}"
parts.append(f"{chain}{lbl}")
mix.append(lbl)
if not mix:
return ";".join(parts), False # silent output (video only)
if len(mix) == 1:
parts.append(f"{mix[0]}atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]")
else:
parts.append("".join(mix) + f"amix=inputs={len(mix)}:normalize=0:duration=longest[amx]")
parts.append(f"[amx]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]")
return ";".join(parts), True
def _segment(p: Project, scene_ids: list) -> Project:
@@ -640,9 +677,9 @@ if web is not None and PromptServer is not None:
# --- API: final render (hard-cut concat of kept clips, with per-clip in/out) ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/render")
async def _render_final(req):
_project_or_404(req.match_info["pid"])
proj = _project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
clips = body.get("clips") or [] # ordered [{filename, subfolder, type, in?, dur?}]
clips = body.get("clips") or [] # ordered [{filename, subfolder, type, in?, dur?, volume?}]
if not clips:
return web.json_response({"detail": "Nothing to render — no generated clips."}, status=400)
import os
@@ -668,13 +705,22 @@ if web is not None and PromptServer is not None:
if missing:
return web.json_response({"detail": f"{len(missing)} clip file(s) not found on disk — regenerate then render."}, status=400)
# Inserted audio tracks (project-level): resolve each media asset to a file. Skip any
# that no longer exist on disk rather than failing the whole render.
keep_original = bool(getattr(proj, "keep_original_audio", True))
tracks = []
for t in (getattr(proj, "audio_tracks", None) or []):
mp = media.path_for(t.get("media_ref") or "")
if mp is None:
continue
tracks.append({"path": str(mp), "start_sec": t.get("start_sec") or 0, "volume": t.get("volume", 1.0)})
# The final render is ephemeral (temp dir) — persist it via the Export Save dialog.
out_name = f"funpack_final_{int(_time.time())}.mp4"
out_path = os.path.join(tempdir, out_name)
# Per-clip in/out via -ss/-t before each input, then a filter graph that applies
# per-clip video effects (blur/fade/zoom — pure pixel ops, never re-encode to
# latent) and folds clips together with xfade/concat seam transitions. Re-encode
# keeps the LTXAV audio.
# per-clip video effects + seam transitions and the audio mix (per-clip volume,
# original-audio toggle, inserted tracks). Re-encode keeps/mixes the audio.
cmd = [ff, "-y"]
for c, pth in zip(clips, paths):
inn, dur = c.get("in"), c.get("dur")
@@ -684,10 +730,14 @@ if web is not None and PromptServer is not None:
cmd += ["-t", f"{float(dur):.3f}"]
cmd += ["-i", pth]
n = len(clips)
filt = _build_render_filter(clips)
for t in tracks: # inserted-audio inputs follow the clip inputs (indices n..n+k-1)
cmd += ["-i", t["path"]]
filt, has_audio = _build_render_filter(clips, tracks=tracks, keep_original=keep_original, base_input=n)
cmd += ["-filter_complex", filt, "-map", "[vout]"]
if has_audio:
cmd += ["-map", "[aout]", "-c:a", "aac", "-b:a", "192k"]
cmd += [
"-filter_complex", filt, "-map", "[vout]", "-map", "[aout]",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)