pixelweaver 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.development +1 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +18 -0
- package/.prettierignore +5 -0
- package/.prettierrc +16 -0
- package/.python-version +1 -0
- package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
- package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
- package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
- package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
- package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
- package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
- package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
- package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
- package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
- package/.rlsbl/bases/CHANGELOG.md +5 -0
- package/.rlsbl/bases/LICENSE +21 -0
- package/.rlsbl/changes/.validated +1 -0
- package/.rlsbl/changes/0.1.0.jsonl +85 -0
- package/.rlsbl/changes/0.1.0.md +13 -0
- package/.rlsbl/changes/unreleased.jsonl +0 -0
- package/.rlsbl/config.json +6 -0
- package/.rlsbl/hashes.json +14 -0
- package/.rlsbl/hooks/post-release.sh +8 -0
- package/.rlsbl/hooks/pre-checks.sh +5 -0
- package/.rlsbl/hooks/pre-release.sh +8 -0
- package/.rlsbl/lint/go.toml +17 -0
- package/.rlsbl/lint/npm.toml +19 -0
- package/.rlsbl/lint/python.toml +25 -0
- package/.rlsbl/releases/unreleased.toml +0 -0
- package/.rlsbl/releases/v0.1.0.toml +3 -0
- package/.rlsbl/version +1 -0
- package/.selfdoc/hashes/hashes.json +146 -0
- package/.strictcli/schema.json +227 -0
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +100 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/assets/icon.png +0 -0
- package/docs/_README.md +117 -0
- package/docs/cli-config.md +35 -0
- package/docs/cli-dev.md +21 -0
- package/docs/cli-diagnose.md +12 -0
- package/docs/cli-index.md +30 -0
- package/docs/cli-list.md +18 -0
- package/docs/cli-mcp.md +18 -0
- package/docs/cli-new.md +26 -0
- package/docs/cli-open.md +21 -0
- package/docs/cli-serve.md +21 -0
- package/docs/cli-stop.md +12 -0
- package/docs/gen-index.md +36 -0
- package/docs/index.md +13 -0
- package/docs/server-src-pixelweaver-__main__.md +12 -0
- package/docs/server-src-pixelweaver-autosave.md +12 -0
- package/docs/server-src-pixelweaver-bridge.md +12 -0
- package/docs/server-src-pixelweaver-cli.md +12 -0
- package/docs/server-src-pixelweaver-config.md +12 -0
- package/docs/server-src-pixelweaver-connections.md +12 -0
- package/docs/server-src-pixelweaver-main.md +12 -0
- package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
- package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
- package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
- package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
- package/docs/server-src-pixelweaver-mcp_server.md +12 -0
- package/docs/server-src-pixelweaver-protocol.md +12 -0
- package/docs/server-src-pixelweaver-state.md +12 -0
- package/docs/server-src-pixelweaver-storage.md +12 -0
- package/docs/server-src-pixelweaver-websocket.md +12 -0
- package/docs/server-src-pixelweaver.md +12 -0
- package/e2e/app-launch.test.ts +35 -0
- package/e2e/menus.test.ts +26 -0
- package/e2e/tools.test.ts +27 -0
- package/e2e/undo-redo.test.ts +11 -0
- package/eslint.config.js +62 -0
- package/index.html +13 -0
- package/package.json +48 -0
- package/playwright.config.ts +19 -0
- package/plugins/builtin/.gitkeep +0 -0
- package/plugins/builtin/advanced-fill-tool.ts +146 -0
- package/plugins/builtin/circle-tool.ts +186 -0
- package/plugins/builtin/diamond-tool.ts +182 -0
- package/plugins/builtin/dither-tool.ts +186 -0
- package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
- package/plugins/builtin/drawing-utils.test.ts +495 -0
- package/plugins/builtin/drawing-utils.ts +431 -0
- package/plugins/builtin/effects/blur.ts +97 -0
- package/plugins/builtin/effects/color-effects.ts +278 -0
- package/plugins/builtin/effects/flip.ts +83 -0
- package/plugins/builtin/effects/glow.ts +118 -0
- package/plugins/builtin/effects/outline.ts +110 -0
- package/plugins/builtin/effects/rotate.ts +357 -0
- package/plugins/builtin/effects/scale.ts +258 -0
- package/plugins/builtin/effects/shadow.ts +111 -0
- package/plugins/builtin/effects/sharpen.ts +102 -0
- package/plugins/builtin/effects.test.ts +715 -0
- package/plugins/builtin/eraser-tool.ts +23 -0
- package/plugins/builtin/eyedropper-tool.ts +83 -0
- package/plugins/builtin/fill-tool.ts +93 -0
- package/plugins/builtin/gradient-tool.ts +204 -0
- package/plugins/builtin/gradient.test.ts +142 -0
- package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
- package/plugins/builtin/importers/aseprite-parser.ts +497 -0
- package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
- package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
- package/plugins/builtin/line-tool.ts +267 -0
- package/plugins/builtin/make-stroke-tool.ts +271 -0
- package/plugins/builtin/noise-dither.test.ts +151 -0
- package/plugins/builtin/noise-tool.ts +131 -0
- package/plugins/builtin/pattern-stamp-tool.ts +162 -0
- package/plugins/builtin/pencil-tool.ts +25 -0
- package/plugins/builtin/rect-tool.ts +179 -0
- package/plugins/builtin/selection-tool.ts +388 -0
- package/plugins/builtin/selection.test.ts +195 -0
- package/plugins/builtin/tool-plugins.test.ts +529 -0
- package/public/favicon.svg +7 -0
- package/public/sw.js +91 -0
- package/pyproject.toml +49 -0
- package/scripts/eslint-wave-a-list.sh +24 -0
- package/scripts/eslint-wave-a-status.sh +25 -0
- package/scripts/fix-index-signature-access.py +127 -0
- package/scripts/fix-unchecked-index.py +128 -0
- package/scripts/fix-unchecked-vars.py +127 -0
- package/scripts/fix-wave-a-bangs.py +36 -0
- package/scripts/fix-wave-a-templates.py +108 -0
- package/scripts/fix-wave-b-void-expr.py +167 -0
- package/scripts/generate-command-params.py +540 -0
- package/scripts/migrate-single-frame-to-multi.py +171 -0
- package/scripts/smoke-test.sh +77 -0
- package/selfdoc.json +10 -0
- package/server/README.md +0 -0
- package/server/src/pixelweaver/__init__.py +1 -0
- package/server/src/pixelweaver/__main__.py +4 -0
- package/server/src/pixelweaver/autosave.py +114 -0
- package/server/src/pixelweaver/bridge.py +127 -0
- package/server/src/pixelweaver/cli.py +199 -0
- package/server/src/pixelweaver/config.py +24 -0
- package/server/src/pixelweaver/connections.py +54 -0
- package/server/src/pixelweaver/main.py +271 -0
- package/server/src/pixelweaver/mcp_bridge.py +189 -0
- package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
- package/server/src/pixelweaver/mcp_export_tools.py +291 -0
- package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
- package/server/src/pixelweaver/mcp_history_tools.py +106 -0
- package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
- package/server/src/pixelweaver/mcp_lock.py +37 -0
- package/server/src/pixelweaver/mcp_project_tools.py +302 -0
- package/server/src/pixelweaver/mcp_read_tools.py +163 -0
- package/server/src/pixelweaver/mcp_registry.py +247 -0
- package/server/src/pixelweaver/mcp_resources.py +312 -0
- package/server/src/pixelweaver/mcp_server.py +234 -0
- package/server/src/pixelweaver/protocol.py +219 -0
- package/server/src/pixelweaver/state.py +267 -0
- package/server/src/pixelweaver/storage.py +293 -0
- package/server/src/pixelweaver/websocket.py +282 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/conftest.py +17 -0
- package/server/tests/test_api.py +96 -0
- package/server/tests/test_bridge.py +161 -0
- package/server/tests/test_health.py +9 -0
- package/server/tests/test_integration.py +86 -0
- package/server/tests/test_mcp_bridge.py +293 -0
- package/server/tests/test_mcp_lock.py +34 -0
- package/server/tests/test_mcp_registry.py +679 -0
- package/server/tests/test_mcp_resources.py +648 -0
- package/server/tests/test_protocol.py +125 -0
- package/server/tests/test_state.py +87 -0
- package/server/tests/test_storage.py +306 -0
- package/server/tests/test_websocket.py +275 -0
- package/src/App.svelte +107 -0
- package/src/app.css +215 -0
- package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
- package/src/lib/animation/animation-commands.test.ts +228 -0
- package/src/lib/animation/animation-commands.ts +540 -0
- package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
- package/src/lib/animation/animation-preview.svelte.ts +151 -0
- package/src/lib/animation/clipboard.ts +134 -0
- package/src/lib/animation/frame-model.svelte.ts +437 -0
- package/src/lib/animation/frame-model.test.ts +314 -0
- package/src/lib/animation/frame-selection.svelte.ts +77 -0
- package/src/lib/animation/frame-tags.svelte.ts +238 -0
- package/src/lib/animation/frame-tags.test.ts +136 -0
- package/src/lib/animation/import.test.ts +141 -0
- package/src/lib/animation/import.ts +112 -0
- package/src/lib/animation/spritesheet-export.test.ts +239 -0
- package/src/lib/animation/spritesheet-export.ts +314 -0
- package/src/lib/canvas/CanvasViewport.svelte +216 -0
- package/src/lib/canvas/canvas-init-plugin.ts +178 -0
- package/src/lib/canvas/canvas-renderer.ts +408 -0
- package/src/lib/canvas/canvas-state.svelte.ts +232 -0
- package/src/lib/canvas/canvas-state.test.ts +139 -0
- package/src/lib/canvas/input-handler.ts +221 -0
- package/src/lib/canvas/input-plugin.ts +150 -0
- package/src/lib/canvas/onion-skin.ts +94 -0
- package/src/lib/canvas/pixel-buffer.test.ts +249 -0
- package/src/lib/canvas/pixel-buffer.ts +151 -0
- package/src/lib/canvas/render-state.svelte.ts +18 -0
- package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
- package/src/lib/canvas/tile-mode.test.ts +53 -0
- package/src/lib/canvas/tile-mode.ts +92 -0
- package/src/lib/canvas/viewport-utils.ts +24 -0
- package/src/lib/canvas/zoom-utils.ts +31 -0
- package/src/lib/color/.gitkeep +0 -0
- package/src/lib/color/color-commands.ts +87 -0
- package/src/lib/color/color-state.svelte.ts +98 -0
- package/src/lib/color/color-state.test.ts +91 -0
- package/src/lib/color/color-utils.test.ts +220 -0
- package/src/lib/color/color-utils.ts +243 -0
- package/src/lib/color/palette-state.svelte.ts +127 -0
- package/src/lib/color/palette-state.test.ts +154 -0
- package/src/lib/color/palette.ts +79 -0
- package/src/lib/core/bootstrap.ts +66 -0
- package/src/lib/core/command-params.generated.ts +1549 -0
- package/src/lib/core/command-params.ts +20 -0
- package/src/lib/core/command-runner.ts +79 -0
- package/src/lib/core/commands.ts +134 -0
- package/src/lib/core/dispatcher.test.ts +548 -0
- package/src/lib/core/dispatcher.ts +361 -0
- package/src/lib/core/index.test.ts +7 -0
- package/src/lib/core/notification-state.svelte.ts +119 -0
- package/src/lib/core/plugin-api.ts +210 -0
- package/src/lib/core/plugin-discovery.test.ts +53 -0
- package/src/lib/core/plugin-discovery.ts +65 -0
- package/src/lib/core/plugin-loader.test.ts +159 -0
- package/src/lib/core/plugin-loader.ts +240 -0
- package/src/lib/core/plugin-types.ts +286 -0
- package/src/lib/core/registries.svelte.ts +74 -0
- package/src/lib/core/tool-options-state.svelte.ts +61 -0
- package/src/lib/dock/DockLayout.svelte +375 -0
- package/src/lib/dock/TabAddMenu.svelte +90 -0
- package/src/lib/dock/dock-persistence.ts +46 -0
- package/src/lib/dock/dock-plugin.ts +49 -0
- package/src/lib/dock/dock-presets.ts +156 -0
- package/src/lib/dock/dock-state.svelte.ts +77 -0
- package/src/lib/dock/dock-types.ts +2 -0
- package/src/lib/dock/dockview-theme.css +226 -0
- package/src/lib/dock/dockview-theme.ts +17 -0
- package/src/lib/dock/header-action-renderer.ts +77 -0
- package/src/lib/edit/clipboard-state.ts +34 -0
- package/src/lib/export/download.ts +201 -0
- package/src/lib/export/png-metadata.ts +181 -0
- package/src/lib/history/ActionLogPanel.svelte +418 -0
- package/src/lib/history/action-log-panel-plugin.ts +24 -0
- package/src/lib/history/action-log.svelte.ts +172 -0
- package/src/lib/history/action-log.test.ts +168 -0
- package/src/lib/history/history-commands.ts +403 -0
- package/src/lib/history/macros.svelte.ts +320 -0
- package/src/lib/history/macros.test.ts +224 -0
- package/src/lib/history/replay-engine.test.ts +241 -0
- package/src/lib/history/replay-engine.ts +149 -0
- package/src/lib/history/spec-format.test.ts +250 -0
- package/src/lib/history/spec-format.ts +210 -0
- package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
- package/src/lib/iso/iso-math.test.ts +77 -0
- package/src/lib/iso/iso-math.ts +77 -0
- package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
- package/src/lib/iso/seam-checker.test.ts +126 -0
- package/src/lib/iso/seam-checker.ts +93 -0
- package/src/lib/iso/tessellation.ts +67 -0
- package/src/lib/layers/compositor.test.ts +193 -0
- package/src/lib/layers/compositor.ts +175 -0
- package/src/lib/layers/layer-commands.test.ts +263 -0
- package/src/lib/layers/layer-commands.ts +429 -0
- package/src/lib/layers/layer-tree.svelte.ts +516 -0
- package/src/lib/layers/layer-tree.test.ts +383 -0
- package/src/lib/layers/layer-types.ts +56 -0
- package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
- package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
- package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
- package/src/lib/leveleditor/depth-sort.test.ts +70 -0
- package/src/lib/leveleditor/depth-sort.ts +39 -0
- package/src/lib/leveleditor/level-editor-commands.ts +353 -0
- package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
- package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
- package/src/lib/leveleditor/map-state.svelte.ts +372 -0
- package/src/lib/leveleditor/map-state.test.ts +243 -0
- package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
- package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
- package/src/lib/leveleditor/tiled-export.test.ts +144 -0
- package/src/lib/leveleditor/tiled-export.ts +374 -0
- package/src/lib/pywebview.d.ts +15 -0
- package/src/lib/recovery/.gitkeep +0 -0
- package/src/lib/recovery/idb-store.ts +118 -0
- package/src/lib/recovery/recovery-manager.test.ts +321 -0
- package/src/lib/recovery/recovery-manager.ts +115 -0
- package/src/lib/recovery/recovery-plugin.ts +55 -0
- package/src/lib/recovery/recovery-state.svelte.ts +21 -0
- package/src/lib/recovery/sw-plugin.ts +18 -0
- package/src/lib/recovery/sw-register.ts +17 -0
- package/src/lib/save/directory-format.ts +42 -0
- package/src/lib/save/project-snapshot.ts +139 -0
- package/src/lib/save/recent-projects.ts +56 -0
- package/src/lib/save/save-state.svelte.ts +35 -0
- package/src/lib/save/storage.ts +167 -0
- package/src/lib/save/zip-format.ts +45 -0
- package/src/lib/shortcuts/.gitkeep +0 -0
- package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
- package/src/lib/shortcuts/default-bindings.ts +61 -0
- package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
- package/src/lib/shortcuts/shortcut-init.ts +380 -0
- package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
- package/src/lib/shortcuts/shortcut-manager.ts +281 -0
- package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
- package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
- package/src/lib/sync/patch-applicator.ts +300 -0
- package/src/lib/sync/patch-types.ts +65 -0
- package/src/lib/sync/project-manager.ts +108 -0
- package/src/lib/sync/sync-init.ts +152 -0
- package/src/lib/sync/sync-plugin.ts +19 -0
- package/src/lib/sync/sync-state.svelte.ts +56 -0
- package/src/lib/sync/ws-client.test.ts +604 -0
- package/src/lib/sync/ws-client.ts +574 -0
- package/src/lib/tools/.gitkeep +0 -0
- package/src/lib/ui/.gitkeep +0 -0
- package/src/lib/ui/AboutDialog.svelte +113 -0
- package/src/lib/ui/ColorPicker.svelte +761 -0
- package/src/lib/ui/ContextMenu.svelte +216 -0
- package/src/lib/ui/ExportDialog.svelte +747 -0
- package/src/lib/ui/FrameStrip.svelte +854 -0
- package/src/lib/ui/LayerPanel.svelte +810 -0
- package/src/lib/ui/MenuBar.svelte +590 -0
- package/src/lib/ui/NewProjectDialog.svelte +803 -0
- package/src/lib/ui/PluginManagerPanel.svelte +475 -0
- package/src/lib/ui/PromptDialog.svelte +252 -0
- package/src/lib/ui/RecoveryDialog.svelte +295 -0
- package/src/lib/ui/ResizeDialog.svelte +416 -0
- package/src/lib/ui/StatusBar.svelte +145 -0
- package/src/lib/ui/ToolbarPanel.svelte +488 -0
- package/src/lib/ui/animation-menu-commands.ts +194 -0
- package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
- package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
- package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
- package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
- package/src/lib/ui/dialog-state.svelte.ts +70 -0
- package/src/lib/ui/edit-commands.ts +271 -0
- package/src/lib/ui/file-commands.ts +275 -0
- package/src/lib/ui/file-open.ts +99 -0
- package/src/lib/ui/help-commands.ts +93 -0
- package/src/lib/ui/image-commands.ts +181 -0
- package/src/lib/ui/layer-menu-commands.ts +420 -0
- package/src/lib/ui/menu-builder.ts +224 -0
- package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
- package/src/lib/ui/notifications/notification-plugin.ts +29 -0
- package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
- package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
- package/src/lib/ui/plugin-state.svelte.ts +62 -0
- package/src/lib/ui/select-commands.ts +75 -0
- package/src/lib/ui/theme-plugin.ts +18 -0
- package/src/lib/ui/theme.svelte.ts +45 -0
- package/src/lib/ui/theme.test.ts +51 -0
- package/src/lib/ui/toolbar-config.ts +90 -0
- package/src/lib/ui/toolbar-plugin.ts +39 -0
- package/src/lib/ui/view-commands.ts +629 -0
- package/src/lib/variants/BisectionExportDialog.svelte +500 -0
- package/src/lib/variants/VariantPanel.svelte +822 -0
- package/src/lib/variants/bisection-export.test.ts +113 -0
- package/src/lib/variants/bisection-export.ts +148 -0
- package/src/lib/variants/palette-extraction.test.ts +111 -0
- package/src/lib/variants/palette-extraction.ts +84 -0
- package/src/lib/variants/palette-interpolation.test.ts +113 -0
- package/src/lib/variants/palette-interpolation.ts +87 -0
- package/src/lib/variants/palette-swap.test.ts +101 -0
- package/src/lib/variants/palette-swap.ts +114 -0
- package/src/lib/variants/variant-commands.ts +594 -0
- package/src/lib/variants/variant-panel-plugin.ts +27 -0
- package/src/lib/variants/variant-randomizer.ts +101 -0
- package/src/lib/variants/variant-state.svelte.ts +166 -0
- package/src/lib/variants/variant-state.test.ts +138 -0
- package/src/main.ts +14 -0
- package/src/vite-env.d.ts +3 -0
- package/svelte.config.js +2 -0
- package/todo/.done/audit-design-decisions.md +812 -0
- package/todo/.done/audit-implementation-plan.md +1235 -0
- package/todo/.done/happy-path-polish.md +177 -0
- package/todo/.done/pixelweaver-full-build.md +937 -0
- package/todo/.done/server-multi-frame-design.md +405 -0
- package/todo/.done/typed-dispatcher-design.md +435 -0
- package/todo/.done/unified-toolbar-and-action-system.md +323 -0
- package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
- package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
- package/todo/comprehensive-audit.md +1085 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +20 -0
- package/uv.lock +1167 -0
- package/vite.config.ts +32 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""In-memory authoritative state for PixelWeaver projects.
|
|
2
|
+
|
|
3
|
+
The server is the single source of truth. All state mutations flow through
|
|
4
|
+
ServerState, which is then persisted to disk by the auto-saver.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pixelweaver.mcp_lock import MCPLock
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FrameState:
|
|
19
|
+
"""Single animation frame within a canvas.
|
|
20
|
+
|
|
21
|
+
Each frame holds its own pixel data per layer. The id is a stable UUID
|
|
22
|
+
that matches the frontend Frame.id. duration_ms=None means "derive
|
|
23
|
+
from global_fps".
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
duration_ms: int | None = None
|
|
28
|
+
pixel_data: dict[str, bytes] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class CanvasState:
|
|
33
|
+
"""State for a single canvas within a project."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
width: int
|
|
37
|
+
height: int
|
|
38
|
+
layers: list[dict[str, Any]] = field(default_factory=list)
|
|
39
|
+
# Pixel data lives exclusively in FrameState.pixel_data (per frame,
|
|
40
|
+
# per layer). Access via canvas.current_frame().pixel_data[layer_id].
|
|
41
|
+
frames: list[FrameState] = field(default_factory=list)
|
|
42
|
+
current_frame_index: int = 0
|
|
43
|
+
global_fps: float = 12.0
|
|
44
|
+
origin_x: int = 0
|
|
45
|
+
origin_y: int = 0
|
|
46
|
+
|
|
47
|
+
def current_frame(self) -> FrameState:
|
|
48
|
+
"""Return the currently selected frame (bounds-checked)."""
|
|
49
|
+
if not self.frames:
|
|
50
|
+
raise ValueError("Canvas has no frames")
|
|
51
|
+
idx = max(0, min(self.current_frame_index, len(self.frames) - 1))
|
|
52
|
+
return self.frames[idx]
|
|
53
|
+
|
|
54
|
+
def frame_at(self, index: int) -> FrameState:
|
|
55
|
+
"""Return frame at the given index (bounds-checked)."""
|
|
56
|
+
if index < 0 or index >= len(self.frames):
|
|
57
|
+
raise IndexError(
|
|
58
|
+
f"Frame index {index} out of range [0, {len(self.frames)})"
|
|
59
|
+
)
|
|
60
|
+
return self.frames[index]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def frame_count(self) -> int:
|
|
64
|
+
"""Number of frames in this canvas."""
|
|
65
|
+
return len(self.frames)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
"""Serialize to a JSON-compatible dict (excludes heavy pixel data)."""
|
|
69
|
+
return {
|
|
70
|
+
"name": self.name,
|
|
71
|
+
"width": self.width,
|
|
72
|
+
"height": self.height,
|
|
73
|
+
"layers": self.layers,
|
|
74
|
+
"frames": [
|
|
75
|
+
{"id": f.id, "duration_ms": f.duration_ms} for f in self.frames
|
|
76
|
+
],
|
|
77
|
+
"current_frame_index": self.current_frame_index,
|
|
78
|
+
"global_fps": self.global_fps,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def to_full_dict(self) -> dict[str, Any]:
|
|
82
|
+
"""Serialize including info about which layers have pixel data."""
|
|
83
|
+
d = self.to_dict()
|
|
84
|
+
d["pixel_data_layers"] = list(self.current_frame().pixel_data.keys())
|
|
85
|
+
return d
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ProjectState:
|
|
90
|
+
"""State for a single project."""
|
|
91
|
+
|
|
92
|
+
name: str
|
|
93
|
+
width: int
|
|
94
|
+
height: int
|
|
95
|
+
canvases: dict[str, CanvasState] = field(default_factory=dict)
|
|
96
|
+
command_history: list[dict[str, Any]] = field(default_factory=list)
|
|
97
|
+
# Commands that were undone -- available for redo
|
|
98
|
+
redo_stack: list[dict[str, Any]] = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict[str, Any]:
|
|
101
|
+
"""Serialize to a JSON-compatible dict for state sync."""
|
|
102
|
+
return {
|
|
103
|
+
"name": self.name,
|
|
104
|
+
"width": self.width,
|
|
105
|
+
"height": self.height,
|
|
106
|
+
"canvases": {k: v.to_dict() for k, v in self.canvases.items()},
|
|
107
|
+
"history": self.command_history,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ServerState:
|
|
112
|
+
"""In-memory authoritative state for all projects."""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self.projects: dict[str, ProjectState] = {}
|
|
116
|
+
self.active_project: str | None = None
|
|
117
|
+
# Shared mutex protecting concurrent state mutations from MCP tool
|
|
118
|
+
# calls and WebSocket command handlers within the same process.
|
|
119
|
+
self.mutation_lock = MCPLock()
|
|
120
|
+
|
|
121
|
+
# Hard ceiling on canvas dimensions to prevent OOM allocation.
|
|
122
|
+
# MCP caps at 1024, REST at 4096; this is the last line of defense.
|
|
123
|
+
MAX_DIMENSION = 4096
|
|
124
|
+
|
|
125
|
+
def create_project(self, name: str, width: int, height: int) -> ProjectState:
|
|
126
|
+
"""Create a new project with a default canvas and layer."""
|
|
127
|
+
if not (1 <= width <= self.MAX_DIMENSION and 1 <= height <= self.MAX_DIMENSION):
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Dimensions must be 1..{self.MAX_DIMENSION}, got {width}x{height}"
|
|
130
|
+
)
|
|
131
|
+
project = ProjectState(name=name, width=width, height=height)
|
|
132
|
+
|
|
133
|
+
# Create one default canvas with a single frame and layer
|
|
134
|
+
default_layer_id = str(uuid.uuid4())
|
|
135
|
+
pixel_data = {default_layer_id: bytes(width * height * 4)}
|
|
136
|
+
frame = FrameState(id=str(uuid.uuid4()), pixel_data=pixel_data)
|
|
137
|
+
canvas = CanvasState(
|
|
138
|
+
name="canvas-0",
|
|
139
|
+
width=width,
|
|
140
|
+
height=height,
|
|
141
|
+
layers=[
|
|
142
|
+
{
|
|
143
|
+
"id": default_layer_id,
|
|
144
|
+
"name": "Layer 0",
|
|
145
|
+
"visible": True,
|
|
146
|
+
"opacity": 1.0,
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
frames=[frame],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
project.canvases["canvas-0"] = canvas
|
|
153
|
+
self.projects[name] = project
|
|
154
|
+
|
|
155
|
+
if self.active_project is None:
|
|
156
|
+
self.active_project = name
|
|
157
|
+
|
|
158
|
+
return project
|
|
159
|
+
|
|
160
|
+
def get_project(self, name: str) -> ProjectState | None:
|
|
161
|
+
"""Get a project by name."""
|
|
162
|
+
return self.projects.get(name)
|
|
163
|
+
|
|
164
|
+
def get_active_project(self) -> ProjectState | None:
|
|
165
|
+
"""Get the currently active project."""
|
|
166
|
+
if self.active_project is None:
|
|
167
|
+
return None
|
|
168
|
+
return self.projects.get(self.active_project)
|
|
169
|
+
|
|
170
|
+
def delete_project(self, name: str) -> bool:
|
|
171
|
+
"""Delete a project. Returns True if it existed."""
|
|
172
|
+
if name in self.projects:
|
|
173
|
+
del self.projects[name]
|
|
174
|
+
if self.active_project == name:
|
|
175
|
+
# Switch to another project or None
|
|
176
|
+
self.active_project = next(iter(self.projects), None)
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def list_projects(self) -> list[str]:
|
|
181
|
+
"""Return names of all projects."""
|
|
182
|
+
return list(self.projects.keys())
|
|
183
|
+
|
|
184
|
+
def get_sync_state(self, project_name: str | None = None) -> dict[str, Any]:
|
|
185
|
+
"""Build a full state snapshot for a sync response.
|
|
186
|
+
|
|
187
|
+
If project_name is None, uses the active project.
|
|
188
|
+
"""
|
|
189
|
+
name = project_name or self.active_project
|
|
190
|
+
if name is None or name not in self.projects:
|
|
191
|
+
return {
|
|
192
|
+
"project": None,
|
|
193
|
+
"canvases": {},
|
|
194
|
+
"layers": {},
|
|
195
|
+
"history": [],
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
project = self.projects[name]
|
|
199
|
+
return {
|
|
200
|
+
"project": {
|
|
201
|
+
"name": project.name,
|
|
202
|
+
"width": project.width,
|
|
203
|
+
"height": project.height,
|
|
204
|
+
},
|
|
205
|
+
"canvases": {k: v.to_dict() for k, v in project.canvases.items()},
|
|
206
|
+
"layers": {
|
|
207
|
+
canvas_name: canvas.layers
|
|
208
|
+
for canvas_name, canvas in project.canvases.items()
|
|
209
|
+
},
|
|
210
|
+
"history": project.command_history,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_full_state_patch(project: ProjectState) -> dict[str, Any]:
|
|
215
|
+
"""Build a full-state patch from the current project state.
|
|
216
|
+
|
|
217
|
+
Serializes all canvas data including per-frame pixel buffers as base64
|
|
218
|
+
strings. Used when MCP modifies state and needs to notify WebSocket
|
|
219
|
+
clients. Keys are camelCase for JS consumers.
|
|
220
|
+
"""
|
|
221
|
+
canvases: dict[str, Any] = {}
|
|
222
|
+
for name, canvas in project.canvases.items():
|
|
223
|
+
# Layer metadata (without pixel data -- that lives in frames now)
|
|
224
|
+
layers_data = []
|
|
225
|
+
for layer in canvas.layers:
|
|
226
|
+
layers_data.append({
|
|
227
|
+
"id": layer["id"],
|
|
228
|
+
"name": layer.get("name", ""),
|
|
229
|
+
"type": layer.get("type", "pixel"),
|
|
230
|
+
"visible": layer.get("visible", True),
|
|
231
|
+
"opacity": layer.get("opacity", 1.0),
|
|
232
|
+
"blendMode": layer.get("blendMode", "normal"),
|
|
233
|
+
"locked": layer.get("locked", False),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
# Per-frame pixel data
|
|
237
|
+
frames_data = []
|
|
238
|
+
for frame in canvas.frames:
|
|
239
|
+
frames_data.append({
|
|
240
|
+
"id": frame.id,
|
|
241
|
+
"durationMs": frame.duration_ms,
|
|
242
|
+
"pixelData": {
|
|
243
|
+
layer_id: base64.b64encode(raw_bytes).decode("ascii")
|
|
244
|
+
for layer_id, raw_bytes in frame.pixel_data.items()
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
canvases[name] = {
|
|
249
|
+
"name": canvas.name,
|
|
250
|
+
"width": canvas.width,
|
|
251
|
+
"height": canvas.height,
|
|
252
|
+
"layers": layers_data,
|
|
253
|
+
"frames": frames_data,
|
|
254
|
+
"currentFrameIndex": canvas.current_frame_index,
|
|
255
|
+
"globalFps": canvas.global_fps,
|
|
256
|
+
"originX": canvas.origin_x,
|
|
257
|
+
"originY": canvas.origin_y,
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
"type": "full_state",
|
|
261
|
+
"snapshot": {
|
|
262
|
+
"name": project.name,
|
|
263
|
+
"width": project.width,
|
|
264
|
+
"height": project.height,
|
|
265
|
+
"canvases": canvases,
|
|
266
|
+
},
|
|
267
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Project file I/O for PixelWeaver.
|
|
2
|
+
|
|
3
|
+
Saves/loads projects as directory structures:
|
|
4
|
+
<base_dir>/<project_name>/
|
|
5
|
+
project.json -- project metadata
|
|
6
|
+
canvases/<canvas_name>/
|
|
7
|
+
canvas.json -- canvas metadata (dimensions, layer tree)
|
|
8
|
+
frames.json -- frame manifest (IDs, durations, playback state)
|
|
9
|
+
frames/<uuid>/
|
|
10
|
+
layer-<id>.png -- raw pixel data as PNG per layer
|
|
11
|
+
history.json -- command history for this canvas
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import shutil
|
|
20
|
+
from io import BytesIO
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from PIL import Image
|
|
25
|
+
|
|
26
|
+
from pixelweaver.state import CanvasState, FrameState, ProjectState
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _assert_within_base(project_dir: Path, base_dir: Path) -> None:
|
|
32
|
+
"""Raise ValueError if project_dir escapes base_dir (path traversal guard)."""
|
|
33
|
+
if not project_dir.resolve().is_relative_to(base_dir.resolve()):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Path traversal detected: {project_dir} is not inside {base_dir}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_project(project: ProjectState, base_dir: str) -> None:
|
|
40
|
+
"""Save a project to disk as a directory structure."""
|
|
41
|
+
project_dir = Path(base_dir) / project.name
|
|
42
|
+
_assert_within_base(project_dir, Path(base_dir))
|
|
43
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
# project.json -- top-level metadata
|
|
46
|
+
project_meta: dict[str, Any] = {
|
|
47
|
+
"name": project.name,
|
|
48
|
+
"width": project.width,
|
|
49
|
+
"height": project.height,
|
|
50
|
+
"canvases": list(project.canvases.keys()),
|
|
51
|
+
}
|
|
52
|
+
(project_dir / "project.json").write_text(json.dumps(project_meta, indent=2))
|
|
53
|
+
|
|
54
|
+
# Each canvas
|
|
55
|
+
for canvas_name, canvas in project.canvases.items():
|
|
56
|
+
_save_canvas(canvas, project_dir / "canvases" / canvas_name)
|
|
57
|
+
|
|
58
|
+
# Global command history (all commands across canvases)
|
|
59
|
+
(project_dir / "history.json").write_text(json.dumps(project.command_history, indent=2))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _save_canvas(canvas: CanvasState, canvas_dir: Path) -> None:
|
|
63
|
+
"""Save a single canvas to its directory."""
|
|
64
|
+
canvas_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
# canvas.json -- layer metadata (unchanged)
|
|
67
|
+
canvas_meta: dict[str, Any] = {
|
|
68
|
+
"name": canvas.name,
|
|
69
|
+
"width": canvas.width,
|
|
70
|
+
"height": canvas.height,
|
|
71
|
+
"layers": canvas.layers,
|
|
72
|
+
}
|
|
73
|
+
(canvas_dir / "canvas.json").write_text(json.dumps(canvas_meta, indent=2))
|
|
74
|
+
|
|
75
|
+
# frames.json -- frame manifest with playback state
|
|
76
|
+
frames_manifest: dict[str, Any] = {
|
|
77
|
+
"frames": [
|
|
78
|
+
{"id": frame.id, "duration_ms": frame.duration_ms}
|
|
79
|
+
for frame in canvas.frames
|
|
80
|
+
],
|
|
81
|
+
"current_frame_index": canvas.current_frame_index,
|
|
82
|
+
"global_fps": canvas.global_fps,
|
|
83
|
+
"origin_x": canvas.origin_x,
|
|
84
|
+
"origin_y": canvas.origin_y,
|
|
85
|
+
}
|
|
86
|
+
(canvas_dir / "frames.json").write_text(json.dumps(frames_manifest, indent=2))
|
|
87
|
+
|
|
88
|
+
# Collect the set of frame IDs that should exist on disk
|
|
89
|
+
live_frame_ids = {frame.id for frame in canvas.frames}
|
|
90
|
+
|
|
91
|
+
# Save each frame's pixel data in frames/<uuid>/
|
|
92
|
+
frames_dir = canvas_dir / "frames"
|
|
93
|
+
frames_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
|
|
95
|
+
for frame in canvas.frames:
|
|
96
|
+
frame_dir = frames_dir / frame.id
|
|
97
|
+
frame_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
for layer in canvas.layers:
|
|
99
|
+
layer_id = layer["id"]
|
|
100
|
+
pixel_bytes = frame.pixel_data.get(layer_id)
|
|
101
|
+
if pixel_bytes is None:
|
|
102
|
+
continue
|
|
103
|
+
png_path = frame_dir / f"layer-{layer_id}.png"
|
|
104
|
+
_save_layer_png(pixel_bytes, canvas.width, canvas.height, png_path)
|
|
105
|
+
|
|
106
|
+
# Cleanup: remove frame directories that are no longer in canvas.frames
|
|
107
|
+
# (includes old "0" directory from the pre-UUID format)
|
|
108
|
+
if frames_dir.is_dir():
|
|
109
|
+
for child in frames_dir.iterdir():
|
|
110
|
+
if child.is_dir() and child.name not in live_frame_ids:
|
|
111
|
+
shutil.rmtree(child)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _save_layer_png(pixel_bytes: bytes, width: int, height: int, path: Path) -> None:
|
|
115
|
+
"""Write raw RGBA bytes as a PNG file."""
|
|
116
|
+
expected_size = width * height * 4
|
|
117
|
+
if len(pixel_bytes) != expected_size:
|
|
118
|
+
logger.warning(
|
|
119
|
+
"Pixel data size mismatch for %s: expected %d, got %d",
|
|
120
|
+
path,
|
|
121
|
+
expected_size,
|
|
122
|
+
len(pixel_bytes),
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
img = Image.frombytes("RGBA", (width, height), pixel_bytes)
|
|
127
|
+
img.save(path, format="PNG")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_project(project_dir: str) -> ProjectState:
|
|
131
|
+
"""Load a project from its directory on disk."""
|
|
132
|
+
pdir = Path(project_dir)
|
|
133
|
+
|
|
134
|
+
project_meta = json.loads((pdir / "project.json").read_text())
|
|
135
|
+
|
|
136
|
+
# Defense-in-depth: the name stored in project.json will be used later by
|
|
137
|
+
# save_project / autosave. Verify it cannot escape the parent data dir.
|
|
138
|
+
loaded_name = project_meta["name"]
|
|
139
|
+
_assert_within_base(pdir.parent / loaded_name, pdir.parent)
|
|
140
|
+
|
|
141
|
+
project = ProjectState(
|
|
142
|
+
name=loaded_name,
|
|
143
|
+
width=project_meta["width"],
|
|
144
|
+
height=project_meta["height"],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Load canvases -- verify each canvas name stays inside the project dir
|
|
148
|
+
canvas_names: list[str] = project_meta.get("canvases", [])
|
|
149
|
+
for canvas_name in canvas_names:
|
|
150
|
+
canvas_dir = pdir / "canvases" / canvas_name
|
|
151
|
+
_assert_within_base(canvas_dir, pdir)
|
|
152
|
+
if canvas_dir.is_dir():
|
|
153
|
+
project.canvases[canvas_name] = _load_canvas(canvas_dir)
|
|
154
|
+
|
|
155
|
+
# Load global command history
|
|
156
|
+
history_path = pdir / "history.json"
|
|
157
|
+
if history_path.exists():
|
|
158
|
+
project.command_history = json.loads(history_path.read_text())
|
|
159
|
+
|
|
160
|
+
return project
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _load_canvas(canvas_dir: Path) -> CanvasState:
|
|
164
|
+
"""Load a single canvas from its directory.
|
|
165
|
+
|
|
166
|
+
Requires frames.json to exist. Old pre-UUID format (frames/0/) is not
|
|
167
|
+
supported -- use the migration script (Step 11) to convert old data.
|
|
168
|
+
"""
|
|
169
|
+
canvas_meta = json.loads((canvas_dir / "canvas.json").read_text())
|
|
170
|
+
layers = canvas_meta.get("layers", [])
|
|
171
|
+
|
|
172
|
+
frames_json_path = canvas_dir / "frames.json"
|
|
173
|
+
if not frames_json_path.exists():
|
|
174
|
+
raise FileNotFoundError(
|
|
175
|
+
f"Missing frames.json in {canvas_dir}. "
|
|
176
|
+
"Old format (frames/0/) is no longer supported. "
|
|
177
|
+
"Run the migration script to convert existing projects."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
manifest = json.loads(frames_json_path.read_text())
|
|
181
|
+
|
|
182
|
+
# Reconstruct each FrameState from manifest + layer PNGs on disk
|
|
183
|
+
frames: list[FrameState] = []
|
|
184
|
+
for frame_entry in manifest["frames"]:
|
|
185
|
+
frame_id = frame_entry["id"]
|
|
186
|
+
duration_ms = frame_entry.get("duration_ms")
|
|
187
|
+
pixel_data: dict[str, bytes] = {}
|
|
188
|
+
|
|
189
|
+
frame_dir = canvas_dir / "frames" / frame_id
|
|
190
|
+
for layer in layers:
|
|
191
|
+
layer_id = layer["id"]
|
|
192
|
+
png_path = frame_dir / f"layer-{layer_id}.png"
|
|
193
|
+
if png_path.exists():
|
|
194
|
+
pixel_data[layer_id] = _load_layer_png(png_path)
|
|
195
|
+
# Missing PNG = transparent layer (empty bytes not stored)
|
|
196
|
+
|
|
197
|
+
frames.append(FrameState(id=frame_id, duration_ms=duration_ms, pixel_data=pixel_data))
|
|
198
|
+
|
|
199
|
+
canvas = CanvasState(
|
|
200
|
+
name=canvas_meta["name"],
|
|
201
|
+
width=canvas_meta["width"],
|
|
202
|
+
height=canvas_meta["height"],
|
|
203
|
+
layers=layers,
|
|
204
|
+
frames=frames,
|
|
205
|
+
current_frame_index=manifest.get("current_frame_index", 0),
|
|
206
|
+
global_fps=manifest.get("global_fps", 12.0),
|
|
207
|
+
origin_x=manifest.get("origin_x", 0),
|
|
208
|
+
origin_y=manifest.get("origin_y", 0),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return canvas
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _load_layer_png(path: Path) -> bytes:
|
|
215
|
+
"""Read a PNG file and return raw RGBA bytes."""
|
|
216
|
+
img = Image.open(path).convert("RGBA")
|
|
217
|
+
return img.tobytes()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def list_projects(base_dir: str) -> list[str]:
|
|
221
|
+
"""List all project directories that contain a project.json."""
|
|
222
|
+
base = Path(base_dir)
|
|
223
|
+
if not base.is_dir():
|
|
224
|
+
return []
|
|
225
|
+
return sorted(
|
|
226
|
+
d.name for d in base.iterdir() if d.is_dir() and (d / "project.json").exists()
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def export_frame_png(project: ProjectState, canvas_name: str, frame: int = 0) -> bytes:
|
|
231
|
+
"""Composite all visible layers of a canvas frame into a single PNG.
|
|
232
|
+
|
|
233
|
+
Returns PNG bytes suitable for an HTTP response.
|
|
234
|
+
|
|
235
|
+
The ``frame`` parameter selects which frame to composite by index.
|
|
236
|
+
``canvas.frame_at()`` performs bounds checking and raises ``IndexError``
|
|
237
|
+
for out-of-range indices.
|
|
238
|
+
"""
|
|
239
|
+
canvas = project.canvases.get(canvas_name)
|
|
240
|
+
if canvas is None:
|
|
241
|
+
raise ValueError(f"Canvas {canvas_name!r} not found")
|
|
242
|
+
|
|
243
|
+
# frame_at() raises IndexError for out-of-range indices
|
|
244
|
+
target_frame = canvas.frame_at(frame)
|
|
245
|
+
|
|
246
|
+
# Start with a transparent base image
|
|
247
|
+
composite = Image.new("RGBA", (canvas.width, canvas.height), (0, 0, 0, 0))
|
|
248
|
+
|
|
249
|
+
# Composite visible layers bottom-to-top
|
|
250
|
+
for layer in canvas.layers:
|
|
251
|
+
if not layer.get("visible", True):
|
|
252
|
+
continue
|
|
253
|
+
layer_id = layer["id"]
|
|
254
|
+
pixel_bytes = target_frame.pixel_data.get(layer_id)
|
|
255
|
+
if pixel_bytes is None:
|
|
256
|
+
continue
|
|
257
|
+
expected_size = canvas.width * canvas.height * 4
|
|
258
|
+
if len(pixel_bytes) != expected_size:
|
|
259
|
+
continue
|
|
260
|
+
layer_img = Image.frombytes("RGBA", (canvas.width, canvas.height), pixel_bytes)
|
|
261
|
+
opacity = layer.get("opacity", 1.0)
|
|
262
|
+
if opacity < 1.0:
|
|
263
|
+
# Reduce alpha channel by opacity factor
|
|
264
|
+
r, g, b, a = layer_img.split()
|
|
265
|
+
a = a.point(lambda x: int(x * opacity))
|
|
266
|
+
layer_img = Image.merge("RGBA", (r, g, b, a))
|
|
267
|
+
composite = Image.alpha_composite(composite, layer_img)
|
|
268
|
+
|
|
269
|
+
buf = BytesIO()
|
|
270
|
+
composite.save(buf, format="PNG")
|
|
271
|
+
return buf.getvalue()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def make_thumbnail_base64(png_bytes: bytes, max_size: int = 128) -> str:
|
|
275
|
+
"""Return a base64-encoded PNG thumbnail of the given PNG bytes.
|
|
276
|
+
|
|
277
|
+
Used by MCP tool handlers to attach a preview of the active canvas to
|
|
278
|
+
mutation results without sending the full composited image. The image
|
|
279
|
+
is only downscaled when one of its dimensions exceeds ``max_size``;
|
|
280
|
+
smaller images pass through untouched. Nearest-neighbor resampling is
|
|
281
|
+
used to preserve pixel-art edges.
|
|
282
|
+
|
|
283
|
+
This helper replaces the previously inlined ``Image.thumbnail`` calls
|
|
284
|
+
in ``mcp_registry.py`` and supersedes the deleted ``thumbnails.py``
|
|
285
|
+
module, which worked from raw RGBA data rather than composited PNG
|
|
286
|
+
bytes.
|
|
287
|
+
"""
|
|
288
|
+
img = Image.open(BytesIO(png_bytes))
|
|
289
|
+
if img.width > max_size or img.height > max_size:
|
|
290
|
+
img.thumbnail((max_size, max_size), Image.NEAREST)
|
|
291
|
+
buf = BytesIO()
|
|
292
|
+
img.save(buf, format="PNG")
|
|
293
|
+
return base64.b64encode(buf.getvalue()).decode("ascii")
|