core: anchor and postfix, the other two project-level prompt fields

Alongside the existing negative prompt: anchor (prepended to every scene)
and postfix + postfix_enabled (appended, with its own on/off switch --
v4 proved a postfix worth keeping needs to be turned off for a quick test
without losing it, which clearing the field would do).

Data layer only, deliberately: unlike negative, which maps 1:1 onto an
existing CLIPTextEncode "text" input via a role, anchor and postfix need
to be COMBINED with each scene's own prompt text, and nothing in the
pipeline does that yet -- that is Studio's job, not built. Wiring a UI
field to a mechanism that does not exist yet is the "half-assed port"
this project has already been steered away from once this session.

Also backfilled round-trip and never-trust-a-file tests for the existing
negative field, which had none at the core/projects.py level despite
video already having them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-08 11:00:48 +03:00
co-authored by Claude Sonnet 5
parent 93bdd27b32
commit e9fcfa247b
2 changed files with 85 additions and 3 deletions
+27 -3
View File
@@ -137,10 +137,25 @@ class Project:
#: A negative prompt, sent at whatever "project.negative" input the
#: pipeline declares. Project-level for the same reason `video` is:
#: something set rarely, once, not re-typed per scene.
#: ponytail: one string, not a dict keyed by input name the way `video`
#: is -- there is exactly one such role today. Generalise if a second
#: project-level TEXT role ever shows up.
negative: str = ""
#: Text prepended to every scene's prompt. v4's "anchor" -- the shared
#: subject/setting line a whole project holds once instead of retyping
#: per scene.
anchor: str = ""
#: Text appended to every scene's prompt. v4's "postfix" -- symmetric to
#: anchor, but with its own on/off switch: v4 proved that a postfix
#: someone spent time writing needs to be turned off for a quick test
#: without losing it, which clearing the field would do. Anchor never
#: grew the same need in v4 and does not get one here either.
postfix: str = ""
postfix_enabled: bool = True
#: ponytail: three flat strings (plus one bool), not a dict keyed by role
#: name -- an earlier version of this comment said to generalise once a
#: second project-level text role showed up, and now three have. Tried
#: it: postfix's own enabled flag does not fit a plain role->string map
#: any better than a fourth field would, so the "simpler" shape turns out
#: not to be. Three named fields stay more readable than an abstraction
#: that has to special-case one of its own four entries anyway.
updated_at: float = 0.0
@staticmethod
@@ -149,12 +164,21 @@ class Project:
pid = d.get("id")
raw = d.get("scenes")
negative = d.get("negative")
anchor = d.get("anchor")
postfix = d.get("postfix")
postfix_enabled = d.get("postfix_enabled")
return Project(
id=pid if is_id(pid) else _new_id(),
name=_clean_name(d.get("name")),
scenes=[Scene.from_dict(s) for s in (raw if isinstance(raw, list) else [])],
video=_clean_video(d.get("video")),
negative=negative if isinstance(negative, str) else "",
anchor=anchor if isinstance(anchor, str) else "",
postfix=postfix if isinstance(postfix, str) else "",
# True unless the file explicitly says False -- absent (a project
# from before this field existed) must read as "on", the same as
# v4's own default, not as "off" because nothing was there to say.
postfix_enabled=postfix_enabled if isinstance(postfix_enabled, bool) else True,
updated_at=float(d.get("updated_at") or 0.0),
)
+58
View File
@@ -378,3 +378,61 @@ def test_a_scene_field_read_back_is_never_trusted():
for junk in (0, -4, True, None, "soon", 999_999):
assert projects.Scene.from_dict({"length": junk}).length is None, junk
# --- the project-level prompt fields: negative, anchor, postfix --------------
def test_negative_survives_a_round_trip(store):
made = projects.create("Negative")
made.negative = "blurry, low quality"
projects.save(made)
assert projects.get(made.id).negative == "blurry, low quality"
def test_anchor_survives_a_round_trip(store):
made = projects.create("Anchor")
made.anchor = "a lone rider on a frost-glider"
projects.save(made)
assert projects.get(made.id).anchor == "a lone rider on a frost-glider"
def test_postfix_and_its_toggle_survive_a_round_trip(store):
made = projects.create("Postfix")
made.postfix = "cinematic, 35mm"
made.postfix_enabled = False
projects.save(made)
back = projects.get(made.id)
assert back.postfix == "cinematic, 35mm"
assert back.postfix_enabled is False
def test_a_fresh_project_has_empty_text_fields_and_postfix_on(store):
made = projects.create("Fresh")
assert made.negative == made.anchor == made.postfix == ""
assert made.postfix_enabled is True
def test_a_project_file_from_before_these_fields_existed_reads_as_the_v4_default():
"""postfix_enabled defaults ON, matching v4 -- a project saved before this
field existed must not silently read as "postfix off" just because
nothing was there to say "on"."""
project = projects.Project.from_dict({"name": "Old"})
assert project.postfix_enabled is True
assert project.negative == project.anchor == project.postfix == ""
def test_the_text_fields_read_back_are_never_trusted():
for junk in (0, -4, True, None, ["a", "list"], {"not": "a string"}):
project = projects.Project.from_dict(
{"negative": junk, "anchor": junk, "postfix": junk})
assert project.negative == project.anchor == project.postfix == "", junk
def test_postfix_enabled_read_back_is_never_trusted():
for junk in ("yes", 1, 0, None, [], "false"):
# Anything that is not a real bool degrades to the v4 default (on),
# the same rule every other field here follows: a file cannot say
# something with a value core does not recognise as that thing.
assert projects.Project.from_dict({"postfix_enabled": junk}).postfix_enabled is True, junk
assert projects.Project.from_dict({"postfix_enabled": False}).postfix_enabled is False