Fix fast scrub preview and multi-select plain concat export.

Coalesce playhead seeks during drag so rapid scrubbing stays stable, and
route multi-select export through trim-only hard-cut concat instead of Render Final.
This commit is contained in:
digital-garbage
2026-06-11 19:16:30 +03:00
parent ec5290e05a
commit 17711d829e
5 changed files with 317 additions and 34 deletions
+2
View File
@@ -113,6 +113,8 @@
renderFinal: (id, clips) => j("POST", API(`/projects/${id}/render`), { clips }),
renderFinalStatus: (id, jobId) => j("GET", API(`/projects/${id}/render/${encodeURIComponent(jobId)}`)),
exportClip: (id, clip) => j("POST", API(`/projects/${id}/export-clip`), { clip }),
exportClipsCombined: (id, clips) => j("POST", API(`/projects/${id}/export-clips`), { clips }),
exportClipsStatus: (id, jobId) => j("GET", API(`/projects/${id}/export-clips/${encodeURIComponent(jobId)}`)),
resultUrl: (id, m) =>
API(`/projects/${id}/result?filename=${encodeURIComponent(m.filename)}`) +
`&subfolder=${encodeURIComponent(m.subfolder || "")}&type=${encodeURIComponent(m.type || "output")}`,
+174 -16
View File
@@ -20,6 +20,14 @@
let _active = null; // visible <video>
let _seekPending = null; // offset to apply once the active video has metadata
let _playPending = false;
let _seekRaf = null; // coalesce rapid scrub seeks to one per frame
let _pendingSeekSec = null;
let _videoSeekTimer = null;
let _pendingVideoTarget = null;
let _pendingFxClip = null;
let _pendingFxOffset = 0;
let _seekToken = 0;
let _scrubDepth = 0; // suppress render()-driven re-seek while dragging playhead
const _insPool = new Map(); // track id -> <audio>
let _insTracks = [];
@@ -42,6 +50,47 @@
const _clipUrl = (clip) => clip.binUrl || (clip.media ? _urlFor(clip.media) : "");
const _clampT = (v, t) => Math.max(0, Math.min(t, (v.duration || (t + 1)) - 0.02));
function _applyVideoTime(v, target, fast) {
const t = _clampT(v, target);
try {
if (fast && typeof v.fastSeek === "function") v.fastSeek(t);
else v.currentTime = t;
} catch (_) {
try { v.currentTime = t; } catch (_) {}
}
}
function _flushVideoSeek(force) {
if (_videoSeekTimer) {
clearTimeout(_videoSeekTimer);
_videoSeekTimer = null;
}
if (_pendingVideoTarget == null || !_active || !_pendingFxClip) return;
const v = _active;
const target = _pendingVideoTarget;
const clip = _pendingFxClip;
const offset = _pendingFxOffset;
_pendingVideoTarget = null;
_pendingFxClip = null;
_pendingFxOffset = 0;
const token = ++_seekToken;
if (v.readyState >= 1) {
_applyVideoTime(v, target, force !== true && !_playing);
if (token === _seekToken) _applyFx(clip, offset);
} else {
v._pmSeekPending = target;
_seekPending = target;
}
}
function _scheduleVideoSeek(target, clip, offset) {
_pendingVideoTarget = target;
_pendingFxClip = clip;
_pendingFxOffset = offset;
if (_videoSeekTimer) return;
_videoSeekTimer = setTimeout(() => _flushVideoSeek(false), _playing ? 0 : 24);
}
function _segmentSourceBadge(sceneId) {
if (!sceneId) return "";
const sc = S.scene(sceneId);
@@ -284,9 +333,19 @@
zoom.append(v);
viewport.append(zoom);
v.addEventListener("loadedmetadata", () => {
const pending = v._pmSeekPending != null ? v._pmSeekPending : (v === _active ? _seekPending : null);
if (pending != null) {
_applyVideoTime(v, pending, !_playing);
v._pmSeekPending = null;
if (v === _active) _seekPending = null;
}
if (v !== _active) return;
if (_seekPending != null) { v.currentTime = _clampT(v, _seekPending); _seekPending = null; }
if (_playPending) { _playPending = false; v.play().catch(() => {}); }
if (_currentClip) _applyFx(_currentClip, Math.max(0, _phSec - _currentClip.startSec));
});
v.addEventListener("seeked", () => {
if (v !== _active || !_currentClip || _playing) return;
_applyFx(_currentClip, Math.max(0, _phSec - _currentClip.startSec));
});
v.addEventListener("ended", () => { if (v === _active) _advance(); });
stage.append(viewport); pool.set(url, v);
@@ -312,6 +371,10 @@
v._pmFx?.viewport.remove();
}
pool.clear(); _active = null; _currentClip = null; _seekPending = null; _playPending = false;
_pendingSeekSec = null;
_scrubDepth = 0;
if (_seekRaf) { cancelAnimationFrame(_seekRaf); _seekRaf = null; }
_flushVideoSeek(true);
_pauseInsAudio();
for (const a of _insPool.values()) a.removeAttribute("src");
_insPool.clear();
@@ -330,6 +393,11 @@
if (v) {
v.classList.add("active");
v._pmFx?.viewport.classList.add("active");
if (v._pmSeekPending != null && v.readyState >= 1) {
_applyVideoTime(v, v._pmSeekPending, !_playing);
v._pmSeekPending = null;
_seekPending = null;
}
}
}
@@ -386,6 +454,26 @@
if (v.volume !== nv) { try { v.volume = nv; } catch (_) {} }
}
function _applyClipUi(clip) {
if (!clip || clip.pending) {
_showBadge("");
_showSlate(clip?.slate || "Not generated yet", clip?.slateSub);
return;
}
_showSlate("");
const badge = clip.ghost ? (clip.slate || "Removed — preview only")
: _segmentSourceBadge(clip.sceneId);
_showBadge(badge);
if (_badgeEl && badge.startsWith("Mixed")) _badgeEl.classList.add("mixed-mode");
else if (_badgeEl) _badgeEl.classList.remove("mixed-mode");
}
function _sameVideoUrl(a, b) {
if (!a || !b) return false;
const ua = _clipUrl(a), ub = _clipUrl(b);
return !!ua && ua === ub;
}
// 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) {
@@ -393,26 +481,24 @@
if (clip.pending) {
_currentClip = clip;
_setActive(null);
_showBadge("");
_showSlate(clip.slate || "Not generated yet", clip.slateSub);
_applyClipUi(clip);
if (play) _startTick();
return;
}
if (!clip.media && !clip.binUrl) return;
const v = _ensureVideo(_clipUrl(clip));
_currentClip = clip; _setActive(v);
_showSlate("");
const badge = clip.ghost ? (clip.slate || "Removed — preview only")
: _segmentSourceBadge(clip.sceneId);
_showBadge(badge);
if (_badgeEl && badge.startsWith("Mixed")) _badgeEl.classList.add("mixed-mode");
else if (_badgeEl) _badgeEl.classList.remove("mixed-mode");
_applyClipUi(clip);
const target = (clip.inSec || 0) + Math.max(0, offset);
if (v.readyState >= 1) {
v.currentTime = _clampT(v, target);
_applyVideoTime(v, target, !play);
v._pmSeekPending = null;
_seekPending = null;
if (play) { v.play().catch(() => {}); _startTick(); } else _playPending = false;
} else {
_seekPending = target; _playPending = !!play;
v._pmSeekPending = target;
_seekPending = target;
_playPending = !!play;
if (play) _startTick();
}
_applyFx(clip, Math.max(0, offset));
@@ -467,15 +553,73 @@
}
// ── transport actions ──────────────────────────────────────────────────────
function _seek(sec) {
function _seekNow(sec) {
_phSec = Math.max(0, sec);
const clip = _clipAt(_phSec);
if (clip) _goto(clip, _phSec - clip.startSec, false);
else { _currentClip = null; _setActive(null); _showSlate(""); _showBadge(""); }
if (!clip) {
_flushVideoSeek(true);
_currentClip = null;
_setActive(null);
_showSlate("");
_showBadge("");
_syncInsAudio();
_notifyPh();
return;
}
const offset = _phSec - clip.startSec;
const sameClip = _currentClip === clip
|| (_currentClip && clip && _currentClip.sceneId === clip.sceneId && _sameVideoUrl(_currentClip, clip));
const sameVideo = !clip.pending && _active && _sameVideoUrl(_currentClip, clip)
&& (clip.media || clip.binUrl);
if (sameVideo && _active.readyState >= 1) {
_currentClip = clip;
_applyClipUi(clip);
const target = (clip.inSec || 0) + Math.max(0, offset);
_scheduleVideoSeek(target, clip, offset);
} else if (!sameClip || clip.pending) {
_flushVideoSeek(true);
_goto(clip, offset, false);
} else if (!clip.pending && (clip.media || clip.binUrl)) {
const target = (clip.inSec || 0) + Math.max(0, offset);
const v = _active;
if (v && v.readyState >= 1) _scheduleVideoSeek(target, clip, offset);
else _goto(clip, offset, false);
}
_syncInsAudio();
_notifyPh();
}
function _seek(sec) {
_pendingSeekSec = Math.max(0, sec);
if (_seekRaf) return;
_seekRaf = requestAnimationFrame(() => {
_seekRaf = null;
const s = _pendingSeekSec;
_pendingSeekSec = null;
if (s == null) return;
_seekNow(s);
});
}
function _flushSeek() {
if (_seekRaf) {
cancelAnimationFrame(_seekRaf);
_seekRaf = null;
}
if (_pendingSeekSec != null) {
const s = _pendingSeekSec;
_pendingSeekSec = null;
_seekNow(s);
}
_flushVideoSeek(true);
}
function _beginScrub() { _scrubDepth++; }
function _endScrub() {
_scrubDepth = Math.max(0, _scrubDepth - 1);
_flushSeek();
}
function _play() {
// At (or past) the end of the timeline → loop the playhead back to the start.
if (_phSec >= (_totalSecCur || 0) - 0.05 || !_clipFrom(_phSec)) _phSec = 0;
@@ -514,6 +658,9 @@
// ── public Player API (consumed by timeline.js + inspector) ──────────────
window.Player = {
seek: _seek,
flushSeek: _flushSeek,
beginScrub: _beginScrub,
endScrub: _endScrub,
getPlayhead: () => _phSec,
play: _play,
pause: _pause,
@@ -681,7 +828,7 @@
}).join("|");
if (hash !== _lastSegHash) {
_lastSegHash = hash;
if (!st.mediaPreviewId) {
if (!st.mediaPreviewId && _scrubDepth === 0) {
const clip = _clipAt(_phSec);
if (clip) _goto(clip, _phSec - clip.startSec, _playing);
else if (_clips.length && !_active) _goto(_clips[0], 0, false);
@@ -692,6 +839,12 @@
const gen = st.gen || { state: "idle", media: [] };
_fpsCur = p?.frame_rate || 25;
// While scrubbing, keep the program monitor DOM stable (no clear/rebuild).
if (_scrubDepth > 0) {
_totalSecCur = S.previewTotalSec ? S.previewTotalSec() : 0;
return;
}
clear(body);
const previewAsset = st.mediaPreviewId
@@ -826,6 +979,7 @@
// Drag-to-scrub on minibar
chip.addEventListener("mousedown", (e) => {
e.preventDefault();
_beginScrub();
const scrub = (ev) => {
const r = minibar.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width));
@@ -833,7 +987,11 @@
};
scrub(e);
const move = (ev) => scrub(ev);
const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); };
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
_endScrub();
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
});
+30 -15
View File
@@ -2071,22 +2071,22 @@
});
}
async function _pollRenderJob(jobId, clipCount) {
async function _pollFfmpegJob(statusFn, jobId, clipCount, busyLabel) {
let transientStreak = 0;
for (;;) {
if (_interrupted) throw new Error("Interrupted.");
try {
const st = await API.renderFinalStatus(state.project.id, jobId);
const st = await statusFn(jobId);
transientStreak = 0;
if (st.state === "done") return st;
if (st.state === "error") throw new Error(st.detail || "Render failed");
updateGenProgress({ msg: `Stitching ${clipCount} clip(s)…` });
if (st.state === "error") throw new Error(st.detail || `${busyLabel} failed`);
updateGenProgress({ msg: `${busyLabel} ${clipCount} clip(s)…` });
} catch (e) {
if (_isTransientTunnelError(e)) {
transientStreak++;
if (transientStreak < 120) {
updateGenProgress({
msg: `Stitching ${clipCount} clip(s) · tunnel reconnect (${transientStreak})…`,
msg: `${busyLabel} ${clipCount} clip(s) · tunnel reconnect (${transientStreak})…`,
});
await _sleep(2000);
continue;
@@ -2098,6 +2098,24 @@
}
}
async function _pollRenderJob(jobId, clipCount) {
return _pollFfmpegJob(
(id) => API.renderFinalStatus(state.project.id, id),
jobId,
clipCount,
"Stitching",
);
}
async function _pollExportClipsJob(jobId, clipCount) {
return _pollFfmpegJob(
(id) => API.exportClipsStatus(state.project.id, id),
jobId,
clipCount,
"Combining",
);
}
// Toggle a pending Studio session reset — applied to the FIRST run of the next
// generation. Clicking again disarms it (in case of a mis-click).
let _resetSessionPending = false;
@@ -2289,13 +2307,11 @@
return _renderClips().filter((c) => set.has(c.sceneId));
}
function _renderPayloadFromClips(rc) {
function _plainExportClipsFromRenderClips(rc) {
return rc.map((c) => ({
filename: c.media?.filename, subfolder: c.media?.subfolder || "", type: c.media?.type || "output",
bin_media_ref: c.bin_media_ref || "",
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, scene_id: c.sceneId,
}));
}
@@ -2304,7 +2320,7 @@
return `${proj}_${clipCount}clips_${_stamp()}.mp4`;
}
// Trim one clip, or stitch multiple selected clips (same ffmpeg path as Render Final).
// Trim one clip, or hard-cut concat multiple selected clips (no effects/transitions).
async function _stitchSelectedClips(saveableIds) {
const rc = _renderClipsForSceneIds(saveableIds);
if (!rc.length) return null;
@@ -2314,10 +2330,9 @@
const out = await API.exportClip(state.project.id, spec.clip);
return { media: out.media, clips: 1, exportName: spec.name };
}
await flushSave();
const queued = await API.renderFinal(state.project.id, _renderPayloadFromClips(rc));
const queued = await API.exportClipsCombined(state.project.id, _plainExportClipsFromRenderClips(rc));
const r = queued?.job_id
? await _pollRenderJob(queued.job_id, rc.length)
? await _pollExportClipsJob(queued.job_id, rc.length)
: queued;
return { ...r, exportName: _combinedSelectionFilename(rc.length) };
}
@@ -2396,7 +2411,7 @@
return !!(state.sceneRenders[sceneId]?.media);
}
// Export selected clip(s): one file; multi-select is stitched with trim/effects/seams.
// Export selected clip(s): one file; multi-select is hard-cut concat with trim only.
async function exportSelected() {
if (!state.project) return;
if (!_hasTimelineClipSelection()) { alert("Select a clip first."); return; }
@@ -2411,7 +2426,7 @@
`${skipped} selected clip(s) have no render yet and will be skipped. Export ${saveableIds.length} clip(s)?`
)) return;
if (saveableIds.length > 1 && !confirm(
`Stitch ${saveableIds.length} selected clips into one file and export?\n\nPer-clip trim, effects, and transitions between selected clips are applied.`
`Combine ${saveableIds.length} selected clips into one file and export?\n\nTimeline trim is applied per clip. No effects, transitions, or extra audio — use Render Final for that.`
)) return;
const showProgress = saveableIds.length > 1;
if (showProgress) {
@@ -2444,7 +2459,7 @@
`${skipped} selected clip(s) have no render yet and will be skipped. Save ${saveableIds.length} clip(s) to Media bin?`
)) return;
if (saveableIds.length > 1 && !confirm(
`Stitch ${saveableIds.length} selected clips into one file and save to Media bin?\n\nPer-clip trim, effects, and transitions between selected clips are applied.`
`Combine ${saveableIds.length} selected clips into one file and save to Media bin?\n\nTimeline trim is applied per clip. No effects, transitions, or extra audio — use Render Final for that.`
)) return;
const showProgress = saveableIds.length > 1;
if (showProgress) {
+8 -3
View File
@@ -314,14 +314,14 @@
? "Select a clip first"
: saveable
? (multi
? `Stitch ${saveableN} selected clips into one file and export (trim, effects, seams)`
? `Combine ${saveableN} selected clips into one file (trim only, hard cuts)`
: "Save the selected clip's rendered video to disk (renders are temporary)")
: "Generate the clip first, or select a video clip with media";
const binTitle = !hasSel
? "Select a clip first"
: saveable
? (multi
? `Stitch ${saveableN} selected clips into one file and save to Media bin`
? `Combine ${saveableN} selected clips into one file and save to Media bin (trim only)`
: "Copy the selected clip into the Media bin - add it back as a plain video clip")
: "Generate the clip first, or select a video clip with media";
return { exportTitle, binTitle };
@@ -1318,6 +1318,7 @@
// updates just the needle + timecode element on every mousemove).
ruler.addEventListener("mousedown", (e) => {
e.preventDefault();
window.Player?.beginScrub?.();
const scrub = (ev) => {
const r = ruler.getBoundingClientRect();
const sec = Math.max(0, Math.min((ev.clientX - r.left) / pxPerSec, totalSec));
@@ -1325,7 +1326,11 @@
};
scrub(e);
const move = (ev) => scrub(ev);
const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); };
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
window.Player?.endScrub?.();
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
});
+103
View File
@@ -583,6 +583,76 @@ class RenderJobError(Exception):
_render_jobs: dict[str, dict] = {}
_export_jobs: dict[str, dict] = {}
def _ffmpeg_concat_clips_plain(clips: list) -> dict:
"""Join trimmed clips with hard cuts only — no effects, transitions, or extra audio mix."""
import os
import shutil
import subprocess
import time as _time
if not clips:
raise RenderJobError("Nothing to export.")
ff = shutil.which("ffmpeg")
if not ff:
raise RenderJobError("ffmpeg not found on PATH — install it to export clips.")
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception as e: # noqa: BLE001
raise RenderJobError(f"Output directory unavailable: {e}") from e
stamp = int(_time.time())
if len(clips) == 1:
out_name = f"funpack_export_{stamp}.mp4"
out_path = os.path.join(tempdir, out_name)
try:
src_path = _resolve_clip_src_path(clips[0])
_ffmpeg_trim_clip(src_path, out_path, clips[0].get("in"), clips[0].get("dur"))
except FileNotFoundError as e:
raise RenderJobError(str(e)) from e
except RuntimeError as e:
raise RenderJobError(str(e)) from e
return {
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": 1,
}
seg_paths: list[str] = []
for i, c in enumerate(clips):
try:
src_path = _resolve_clip_src_path(c)
seg = os.path.join(tempdir, f"funpack_seg_{stamp}_{i}.mp4")
_ffmpeg_trim_clip(src_path, seg, c.get("in"), c.get("dur"))
seg_paths.append(seg)
except FileNotFoundError as e:
raise RenderJobError(str(e)) from e
except RuntimeError as e:
raise RenderJobError(str(e)) from e
out_name = f"funpack_export_{stamp}.mp4"
out_path = os.path.join(tempdir, out_name)
list_path = os.path.join(tempdir, f"funpack_concat_{stamp}.txt")
with open(list_path, "w", encoding="utf-8") as f:
for p in seg_paths:
esc = p.replace("'", "'\\''")
f.write(f"file '{esc}'\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 "")[-1000:]
raise RenderJobError(f"ffmpeg concat failed: {tail}")
return {
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": len(clips),
}
def _ffmpeg_stitch_final(proj, clips: list) -> dict:
@@ -1307,6 +1377,39 @@ if web is not None and PromptServer is not None:
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
})
import asyncio
import uuid as _uuid_export
async def _run_export_concat_job(job_id: str, clips: list) -> None:
_export_jobs[job_id] = {"state": "running"}
try:
result = await asyncio.to_thread(_ffmpeg_concat_clips_plain, clips)
_export_jobs[job_id] = {"state": "done", **result}
except RenderJobError as e:
_export_jobs[job_id] = {"state": "error", "detail": str(e)}
except Exception as e: # noqa: BLE001
_export_jobs[job_id] = {"state": "error", "detail": f"Export failed: {e}"}
@routes.post(UI_PREFIX + "/api/projects/{pid}/export-clips")
async def _export_clips(req):
_project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
clips = body.get("clips") or []
if not clips:
return web.json_response({"detail": "Nothing to export."}, status=400)
job_id = _uuid_export.uuid4().hex
_export_jobs[job_id] = {"state": "queued"}
asyncio.create_task(_run_export_concat_job(job_id, clips))
return web.json_response({"job_id": job_id})
@routes.get(UI_PREFIX + "/api/projects/{pid}/export-clips/{job_id}")
async def _export_clips_status(req):
_project_or_404(req.match_info["pid"])
job = _export_jobs.get(req.match_info["job_id"])
if not job:
return web.json_response({"detail": "Export job not found."}, status=404)
return web.json_response(job)
# --- API: models / pluggable node slots ---
@routes.get(UI_PREFIX + "/api/node-roles")
async def _node_roles(_req):