editor: show postfix in the prompt preview

The per-scene prompt preview mirrors what Studio encodes, and the postfix is
appended to every scene at generation — so it belongs there. Surface it once
after the scenes as a 'postfix' badge, symmetric to the 'anchor' badge shown
once above them.

- bridge.expand_prompt_fragment: expands a standalone fragment (anchor/postfix)
  the same way generation does, so the previewed text matches exactly.
- server preview: result["postfix"] = expanded effective_postfix(p).
- inspector: postfix badge line; _promptPreviewKey includes postfix so editing
  it refreshes the preview live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-23 17:28:01 +03:00
co-authored by Claude Opus 4.8
parent c5ead80ae8
commit c70d046a14
6 changed files with 50 additions and 0 deletions
+19
View File
@@ -68,6 +68,25 @@ def parse_timeline(prompt: str, seed: int = 0) -> dict:
return {"anchor": s.get("anchor_expanded", ""), "scenes": scenes, "transitions": transitions}
def expand_prompt_fragment(text: str) -> str:
"""Expand shortcuts in a standalone fragment (anchor / postfix) the way generation does —
mirrors the _flatten(split_scenes(...)) the chain sampler runs on each, so the preview shows
exactly the expanded text Studio will append/prepend. Empty in → empty out."""
if not str(text or "").strip():
return ""
split_fn = _funpack_attr("conditioning", "split_scenes")
s = split_fn(str(text))
parts = []
ae = (s.get("anchor_expanded") or "").strip()
if ae:
parts.append(ae)
for sc in (s.get("scenes", []) or []):
e = (sc.get("expanded") or "").strip()
if e:
parts.append(e)
return " ".join(parts).strip()
def parse_timeline_raw(prompt: str) -> dict:
"""Split WITHOUT expanding shortcuts — scene texts stay verbatim (shortcuts and
transition triggers preserved). Used by editing views so the user keeps operating
+8
View File
@@ -580,6 +580,14 @@
if (t) l.append(el("span", "pv-badge trans", "→ split"));
box.append(l);
});
// Postfix is appended to every scene at generation — show it once after the scenes, mirroring
// how the anchor (prepended) is shown once above them.
if (pv.postfix) {
const l = el("div", "pv-line");
l.append(el("span", "pv-badge postfix", "postfix"));
l.append(el("span", null, pv.postfix));
box.append(l);
}
const raw = el("details", "pv-raw"); raw.append(el("summary", null, "generation prompt (sent to Studio)")); raw.append(el("pre", null, pv.combined_prompt || "")); box.append(raw);
if (pv.display_prompt && pv.display_prompt !== pv.combined_prompt) {
const disp = el("details", "pv-raw"); disp.append(el("summary", null, "display prompt (timeline view)")); disp.append(el("pre", null, pv.display_prompt)); box.append(disp);
+2
View File
@@ -125,6 +125,8 @@
if (!project) return "";
return JSON.stringify({
anchor: project.anchor,
postfix: project.postfix,
postfix_enabled: project.postfix_enabled,
global_prompt: project.global_prompt,
intro_transition: project.intro_transition,
scenes: (project.scenes || []).map((s) => ({
+1
View File
@@ -550,6 +550,7 @@ input[type=number] { font-family: var(--font-mono); }
.pv-line { display: flex; gap: 8px; align-items: baseline; padding: 3px 0; font-size: 12.5px; }
.pv-badge { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; color: var(--teal); border: 1px solid #57d0c655; border-radius: 4px; padding: 1px 5px; flex: 0 0 auto; }
.pv-badge.anchor { color: var(--amber); border-color: #f3a93c55; }
.pv-badge.postfix { color: var(--amber); border-color: #f3a93c55; }
.pv-badge.trans { color: var(--faint); border-color: transparent; font-size: 9px; }
.pv-badge.rkey { color: #c79bf2; border-color: #c79bf255; text-transform: none; letter-spacing: 0; }
.pv-badge.rkey.default { color: var(--faint); border-color: #ffffff22; }
+4
View File
@@ -29,6 +29,7 @@ from .backend.timeline import (
collapse_generative_units,
continuity_media_refs,
effective_negative_prompt,
effective_postfix,
gen_unit_id,
group_generative_units,
is_video_clip,
@@ -1188,6 +1189,9 @@ if web is not None and PromptServer is not None:
result["scene_refinement_keys"] = bridge.scene_refinement_keys(
prompt, p.refinement_key)
result["refinement_key_pool"] = bridge.refinement_key_pool(prompt)
# Postfix is appended to every scene at generation but lives outside the prompt —
# surface it (expanded) so the preview mirrors what Studio actually encodes.
result["postfix"] = bridge.expand_prompt_fragment(effective_postfix(p))
except Exception as e: # noqa: BLE001
result["parse_error"] = str(e)
return web.json_response(result)
+16
View File
@@ -34,6 +34,7 @@ sys.modules.setdefault(
import templates
import conditioning
from templates import apply_prompt_shortcuts, normalize_shortcut_item
from movie_editor.backend import bridge
def _db(shortcuts):
@@ -137,6 +138,21 @@ def test_postfix_shortcuts_expand_and_key_folds_into_every_scene(monkeypatch):
assert "look" in scene["keys"]
def test_expand_prompt_fragment_matches_generation_postfix(monkeypatch):
"""The preview's postfix text must be expanded exactly like generation appends it — same
shortcut expansion the chain sampler runs via split_scenes_from_segments."""
db = _db({"cine": (["cine"], ["cinematic, shallow depth of field"], "look")})
monkeypatch.setattr(templates, "load_shortcut_db", lambda: db)
monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {})
preview = bridge.expand_prompt_fragment("cine")
generation = conditioning.split_scenes_from_segments(
"", ["x"], postfix="cine")["postfix_expanded"]
assert preview == "cinematic, shallow depth of field"
assert preview == generation # preview mirrors generation exactly
assert bridge.expand_prompt_fragment("") == "" # disabled/empty postfix shows nothing
def test_split_scenes_no_false_cut_from_generic_label_in_expansion(monkeypatch):
"""A content shortcut whose expansion merely CONTAINS 'scene 2' must NOT cut — only a
user-defined custom transition phrase does (the generic label is never matched on expansions)."""