diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 08184aca..e4337da3 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -622,6 +622,14 @@ class Project: # Guide stack toggles — empty / stack_enabled=false keeps Studio carry behaviour. guide_settings: dict = field(default_factory=dict) continuity_settings: dict = field(default_factory=dict) + # Editor preferences carried WITH the project (autocomplete, ideas, anchor, i2v bypass, + # and a snapshot of the shortcut-revolver mode). These are otherwise per-machine — + # browser localStorage and a server-side sidecar — so on a fresh rented instance every + # one of them is back at its default. Riding along in the project file is what makes + # them survive the move. Never read by generation; the editor applies them on open. + # An ABSENT key means "leave whatever this browser already has" — opening a project + # saved before this existed must not reset anything. + editor_settings: dict = field(default_factory=dict) # Last queued generation prompt fingerprint — used to auto-reset Studio session when # the timeline text changes (avoids stale repair memory overriding new actions). generation_meta: dict = field(default_factory=dict) @@ -672,6 +680,7 @@ class Project: models=dict(d.get("models") or {"slots": []}), guide_settings=dict(d.get("guide_settings") or {}), continuity_settings=dict(d.get("continuity_settings") or {}), + editor_settings=dict(d.get("editor_settings") or {}), generation_meta=dict(d.get("generation_meta") or {}), scene_renders=dict(d.get("scene_renders") or {}), scene_ghosts=list(d.get("scene_ghosts") or []), diff --git a/movie_editor/frontend/editor_settings.js b/movie_editor/frontend/editor_settings.js index cdb72094..f33a0494 100644 --- a/movie_editor/frontend/editor_settings.js +++ b/movie_editor/frontend/editor_settings.js @@ -1,6 +1,9 @@ -// Editor settings: per-browser editor preferences (autocomplete, anchor). -// A section of the unified Settings window. Distinct from Engine settings -// (which configures Studio / Chain Sampler behavior on the project). +// Editor settings: how the editor behaves (autocomplete, anchor, shortcut revolver). +// A section of the unified Settings window. Distinct from Engine settings, which +// configures Studio / Chain Sampler behavior — these do not affect what is generated. +// Each one is mirrored into the open project (Store.setEditorSetting) so it survives a +// move to a freshly rented instance, where localStorage and the server-side revolver +// sidecar both start empty. (function () { const { el } = window.dom; const S = window.Store; @@ -148,8 +151,9 @@ wrap.append(el("div", "es-section-title", "Shortcut revolver")); wrap.append(el("div", "es-hint", "For shortcuts with several replacements: remember which replacement fired and don’t reuse it " - + "until the WHOLE set has fired. Off = seeded random pick (may repeat). Saved with your shortcut " - + "library — applies to every browser and to generation.")); + + "until the WHOLE set has fired. Off = seeded random pick (may repeat). This one is a server " + + "setting — it applies to every browser and to generation — but the open project remembers your " + + "choice and puts it back, so a fresh instance doesn’t start from off.")); const main = el("label", "chk es-toggle"); const mainCb = el("input"); mainCb.type = "checkbox"; mainCb.disabled = true; @@ -167,6 +171,12 @@ + "option restarts all cycles.")); wrap.append(sub); + // Mirror into the open project so the choice survives a move to another instance, + // where neither this browser's storage nor the server's revolver sidecar exists. + const remember = (s) => { + if (!S.get().project) return; + S.setEditorSetting("revolver", { enabled: !!(s && s.enabled), random: !!(s && s.random) }); + }; const apply = (s) => { mainCb.checked = !!(s && s.enabled); randCb.checked = !!(s && s.random); @@ -177,13 +187,15 @@ const save = () => { mainCb.disabled = randCb.disabled = true; API.setRevolverSettings({ enabled: mainCb.checked, random: randCb.checked }) - .then(apply) + .then((s) => { apply(s); remember(s); }) .catch(() => { API.revolverSettings().then(apply).catch(() => {}); }); }; mainCb.onchange = save; randCb.onchange = save; sub.style.display = "none"; + // Opening a project already snapshots the server's current mode into it (see + // Store._applyProjectEditorSettings), so nothing to record here on mere display. API.revolverSettings().then(apply).catch(() => { wrap.append(el("div", "es-hint", "Could not load revolver settings.")); }); @@ -222,7 +234,7 @@ window.SettingsWindow.register({ id: "editor", group: "", title: "Editor", - subtitle: "Per-browser preferences — they apply to this browser, not the project.", + subtitle: "How the editor behaves. The open project remembers these, so they follow it to another machine.", keywords: "autocomplete anchor prompt shortcuts ideas suggestions i2v bypass guide preferences revolver random replacements cycle", iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)", icon: '', diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 6ddcae10..8c140870 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -35,14 +35,23 @@ }; // ── editor settings ───────────────────────────────────────────────────────── - // Per-browser editor preferences (not part of the project file). Govern how the - // prompt is parsed/edited, not what is generated from already-distributed scenes. + // Editor preferences. Govern how the prompt is parsed/edited, not what is generated + // from already-distributed scenes. They live in this browser's localStorage AND, once + // a project is open, are mirrored into `project.editor_settings`: on a fresh machine + // (a newly rented GPU box) localStorage is empty and the server-side revolver sidecar + // does not exist, so without the mirror every one of these is back at its default and + // has to be set by hand again. Opening a project restores what it remembers. const EDITOR_SETTINGS_KEY = "funpack_editor_settings"; const EDITOR_SETTINGS_DEFAULTS = { autocomplete: true, suggestions: true, anchorEnabled: true, // "Anchor as guide" i2v bypass: declare a custom i2v node so it can be forced // to a state (value-only) on anchor_guide runs. anchorGuideBypass = {node, input, value}. anchorGuideHasI2v: false, anchorGuideBypass: null, + // Snapshot of the shortcut revolver, which is NOT a browser setting — it lives + // server-side next to the shortcut DB because previews and generation share one + // cycle. Carried here purely so the project can put it back on a new instance; + // the server copy stays the single source of truth while the app is running. + revolver: null, // {enabled, random} | null = never recorded }; function _loadEditorSettings() { try { @@ -51,12 +60,28 @@ } catch (_) { return { ...EDITOR_SETTINGS_DEFAULTS }; } } let _editorSettings = _loadEditorSettings(); + function _persistEditorSettings() { + try { localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(_editorSettings)); } catch (_) {} + } function getEditorSettings() { return { ..._editorSettings }; } function getEditorSetting(key) { return _editorSettings[key]; } function setEditorSetting(key, val) { - if (_editorSettings[key] === val) return; + // Mirrored into the open project so it travels with the file. Deliberately BEFORE the + // unchanged-value return: a browser that was already configured before the project + // learned to remember would otherwise never record anything, because re-picking the + // value you already have is a no-op — and the setting you never touch again is exactly + // the one you lose on the next machine. Silent, since changing a preference is not a + // timeline edit and should not mark the project dirty in the UI. + if (state.project) { + const stored = state.project.editor_settings || {}; + if (JSON.stringify(stored[key]) !== JSON.stringify(val)) { + state.project.editor_settings = { ...stored, [key]: val }; + scheduleSaveSilent(); + } + } + if (JSON.stringify(_editorSettings[key]) === JSON.stringify(val)) return; _editorSettings = { ..._editorSettings, [key]: val }; - try { localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(_editorSettings)); } catch (_) {} + _persistEditorSettings(); notify(); // Anchor toggle changes how the global prompt maps onto the timeline — re-split now. if (key === "anchorEnabled" && state.project) { @@ -66,6 +91,60 @@ } } + /** Put a freshly-opened project's remembered preferences back in force. + * + * Key-by-key, and only for keys the project actually carries: a project saved before + * this existed has no `editor_settings` at all, and that must leave the current browser + * alone rather than resetting it to defaults. Applied wholesale (not via + * setEditorSetting) so the anchor toggle's re-split side effect doesn't fire — the + * caller re-syncs the prompt from the timeline straight after this anyway. + * + * The revolver is the one that isn't ours to set: it is a server setting shared by every + * browser and by generation. We POST it only when it actually differs, so opening a + * project that agrees with the server costs nothing and never restarts a live cycle. + */ + function _applyProjectEditorSettings(project) { + if (!project) return; + const saved = project.editor_settings || {}; + const keys = Object.keys(saved).filter((k) => k !== "revolver" && k in EDITOR_SETTINGS_DEFAULTS); + if (keys.length) { + const next = { ..._editorSettings }; + keys.forEach((k) => { next[k] = saved[k]; }); + _editorSettings = next; + _persistEditorSettings(); + } else { + // A project from before this existed, opened on a machine that IS configured: adopt + // this browser's settings rather than leaving the project blank, so the very next + // move carries them. Nothing changes here and now — these already are the settings + // in force — it only stops the project from travelling empty. + project.editor_settings = { ...(project.editor_settings || {}), ...getEditorSettings() }; + delete project.editor_settings.revolver; // the server's copy is authoritative below + scheduleSaveSilent(); + } + const rev = saved.revolver; + if (rev && typeof rev === "object") { + _editorSettings = { ..._editorSettings, revolver: { enabled: !!rev.enabled, random: !!rev.random } }; + _persistEditorSettings(); + API.revolverSettings().then((cur) => { + if (!!cur.enabled === !!rev.enabled && !!cur.random === !!rev.random) return; + return API.setRevolverSettings({ enabled: !!rev.enabled, random: !!rev.random }) + .then(() => notify()); + }).catch(() => {}); + return; + } + // Nothing recorded yet: take whatever the server has now, so the mode you already set + // by hand travels with the project without you having to toggle it to register it. + API.revolverSettings().then((cur) => { + const snap = { enabled: !!cur.enabled, random: !!cur.random }; + _editorSettings = { ..._editorSettings, revolver: snap }; + _persistEditorSettings(); + if (state.project === project) { + project.editor_settings = { ...(project.editor_settings || {}), revolver: snap }; + scheduleSaveSilent(); + } + }).catch(() => {}); + } + const listeners = new Set(); const SAVE_DEBOUNCE_MS = 5000; // discrete edits (dropdowns, toggles) const SAVE_SILENT_DEBOUNCE_MS = 20000; // typing in fields — flushed on blur / generate @@ -709,6 +788,9 @@ } state.project = loaded; state.notice = ""; + // Before anything reads a preference: anchorEnabled decides how the global prompt + // splits into scenes, and syncGlobalPromptFromTimeline() below runs on that answer. + _applyProjectEditorSettings(loaded); const first = state.project.scenes[0]?.id || null; state.selectedSceneId = first; state.selectedSceneIds = first ? [first] : []; @@ -742,6 +824,13 @@ const p = await API.createProject(name || "Untitled montage"); await refreshProjectList(); await loadProject(p.id); + // A project born on a configured machine inherits that configuration, so it can carry + // it away later. Without this it would only start remembering once a preference was + // toggled — and the settings you never have to touch are exactly the ones you'd lose. + if (state.project && state.project.id === p.id) { + state.project.editor_settings = { ...getEditorSettings() }; + scheduleSaveSilent(); + } } async function deleteProject(id) { diff --git a/tests/test_project_editor_settings.py b/tests/test_project_editor_settings.py new file mode 100644 index 00000000..fac4dcd2 --- /dev/null +++ b/tests/test_project_editor_settings.py @@ -0,0 +1,66 @@ +"""Editor preferences riding along in the project file. + +Autocomplete, shortcut ideas, the anchor toggle, the custom-i2v bypass and the shortcut +revolver are all per-MACHINE: the first four live in the browser's localStorage and the +revolver in a server-side sidecar next to the shortcut DB. Renting a fresh GPU box gives +you neither, so every one of them silently reverts to its default and has to be set again. +`Project.editor_settings` is what carries them across, so the invariants worth pinning are: + +1. It survives a save/load round trip of the project file at all (the Project dataclass is + a whitelist — an unknown key is dropped, not stored). +2. It is optional. A project written before this existed loads with an empty dict, never a + missing attribute and never a set of defaults that would overwrite a configured browser. +3. Nothing in the generation path reads it — it is editor preference, not a render setting. +""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from movie_editor.backend.timeline import Project # noqa: E402 + + +SETTINGS = { + "autocomplete": False, + "suggestions": True, + "anchorEnabled": False, + "anchorGuideHasI2v": True, + "anchorGuideBypass": {"node": "slot_1", "input": "strength", "value": 0.0}, + "revolver": {"enabled": True, "random": True}, +} + + +def test_editor_settings_survive_a_project_round_trip(): + p = Project.from_dict({"name": "trip", "editor_settings": SETTINGS}) + assert p.editor_settings == SETTINGS + # to_dict -> from_dict is exactly what save/load does on disk. + assert Project.from_dict(p.to_dict()).editor_settings == SETTINGS + + +def test_a_project_saved_before_this_existed_carries_nothing(): + """An ABSENT key must mean 'leave this browser alone'. If old projects loaded a full + set of defaults instead, opening one would quietly switch off a revolver you had on.""" + p = Project.from_dict({"name": "old"}) + assert p.editor_settings == {} + assert Project.from_dict(p.to_dict()).editor_settings == {} + + +@pytest.mark.parametrize("bad", [None, "", 0, []]) +def test_a_malformed_value_degrades_to_empty_rather_than_raising(bad): + assert Project.from_dict({"name": "x", "editor_settings": bad}).editor_settings == {} + + +def test_two_projects_keep_their_own_preferences(): + a = Project.from_dict({"name": "a", "editor_settings": {"anchorEnabled": True}}) + b = Project.from_dict({"name": "b", "editor_settings": {"anchorEnabled": False}}) + assert a.editor_settings["anchorEnabled"] is True + assert b.editor_settings["anchorEnabled"] is False + + +def test_generation_never_reads_editor_settings(): + """It is a preference, not a render setting: the build path must not branch on it, or + two machines with different editor prefs would generate different video.""" + src = (Path(__file__).resolve().parents[1] / "movie_editor" / "backend" / "builder.py").read_text() + assert "editor_settings" not in src