From 979a593d3e157e05bf655e2d09994d7a4460ca6c Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:47:10 +0300 Subject: [PATCH] fix: Story box -- stale draft, Generate waits for an in-flight split and refuses a failed one, empty story applies, cut word refused if a scene already holds it Co-Authored-By: Claude Sonnet 5.5 --- app/legacy/composer.js | 17 +++++++++++++---- app/legacy/inspector.js | 2 +- app/legacy/store.js | 30 +++++++++++++++++++++++------- core/story.py | 2 ++ core/tests/test_story.py | 6 ++++++ 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/app/legacy/composer.js b/app/legacy/composer.js index 3ebfd81c..1b9494e1 100644 --- a/app/legacy/composer.js +++ b/app/legacy/composer.js @@ -189,7 +189,9 @@ // global prompt to edit — dereferencing st.project here crashed the whole editor. const live = (st.project && st.project.global_prompt) || (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || ""; - const val = gpDraft != null ? gpDraft : live; + // The draft only stands in for the scenes while its split is still on its way; after + // that the scenes are the truth, so a tab/project/cut-word change never shows old text. + const val = gpDraft != null && S.globalPromptApplyPending() ? gpDraft : live; // Templates: pick one to apply (prompt + variables), Save to snapshot the current state. wrap.append(composeTemplatesBar()); @@ -730,7 +732,7 @@ const wrap = el("div", "bin"); wrap.append(el("div", "lib-form-title", "Cut words")); wrap.append(el("div", "insp-hint", - "Type one of these words in the Story to start a new scene. The first is the one written between scenes for you.")); + "Type one of these words in the Story to start a new scene. The first is the one written between scenes for you. A scene whose own text contains a cut word is split there the next time the Story is edited, so pick words you will not otherwise write.")); const list = el("div", "lib-list"); wrap.append(list); let words = null; @@ -753,7 +755,14 @@ }); }; const add = el("input", "lib-in"); add.placeholder = "New cut word, then Enter"; - add.onkeydown = (e) => { if (e.key === "Enter" && add.value.trim()) { const v = add.value; add.value = ""; save([...words, v]); } }; + add.onkeydown = (e) => { + if (e.key !== "Enter" || !add.value.trim()) return; + const v = add.value.trim(); + const re = new RegExp("(? w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") + "(?!\\w)", "i"); + const hit = ((S.get().project || {}).scenes || []).find((sc) => re.test(sc.text || "")); + if (hit) { alert(`"${v}" already appears in a scene's text, which would split that scene. Pick another word.`); return; } + add.value = ""; save([...words, v]); + }; wrap.append(add); API.storyMarkers().then((r) => { words = r.markers; paint(); }).catch((e) => list.append(el("div", "pj-meta", e.message))); return wrap; @@ -937,7 +946,7 @@ // Keep the Compose textarea in sync with timeline-driven global-prompt changes, // but never clobber what the user is actively typing. window.addEventListener("funpack-global-prompt-updated", (e) => { - if (tab !== "Story" || !composeTextarea) return; + if (tab !== "Story" || !composeTextarea) { if (!(S.globalPromptApplyPending && S.globalPromptApplyPending())) gpDraft = null; return; } if (document.activeElement === composeTextarea) return; // Never rewind the draft while the user's newest text is still debouncing/mid-parse — // the event would carry the previous apply's text and eat their last keystrokes. diff --git a/app/legacy/inspector.js b/app/legacy/inspector.js index 480dd000..e09d49bc 100644 --- a/app/legacy/inspector.js +++ b/app/legacy/inspector.js @@ -738,7 +738,7 @@ const parsed = pv.parsed || {}; // Anchor disabled (editor setting): the backend still reports a leading anchor, but // generation folds it into Scene 1 — show it that way so the preview matches. - const foldAnchor = parsed.anchor && !S.getEditorSetting("anchorEnabled"); + const foldAnchor = false; if (parsed.anchor && !foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge anchor", "anchor")); l.append(el("span", null, parsed.anchor)); box.append(l); } if (foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge", "S1")); l.append(el("span", null, parsed.anchor)); box.append(l); } const sBase = foldAnchor ? 2 : 1; diff --git a/app/legacy/store.js b/app/legacy/store.js index efdf6c7d..5501f89b 100644 --- a/app/legacy/store.js +++ b/app/legacy/store.js @@ -257,6 +257,8 @@ async function loadStoryMarkers() { try { _storyMarker = ((await API.storyMarkers()).markers || [])[0] || "qcut"; } catch (_) {} } + // Another tab or window may have changed the words; the box must write the server's. + window.addEventListener("focus", () => loadStoryMarkers()); function buildGlobalPromptFromTimeline(project) { if (!project) return ""; const active = (project.scenes || []).filter((s) => !s.excluded && isGenerativeScene(s)); @@ -295,7 +297,7 @@ if (!state.project) return; _pinnedGlobalPrompt = null; const built = buildGlobalPromptFromTimeline(state.project); - const prev = (state.project.global_prompt || "").trim(); + const prev = state.project.global_prompt || ""; if (built === prev && (state.preview?.display_prompt || "") === built) return; state.project.global_prompt = built; state.preview = { @@ -338,9 +340,14 @@ // — otherwise we'd save (and generate from) stale scene text while the screen shows the new prompt. async function flushGlobalPromptApply() { if (_globalApplyTimer) { clearTimeout(_globalApplyTimer); _globalApplyTimer = null; } - if (_pendingGlobalPromptText == null) return; - const t = _pendingGlobalPromptText; _pendingGlobalPromptText = null; - await applyGlobalPromptQuiet(t); + if (_pendingGlobalPromptText != null) { + const t = _pendingGlobalPromptText; _pendingGlobalPromptText = null; + await applyGlobalPromptQuiet(t); + } + // A split already in flight (its debounce fired before Generate) is not "pending" any + // more -- wait for it, or Generate would run the scenes the box no longer shows. + await _lastApply.catch(() => {}); + if (_applyFailed) throw new Error("The Story could not be split into scenes (is the server reachable?) -- the scenes were not updated, so nothing was generated."); } function _normalizeSceneText(t) { @@ -597,7 +604,15 @@ return true; } - async function applyGlobalPromptQuiet(text) { + let _lastApply = Promise.resolve(); + let _applyFailed = false; + function applyGlobalPromptQuiet(text) { + const p = _applyGlobalPromptQuiet(text); + _lastApply = p; + return p; + } + + async function _applyGlobalPromptQuiet(text) { // Same shape as commit()'s guard above, and needed for the same reason: // this has an internal closure-scoped caller (applyPromptTemplate) besides its exported // property, so blocking Store.applyGlobalPromptQuiet from tour.js alone @@ -607,8 +622,7 @@ if (window.__FUNPACK_TOUR__) return false; if (!state.project) return false; const trimmed = String(text || "").trim(); - if (!trimmed) return false; - if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) return true; + if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) { _applyFailed = false; return true; } const seq = ++_applySeq; _applyInFlight++; try { @@ -617,8 +631,10 @@ try { res = await API.parsePrompt(state.project.id, trimmed); } catch (e) { console.warn("Global prompt parse failed:", e); + _applyFailed = true; return false; } + _applyFailed = false; // The user typed more while this parse was in flight: a newer apply is scheduled (or // already running). Distributing this OLDER text now would re-split the timeline to a // stale prompt and dispatch a stale updated event — drop it and let the newer one win. diff --git a/core/story.py b/core/story.py index 28aa7f92..355ee7c7 100644 --- a/core/story.py +++ b/core/story.py @@ -35,6 +35,8 @@ def markers() -> list[str]: def _clean(raw) -> list[str]: out = [] for m in raw if isinstance(raw, list) else []: + if not isinstance(m, str): + continue m = " ".join(str(m).split())[:MAX_MARKER] if m and m.lower() not in (o.lower() for o in out): out.append(m) diff --git a/core/tests/test_story.py b/core/tests/test_story.py index fab25e55..095ff1f9 100644 --- a/core/tests/test_story.py +++ b/core/tests/test_story.py @@ -42,3 +42,9 @@ def test_empty_marker_list_is_refused_and_keeps_the_old_one(): def test_unreadable_store_falls_back_to_default(): config.MARKERS_FILE.write_text("{nope", encoding="utf-8") assert story.markers() == story.DEFAULT_MARKERS + + +def test_non_string_markers_are_ignored(): + assert story.save_markers([None, 5, "cut"]) == ["cut"] + with pytest.raises(ValueError): + story.save_markers([None, 5])