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:
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user