Files
olivv-cs-ComfyUI-FunPack/easy_gen/frontend/preview.js
T
digital-garbageandClaude Opus 5 5aac3cc5c1 Easy Gen: re-attach to a running generation after a page reload
Easy Gen had no equivalent of the Cutting Room's reconnect-after-reload: reloading
mid-run left Generate re-enabled with no Interrupt and no progress, while the run
carried on server-side.

Nothing was missing on the backend. Easy Gen queues through the Editor's own
/generate route, so its prompts already carry the editor client_id + funpack run
identity, already come back from GET /api/active, and api.js already exposed
active(). Easy Gen simply never asked.

app.js gains reattachRunning(), run after the project is restored on boot:
- Run targets the loaded project -> full re-attach through the existing
  preview.watch(), so the result still lands in the preview when it finishes.
- Run targets something else (typically a montage queued from the Cutting Room)
  -> preview.monitor(), a new queue watcher over /api/active that shows progress
  and Interrupt without claiming the output. It deliberately does NOT switch
  projects out from under the user to take ownership of a foreign run.
- Loops while further runs are queued behind the running one, so Generate stays
  blocked until the queue drains.

Also fixes a live-run bug found on the way: render() ran on every store notify
(health poll every 15s, every prompt keystroke) and unconditionally set
generateBtn.disabled = !project — re-enabling Generate underneath a generation
that was still going. Both paths now go through one setGenerating() flag, which
render() respects, and a project switch mid-run no longer tears down the poll.

preview.js: watch() takes an optional `note` for the initial label so a re-attach
says "Reconnected after reload" instead of "Starting…"; the Interrupt wiring is
shared between watch() and monitor() as _armStopButton().

Verified in the local ComfyUI: page boots clean (only ComfyUI's own pre-existing
console errors), /api/active is polled on boot, monitor() stays pending with an
armed Interrupt while a job reports running and resolves + hides when it leaves
the queue, and Generate survives a store notify. The full-reattach branch reuses
the already-validated preview.watch() path but could not be exercised end to end
locally, since a real run needs the rental GPU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:37:18 +03:00

276 lines
10 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();
}
// Wire the Interrupt button for whatever run is being followed. Returns a getter for
// the "the user asked for this" flag, since an interrupted job is indistinguishable
// from a failed one at the API level and both callers below need to tell them apart.
function _armStopButton(onFlag) {
stopBtn.disabled = false;
stopBtn.textContent = "■ Interrupt";
stopBtn.onclick = async () => {
onFlag(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.
onFlag(false);
stopBtn.disabled = false;
stopBtn.textContent = "■ Interrupt";
label.textContent = "Could not interrupt: " + (e.message || e);
}
};
}
// Follow a run we cannot map to the loaded project — one queued from the Cutting Room,
// or from Easy Gen under a different project before a reload. There is no history to
// poll for a result here, so this just watches ComfyUI's queue via /api/active: enough
// to keep Generate blocked and offer Interrupt while the GPU is busy.
function monitor(promptId, note) {
return new Promise((resolve) => {
stopPolling();
errorMsg = null;
progressWrap.hidden = false;
label.textContent = note || "A generation is running…";
bar.style.width = "0%";
let interrupted = false;
_armStopButton((v) => { interrupted = v; });
const tick = async () => {
let act = null;
try { act = await API.active(); }
catch (_) { pollTimer = setTimeout(tick, 2000); return; } // transient — keep watching
if (!act || !act.running || act.prompt_id !== promptId) {
stopPolling();
progressWrap.hidden = true;
resolve();
return;
}
if (!interrupted) {
try {
const pr = await API.progress();
if (pr.max > 0) {
bar.style.width = Math.min(100, Math.round((pr.value / pr.max) * 100)) + "%";
label.textContent = `${note || "A generation is running"} · ${pr.value}/${pr.max}`;
}
} catch (_) { /* transient */ }
}
pollTimer = setTimeout(tick, 2000);
};
tick();
});
}
async function watch(projectId, promptId, { onDone, note } = {}) {
stopPolling();
errorMsg = null;
progressWrap.hidden = false;
label.textContent = note || "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;
_armStopButton((v) => { interrupted = v; });
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,
monitor,
stopPolling,
};
}
window.EasyPreview = { create };
})();