feat: the project remembers your Editor settings, so a new rental doesn't reset them

Every Editor preference was per-MACHINE: autocomplete, shortcut ideas, the anchor
toggle and the custom-i2v bypass in browser localStorage, and the shortcut
revolver in a server-side sidecar next to the shortcut DB. A freshly rented box
has neither, so all of them silently revert to their defaults and have to be set
by hand again — the revolver's Random mode every single time.

They now ride along in the project file (Project.editor_settings), and opening a
project puts them back. The revolver is still a server setting shared by every
browser and by generation; the project only restores it, and only POSTs when the
value actually differs, so opening a project that already agrees never restarts a
live cycle.

Two cases that would otherwise leave the feature useless for an existing setup:

* A project saved before this existed, opened on a configured machine, adopts
  the machine's current settings instead of staying blank — including reading the
  server's current revolver mode. Nothing changes in the moment; it just stops
  the project from travelling empty.
* setEditorSetting mirrors into the project BEFORE its unchanged-value return.
  Re-picking a value you already have is a no-op, so without this the settings
  you never touch again — exactly the ones you'd lose — would never be recorded.

An ABSENT key still means "leave this browser alone": loading an old project must
never overwrite a configured setting with a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-06 19:41:18 +03:00
co-authored by Claude Opus 5
parent 946ea6b23a
commit 4e6c68bf51
4 changed files with 187 additions and 11 deletions
+9
View File
@@ -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 []),
+19 -7
View File
@@ -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: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"><path d="M2 4.5h12M2 8h12M2 11.5h12"/><circle cx="6" cy="4.5" r="1.7" fill="#fff" stroke="none"/><circle cx="11" cy="8" r="1.7" fill="#fff" stroke="none"/><circle cx="5" cy="11.5" r="1.7" fill="#fff" stroke="none"/></svg>',
+93 -4
View File
@@ -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) {
+66
View File
@@ -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