fix(editor): prompt templates are editable, deletable and dismissable
The bar never recorded which template was applied, so there was nothing to rename, delete or leave; deletePromptTemplate had no caller at all. Clearing is now explicit because applyGlobalPromptQuiet refuses empty text.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 []),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
Reference in New Issue
Block a user