diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f07eb72..19068957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Export settings no longer prints a loader input the node no longer has (a stale `sla_enabled: on`) or SLA's settings while `sla` is off. ### Added +- A prompt with a single `[Shot N]` can now take a view, and Reactive focus offers it. Settings lists every saved shot choice (readable label) with its own Forget, and writes the change to the project at once. - Settings ▸ Refinement & Taste lists what camera focus and views have learned, with Forget per word / view / everything, and a button to forget this project's saved shot choices. - Reactive focus shows each shot as typed (shortcuts unexpanded) and as written, lets you pick several views ("fine from any of these") and several targets in order (the camera travels from the first to the last). - Shot views now learn from ratings (liked = good, disliked = bad; picture-only dislikes teach nothing) and only offer views that can show the shot: a face shot never gets the view from behind, a back or someone leaving never a front/POV view. Reactive focus gains a second step to pick each shot's view. diff --git a/movie_editor/frontend/reactive_focus.js b/movie_editor/frontend/reactive_focus.js index 6dbf2284..1f33a6aa 100644 --- a/movie_editor/frontend/reactive_focus.js +++ b/movie_editor/frontend/reactive_focus.js @@ -14,6 +14,11 @@ .filter((sc) => sc[field].length); } + // What the settings list shows for a saved shot, so it can be recognised and forgotten. + function label(s) { + return `Shot ${s.shot} — ${(s.raw || s.text || "").slice(0, 70)}`; + } + // One page of rows. -> {nodes, collect()}; collect() -> {choices, decisions}. function movesPage(scenes) { const state = new Map(); @@ -36,6 +41,7 @@ const lemmas = st.picks.length ? st.picks : [s.auto_lemma]; choices[s.key] = st.mode === "none" ? { mode: "none", lemma: null } : { mode: st.mode, lemma: lemmas[0], lemmas }; + choices[s.key].label = label(s); decisions.push({ auto: s.auto_lemma, picked: st.mode === "none" ? [] : lemmas, mode: st.mode }); })); return { choices, decisions }; @@ -64,6 +70,7 @@ if (st.mode === "none") choices[s.key] = { mode: "none" }; else if (st.picks.length) choices[s.key] = { mode: "pick", views: st.picks.slice() }; else choices[s.key] = { mode: "auto" }; + choices[s.key].label = label(s); // Accepting "Auto" is not a pick: the draw may land on any allowed view. decisions.push({ auto: s.auto, picked: st.mode === "none" ? [] : st.picks, traits: s.traits }); })); @@ -163,7 +170,7 @@ // Why no shot was offered a view: the honest reason per shot. function whyNone(scenes, saved) { const all = scenes.flatMap((sc) => sc.views || []); - if (!all.length) return "a view only opens shots 2 and later, so the prompt needs two or more [Shot N] blocks."; + if (!all.length) return "the prompt has no [Shot N] block to open with a view."; return all.map((s) => { if (s.already) return `shot ${s.shot} already states “${s.stated}”`; if (saved[s.key]) return `shot ${s.shot} is already decided (forget it in Settings ▸ Refinement & Taste)`; diff --git a/movie_editor/frontend/settings_window.js b/movie_editor/frontend/settings_window.js index 9d4a4e74..bbae8359 100644 --- a/movie_editor/frontend/settings_window.js +++ b/movie_editor/frontend/settings_window.js @@ -228,16 +228,36 @@ const m = await API.focusMemory(); clear(box); const proj = window.Store?.get().project; - const rf = proj && window.StudioSettings ? window.StudioSettings.read(proj).rf : {}; - const saved = Object.keys(rf.focus_choices || {}).length + Object.keys(rf.view_choices || {}).length; - box.append(actionRow("This project's saved choices", - saved ? `${saved} shot choice(s) saved; forgetting them makes Reactive focus ask again.` - : "Nothing saved: Reactive focus asks about every shot.", - "Forget…", () => { - if (!confirm("Forget this project's saved focus and view choices? Reactive focus will ask again.")) return; - window.StudioSettings.patchRefiner({ focus_choices: {}, view_choices: {} }, true); - reload(); - }, { disabled: !saved })); + const rfNow = () => (window.Store?.get().project && window.StudioSettings + ? window.StudioSettings.read(window.Store.get().project).rf : {}); + const rf = rfNow(); + const moves = rf.focus_choices || {}, views = rf.view_choices || {}; + const keys = [...new Set([...Object.keys(moves), ...Object.keys(views)])]; + // Take saved choices back out of the project and write it NOW: waiting for the autosave + // lets a reload inside its delay bring them back. + const dropSaved = async (which) => { + const cur = rfNow(); + const strip = (o) => Object.fromEntries(Object.entries(o || {}).filter(([k]) => !which.includes(k))); + window.StudioSettings.patchRefiner({ focus_choices: strip(cur.focus_choices), view_choices: strip(cur.view_choices) }, true); + try { await window.Store.flushSave?.(); } catch (_) {} + const left = Object.keys(rfNow().focus_choices || {}).concat(Object.keys(rfNow().view_choices || {})) + .filter((k) => which.includes(k)).length; + if (left) alert("Could not forget those choices — they are still in the project."); + reload(); + }; + box.append(actionRow("Saved shot choices in this project", + keys.length ? `${keys.length} shot(s) decided; forgetting one makes Reactive focus ask about it again.` + : "Nothing saved: Reactive focus asks about every shot.", + "Forget all…", () => { + if (confirm("Forget all saved focus and view choices in this project? Reactive focus will ask again.")) dropSaved(keys); + }, { disabled: !keys.length, danger: true })); + keys.forEach((k) => { + const m = moves[k], v = views[k]; + const what = [m && (m.mode === "none" ? "no move" : `${m.mode}: ${(m.lemmas || [m.lemma]).filter(Boolean).join(" → ")}`), + v && (v.mode === "none" ? "no view" : v.views ? `view: ${v.views.join(" / ")}` : `view: ${v.mode}`)] + .filter(Boolean).join(" · "); + box.append(actionRow((m?.label || v?.label || `Shot ${k.slice(0, 6)}…`), what, "Forget", () => dropSaved([k]))); + }); box.append(el("div", "sw-hint", `Remembered across all projects: ${m.prompts} prompt(s), ${m.shots} reviewed shot(s), ${m.kept} kept a move.`)); m.words.forEach((w) => box.append(actionRow(w.word, diff --git a/shot_camera.py b/shot_camera.py index 7856ec69..c6e88bf4 100644 --- a/shot_camera.py +++ b/shot_camera.py @@ -610,7 +610,7 @@ def view_options(text, stats=None): marks = list(SHOT.finditer(text or "")) out = [] for i, m in enumerate(marks): - if not i: + if not i and len(marks) > 1: # see add_shot_views: a lone shot may take a view continue end = marks[i + 1].start() if i + 1 < len(marks) else len(text) picture, _sound = _split_sound(text[m.end():end]) @@ -626,17 +626,18 @@ def view_options(text, stats=None): def add_shot_views(text, seed=0, chance=0.4, stats=None, choices=None, skipped=None): - """-> (new prompt, [{"shot", "view", "traits"}]). Shots 2+ that state no view get one of the + """-> (new prompt, [{"shot", "view", "traits"}]). Shots 2+ (or the only shot) that state no view get one of the views that can show them as a sentence of its own (after the cut opener, if there is one), never the same as the shot before, drawn per shot from the seed and weighted by `stats` (what ratings taught). `choices` {shot_key: {"mode": "auto"|"none"|"pick", "view": str}}: a person's pick is always used, "none" leaves the shot alone. `skipped`, if a list, gets one "shot N: reason" per shot that got no view.""" marks = list(SHOT.finditer(text or "")) - if len(marks) < 2: - if skipped is not None and marks: - skipped.append("only one [Shot N]: a view opens shots 2 and later") + if not marks: return text, [] + # Shot 1 of several may sit on a reference image or a pinned first frame, which a view would + # contradict, so views open shots 2+. A prompt with ONE shot has nothing else to open. + lone = len(marks) == 1 out, added, last = [text[:marks[0].start()]], [], None for i, m in enumerate(marks): end = marks[i + 1].start() if i + 1 < len(marks) else len(text) @@ -644,10 +645,10 @@ def add_shot_views(text, seed=0, chance=0.4, stats=None, choices=None, skipped=N rng = random.Random(f"{seed}:view:{i}") picture, sound = _split_sound(body) view = None - stated = VIEW_STATED.search(CUT.sub("", picture)) if i else None + stated = VIEW_STATED.search(CUT.sub("", picture)) if (i or lone) else None if stated and skipped is not None: skipped.append(f"shot {m.group(1)}: already states “{stated.group(0)}”") - if i and not stated: + if (i or lone) and not stated: traits = view_traits(picture) said = (choices or {}).get(shot_key(picture)) or {} allowed = allowed_views(traits) diff --git a/tests/test_shot_camera.py b/tests/test_shot_camera.py index 9c4420e0..46206b65 100644 --- a/tests/test_shot_camera.py +++ b/tests/test_shot_camera.py @@ -302,7 +302,7 @@ def test_views_respect_chance_zero_and_a_view_already_stated(): assert sc.add_shot_views(P, seed=1, chance=0.0) == (P, []) _out, added = sc.add_shot_views(P, seed=1, chance=1.0) assert [a["shot"] for a in added] == [3] - assert sc.add_shot_views("[Shot 1] only one.", seed=1, chance=1.0)[1] == [] + assert sc.add_shot_views("[Shot 1] only one.", seed=1, chance=1.0)[1][0]["shot"] == 1 # a lone shot takes a view def test_a_view_goes_after_the_cut_opener_and_before_the_action(): @@ -449,8 +449,16 @@ def test_a_body_action_from_behind_is_not_a_stated_view(): def test_views_say_why_a_shot_got_none(): why = [] - sc.add_shot_views("[Shot 1] A cat.", seed=1, chance=1.0, skipped=why) - assert "only one [Shot N]" in why[0] + sc.add_shot_views("[Shot 1] POV view, a cat.", seed=1, chance=1.0, skipped=why) + assert "already states" in why[0] why = [] sc.add_shot_views("[Shot 1] A.\n[Shot 2] POV view, a dog.\n[Shot 3] A bird.", seed=1, chance=0.0, skipped=why) assert any("already states" in w for w in why) and any("by chance" in w for w in why) + + +def test_a_lone_shot_can_take_a_view_but_shot_one_of_several_still_cannot(): + out, added = sc.add_shot_views("[Shot 1] A cat sits.", seed=1, chance=1.0) + assert added and added[0]["shot"] == 1 and out.startswith("[Shot 1] ") + assert sc.view_options("[Shot 1] A cat sits.")[0]["shot"] == 1 + _o, several = sc.add_shot_views("[Shot 1] A.\n[Shot 2] B.", seed=1, chance=1.0) + assert [a["shot"] for a in several] == [2]