Movie Editor: per-clip in/out model — splits & deletes honored in preview + render

Replace whole-run renderedSegments with per-scene sceneRenders {media, inSec}. The
player builds per-scene clips (media, startSec, durationSec, inSec) and plays each clip's
[inSec, inSec+dur] window via a rAF tick (precise out-points, so deleted parts aren't
played; contiguous split seams continue seamlessly). Split sets the second half's inSec;
delete drops the mapping. Render route takes per-clip in/dur and concatenates only kept
clips (ffmpeg -ss/-t + concat filter, audio preserved). Export/hasRender use sceneRenders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-07 19:08:49 +03:00
co-authored by Claude Opus 4.8
parent b8fa482512
commit 7ffee3e688
4 changed files with 165 additions and 139 deletions
+79 -79
View File
@@ -18,7 +18,6 @@
const stage = document.createElement("div"); stage.className = "pm-stage";
const pool = new Map(); // url -> <video>
let _active = null; // visible <video>
let _currentSeg = null; // segment the active video belongs to
let _seekPending = null; // offset to apply once the active video has metadata
let _playPending = false;
@@ -31,43 +30,37 @@
_phListeners.forEach((fn) => { try { fn(_phSec, _playing); } catch (_) {} });
}
// ── segment helpers ────────────────────────────────────────────────────────
// Segments are [{sceneIds, media, startSec, durationSec}], computed from store state.
let _segs = [];
// ── clips ────────────────────────────────────────────────────────────────────
// Per-scene clips: { media, sceneId, startSec (timeline), durationSec, inSec (source
// in-point) }. Built from store.sceneRenders so splits/deletes play correct portions.
let _clips = [];
let _currentClip = null;
const _urlFor = (seg) => API.resultUrl(S.get().project?.id, seg.media);
const _clampT = (v, t) => Math.max(0, Math.min(t, (v.duration || t) - 0.02));
const _urlFor = (media) => API.resultUrl(S.get().project?.id, media);
const _clampT = (v, t) => Math.max(0, Math.min(t, (v.duration || (t + 1)) - 0.02));
function _buildSegs(st) {
function _buildClips(st) {
if (!st.project) return [];
const p = st.project;
const sr = st.sceneRenders || {};
const sFps = (sc) => ((sc.fps_mode !== "project" && sc.fps != null) ? sc.fps : p.frame_rate) || 25;
const sFrames = (sc) => ((sc.frames_mode !== "project" && sc.frames != null) ? sc.frames : p.num_frames_per_scene) || 1;
// Map sceneId → timeline start time
const startOf = {};
const out = [];
let acc = 0;
for (const sc of (p.scenes || [])) {
startOf[sc.id] = acc;
acc += sFrames(sc) / sFps(sc);
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 });
acc += dur; // timeline advances even over ungenerated scenes (gaps)
}
return (st.renderedSegments || []).flatMap((seg) => {
const ids = seg.sceneIds || [];
if (!ids.length || !seg.media) return [];
const starts = ids.map((id) => startOf[id]).filter((s) => s != null);
if (!starts.length) return [];
const startSec = Math.min(...starts);
const endSec = Math.max(...ids.map((id) => {
const sc = (p.scenes || []).find((s) => s.id === id);
return sc ? startOf[id] + sFrames(sc) / sFps(sc) : 0;
}));
return [{ ...seg, startSec, durationSec: Math.max(0, endSec - startSec) }];
});
return out;
}
function _segAt(sec) {
return _segs.find((s) => sec >= s.startSec - 0.05 && sec < s.startSec + s.durationSec + 0.05) || null;
function _clipAt(sec) {
return _clips.find((c) => sec >= c.startSec - 0.05 && sec < c.startSec + c.durationSec - 0.001) || null;
}
function _clipFrom(sec) { // first clip at/after sec (for play/skip-gap)
return _clipAt(sec) || _clips.find((c) => c.startSec >= sec - 0.05) || null;
}
// ── video pool management ─────────────────────────────────────────────────────
@@ -81,30 +74,25 @@
if (_seekPending != null) { v.currentTime = _clampT(v, _seekPending); _seekPending = null; }
if (_playPending) { _playPending = false; v.play().catch(() => {}); }
});
v.addEventListener("timeupdate", () => {
if (v !== _active || !_currentSeg) return;
_phSec = _currentSeg.startSec + v.currentTime; _notifyPh();
});
v.addEventListener("ended", () => { if (v === _active) _onEnded(); });
v.addEventListener("ended", () => { if (v === _active) _advance(); });
stage.append(v); pool.set(url, v);
return v;
}
// Add videos for current segments (preload), drop ones no longer used. Keeps the pool
// in sync as the timeline is cut/regenerated on the fly.
function _syncPool() {
const urls = new Set(_segs.map(_urlFor));
const urls = new Set(_clips.map((c) => _urlFor(c.media)));
urls.forEach((u) => _ensureVideo(u));
for (const [u, v] of [...pool]) {
if (urls.has(u)) continue;
v.pause(); v.remove(); pool.delete(u);
if (_active === v) { _active = null; _currentSeg = null; }
if (_active === v) { _active = null; }
}
}
function _resetPool() {
_stopTick();
for (const [, v] of pool) { v.pause(); v.remove(); }
pool.clear(); _active = null; _currentSeg = null; _seekPending = null; _playPending = false;
pool.clear(); _active = null; _currentClip = null; _seekPending = null; _playPending = false;
}
function _setActive(v) {
@@ -113,47 +101,64 @@
_active = v; if (v) v.classList.add("active");
}
// Show `seg` at `offset`; play if requested. The clip is preloaded, so the frame
// appears immediately on a same- or cross-clip jump (no black flash).
function _goto(seg, offset, play) {
if (!seg || !seg.media) return;
const v = _ensureVideo(_urlFor(seg));
_currentSeg = seg; _setActive(v);
// Show `clip` at `offset` seconds into the clip; play if requested. Seeks to the clip's
// in-point + offset within the (preloaded) source video.
function _goto(clip, offset, play) {
if (!clip || !clip.media) return;
const v = _ensureVideo(_urlFor(clip.media));
_currentClip = clip; _setActive(v);
const target = (clip.inSec || 0) + Math.max(0, offset);
if (v.readyState >= 1) {
v.currentTime = _clampT(v, offset);
if (play) v.play().catch(() => {}); else _playPending = false;
v.currentTime = _clampT(v, target);
if (play) { v.play().catch(() => {}); _startTick(); } else _playPending = false;
} else {
_seekPending = offset; _playPending = !!play;
_seekPending = target; _playPending = !!play;
if (play) _startTick();
}
}
function _onEnded() {
_playing = false;
if (_currentSeg) {
const endSec = _currentSeg.startSec + _currentSeg.durationSec;
const next = _segs.find((s) => s.startSec >= endSec - 0.05 && s !== _currentSeg);
if (next) { _phSec = next.startSec; _playing = true; _goto(next, 0, true); }
else _phSec = endSec;
}
// Advance to the next clip at a clip's out-point (or stop). Same-source contiguous
// clips (a split seam) continue without reseeking; otherwise seek; no next → stop.
function _advance() {
if (!_currentClip) { _pause(); return; }
const end = _currentClip.startSec + _currentClip.durationSec;
const next = _clips.find((c) => c.startSec >= end - 0.02 && c !== _currentClip);
if (!next) { _phSec = end; _pause(); return; }
const contiguous = _urlFor(next.media) === _urlFor(_currentClip.media)
&& Math.abs((next.inSec || 0) - ((_currentClip.inSec || 0) + _currentClip.durationSec)) < 0.05;
_phSec = next.startSec;
_currentClip = next;
if (!contiguous) _goto(next, 0, _playing); // jump (different source / non-contiguous)
_notifyPh();
_renderTransport();
}
// ── playback tick (precise out-points, so deleted parts aren't played) ─────────
let _raf = null;
function _startTick() { if (!_raf) _raf = requestAnimationFrame(_tick); }
function _stopTick() { if (_raf) { cancelAnimationFrame(_raf); _raf = null; } }
function _tick() {
_raf = null;
if (!_playing || !_active || !_currentClip) return;
const within = _active.currentTime - (_currentClip.inSec || 0);
if (within >= _currentClip.durationSec - 0.001) { _advance(); }
else { _phSec = _currentClip.startSec + Math.max(0, within); _notifyPh(); }
if (_playing) _raf = requestAnimationFrame(_tick);
}
// ── transport actions ──────────────────────────────────────────────────────
function _seek(sec) {
_phSec = Math.max(0, sec);
const seg = _segAt(_phSec);
if (seg) _goto(seg, _phSec - seg.startSec, false);
const clip = _clipAt(_phSec);
if (clip) _goto(clip, _phSec - clip.startSec, false);
_notifyPh();
}
function _play() {
let seg = _segAt(_phSec);
if (!seg) seg = _segs.find((s) => s.startSec >= _phSec - 0.05);
if (!seg) return;
if (_phSec < seg.startSec) _phSec = seg.startSec;
const clip = _clipFrom(_phSec);
if (!clip) return;
if (_phSec < clip.startSec) _phSec = clip.startSec;
_playing = true;
_goto(seg, _phSec - seg.startSec, true);
_goto(clip, _phSec - clip.startSec, true);
_notifyPh();
_renderTransport();
}
@@ -162,6 +167,7 @@
if (_active) _active.pause();
_playing = false;
_playPending = false;
_stopTick();
_notifyPh();
_renderTransport();
}
@@ -171,7 +177,8 @@
_phSec = 0;
_playing = false;
_playPending = false;
const first = _segs[0];
_stopTick();
const first = _clips[0];
if (first) _goto(first, 0, false);
_notifyPh();
_renderTransport();
@@ -222,7 +229,7 @@
_transportEl.append(stopBtn, playBtn, tc);
// Anchor capture — only when a rendered frame is under the playhead.
if (_currentSeg) {
if (_currentClip) {
const scenes = (S.get().project?.scenes || []).filter((s) => !s.excluded);
if (scenes.length) {
const sep = el("div", "pm-sep"); _transportEl.append(sep);
@@ -302,23 +309,16 @@
_phSec = 0; _playing = false; _lastSegHash = "";
}
_segs = _buildSegs(st);
_clips = _buildClips(st);
_syncPool(); // preload current clips, drop stale ones (timeline is dynamic)
// Keep _currentSeg pointing at a fresh segment object (positions change with edits).
if (_currentSeg) {
const u = _urlFor(_currentSeg);
_currentSeg = _segAt(_phSec) && _urlFor(_segAt(_phSec)) === u ? _segAt(_phSec)
: (_segs.find((s) => _urlFor(s) === u) || _currentSeg);
}
const hash = _segs.map((s) => s.media?.filename || "").join("|");
// Re-resolve the clip under the playhead each render (positions change with edits).
const hash = _clips.map((c) => c.media?.filename + "@" + c.startSec.toFixed(2) + "+" + c.inSec.toFixed(2)).join("|");
if (hash !== _lastSegHash) {
_lastSegHash = hash;
// Segments changed — re-resolve the frame at the playhead (keep playing if we were).
const seg = _segAt(_phSec);
if (seg) _goto(seg, _phSec - seg.startSec, _playing);
else if (_segs.length && !_active) _goto(_segs[0], 0, false);
const clip = _clipAt(_phSec);
if (clip) _goto(clip, _phSec - clip.startSec, _playing);
else if (_clips.length && !_active) _goto(_clips[0], 0, false);
}
const p = st.project;
@@ -335,7 +335,7 @@
// ── canvas (video + placeholder) ────────────────────────────────────────
const canvas = el("div", "pm-canvas");
if (_segs.length) {
if (_clips.length) {
canvas.append(stage); // pooled videos live here; only the active one is visible
} else if (["queuing", "running", "pending"].includes(gen.state)) {
const splash = el("div", "pm-gen-splash");
@@ -382,7 +382,7 @@
(p.scenes || []).forEach((sc) => {
const d = sFrames(sc) / sFps(sc);
const o = acc; acc += d;
const rendered = _segs.some((s) => (s.sceneIds || []).includes(sc.id));
const rendered = !!((st.sceneRenders || {})[sc.id] || {}).media;
const chip = el("div", "pm-chip" + (rendered ? " rendered" : ""));
chip.style.width = (d / _totalSecCur * 100).toFixed(3) + "%";
chip.title = `Scene ${(p.scenes || []).indexOf(sc) + 1}${rendered ? " (rendered)" : " (not rendered)"}`;
+59 -28
View File
@@ -10,10 +10,10 @@
health: null, // {ok, comfy_url, template_exists}
preview: null, // {combined_prompt, parsed, warning, parse_error}
gen: { state: "idle", promptId: null, media: [], msg: "" },
// Rendered video segments: [{sceneIds, media, startSec (computed), durationSec (computed)}]
// Not persisted to disk — cleared on project switch. Player uses these to map
// timeline positions to video files and compute seek offsets.
renderedSegments: [],
// Per-scene render mapping: { sceneId: { media, inSec } } — `inSec` is the IN-point
// within that source video this clip starts at (so split halves / deleted clips play
// the correct portion). Not persisted to disk; cleared on project switch.
sceneRenders: {},
saving: false,
models: { slots: [] }, // pluggable node config (shared with Models modal)
mediaBin: [], // uploaded assets [{id,name,kind,...}]
@@ -40,7 +40,7 @@
state.project = await API.getProject(id);
state.selectedSceneId = state.project.scenes[0]?.id || null;
state.gen = { state: "idle", promptId: null, media: [], msg: "" };
state.renderedSegments = []; // segments are per-session; not persisted
state.sceneRenders = {}; // per-session; not persisted
notify();
refreshPreview();
loadModels(); // models config is per-project — reload for this project
@@ -178,6 +178,7 @@
function removeScene(id) {
if (!state.project) return;
state.project.scenes = state.project.scenes.filter((s) => s.id !== id);
delete state.sceneRenders[id]; // drop its render mapping (preview/render skip it)
if (state.selectedSceneId === id) state.selectedSceneId = state.project.scenes[0]?.id || null;
notify(); scheduleSave();
}
@@ -185,6 +186,14 @@
// LTX-valid frame counts are 8k+1 (so (frames-1) % 8 === 0); snap to the nearest.
function snapFrames(n) { return Math.max(9, Math.round((Math.round(n) - 1) / 8) * 8 + 1); }
// A scene's duration in seconds (respecting per-scene frames/fps modes).
function sceneDurationSec(sc) {
const p = state.project; if (!p || !sc) return 0;
const fps = (sc.fps_mode !== "project" && sc.fps != null ? sc.fps : p.frame_rate) || 25;
const frames = (sc.frames_mode !== "project" && sc.frames != null ? sc.frames : p.num_frames_per_scene) || 1;
return frames / fps;
}
// Resize a clip by setting its frame count from a target duration (seconds) × fps.
// Uses silent save so the drag handle isn't interrupted by a re-render mid-drag.
function resizeScene(id, durationSec) {
@@ -217,10 +226,13 @@
second.transition_frames = s.transition_frames || null;
s.frames = cut; s.transition_to_next = ""; s.transition_frames = null;
arr.splice(i + 1, 0, second);
// Keep the existing render covering both halves (same source media spans the cut).
(state.renderedSegments || []).forEach((seg) => {
if ((seg.sceneIds || []).includes(id) && !seg.sceneIds.includes(second.id)) seg.sceneIds.push(second.id);
});
// Keep the render across the cut: the second half plays the SAME source video starting
// at the first half's out-point (its in-point + first-half duration).
const r = state.sceneRenders[id];
if (r && r.media) {
const fps = s.fps_mode !== "project" && s.fps != null ? s.fps : state.project.frame_rate;
state.sceneRenders[second.id] = { media: r.media, inSec: (r.inSec || 0) + cut / (fps || 25) };
}
notify(); scheduleSave();
}
@@ -361,14 +373,17 @@
return runs;
}
// Record a completed run's output so the player can map it onto the timeline.
// Record a completed run's output: map each of the run's scenes to the one source
// video at its cumulative in-point, so splits/deletes later play the right portions.
function _recordSegment(mediaList, targetSceneIds) {
if (!mediaList || !mediaList.length || !targetSceneIds || !targetSceneIds.length) return;
const keep = (state.renderedSegments || []).filter(
(seg) => !(seg.sceneIds || []).some((id) => targetSceneIds.includes(id))
);
const primary = mediaList.find((m) => m.kind === "videos" || m.kind === "gifs") || mediaList[0];
state.renderedSegments = [...keep, { sceneIds: targetSceneIds, media: primary }];
let inAcc = 0;
for (const id of targetSceneIds) {
const sc = scene(id); if (!sc) continue;
state.sceneRenders[id] = { media: primary, inSec: inAcc };
inAcc += sceneDurationSec(sc);
}
}
// Poll a single queued prompt to completion. Resolves true on success, false on error.
@@ -499,32 +514,48 @@
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
}
// Ordered list of rendered clips in timeline order: {media, inSec, durationSec} for
// each non-excluded scene that has a render. This is what plays/exports — so splits
// and deletes are honoured (deleted scenes simply aren't here).
function _renderClips() {
const out = [];
for (const sc of (state.project.scenes || [])) {
if (sc.excluded) continue;
const r = state.sceneRenders[sc.id];
if (r && r.media) out.push({ media: r.media, inSec: r.inSec || 0, durationSec: sceneDurationSec(sc) });
}
return out;
}
// Export the render covering the selected clip via a Save dialog.
async function exportSelected() {
if (!state.project) return;
const id = state.selectedSceneId;
if (!id) { alert("Select a clip to export."); return; }
const seg = (state.renderedSegments || []).find((s) => s.media && (s.sceneIds || []).includes(id));
if (!seg) { alert("That clip hasn't been generated yet — generate it first."); return; }
const r = state.sceneRenders[id];
if (!r || !r.media) { alert("That clip hasn't been generated yet — generate it first."); return; }
const idx = state.project.scenes.findIndex((s) => s.id === id);
await _saveBlobAs(API.resultUrl(state.project.id, seg.media), `scene_${idx >= 0 ? idx + 1 : "x"}.mp4`);
await _saveBlobAs(API.resultUrl(state.project.id, r.media), `scene_${idx >= 0 ? idx + 1 : "x"}.mp4`);
}
// Stitch the rendered runs (hard cut, video + audio) into one final file.
// Stitch the kept clips (in/out per clip, hard cut, video + audio) into one final file.
async function renderFinal() {
if (!state.project) return;
const order = state.project.scenes.filter((s) => !s.excluded).map((s) => s.id);
const pos = (ids) => Math.min(...ids.map((id) => { const i = order.indexOf(id); return i < 0 ? 1e9 : i; }));
const segs = (state.renderedSegments || []).filter((s) => s.media)
.slice().sort((a, b) => pos(a.sceneIds || []) - pos(b.sceneIds || []));
if (!segs.length) { alert("No generated runs to stitch yet — generate first."); return; }
const clips = segs.map((s) => ({ filename: s.media.filename, subfolder: s.media.subfolder || "", type: s.media.type || "output" }));
set({ gen: { state: "running", promptId: null, media: [], msg: `Stitching ${clips.length} run(s)…` } });
const rc = _renderClips();
if (!rc.length) { alert("No generated clips to stitch yet — generate first."); return; }
const clips = rc.map((c) => ({
filename: c.media.filename, subfolder: c.media.subfolder || "", type: c.media.type || "output",
in: +c.inSec.toFixed(3), dur: +c.durationSec.toFixed(3),
}));
set({ gen: { state: "running", promptId: null, media: [], msg: `Stitching ${clips.length} clip(s)…` } });
try {
const r = await API.renderFinal(state.project.id, clips);
// Show the stitched file across the whole timeline in the program monitor.
state.renderedSegments = [{ sceneIds: order, media: r.media }];
set({ gen: { state: "done", promptId: null, media: [r.media], msg: `Final video rendered from ${r.clips} run(s) — saving…` } });
// Play the stitched file across the whole timeline (single source, in-point 0).
const order = state.project.scenes.filter((s) => !s.excluded);
state.sceneRenders = {};
let acc = 0;
for (const sc of order) { state.sceneRenders[sc.id] = { media: r.media, inSec: acc }; acc += sceneDurationSec(sc); }
set({ gen: { state: "done", promptId: null, media: [r.media], msg: `Final video rendered from ${r.clips} clip(s) — saving…` } });
const name = (state.project.name || "montage").replace(/\s+/g, "_") + ".mp4";
await _saveBlobAs(API.resultUrl(state.project.id, r.media), name);
} catch (e) {
+1 -2
View File
@@ -25,8 +25,7 @@
const sFrames = (sc, p) => ((sc.frames_mode !== "project" && sc.frames != null) ? sc.frames : p.num_frames_per_scene) || 1;
const sDur = (sc, p) => sFrames(sc, p) / sFps(sc, p);
const hasRender = (st, sceneId) =>
(st.renderedSegments || []).some((s) => s.media && (s.sceneIds || []).includes(sceneId));
const hasRender = (st, sceneId) => !!(st.sceneRenders && st.sceneRenders[sceneId] && st.sceneRenders[sceneId].media);
const p2 = (n) => String(n).padStart(2, "0");
function timecode(sec, fps) {
+26 -30
View File
@@ -552,18 +552,17 @@ if web is not None and PromptServer is not None:
raise web.HTTPBadGateway(reason=f"could not fetch result: {e}")
return web.Response(body=data, content_type=ctype.split(";")[0])
# --- API: final render (hard-cut concat of generated runs, video + audio) ---
# --- 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"])
body = await req.json() if req.can_read_body else {}
clips = body.get("clips") or [] # ordered [{filename, subfolder, type}]
clips = body.get("clips") or [] # ordered [{filename, subfolder, type, in?, dur?}]
if not clips:
return web.json_response({"detail": "Nothing to render — no generated runs."}, status=400)
return web.json_response({"detail": "Nothing to render — no generated clips."}, status=400)
import os
import shutil
import subprocess
import tempfile
import time as _time
ff = shutil.which("ffmpeg")
if not ff:
@@ -584,36 +583,33 @@ 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)
# The final render is ALSO ephemeral (temp dir) — the user persists it via the
# Export Save dialog, not by us writing to the output directory.
# 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)
# concat demuxer + re-encode → tolerates differing codecs/params between runs
# and keeps BOTH the video and the LTXAV audio stream.
list_fd, list_path = tempfile.mkstemp(suffix=".txt")
try:
with os.fdopen(list_fd, "w") as lf:
for pth in paths:
safe = pth.replace("'", "'\\''")
lf.write(f"file '{safe}'\n")
cmd = [
ff, "-y", "-f", "concat", "-safe", "0", "-i", list_path,
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
tail = (proc.stderr or "")[-800:]
return web.json_response({"detail": f"ffmpeg concat failed: {tail}"}, status=500)
finally:
try:
os.unlink(list_path)
except OSError:
pass
# Per-clip in/out via -ss/-t before each input, concatenated with the concat filter
# (re-encode; keeps the LTXAV audio). This honours splits and deletions.
cmd = [ff, "-y"]
for c, pth in zip(clips, paths):
inn, dur = c.get("in"), c.get("dur")
if inn is not None:
cmd += ["-ss", f"{float(inn):.3f}"]
if dur is not None:
cmd += ["-t", f"{float(dur):.3f}"]
cmd += ["-i", pth]
n = len(clips)
filt = "".join(f"[{i}:v:0][{i}:a:0]" for i in range(n)) + f"concat=n={n}:v=1:a=1[v][a]"
cmd += [
"-filter_complex", filt, "-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
tail = (proc.stderr or "")[-1000:]
return web.json_response({"detail": f"ffmpeg render failed: {tail}"}, status=500)
return web.json_response({
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": len(paths),
"clips": n,
})
# --- API: models / pluggable node slots ---