Pieces B and C: Scene gains source_image (aspect-ratio reference only -- resolution itself stays a project.video setting, ImageTransformKJ's "total_pixels" keep_proportion fits the picture's aspect into that budget) and references (media ids, in order). Inspector's Scene tab gets pick/clear/ remove controls; picking routes through the existing Media bin via a small pending-pick handshake in boot.js (one bin, one onPick, so "which field asked" has to be tracked between the click that asked and the click that answered). References wire into MiniMaxH3ReferenceToVideo's ref_images autogrow input dynamically, per run, only for what a scene actually has -- an unwired slot, not an empty image, is what "not in use" means to that node. The target is data on the role itself (wireTo), not a slot id boot.js would otherwise have to know, keeping the "core never names an implementation" contract intact one layer up into the app. Verifying this exposed a second real core gap (after the MatchType one): core/graph.py had no idea what an Autogrow (V3's io.Autogrow) input even is -- "ref_images" reported as one opaque COMFY_AUTOGROW_V3 slot, its real per-instance sockets (ref_image_0, ref_image_1, ...) invisible to declared()/build(), so a wire naming one was refused as an input the node does not have. Fixed generically in comfy_types.py (autogrow_instances()) and graph.py, confirmed against ComfyUI's own _io.py (finalize_prefix's dotted join, build_nested_inputs's runtime un-nesting) -- not guessed, and proven end to end with a real override()+build() test using the exact edit boot.js makes.
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""Projects: an ordered list of scenes, one JSON file each.
|
|
|
|
A scene owns its prompt text and the result it last produced. Nothing else about
|
|
a scene lives here. v4's Scene accumulated three dozen fields over its life --
|
|
transitions, effects, audio gain, per-scene frames, guide sources -- and each of
|
|
those belongs to the feature that needs it, added when that feature lands, not to
|
|
the model every part of the app has to load.
|
|
|
|
Per-scene length and fps are deliberately absent: regeneration uses the PROJECT's
|
|
values, never a scene's own, so a scene cropped on the timeline and regenerated
|
|
comes back whole. A crop is a timeline decision; a regenerate is a new scene.
|
|
|
|
Synchronous, one file per project, stdlib json. Projects are small.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import time
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, field
|
|
|
|
from . import config, media
|
|
|
|
#: A generated id, never a user-supplied name, is what reaches the filesystem.
|
|
_ID = re.compile(r"\A[0-9a-f]{12}\Z")
|
|
|
|
MAX_NAME = 120
|
|
|
|
|
|
def _new_id() -> str:
|
|
return uuid.uuid4().hex[:12]
|
|
|
|
|
|
def is_id(value) -> bool:
|
|
"""True for something this module generated. Everything from a request is
|
|
checked against this before it is used to build a path."""
|
|
return isinstance(value, str) and bool(_ID.match(value))
|
|
|
|
|
|
def _clean_name(raw, fallback="Untitled") -> str:
|
|
name = (raw if isinstance(raw, str) else "").strip()
|
|
return name[:MAX_NAME] or fallback
|
|
|
|
|
|
MAX_SETTING = 16384
|
|
|
|
|
|
def _whole(value) -> int | None:
|
|
"""A whole positive number, or nothing. Everything read back out of a
|
|
project file goes through here: the file outlives the code that wrote it."""
|
|
if isinstance(value, bool) or value is None:
|
|
return None
|
|
try:
|
|
number = int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return number if 1 <= number <= MAX_SETTING else None
|
|
|
|
|
|
#: What a person can say about a result: it was good, or it was not.
|
|
#:
|
|
#: Binary because that is all anything downstream reads. dev proved it on H3 --
|
|
#: the steering only ever takes the SIGN of a rating, never its size, so a
|
|
#: ten-point scale was a UI claiming a precision nothing used, and at the sample
|
|
#: sizes this runs at a mediocre middle rating only diluted the direction.
|
|
#:
|
|
#: Core keeps the word and nothing else. What either of them MEANS belongs to
|
|
#: whatever learns from them, which is not here.
|
|
RATINGS = ("liked", "disliked")
|
|
|
|
|
|
@dataclass
|
|
class Scene:
|
|
id: str = field(default_factory=_new_id)
|
|
text: str = ""
|
|
#: The asset this scene last produced, so a reload shows the timeline the
|
|
#: user left rather than an empty one they have to regenerate.
|
|
result: str | None = None
|
|
#: How long this clip RUNS on the timeline -- a crop, made here. It is not
|
|
#: what a regenerate uses: that reads the project, because the crop was a
|
|
#: timeline decision and a regenerate is a new scene.
|
|
length: int | None = None
|
|
rating: str | None = None
|
|
#: A media library id, picked to size this scene's generation -- not
|
|
#: included in it. v4's "drop a picture on the timeline" only ever fed a
|
|
#: resolution/aspect-ratio node; the picture's own pixels went nowhere. Per
|
|
#: scene, not per project: different shots can want a different canvas.
|
|
source_image: str | None = None
|
|
#: Media library ids, in the order they were added -- reference images for
|
|
#: whatever the pipeline's own reference input wants them for. Per scene
|
|
#: for the same reason source_image is: a reference is about THIS shot.
|
|
references: list[str] = field(default_factory=list)
|
|
|
|
@staticmethod
|
|
def from_dict(d) -> "Scene":
|
|
d = d if isinstance(d, dict) else {}
|
|
sid = d.get("id")
|
|
result = d.get("result")
|
|
rating = d.get("rating")
|
|
source_image = d.get("source_image")
|
|
raw_refs = d.get("references")
|
|
return Scene(
|
|
id=sid if is_id(sid) else _new_id(),
|
|
text=d.get("text") if isinstance(d.get("text"), str) else "",
|
|
result=result if isinstance(result, str) else None,
|
|
length=_whole(d.get("length")),
|
|
rating=rating if rating in RATINGS else None,
|
|
source_image=source_image if media.is_id(source_image) else None,
|
|
references=[r for r in raw_refs if media.is_id(r)] if isinstance(raw_refs, list) else [],
|
|
)
|
|
|
|
|
|
def _clean_video(raw) -> dict:
|
|
"""What the app is holding for the pipeline's `project.video` inputs.
|
|
|
|
Core does not know what a video setting IS. Which of them exist is the
|
|
pipeline's business -- it says so with a role -- and naming width and height
|
|
here would be core naming an implementation. What it knows is that a project
|
|
file is the one thing in this app that outlives the code that wrote it, so
|
|
everything read back out of one is checked: a whole positive number, or gone.
|
|
"""
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
clean = {}
|
|
for key, value in raw.items():
|
|
# `True` is an int in Python and would land in a width.
|
|
if not isinstance(key, str) or isinstance(value, bool):
|
|
continue
|
|
try:
|
|
number = int(value)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if 1 <= number <= MAX_SETTING:
|
|
clean[key] = number
|
|
return clean
|
|
|
|
|
|
def _clean_variables(raw) -> list:
|
|
"""[{"name": str, "value": str}, ...] -- anything else in a slot is
|
|
dropped rather than guessed at, the same rule every other field reads
|
|
back out of a project file follows. A name that is empty after stripping
|
|
the leading `$` is not a variable anyone could ever reference, so it is
|
|
dropped rather than kept as a row nothing can ever match."""
|
|
if not isinstance(raw, list):
|
|
return []
|
|
out = []
|
|
for item in raw:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = str(item.get("name") or "").lstrip("$").strip()
|
|
if not name:
|
|
continue
|
|
value = item.get("value")
|
|
out.append({"name": name[:MAX_NAME], "value": value if isinstance(value, str) else ""})
|
|
return out
|
|
|
|
|
|
@dataclass
|
|
class Project:
|
|
id: str = field(default_factory=_new_id)
|
|
name: str = "Untitled"
|
|
scenes: list[Scene] = field(default_factory=list)
|
|
#: Settings the whole project is generated at -- size, length -- rather than
|
|
#: any one scene. A scene cropped on the timeline and regenerated comes back
|
|
#: at the project's length: the crop was a timeline decision and a regenerate
|
|
#: is a new scene.
|
|
video: dict = field(default_factory=dict)
|
|
#: 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.
|
|
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.
|
|
#:
|
|
#: $name -> text, substituted into anchor/scene/postfix at generation
|
|
#: (core/prompt_build.py). A LIST, not a dict: order is what a person set,
|
|
#: and preserving it is what makes "the row I just added" stay at the
|
|
#: bottom instead of jumping around alphabetically on the next save.
|
|
variables: list = field(default_factory=list)
|
|
updated_at: float = 0.0
|
|
|
|
@staticmethod
|
|
def from_dict(d) -> "Project":
|
|
d = d if isinstance(d, dict) else {}
|
|
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,
|
|
variables=_clean_variables(d.get("variables")),
|
|
updated_at=float(d.get("updated_at") or 0.0),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
def _dir():
|
|
config.PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
return config.PROJECTS_DIR
|
|
|
|
|
|
def _path(project_id: str):
|
|
# is_id, not a sanitiser: a name that merely survives cleaning can still be
|
|
# "..", and a store that repairs a bad id quietly is one that writes
|
|
# somewhere nobody asked for.
|
|
if not is_id(project_id):
|
|
raise ValueError(f"not a project id: {project_id!r}")
|
|
return _dir() / f"{project_id}.json"
|
|
|
|
|
|
def listing() -> list[dict]:
|
|
"""Every project, newest first, as the little the picker needs."""
|
|
out = []
|
|
for p in _dir().glob("*.json"):
|
|
if not is_id(p.stem):
|
|
continue
|
|
try:
|
|
d = json.loads(p.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
continue # an unreadable file is not a reason to have no list
|
|
if not isinstance(d, dict):
|
|
continue
|
|
out.append({
|
|
"id": p.stem,
|
|
"name": _clean_name(d.get("name"), p.stem),
|
|
"scenes": len(d.get("scenes") or []),
|
|
"updated_at": float(d.get("updated_at") or 0.0),
|
|
})
|
|
out.sort(key=lambda x: x["updated_at"], reverse=True)
|
|
return out
|
|
|
|
|
|
def get(project_id: str) -> Project | None:
|
|
try:
|
|
path = _path(project_id)
|
|
except ValueError:
|
|
return None
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
return Project.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
|
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
return None
|
|
|
|
|
|
def save(project: Project) -> Project:
|
|
project.updated_at = time.time()
|
|
path = _path(project.id)
|
|
tmp = path.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(project.to_dict(), indent=2), encoding="utf-8")
|
|
tmp.replace(path) # a concurrent read never sees a half-written project
|
|
return project
|
|
|
|
|
|
def create(name=None) -> Project:
|
|
"""A new project with one empty scene: a timeline with nothing on it cannot
|
|
be typed into, so a brand-new project would need an Add before it could be
|
|
used at all."""
|
|
return save(Project(name=_clean_name(name), scenes=[Scene()]))
|
|
|
|
|
|
def delete(project_id: str) -> bool:
|
|
try:
|
|
path = _path(project_id)
|
|
except ValueError:
|
|
return False
|
|
if not path.exists():
|
|
return False
|
|
path.unlink()
|
|
return True
|