feat: Reactive focus modal before Generate

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-30 20:11:43 +03:00
co-authored by Claude Sonnet 5.5
parent 5d419c97d9
commit 48446195ed
8 changed files with 125 additions and 0 deletions
+1
View File
@@ -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.
+2
View File
@@ -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,
+2
View File
@@ -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 }),
+3
View File
@@ -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,
+1
View File
@@ -144,6 +144,7 @@
<script src="overlay_preview.js"></script>
<script src="sampler_panel.js"></script>
<script src="engine_settings.js"></script>
<script src="reactive_focus.js"></script>
<script src="editor_settings.js"></script>
<script src="temp_browser.js"></script>
<script src="block_sweep.js"></script>
+101
View File
@@ -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 };
})();
@@ -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 }), []);
});
+3
View File
@@ -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 || [])];