diff --git a/modules/conditioning/shot_camera/__init__.py b/modules/conditioning/shot_camera/__init__.py index 116deb3e..22aa9aea 100644 --- a/modules/conditioning/shot_camera/__init__.py +++ b/modules/conditioning/shot_camera/__init__.py @@ -6,6 +6,7 @@ pick the move. Off by default: * cut times: shots after the first open with their time * 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 +* reframe within the shot: at a boundary that was not cut, the camera changes view and focus with no cut, in one continuous take * 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 ("'s hand", not "") * 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 @@ -41,6 +42,11 @@ SETTINGS = { "shot_cuts_chance": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "label": "Chance of a cut within the shot", "hint": "1 = always cut between those shortcuts, 0 = never. Ratings nudge the values in between.", "when": {"cut_same_shot": True}}, + "reframe_same_shot": {"type": "bool", "default": False, "label": "Reframe within the shot", + "hint": "Where one shortcut ends and the next begins inside a shot that is not cut, the camera moves to a new view and focus without a cut: one continuous take, the actions follow on. A shot that reframes gets no extra camera move."}, + "reframe_chance": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "label": "Chance of a reframe", + "hint": "1 = always reframe between those shortcuts, 0 = never. Ratings nudge the values in between.", + "when": {"reframe_same_shot": True}}, "shot_views": {"type": "bool", "default": False, "label": "Shot views", "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", diff --git a/modules/conditioning/shot_camera/engine.py b/modules/conditioning/shot_camera/engine.py index 376a229e..f2b1106f 100644 --- a/modules/conditioning/shot_camera/engine.py +++ b/modules/conditioning/shot_camera/engine.py @@ -717,6 +717,60 @@ def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=()): return "".join(out), info +# ── reframes ──────────────────────────────────────────────────────────────────────── +# A shortcut boundary inside a shot that is NOT cut can turn the camera instead: one continuous +# take, the actions follow on, and only the view and the focus change. Run after the cuts, so +# the boundaries left are exactly the ones that were kept whole. +REFRAMES = ("Without a cut, the camera smoothly moves to {view}{x}.", + "Without a cut, the camera glides around to {view}{x}.", + "Without a cut, the camera drifts over to {view}{x}.") +_REFRAME = re.compile(r"\s*Without a cut, the camera [^.!?]*[.!?]", re.I) + + +def _a_view(view): + return "a " + (view if view.startswith("POV") else view.lower()) + + +def add_reframes(text, seed=0, chance=0.5, pieces=(), stats=None): + """-> (new prompt, info). At each shortcut boundary inside a shot, with odds `chance`, a + sentence moves the camera to a new view (never the one just used) aimed at the most specific + thing in what follows, without a cut. info = {"added": [{"shot", "view", "traits", + "target"}], "arms": ["reframe:yes"|"reframe:no", ...]}.""" + marks = list(SHOT.finditer(text or "")) + info = {"added": [], "arms": []} + if not marks: + return text, info + out, last = [text[:marks[0].start()]], None + 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]) + points = _cut_points(picture, pieces) + chosen = [] + for n, at in enumerate(points): + rng = random.Random(f"{seed}:reframe:{i}:{n}") + take = rng.random() < chance + info["arms"].append("reframe:yes" if take else "reframe:no") + if not take: + continue + following = picture[at:(points[n + 1] if n + 1 < len(points) else len(picture))] + traits = view_traits(following) + allowed = allowed_views(traits) + pool = [v for v in allowed if v != last] or allowed + view = rng.choices(pool, weights=[_view_weight(v, traits, stats) for v in pool])[0] + cands = [c for c in candidates(own_words_removed(following)) if _score(c) > 0.5] + target = _name(_draw(cands, None, None, 1)[0]) if cands else None + sentence = rng.choice(REFRAMES).format(view=_a_view(view), x=f", framing {target}" if target else "") + chosen.append((at, sentence)) + info["added"].append({"shot": int(m.group(1)), "view": view, "traits": traits, "target": target}) + last = view + for at, sentence in reversed(chosen): # right to left: earlier indexes stay valid + head = picture[:at].rstrip() + picture = head + ("" if head.endswith((".", "!", "?")) else ".") + " " + sentence + " " \ + + _fresh_piece(picture[at:]) + out.append(m.group(0) + picture + sound) + return "".join(out), info + + # ── views ─────────────────────────────────────────────────────────────────────────── # Chosen, not detected: a prompt seldom says which view it wants. The user's own trusted # wording, verbatim. Shot 1 is skipped: it may sit on a reference image or a pinned first @@ -799,7 +853,7 @@ def view_options(text, stats=None): traits = view_traits(picture) cands = sorted(((v, _view_weight(v, traits, stats)) for v in allowed_views(traits)), key=lambda c: -c[1]) - stated = VIEW_STATED.search(CUT.sub("", picture)) + stated = VIEW_STATED.search(CUT.sub("", _REFRAME.sub("", picture))) out.append({"shot": int(m.group(1)), "key": key, "traits": traits, "already": bool(stated), "stated": stated.group(0) if stated else "", "candidates": [{"view": v, "score": round(w, 2)} for v, w in cands], @@ -829,7 +883,7 @@ def add_shot_views(text, seed=0, chance=0.4, stats=None, choices=None, skipped=N picture, sound = _split_sound(body) key = keys(picture) view = None - stated = VIEW_STATED.search(CUT.sub("", picture)) if (i or lone) else None + stated = VIEW_STATED.search(CUT.sub("", _REFRAME.sub("", picture))) if (i or lone) else None if stated and skipped is not None: skipped.append(f"shot {m.group(1)}: already states “{stated.group(0)}”") if (i or lone) and not stated: @@ -872,7 +926,7 @@ def own_words_removed(picture): become a target or a topic.""" picture = _OUR_OPENER.sub("", picture, count=1) picture = _CUT_WORDS.sub("", picture, count=1) - return _VIEW_SENTENCE.sub("", picture, count=1) + return _REFRAME.sub("", _VIEW_SENTENCE.sub("", picture, count=1)) _OUR_OPENER = re.compile(r"^\s*At \d\d:\d\d\.\d{3},\s*(?:" + "|".join(re.escape(o) for o in CUT_OPENERS) @@ -888,6 +942,7 @@ def content_fingerprint(text): body = re.sub(r"\[\s*Shot\s+\d+\s*\]", " ", text or "", flags=re.I) body = re.sub(r"At \d\d:\d\d\.\d{3},\s*(?:" + "|".join(re.escape(o) for o in CUT_OPENERS) + r")\.", " ", body, flags=re.I) + body = _REFRAME.sub(" ", body) body = re.sub(r"\s+", " ", body) body = re.sub(r"(?:^|(?<=[.!?]\s))(?:" + "|".join(re.escape(v) for v in VIEWS) + r")\.", " ", body) body = re.sub(r"\s+", " ", body).strip().lower() diff --git a/modules/conditioning/shot_camera/memory.py b/modules/conditioning/shot_camera/memory.py index f80d4c0d..e460e49c 100644 --- a/modules/conditioning/shot_camera/memory.py +++ b/modules/conditioning/shot_camera/memory.py @@ -171,7 +171,8 @@ def learn_views(decisions): def arm_stats(): """{arm: (good, bad)} for what ratings taught about the camera's own choices: "move:yes" / "move:no" (a shot got a move / was left alone), "style:hold|travel|k1|k2|k3", "word:", - "split:yes|no" (a shortcut boundary inside a shot was cut / kept whole).""" + "split:yes|no" (a shortcut boundary inside a shot was cut / kept whole), "reframe:yes|no" (a kept + boundary turned the camera without a cut / did not).""" return {k: (float(v[0]), float(v[1])) for k, v in (_read().get("arms") or {}).items() if isinstance(v, (list, tuple)) and len(v) == 2} @@ -341,6 +342,13 @@ def split_chance(chance): return max(0.0, min(1.0, chance * _lean("split:yes", "split:no"))) +def reframe_chance(chance): + """The chance of reframing at a kept boundary, tilted by whether reframed runs were liked (0 and 1 kept as set).""" + if chance <= 0.0 or chance >= 1.0: + return max(0.0, min(1.0, chance)) + return max(0.0, min(1.0, chance * _lean("reframe:yes", "reframe:no"))) + + def _save(data): path = _path() os.makedirs(os.path.dirname(path), exist_ok=True) diff --git a/modules/conditioning/shot_camera/nodes.py b/modules/conditioning/shot_camera/nodes.py index c7329f46..a9644bbe 100644 --- a/modules/conditioning/shot_camera/nodes.py +++ b/modules/conditioning/shot_camera/nodes.py @@ -52,6 +52,13 @@ def rewrite(text, values, seconds=None, pieces=None): text, info = engine.add_shot_cuts(text, seconds, seed=seed, chance=chance, pieces=_pieces() if pieces is None else pieces) arms += info.get("arms", []) said.append(f"cuts at {', '.join(info['times'])}" + (f" ({info['before']}→{info['after']} shots)" if info["after"] != info["before"] else "") if info["times"] else f"cuts: {info['why']}") + if values.get("reframe_same_shot") and engine.SHOT.search(text or ""): + text, info = engine.add_reframes(text, seed=seed, chance=memory.reframe_chance(float(values.get("reframe_chance", 0.5))), + pieces=_pieces() if pieces is None else pieces, stats=memory.view_stats()) + arms += info["arms"] + views += [{"view": a["view"], "traits": a["traits"]} for a in info["added"]] + said.append("reframes: " + (", ".join(f"shot {a['shot']} {a['view']}" + (f" on {a['target']}" if a["target"] else "") for a in info["added"]) + if info["added"] else "none" + ("" if info["arms"] else " (no shortcut boundary inside a shot)"))) if values.get("shot_views") and engine.SHOT.search(text or ""): skipped = [] text, added = engine.add_shot_views(text, seed=seed, chance=float(values.get("shot_views_chance", 0.4)), stats=memory.view_stats(), skipped=skipped) @@ -105,7 +112,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", "detail_notes")) 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", "reframe_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) diff --git a/modules/conditioning/shot_camera/tests/test_reframe.py b/modules/conditioning/shot_camera/tests/test_reframe.py new file mode 100644 index 00000000..ffc97677 --- /dev/null +++ b/modules/conditioning/shot_camera/tests/test_reframe.py @@ -0,0 +1,69 @@ +"""Reframe within the shot: a kept shortcut boundary turns the camera in one continuous take, never a cut.""" + +from .. import engine, memory, nodes + +DANCE = "She sways her hips from side to side, smiling at the room." +WAVE = "She raises her hand and waves at the camera with her fingers." + + +def test_reframe_turns_the_camera_between_shortcuts_without_a_cut(tmp_path, monkeypatch): + monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json")) + text = f"[Shot 1] {DANCE} {WAVE}" + out, said, chose = nodes.rewrite(text, {"reframe_same_shot": True, "reframe_chance": 1}, seconds=10, pieces=[DANCE, WAVE]) + assert out.count("[Shot ") == 1 and "cuts to" not in out, "one take: no new shot, no cut words" + head, tail = out.split("Without a cut, the camera ") + assert DANCE in head and WAVE in tail, "the reframe sits between the two actions, in order" + assert "framing" in tail.split(".")[0], "aimed at what the next action is about" + assert "reframes: shot 1" in said + assert chose["arms"] == ["reframe:yes"] and chose["views"], "learned like the other camera choices" + + +def test_the_view_never_repeats_and_respects_what_the_part_shows(): + text = f"[Shot 1] {DANCE} {DANCE} {DANCE} {DANCE}" + out, info = engine.add_reframes(text, seed="s", chance=1, pieces=[DANCE]) + views = [a["view"] for a in info["added"]] + assert len(views) == 3 and all(a != b for a, b in zip(views, views[1:])) + assert all(v != "View from behind" for v in views), "a smiling face is not shown from behind" + + +def test_zero_chance_adds_nothing_and_no_boundary_says_so(tmp_path, monkeypatch): + monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json")) + text = f"[Shot 1] {DANCE} {WAVE}" + assert engine.add_reframes(text, seed=1, chance=0, pieces=[DANCE, WAVE])[0] == text + out, said, _ = nodes.rewrite("[Shot 1] A woman walks.", {"reframe_same_shot": True, "reframe_chance": 1}, seconds=5, pieces=[]) + assert out == "[Shot 1] A woman walks." and "no shortcut boundary inside a shot" in said + + +def test_a_cut_boundary_is_not_also_reframed(tmp_path, monkeypatch): + monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json")) + text = f"[Shot 1] {DANCE} {WAVE}" + out, _, _ = nodes.rewrite(text, {"cut_same_shot": True, "shot_cuts_chance": 1, "reframe_same_shot": True, "reframe_chance": 1}, + seconds=10, pieces=[DANCE, WAVE]) + assert out.count("[Shot ") == 2 and "Without a cut" not in out + + +def test_the_reframe_is_not_content_and_does_not_block_the_opening_view(): + text = f"[Shot 1] A woman walks in. [Shot 2] {DANCE} {WAVE}" + out, _ = engine.add_reframes(text, seed=2, chance=1, pieces=[DANCE, WAVE]) + assert engine.content_fingerprint(out) == engine.content_fingerprint(text), "same prompt, same seed next run" + shot2 = engine.shot_texts(out)[2] + assert "Without a cut" not in shot2 + _, added = engine.add_shot_views(out, seed=0, chance=1) + assert [a["shot"] for a in added] == [2], "a mid-shot reframe is not the view the shot opens on" + + +def test_ratings_tilt_the_reframe_chance(tmp_path, monkeypatch): + monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json")) + assert memory.reframe_chance(0.5) == 0.5 + memory.record_run("r", {"views": [], "arms": ["reframe:yes"]}) + memory.on_rating("r", "liked") + assert memory.reframe_chance(0.5) > 0.5 + assert memory.reframe_chance(1.0) == 1.0, "always stays always" + + +def test_cut_within_the_shot_at_zero_leaves_every_boundary_to_reframe(tmp_path, monkeypatch): + monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json")) + text = f"[Shot 1] {DANCE} {WAVE} {DANCE}" + out, _, _ = nodes.rewrite(text, {"cut_same_shot": True, "shot_cuts_chance": 0, "reframe_same_shot": True, "reframe_chance": 1}, + seconds=10, pieces=[DANCE, WAVE]) + assert out.count("[Shot ") == 1 and out.count("Without a cut") == 2