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 || [])];