diff --git a/core/projects.py b/core/projects.py index 372047ee..3532fbc3 100644 --- a/core/projects.py +++ b/core/projects.py @@ -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), ) diff --git a/core/tests/test_projects.py b/core/tests/test_projects.py index 48581703..9137bbc7 100644 --- a/core/tests/test_projects.py +++ b/core/tests/test_projects.py @@ -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