From 0d281c5a6e2c8a64b6b71efd5f485b8feee0dc47 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:21:47 +0300 Subject: [PATCH] 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 --- app/ui/features/media/hub.test.js | 15 +++++++++++ modules/conditioning/shot_camera/engine.py | 21 ++++++++++------ modules/conditioning/shot_camera/nodes.py | 2 ++ .../shot_camera/tests/test_cut_openers.py | 25 ++++++++++++++++++- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/app/ui/features/media/hub.test.js b/app/ui/features/media/hub.test.js index 2ed61514..804940b0 100644 --- a/app/ui/features/media/hub.test.js +++ b/app/ui/features/media/hub.test.js @@ -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"); +}); diff --git a/modules/conditioning/shot_camera/engine.py b/modules/conditioning/shot_camera/engine.py index 1665fc40..be35d0af 100644 --- a/modules/conditioning/shot_camera/engine.py +++ b/modules/conditioning/shot_camera/engine.py @@ -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 '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) diff --git a/modules/conditioning/shot_camera/nodes.py b/modules/conditioning/shot_camera/nodes.py index ad801b7f..c71c2c6a 100644 --- a/modules/conditioning/shot_camera/nodes.py +++ b/modules/conditioning/shot_camera/nodes.py @@ -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(), diff --git a/modules/conditioning/shot_camera/tests/test_cut_openers.py b/modules/conditioning/shot_camera/tests/test_cut_openers.py index 5c1fe4e2..662cf010 100644 --- a/modules/conditioning/shot_camera/tests/test_cut_openers.py +++ b/modules/conditioning/shot_camera/tests/test_cut_openers.py @@ -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