feat: uneven shot lengths with a chance slider; busier text tends to run longer, ratings tilt the chance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
27b3ae6591
commit
55b685ed40
@@ -49,6 +49,8 @@ SETTINGS = {
|
||||
"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}},
|
||||
"uneven_shots_chance": {"type": "float", "default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05, "label": "Chance of an uneven shot length",
|
||||
"hint": "0 = equal shots. 1 = every shot's length is changed, busier text tending to run longer. Needs a cut option on. Ratings nudge the values in between."},
|
||||
"focus_infusion_chance": {"type": "float", "default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05, "label": "Chance of focus infusion",
|
||||
"hint": "0 = never. 1 = a camera focus always aims at a word your prompts keep returning to, not at the shot's own. Ratings nudge the values in between."},
|
||||
"shot_views": {"type": "bool", "default": False, "label": "Shot views",
|
||||
|
||||
@@ -706,7 +706,30 @@ def _opener(rng, difference, last):
|
||||
return phrase, kind
|
||||
|
||||
|
||||
def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=(), transition=0.0):
|
||||
def _screen_time(blocks, seed, uneven, arms):
|
||||
"""-> one weight per shot, or None when every shot gets an equal share (`uneven` 0, or one shot).
|
||||
With odds `uneven` a shot's share is changed, longer or shorter; the busier its text compared with the
|
||||
others, the likelier longer and the more of it (damped, so no shot is a blink beside another).
|
||||
1 changes every shot. `arms` collects "length:yes" / "length:no" for the rating that follows."""
|
||||
if uneven <= 0 or len(blocks) < 2:
|
||||
return None
|
||||
words = [max(1, len(blocks[k][0].split())) for k in range(len(blocks))]
|
||||
mean = sum(words) / len(words)
|
||||
weights = []
|
||||
for k, count in enumerate(words):
|
||||
rng = random.Random(f"{seed}:length:{k}")
|
||||
if rng.random() >= uneven:
|
||||
weights.append(1.0)
|
||||
arms.append("length:no")
|
||||
continue
|
||||
busy = count / mean
|
||||
longer = rng.random() < min(0.9, max(0.1, 0.5 + 0.4 * (busy - 1)))
|
||||
weights.append((1.4 + 0.6 * rng.random() if longer else 0.5 + 0.3 * rng.random()) * busy ** 0.5)
|
||||
arms.append("length:yes")
|
||||
return weights
|
||||
|
||||
|
||||
def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=(), transition=0.0, uneven=0.0):
|
||||
"""-> (new prompt, info). A shot is cut between the shortcuts inside it, including the same
|
||||
shortcut used again; every shot after the first then opens with its cut time, spread evenly
|
||||
over `seconds` and rounded to whole seconds. A prompt that already carries cut times is left
|
||||
@@ -751,9 +774,13 @@ def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=(), transition=0.0):
|
||||
# it gave one-second shots next to five-second ones).
|
||||
n = len(blocks)
|
||||
gap = MIN_SHOT_SECONDS if n * MIN_SHOT_SECONDS <= seconds else 1
|
||||
weights = _screen_time(blocks, seed, uneven, info["arms"])
|
||||
cum = [0.0]
|
||||
for w in weights or ():
|
||||
cum.append(cum[-1] + w)
|
||||
times = []
|
||||
for k in range(1, n):
|
||||
t = round(k * seconds / n)
|
||||
t = round(k * seconds / n) if weights is None else round(seconds * cum[k] / cum[-1])
|
||||
t = max(t, (times[-1] if times else 0) + gap) # not too close behind
|
||||
t = min(t, int(seconds) - (n - k) * gap) # leave room for the shots after
|
||||
times.append(t)
|
||||
|
||||
@@ -361,6 +361,12 @@ def split_chance(chance):
|
||||
return _odds(chance, _lean("split:yes", "split:no"))
|
||||
|
||||
|
||||
def length_chance(chance):
|
||||
"""The chance a shot's length is changed from an equal share, tilted by whether such runs were liked
|
||||
(0 and 1 kept as set)."""
|
||||
return _odds(chance, _lean("length:yes", "length:no"))
|
||||
|
||||
|
||||
def infuse_chance(chance):
|
||||
"""The chance a camera focus is replaced by a word the user's prompts keep returning to, tilted by whether
|
||||
runs that did it were liked (0 and 1 kept as set)."""
|
||||
|
||||
@@ -52,7 +52,8 @@ def rewrite(text, values, seconds=None, pieces=None):
|
||||
if (values.get("shot_cuts") or splitting) and engine.SHOT.search(text or ""):
|
||||
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,
|
||||
transition=float(values.get("cut_difference_chance", 0.0)))
|
||||
transition=float(values.get("cut_difference_chance", 0.0)),
|
||||
uneven=memory.length_chance(float(values.get("uneven_shots_chance", 0.0))))
|
||||
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'] or 'none'}")
|
||||
if info.get("transitions"):
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Uneven shot lengths: a shot's share of the video is now and then changed, busier text tending to run longer."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import engine, memory, nodes
|
||||
|
||||
SHORT = "She waves."
|
||||
MID = "She turns and smiles at the camera."
|
||||
BUSY = ("She picks up the heavy red umbrella from the wet cobblestone street while the rain keeps falling "
|
||||
"on her shoulders and a car passes slowly behind her.")
|
||||
TEXT = f"[Shot 1] {SHORT} {BUSY} {MID}"
|
||||
PIECES = [SHORT, BUSY, MID]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json"))
|
||||
|
||||
|
||||
def _times(out):
|
||||
return [int(m.group(1)) * 60 + int(m.group(2)) for m in re.finditer(r"At (\d\d):(\d\d)\.000", out)]
|
||||
|
||||
|
||||
def _cut(seconds=30, seed=0, uneven=0.0, text=TEXT, pieces=PIECES):
|
||||
out, info = engine.add_shot_cuts(text, seconds, seed=seed, chance=1, pieces=pieces, uneven=uneven)
|
||||
return _times(out), info
|
||||
|
||||
|
||||
def test_at_zero_the_shots_are_equal_and_nothing_is_recorded():
|
||||
times, info = _cut(seconds=12)
|
||||
assert times == [4, 8]
|
||||
assert not [a for a in info["arms"] if a.startswith("length:")]
|
||||
|
||||
|
||||
def test_at_one_every_shot_is_changed_and_the_times_stay_valid():
|
||||
seen = set()
|
||||
for seed in range(60):
|
||||
times, info = _cut(seconds=30, seed=seed, uneven=1.0)
|
||||
seen.add(tuple(times))
|
||||
assert len(times) == 2 and 0 < times[0] < times[1] < 30, times
|
||||
durations = [times[0], times[1] - times[0], 30 - times[1]]
|
||||
assert min(durations) >= engine.MIN_SHOT_SECONDS, (times, durations)
|
||||
assert [a for a in info["arms"] if a.startswith("length:")] == ["length:yes"] * 3
|
||||
assert len(seen) > 5, "the lengths differ from take to take"
|
||||
assert (10, 20) not in seen or len(seen) > 5
|
||||
|
||||
|
||||
def test_busier_text_tends_to_run_longer():
|
||||
short, busy = [], []
|
||||
for seed in range(150):
|
||||
times, _ = _cut(seconds=30, seed=seed, uneven=1.0)
|
||||
short.append(times[0])
|
||||
busy.append(times[1] - times[0])
|
||||
assert sum(busy) / len(busy) > sum(short) / len(short) + 2, "the busy shot gets more screen time on average"
|
||||
|
||||
|
||||
def test_between_zero_and_one_the_share_of_changed_shots_follows_the_slider():
|
||||
blocks = [[SHORT, ""], [BUSY, ""], [MID, ""]]
|
||||
for uneven in (0.2, 0.5, 0.8):
|
||||
yes = total = 0
|
||||
for seed in range(300):
|
||||
arms = []
|
||||
engine._screen_time(blocks, seed, uneven, arms)
|
||||
yes += arms.count("length:yes")
|
||||
total += len(arms)
|
||||
assert abs(yes / total - uneven) < 0.07, uneven
|
||||
|
||||
|
||||
def test_the_same_seed_gives_the_same_lengths_and_a_single_shot_is_left_alone():
|
||||
assert _cut(seed=3, uneven=1.0)[0] == _cut(seed=3, uneven=1.0)[0]
|
||||
assert engine._screen_time([[SHORT, ""]], 1, 1.0, []) is None
|
||||
|
||||
|
||||
def test_the_slider_set_in_the_node_changes_the_lengths_and_zero_does_not():
|
||||
base = {"cut_same_shot": True, "shot_cuts_chance": 1}
|
||||
equal = _times(nodes.rewrite(TEXT, base, seconds=30, pieces=PIECES)[0])
|
||||
assert equal == [10, 20]
|
||||
assert _times(nodes.rewrite(TEXT, {**base, "uneven_shots_chance": 0}, seconds=30, pieces=PIECES)[0]) == equal
|
||||
changed = {tuple(_times(nodes.rewrite(TEXT, {**base, "uneven_shots_chance": 1, "variation": v}, seconds=30, pieces=PIECES)[0])) for v in range(20)}
|
||||
assert changed != {tuple(equal)} and len(changed) > 3
|
||||
|
||||
|
||||
def test_the_chance_follows_the_slider_at_the_ends_and_ratings_tilt_it_between():
|
||||
assert memory.length_chance(0.0) == 0.0 and memory.length_chance(1.0) == 1.0 and memory.length_chance(0.5) == 0.5
|
||||
memory.record_run("a", {"views": [], "arms": ["length:yes"]})
|
||||
memory.on_rating("a", "liked")
|
||||
assert memory.length_chance(0.5) > 0.5 and memory.length_chance(1.0) == 1.0
|
||||
memory.record_run("b", {"views": [], "arms": ["length:yes", "length:no"]})
|
||||
for k in range(10):
|
||||
memory.record_run(f"d{k}", {"views": [], "arms": ["length:yes"]})
|
||||
memory.on_rating(f"d{k}", "disliked")
|
||||
assert memory.length_chance(0.5) < 0.5 and memory.length_chance(0.0) == 0.0
|
||||
Reference in New Issue
Block a user