feat: Reactive focus modal before Generate
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
5d419c97d9
commit
48446195ed
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }), []);
|
||||
});
|
||||
@@ -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 || [])];
|
||||
|
||||
Reference in New Issue
Block a user