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__"