diff --git a/docs/FunPackSceneBuilder.md b/docs/FunPackSceneBuilder.md index 9894a63c..019db25e 100644 --- a/docs/FunPackSceneBuilder.md +++ b/docs/FunPackSceneBuilder.md @@ -10,7 +10,9 @@ If no scene exists yet, opening the editor, prompt editor, or database first ask ## Editor Menus -**Positive prompt**: Opens a multiline text composer. Typed text, commas, periods, and manual ordering are preserved exactly. Database chips can be clicked or dragged into the text field, and chips already present in the prompt are highlighted. +**Positive prompt**: Opens a multiline text composer. Typed text, commas, periods, and manual ordering are preserved exactly. Database chips can be clicked or dragged into the text field, filtered by category, and chips already present in the prompt are highlighted. Click a highlighted phrase to remove it from the prompt. + +**Preconstruct**: In the positive prompt editor, this button adds matching database phrases for the current intent into the editor text. It only runs when you press the button; queueing the node never preconstructs automatically. **Negative prompt**: Same composer behavior for the negative prompt. diff --git a/templates.py b/templates.py index 269d2251..b7276e1f 100644 --- a/templates.py +++ b/templates.py @@ -305,6 +305,13 @@ def normalize_scene_key(value): return re.sub(r"\s+", " ", value).strip() +def normalize_scene_text_spacing(value): + text = re.sub(r"[ \t\r\f\v]+", " ", str(value or "")).strip() + text = re.sub(r"\s+([,.])", r"\1", text) + text = re.sub(r"([,.])([^\s,.])", r"\1 \2", text) + return text.strip() + + def scene_token_words(text): return [ token.lower() @@ -330,7 +337,7 @@ def categorize_scene_phrase(text, source="positive"): def extract_scene_phrases(text, source="positive", include_words=False): phrases = [] seen = set() - for raw in re.split(r"[,;.\n]+", str(text or "")): + for raw in re.split(r"[,;.\n]+", normalize_scene_text_spacing(text)): clean = re.sub(r"\s+", " ", raw).strip(" ,;:.").strip() if len(clean) < 2: continue @@ -498,7 +505,7 @@ def normalize_scene_phrase_list(value): seen = set() for item in value: text = item.get("text") if isinstance(item, dict) else item - clean = re.sub(r"\s+", " ", str(text or "")).strip(" ,;:.").strip() + clean = normalize_scene_text_spacing(text).strip(" ,;:.").strip() key = clean.lower() if not clean or key in seen: continue @@ -546,8 +553,8 @@ def scene_text_from_phrases(phrases): def scene_from_texts(name, aliases, output_mode, positive_text, negative_text, refinement_key=""): - positive_text = str(positive_text or "").strip() - negative_text = str(negative_text or "").strip() + positive_text = normalize_scene_text_spacing(positive_text) + negative_text = normalize_scene_text_spacing(negative_text) return { "name": normalize_scene_name(name), "aliases": aliases_from_text(aliases), @@ -1248,7 +1255,23 @@ class FunPackSceneBuilder: payload = parse_scene_payload(scene_payload) data = load_scene_db(refinement_key) - memory_changed = remember_scene_phrases(data, positive_prompt, negative_prompt) + memory_positive = "\n".join( + item for item in ( + positive_prompt, + scene_positive, + scene_text_from_phrases(payload.get("positive_phrases", [])), + ) + if str(item or "").strip() + ) + memory_negative = "\n".join( + item for item in ( + negative_prompt, + scene_negative, + scene_text_from_phrases(payload.get("negative_phrases", [])), + ) + if str(item or "").strip() + ) + memory_changed = remember_scene_phrases(data, memory_positive, memory_negative) scenes = data.setdefault("scenes", {}) manual = self._manual_scene( selected_name, diff --git a/tests/test_scene_builder.py b/tests/test_scene_builder.py index f4c0c363..1762ec1b 100644 --- a/tests/test_scene_builder.py +++ b/tests/test_scene_builder.py @@ -28,7 +28,9 @@ from conditioning import FunPackVideoRefinerV2 from templates import ( FunPackSceneBuilder, apply_scene_database_authority, + extract_scene_phrases, load_scene_db, + normalize_scene_text_spacing, normalize_scene_memory_items, remember_scene_phrases, save_scene_db, @@ -231,6 +233,30 @@ def test_scene_builder_auto_no_match_falls_back_to_manual(monkeypatch, tmp_path) assert "Manual" in outputs[3] +def test_scene_builder_auto_still_collects_provided_scene_knowledge(monkeypatch, tmp_path): + use_tmp_scene_store(monkeypatch, tmp_path) + builder = FunPackSceneBuilder() + + builder.build_scene( + scene="-None-", + scene_name="", + aliases="", + action="load", + mode="Auto", + intent_prompt="no matching saved scene", + scene_positive="glass rain, neon reflection", + scene_negative="bad fingers", + positive_prompt="", + negative_prompt="", + refinement_key="auto_memory_key", + ) + + data = load_scene_db("auto_memory_key") + assert "glass rain" in data["universal_memory"] + assert "neon reflection" in data["universal_memory"] + assert "bad fingers" in data["universal_memory"] + + def test_scene_builder_learning_mode_collects_memory_and_passes_inputs_through(monkeypatch, tmp_path): use_tmp_scene_store(monkeypatch, tmp_path) builder = FunPackSceneBuilder() @@ -289,6 +315,12 @@ def test_scene_builder_collects_prompt_words_from_sentence_chunks(monkeypatch, t assert "the" not in data["universal_memory"] +def test_scene_builder_treats_space_before_punctuation_as_normal_punctuation(): + assert normalize_scene_text_spacing("red dress . walking pose , smoke") == "red dress. walking pose, smoke" + phrases = extract_scene_phrases("red dress . walking pose , smoke") + assert [item["text"] for item in phrases] == ["red dress", "walking pose", "smoke"] + + def test_refiner_reset_preserves_scene_builder_memory_in_refinement_key(monkeypatch, tmp_path): use_tmp_scene_store(monkeypatch, tmp_path) builder = FunPackSceneBuilder() diff --git a/web/funpack_scene_builder.js b/web/funpack_scene_builder.js index 5d446436..70c561a4 100644 --- a/web/funpack_scene_builder.js +++ b/web/funpack_scene_builder.js @@ -140,6 +140,20 @@ function linkedRefinementKey(node) { return selected || typed; } +function linkedTextValue(node, inputName) { + const source = linkedInputNode(node, inputName); + if (!source) { + return ""; + } + for (const name of ["text", "prompt", "positive_prompt", "negative_prompt", "string", "value"]) { + const value = getWidgetValue(source, name, ""); + if (String(value || "").trim()) { + return String(value); + } + } + return ""; +} + function sceneNames() { const names = sceneData?.scenes; return Array.isArray(names) && names.length ? names : [NONE_SCENE]; @@ -553,16 +567,24 @@ function insertAtCursor(textarea, text) { const suffix = textarea.value.slice(end); const needsComma = prefix.trim() && !/[,\s.;\n]$/.test(prefix); const insertion = `${needsComma ? ", " : ""}${text}`; - textarea.value = `${prefix}${insertion}${suffix}`; + textarea.value = normalizePromptSpacing(`${prefix}${insertion}${suffix}`); const cursor = prefix.length + insertion.length; textarea.focus(); textarea.setSelectionRange(cursor, cursor); } +function normalizePromptSpacing(value) { + return String(value || "") + .replace(/\s+([,.])/g, "$1") + .replace(/([,.])([^\s,.])/g, "$1 $2") + .replace(/[ \t\r\f\v]+/g, " ") + .trim(); +} + function promptLookupKey(value) { return String(value || "") .toLowerCase() - .replace(/[^\w'’.-]+/g, " ") + .replace(/[^\w'’]+/g, " ") .replace(/\s+/g, " ") .trim(); } @@ -576,6 +598,98 @@ function promptContainsPhrase(prompt, phrase) { return ` ${promptKey} `.includes(` ${phraseKey} `); } +function promptWords(value) { + const key = promptLookupKey(value); + return key ? key.split(" ").filter((word) => word.length >= 2) : []; +} + +function removePhraseFromPrompt(prompt, phrase) { + const phraseKey = promptLookupKey(phrase); + if (!phraseKey) { + return prompt; + } + const parts = String(prompt || "").split(/([,;.\n]+)/); + let changed = false; + for (let index = 0; index < parts.length; index += 2) { + const segment = parts[index]; + const segmentKey = promptLookupKey(segment); + if (!segmentKey) { + continue; + } + if (segmentKey === phraseKey) { + parts[index] = ""; + changed = true; + continue; + } + const padded = ` ${segmentKey} `; + if (padded.includes(` ${phraseKey} `)) { + const pattern = new RegExp(`(^|\\s)${phraseKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`, "i"); + parts[index] = segmentKey.replace(pattern, " ").replace(/\s+/g, " ").trim(); + changed = true; + } + } + if (!changed) { + return prompt; + } + const output = parts.join(""); + return normalizePromptSpacing(output.replace(/\s*([,;.])\s*([,;.]\s*)+/g, "$1 ")); +} + +function preconstructPrompt(currentPrompt, intentText, isNegative = false) { + if (isNegative) { + return { prompt: currentPrompt, added: [] }; + } + const intentWords = new Set(promptWords(intentText)); + if (!intentWords.size) { + return { prompt: currentPrompt, added: [] }; + } + const prompt = normalizePromptSpacing(currentPrompt); + const intentKey = promptLookupKey(intentText); + const candidates = []; + for (const item of memoryItems()) { + if (item.source === "negative" || item.category === "negative") { + continue; + } + const text = normalizePromptSpacing(item.text || item.key || ""); + const key = promptLookupKey(text); + if (!key || promptContainsPhrase(prompt, text)) { + continue; + } + const words = promptWords(text); + const overlap = words.filter((word) => intentWords.has(word)).length; + const exact = intentKey && ` ${intentKey} `.includes(` ${key} `); + if (!exact && !overlap) { + continue; + } + const count = Number.isFinite(Number(item.count)) ? Number(item.count) : 0; + const score = (exact ? 4 : 0) + overlap / Math.max(1, words.length) + Math.min(1.5, count * 0.04) + Math.min(0.5, words.length * 0.04); + candidates.push({ text, score, words: words.length, count }); + } + candidates.sort((left, right) => right.score - left.score || right.words - left.words || right.count - left.count || left.text.localeCompare(right.text)); + const added = []; + const seen = new Set(promptWords(prompt)); + for (const candidate of candidates) { + const key = promptLookupKey(candidate.text); + if (!key || added.some((text) => promptLookupKey(text) === key)) { + continue; + } + added.push(candidate.text); + for (const word of promptWords(candidate.text)) { + seen.add(word); + } + if (added.length >= 16) { + break; + } + } + if (!added.length) { + return { prompt, added }; + } + return { + prompt: normalizePromptSpacing(`${prompt}${prompt ? ", " : ""}${added.join(", ")}`), + added, + }; +} + function shell(root, title, view) { root.replaceChildren(); const header = document.createElement("div"); @@ -832,6 +946,7 @@ function renderPromptEditor(panel, node, kind) { textarea.value = String(getWidgetValue(node, widgetName, "") || ""); textarea.placeholder = isNegative ? "Write negative prompt words and phrases here." : "Write positive prompt words and phrases here."; textarea.addEventListener("input", () => { + textarea.value = textarea.value.replace(/\s+([,.])/g, "$1"); setWidgetValue(node, widgetName, textarea.value); setDirty(node); updateUsedChips(); @@ -847,29 +962,62 @@ function renderPromptEditor(panel, node, kind) { }); body.append(textarea); + const highlights = document.createElement("div"); + highlights.className = "funpack-scene-highlights"; + body.append(highlights); + const search = document.createElement("input"); search.type = "search"; search.placeholder = "Search phrase bank"; search.className = "funpack-scene-search"; - body.append(search); + const categoryFilter = panelSelect(["All", ...GROUP_ORDER], "All"); + categoryFilter.title = "Show one category or all categories."; + const filterRow = document.createElement("div"); + filterRow.className = "funpack-scene-filter-row"; + filterRow.append(search, categoryFilter); + body.append(filterRow); const bank = document.createElement("div"); bank.className = "funpack-scene-bank"; body.append(bank); const updateUsedChips = () => { + highlights.replaceChildren(); + const usedPhrases = []; for (const chip of bank.querySelectorAll(".funpack-scene-chip[data-phrase]")) { const used = promptContainsPhrase(textarea.value, chip.dataset.phrase || ""); chip.classList.toggle("used", used); - chip.title = used ? `Already used: ${chip.dataset.phrase}` : (chip.dataset.phrase || chip.textContent || ""); + chip.title = used ? `Click to remove: ${chip.dataset.phrase}` : (chip.dataset.phrase || chip.textContent || ""); + if (used && !usedPhrases.includes(chip.dataset.phrase || "")) { + usedPhrases.push(chip.dataset.phrase || ""); + } + } + if (!usedPhrases.length) { + const empty = document.createElement("span"); + empty.className = "funpack-scene-muted"; + empty.textContent = "No database phrases in this prompt."; + highlights.append(empty); + return; + } + for (const phrase of usedPhrases) { + const item = panelButton(phrase, "compact"); + item.classList.add("funpack-scene-highlight"); + item.title = `Remove ${phrase}`; + item.addEventListener("click", () => { + textarea.value = removePhraseFromPrompt(textarea.value, phrase); + textarea.dispatchEvent(new Event("input")); + }); + highlights.append(item); } }; const renderBank = () => { const query = search.value.toLowerCase().trim(); + const selectedCategory = categoryFilter.value; bank.replaceChildren(); const items = memoryItems() .filter((item) => isNegative ? item.source === "negative" || item.category === "negative" : item.source !== "negative" && item.category !== "negative") + .filter((item) => selectedCategory === "All" || (item.category || (item.source === "negative" ? "negative" : "details")) === selectedCategory) .filter((item) => !query || String(item.text || "").toLowerCase().includes(query)); const byGroup = new Map(); for (const item of items) { @@ -893,7 +1041,11 @@ function renderPromptEditor(panel, node, kind) { for (const item of groupItems) { const phrase = item.text || item.key; const chip = phraseButton(phrase, (text) => { - insertAtCursor(textarea, text); + if (promptContainsPhrase(textarea.value, text)) { + textarea.value = removePhraseFromPrompt(textarea.value, text); + } else { + insertAtCursor(textarea, text); + } textarea.dispatchEvent(new Event("input")); }); chip.dataset.phrase = phrase; @@ -904,12 +1056,31 @@ function renderPromptEditor(panel, node, kind) { updateUsedChips(); }; search.addEventListener("input", renderBank); + categoryFilter.addEventListener("change", renderBank); renderBank(); const footer = document.createElement("div"); footer.className = "funpack-scene-footer"; const back = panelButton("Back"); back.addEventListener("click", () => renderPanel(panel, node, "menu")); + const preconstruct = panelButton("Preconstruct", "primary"); + preconstruct.disabled = isNegative; + preconstruct.title = isNegative ? "Preconstruct is available for the positive prompt." : "Add database phrases that match the current intent."; + preconstruct.addEventListener("click", () => { + const intent = String( + getWidgetValue(node, "intent_prompt", "") || + linkedTextValue(node, "intent_prompt") || + textarea.value || + "" + ); + const result = preconstructPrompt(textarea.value, intent, isNegative); + if (!result.added.length) { + showPanelError(new Error("No matching database phrases found for the current intent.")); + return; + } + textarea.value = result.prompt; + textarea.dispatchEvent(new Event("input")); + }); const cancel = panelButton("Cancel"); cancel.addEventListener("click", () => { setWidgetValue(node, widgetName, panel.snapshots[snapshotKey]); @@ -932,7 +1103,7 @@ function renderPromptEditor(panel, node, kind) { showPanelError(error); } }); - footer.append(back, cancel, confirm); + footer.append(back, preconstruct, cancel, confirm); body.append(footer); textarea.focus(); } @@ -1309,6 +1480,25 @@ function injectStyles() { outline: none; } .funpack-scene-search { margin: 8px 0; } + .funpack-scene-filter-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 135px; + gap: 8px; + align-items: center; + } + .funpack-scene-highlights { + min-height: 28px; + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; + padding: 7px 0 2px; + } + .funpack-scene-highlight { + border-color: rgba(100, 210, 140, 0.75); + background: rgba(58, 132, 86, 0.42); + color: #eaffef; + } .funpack-scene-bank, .funpack-scene-db-list { max-height: 270px;