From cfc6847682efc79c4ecdee7e121163931bc1791c Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:40:19 +0300 Subject: [PATCH] Fix intermittent anchor revert from overlapping autosaves Autosave responses could overwrite a newer drag-dropped i2v anchor if an older commit finished later. Snapshot saves, skip applying stale responses when local edits happened in flight, and persist anchor changes immediately. Sync gen-unit source to all subclips and read anchor from the root on clips. --- movie_editor/frontend/store.js | 79 ++++++++++++++++++++++++------- movie_editor/frontend/timeline.js | 23 +++++++-- 2 files changed, 79 insertions(+), 23 deletions(-) diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 7de35083..aa837531 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -30,6 +30,9 @@ const listeners = new Set(); let saveTimer = null; + let _localDirty = false; // local edits newer than the in-flight save snapshot + let _commitPromise = null; // avoid overlapping saves stomping newer anchor edits + let _commitQueued = false; let _selectionAnchorId = null; // shift-click range anchor function notify() { listeners.forEach((fn) => { try { fn(state); } catch (e) { console.error(e); } }); } @@ -140,6 +143,7 @@ // to fire every keystroke. Discrete actions (dropdowns, checkboxes) save ~1s after the // last change; free typing waits longer and is flushed on blur (see flushSave). function scheduleSave() { + _localDirty = true; state.saving = true; notify(); clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveTimer = null; commit(); }, 1200); @@ -148,6 +152,7 @@ // For free-text / number fields: update state and queue a save WITHOUT re-rendering, // so typing isn't interrupted. Long debounce; blur flushes it (flushSave). function scheduleSaveSilent() { + _localDirty = true; clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveTimer = null; commit(); }, 6000); } @@ -155,24 +160,38 @@ // Commit any pending save immediately (field blur / before generate). Returns the // commit promise when it flushed, else null. function flushSave() { - if (!saveTimer) return null; clearTimeout(saveTimer); saveTimer = null; + if (!_localDirty && !_commitPromise) return null; return commit(); } async function commit() { if (!state.project) return; - try { - state.project = await API.saveProject(state.project.id, state.project); - state.saving = false; - _pruneSelection(); - notify(); - refreshProjectList(); - refreshPreview(true); - } catch (e) { - state.saving = false; notify(); - console.error("save failed", e); - } + if (_commitPromise) { _commitQueued = true; return _commitPromise; } + _commitPromise = (async () => { + const snapshot = JSON.parse(JSON.stringify(state.project)); + _localDirty = false; + try { + const saved = await API.saveProject(snapshot.id, snapshot); + // If the user edited again while this save was in flight (e.g. dropped a new + // anchor), do NOT replace local state with the older snapshot we just wrote. + if (_localDirty) scheduleSave(); + else state.project = saved; + state.saving = false; + _pruneSelection(); + notify(); + refreshProjectList(); + refreshPreview(true); + } catch (e) { + _localDirty = true; + state.saving = false; notify(); + console.error("save failed", e); + } finally { + _commitPromise = null; + if (_commitQueued) { _commitQueued = false; commit(); } + } + })(); + return _commitPromise; } function patchProject(patch) { if (!state.project) return; Object.assign(state.project, patch); notify(); scheduleSave(); } @@ -218,22 +237,46 @@ if (root?.rating) root.rating = ""; } + // Source lives on the gen-unit root; editorial subclips must mirror it for UI + saves. + function _syncGenUnitSource(root) { + if (!root || !state.project) return; + const src = JSON.parse(JSON.stringify(root.source || {})); + const uid = genUnitId(root); + (state.project.scenes || []).forEach((sc) => { + if (genUnitId(sc) === uid) sc.source = JSON.parse(JSON.stringify(src)); + }); + } + function _applyScenePatch(id, patch, quiet) { const t = _patchSceneTarget(id, patch); if (!t) return; - if (_anchorPatchChanged(t, patch)) _invalidateSceneAfterAnchorChange(t); + const anchorChanged = _anchorPatchChanged(t, patch); + if (anchorChanged) _invalidateSceneAfterAnchorChange(t); const merged = { ...patch }; if (merged.source) merged.source = { ...(t.source || {}), ...merged.source }; Object.assign(t, merged); - if (quiet) scheduleSaveSilent(); else { notify(); scheduleSave(); } + if (merged.source) _syncGenUnitSource(genUnitRoot(genUnitId(t)) || t); + if (anchorChanged) { + clearTimeout(saveTimer); saveTimer = null; + _localDirty = true; state.saving = true; notify(); + commit(); + } else if (quiet) scheduleSaveSilent(); + else { notify(); scheduleSave(); } } function patchScene(id, patch) { _applyScenePatch(id, patch, false); } function patchSceneQuiet(id, patch) { - const s = scene(id); if (!s) return; - if (_anchorPatchChanged(s, patch)) _invalidateSceneAfterAnchorChange(s); + const t = _patchSceneTarget(id, patch) || scene(id); if (!t) return; + const anchorChanged = _anchorPatchChanged(t, patch); + if (anchorChanged) _invalidateSceneAfterAnchorChange(t); const merged = { ...patch }; - if (merged.source) merged.source = { ...(s.source || {}), ...merged.source }; - Object.assign(s, merged); scheduleSaveSilent(); + if (merged.source) merged.source = { ...(t.source || {}), ...merged.source }; + Object.assign(t, merged); + if (merged.source) _syncGenUnitSource(genUnitRoot(genUnitId(t)) || t); + if (anchorChanged) { + clearTimeout(saveTimer); saveTimer = null; + _localDirty = true; state.saving = true; notify(); + commit(); + } else scheduleSaveSilent(); } function _sceneOrder() { return (state.project?.scenes || []).map((s) => s.id); } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 798eaf1b..b709fb02 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -9,9 +9,21 @@ const SRC_ICON = { empty: "▦", image: "◐", generated_frame: "⛶", carry: "⇥", mixed: "◑" }; - function anchorMediaRef(scene) { - const t = scene.source?.type; - if (t === "image" || t === "mixed" || t === "generated_frame") return scene.source?.media_ref || null; + function genUnitRootScene(scene, p) { + const uid = scene.gen_unit_id || scene.id; + return (p.scenes || []).find((s) => (s.gen_unit_id || s.id) === uid && !(s.cut_offset_frames > 0)) + || scene; + } + + function sceneSourceForClip(scene, p) { + const root = genUnitRootScene(scene, p); + return root.source || scene.source || {}; + } + + function anchorMediaRef(scene, p) { + const src = sceneSourceForClip(scene, p); + const t = src.type; + if (t === "image" || t === "mixed" || t === "generated_frame") return src.media_ref || null; return null; } @@ -157,7 +169,8 @@ function clipEl(st, p, scene, index, leftPx, widthPx) { const unitCuts = (p.scenes || []).filter((s) => (s.gen_unit_id || s.id) === (scene.gen_unit_id || scene.id)).length; const subclip = (scene.cut_offset_frames || 0) > 0; - const srcType = scene.source?.type || "empty"; + const src = sceneSourceForClip(scene, p); + const srcType = src.type || "empty"; const clip = el("div", "clip" + clipSelClass(st, scene.id) + (scene.excluded ? " excluded" : "") + (hasRender(st, scene.id) ? " rendered" : (!scene.excluded ? " pending" : "")) @@ -215,7 +228,7 @@ }); // i2v anchor thumbnail (image + mixed + generated_frame) - const mref = anchorMediaRef(scene); + const mref = anchorMediaRef(scene, p); const asset = mref ? (st.mediaBin || []).find((m) => m.id === mref) : null; if (asset) { const th = el("div", "clip-thumb");