Files
olivv-cs-ComfyUI-FunPack/core/projects.py
T
digital-garbageandClaude Opus 5 9dcd94c156 app, core: a project of scenes, and the timeline that draws it
The app could run a pipeline and had nowhere to keep what it was making. A
project is an ordered list of scenes, one JSON file each, and it is the only
part of the app that has to survive a reload.

A scene owns its prompt text and the result it produced, and nothing else. v4's
Scene grew three dozen fields; each belongs to the feature that needs it. Per
scene length and fps are deliberately absent — a regenerate uses the project's
values, so a scene cropped on the timeline comes back whole.

The prompt box edits the current scene rather than holding its own copy, so
what is on screen and what a run uses cannot disagree.

app: the region toggles are named buttons in the timeline bar

v4's arrangement. The kit's fallback is one glyph on a rail at each edge of the
window, which is right for a kit that cannot know what its panels are called and
wrong here, where they have names and a bar to put them in.

A pressed toggle was styled only inside a button group, so a standalone one
carried aria-pressed and looked identical either way.

gallery.strip is selectable like its siblings: a filmstrip is the one shape
whose whole job is saying which of these you are on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:03:19 +03:00

164 lines
5.0 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
#: 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
@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
@staticmethod
def from_dict(d) -> "Scene":
d = d if isinstance(d, dict) else {}
sid = d.get("id")
result = d.get("result")
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,
)
@dataclass
class Project:
id: str = field(default_factory=_new_id)
name: str = "Untitled"
scenes: list[Scene] = 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")
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 [])],
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