feat: add a liked detail only where that body part is already named

A like of a short detail such as "detailed lips" can append that sentence at the end of a later shot that already says lips. The original sentence is left alone. Shots that already describe the part, or never name it, get nothing. Two bad-composition votes more than the likes drop that detail.
This commit is contained in:
digital-garbage
2026-10-07 18:46:54 +03:00
parent d366f22e27
commit e1d9c8f2ee
7 changed files with 273 additions and 6 deletions
+3 -1
View File
@@ -20,8 +20,10 @@ export const shotMemory = (app) => function mount() {
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 && !(data.arms || []).length) return page.set([c.emptyState.default({ icon: "◎", title: "Nothing learned yet", hint: "In a pipeline with the Shot camera node, turn on camera moves or shot views in Engine, then rate renders: the camera remembers which words and views you liked." })]);
const details = data.details || [];
if (!words.length && !views.length && !(data.arms || []).length && !details.length) return page.set([c.emptyState.default({ icon: "◎", title: "Nothing learned yet", hint: "In a pipeline with the Shot camera node, turn on camera moves, shot views or liked details in Engine, then rate renders: the camera remembers which words, views and details you liked." })]);
page.set([c.hint.default({ text: `${data.prompts} prompts seen · ${data.shots} shots reviewed. Forget anything that taught it the wrong thing.` }),
...(details.length ? [c.label.section({ text: "Liked details" }), ...details.map((d) => row(d.phrase, `${d.kept ? "will be added" : "dropped"} · liked ${d.good.toFixed(1)} · disliked ${d.bad.toFixed(1)}`, "detail", d.phrase))] : []),
...(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] : []),
@@ -8,6 +8,7 @@ pick the move. Off by default:
* cut within the same shot: "[Shot 1] DANCE KISS" and "[Shot 1] DANCE DANCE" are cut between the shortcuts, so each part gets a time, a view and a focus. Its chance slider is hidden until this is on
* views: your own trusted list (side view, from above, POV...), never on shot 1, never the same twice in a row
* camera moves: one to three per shot, aimed at the most specific thing in it ("<Subject 1>'s hand", not "<Subject 1>")
* liked details: a short detail you liked ("detailed lips") may be added, once, at the end of a later shot that already names that body part. Its chance slider is hidden until this is on
Deterministic: the same prompt gives the same shots; "Variation" re-rolls. Ratings teach it: which views and moves are liked
(a dislike that blames the picture alone teaches nothing here), and the words your prompts keep returning to are favoured.
@@ -44,6 +45,11 @@ SETTINGS = {
"hint": "Later shots open on a view (side, from above, POV…), never the same twice in a row."},
"shot_views_chance": {"type": "float", "default": 0.4, "min": 0.0, "max": 1.0, "step": 0.05, "label": "Chance a shot gets a view",
"when": {"shot_views": True}},
"detail_notes": {"type": "bool", "default": False, "label": "Liked details",
"hint": "When a shot already names a body part, a short detail you liked of that part (such as \"detailed lips\") may be added once, as its own sentence at the end of the shot."},
"detail_notes_chance": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "label": "Chance of a liked detail",
"hint": "1 = always add a matching detail, 0 = never. Ratings nudge the values in between. A detail that keeps coming back bad is dropped.",
"when": {"detail_notes": True}},
"variation": {"type": "int", "default": 0, "min": 0, "max": 9999, "label": "Variation",
"hint": "The same prompt always gets the same shots. Change this number to draw different ones."},
}
+129
View File
@@ -49,6 +49,12 @@ GENERIC = {"camera", "video", "scene", "shot", "frame", "background", "foregroun
PARTS = {"lip", "mouth", "tongue", "tooth", "teeth", "eye", "eyes", "face", "cheek", "hand",
"finger", "hair", "neck", "shoulder", "waist", "hip", "leg", "foot", "feet", "arm",
"skin", "expression", "smile", "gaze", "back", "chin", "brow"}
# A liked detail may name one of these. Hair, face, skin and the like are appearance: the old
# refiner refused to auto-inject those, and a like of a clip cannot tell the person from the act.
DETAIL_PARTS = PARTS - {"hair", "face", "skin", "expression", "smile", "gaze", "back"}
# Adjectives that locate a part ("lower lip") are not a detail worth repeating everywhere.
_NOT_A_DETAIL = {"left", "right", "other", "same", "whole", "own", "both", "each", "another",
"first", "next", "last", "upper", "lower", "front", "back"}
PLACES = {"room", "street", "kitchen", "bedroom", "bed", "table", "floor", "stage", "hall",
"garden", "beach", "forest", "city", "car", "office", "bathroom", "field"}
MOVES_DETAIL = ("The camera pushes in toward {x}.", "The camera racks focus to {x}.",
@@ -203,6 +209,129 @@ def candidates(picture):
return out
def _content_amods(root):
"""Adjectives that describe `root`, ignoring ones that only say which side it is on."""
return sorted((c for c in root.children
if c.dep_ == "amod" and c.is_alpha and c.lemma_.lower() not in _NOT_A_DETAIL),
key=lambda t: t.i)
def detail_phrases(picture):
"""[(lemma, phrase)] for a short detail already written on a body part ("detailed lips").
A bare noun is not a detail. Neither is a place, a person, or hair and face: those are who
the clip is about, and a like must not paste them onto the next one. With no tagger, one
adjective in front of a body-part word is the same idea."""
hidden = _hide(picture or "")
nlp = _spacy()
if nlp is None:
return _detail_phrases_plain(hidden)
out, seen = [], set()
for root in (c.root for c in nlp(hidden).noun_chunks):
lemma = root.lemma_.lower()
if lemma not in DETAIL_PARTS:
continue
amods = _content_amods(root)
if not amods:
continue
phrase = " ".join(c.text.lower() for c in amods) + " " + root.text.lower()
phrase = re.sub(r"\s+", " ", phrase).strip()
if not phrase or phrase in seen or len(phrase.split()) > 5:
continue
seen.add(phrase)
out.append((lemma, phrase))
return out
def _detail_phrases_plain(hidden):
parts = "|".join(sorted(DETAIL_PARTS, key=len, reverse=True))
skip = DETERMINERS | POSSESSIVE_PRONOUNS | _NOT_A_DETAIL | {"and", "with", "from", "into", "onto", "over"}
out, seen = [], set()
for m in re.finditer(rf"\b([A-Za-z]{{3,}})\s+({parts})s?\b", hidden):
adj = m.group(1).lower()
if adj in skip:
continue
word = m.group(2).lower()
lemma = word if word in DETAIL_PARTS else word[:-1]
phrase = f"{adj} {m.group(2).lower()}"
if phrase in seen:
continue
seen.add(phrase)
out.append((lemma, phrase))
return out
def qualified_lemmas(picture):
"""Body-part lemmas this shot already describes with an adjective. A second detail would
argue with that adjective, so nothing is added onto them."""
hidden = _hide(picture or "")
nlp = _spacy()
if nlp is None:
return {lemma for lemma, _phrase in _detail_phrases_plain(hidden)}
out = set()
for tok in nlp(hidden):
if tok.lemma_.lower() in DETAIL_PARTS and any(c.dep_ == "amod" and c.is_alpha for c in tok.children):
out.add(tok.lemma_.lower())
return out
def detail_phrases_in(text):
"""Every detail phrase in a prompt, one per wording, read per shot so the sound track is skipped."""
marks = list(SHOT.finditer(text or ""))
chunks = ([text] if not marks else
[text[m.end():(marks[i + 1].start() if i + 1 < len(marks) else len(text))] for i, m in enumerate(marks)])
out, seen = [], set()
for chunk in chunks:
picture, _sound = _split_sound(chunk)
for lemma, phrase in detail_phrases(picture):
if phrase not in seen:
seen.add(phrase)
out.append((lemma, phrase))
return out
def _as_sentence(phrase):
phrase = (phrase or "").strip().rstrip(".")
return (phrase[:1].upper() + phrase[1:] + ".") if phrase else ""
def add_detail_notes(text, seed=0, chance=0.5, bank=()):
"""-> (prompt, {"added": [{"shot", "lemma", "phrase"}], "why"}).
A banked detail is added once, as its own sentence at the end of a shot, and only when that
shot already names the body part, the part is not already described, and the words are not
already there. One detail per shot: the one ratings have kept most. The original sentence
is never rewritten, so it cannot be made ungrammatical. `chance` of 0 or 1 is kept as set;
anything between is the caller's, already tilted per phrase."""
marks = list(SHOT.finditer(text or ""))
info = {"added": [], "why": ""}
if not marks:
info["why"] = "no shots"
return text, info
usable = [b for b in bank or () if isinstance(b, dict) and b.get("lemma") and b.get("phrase") and float(b.get("good") or 0) >= 1]
if not usable:
info["why"] = "nothing learned yet"
return text, info
out = [text[:marks[0].start()]]
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
picture, sound = _split_sound(text[m.end():end])
present = {c[0] for c in candidates(picture)}
taken = qualified_lemmas(picture)
low = picture.lower()
options = [b for b in usable
if b["lemma"] in present and b["lemma"] not in taken and b["phrase"].lower() not in low]
options.sort(key=lambda b: (-(float(b["good"]) - float(b["bad"])), -float(b["good"]), b["phrase"]))
if options and random.Random(f"{seed}:detail:{i}:{options[0]['phrase']}").random() < float(options[0].get("chance", chance)):
sentence = _as_sentence(options[0]["phrase"])
picture = picture.rstrip() + " " + sentence + " "
info["added"].append({"shot": int(m.group(1)), "lemma": options[0]["lemma"], "phrase": options[0]["phrase"]})
out.append(m.group(0) + picture + sound)
if not info["added"]:
info["why"] = "nothing matched"
return "".join(out), info
def _score(c, prior=None):
"""How much `c` deserves the camera: what it is in its own prompt (an owned part, an object
of a verb), plus, when given, `prior`'s bonus for words the user's prompts keep returning to."""
+70 -2
View File
@@ -40,7 +40,7 @@ def _path():
_LOCK = threading.RLock() # the worker thread (observe, record_run) and the rating route write the same file
_DICTS = ("picks", "rejects", "seen", "views", "arms", "runs", "rated")
_DICTS = ("picks", "rejects", "seen", "views", "arms", "runs", "rated", "details")
def _locked(fn):
@@ -200,6 +200,61 @@ def rate_arms(used, sign, k=1.0):
return len(used)
def detail_chance(chance, good, bad):
"""The chance of adding one learned detail. 0 and 1 stay as the user set them. In between,
a detail that was liked is more likely and one that was blamed is less, gently: one vote
cannot flip it."""
chance = float(chance)
if chance <= 0.0 or chance >= 1.0:
return max(0.0, min(1.0, chance))
rate = (float(good) + 1.0) / (float(good) + float(bad) + 2.0)
return max(0.0, min(1.0, chance * 2.0 * rate))
def detail_bank():
"""Details a like has taught, still allowed to be added: good at least once, and not
suppressed by bad-composition votes outrunning the likes."""
rows = []
for phrase, row in (_read().get("details") or {}).items():
if not isinstance(row, dict):
continue
good, bad = float(row.get("good") or 0), float(row.get("bad") or 0)
lemma = str(row.get("lemma") or "")
if good < 1 or not lemma or (bad >= 2 and bad > good):
continue
rows.append({"lemma": lemma, "phrase": str(phrase), "good": good, "bad": bad})
return rows
def _detail_pairs(run, sign):
"""What a like credits (details written in that prompt) or a dislike blames (details this
run actually added)."""
if sign > 0:
from . import engine
return engine.detail_phrases_in((run or {}).get("text") or "")
return [(d.get("lemma") or "", str(d.get("phrase") or "").strip().lower())
for d in (run or {}).get("details") or []
if isinstance(d, dict) and str(d.get("phrase") or "").strip()]
def _apply_detail_lesson(run, sign, undo=False):
"""Credit or blame the details of one run. `undo` takes that same lesson back. -> phrases touched."""
pairs = [(lemma, phrase) for lemma, phrase in _detail_pairs(run, sign) if phrase]
if not pairs or not sign:
return 0
delta = -1.0 if undo else 1.0
field = "good" if sign > 0 else "bad"
data = _read()
store = data.setdefault("details", {})
for lemma, phrase in pairs:
row = store.setdefault(phrase, {"lemma": lemma or "", "good": 0.0, "bad": 0.0})
if lemma:
row["lemma"] = lemma
row[field] = max(0.0, round(float(row.get(field) or 0) + delta, 4))
_save(data)
return len(pairs)
def summary(limit=40):
"""What is remembered, for the person to read and prune: words (picked / replaced / merely
recurring) and views (good / bad, with the per-trait rows folded under their view)."""
@@ -212,7 +267,16 @@ def summary(limit=40):
views.sort(key=lambda r: -(r["good"] + r["bad"]))
arms = [{"arm": k, "good": g, "bad": b} for k, (g, b) in arm_stats().items() if not k.startswith("word:")]
arms.sort(key=lambda r: -(r["good"] + r["bad"]))
details = []
for phrase, row in (data.get("details") or {}).items():
if not isinstance(row, dict):
continue
good, bad = float(row.get("good") or 0), float(row.get("bad") or 0)
details.append({"phrase": phrase, "lemma": row.get("lemma") or "", "good": good, "bad": bad,
"kept": good >= 1 and not (bad >= 2 and bad > good)})
details.sort(key=lambda r: (-(r["good"] + r["bad"]), r["phrase"]))
return {"words": words[:limit], "more": max(0, len(words) - limit), "views": views, "arms": arms,
"details": details[:limit],
"prompts": int(data.get("prompts", 0)), "shots": int(data.get("shots", 0)),
"kept": int(data.get("kept", 0))}
@@ -238,6 +302,8 @@ def forget(kind, name=None):
views.pop(k)
elif kind == "arm":
existed = (data.get("arms") or {}).pop(name, None) is not None
elif kind == "detail":
existed = (data.get("details") or {}).pop(str(name or "").strip().lower(), None) is not None
else:
raise ValueError(f"unknown kind {kind!r}")
if existed: # a forgotten lesson must not be rebuilt by re-rating the run that taught it
@@ -333,7 +399,9 @@ def on_rating(prompt_id, rating, axis=None):
if before:
rate_views(run.get("views"), before, -1.0)
rate_arms(run.get("arms"), before, -1.0)
n = (rate_views(run.get("views"), sign) + rate_arms(run.get("arms"), sign)) if sign else 0
_apply_detail_lesson(run, before, undo=True)
n = (rate_views(run.get("views"), sign) + rate_arms(run.get("arms"), sign)
+ _apply_detail_lesson(run, sign)) if sign else 0
data = _read()
data.setdefault("rated", {})[pid] = sign
data["rated"] = dict(list(data["rated"].items())[-MAX_RUNS:])
+15 -3
View File
@@ -66,7 +66,19 @@ def rewrite(text, values, seconds=None, pieces=None):
memory.observe(engine.content_fingerprint(before), [w for r in report for w in r.get("lemmas", [])])
moved = [r for r in report if r["move"]]
said.append(f"moves: {len(moved)} of {len(report)} shots" + ("" if moved else " (" + "; ".join(f"shot {r['shot']} {r['why']}" for r in report) + ")"))
return text, "; ".join(said), {"views": views, "arms": arms}
details = []
if values.get("detail_notes") and engine.SHOT.search(text or ""):
base = float(values.get("detail_notes_chance", 0.5))
bank = []
for row in memory.detail_bank():
row = dict(row)
row["chance"] = memory.detail_chance(base, row["good"], row["bad"])
bank.append(row)
text, info = engine.add_detail_notes(text, seed=seed, chance=base, bank=bank)
details = info.get("added") or []
said.append("details: " + (", ".join(f"shot {d['shot']} {d['phrase']}" for d in details) if details else info.get("why") or "none"))
return text, "; ".join(said), {"views": views, "arms": arms, "details": details,
"text": text if values.get("detail_notes") else None}
class FunPackShotCamera(io.ComfyNode):
@@ -93,7 +105,7 @@ class FunPackShotCamera(io.ComfyNode):
@classmethod
def execute(cls, text: str, length: int, frame_rate: float, settings=None) -> io.NodeOutput:
values = _values(settings)
if not any(values.get(k) for k in ("camera_moves", "shot_cuts", "shot_views", "cut_same_shot")) or not engine.SHOT.search(text or ""):
if not any(values.get(k) for k in ("camera_moves", "shot_cuts", "shot_views", "cut_same_shot", "detail_notes")) or not engine.SHOT.search(text or ""):
return io.NodeOutput(text, "unchanged")
try:
out, said, chose = rewrite(text, values, seconds=length / frame_rate if frame_rate else None)
@@ -101,7 +113,7 @@ class FunPackShotCamera(io.ComfyNode):
log.failed("FunPack Shot Camera", exc)
return io.NodeOutput(text, f"left as written ({exc})")
pid = _prompt_id()
if pid and (chose["views"] or chose["arms"]):
if pid and (chose["views"] or chose["arms"] or chose.get("text") is not None):
try:
memory.record_run(pid, chose)
except Exception as exc: # noqa: BLE001 -- only the learning is lost, not the run
@@ -1,6 +1,8 @@
"""The node's rewrite, the rating hook and the module contract."""
import importlib
import pytest
from .. import memory, nodes
@@ -46,6 +48,30 @@ def test_rating_teaches_once_and_skips_picture_only(tmp_path, monkeypatch):
assert memory.on_rating("unknown", "liked") == 0
def test_a_like_teaches_a_detail_and_a_bad_composition_can_retire_it(tmp_path, monkeypatch):
pytest.importorskip("spacy")
monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json"))
m = _mod()
assert m.SETTINGS["detail_notes"]["type"] == "bool" and "when" not in m.SETTINGS["detail_notes"]
assert m.SETTINGS["detail_notes_chance"]["when"] == {"detail_notes": True}
text = "[Shot 1] She kisses his lips."
memory.record_run("liked", {"views": [], "arms": [], "text": "[Shot 1] She has detailed lips.", "details": []})
assert memory.on_rating("liked", "disliked", "image") == 0
assert memory.detail_bank() == []
assert memory.on_rating("liked", "liked") == 1
assert memory.detail_bank()[0]["phrase"] == "detailed lips"
out, said, chose = nodes.rewrite(text, {"detail_notes": True, "detail_notes_chance": 1})
assert "Detailed lips." in out and "shot 1 detailed lips" in said
assert out.startswith("[Shot 1] She kisses his lips.")
memory.record_run("bad1", {"views": [], "arms": [], "text": out, "details": chose["details"]})
memory.record_run("bad2", {"views": [], "arms": [], "text": out, "details": chose["details"]})
memory.on_rating("bad1", "disliked", "composition")
memory.on_rating("bad2", "disliked", "composition")
assert memory.detail_bank() == []
quiet, _, _ = nodes.rewrite(text, {"detail_notes": True, "detail_notes_chance": 1})
assert quiet == text
def test_contract():
m = _mod()
assert m.PROVIDES["on_rating"]("nope", "liked") == 0
@@ -15,6 +15,30 @@ MUSIC = "Upbeat jazz music with a soft bassline plays throughout."
PROMPT = HEAD + S1 + S2 + S3 + MUSIC
def test_a_liked_detail_is_added_only_where_that_body_part_already_is():
"""The sentence already written is left as it is. The detail is a new sentence at the end,
and only in a shot that already names that part without describing it."""
pytest.importorskip("spacy")
bank = [{"lemma": "lip", "phrase": "detailed lips", "good": 2, "bad": 0, "chance": 1}]
bare = "[Shot 1] She kisses his lips. The music plays."
out, info = sc.add_detail_notes(bare, seed=1, chance=1, bank=bank)
assert info["added"] == [{"shot": 1, "lemma": "lip", "phrase": "detailed lips"}]
assert out.index("his lips") < out.index("Detailed lips.")
assert out.index("Detailed lips.") < out.index("music")
assert "She kisses his lips" in out
described = "[Shot 1] She presses her wet lips to his cheek."
kept, none = sc.add_detail_notes(described, seed=1, chance=1, bank=bank)
assert kept == described and none["added"] == []
elsewhere = "[Shot 1] A lamp sits on the table while the music plays."
same, skipped = sc.add_detail_notes(elsewhere, seed=1, chance=1, bank=bank)
assert same == elsewhere and skipped["added"] == []
never, _ = sc.add_detail_notes(bare, seed=1, chance=0, bank=[{**bank[0], "chance": 0}])
assert never == bare
def test_no_shot_markers_leaves_the_prompt_alone():
assert sc.add_camera_moves("A woman walks. Music plays.") == ("A woman walks. Music plays.", [])