editor: reconnect to running generation after UI reload + Temp files browser

Reconnect-after-reload: editor-queued prompts are now stamped with a stable
client_id and a funpack run-identity payload in ComfyUI's queue extra_data.
On boot the editor checks /api/active, re-attaches to any still-running run
(loading its project, blocking Generate, exposing Interrupt, recording the
result), and loops over queued montage runs until the queue drains. Run
identity lives in ComfyUI's ephemeral queue, not a persistent cache.

Temp files browser (Settings ▸ Temp files): media-bin-style grid over
ComfyUI's temp dir for scene previews and other transient outputs. Open in a
new tab (same-origin /view) or save to disk. No in-app player by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-29 15:23:48 +03:00
co-authored by Claude Opus 4.8
parent 0baf5de91f
commit 1340070aed
10 changed files with 457 additions and 4 deletions
+54 -1
View File
@@ -497,8 +497,16 @@ class ComfyError(RuntimeError):
pass
async def queue_prompt(graph: dict, client_id: Optional[str] = None) -> dict:
# Stable client_id stamped on every editor-queued prompt so a reloaded UI can tell its
# own in-flight generation apart from unrelated ComfyUI jobs (see active_generation).
EDITOR_CLIENT_ID = "funpack-movie-editor"
async def queue_prompt(graph: dict, client_id: Optional[str] = None,
extra_data: Optional[dict] = None) -> dict:
payload = {"prompt": graph, "client_id": client_id or uuid.uuid4().hex}
if extra_data:
payload["extra_data"] = dict(extra_data)
async with await _session() as s:
async with s.post(_url("/prompt"), json=payload) as r:
data = await r.json()
@@ -588,6 +596,51 @@ async def is_running(prompt_id: str) -> bool:
return False
def _queue_item_extra(item: list) -> dict:
"""extra_data dict from a /queue tuple [number, prompt_id, prompt, extra_data, ...]."""
extra = item[3] if len(item) > 3 else None
return extra if isinstance(extra, dict) else {}
def _select_active(queue_state: dict) -> dict:
"""Pick the editor's own in-flight generation out of a /queue snapshot.
Filters by EDITOR_CLIENT_ID so unrelated ComfyUI jobs are ignored, and reads back the
``funpack`` payload (project id + target scenes) stamped at queue time so a reloaded
client can re-attach and record the result onto the right scenes."""
running, pending = [], []
for item in queue_state.get("queue_running", []) or []:
if _queue_item_extra(item).get("client_id") == EDITOR_CLIENT_ID:
running.append(item)
for item in queue_state.get("queue_pending", []) or []:
if _queue_item_extra(item).get("client_id") == EDITOR_CLIENT_ID:
pending.append(item)
out = {"running": bool(running), "prompt_id": None, "pid": None,
"scene_ids": [], "only_scene": None, "pending": len(pending)}
if running:
item = running[0]
out["prompt_id"] = item[1] if len(item) > 1 else None
fp = _queue_item_extra(item).get("funpack") or {}
if isinstance(fp, dict):
out["pid"] = fp.get("pid")
out["scene_ids"] = fp.get("scene_ids") or []
out["only_scene"] = fp.get("only_scene")
return out
async def active_generation() -> dict:
"""The editor's own in-flight generation, recovered from ComfyUI's queue.
Survives a UI reload: the queue lives in the (still-running) ComfyUI process, so the
reloaded client can re-block Generate, expose Interrupt, and record the result when it
finishes. See _select_active for the filtering."""
async with await _session() as s:
async with s.get(_url("/queue")) as r:
r.raise_for_status()
state = await r.json()
return _select_active(state)
_object_info_cache = {"data": None}
+22
View File
@@ -139,9 +139,31 @@
j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null, scene_ids: sceneIds || null, reset_session: !!resetSession, node_overrides: nodeOverrides || null }),
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
progress: () => j("GET", API("/progress")),
// The editor's own in-flight generation, recovered from ComfyUI's queue (survives a UI reload).
active: () => j("GET", API("/active")),
ratingLabels: () => j("GET", API("/rating-labels")),
log: (limit) => j("GET", API("/log" + (limit ? `?limit=${limit}` : ""))),
interrupt: () => j("POST", API("/interrupt")),
// ComfyUI temp folder browser (scene previews & other transient outputs, wiped on restart).
listTemp: () => j("GET", API("/temp")),
// Served by ComfyUI's own same-origin /view endpoint — no editor route needed.
tempFileUrl: (f) => "/view?" + new URLSearchParams({
filename: f.filename, subfolder: f.subfolder || "", type: "temp", t: String(f.mtime || Date.now()),
}).toString(),
async downloadTempFile(f) {
const res = await fetch(this.tempFileUrl(f), { cache: "no-store" });
if (!res.ok) throw new Error(`Could not fetch ${f.filename} (HTTP ${res.status})`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = f.filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
},
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 }),
+1
View File
@@ -126,6 +126,7 @@
<script src="sampler_panel.js"></script>
<script src="engine_settings.js"></script>
<script src="editor_settings.js"></script>
<script src="temp_browser.js"></script>
<script src="autocomplete.js"></script>
<script src="slot_picker.js"></script>
<script src="welcome.js"></script>
+1
View File
@@ -289,6 +289,7 @@
{ label: "Editor settings…", action: () => window.EditorSettingsModal.open() },
{ label: "Engine settings…", disabled: !hasProject() || (!studioOn && !chainOn),
action: () => window.EngineSettingsModal.open() },
{ label: "Temp files…", action: () => window.TempBrowserModal.open() },
{ sep: true },
{ label: "Models…", action: () => window.ModelsModal.open() },
{ label: "Import ComfyUI Workflow…", disabled: !hasProject(),
+81 -1
View File
@@ -2802,6 +2802,70 @@
try { await API.interrupt(); } catch (_) {}
}
// Re-attach to a generation that was already running when the UI (re)loaded. The job
// lives in ComfyUI's still-running queue; bridge.active_generation hands back its
// prompt id + the project/scenes it targets. We restore the "running" gen state so
// Generate stays blocked and Interrupt is offered — exactly like a live run — and,
// when the matching project is loaded, record the result onto its scenes on completion.
async function resumeRunningGeneration(act) {
if (!act || !act.running || !act.prompt_id) return false;
_interrupted = false;
// A reloaded montage can have further runs still queued behind the running one. Re-attach
// to each in turn so Generate stays blocked (and outputs get recorded) until the queue
// drains — not just for the one prompt that happened to be running at reload.
let cur = act;
let lastPromptId = null;
while (cur && cur.running && cur.prompt_id && cur.prompt_id !== lastPromptId && !_interrupted) {
lastPromptId = cur.prompt_id;
pollStart = Date.now();
const sceneIds = (cur.scene_ids && cur.scene_ids.length)
? cur.scene_ids
: (cur.only_scene ? [cur.only_scene] : []);
const more = cur.pending > 0 ? ` · ${cur.pending} run(s) queued` : "";
set({ gen: { state: "running", promptId: cur.prompt_id, media: [], msg: `Generation running (reconnected after reload)…${more}` } });
if (state.project && cur.pid && state.project.id === cur.pid && sceneIds.length) {
// Full re-attach: poll history and place the output on the run's scenes when done.
_markGenInFlight(sceneIds);
try { await _pollPromise(cur.prompt_id, sceneIds, "Generation running (reconnected)"); }
finally { _clearGenInFlight(sceneIds); }
} else {
// No matching project loaded (or single-scene run on another project): we can't map
// the output, but still surface Interrupt and clear busy when it stops.
await _monitorActive(cur.prompt_id);
}
if (_interrupted) break;
// Another queued run may now be executing — re-attach to it.
try { cur = await API.active(); } catch (_) { cur = null; }
}
return true;
}
// Lightweight queue watcher used when we can't fully re-attach (see above). Polls
// /active until the prompt leaves the queue or the user interrupts.
function _monitorActive(promptId) {
return new Promise((resolve) => {
_clearGenTimers();
pollTimer = setInterval(async () => {
if (_interrupted) {
_clearGenTimers();
set({ gen: { state: "idle", promptId, media: [], msg: "Interrupted." } });
resolve();
return;
}
try {
const a = await API.active();
if (!a || !a.running || a.prompt_id !== promptId) {
_clearGenTimers();
set({ gen: { state: "done", promptId, media: [], msg: "Generation finished. Open Settings ▸ Temp files to view or save the output, then regenerate to place it." } });
resolve();
} else {
updateGenProgress({ msg: `Generation running (reconnected) ${_elapsed()}` });
}
} catch (_) { /* transient — keep watching */ }
}, 2000);
});
}
function _elapsed() {
const s = Math.floor((Date.now() - pollStart) / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
@@ -4031,8 +4095,24 @@
await loadModels();
window.addEventListener("funpack-models-changed", loadModels);
await refreshProjectList();
// Re-attach to a generation already running in ComfyUI (UI was reloaded mid-run).
// Load its project first so Generate is blocked, Interrupt is shown, and the result
// lands on the right scenes — then skip the welcome page, there's work in flight.
let resumed = false;
try {
const act = await API.active();
if (act && act.running && act.prompt_id) {
if (act.pid && (state.projects || []).some((p) => p.id === act.pid)
&& (!state.project || state.project.id !== act.pid)) {
await loadProject(act.pid);
}
notify();
resumeRunningGeneration(act); // fire-and-forget poll loop
resumed = true;
}
} catch (_) { /* queue unreachable — boot normally */ }
notify();
if (window.WelcomePage) window.WelcomePage.open();
if (!resumed && window.WelcomePage) window.WelcomePage.open();
}
window.Store = {
+12 -1
View File
@@ -1151,7 +1151,18 @@ body.tl-spacing { cursor: grabbing; user-select: none; }
.engine-modal-summary { font-size: 12px; color: var(--muted); font-family: var(--font-mono); margin-bottom: 12px; padding: 8px 10px; background: var(--ink-2); border-radius: 8px; border: 1px solid var(--line); }
.modal-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: linear-gradient(#1b1813, #161410); }
.modal-title { font-family: var(--font-display); font-weight: 700; font-size: 17px; }
.modal-head-right { margin-left: auto; display: flex; gap: 8px; }
.modal-head-right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
/* Temp files browser */
.tmp-count { white-space: nowrap; }
.tmp-body { margin-top: 12px; }
.tmp-grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); }
.tmp-card { cursor: default; padding-bottom: 6px; }
.tmp-card .media-thumb { height: 76px; }
.tmp-card .media-name { font-size: 11px; word-break: break-all; }
.tmp-meta { padding: 0 8px; }
.tmp-card-acts { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 8px 0; }
.tmp-card-acts .btn { flex: 1; white-space: nowrap; }
.modal-content { padding: 16px; overflow: auto; }
.composer { background: var(--ink-0); border: 1px solid var(--line); border-radius: 10px; padding: 14px; margin-bottom: 18px; }
+133
View File
@@ -0,0 +1,133 @@
// Temp files browser: a lightweight media-bin view over ComfyUI's temp directory
// (scene previews & other transient outputs that are wiped on restart). Opened from the
// Settings menu. Each item can be opened in a new tab or saved to disk — no in-app player.
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
let overlay = null;
let files = [];
function close() { if (overlay) { overlay.remove(); overlay = null; } }
function _fmtSize(n) {
if (!n && n !== 0) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function _appendThumb(thumb, f) {
const url = API.tempFileUrl(f);
if (f.kind === "image") {
const img = el("img");
img.src = url; img.loading = "lazy";
thumb.append(img);
return;
}
if (f.kind === "video") {
const ph = el("span", "media-icon media-vid-ph", "▶");
thumb.append(ph);
const vid = document.createElement("video");
vid.className = "media-vid-thumb";
vid.muted = true; vid.preload = "metadata"; vid.playsInline = true;
vid.src = url;
vid.onloadeddata = () => {
if (!thumb.isConnected) return;
ph.remove();
if (!thumb.querySelector("video")) thumb.append(vid);
};
return;
}
thumb.append(el("span", "media-icon media-aud-ph", "♪"));
}
function _card(f) {
const card = el("div", "media-card tmp-card");
const thumb = el("div", "media-thumb");
_appendThumb(thumb, f);
card.append(thumb);
const nameRow = el("div", "media-name-row");
const nameEl = el("div", "media-name", f.filename);
nameEl.title = f.subfolder ? `${f.subfolder}/${f.filename}` : f.filename;
nameRow.append(nameEl);
card.append(nameRow);
const meta = el("div", "pj-meta tmp-meta", _fmtSize(f.size));
card.append(meta);
const acts = el("div", "tmp-card-acts");
if (f.kind === "video" || f.kind === "image" || f.kind === "audio") {
const open = el("button", "btn ghost tiny", f.kind === "video" ? "↗ Open video" : "↗ Open");
open.title = "Open in a new browser tab";
open.onclick = () => window.open(API.tempFileUrl(f), "_blank");
acts.append(open);
}
const save = el("button", "btn ghost tiny", "⤓ Save");
save.title = "Download to your computer";
save.onclick = async () => {
save.disabled = true;
try { await API.downloadTempFile(f); }
catch (e) { alert("Save failed: " + (e.message || e)); }
finally { save.disabled = false; }
};
acts.append(save);
card.append(acts);
return card;
}
function renderBody(body) {
clear(body);
if (!files.length) {
body.append(el("div", "pj-meta media-grid-empty", "No temp media right now. Scene previews and other transient outputs appear here while ComfyUI is running."));
return;
}
const grid = el("div", "media-grid tmp-grid");
files.forEach((f) => grid.append(_card(f)));
body.append(grid);
}
async function refresh(body, countEl) {
body.append(el("div", "pj-meta", "Loading…"));
try {
files = (await API.listTemp()).files || [];
} catch (e) {
clear(body);
body.append(el("div", "pj-meta media-grid-empty", "Could not list temp files: " + (e.message || e)));
return;
}
if (countEl) countEl.textContent = files.length ? `${files.length} file(s)` : "";
clear(body);
renderBody(body);
}
function open() {
close();
overlay = el("div", "modal-overlay");
const box = el("div", "modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Temp files"));
const hr = el("div", "modal-head-right");
const countEl = el("span", "pj-meta tmp-count");
const refreshBtn = el("button", "btn ghost tiny", "⟳ Refresh");
const x = el("button", "btn ghost tiny", "✕"); x.onclick = close;
hr.append(countEl, refreshBtn, x);
head.append(hr); box.append(head);
const content = el("div", "modal-content");
content.append(el("div", "es-hint",
"Transient ComfyUI outputs — scene previews and the like. These are wiped when ComfyUI restarts, so save anything worth keeping."));
const body = el("div", "tmp-body");
content.append(body);
box.append(content);
overlay.append(box);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
refreshBtn.onclick = () => refresh(body, countEl);
refresh(body, countEl);
}
window.TempBrowserModal = { open, close };
})();
+74 -1
View File
@@ -105,6 +105,54 @@ def _resolve_comfy_media_path(filename: str, subfolder: str = "", type_: str = "
return os.path.join(base, subfolder or "", filename)
_TEMP_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
_TEMP_VIDEO_EXT = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".gif"}
_TEMP_AUDIO_EXT = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus"}
def _temp_kind(ext: str) -> Optional[str]:
ext = ext.lower()
if ext in _TEMP_VIDEO_EXT:
return "video"
if ext in _TEMP_IMAGE_EXT:
return "image"
if ext in _TEMP_AUDIO_EXT:
return "audio"
return None
def _list_temp_media() -> list[dict]:
"""Media files (image/video/audio) sitting in ComfyUI's temp directory — scene
previews and other transient outputs that are wiped on restart. Newest first."""
import os
try:
import folder_paths
base = folder_paths.get_temp_directory()
except Exception:
return []
out: list[dict] = []
for root, _dirs, files in os.walk(base):
for name in files:
kind = _temp_kind(os.path.splitext(name)[1])
if not kind:
continue
full = os.path.join(root, name)
try:
st = os.stat(full)
except OSError:
continue
subfolder = os.path.relpath(root, base)
out.append({
"filename": name,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, "/"),
"kind": kind,
"size": st.st_size,
"mtime": st.st_mtime,
})
out.sort(key=lambda i: i.get("mtime", 0), reverse=True)
return out
def _resolve_clip_src_path(clip: dict) -> str:
"""Absolute path to a render or media-bin file referenced by an export/import clip spec."""
import os
@@ -1538,8 +1586,17 @@ if web is not None and PromptServer is not None:
applied_ovr = _apply_node_overrides(graph, body.get("node_overrides"))
if applied_ovr:
report.setdefault("node_overrides", []).extend(applied_ovr)
# Stamp the queued prompt with our client_id + run identity so a reloaded UI can
# re-attach to this generation (block Generate, expose Interrupt, record the result).
fp_meta = {
"pid": p.id,
"scene_ids": list(scene_ids) if scene_ids else None,
"only_scene": body.get("only_scene") or None,
}
try:
result = await bridge.queue_prompt(graph)
result = await bridge.queue_prompt(
graph, client_id=bridge.EDITOR_CLIENT_ID, extra_data={"funpack": fp_meta},
)
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Failed to queue with ComfyUI: {e}"}, status=502)
prompt_id = result.get("prompt_id")
@@ -1621,6 +1678,22 @@ if web is not None and PromptServer is not None:
except Exception as e: # noqa: BLE001
return web.json_response({"detail": str(e)}, status=502)
@routes.get(UI_PREFIX + "/api/active")
async def _active(_req):
# The editor's own in-flight generation, recovered from ComfyUI's queue so a
# reloaded UI can re-attach (block Generate, expose Interrupt). Never raises —
# an unreachable queue just means "nothing we can re-attach to".
try:
return web.json_response(await bridge.active_generation())
except Exception as e: # noqa: BLE001
return web.json_response(
{"running": False, "prompt_id": None, "pid": None,
"scene_ids": [], "only_scene": None, "pending": 0, "error": str(e)})
@routes.get(UI_PREFIX + "/api/temp")
async def _temp_list(_req):
return web.json_response({"files": _list_temp_media()})
@routes.get(UI_PREFIX + "/api/projects/{pid}/result")
async def _result(req):
import os
+47
View File
@@ -20,6 +20,53 @@ def test_ensure_funpack_path_adds_repo_root():
assert root in sys.path
def _q_item(prompt_id, client_id, funpack=None):
extra = {"client_id": client_id}
if funpack is not None:
extra["funpack"] = funpack
return [1, prompt_id, {"graph": True}, extra, ["9"]]
def test_select_active_picks_editor_running_job_with_metadata():
state = {
"queue_running": [_q_item("p1", bridge.EDITOR_CLIENT_ID,
{"pid": "proj-7", "scene_ids": ["a", "b"], "only_scene": None})],
"queue_pending": [_q_item("p2", bridge.EDITOR_CLIENT_ID, {"pid": "proj-7"})],
}
out = bridge._select_active(state)
assert out["running"] is True
assert out["prompt_id"] == "p1"
assert out["pid"] == "proj-7"
assert out["scene_ids"] == ["a", "b"]
assert out["pending"] == 1
def test_select_active_ignores_foreign_client_jobs():
state = {
"queue_running": [_q_item("other", "some-comfy-tab")],
"queue_pending": [_q_item("other2", "some-comfy-tab")],
}
out = bridge._select_active(state)
assert out["running"] is False
assert out["prompt_id"] is None
assert out["pending"] == 0
def test_select_active_empty_queue():
out = bridge._select_active({})
assert out == {"running": False, "prompt_id": None, "pid": None,
"scene_ids": [], "only_scene": None, "pending": 0}
def test_select_active_running_without_funpack_meta():
state = {"queue_running": [_q_item("p1", bridge.EDITOR_CLIENT_ID)]}
out = bridge._select_active(state)
assert out["running"] is True
assert out["prompt_id"] == "p1"
assert out["pid"] is None
assert out["scene_ids"] == []
def test_rating_labels_hide_internal_editor_values(monkeypatch):
def _fake_attr(_mod, name):
table = {
+32
View File
@@ -28,6 +28,38 @@ from movie_editor.server import (
)
CONTINUE = "__funpack_continue__"
def test_temp_kind_classifies_media_and_skips_other():
assert srv._temp_kind(".mp4") == "video"
assert srv._temp_kind(".PNG") == "image"
assert srv._temp_kind(".wav") == "audio"
assert srv._temp_kind(".txt") is None
assert srv._temp_kind(".json") is None
def test_list_temp_media_lists_media_newest_first(tmp_path, monkeypatch):
(tmp_path / "old.png").write_bytes(b"x")
(tmp_path / "new.mp4").write_bytes(b"yy")
(tmp_path / "notes.txt").write_text("skip me")
sub = tmp_path / "previews"
sub.mkdir()
(sub / "clip.webm").write_bytes(b"zzz")
import os
os.utime(tmp_path / "old.png", (1000, 1000))
os.utime(tmp_path / "new.mp4", (2000, 2000))
os.utime(sub / "clip.webm", (1500, 1500))
fake = type("FP", (), {"get_temp_directory": staticmethod(lambda: str(tmp_path))})
monkeypatch.setitem(__import__("sys").modules, "folder_paths", fake)
files = srv._list_temp_media()
names = [f["filename"] for f in files]
assert "notes.txt" not in names
assert names == ["new.mp4", "clip.webm", "old.png"] # newest mtime first
clip = next(f for f in files if f["filename"] == "clip.webm")
assert clip["subfolder"] == "previews"
assert clip["kind"] == "video"
FRESH = "__funpack_fresh_prompt__"