diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec629079..dda1338c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
+- Reactive focus (Engine Settings, needs camera moves): before Generate, pick what the camera aims at in each `[Shot N]`, hold or move, or no move. Asked once per shot; picks are remembered and steer later suggestions.
### Fixed
- An H3 latent node (Reference-to-Video / Empty AV Latent) whose `length` is not linked now follows the project's frames, instead of keeping an old typed number that made the sampler refuse the run; the settings export shows it as "follows project".
- A view added by Shot views (POV...) no longer makes the shot look like it already has a camera move.
diff --git a/movie_editor/backend/settings_card.py b/movie_editor/backend/settings_card.py
index 34392b9d..d9392718 100644
--- a/movie_editor/backend/settings_card.py
+++ b/movie_editor/backend/settings_card.py
@@ -261,6 +261,7 @@ _OWNED_BY = {
"output_guidance_strength": ("output_guidance", None),
"h3_phrase_variability": ("h3_phrase_emphasis", ('true',)),
"camera_moves_chance": ("camera_moves", ('true',)),
+ "reactive_focus": ("camera_moves", ('true',)),
"shot_cut_chance": ("shot_cuts", ('true',)),
"shot_view_chance": ("shot_views", ('true',)),
"trajectory_guidance_strength": ("trajectory_guidance", None),
@@ -349,6 +350,7 @@ _EDITOR_DEFAULTS = {
"h3_block_repeat_last_steps": 0,
"h3_phrase_emphasis": False,
"camera_moves": False,
+ "reactive_focus": False,
"camera_moves_chance": 0.7,
"shot_cuts": False,
"shot_cut_chance": 0.0,
diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js
index 79f7147a..eeb919a6 100644
--- a/movie_editor/frontend/api.js
+++ b/movie_editor/frontend/api.js
@@ -171,6 +171,8 @@
j("POST", API(`/projects/${pid}/workflow/apply`), { workflow, bindings }),
restart: () => j("POST", API("/restart")),
systemInfo: () => j("GET", API("/system/info")),
+ focusOptions: (pid, sceneIds) => j("POST", API(`/projects/${pid}/focus_options`), { scene_ids: sceneIds }),
+ focusLearn: (decisions) => j("POST", API("/focus/learn"), { decisions }),
gitStatus: () => j("GET", API("/git/status")),
gitUpdate: (branch) => j("POST", API("/git/update"), branch ? { branch } : {}),
gitCheckout: (branch) => j("POST", API("/git/checkout"), { branch }),
diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js
index e35a88ae..8b906597 100644
--- a/movie_editor/frontend/engine_settings.js
+++ b/movie_editor/frontend/engine_settings.js
@@ -124,6 +124,9 @@
{ name: "camera_moves_chance", label: "Camera move chance", kind: "float", default: 0.7, min: 0.0, max: 1.0, step: 0.05,
dependsOn: "camera_moves", dependsVals: [true],
hint: "How often a [Shot N] block gets camera moves at all. Each one that does gets one to three (mostly one), sometimes travelling from the first thing to the last, sometimes ending with a pull-back — drawn per shot from the seed, so no pattern repeats in every prompt. 1 = every eligible shot." },
+ { name: "reactive_focus", label: "Reactive focus (ask before Generate)", kind: "bool", default: false,
+ dependsOn: "camera_moves", dependsVals: [true],
+ hint: "Before Generate, lists what the camera could aim at in each [Shot N] so you can pick the target, hold on it or move, or skip the move. Asked once per shot; your picks are remembered and steer later suggestions." },
{ name: "shot_cuts", label: "Shot cut times (H3)", kind: "bool", default: false,
hint: "Gives every [Shot N] after the first its cut time (\"At 00:04.000, the camera cuts to...\"), spread over the scene's length and rounded to whole seconds, and adds a cut inside a shot where its main point changes. Prompts that already carry cut times are left alone. Needs the Editor's scene lengths." },
{ name: "shot_cut_chance", label: "Cut-in-shot chance", kind: "float", default: 0.0, min: 0.0, max: 1.0, step: 0.05,
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index db971c43..2096cd6a 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -144,6 +144,7 @@
+
diff --git a/movie_editor/frontend/reactive_focus.js b/movie_editor/frontend/reactive_focus.js
new file mode 100644
index 00000000..01ce4e4e
--- /dev/null
+++ b/movie_editor/frontend/reactive_focus.js
@@ -0,0 +1,101 @@
+// "Reactive focus": before Generate, list what the camera could aim at in each [Shot N],
+// let the person choose (target, hold vs move, or no move), and remember it.
+(function () {
+ const el = (typeof window !== "undefined" && window.dom) ? window.dom.el : null;
+ const MODES = [["auto", "Auto"], ["move", "Move"], ["hold", "Hold"], ["none", "No move"]];
+
+ // Only shots the person has not decided yet (choices are keyed by the shot's own text).
+ function pending(scenes, saved) {
+ return scenes.map((sc) => ({ ...sc, shots: sc.shots.filter((s) => !s.already && s.candidates.length && !saved[s.key]) }))
+ .filter((sc) => sc.shots.length);
+ }
+
+ // -> Promise<{choices, decisions} | null> (null = cancelled)
+ function ask(scenes) {
+ return new Promise((resolve) => {
+ const O = window.OverlayUI;
+ let done = false;
+ const { body, foot, close } = O.openModal({
+ title: "Reactive focus",
+ subtitle: "Where should the camera look in each shot? Your picks are remembered and steer future suggestions.",
+ widthClass: "ov-modal-wide",
+ onClose: () => { if (!done) { done = true; resolve(null); } },
+ });
+ const state = new Map(); // shot key -> {mode, lemma|null}
+ scenes.forEach((sc) => {
+ body.appendChild(el("div", "ov-label", `Scene ${sc.index + 1} — ${sc.preview}`));
+ sc.shots.forEach((s) => {
+ const st = { mode: "auto", lemma: null };
+ state.set(s.key, st);
+ const row = el("div", "ov-field full");
+ row.appendChild(el("span", "ov-label", `Shot ${s.shot}`));
+ const chips = el("div", "ov-checklist");
+ const chipBtns = s.candidates.slice(0, 6).map((c) => {
+ const b = el("button", "btn", c.text);
+ b.type = "button";
+ b.onclick = () => { st.lemma = st.lemma === c.lemma ? null : c.lemma; paint(); };
+ return [b, c];
+ });
+ chipBtns.forEach(([b]) => chips.appendChild(b));
+ const sel = document.createElement("select");
+ sel.className = "ov-input";
+ MODES.forEach(([v, t]) => { const o = document.createElement("option"); o.value = v; o.textContent = t; sel.appendChild(o); });
+ sel.onchange = () => { st.mode = sel.value; paint(); };
+ function paint() {
+ chipBtns.forEach(([b, c]) => b.classList.toggle("primary", (st.lemma || s.auto_lemma) === c.lemma));
+ chips.style.opacity = st.mode === "none" ? "0.4" : "";
+ }
+ paint();
+ row.append(chips, sel);
+ body.appendChild(row);
+ });
+ });
+ const finish = (useChoices) => {
+ const choices = {}, decisions = [];
+ if (useChoices) {
+ scenes.forEach((sc) => sc.shots.forEach((s) => {
+ const st = state.get(s.key);
+ choices[s.key] = { mode: st.mode, lemma: st.mode === "none" ? null : (st.lemma || s.auto_lemma) };
+ decisions.push({ auto: s.auto_lemma, picked: choices[s.key].lemma, mode: st.mode });
+ }));
+ }
+ done = true;
+ close();
+ resolve({ choices, decisions });
+ };
+ const cancel = el("button", "btn", "Cancel");
+ const skip = el("button", "btn", "Skip — let it choose");
+ const go = el("button", "btn primary", "Generate");
+ cancel.onclick = () => close();
+ skip.onclick = () => finish(false);
+ go.onclick = () => finish(true);
+ foot.append(cancel, skip, go);
+ });
+ }
+
+ // Called by Store before it queues a run. -> false to abort the run.
+ async function review(project, sceneIds) {
+ const { rf } = window.StudioSettings.read(project);
+ if (!rf.camera_moves || !rf.reactive_focus) return true;
+ let scenes;
+ try {
+ scenes = (await window.MovieEditorAPI.focusOptions(project.id, sceneIds)).scenes || [];
+ } catch (e) {
+ console.warn("[FunPack] reactive focus: could not list targets —", e.message);
+ return true;
+ }
+ const saved = (rf.focus_choices && typeof rf.focus_choices === "object") ? rf.focus_choices : {};
+ const todo = pending(scenes, saved);
+ if (!todo.length) return true;
+ const res = await ask(todo);
+ if (!res) return false;
+ if (res.decisions.length) {
+ window.StudioSettings.patchRefiner({ focus_choices: { ...saved, ...res.choices } }, true);
+ window.MovieEditorAPI.focusLearn(res.decisions).catch((e) => console.warn("[FunPack] focus memory:", e.message));
+ }
+ return true;
+ }
+
+ if (typeof window !== "undefined") window.ReactiveFocus = { review, pending };
+ if (typeof module !== "undefined") module.exports = { pending };
+})();
diff --git a/movie_editor/frontend/reactive_focus.test.js b/movie_editor/frontend/reactive_focus.test.js
new file mode 100644
index 00000000..7f30c8e8
--- /dev/null
+++ b/movie_editor/frontend/reactive_focus.test.js
@@ -0,0 +1,12 @@
+const test = require("node:test");
+const assert = require("node:assert");
+const { pending } = require("./reactive_focus.js");
+
+const shot = (key, extra) => ({ shot: 1, key, already: false, auto_lemma: "a", candidates: [{ lemma: "a", text: "a" }], ...extra });
+
+test("asks only about shots not decided yet, and skips shots with a camera move already", () => {
+ const scenes = [{ index: 0, preview: "p", shots: [shot("k1"), shot("k2"), shot("k3", { already: true }), shot("k4", { candidates: [] })] }];
+ const out = pending(scenes, { k1: { mode: "auto" } });
+ assert.deepStrictEqual(out[0].shots.map((s) => s.key), ["k2"]);
+ assert.deepStrictEqual(pending(scenes, { k1: 1, k2: 1 }), []);
+});
diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js
index 7051368d..98cc9f31 100644
--- a/movie_editor/frontend/store.js
+++ b/movie_editor/frontend/store.js
@@ -3732,6 +3732,9 @@
async function _generateRun(sceneIds, onlyScene, prefix, resetSession, extraOverrides) {
_interrupted = false;
+ if (!extraOverrides && window.ReactiveFocus && state.project) {
+ if (!(await window.ReactiveFocus.review(state.project, onlyScene ? [onlyScene] : sceneIds))) return false;
+ }
set({ gen: { state: "queuing", promptId: null, media: [], msg: `${prefix}: queuing…`, step: 0, maxStep: 0 } });
try {
const overrides = [...(_anchorGuideNodeOverrides(sceneIds) || []), ...(extraOverrides || [])];