fix: a pin repaints its tile at once; a rebuilt focused tile keeps focus; clock-time guard needs a real cut phrase; plain nouns (back, pan) can be infused; infusion hint names its prerequisites

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-13 06:21:06 +03:00
co-authored by Claude Sonnet 5.5
parent 4bb2ac56cc
commit 57ea1a70b6
7 changed files with 39 additions and 7 deletions
+1 -1
View File
@@ -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();
-1
View File
@@ -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";
+20
View File
@@ -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();
});
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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",
+6 -3
View File
@@ -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.
@@ -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"}