feat: shot camera memory can be read and pruned from Settings (routes + Learning section)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 15:25:48 +03:00
co-authored by Claude Sonnet 5.5
parent 079a5104c7
commit 3e335a4a77
5 changed files with 84 additions and 1 deletions
+5
View File
@@ -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",
+32
View File
@@ -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() };
};
+2
View File
@@ -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`,
+17 -1
View File
@@ -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}
@@ -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