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:
co-authored by
Claude Opus 5
parent
946ea6b23a
commit
4e6c68bf51
@@ -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 []),
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user