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>
276 lines
10 KiB
JavaScript
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 };
|
|
})();
|