diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index 546487c7..8728e88f 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -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} diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 3dab45ff..06946454 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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 }), diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html index ba317f97..3d5f67d7 100644 --- a/movie_editor/frontend/index.html +++ b/movie_editor/frontend/index.html @@ -126,6 +126,7 @@ + diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js index fede0cfe..79707196 100644 --- a/movie_editor/frontend/menubar.js +++ b/movie_editor/frontend/menubar.js @@ -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(), diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 54b977dc..4e9415c1 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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 = { diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 13104d59..cb79de0c 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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; } diff --git a/movie_editor/frontend/temp_browser.js b/movie_editor/frontend/temp_browser.js new file mode 100644 index 00000000..ce10515c --- /dev/null +++ b/movie_editor/frontend/temp_browser.js @@ -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 }; +})(); diff --git a/movie_editor/server.py b/movie_editor/server.py index 239e2825..92b4ff75 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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 diff --git a/movie_editor/tests/test_bridge.py b/movie_editor/tests/test_bridge.py index d0af80d1..c8f52690 100644 --- a/movie_editor/tests/test_bridge.py +++ b/movie_editor/tests/test_bridge.py @@ -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 = { diff --git a/movie_editor/tests/test_server.py b/movie_editor/tests/test_server.py index 782e7dd1..251b6d30 100644 --- a/movie_editor/tests/test_server.py +++ b/movie_editor/tests/test_server.py @@ -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__"