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:
co-authored by
Claude Opus 4.8
parent
b8fa482512
commit
7ffee3e688
@@ -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)"}`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user