On this page
The single schema-neutral mutation path for layer and frame structure on a canvas, returning structural undo records shared by the MCP and WebSocket edit paths.
#server.src.pixelweaver.structural_ops
#server.src.pixelweaver.structural_ops
Shared, schema-neutral structural mutations for PixelWeaver.
The bespoke MCP handlers (mcp_layer_tools, mcp_frame_tools, ...) and the browser command applier (command_applier) both need to mutate the same authoritative structure -- layers and frames on a CanvasState. Previously each layer/frame mutation lived only inside an MCP handler; the browser path fell through to a history-only append and the structure was silently lost on reload (verified bugs: Add/Duplicate Frame and Add/Delete Layer from the UI, set_layer_opacity forwarded but dropped).
This module is the ONE mutation path per structural operation. Every function here:
- takes already-resolved authoritative objects (CanvasState / the target layer dict) plus plain, schema-neutral values (never a client- or MCP-shaped params dict), - performs the mutation, - returns a JSON-serializable structural undo record describing how to revert (undo) and re-apply (redo) the change.
The undo record rides the command-history entry (undo_history.UNDO_KEY) so both the WebSocket and MCP undo paths revert identically and the record survives the collab<->MCP JSON round-trip and history.json persistence. Records are op-specific to bound memory: metadata ops store only the previous scalar value; buffer-destroying ops (layer/frame removal) store the affected buffers.
Callers keep their own validation and error shaping (MCP returns structured error_code dicts; the applier raises CommandApplyError). The guards here are defensive last-resort ValueErrors.
#_snapshot_layer_buffers
def _snapshot_layer_buffers(canvas: CanvasState, layer_id: str) -> dict[str, Any]Capture a layer's per-frame pixel and role buffers keyed by frame index.
The inverse (restore) lives in undo_history -- these snapshots are only ever consumed there via the undo record, so this module never reconstructs.
#_snapshot_subtree_buffers
def _snapshot_subtree_buffers(canvas: CanvasState, layer: dict[str, Any]) -> dict[str, Any]Capture per-frame buffers for EVERY leaf in layer's subtree.
Keyed by leaf id -> {"pixel": {...}, "role": {...}}. A plain leaf yields a single-entry dict; a group yields one entry per descendant leaf. The inverse (restore) lives in undo_history.
#_frame_content
def _frame_content(frame: FrameState) -> dict[str, Any]Serialize a whole frame (id + duration + all buffers).
#snapshot_layers
def snapshot_layers(canvas: CanvasState) -> dict[str, Any]Capture the canvas's full layer tree, active-layer pointer, and every frame's per-layer buffers (base64). JSON-serializable (rides history.json).
#make_layers_snapshot_record
def make_layers_snapshot_record(canvas: CanvasState, before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]Build a whole-layer-tree before/after undo record for one canvas.
#set_layer_field
def set_layer_field(canvas: CanvasState, layer: dict[str, Any], field: str, value: Any) -> dict[str, Any]Set one metadata field on a layer; return a before/after undo record.
#set_layer_fields
def set_layer_fields(canvas: CanvasState, layer: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]Set several metadata fields on a layer at once; return an undo record.
#set_canvas_fields
def set_canvas_fields(canvas: CanvasState, updates: dict[str, Any]) -> dict[str, Any]Set one or more scalar attributes on the canvas; return an undo record.
#set_frame_field
def set_frame_field(canvas: CanvasState, frame_index: int, field: str, value: Any) -> dict[str, Any]Set one metadata field on a frame; return a before/after undo record.
#new_layer_dict
def new_layer_dict(name: str, *, kind: str='rgba', palette_class_id: str | None=None, layer_id: str | None=None) -> dict[str, Any]Build a fresh layer dict matching the authoritative layer shape.
#new_group_dict
def new_group_dict(name: str, *, group_id: str | None=None, children: list[dict[str, Any]] | None=None, expanded: bool=True) -> dict[str, Any]Build a fresh group dict matching the authoritative (client) group shape.
#add_layer
def add_layer(canvas: CanvasState, layer: dict[str, Any], index: int | None=None, *, parent_id: str | None=None) -> dict[str, Any]Insert layer at index within parent_id (default: top of the root list). Return undo record. parent_id names a group, or None for the root list (a non-group / missing parent is a hard error).
#remove_layer
def remove_layer(canvas: CanvasState, layer: dict[str, Any]) -> dict[str, Any]Remove layer (leaf or group) and drop every descendant leaf's per-frame buffers. Return undo record.
Defensive: raises ValueError when removing the last ROOT-level layer (preserves the flat-model guard; a nested layer is always removable).
#move_layer
def move_layer(canvas: CanvasState, layer: dict[str, Any], position: int, *, parent_id: str | None=None) -> dict[str, Any]Move layer to position within parent_id (None = root).
Mirrors the client moveLayer(id, newParentId, newIndex): the layer is removed from its current slot then inserted at the clamped index in the destination list. Returns a parent-aware before/after undo record.
#group_layers
def group_layers(canvas: CanvasState, ids: list[str], group_name: str, group_id: str) -> dict[str, Any]Wrap ids (which must be siblings) in a new group with group_id.
Faithful port of the client groupLayers: the group takes the position of ids[0], the wrapped layers keep their relative order, and the group id is the client-minted (embedded) id so both sides agree. Returns the new group dict.
#ungroup_layer
def ungroup_layer(canvas: CanvasState, group_id: str) -> NoneDissolve a group, splicing its children into the parent at its position (faithful port of the client ungroupLayer).
#add_frame
def add_frame(canvas: CanvasState, insert_pos: int, *, frame_id: str | None=None, duplicate_of: int | None=None) -> tuple[FrameState, dict[str, Any]]Insert a new frame at insert_pos.
duplicate_of (a frame index) deep-copies that frame's buffers into the new frame; otherwise the new frame starts empty. Returns (frame, record).
#add_existing_frame
def add_existing_frame(canvas: CanvasState, insert_pos: int, frame: FrameState) -> dict[str, Any]Insert a pre-built FrameState at insert_pos; return a frame_add record carrying the frame's FULL content (used by paste_frame, which builds frames with embedded buffers before inserting them).
#remove_frame
def remove_frame(canvas: CanvasState, frame_index: int) -> dict[str, Any]Remove the frame at frame_index. Return undo record.
Defensive: raises ValueError on the last frame or an out-of-range index.
#create_part_family
def create_part_family(project, *, part_set: str, part_name: str, count: int, width: int, height: int, start_index: int=0, canvas_ids: list[str] | None=None) -> tuple[list[dict[str, str]], dict[str, Any]]Create count canvases <part_set>.<i> as a part family (ONE path).
Shared by the MCP handler and the browser command applier so a family is minted identically regardless of which client asked. Each canvas gets a single rgba "Layer 0" and one empty frame keyed by that layer id, and its part-set metadata is set through the validated setter.
Validation is atomic (dry-run first): a dimension mismatch with an existing set member, a name collision, or a duplicate (part_name, variant_index) is a hard ValueError and NOTHING is created. Callers shape the error (MCP -> structured dict; the applier -> CommandApplyError).
canvas_ids are the CLIENT-generated canvas instance ids (stabilized in the browser command's execute); when present they label the returned/recorded entries so both sides agree on identity. The server keys canvases by name, so the ids are identity metadata only. Returns (created, record) where created is [{"name", "id"}, ...] and record is a project-level canvases_added undo record.
#reorder_frames
def reorder_frames(canvas: CanvasState, new_order_ids: list[str]) -> dict[str, Any]Reorder frames to match new_order_ids (list of frame ids).
Robust for both single-frame moves (reorder_frame) and bulk reorders (reorder_frames): the caller computes the target ordering and this captures a by-id before/after so undo/redo is order-exact.