diff --git a/modules/conditioning/shot_camera/__init__.py b/modules/conditioning/shot_camera/__init__.py index 22aa9aea..4be4dc88 100644 --- a/modules/conditioning/shot_camera/__init__.py +++ b/modules/conditioning/shot_camera/__init__.py @@ -6,7 +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 +* reframe within the shot: at a boundary that was not cut, the camera moves to a new focus with no cut, in one continuous take (the view may stay) * 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 @@ -43,7 +43,7 @@ SETTINGS = { "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."}, + "hint": "Where one shortcut ends and the next begins inside a shot that is not cut, the camera moves on to what the next action is about, without a cut: one continuous take, the actions follow on. The view may turn or stay. 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}}, diff --git a/modules/conditioning/shot_camera/engine.py b/modules/conditioning/shot_camera/engine.py index f2b1106f..92641cbb 100644 --- a/modules/conditioning/shot_camera/engine.py +++ b/modules/conditioning/shot_camera/engine.py @@ -718,51 +718,68 @@ def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=()): # ── 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}.") +# A shortcut boundary inside a shot that is NOT cut can move the camera instead: one continuous +# take, the actions follow on, the camera travels to what the next action is about. The focus +# always changes and the camera always moves; the view may turn or stay as it is (the user's +# rule: "What matters is focus change and movement"). Run after the cuts, so the boundaries +# left are exactly the ones that were kept whole. +REFRAMES = ("Without a cut, the camera pans over to {x}{view}.", + "Without a cut, the camera glides to {x}{view}.", + "Without a cut, the camera pushes in on {x}{view}.", + "Without a cut, the camera tracks over to {x}{view}.") _REFRAME = re.compile(r"\s*Without a cut, the camera [^.!?]*[.!?]", re.I) +KEEP_VIEW_SHARE = 0.5 # of reframes, how many keep the view and only move to the new focus 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): +def _focus(text, avoid=None): + """The most specific thing `text` is about, skipping the lemma `avoid`: -> candidate or None.""" + cands = [c for c in candidates(own_words_removed(text)) if _score(c) > 0.5 and c[0] != avoid] + return _draw(cands, None, None, 1)[0] if cands else None + + +def add_reframes(text, seed=0, chance=0.5, pieces=(), stats=None, arms=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", ...]}.""" + sentence moves the camera, without a cut, to the most specific thing in what follows that + differs from what the action before was about; a boundary with no such thing is left alone. + About half keep the view (tilted by ratings), the rest turn to one that can show the part. + info = {"added": [{"shot", "view" (None = kept), "traits", "target"}], + "arms": ["reframe:yes"|"reframe:no", "reframe:keep"|"reframe:turn", ...]}.""" marks = list(SHOT.finditer(text or "")) info = {"added": [], "arms": []} if not marks: return text, info - out, last = [text[:marks[0].start()]], None + 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]) points = _cut_points(picture, pieces) + before = _focus(picture[:points[0]]) if points else None chosen = [] for n, at in enumerate(points): + following = picture[at:(points[n + 1] if n + 1 < len(points) else len(picture))] + target = _focus(following, avoid=before[0] if before else None) + if target is None: # nothing new to move to: no reframe, nothing to learn + before = _focus(following) or before + continue rng = random.Random(f"{seed}:reframe:{i}:{n}") take = rng.random() < chance info["arms"].append("reframe:yes" if take else "reframe:no") + before = target 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 "") + view = None + if rng.random() >= _tilt(KEEP_VIEW_SHARE, "reframe:keep", arms): + allowed = allowed_views(traits) + view = rng.choices(allowed, weights=[_view_weight(v, traits, stats) for v in allowed])[0] + info["arms"].append("reframe:turn" if view else "reframe:keep") + sentence = rng.choice(REFRAMES).format(x=_name(target), view=f", turning to {_a_view(view)}" if view else "") chosen.append((at, sentence)) - info["added"].append({"shot": int(m.group(1)), "view": view, "traits": traits, "target": target}) - last = view + info["added"].append({"shot": int(m.group(1)), "view": view, "traits": traits, "target": _name(target)}) for at, sentence in reversed(chosen): # right to left: earlier indexes stay valid head = picture[:at].rstrip() picture = head + ("" if head.endswith((".", "!", "?")) else ".") + " " + sentence + " " \ diff --git a/modules/conditioning/shot_camera/nodes.py b/modules/conditioning/shot_camera/nodes.py index a9644bbe..48e38817 100644 --- a/modules/conditioning/shot_camera/nodes.py +++ b/modules/conditioning/shot_camera/nodes.py @@ -51,14 +51,15 @@ def rewrite(text, values, seconds=None, pieces=None): chance = memory.split_chance(float(values.get("shot_cuts_chance", 0.5))) if splitting else 0.0 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']}") + 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'] or 'none'}") 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()) + pieces=_pieces() if pieces is None else pieces, stats=memory.view_stats(), + arms=memory.arm_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)"))) + views += [{"view": a["view"], "traits": a["traits"]} for a in info["added"] if a["view"]] + said.append("reframes: " + (", ".join(f"shot {a['shot']} to {a['target']}" + (f" ({a['view']})" if a["view"] else "") for a in info["added"]) + if info["added"] else "none" + ("" if info["arms"] else " (no boundary inside a shot moves to something new)"))) 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) diff --git a/modules/conditioning/shot_camera/tests/test_reframe.py b/modules/conditioning/shot_camera/tests/test_reframe.py index ffc97677..c1c384ae 100644 --- a/modules/conditioning/shot_camera/tests/test_reframe.py +++ b/modules/conditioning/shot_camera/tests/test_reframe.py @@ -13,17 +13,26 @@ def test_reframe_turns_the_camera_between_shortcuts_without_a_cut(tmp_path, monk 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" + assert "hand" in tail.split(".")[0] or "fingers" in tail.split(".")[0], "moves to what the next action is about" + assert "reframes: shot 1 to " in said + assert chose["arms"][0] == "reframe:yes" and chose["arms"][1] in ("reframe:keep", "reframe:turn") -def test_the_view_never_repeats_and_respects_what_the_part_shows(): +def test_every_reframe_moves_to_a_new_focus_and_the_view_may_stay(): 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" + targets = [a["target"] for a in info["added"]] + assert len(targets) == 3 and all(t for t in targets) + assert all(a != b for a, b in zip(targets, targets[1:])), "the focus changes at every reframe" + assert all(v != "View from behind" for v in (a["view"] for a in info["added"]) if v), "a smiling face is not turned away from" + kept = sum(1 for seed in range(40) for a in engine.add_reframes(text, seed=seed, chance=1, pieces=[DANCE])[1]["added"] if a["view"] is None) + assert kept > 0, "keeping the view is allowed" + + +def test_a_boundary_with_nothing_new_to_move_to_is_left_alone(): + plain = "She smiles warmly at the room again." + out, info = engine.add_reframes(f"[Shot 1] {plain} {plain}", seed=1, chance=1, pieces=[plain]) + assert "Without a cut" not in out and info["arms"] == [], "no focus change means no reframe and nothing to learn" def test_zero_chance_adds_nothing_and_no_boundary_says_so(tmp_path, monkeypatch): @@ -31,7 +40,7 @@ def test_zero_chance_adds_nothing_and_no_boundary_says_so(tmp_path, monkeypatch) 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 + assert out == "[Shot 1] A woman walks." and "no boundary inside a shot moves to something new" in said def test_a_cut_boundary_is_not_also_reframed(tmp_path, monkeypatch):