diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cd2bb9..ae03e369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,15 @@ looked exactly like one where the feature was switched off. ### Fixed +- **Prompt templates can be renamed, deleted, and turned off.** The Composer's Templates bar + applied a template and immediately forgot it, so there was no state to leave and no way to + leave it — and clearing the prompt box by hand did nothing, because an empty global prompt + is refused (an empty parse would wipe the timeline on a stray keystroke). The bar now shows + which template is applied, offers `— None —` to clear the prompt as a deliberate, undoable + action, and gives the applied template Rename and Delete. Saving pre-fills the applied + name, so updating a template is the default gesture rather than a trick. Deleting one + leaves the prompt alone. + - **"No, I'll use my own pipeline" left the setup modal on screen.** Picking a model family starts a chain of requests that reopens the modal with fresh prerequisites; dismissing it mid-flight closed it, then the reply landed and put it straight back — and since opting out diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 073e026e..bfa055e0 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -601,6 +601,10 @@ class Project: # Saved global-prompt templates: [{"name": str, "prompt": str, "variables": [...]}]. Selecting # one in the Composer applies its prompt + variables; loaded with the project (no Load button). prompt_templates: list = field(default_factory=list) + # Which saved template the global prompt currently came from, so the Composer can show + # it as selected and offer rename / update / delete for it. "" = none applied; the + # prompt is whatever the user typed. + active_prompt_template: str = "" # Refinement key for this project's runs. Feeds the FunPackRefinementKeyLoader (Studio / # Chain Sampler / SaveRefinementLatent). "default" = the keyless/default store. Shortcuts # bound to a non-default key layer their own per-scene training on top of this. @@ -677,6 +681,7 @@ class Project: h3_references=list(d.get("h3_references") or []), references=[str(r) for r in (d.get("references") or []) if r], prompt_templates=list(d.get("prompt_templates") or []), + active_prompt_template=str(d.get("active_prompt_template") or ""), refinement_key=str(d.get("refinement_key") or "default"), keep_original_audio=bool(d.get("keep_original_audio", True)), audio_tracks=list(d.get("audio_tracks") or []), diff --git a/movie_editor/frontend/composer.js b/movie_editor/frontend/composer.js index 85336712..2e251b81 100644 --- a/movie_editor/frontend/composer.js +++ b/movie_editor/frontend/composer.js @@ -256,28 +256,87 @@ let varHintEl = null; // undeclared / cycle hint line under the prompt // ── templates bar (above the global prompt) ───────────────────────────────────── + const TPL_NONE = "__none__"; + function composeTemplatesBar() { const bar = el("div", "compose-templates"); + const tpls = S.promptTemplates() || []; + // Which template the prompt came from. Without it the select forgot its own answer the + // instant it was given, so there was no state to leave — and so no way to leave it. + const active = S.get().project?.active_prompt_template || ""; + const known = tpls.some((t) => t && t.name === active); + const sel = el("select", "lib-in compose-tpl-select"); - const ph = new Option("Templates…", ""); ph.disabled = true; ph.selected = true; sel.append(ph); - (S.promptTemplates() || []).forEach((t) => { + const ph = new Option("Templates…", ""); ph.disabled = true; sel.append(ph); + sel.append(new Option("— None (clear the prompt) —", TPL_NONE)); + tpls.forEach((t) => { const preview = String(t.prompt || "").replace(/\s+/g, " ").slice(0, 48); const o = new Option(`${t.name}${preview ? " — " + preview : ""}`, t.name); + if (t.name === active) o.selected = true; sel.append(o); }); + if (!known) ph.selected = true; sel.title = "Apply a saved global prompt + its variables"; - sel.onchange = () => { const n = sel.value; if (n) S.applyPromptTemplate(n).then(render); }; + sel.onchange = () => { + const n = sel.value; + if (!n) return; + if (n === TPL_NONE) { + if (!confirm( + "Clear the global prompt and stop using the current template?\n\n" + + "This empties every scene's text. Variables are left alone — edit them in " + + "Variables below. Undo restores it." + )) { render(); return; } + S.clearGlobalPrompt(); + render(); + return; + } + S.applyPromptTemplate(n).then(render); + }; bar.append(sel); - const save = el("button", "btn ghost tiny", "Save"); - save.title = "Save the current global prompt + variables as a template"; + + const currentText = () => (composeTextarea ? composeTextarea.value + : (S.get().project?.global_prompt || "")); + + // Saving under an existing name overwrites it, so pre-filling the applied template's + // name makes "update this template" the default gesture rather than a hidden trick. + const save = el("button", "btn ghost tiny", known ? "Update…" : "Save"); + save.title = known + ? `Overwrite "${active}" with the current prompt + variables, or save under a new name` + : "Save the current global prompt + variables as a template"; save.onclick = () => { - const name = (window.prompt("Template name:") || "").trim(); + const name = (window.prompt("Template name:", known ? active : "") || "").trim(); if (!name) return; - const txt = composeTextarea ? composeTextarea.value : (S.get().project?.global_prompt || ""); - S.savePromptTemplate(name, txt); + S.savePromptTemplate(name, currentText()); render(); }; bar.append(save); + + // Rename and delete act on the applied template. They are the two operations the bar + // never offered: a template could only be replaced by saving over its own name, and + // nothing anywhere called the store's deletePromptTemplate. + if (known) { + const ren = el("button", "btn ghost tiny", "✎"); + ren.title = `Rename "${active}"`; + ren.onclick = () => { + const next = (window.prompt("Rename template:", active) || "").trim(); + if (!next || next === active) return; + if (!S.renamePromptTemplate(active, next)) { + alert(`A template called "${next}" already exists.`); + return; + } + render(); + }; + bar.append(ren); + + const del = el("button", "btn ghost tiny danger", "×"); + del.title = `Delete "${active}"`; + del.onclick = () => { + if (!confirm(`Delete the template "${active}"?\n\nThe prompt itself stays as it is.`)) return; + S.deletePromptTemplate(active); + render(); + }; + bar.append(del); + } return bar; } diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index dab31fe9..e5ee0520 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -1101,16 +1101,54 @@ } function deletePromptTemplate(name) { if (!state.project) return; - patchProject({ prompt_templates: (state.project.prompt_templates || []).filter((t) => t && t.name !== name) }); + const patch = { + prompt_templates: (state.project.prompt_templates || []).filter((t) => t && t.name !== name), + }; + // Deleting the one in use leaves nothing selected — the prompt itself is untouched, + // it is just no longer "from" a template. + if (state.project.active_prompt_template === name) patch.active_prompt_template = ""; + patchProject(patch); + } + function renamePromptTemplate(oldName, newName) { + if (!state.project) return false; + const nm = String(newName || "").trim(); + if (!nm || nm === oldName) return false; + const list = state.project.prompt_templates || []; + if (list.some((t) => t && t.name === nm)) return false; // caller reports the clash + const patch = { + prompt_templates: list.map((t) => (t && t.name === oldName ? { ...t, name: nm } : t)), + }; + if (state.project.active_prompt_template === oldName) patch.active_prompt_template = nm; + patchProject(patch); + return true; } async function applyPromptTemplate(name) { if (!state.project) return; const tpl = (state.project.prompt_templates || []).find((t) => t && t.name === name); if (!tpl) return; // Restore the saved variables first, then distribute the prompt (re-splits the timeline). - patchProjectQuiet({ variables: JSON.parse(JSON.stringify(tpl.variables || [])) }); + patchProjectQuiet({ + variables: JSON.parse(JSON.stringify(tpl.variables || [])), + active_prompt_template: name, + }); await applyGlobalPromptQuiet(tpl.prompt || ""); } + // Back to no template. applyGlobalPromptQuiet REFUSES empty text (an empty parse would + // wipe the timeline as a side effect of a stray keystroke), which is why clearing the + // box by hand never did anything — the scene texts it is built from stayed put. Clearing + // is therefore its own deliberate operation, and it is recorded for undo. + function clearGlobalPrompt() { + if (!state.project) return false; + // Drop any debounced edit first. It carries the text that is being cleared, and would + // otherwise land half a second later and redistribute it back onto the scenes. + if (_globalApplyTimer) { clearTimeout(_globalApplyTimer); _globalApplyTimer = null; } + _pendingGlobalPromptText = null; + _historyRecord(); + (state.project.scenes || []).forEach((sc) => { sc.text = ""; }); + patchProject({ active_prompt_template: "" }); + syncGlobalPromptFromTimeline(); + return true; + } function scene(id) { return state.project?.scenes.find((s) => s.id === id) || null; } @@ -4449,6 +4487,7 @@ applyEnginePreset, ENGINE_PRESETS, undo, redo, refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, globalPromptApplyPending, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate: _clockedGenerate, generateMontage: _clockedMontage, generateSelected: _clockedSelected, genElapsed, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelBypass, setModelLink, clearNotice, projectVariables, setProjectVariables, promptTemplates, savePromptTemplate, deletePromptTemplate, applyPromptTemplate, + renamePromptTemplate, clearGlobalPrompt, setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow, loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset, loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, clearShortcuts, addCategory, diff --git a/movie_editor/tests/test_timeline.py b/movie_editor/tests/test_timeline.py index 1a6a953e..da7251a4 100644 --- a/movie_editor/tests/test_timeline.py +++ b/movie_editor/tests/test_timeline.py @@ -438,3 +438,20 @@ def test_projects_store_crud(tmp_path, monkeypatch): assert any(x["id"] == created.id for x in projects.list_projects()) assert projects.delete(created.id) is True assert projects.get(created.id) is None + + +def test_active_prompt_template_round_trips(): + """The Composer marks which template the prompt came from; from_dict reads only known + keys, so an unmodelled field would be silently dropped on every save.""" + from movie_editor.backend.timeline import Project + p = Project.from_dict({"id": "p", "name": "n", "active_prompt_template": "Cinematic"}) + assert p.active_prompt_template == "Cinematic" + assert Project.from_dict(p.to_dict()).active_prompt_template == "Cinematic" + + +def test_active_prompt_template_defaults_to_none_applied(): + from movie_editor.backend.timeline import Project + assert Project.from_dict({"id": "p", "name": "n"}).active_prompt_template == "" + # A stored null must not become the string "None". + assert Project.from_dict({"id": "p", "name": "n", + "active_prompt_template": None}).active_prompt_template == ""