From 3e335a4a772ed326a903efadeff95f310edc2d1e Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:25:48 +0300 Subject: [PATCH] feat: shot camera memory can be read and pruned from Settings (routes + Learning section) Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/settings/index.js | 5 +++ app/ui/features/settings/shotmemory.js | 32 +++++++++++++++++++ app/ui/shell/api.js | 2 ++ modules/conditioning/shot_camera/__init__.py | 18 ++++++++++- .../shot_camera/tests/test_node.py | 28 ++++++++++++++++ 5 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 app/ui/features/settings/shotmemory.js diff --git a/app/ui/features/settings/index.js b/app/ui/features/settings/index.js index 0fea7c15..866764ff 100644 --- a/app/ui/features/settings/index.js +++ b/app/ui/features/settings/index.js @@ -9,6 +9,7 @@ import { system } from "./system.js"; import { packs } from "./packs.js"; import { taste } from "./taste.js"; import { editor } from "./editor.js"; +import { shotMemory } from "./shotmemory.js"; export default { id: "settings-sections", @@ -29,6 +30,10 @@ export default { modules(app), "Generation", "modules enable disable quarantine"); add("refinement", "Refinement & Taste", "Learned-taste state: refinement keys and the Absolute global-taste store.", "✦", "danger", taste(app), "Learning", "refinement taste keys rating"); + // only when that module is installed: a section for something absent would be a dead end + app.pipeline.ensureLoaded().then(() => { + if (app.pipeline.modulesById()["conditioning_shot_camera"]) add("shotmemory", "Shot camera memory", "What the camera has learned from your prompts and ratings.", "◎", "neutral", shotMemory(app), "Learning", "shot camera views moves words forget"); + }); add("system", "Updates & ComfyUI", "Server connection, FunPack code updates, pipeline health.", "⟳", "good", system(app), "System", "update git branch restart rollback"); add("customnodes", "Custom Nodes", "Install, update and remove ComfyUI node packs.", "⧉", "neutral", diff --git a/app/ui/features/settings/shotmemory.js b/app/ui/features/settings/shotmemory.js new file mode 100644 index 00000000..b8854bd2 --- /dev/null +++ b/app/ui/features/settings/shotmemory.js @@ -0,0 +1,32 @@ +// Shot camera memory: the words and views the camera has learned from your prompts and ratings, to read and forget. +import { composer as c } from "../../composer/composer.js"; + +export const shotMemory = (app) => function mount() { + const page = c.region.stack({ gap: "md" }); + let data = null, error = ""; + + async function refresh() { + try { data = await app.api.shotMemory(); error = ""; } catch (err) { data = null; error = err.message; } + draw(); + } + async function forget(kind, name) { + const what = kind === "all" ? "everything the camera has learned" : `“${name}”`; + if (!(await c.modal.dialogue({ title: "Forget", message: `Forget ${what}? This cannot be undone.`, tone: "danger", confirmLabel: "Forget" }).result)) return; + try { await app.api.forgetShotMemory(kind, name); } catch (err) { c.toast.warn({ text: err.message }); } + refresh(); + } + const row = (label, hint, kind, name) => c.settingsRow.default({ label, hint, control: c.button.sm({ label: "Forget", tone: "danger", onClick: () => forget(kind, name) }) }); + function draw() { + if (error) return page.set([c.banner.warn({ text: `Could not read the camera's memory: ${error}` })]); + if (!data) return page.set([c.hint.default({ text: "Loading…" })]); + const words = data.words || [], views = data.views || []; + if (!words.length && !views.length) return page.set([c.emptyState.default({ icon: "◎", title: "Nothing learned yet", hint: "Turn on camera moves or shot views, then rate renders: the camera remembers which words and views you liked." })]); + page.set([c.hint.default({ text: `${data.prompts} prompts seen · ${data.shots} shots reviewed. Forget anything that taught it the wrong thing.` }), + ...(views.length ? [c.label.section({ text: "Views" }), ...views.map((v) => row(v.view, `liked ${v.good.toFixed(1)} · disliked ${v.bad.toFixed(1)}`, "view", v.view))] : []), + ...(words.length ? [c.label.section({ text: "Words the camera aims at" }), ...words.map((w) => row(w.word, `picked ${w.picks} · replaced ${w.rejects} · seen ${w.seen}`, "word", w.word)), + data.more ? c.hint.default({ text: `and ${data.more} more` }) : null] : []), + c.button.sm({ label: "Forget everything", tone: "danger", onClick: () => forget("all") })].filter(Boolean)); + } + draw(); refresh(); + return { node: page.node, destroy: () => page.node.remove() }; +}; diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 942e1fa7..24e2c340 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -51,6 +51,8 @@ export const api = { enhancerRuns: () => call("GET", "/api/m/conditioning_prompt_enhancer/runs"), rateTaste: (promptId, rating, axis) => call("POST", "/api/m/taste/rate", { prompt_id: promptId, rating, axis: axis || null }), newTasteGeneration: () => call("POST", "/api/m/taste/generation", {}), + shotMemory: () => call("GET", "/api/m/conditioning_shot_camera/memory"), + forgetShotMemory: (kind, name) => call("POST", "/api/m/conditioning_shot_camera/forget", { kind, name }), tasteKeys: () => call("GET", "/api/m/taste/keys"), deleteTasteKey: (name) => call("DELETE", `/api/m/taste/keys/${encodeURIComponent(name)}`), tasteKeyUrl: (name) => `${BASE}/api/m/taste/keys/${encodeURIComponent(name)}/export`, diff --git a/modules/conditioning/shot_camera/__init__.py b/modules/conditioning/shot_camera/__init__.py index 2dfcc981..f5f848bd 100644 --- a/modules/conditioning/shot_camera/__init__.py +++ b/modules/conditioning/shot_camera/__init__.py @@ -49,4 +49,20 @@ def on_rating(prompt_id, rating, axis=None): return memory.on_rating(prompt_id, rating, axis) -PROVIDES = {"on_rating": on_rating} +def routes(table, base, web): + """What the camera has learned, to read and prune from Settings.""" + @table.get(base + "/memory") + async def _memory(_req): + return web.json_response(memory.summary()) + + @table.post(base + "/forget") + async def _forget(req): + try: + body = await req.json() + gone = memory.forget(body.get("kind"), body.get("name")) + except (ValueError, AttributeError, TypeError) as exc: + return web.json_response({"why": str(exc)}, status=400) + return web.json_response({"forgotten": gone}) + + +PROVIDES = {"on_rating": on_rating, "routes": routes} diff --git a/modules/conditioning/shot_camera/tests/test_node.py b/modules/conditioning/shot_camera/tests/test_node.py index 0c0bb016..5ed1998e 100644 --- a/modules/conditioning/shot_camera/tests/test_node.py +++ b/modules/conditioning/shot_camera/tests/test_node.py @@ -72,3 +72,31 @@ def test_more_shots_than_seconds_refuses_instead_of_writing_bad_times(tmp_path, text = "\n".join(f"[Shot {i}] A woman walks along street number {i}." for i in range(1, 8)) out, said, _ = nodes.rewrite(text, {"shot_cuts": True}, seconds=5.17, pieces=[]) assert out == text and "do not fit" in said + + +def test_memory_routes_read_and_forget(tmp_path, monkeypatch): + import asyncio, json + monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json")) + memory.record_run("a", {"views": [{"view": "side view", "traits": ["none"]}], "arms": []}) + memory.on_rating("a", "liked") + handlers = {} + + class Table: + def get(self, path): return lambda fn: handlers.setdefault(("GET", path), fn) + def post(self, path): return lambda fn: handlers.setdefault(("POST", path), fn) + + class web: + @staticmethod + def json_response(data, status=200): + return type("R", (), {"status": status, "data": data})() + + class Req: + def __init__(self, body=None): self._b = body + async def json(self): return self._b + + _mod().routes(Table(), "/b", web) + got = asyncio.run(handlers[("GET", "/b/memory")](Req())) + assert [v["view"] for v in got.data["views"]] == ["side view"] + assert asyncio.run(handlers[("POST", "/b/forget")](Req({"kind": "view", "name": "side view"}))).data == {"forgotten": True} + assert asyncio.run(handlers[("GET", "/b/memory")](Req())).data["views"] == [] + assert asyncio.run(handlers[("POST", "/b/forget")](Req({"kind": "nonsense"}))).status == 400