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:
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user