fix: more forms of a user-written cut or transition are kept without a second opener; cut times written anywhere leave the prompt alone; the status line names the transitions used; tests for the bin badge repaint

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-13 05:21:47 +03:00
co-authored by Claude Sonnet 5.5
parent 848cf2e041
commit 0d281c5a6e
4 changed files with 55 additions and 8 deletions
+15
View File
@@ -48,3 +48,18 @@ test("the selected scene's references are marked on their tiles, and the menu ta
clear.click();
assert.deepEqual(set, [["s1", "references", ["m1"]], ["s1", "references", []]]);
});
test("a changed pin repaints the slot numbers on the tiles even when the list of references is the same", async () => {
const fixed = { m1: 2 };
const project = { selected: { id: "s1", references: ["m1", "m2"] }, project: { fixed_references: fixed }, setScene: () => {} };
const api = { media: async () => ({ media: [{ id: "m1", name: "cat.png", kind: "image" }, { id: "m2", name: "dog.png", kind: "image" }] }) };
const host = document.createElement("div");
let fire;
hub.setup({ host, app: { project, api, mediaPeek: {}, say: () => {}, on: (cb) => { fire = cb; return () => {}; } } });
await new Promise((r) => setTimeout(r, 10));
const flag = (n) => [...[...host.querySelectorAll(".cx-cell")].find((x) => x.textContent.includes(n)).querySelectorAll(".cx-cell-flag")].map((x) => x.textContent).join();
assert.deepEqual([flag("cat.png"), flag("dog.png")], ["R2", "R1"], "pinned at 2, the other takes the free number");
delete fixed.m1;
fire("project");
assert.deepEqual([flag("cat.png"), flag("dog.png")], ["R1", "R2"], "unpinned: back to list order");
});
+14 -7
View File
@@ -25,7 +25,11 @@ AUDIO = re.compile(r"\b(music|soundtrack|score|melody|instrumental|beat|bassline
# H3's own vocabulary (VIDEO_PROMPT_WRITING_GUIDE_base_en.md): a shot opening "the camera cuts
# to" is a cut, not a camera move, and must not make the shot look like it already has one.
# Every cut or transition verb this module writes or a user is likely to write after "the shot" / "the camera".
_CUT_VERB = r"(?:cuts|transitions|changes|switches|cross-dissolves|dissolves|wipes|fades)(?:\s+(?:out|across))?"
_CUT_VERB = r"(?:cuts|transitions|changes|switches|cross-dissolves|cross-fades|dissolves|wipes|fades)(?:\s+(?:out|across))?"
# A cut or transition a user may open a shot with: "The shot slowly fades to", "Then the camera cuts to", "Smash cut to", "Fade to black".
_LEAD_PHRASE = (r"(?:(?:then|suddenly|next)[,\s]+)?(?:(?:the\s+)?(?:camera|shot|scene)\s+(?:(?:then|slowly|quickly|suddenly)\s+)?" + _CUT_VERB
+ r"|(?:an?\s+)?(?:smash\s+cut|hard\s+cut|match\s+cut|jump\s+cut|cut|cross-?dissolve|dissolve|cross-?fade|fade|wipe)(?:\s+(?:out|in|through\s+black))?)"
r"\s+(?:to|into|in\s+on)\b")
CUT = re.compile(r"\b(?:the\s+)?(?:camera|shot)\s+" + _CUT_VERB + r"\s+to\b", re.I)
# Whether a shot ALREADY has a camera move. Words like tilt, roll, pull, push, arc, focus are
# ordinary body movement ("tilts <Subject 1>'s head up and down", "pulls out a knife"), and
@@ -603,6 +607,7 @@ def add_camera_moves(text, seed=None, chance=1.0, prior=None, choices=None, arms
# a view and a focus. Never inside one shortcut. Nothing in a prompt knows the video's
# length, so the caller hands it in (seconds).
STAMP = re.compile(r"\b\d\d:\d\d\.\d{3}\b")
ANY_STAMP = re.compile(r"\bAt\s+\d{1,2}:\d{2}(?:\.\d+)?\s*,|\b\d\d:\d\d\.\d{3}\b", re.I)
# The official MiniMax-H3 guide (docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md) approves these ordinary cut phrases.
# Dissolve, fade and wipe are for when the user asks for them, never default.
# Each cut says what is new: a new angle, place, subject, viewpoint, state or moment. Only these four verbs.
@@ -621,7 +626,7 @@ CUT_OPENERS = ("the camera cuts to a new angle", "the camera cuts to the next mo
"the shot changes to a new view", "the shot changes to what comes next",
"the shot switches to what follows", "the shot switches to the next moment")
OUR_OPENERS = CUT_OPENERS + tuple(phrase for _, phrase in TRANSITIONS) # everything this module writes after a cut time
LEAD_CUT = re.compile(r"^\s*(?:the\s+)?(?:camera|shot)\s+" + _CUT_VERB + r"\s+to\b", re.I)
LEAD_CUT = re.compile(r"^\s*" + _LEAD_PHRASE, re.I)
MIN_SHOT_SECONDS = 2
@@ -685,7 +690,7 @@ def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=(), transition=0.0):
return text, info
bodies = [text[m.end():(marks[i + 1].start() if i + 1 < len(marks) else len(text))]
for i, m in enumerate(marks)]
if any(STAMP.search(b[:60]) for b in bodies[1:]):
if any(ANY_STAMP.search(b) for b in bodies):
info["why"] = "cut times are already written, left as they are"
return text, info
header = text[:marks[0].start()]
@@ -726,7 +731,7 @@ def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=(), transition=0.0):
info["arms"] = []
return text, info
out = [header]
last = None # the transition kind of the cut before this one
last, kinds = None, [] # the transition kind of the cut before this one, and every kind used
for k, (picture, sound) in enumerate(blocks):
label = f"[Shot {k + 1}]"
body = picture
@@ -738,11 +743,13 @@ def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=(), transition=0.0):
last = None
else:
opener, last = _opener(rng, transition, last)
if last:
kinds.append(last)
body = " " + stamp + opener + ". " + body.lstrip()
else:
body = body if body.startswith(" ") else " " + body
out.append(label + body + sound)
info.update(after=len(blocks), times=[_stamp(t) for t in times])
info.update(after=len(blocks), times=[_stamp(t) for t in times], transitions=kinds)
return "".join(out), info
@@ -983,7 +990,7 @@ def own_words_removed(picture):
_OUR_OPENER = re.compile(r"^\s*At \d\d:\d\d\.\d{3},\s*(?:" + "|".join(re.escape(o) for o in OUR_OPENERS)
+ r")\.\s*", re.I)
_CUT_WORDS = re.compile(r"^\s*(?:At \d\d:\d\d\.\d{3},\s*)?(?:the\s+)?(?:camera|shot)\s+" + _CUT_VERB + r"\s+to\b\s*", re.I)
_CUT_WORDS = re.compile(r"^\s*(?:At \d\d:\d\d\.\d{3},\s*)?" + _LEAD_PHRASE + r"\s*", re.I)
def content_fingerprint(text):
@@ -993,7 +1000,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 OUR_OPENERS) + r")\.",
" ", body, flags=re.I)
body = re.sub(r"At \d\d:\d\d\.\d{3},\s*(?=(?:the\s+)?(?:camera|shot)\s+" + _CUT_VERB + r"\s+to\b)", " ", body, flags=re.I) # the time before a cut phrase the user wrote
body = re.sub(r"At \d\d:\d\d\.\d{3},\s*(?=" + _LEAD_PHRASE + r")", " ", body, flags=re.I) # the time before a cut phrase the user wrote
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)
@@ -53,6 +53,8 @@ def rewrite(text, values, seconds=None, pieces=None):
transition=float(values.get("cut_difference_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"):
said[-1] += f" · transitions: {', '.join(info['transitions'])}"
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(),
@@ -86,7 +86,12 @@ import pytest
@pytest.mark.parametrize("phrase", ["The shot fades to black.", "The shot fades out to black.", "The shot dissolves to her hand.",
"The camera wipes across to her hand.", "The shot cross-dissolves to her hand."])
"The camera wipes across to her hand.", "The shot cross-dissolves to her hand.",
"Fade to black.", "Cut to her hand.", "Dissolve to her hand.", "A cross-dissolve to her hand.",
"Smash cut to her hand.", "The shot cross-fades to her hand.", "Then the camera cuts to her hand.",
"The camera then cuts to her hand.", "The shot slowly fades to black.",
"The shot quickly wipes across to her hand.", "The shot dissolves into her hand.",
"The scene fades to black."])
def test_the_cut_phrases_the_hint_names_are_kept_without_a_second_opener(monkeypatch, tmp_path, phrase):
monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json"))
out, _ = engine.add_shot_cuts(f"[Shot 1] {DANCE} [Shot 2] {phrase} {WAVE}", 10, seed=1, chance=0, pieces=[DANCE, WAVE], transition=1.0)
@@ -115,3 +120,21 @@ def test_each_cut_rolls_for_itself_so_a_shot_can_mix_plain_and_transition_cuts(m
plain = sum(out.count(o) for o in engine.CUT_OPENERS)
mixes += plain > 0 and sum(out.count(p) for _, p in engine.TRANSITIONS) > 0
assert mixes > 5
@pytest.mark.parametrize("stamp", ["At 00:04.000, the camera cuts to the door.", "At 00:03, the camera cuts to her hand.",
"At 0:03.5, the shot cuts to her hand."])
def test_cut_times_the_user_wrote_anywhere_leave_the_prompt_alone(monkeypatch, tmp_path, stamp):
monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json"))
text = f"[Shot 1] {DANCE} [Shot 2] {WAVE} " + "and she keeps going " * 6 + stamp
out, info = engine.add_shot_cuts(text, 12, seed=1, chance=1, pieces=[DANCE, WAVE], transition=1.0)
assert out == text and "already written" in info["why"], stamp
def test_the_status_line_says_which_transitions_were_used(monkeypatch, tmp_path):
monkeypatch.setenv("SHOT_CAMERA_MEMORY", str(tmp_path / "m.json"))
said = nodes.rewrite(f"[Shot 1] {DANCE} {WAVE} {DANCE}", {"cut_same_shot": True, "shot_cuts_chance": 1, "cut_difference_chance": 1},
seconds=12, pieces=[DANCE, WAVE])[1]
assert "transitions: " in said and any(k in said for k in ("dissolve", "fade", "wipe")), said
plain = nodes.rewrite(f"[Shot 1] {DANCE} {WAVE} {DANCE}", {"cut_same_shot": True, "shot_cuts_chance": 1}, seconds=12, pieces=[DANCE, WAVE])[1]
assert "transitions" not in plain