Gallery: saved videos and pictures next to Temp files, with preview and confirmed delete

Settings > Saved & Temp Files now has two tabs. Saved lists what ComfyUI wrote to its output folder; Temp is the old list. Click a file to look at it, open it in a tab, or delete it after a question. Delete only removes media files inside the chosen folder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xHQk4XeXzJpBR3HSwnoBZ
This commit is contained in:
Claude
2026-10-17 21:17:31 +00:00
parent 6372f11758
commit 2921ff517b
6 changed files with 195 additions and 34 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ export default {
mount: "settings",
needs: ["dom"],
setup: ({ host, app }) => host.add({
id: "temp", group: "System", title: "Temp Files", subtitle: "Where a file went when it did not land in the bin.",
keywords: "temp files output directory", icon: "▥", mount: (ctx) => mount({ ...ctx, dom: app.dom }),
id: "temp", group: "System", title: "Saved & Temp Files", subtitle: "Your saved videos and pictures, and where a file went when it did not land in the bin.",
keywords: "gallery saved temp files output directory videos pictures delete", icon: "▥", mount: (ctx) => mount({ ...ctx, dom: app.dom }),
}),
};
+69 -23
View File
@@ -1,41 +1,87 @@
// Where a file went when it did not land in the bin. ComfyUI wipes its temp folder on restart, so
// this is a place to FIND something (opens in a new tab), not to manage it.
// Two places a file can be found: the gallery (what was saved, and stays) and the temp folder (what ComfyUI wipes on restart).
// Clicking a file looks at it here; it can be opened in its own tab, or deleted after a question.
import { composer } from "../../composer/composer.js";
const ICON = { image: "▦", video: "▶", audio: "♪" };
const url = (f) => `/view?${new URLSearchParams({ filename: f.filename, subfolder: f.subfolder || "", type: "temp" })}`;
const PLACES = [{ value: "output", label: "Saved" }, { value: "temp", label: "Temp files" }];
const EMPTY = { output: "Nothing saved yet", temp: "Nothing in the temp directory" };
const url = (f, where) => `/view?${new URLSearchParams({ filename: f.filename, subfolder: f.subfolder || "", type: where })}`;
const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`);
const when = (t) => new Date((Number(t) || 0) * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
const id = (f) => `${f.subfolder}/${f.filename}`;
export function mount({ dom, setFooter, close }) {
const body = composer.region.stack({ gap: "sm", fill: true, label: "Temp files" });
let where = "output", files = [], payload = {}, looking = null;
const body = composer.region.stack({ gap: "md", fill: true, label: "Files" });
const content = composer.region.stack({ gap: "sm", fill: true, label: "Files in this folder" });
const viewer = composer.viewer.cover({ onClose: () => { looking = null; content.node.hidden = false; footer(); } });
const tabs = composer.tabs.underline({ label: "Where to look", value: where, tabs: PLACES, onChange: (v) => { where = v; viewer.close(); load(); } });
body.set([tabs, content, viewer]);
async function load() {
body.set([composer.hint.default({ text: "Looking…" })]);
let payload;
function look(f) {
looking = f;
content.node.hidden = true; // the grid would show through below the picture
viewer.show({ src: url(f, where), kind: f.kind });
footer();
}
async function remove(f) {
const ok = await composer.modal.dialogue({ title: "Delete file", tone: "danger", confirmLabel: "Delete",
message: `Delete “${f.filename}” from ${where === "output" ? "your saved files" : "the temp folder"} for good? It does not go to the trash.` }).result;
if (!ok) return;
try {
payload = await (await fetch("/funpack/api/temp", { cache: "no-store" })).json();
const res = await fetch("/funpack/api/temp/delete", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ where, filename: f.filename, subfolder: f.subfolder || "" }) });
const out = await res.json().catch(() => ({}));
if (!res.ok || !out.deleted) throw new Error(out.detail || `HTTP ${res.status}`);
} catch (err) {
body.set([composer.banner.danger({ text: `Could not read the temp directory: ${err.message}` })]);
return;
return composer.toast.warn({ text: `Could not delete it: ${err.message}` });
}
const files = payload.files || [];
const id = (f) => `${f.subfolder}/${f.filename}`;
body.set([files.length
? composer.gallery.list({
items: files.map((f) => ({
id: id(f), label: f.filename, icon: ICON[f.kind] || "▦", badge: f.kind === "image" ? null : f.kind,
hint: `${f.kind} · ${size(Number(f.size) || 0)} · ${new Date((Number(f.mtime) || 0) * 1000).toLocaleTimeString()}`,
thumb: f.kind === "image" ? url(f) : null,
})),
onActivate: (cell) => dom.openTab(url(files.find((f) => id(f) === cell.id))),
})
: composer.emptyState.default({ icon: "▤", title: "Nothing in the temp directory", hint: payload.detail || "" })]);
viewer.close();
composer.toast.good({ text: `Deleted ${f.filename}.` });
load();
}
function footer() {
setFooter({ note: payload.path || "", actions: [
composer.button.md({ label: "Refresh", onClick: load }),
...(looking ? [
composer.button.md({ label: "Open in a tab", onClick: () => dom.openTab(url(looking, where)) }),
composer.button.md({ label: "Delete", tone: "danger", onClick: () => remove(looking) }),
] : [composer.button.md({ label: "Refresh", onClick: load })]),
composer.button.md({ label: "Close", tone: "primary", onClick: close }),
] });
}
async function load() {
looking = null;
content.set([composer.hint.default({ text: "Looking…" })]);
try {
payload = await (await fetch(`/funpack/api/temp?where=${where}`, { cache: "no-store" })).json();
} catch (err) {
payload = {};
content.set([composer.banner.danger({ text: `Could not read the folder: ${err.message}` })]);
return footer();
}
files = payload.files || [];
content.set([files.length
? composer.gallery.adaptive({
id: "files", cols: 4,
items: files.map((f) => ({
id: id(f), label: f.filename, icon: ICON[f.kind] || "▦", badge: f.kind === "image" ? null : f.kind,
hint: `${f.kind} · ${size(Number(f.size) || 0)} · ${when(f.mtime)}`,
thumb: f.kind === "image" ? url(f, where) : null,
})),
onActivate: (cell) => look(files.find((f) => id(f) === cell.id)),
onContext: (cell, e) => {
const f = files.find((x) => id(x) === cell.id);
composer.menu.context({ x: e.clientX, y: e.clientY, items: [{ id: "look", label: "Look at it" }, { id: "tab", label: "Open in a tab" }, { separator: true }, { id: "del", label: "Delete…", danger: true }],
onPick: (pick) => ({ look: () => look(f), tab: () => dom.openTab(url(f, where)), del: () => remove(f) })[pick]?.() });
},
})
: composer.emptyState.default({ icon: "▤", title: EMPTY[where], hint: payload.detail || "" })]);
footer();
}
load();
return body;
}
+15 -1
View File
@@ -754,7 +754,21 @@ def register(routes, prefix=None):
limit = int(req.query.get("limit", temp_files.MAX_FILES))
except (TypeError, ValueError):
limit = temp_files.MAX_FILES
return web.json_response(await asyncio.to_thread(temp_files.listing, limit))
where = "output" if req.query.get("where") == "output" else "temp"
return web.json_response(await asyncio.to_thread(temp_files.listing, limit, where))
@routes.post(P + "/api/temp/delete")
async def _temp_delete(req):
"""Delete one saved or temp media file: {"where", "filename", "subfolder"}."""
import asyncio
try:
body = await req.json()
except ValueError:
body = None
if not isinstance(body, dict) or body.get("where") not in ("temp", "output"):
return web.json_response({"detail": "Say which folder: temp or output."}, status=400)
out = await asyncio.to_thread(temp_files.delete, body.get("filename"), body.get("subfolder") or "", body["where"])
return web.json_response(out, status=200 if out["deleted"] else 400)
# --- node packs -----------------------------------------------------------
#
+50 -8
View File
@@ -1,8 +1,10 @@
"""What ComfyUI has in its temp directory.
"""What ComfyUI has in its temp directory, and what it has saved in its output directory.
Previews and other transient outputs land there, and they are wiped when the
Previews and other transient outputs land in temp, and they are wiped when the
server restarts -- so "where did that go?" has an answer for as long as the
server has been up, and this is where it is.
server has been up, and this is where it is. Saved videos and pictures land in
output and stay, which makes that the gallery: the same listing, and the one
thing temp does not need, a way to delete.
Only media, and only what can be shown: a temp directory also fills with
whatever any node felt like writing, and a browser listing 400 .pt files is not
@@ -42,7 +44,21 @@ def temp_dir() -> str | None:
return None
def listing(limit: int = MAX_FILES) -> dict:
def output_dir() -> str | None:
"""ComfyUI's output directory, or None when ComfyUI is not here to ask."""
try:
import folder_paths
return folder_paths.get_output_directory()
except Exception: # noqa: BLE001
return None
def base_of(where: str) -> str | None:
"""The folder `where` ("temp" or "output") names; anything else names nothing."""
return {"temp": temp_dir, "output": output_dir}.get(where, lambda: None)()
def listing(limit: int = MAX_FILES, where: str = "temp") -> dict:
"""`{files, path, detail}`, newest first.
`detail` carries the reason there is nothing rather than leaving an empty
@@ -50,13 +66,14 @@ def listing(limit: int = MAX_FILES) -> dict:
"this is not a ComfyUI install" and from "the directory was wiped".
"""
limit = max(1, min(int(limit or MAX_FILES), MAX_FILES))
base = temp_dir()
saved = where == "output"
base = base_of(where)
if not base:
return {"files": [], "path": None,
"detail": "ComfyUI is not here to ask where its temp files go."}
"detail": f"ComfyUI is not here to ask where its {'saved' if saved else 'temp'} files go."}
if not os.path.isdir(base):
return {"files": [], "path": base,
"detail": "The temp directory does not exist yet. It is made when "
"detail": f"The {'output' if saved else 'temp'} directory does not exist yet. It is made when "
"something is first written to it."}
found: list[dict] = []
@@ -80,5 +97,30 @@ def listing(limit: int = MAX_FILES) -> dict:
})
found.sort(key=lambda f: f.get("mtime", 0), reverse=True)
detail = "" if found else "Nothing here. Temp files are wiped when ComfyUI restarts."
detail = "" if found else ("Nothing saved yet. A saved video or picture lands here." if saved
else "Nothing here. Temp files are wiped when ComfyUI restarts.")
return {"files": found[:limit], "path": base, "detail": detail}
def delete(filename, subfolder="", where: str = "output") -> dict:
"""Remove one media file from `where`. `{deleted: True}`, or `{deleted: False, detail}` saying why not.
The name is resolved the way /view resolves it and must stay inside that folder; and only
what the listing would show can go, so this cannot be turned on a model, a workflow or any
other file that happens to live next to the pictures.
"""
base = base_of(where)
if not base or not isinstance(filename, str) or not filename or not kind_of(filename):
return {"deleted": False, "detail": "Name a saved picture, video or sound."}
base = os.path.realpath(base)
full = os.path.realpath(os.path.join(base, subfolder if isinstance(subfolder, str) else "",
os.path.basename(filename)))
if os.path.commonpath((full, base)) != base or full == base:
return {"deleted": False, "detail": "That file is not in this folder."}
try:
os.remove(full)
except FileNotFoundError:
return {"deleted": False, "detail": "That file is already gone."}
except OSError as err:
return {"deleted": False, "detail": f"Could not delete it: {err.strerror or err}"}
return {"deleted": True}
+32
View File
@@ -664,3 +664,35 @@ def test_requirements_route_refuses_during_a_generation(server, monkeypatch):
lambda: (_ for _ in ()).throw(AssertionError("must not install during a generation")))
status, body = _request(server["port"], "POST", "/funpack/api/requirements")
assert status == 409 and "generation" in body["detail"]
# --- the gallery: saved files, and deleting them -------------------------------
def test_saved_files_are_listed_from_the_output_folder(server, monkeypatch, tmp_path):
from core import temp_files
(tmp_path / "FunPack_00001_.mp4").write_bytes(b"x")
monkeypatch.setattr(temp_files, "output_dir", lambda: str(tmp_path))
monkeypatch.setattr(temp_files, "temp_dir", lambda: None)
status, body = _request(server["port"], "GET", "/funpack/api/temp?where=output")
assert [f["filename"] for f in body["files"]] == ["FunPack_00001_.mp4"]
status, body = _request(server["port"], "GET", "/funpack/api/temp")
assert body["files"] == [], "temp and saved were mixed up"
def test_delete_removes_a_media_file_and_nothing_else(server, monkeypatch, tmp_path):
from core import temp_files
out = tmp_path / "out"
(out / "sub").mkdir(parents=True)
pic, weights, outside = out / "sub" / "a.png", out / "model.safetensors", tmp_path / "secret.png"
for f in (pic, weights, outside):
f.write_bytes(b"x")
monkeypatch.setattr(temp_files, "output_dir", lambda: str(out))
port = server["port"]
ask = lambda **kw: _request(port, "POST", "/funpack/api/temp/delete", {"where": "output", **kw})
assert ask(filename="a.png", subfolder="sub")[0] == 200 and not pic.exists()
assert ask(filename="model.safetensors")[0] == 400 and weights.exists(), "a non-media file was deleted"
assert ask(filename="secret.png", subfolder="..")[0] == 400 and outside.exists(), "left the folder"
assert ask(filename="../secret.png")[0] == 400 and outside.exists()
assert ask(filename="a.png", subfolder="sub")[0] == 400, "a second delete should say it is gone"
assert _request(port, "POST", "/funpack/api/temp/delete", {"where": "input", "filename": "a.png"})[0] == 400
+27
View File
@@ -0,0 +1,27 @@
"""The gallery lists what ComfyUI saved, and deletes only media inside that folder."""
from core import temp_files
def test_saved_files_are_listed_from_output_not_temp(monkeypatch, tmp_path):
(tmp_path / "FunPack_00001_.mp4").write_bytes(b"x")
monkeypatch.setattr(temp_files, "output_dir", lambda: str(tmp_path))
monkeypatch.setattr(temp_files, "temp_dir", lambda: None)
assert [f["filename"] for f in temp_files.listing(where="output")["files"]] == ["FunPack_00001_.mp4"]
assert temp_files.listing()["files"] == []
def test_delete_removes_a_media_file_and_nothing_else(monkeypatch, tmp_path):
out = tmp_path / "out"
(out / "sub").mkdir(parents=True)
pic, weights, outside = out / "sub" / "a.png", out / "model.safetensors", tmp_path / "secret.png"
for f in (pic, weights, outside):
f.write_bytes(b"x")
monkeypatch.setattr(temp_files, "output_dir", lambda: str(out))
assert temp_files.delete("a.png", "sub", "output") == {"deleted": True} and not pic.exists()
assert not temp_files.delete("model.safetensors", "", "output")["deleted"] and weights.exists()
assert not temp_files.delete("secret.png", "..", "output")["deleted"] and outside.exists(), "left the folder"
assert not temp_files.delete("../secret.png", "", "output")["deleted"] and outside.exists()
assert not temp_files.delete("a.png", "sub", "output")["deleted"], "a second delete should say it is gone"
assert not temp_files.delete("a.png", "", "input")["deleted"]