On this page
Project file I/O for PixelWeaver.
#server.src.pixelweaver.storage
#server.src.pixelweaver.storage
Project file I/O for PixelWeaver.
Saves/loads projects as directory structures:
#CanvasNotFoundError
Requested canvas does not exist (a client error, not an invariant violation) -- HTTP handlers map this to 404, while other ValueErrors from export paths (corrupt role planes, size mismatches) are server-side invariant violations and map to 500.
#_assert_within_base
def _assert_within_base(project_dir: Path, base_dir: Path) -> NoneRaise ValueError if project_dir escapes base_dir (path traversal guard).
#save_project
def save_project(project: ProjectState, base_dir: str) -> NoneSave a project to disk as a directory structure.
#_save_canvas
def _save_canvas(canvas: CanvasState, canvas_dir: Path, manifest: dict[str, Any] | None) -> NoneSave a single canvas to its directory.
#_save_layer_png
def _save_layer_png(pixel_bytes: bytes, width: int, height: int, path: Path) -> NoneWrite raw RGBA bytes as a PNG file.
#hex_to_rgb
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]Parse a #rrggbb hex color into an (r, g, b) tuple.
#role_class_ramp
def role_class_ramp(manifest: dict[str, Any] | None, layer: dict[str, Any]) -> list[tuple[int, int, int]]Resolve a role layer's class ramp (list of RGB tuples) from the manifest.
Hard error when the manifest is absent or the class does not resolve -- creation-time validation makes this impossible, so hitting it means corrupted state.
#_save_role_plane_png
def _save_role_plane_png(role_bytes: bytes, width: int, height: int, path: Path, ramp: list[tuple[int, int, int]]) -> NoneWrite role bytes as an indexed PNG (PIL mode "P").
One byte per pixel; the layer's class ramp becomes the PNG palette (padded to 256 entries so the 0xFF sentinel index exists). Transparency for the sentinel uses a tRNS chunk (transparency=255), the standard-compliant palette-alpha mechanism.
Size mismatches and out-of-range role values are hard errors (matching the export path): silently dropping or persisting corrupt role planes is forbidden degradation. (The rgba path's warn-and-skip behavior is long-standing and intentionally untouched.)
#_load_role_plane_png
def _load_role_plane_png(path: Path) -> bytesRead an indexed PNG back into raw role bytes (palette indices).
#load_project
def load_project(project_dir: str) -> ProjectStateLoad a project from its directory on disk.
#_load_canvas
def _load_canvas(canvas_dir: Path) -> CanvasStateLoad a single canvas from its directory.
Requires frames.json to exist. Old pre-UUID format (frames/0/) is not supported -- use the migration script (Step 11) to convert old data.
#_load_layer_png
def _load_layer_png(path: Path) -> bytesRead a PNG file and return raw RGBA bytes.
#list_projects
def list_projects(base_dir: str) -> list[str]List all project directories that contain a project.json.
#save_active_project_marker
def save_active_project_marker(base_dir: str, name: str) -> NonePersist the active project's name to a marker file in the data dir.
Best-effort: a failure to write the marker must never break navigation, so write errors are logged and swallowed. The marker is a tiny JSON file, not project content -- it records only the pointer.
#load_active_project_marker
def load_active_project_marker(base_dir: str) -> str | NoneRead the active project's name from the marker file, or None if absent.
A missing or malformed marker is not an error -- it just means "no persisted choice"; callers fall back to their default.
#_resolve_role_rgba
def _resolve_role_rgba(role_bytes: bytes, width: int, height: int, manifest: dict[str, Any] | None, layer: dict[str, Any]) -> bytesResolve a role plane into raw RGBA bytes via the manifest LUT.
Mirrors the frontend compositor: role -> class(start+role) -> palette hex -> RGBA; sentinel 0xFF transparent; all other values fully opaque. Out-of-range role values are a hard error.
#_effective_visible_leaves
def _effective_visible_leaves(layers: list[dict[str, Any]]) -> list[dict[str, Any]]Leaf layers that are visible AND have every ancestor group visible, bottom-to-top. A flat list yields its visible root leaves (unchanged behavior); nested groups gate their descendants' visibility.
#export_frame_png
def export_frame_png(project: ProjectState, canvas_name: str, frame: int=0) -> bytesComposite all visible layers of a canvas frame into a single PNG.
Returns PNG bytes suitable for an HTTP response.
The frame parameter selects which frame to composite by index. canvas.frame_at() performs bounds checking and raises IndexError for out-of-range indices.
#make_thumbnail_base64
def make_thumbnail_base64(png_bytes: bytes, max_size: int=128) -> strReturn a base64-encoded PNG thumbnail of the given PNG bytes.
Used by MCP tool handlers to attach a preview of the active canvas to mutation results without sending the full composited image. The image is only downscaled when one of its dimensions exceeds max_size; smaller images pass through untouched. Nearest-neighbor resampling is used to preserve pixel-art edges.
This helper replaces the previously inlined Image.thumbnail calls in mcp_registry.py and supersedes the deleted thumbnails.py module, which worked from raw RGBA data rather than composited PNG bytes.