diff --git a/app/ui/composer/elements/gallery.js b/app/ui/composer/elements/gallery.js index 70beda60..40362b45 100644 --- a/app/ui/composer/elements/gallery.js +++ b/app/ui/composer/elements/gallery.js @@ -81,7 +81,7 @@ function collection({ items = [], empty = EMPTY, selection = [], onActivate, onC kept = next; const now = [...host.children]; // a rebuilt tile swaps in place when every other tile stays where it is, so a tile with the keyboard focus keeps it - if (now.length === cells.length && cells.every((cell, i) => cell === now[i] || !cell.parentNode)) cells.forEach((cell, i) => { if (cell !== now[i]) now[i].replaceWith(cell); }); + if (now.length === cells.length && cells.every((cell, i) => cell === now[i] || !cell.parentNode)) cells.forEach((cell, i) => { if (cell === now[i]) return; const had = now[i] === document.activeElement; now[i].replaceWith(cell); if (had) cell.focus({ preventScroll: true }); }); else if (now.length !== cells.length || cells.some((cell, i) => cell !== now[i])) host.replaceChildren(...cells); } draw(); diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index cc4ce33c..6ca2c7df 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -18,7 +18,6 @@ const mediaField = (app, p, sc, { label, hint, key, multiple, kinds, empty }) => c.button.sm({ label: "✕", tone: "ghost", disabled: !ids.length, title: "Clear", onClick: () => set([]) })] }) }); }; -/** One "Pin reference to the slot" per reference: a pinned one keeps its R-number when an earlier reference is switched off. */ /** A number-or-mode pair: "Project default / Timeline trim / Custom", and the number only when Custom. */ const lengthField = (p, sc, label, modeKey, valueKey, shown, grid) => { const mode = sc[modeKey] || "project"; diff --git a/app/ui/features/media/hub.test.js b/app/ui/features/media/hub.test.js index 9aea1ad2..569e46ca 100644 --- a/app/ui/features/media/hub.test.js +++ b/app/ui/features/media/hub.test.js @@ -122,3 +122,23 @@ test("redrawing the bin keeps keyboard focus on the tile that has it", async () const now = [...host.querySelectorAll(".cx-cell")].find((x) => x.textContent.includes("dog.png")); assert.ok(was, "focusable"); assert.equal(now, tile, "same tile node"); assert.equal(document.activeElement, tile, "the focused tile survives the redraw"); host.remove(); }); + +test("pinning the first reference repaints its tile even though no number moves; toggling the focused tile keeps its focus", async () => { + const fixed = {}; + 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"); + document.body.append(host); + let fire; + hub.setup({ host, app: { project, api, mediaPeek: {}, say: () => {}, on: (cb) => { fire = cb; return () => {}; } } }); + await new Promise((r) => setTimeout(r, 10)); + const cell = (n) => [...host.querySelectorAll(".cx-cell")].find((x) => x.textContent.includes(n)); + fixed.m1 = 1; + fire("project"); + assert.match(cell("cat.png").textContent, /📌R1/, "pinned: the pin shows"); + cell("dog.png").focus(); + project.selected.references = ["m1"]; + fire("project"); + assert.equal(document.activeElement, cell("dog.png"), "the focused tile, rebuilt, keeps the focus"); + host.remove(); +}); diff --git a/app/ui/features/media/index.js b/app/ui/features/media/index.js index 0f4a57e7..ff596be2 100644 --- a/app/ui/features/media/index.js +++ b/app/ui/features/media/index.js @@ -114,7 +114,7 @@ export default { let drawn = ""; function draw(force) { const sc = p.selected, plan = sc ? sceneReferences(sc, (p.project || {}).fixed_references) : { ids: [], pins: {} }, refs = plan.ids, nums = referenceNumbers(refs, plan.pins); - const key = `${(sc || {}).source_image}|${refs}|${nums}|${items.map((m) => m.id)}|${selecting}|${[...chosen]}|${show.filter}`; + const key = `${(sc || {}).source_image}|${refs}|${nums}|${JSON.stringify(plan.pins)}|${items.map((m) => m.id)}|${selecting}|${[...chosen]}|${show.filter}`; if (!force && key === drawn) return; // typing elsewhere must not rebuild the thumbnails drawn = key; const visible = new Set(items.filter((m) => show.filter === "all" || m.kind === show.filter).map((m) => m.id)); diff --git a/modules/conditioning/shot_camera/__init__.py b/modules/conditioning/shot_camera/__init__.py index 2a3fd493..15f629ee 100644 --- a/modules/conditioning/shot_camera/__init__.py +++ b/modules/conditioning/shot_camera/__init__.py @@ -52,7 +52,7 @@ SETTINGS = { "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."}, + "hint": "0 = never. 1 = a camera focus always aims at a word your prompts keep returning to, not at the shot's own. Needs camera moves or reframes on, and a few prompts seen. Ratings nudge the values in between."}, "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 ad4fcc40..481ceb2f 100644 --- a/modules/conditioning/shot_camera/engine.py +++ b/modules/conditioning/shot_camera/engine.py @@ -550,12 +550,15 @@ def _own_words(): return {w for t in texts for w in re.findall(r"[a-z]+", re.sub(r"\{[a-z]+\}", " ", t.lower()))} +ORDINARY = {"back", "side", "front", "pan", "track", "arc", "hold"} + + def _infused(picture, taken, bank, rng): """-> a candidate for the camera to aim at, drawn from the words the user's prompts keep returning to (`bank`: [(lemma, weight)]), or None. Never a word the shot already names (that would change nothing) and never one of this module's own words.""" - own = _own_words() - mine = lambda lemma: lemma in own or any(lemma + end in own for end in ("s", "es", "d", "ed", "ing")) # "zoom" for "zooms", "fade" for "fades" + own = _own_words() - ORDINARY # words that are also plain nouns (a person's back, a frying pan) stay available + mine = lambda lemma: lemma not in ORDINARY and (lemma in own or any(lemma + end in own for end in ("s", "es", "d", "ed", "ing"))) # "zoom" for "zooms", "fade" for "fades" options = [(lemma, w) for lemma, w in bank if not mine(lemma) and lemma not in taken and w > 0] if not options: return None @@ -638,7 +641,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*,\s*(?:the\s+)?(?:shot|camera|scene|cut|\w+\s+cut|" + _CUT_VERB + r")\b|\b\d\d:\d\d\.\d{3}\b", re.I) +ANY_STAMP = re.compile(r"\bAt\s+\d{1,2}:\d{2}(?:\.\d+)?\s*,\s*(?:(?:the\s+)?(?:shot|camera)\s+(?:\w+ly\s+)?" + _CUT_VERB + r"|(?:\w+\s+)?cut\s+(?:to|away|back|into)|" + _CUT_VERB + r")\b|\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. diff --git a/modules/conditioning/shot_camera/tests/test_shot_length.py b/modules/conditioning/shot_camera/tests/test_shot_length.py index 262c8faf..f5db38a0 100644 --- a/modules/conditioning/shot_camera/tests/test_shot_length.py +++ b/modules/conditioning/shot_camera/tests/test_shot_length.py @@ -97,3 +97,13 @@ def test_the_chance_follows_the_slider_at_the_ends_and_ratings_tilt_it_between() def test_a_clock_time_in_the_story_does_not_count_as_a_written_cut_time(): assert not engine.ANY_STAMP.search("She arrives at 8:30, and sits.") assert engine.ANY_STAMP.search("At 00:04.000, the shot cuts to her hand.") + + +@pytest.mark.parametrize("line", ["At 9:30, he cut the rope.", "At 9:30, she cut the cake slowly.", "At 9:30, the scene opens in a park."]) +def test_a_clock_time_before_ordinary_story_text_is_not_a_written_cut_time(line): + assert not engine.ANY_STAMP.search(line) + + +def test_words_that_are_also_plain_nouns_can_be_infused(): + got = {engine._infused("", set(), [("back", 1), ("pan", 1)], __import__("random").Random(k))[0] for k in range(40)} + assert got == {"back", "pan"}