Files
olivv-cs-ComfyUI-FunPack/easy_gen/frontend/preview.js
T
digital-garbageandClaude 3222fbcb31 Easy Gen: Interrupt button on the progress panel
Easy Gen had no way to stop a run — the only exit was the ComfyUI queue. Adds
the same "■ Interrupt" control the Cutting Room's player readout uses (shared
.gen-interrupt styling), living inside the progress panel so it exists only
while there is something to stop.

ComfyUI records an interrupted prompt in history as status_str "error" with no
media, which the status endpoint cannot tell apart from a real crash — so watch()
remembers that the stop was asked for and reports "Generation stopped." instead
of "Generation failed.", while still showing any partial media the run wrote.
A failed interrupt call re-enables the button and says so rather than leaving a
dead control over a run that is still going.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 20:22:48 +03:00

230 lines
8.3 KiB
JavaScript

// Big preview pane: plain <video>/<img>, not the Editor's timeline-oriented
// player.js. Polls /api/progress (step count) while a run is in flight and
// /api/projects/{id}/status/{prompt_id} for the final result.
//
// Display priority: generated result > uploaded source > empty message. The
// corner ✕ closes whichever of those two media layers is currently showing —
// closing a generated result reveals the uploaded source underneath (if any),
// closing the uploaded source clears the upload itself (via onClearUpload).
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
function create(root) {
const inner = el("div", "easy-preview-inner");
const closeBtn = el("button", "easy-preview-close", "✕");
closeBtn.type = "button";
closeBtn.title = "Close";
closeBtn.hidden = true;
const badge = el("div", "easy-preview-badge", "Uploaded source");
badge.hidden = true;
const progressWrap = el("div", "easy-progress");
progressWrap.hidden = true;
const bar = el("div", "easy-progress-bar");
const label = el("div", "easy-progress-label");
// Same "■ Interrupt" affordance as the Cutting Room's player readout, and it lives in the
// progress panel so it only exists while there's something to stop.
const stopBtn = el("button", "gen-interrupt easy-progress-stop", "■ Interrupt");
stopBtn.type = "button";
stopBtn.title = "Stop the current generation";
const progressRow = el("div", "easy-progress-row");
progressRow.append(label, stopBtn);
progressWrap.append(bar, progressRow);
root.append(inner, closeBtn, badge, progressWrap);
let pollTimer = null;
let progressTimer = null;
// generated/upload hold the last-drawn payload so the ✕ handler knows what
// layer it's dismissing; emptyMsg is what to fall back to once both are gone.
let generated = null; // { media, projectId } | null
let upload = null; // { url, kind } | null
let errorMsg = null;
let emptyMsg = "No generation yet";
let onClearUpload = null;
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer);
if (progressTimer) clearInterval(progressTimer);
pollTimer = progressTimer = null;
}
function mediaEl(url, kind, opts = {}) {
if (kind === "video") {
const video = el("video");
video.src = url;
video.controls = true;
// Not the `autoplay` attribute: that makes the browser call .play() internally, and
// if this element gets replaced/removed before the fetch starts playing (e.g. the
// user immediately picks a different Gallery item), that internal promise rejects
// with "fetching process...aborted by the user agent" — surfacing as an unhandled
// rejection banner, since we never had a handle on it to catch. Call .play()
// ourselves so we own the promise.
if (opts.autoplay) {
video.loop = true;
video.play().catch(() => {});
}
return video;
}
const img = el("img");
img.src = url;
return img;
}
// The store notifies on every health poll / prompt keystroke, and each of those calls
// setUpload()/render() again — without this, draw() rebuilt the <video> from scratch
// every time (new element = autoplay/loop reapplied), silently resuming anything the
// user had paused. Skip the rebuild when the content actually shown hasn't changed.
let _lastSig = null;
function _sig() {
if (errorMsg) return "error:" + errorMsg;
if (generated) return "generated:" + generated.projectId + ":" + generated.media.filename;
if (upload) return "upload:" + upload.url + ":" + upload.kind;
return "empty:" + emptyMsg;
}
function draw() {
const sig = _sig();
if (sig === _lastSig) return;
_lastSig = sig;
clear(inner);
badge.hidden = true;
if (errorMsg) {
closeBtn.hidden = true;
inner.append(el("div", "easy-preview-empty easy-preview-error", errorMsg));
} else if (generated) {
closeBtn.hidden = false;
const url = API.resultUrl(generated.projectId, generated.media);
inner.append(mediaEl(url, generated.media.kind === "images" ? "image" : "video", { autoplay: true }));
} else if (upload) {
closeBtn.hidden = false;
badge.hidden = false;
inner.append(mediaEl(upload.url, upload.kind));
} else {
closeBtn.hidden = true;
inner.append(el("div", "easy-preview-empty", emptyMsg));
}
}
closeBtn.onclick = () => {
if (generated) {
generated = null;
draw();
} else if (upload) {
upload = null;
draw();
if (onClearUpload) onClearUpload();
}
};
function setUpload(url, kind) {
upload = url ? { url, kind } : null;
draw();
}
function setGenerated(media, projectId) {
generated = media ? { media, projectId } : null;
draw();
}
function showEmpty(msg) {
errorMsg = null;
generated = null;
emptyMsg = msg || "No generation yet";
draw();
}
function showError(msg) {
errorMsg = msg;
draw();
}
async function watch(projectId, promptId, { onDone } = {}) {
stopPolling();
errorMsg = null;
progressWrap.hidden = false;
label.textContent = "Starting…";
bar.style.width = "0%";
// An interrupted job lands in ComfyUI's history as status_str "error" with no media —
// indistinguishable from a real failure at the API level. Remember that the user asked
// for it so the poll below reports "stopped", not "Generation failed".
let interrupted = false;
stopBtn.disabled = false;
stopBtn.textContent = "■ Interrupt";
stopBtn.onclick = async () => {
interrupted = true;
stopBtn.disabled = true;
stopBtn.textContent = "Interrupting…";
label.textContent = "Interrupting…";
try { await API.interrupt(); }
catch (e) {
// The run may well still be going — say so instead of leaving a dead button.
interrupted = false;
stopBtn.disabled = false;
stopBtn.textContent = "■ Interrupt";
label.textContent = "Could not interrupt: " + (e.message || e);
}
};
progressTimer = setInterval(async () => {
if (interrupted) return; // don't paint "Sampling…" over "Interrupting…"
try {
const pr = await API.progress();
if (pr.max > 0) {
const pct = Math.min(100, Math.round((pr.value / pr.max) * 100));
bar.style.width = pct + "%";
label.textContent = `Sampling… ${pr.value}/${pr.max}`;
}
} catch (_) { /* transient */ }
}, 700);
const poll = async () => {
let res;
try {
res = await API.status(projectId, promptId);
} catch (e) {
stopPolling();
progressWrap.hidden = true;
showError("Status check failed: " + (e.message || e));
if (onDone) onDone(false);
return;
}
if (res.state === "completed") {
stopPolling();
progressWrap.hidden = true;
const media = res.media && res.media.length ? res.media[res.media.length - 1] : null;
// A run stopped mid-sampling can still have written partial media — show it if so,
// it's the whole point of interrupting a long run you can already tell is wrong.
if (media) setGenerated(media, projectId);
else if (interrupted) showEmpty("Generation stopped.");
else showEmpty("Generation finished but produced no media.");
if (onDone) onDone(!interrupted, res);
return;
}
if (res.state === "error") {
stopPolling();
progressWrap.hidden = true;
if (interrupted) showEmpty("Generation stopped.");
else showError(res.error || "Generation failed.");
if (onDone) onDone(false, res);
return;
}
pollTimer = setTimeout(poll, 1200);
};
poll();
}
return {
setUpload,
setGenerated,
setOnClearUpload(fn) { onClearUpload = fn; },
showEmpty,
showError,
watch,
stopPolling,
};
}
window.EasyPreview = { create };
})();