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.
Files changed (392) hide show
  1. package/.env.development +1 -0
  2. package/.github/workflows/ci.yml +22 -0
  3. package/.github/workflows/publish.yml +18 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc +16 -0
  6. package/.python-version +1 -0
  7. package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
  8. package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
  9. package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
  10. package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
  11. package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
  12. package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
  13. package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
  14. package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
  15. package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
  16. package/.rlsbl/bases/CHANGELOG.md +5 -0
  17. package/.rlsbl/bases/LICENSE +21 -0
  18. package/.rlsbl/changes/.validated +1 -0
  19. package/.rlsbl/changes/0.1.0.jsonl +85 -0
  20. package/.rlsbl/changes/0.1.0.md +13 -0
  21. package/.rlsbl/changes/unreleased.jsonl +0 -0
  22. package/.rlsbl/config.json +6 -0
  23. package/.rlsbl/hashes.json +14 -0
  24. package/.rlsbl/hooks/post-release.sh +8 -0
  25. package/.rlsbl/hooks/pre-checks.sh +5 -0
  26. package/.rlsbl/hooks/pre-release.sh +8 -0
  27. package/.rlsbl/lint/go.toml +17 -0
  28. package/.rlsbl/lint/npm.toml +19 -0
  29. package/.rlsbl/lint/python.toml +25 -0
  30. package/.rlsbl/releases/unreleased.toml +0 -0
  31. package/.rlsbl/releases/v0.1.0.toml +3 -0
  32. package/.rlsbl/version +1 -0
  33. package/.selfdoc/hashes/hashes.json +146 -0
  34. package/.strictcli/schema.json +227 -0
  35. package/CHANGELOG.md +17 -0
  36. package/CLAUDE.md +100 -0
  37. package/LICENSE +21 -0
  38. package/README.md +116 -0
  39. package/assets/icon.png +0 -0
  40. package/docs/_README.md +117 -0
  41. package/docs/cli-config.md +35 -0
  42. package/docs/cli-dev.md +21 -0
  43. package/docs/cli-diagnose.md +12 -0
  44. package/docs/cli-index.md +30 -0
  45. package/docs/cli-list.md +18 -0
  46. package/docs/cli-mcp.md +18 -0
  47. package/docs/cli-new.md +26 -0
  48. package/docs/cli-open.md +21 -0
  49. package/docs/cli-serve.md +21 -0
  50. package/docs/cli-stop.md +12 -0
  51. package/docs/gen-index.md +36 -0
  52. package/docs/index.md +13 -0
  53. package/docs/server-src-pixelweaver-__main__.md +12 -0
  54. package/docs/server-src-pixelweaver-autosave.md +12 -0
  55. package/docs/server-src-pixelweaver-bridge.md +12 -0
  56. package/docs/server-src-pixelweaver-cli.md +12 -0
  57. package/docs/server-src-pixelweaver-config.md +12 -0
  58. package/docs/server-src-pixelweaver-connections.md +12 -0
  59. package/docs/server-src-pixelweaver-main.md +12 -0
  60. package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
  61. package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
  62. package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
  63. package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
  64. package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
  65. package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
  66. package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
  67. package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
  68. package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
  69. package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
  70. package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
  71. package/docs/server-src-pixelweaver-mcp_server.md +12 -0
  72. package/docs/server-src-pixelweaver-protocol.md +12 -0
  73. package/docs/server-src-pixelweaver-state.md +12 -0
  74. package/docs/server-src-pixelweaver-storage.md +12 -0
  75. package/docs/server-src-pixelweaver-websocket.md +12 -0
  76. package/docs/server-src-pixelweaver.md +12 -0
  77. package/e2e/app-launch.test.ts +35 -0
  78. package/e2e/menus.test.ts +26 -0
  79. package/e2e/tools.test.ts +27 -0
  80. package/e2e/undo-redo.test.ts +11 -0
  81. package/eslint.config.js +62 -0
  82. package/index.html +13 -0
  83. package/package.json +48 -0
  84. package/playwright.config.ts +19 -0
  85. package/plugins/builtin/.gitkeep +0 -0
  86. package/plugins/builtin/advanced-fill-tool.ts +146 -0
  87. package/plugins/builtin/circle-tool.ts +186 -0
  88. package/plugins/builtin/diamond-tool.ts +182 -0
  89. package/plugins/builtin/dither-tool.ts +186 -0
  90. package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
  91. package/plugins/builtin/drawing-utils.test.ts +495 -0
  92. package/plugins/builtin/drawing-utils.ts +431 -0
  93. package/plugins/builtin/effects/blur.ts +97 -0
  94. package/plugins/builtin/effects/color-effects.ts +278 -0
  95. package/plugins/builtin/effects/flip.ts +83 -0
  96. package/plugins/builtin/effects/glow.ts +118 -0
  97. package/plugins/builtin/effects/outline.ts +110 -0
  98. package/plugins/builtin/effects/rotate.ts +357 -0
  99. package/plugins/builtin/effects/scale.ts +258 -0
  100. package/plugins/builtin/effects/shadow.ts +111 -0
  101. package/plugins/builtin/effects/sharpen.ts +102 -0
  102. package/plugins/builtin/effects.test.ts +715 -0
  103. package/plugins/builtin/eraser-tool.ts +23 -0
  104. package/plugins/builtin/eyedropper-tool.ts +83 -0
  105. package/plugins/builtin/fill-tool.ts +93 -0
  106. package/plugins/builtin/gradient-tool.ts +204 -0
  107. package/plugins/builtin/gradient.test.ts +142 -0
  108. package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
  109. package/plugins/builtin/importers/aseprite-parser.ts +497 -0
  110. package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
  111. package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
  112. package/plugins/builtin/line-tool.ts +267 -0
  113. package/plugins/builtin/make-stroke-tool.ts +271 -0
  114. package/plugins/builtin/noise-dither.test.ts +151 -0
  115. package/plugins/builtin/noise-tool.ts +131 -0
  116. package/plugins/builtin/pattern-stamp-tool.ts +162 -0
  117. package/plugins/builtin/pencil-tool.ts +25 -0
  118. package/plugins/builtin/rect-tool.ts +179 -0
  119. package/plugins/builtin/selection-tool.ts +388 -0
  120. package/plugins/builtin/selection.test.ts +195 -0
  121. package/plugins/builtin/tool-plugins.test.ts +529 -0
  122. package/public/favicon.svg +7 -0
  123. package/public/sw.js +91 -0
  124. package/pyproject.toml +49 -0
  125. package/scripts/eslint-wave-a-list.sh +24 -0
  126. package/scripts/eslint-wave-a-status.sh +25 -0
  127. package/scripts/fix-index-signature-access.py +127 -0
  128. package/scripts/fix-unchecked-index.py +128 -0
  129. package/scripts/fix-unchecked-vars.py +127 -0
  130. package/scripts/fix-wave-a-bangs.py +36 -0
  131. package/scripts/fix-wave-a-templates.py +108 -0
  132. package/scripts/fix-wave-b-void-expr.py +167 -0
  133. package/scripts/generate-command-params.py +540 -0
  134. package/scripts/migrate-single-frame-to-multi.py +171 -0
  135. package/scripts/smoke-test.sh +77 -0
  136. package/selfdoc.json +10 -0
  137. package/server/README.md +0 -0
  138. package/server/src/pixelweaver/__init__.py +1 -0
  139. package/server/src/pixelweaver/__main__.py +4 -0
  140. package/server/src/pixelweaver/autosave.py +114 -0
  141. package/server/src/pixelweaver/bridge.py +127 -0
  142. package/server/src/pixelweaver/cli.py +199 -0
  143. package/server/src/pixelweaver/config.py +24 -0
  144. package/server/src/pixelweaver/connections.py +54 -0
  145. package/server/src/pixelweaver/main.py +271 -0
  146. package/server/src/pixelweaver/mcp_bridge.py +189 -0
  147. package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
  148. package/server/src/pixelweaver/mcp_export_tools.py +291 -0
  149. package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
  150. package/server/src/pixelweaver/mcp_history_tools.py +106 -0
  151. package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
  152. package/server/src/pixelweaver/mcp_lock.py +37 -0
  153. package/server/src/pixelweaver/mcp_project_tools.py +302 -0
  154. package/server/src/pixelweaver/mcp_read_tools.py +163 -0
  155. package/server/src/pixelweaver/mcp_registry.py +247 -0
  156. package/server/src/pixelweaver/mcp_resources.py +312 -0
  157. package/server/src/pixelweaver/mcp_server.py +234 -0
  158. package/server/src/pixelweaver/protocol.py +219 -0
  159. package/server/src/pixelweaver/state.py +267 -0
  160. package/server/src/pixelweaver/storage.py +293 -0
  161. package/server/src/pixelweaver/websocket.py +282 -0
  162. package/server/tests/__init__.py +0 -0
  163. package/server/tests/conftest.py +17 -0
  164. package/server/tests/test_api.py +96 -0
  165. package/server/tests/test_bridge.py +161 -0
  166. package/server/tests/test_health.py +9 -0
  167. package/server/tests/test_integration.py +86 -0
  168. package/server/tests/test_mcp_bridge.py +293 -0
  169. package/server/tests/test_mcp_lock.py +34 -0
  170. package/server/tests/test_mcp_registry.py +679 -0
  171. package/server/tests/test_mcp_resources.py +648 -0
  172. package/server/tests/test_protocol.py +125 -0
  173. package/server/tests/test_state.py +87 -0
  174. package/server/tests/test_storage.py +306 -0
  175. package/server/tests/test_websocket.py +275 -0
  176. package/src/App.svelte +107 -0
  177. package/src/app.css +215 -0
  178. package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
  179. package/src/lib/animation/animation-commands.test.ts +228 -0
  180. package/src/lib/animation/animation-commands.ts +540 -0
  181. package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
  182. package/src/lib/animation/animation-preview.svelte.ts +151 -0
  183. package/src/lib/animation/clipboard.ts +134 -0
  184. package/src/lib/animation/frame-model.svelte.ts +437 -0
  185. package/src/lib/animation/frame-model.test.ts +314 -0
  186. package/src/lib/animation/frame-selection.svelte.ts +77 -0
  187. package/src/lib/animation/frame-tags.svelte.ts +238 -0
  188. package/src/lib/animation/frame-tags.test.ts +136 -0
  189. package/src/lib/animation/import.test.ts +141 -0
  190. package/src/lib/animation/import.ts +112 -0
  191. package/src/lib/animation/spritesheet-export.test.ts +239 -0
  192. package/src/lib/animation/spritesheet-export.ts +314 -0
  193. package/src/lib/canvas/CanvasViewport.svelte +216 -0
  194. package/src/lib/canvas/canvas-init-plugin.ts +178 -0
  195. package/src/lib/canvas/canvas-renderer.ts +408 -0
  196. package/src/lib/canvas/canvas-state.svelte.ts +232 -0
  197. package/src/lib/canvas/canvas-state.test.ts +139 -0
  198. package/src/lib/canvas/input-handler.ts +221 -0
  199. package/src/lib/canvas/input-plugin.ts +150 -0
  200. package/src/lib/canvas/onion-skin.ts +94 -0
  201. package/src/lib/canvas/pixel-buffer.test.ts +249 -0
  202. package/src/lib/canvas/pixel-buffer.ts +151 -0
  203. package/src/lib/canvas/render-state.svelte.ts +18 -0
  204. package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
  205. package/src/lib/canvas/tile-mode.test.ts +53 -0
  206. package/src/lib/canvas/tile-mode.ts +92 -0
  207. package/src/lib/canvas/viewport-utils.ts +24 -0
  208. package/src/lib/canvas/zoom-utils.ts +31 -0
  209. package/src/lib/color/.gitkeep +0 -0
  210. package/src/lib/color/color-commands.ts +87 -0
  211. package/src/lib/color/color-state.svelte.ts +98 -0
  212. package/src/lib/color/color-state.test.ts +91 -0
  213. package/src/lib/color/color-utils.test.ts +220 -0
  214. package/src/lib/color/color-utils.ts +243 -0
  215. package/src/lib/color/palette-state.svelte.ts +127 -0
  216. package/src/lib/color/palette-state.test.ts +154 -0
  217. package/src/lib/color/palette.ts +79 -0
  218. package/src/lib/core/bootstrap.ts +66 -0
  219. package/src/lib/core/command-params.generated.ts +1549 -0
  220. package/src/lib/core/command-params.ts +20 -0
  221. package/src/lib/core/command-runner.ts +79 -0
  222. package/src/lib/core/commands.ts +134 -0
  223. package/src/lib/core/dispatcher.test.ts +548 -0
  224. package/src/lib/core/dispatcher.ts +361 -0
  225. package/src/lib/core/index.test.ts +7 -0
  226. package/src/lib/core/notification-state.svelte.ts +119 -0
  227. package/src/lib/core/plugin-api.ts +210 -0
  228. package/src/lib/core/plugin-discovery.test.ts +53 -0
  229. package/src/lib/core/plugin-discovery.ts +65 -0
  230. package/src/lib/core/plugin-loader.test.ts +159 -0
  231. package/src/lib/core/plugin-loader.ts +240 -0
  232. package/src/lib/core/plugin-types.ts +286 -0
  233. package/src/lib/core/registries.svelte.ts +74 -0
  234. package/src/lib/core/tool-options-state.svelte.ts +61 -0
  235. package/src/lib/dock/DockLayout.svelte +375 -0
  236. package/src/lib/dock/TabAddMenu.svelte +90 -0
  237. package/src/lib/dock/dock-persistence.ts +46 -0
  238. package/src/lib/dock/dock-plugin.ts +49 -0
  239. package/src/lib/dock/dock-presets.ts +156 -0
  240. package/src/lib/dock/dock-state.svelte.ts +77 -0
  241. package/src/lib/dock/dock-types.ts +2 -0
  242. package/src/lib/dock/dockview-theme.css +226 -0
  243. package/src/lib/dock/dockview-theme.ts +17 -0
  244. package/src/lib/dock/header-action-renderer.ts +77 -0
  245. package/src/lib/edit/clipboard-state.ts +34 -0
  246. package/src/lib/export/download.ts +201 -0
  247. package/src/lib/export/png-metadata.ts +181 -0
  248. package/src/lib/history/ActionLogPanel.svelte +418 -0
  249. package/src/lib/history/action-log-panel-plugin.ts +24 -0
  250. package/src/lib/history/action-log.svelte.ts +172 -0
  251. package/src/lib/history/action-log.test.ts +168 -0
  252. package/src/lib/history/history-commands.ts +403 -0
  253. package/src/lib/history/macros.svelte.ts +320 -0
  254. package/src/lib/history/macros.test.ts +224 -0
  255. package/src/lib/history/replay-engine.test.ts +241 -0
  256. package/src/lib/history/replay-engine.ts +149 -0
  257. package/src/lib/history/spec-format.test.ts +250 -0
  258. package/src/lib/history/spec-format.ts +210 -0
  259. package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
  260. package/src/lib/iso/iso-math.test.ts +77 -0
  261. package/src/lib/iso/iso-math.ts +77 -0
  262. package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
  263. package/src/lib/iso/seam-checker.test.ts +126 -0
  264. package/src/lib/iso/seam-checker.ts +93 -0
  265. package/src/lib/iso/tessellation.ts +67 -0
  266. package/src/lib/layers/compositor.test.ts +193 -0
  267. package/src/lib/layers/compositor.ts +175 -0
  268. package/src/lib/layers/layer-commands.test.ts +263 -0
  269. package/src/lib/layers/layer-commands.ts +429 -0
  270. package/src/lib/layers/layer-tree.svelte.ts +516 -0
  271. package/src/lib/layers/layer-tree.test.ts +383 -0
  272. package/src/lib/layers/layer-types.ts +56 -0
  273. package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
  274. package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
  275. package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
  276. package/src/lib/leveleditor/depth-sort.test.ts +70 -0
  277. package/src/lib/leveleditor/depth-sort.ts +39 -0
  278. package/src/lib/leveleditor/level-editor-commands.ts +353 -0
  279. package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
  280. package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
  281. package/src/lib/leveleditor/map-state.svelte.ts +372 -0
  282. package/src/lib/leveleditor/map-state.test.ts +243 -0
  283. package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
  284. package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
  285. package/src/lib/leveleditor/tiled-export.test.ts +144 -0
  286. package/src/lib/leveleditor/tiled-export.ts +374 -0
  287. package/src/lib/pywebview.d.ts +15 -0
  288. package/src/lib/recovery/.gitkeep +0 -0
  289. package/src/lib/recovery/idb-store.ts +118 -0
  290. package/src/lib/recovery/recovery-manager.test.ts +321 -0
  291. package/src/lib/recovery/recovery-manager.ts +115 -0
  292. package/src/lib/recovery/recovery-plugin.ts +55 -0
  293. package/src/lib/recovery/recovery-state.svelte.ts +21 -0
  294. package/src/lib/recovery/sw-plugin.ts +18 -0
  295. package/src/lib/recovery/sw-register.ts +17 -0
  296. package/src/lib/save/directory-format.ts +42 -0
  297. package/src/lib/save/project-snapshot.ts +139 -0
  298. package/src/lib/save/recent-projects.ts +56 -0
  299. package/src/lib/save/save-state.svelte.ts +35 -0
  300. package/src/lib/save/storage.ts +167 -0
  301. package/src/lib/save/zip-format.ts +45 -0
  302. package/src/lib/shortcuts/.gitkeep +0 -0
  303. package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
  304. package/src/lib/shortcuts/default-bindings.ts +61 -0
  305. package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
  306. package/src/lib/shortcuts/shortcut-init.ts +380 -0
  307. package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
  308. package/src/lib/shortcuts/shortcut-manager.ts +281 -0
  309. package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
  310. package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
  311. package/src/lib/sync/patch-applicator.ts +300 -0
  312. package/src/lib/sync/patch-types.ts +65 -0
  313. package/src/lib/sync/project-manager.ts +108 -0
  314. package/src/lib/sync/sync-init.ts +152 -0
  315. package/src/lib/sync/sync-plugin.ts +19 -0
  316. package/src/lib/sync/sync-state.svelte.ts +56 -0
  317. package/src/lib/sync/ws-client.test.ts +604 -0
  318. package/src/lib/sync/ws-client.ts +574 -0
  319. package/src/lib/tools/.gitkeep +0 -0
  320. package/src/lib/ui/.gitkeep +0 -0
  321. package/src/lib/ui/AboutDialog.svelte +113 -0
  322. package/src/lib/ui/ColorPicker.svelte +761 -0
  323. package/src/lib/ui/ContextMenu.svelte +216 -0
  324. package/src/lib/ui/ExportDialog.svelte +747 -0
  325. package/src/lib/ui/FrameStrip.svelte +854 -0
  326. package/src/lib/ui/LayerPanel.svelte +810 -0
  327. package/src/lib/ui/MenuBar.svelte +590 -0
  328. package/src/lib/ui/NewProjectDialog.svelte +803 -0
  329. package/src/lib/ui/PluginManagerPanel.svelte +475 -0
  330. package/src/lib/ui/PromptDialog.svelte +252 -0
  331. package/src/lib/ui/RecoveryDialog.svelte +295 -0
  332. package/src/lib/ui/ResizeDialog.svelte +416 -0
  333. package/src/lib/ui/StatusBar.svelte +145 -0
  334. package/src/lib/ui/ToolbarPanel.svelte +488 -0
  335. package/src/lib/ui/animation-menu-commands.ts +194 -0
  336. package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
  337. package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
  338. package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
  339. package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
  340. package/src/lib/ui/dialog-state.svelte.ts +70 -0
  341. package/src/lib/ui/edit-commands.ts +271 -0
  342. package/src/lib/ui/file-commands.ts +275 -0
  343. package/src/lib/ui/file-open.ts +99 -0
  344. package/src/lib/ui/help-commands.ts +93 -0
  345. package/src/lib/ui/image-commands.ts +181 -0
  346. package/src/lib/ui/layer-menu-commands.ts +420 -0
  347. package/src/lib/ui/menu-builder.ts +224 -0
  348. package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
  349. package/src/lib/ui/notifications/notification-plugin.ts +29 -0
  350. package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
  351. package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
  352. package/src/lib/ui/plugin-state.svelte.ts +62 -0
  353. package/src/lib/ui/select-commands.ts +75 -0
  354. package/src/lib/ui/theme-plugin.ts +18 -0
  355. package/src/lib/ui/theme.svelte.ts +45 -0
  356. package/src/lib/ui/theme.test.ts +51 -0
  357. package/src/lib/ui/toolbar-config.ts +90 -0
  358. package/src/lib/ui/toolbar-plugin.ts +39 -0
  359. package/src/lib/ui/view-commands.ts +629 -0
  360. package/src/lib/variants/BisectionExportDialog.svelte +500 -0
  361. package/src/lib/variants/VariantPanel.svelte +822 -0
  362. package/src/lib/variants/bisection-export.test.ts +113 -0
  363. package/src/lib/variants/bisection-export.ts +148 -0
  364. package/src/lib/variants/palette-extraction.test.ts +111 -0
  365. package/src/lib/variants/palette-extraction.ts +84 -0
  366. package/src/lib/variants/palette-interpolation.test.ts +113 -0
  367. package/src/lib/variants/palette-interpolation.ts +87 -0
  368. package/src/lib/variants/palette-swap.test.ts +101 -0
  369. package/src/lib/variants/palette-swap.ts +114 -0
  370. package/src/lib/variants/variant-commands.ts +594 -0
  371. package/src/lib/variants/variant-panel-plugin.ts +27 -0
  372. package/src/lib/variants/variant-randomizer.ts +101 -0
  373. package/src/lib/variants/variant-state.svelte.ts +166 -0
  374. package/src/lib/variants/variant-state.test.ts +138 -0
  375. package/src/main.ts +14 -0
  376. package/src/vite-env.d.ts +3 -0
  377. package/svelte.config.js +2 -0
  378. package/todo/.done/audit-design-decisions.md +812 -0
  379. package/todo/.done/audit-implementation-plan.md +1235 -0
  380. package/todo/.done/happy-path-polish.md +177 -0
  381. package/todo/.done/pixelweaver-full-build.md +937 -0
  382. package/todo/.done/server-multi-frame-design.md +405 -0
  383. package/todo/.done/typed-dispatcher-design.md +435 -0
  384. package/todo/.done/unified-toolbar-and-action-system.md +323 -0
  385. package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
  386. package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
  387. package/todo/comprehensive-audit.md +1085 -0
  388. package/tsconfig.app.json +26 -0
  389. package/tsconfig.json +7 -0
  390. package/tsconfig.node.json +20 -0
  391. package/uv.lock +1167 -0
  392. package/vite.config.ts +32 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Rotate Effect -- rotates the active layer by 90/180/270 degrees or free angle.
3
+ *
4
+ * Registers:
5
+ * - Command: `rotate` (tier: 'frame')
6
+ *
7
+ * For cardinal rotations (90/180/270), pixels are swapped directly.
8
+ * For free angle rotation, uses nearest-neighbor sampling.
9
+ */
10
+
11
+ import type { PluginModule } from '../../../src/lib/core/plugin-loader.js';
12
+ import { PixelBuffer } from '../../../src/lib/canvas/pixel-buffer.js';
13
+ import RotateCw from '~icons/lucide/rotate-cw';
14
+
15
+ /**
16
+ * Snapshot captured by rotate execute() for undo: the original buffer
17
+ * dimensions, a copy of its pixel data, and the (frameIndex, layerId)
18
+ * pair the rotation was applied to. On undo we reconstruct a PixelBuffer
19
+ * from this snapshot and write it back to the ORIGINAL pair via
20
+ * setBufferForLayer so that switching the active layer/frame between
21
+ * execute and undo still restores the correct layer.
22
+ *
23
+ * frameIndex / layerId may be null/empty if the context doesn't implement
24
+ * getActiveFrameIndex/getActiveLayerId -- in that case undo falls back to
25
+ * setActiveBuffer (legacy contexts).
26
+ */
27
+ interface BufferSnapshot {
28
+ width: number;
29
+ height: number;
30
+ data: Uint8ClampedArray;
31
+ frameIndex: number | null;
32
+ layerId: string | null;
33
+ }
34
+
35
+ /**
36
+ * Snapshot for rotate_canvas: captures the pre-rotation dimensions and a
37
+ * list of every (frameIndex, layerId) buffer the command touched. Undo
38
+ * iterates the list and writes each captured buffer back through
39
+ * setBufferForLayer, then restores canvas size to oldWidth/oldHeight.
40
+ */
41
+ interface WholeCanvasRotateSnapshot {
42
+ oldWidth: number;
43
+ oldHeight: number;
44
+ entries: Array<{
45
+ frameIndex: number;
46
+ layerId: string;
47
+ width: number;
48
+ height: number;
49
+ data: Uint8ClampedArray;
50
+ }>;
51
+ }
52
+
53
+ /**
54
+ * Compute the rotated pixel data for a buffer.
55
+ *
56
+ * Returns a NEW PixelBuffer sized to match the rotation result:
57
+ * - 0 / 180 degrees: same dimensions (w x h)
58
+ * - 90 / 270 degrees: swapped dimensions (h x w)
59
+ * - non-cardinal angles: same dimensions (w x h), nearest-neighbor sampled
60
+ *
61
+ * The returned buffer is independent of the input. Callers are responsible for
62
+ * writing it back into the canvas (and, for dim-swapping cases, updating
63
+ * canvas/layer state accordingly).
64
+ */
65
+ export function computeRotation(
66
+ buffer: PixelBuffer,
67
+ angle: number,
68
+ ): PixelBuffer {
69
+ const w = buffer.width;
70
+ const h = buffer.height;
71
+
72
+ // Normalize angle to [0, 360)
73
+ const normalizedAngle = ((angle % 360) + 360) % 360;
74
+
75
+ if (normalizedAngle === 90) {
76
+ // 90 CW: output buffer is h wide x w tall.
77
+ // Source (sx, sy) -> destination (h-1-sy, sx).
78
+ // Inverse (used here): dest (dx, dy) <- source (dy, h-1-dx).
79
+ const out = new PixelBuffer(h, w);
80
+ for (let dy = 0; dy < w; dy++) {
81
+ for (let dx = 0; dx < h; dx++) {
82
+ const sx = dy;
83
+ const sy = h - 1 - dx;
84
+ const [r, g, b, a] = buffer.getPixel(sx, sy);
85
+ out.setPixel(dx, dy, r, g, b, a);
86
+ }
87
+ }
88
+ return out;
89
+ } else if (normalizedAngle === 180) {
90
+ // 180: dimensions unchanged. dest(dx, dy) <- source(w-1-dx, h-1-dy).
91
+ const out = new PixelBuffer(w, h);
92
+ for (let dy = 0; dy < h; dy++) {
93
+ for (let dx = 0; dx < w; dx++) {
94
+ const sx = w - 1 - dx;
95
+ const sy = h - 1 - dy;
96
+ const [r, g, b, a] = buffer.getPixel(sx, sy);
97
+ out.setPixel(dx, dy, r, g, b, a);
98
+ }
99
+ }
100
+ return out;
101
+ } else if (normalizedAngle === 270) {
102
+ // 270 CW (= 90 CCW): output buffer is h wide x w tall.
103
+ // Source (sx, sy) -> destination (sy, w-1-sx).
104
+ // Inverse (used here): dest (dx, dy) <- source (w-1-dy, dx).
105
+ const out = new PixelBuffer(h, w);
106
+ for (let dy = 0; dy < w; dy++) {
107
+ for (let dx = 0; dx < h; dx++) {
108
+ const sx = w - 1 - dy;
109
+ const sy = dx;
110
+ const [r, g, b, a] = buffer.getPixel(sx, sy);
111
+ out.setPixel(dx, dy, r, g, b, a);
112
+ }
113
+ }
114
+ return out;
115
+ } else if (normalizedAngle === 0) {
116
+ // No rotation: return a clone.
117
+ return buffer.clone();
118
+ } else {
119
+ // Free angle: nearest-neighbor sampling, same dimensions.
120
+ const out = new PixelBuffer(w, h);
121
+ const rad = (normalizedAngle * Math.PI) / 180;
122
+ const cos = Math.cos(rad);
123
+ const sin = Math.sin(rad);
124
+ const cx = (w - 1) / 2;
125
+ const cy = (h - 1) / 2;
126
+
127
+ for (let dy = 0; dy < h; dy++) {
128
+ for (let dx = 0; dx < w; dx++) {
129
+ // Rotate destination pixel back to find source
130
+ const rx = dx - cx;
131
+ const ry = dy - cy;
132
+ const sx = Math.round(rx * cos + ry * sin + cx);
133
+ const sy = Math.round(-rx * sin + ry * cos + cy);
134
+
135
+ const [r, g, b, a] = buffer.inBounds(sx, sy) ? buffer.getPixel(sx, sy) : [0, 0, 0, 0];
136
+ out.setPixel(dx, dy, r, g, b, a);
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ }
142
+
143
+ export const rotateEffectPlugin: PluginModule = {
144
+ name: 'builtin/effects/rotate',
145
+ version: '1.0.0',
146
+ dependencies: [],
147
+ register(api) {
148
+ api.addCommand('rotate', {
149
+ tier: 'frame',
150
+ label: 'Rotate',
151
+ category: 'Effects',
152
+ icon: RotateCw,
153
+
154
+ execute(params, ctx) {
155
+ const buffer = ctx.getActiveBuffer?.();
156
+ if (!buffer) return;
157
+
158
+ // Snapshot old dims + raw pixel data + the active (frameIndex, layerId)
159
+ // so undo can rebuild the original buffer and write it back to the
160
+ // ORIGINAL layer/frame -- not whatever happens to be active at undo
161
+ // time. Captured defensively: contexts that don't implement
162
+ // getActiveFrameIndex/getActiveLayerId fall through to the legacy
163
+ // setActiveBuffer path at undo time.
164
+ const snapshot: BufferSnapshot = {
165
+ width: buffer.width,
166
+ height: buffer.height,
167
+ data: new Uint8ClampedArray(buffer.data),
168
+ frameIndex: ctx.getActiveFrameIndex?.() ?? null,
169
+ layerId: ctx.getActiveLayerId?.() ?? null,
170
+ };
171
+
172
+ // computeRotation returns a NEW PixelBuffer sized to the result:
173
+ // - 0/180/free: same dimensions
174
+ // - 90/270: swapped dimensions (h x w)
175
+ // Push it through setActiveBuffer so the canvas and active layer
176
+ // adopt the new dimensions. If the context does not implement
177
+ // setActiveBuffer (legacy contexts), fall back to blitting the
178
+ // rotated pixels into the existing buffer (clipped to its bounds).
179
+ const rotated = computeRotation(buffer, params["angle"]);
180
+ if (ctx.setActiveBuffer) {
181
+ ctx.setActiveBuffer(rotated);
182
+ } else {
183
+ // Fallback: clear then overlay within the fixed buffer. This will
184
+ // visually clip for 90/270 on non-square canvases but keeps the
185
+ // command functional in contexts without buffer replacement.
186
+ for (let y = 0; y < buffer.height; y++) {
187
+ for (let x = 0; x < buffer.width; x++) {
188
+ buffer.setPixel(x, y, 0, 0, 0, 0);
189
+ }
190
+ }
191
+ for (let y = 0; y < rotated.height; y++) {
192
+ for (let x = 0; x < rotated.width; x++) {
193
+ const [r, g, b, a] = rotated.getPixel(x, y);
194
+ buffer.setPixel(x, y, r, g, b, a);
195
+ }
196
+ }
197
+ }
198
+ return snapshot;
199
+ },
200
+
201
+ undo(_params, ctx, snapshot) {
202
+ const snap = snapshot as BufferSnapshot | undefined;
203
+ if (!snap) return;
204
+ // Rebuild the original buffer from the snapshot and push it back.
205
+ // new Uint8ClampedArray(snap.data) decouples the restored buffer
206
+ // from the snapshot so redo can re-snapshot cleanly.
207
+ const restored = new PixelBuffer(
208
+ snap.width,
209
+ snap.height,
210
+ new Uint8ClampedArray(snap.data),
211
+ );
212
+ // Prefer writing back to the ORIGINAL (frame, layer) pair captured
213
+ // at execute time. Falls back to setActiveBuffer (current pair) if
214
+ // the context doesn't support per-layer writes or the snapshot
215
+ // predates this change.
216
+ if (
217
+ ctx.setBufferForLayer &&
218
+ snap.frameIndex !== null &&
219
+ snap.layerId !== null
220
+ ) {
221
+ ctx.setBufferForLayer(snap.frameIndex, snap.layerId, restored);
222
+ } else if (ctx.setActiveBuffer) {
223
+ ctx.setActiveBuffer(restored);
224
+ } else {
225
+ // Fallback: write snapshot pixels back into the current buffer.
226
+ const buffer = ctx.getActiveBuffer?.();
227
+ if (!buffer) return;
228
+ for (let y = 0; y < snap.height; y++) {
229
+ for (let x = 0; x < snap.width; x++) {
230
+ const i = (y * snap.width + x) * 4;
231
+ buffer.setPixel(
232
+ x,
233
+ y,
234
+ snap.data[i] ?? 0,
235
+ snap.data[i + 1] ?? 0,
236
+ snap.data[i + 2] ?? 0,
237
+ snap.data[i + 3] ?? 0,
238
+ );
239
+ }
240
+ }
241
+ }
242
+ },
243
+
244
+ describe(params) {
245
+ return `Rotated layer by ${String(params["angle"])} degrees`;
246
+ },
247
+ });
248
+
249
+ api.addMenuItem('menu:effects:rotate', {
250
+ commandId: 'rotate',
251
+ menuPath: 'effects',
252
+ group: 'transform',
253
+ order: 20,
254
+ label: 'Rotate',
255
+ });
256
+
257
+ // --- Whole-canvas rotation ---
258
+ //
259
+ // rotate_canvas rotates EVERY pixel layer across EVERY frame by the
260
+ // same angle so the entire project stays consistent. Canvas dimensions
261
+ // are updated exactly once at the end, not per-layer write (each
262
+ // setBufferForLayer call does update canvas size, but the final
263
+ // setCanvasSize wins after all writes).
264
+ api.addCommand('rotate_canvas', {
265
+ tier: 'project',
266
+ undoable: true,
267
+ label: 'Rotate Canvas',
268
+ category: 'Image',
269
+ icon: RotateCw,
270
+
271
+ execute(params, ctx): WholeCanvasRotateSnapshot | undefined {
272
+ const angle = params["angle"];
273
+ // Require the richer context APIs -- whole-canvas ops have no
274
+ // sensible fallback path in legacy contexts.
275
+ if (!ctx.getAllFrameLayerBuffers || !ctx.setBufferForLayer || !ctx.setCanvasSize) {
276
+ console.warn('rotate_canvas: context missing whole-canvas APIs');
277
+ return;
278
+ }
279
+ const pairs = ctx.getAllFrameLayerBuffers();
280
+ if (pairs.length === 0) return;
281
+
282
+ // Capture old canvas dims from the first buffer. All buffers
283
+ // should share the same dimensions (they always do in the current
284
+ // model -- canvas-wide resize is enforced via canvasState).
285
+ const firstPair = pairs[0];
286
+ if (!firstPair) return;
287
+ const first = firstPair.buffer;
288
+ const snapshot: WholeCanvasRotateSnapshot = {
289
+ oldWidth: first.width,
290
+ oldHeight: first.height,
291
+ entries: pairs.map((p) => ({
292
+ frameIndex: p.frameIndex,
293
+ layerId: p.layerId,
294
+ width: p.buffer.width,
295
+ height: p.buffer.height,
296
+ // Copy the data -- the original buffer object gets replaced
297
+ // below so we can't just hold a reference.
298
+ data: new Uint8ClampedArray(p.buffer.data),
299
+ })),
300
+ };
301
+
302
+ // Rotate each buffer and write it back through setBufferForLayer.
303
+ // computeRotation returns a new buffer; for 90/270 on non-square
304
+ // source the dims swap -- that's expected and applies uniformly
305
+ // to every layer, so the final canvas dims stay consistent.
306
+ let finalW = first.width;
307
+ let finalH = first.height;
308
+ for (const { frameIndex, layerId, buffer } of pairs) {
309
+ const rotated = computeRotation(buffer, angle);
310
+ ctx.setBufferForLayer(frameIndex, layerId, rotated);
311
+ finalW = rotated.width;
312
+ finalH = rotated.height;
313
+ }
314
+
315
+ // Explicit canvas resize once at the end -- setBufferForLayer
316
+ // already updates canvasState per write, but an explicit call
317
+ // here makes the "one authoritative final size" semantics clear.
318
+ ctx.setCanvasSize(finalW, finalH);
319
+
320
+ return snapshot;
321
+ },
322
+
323
+ undo(_params, ctx, snapshot) {
324
+ const snap = snapshot as WholeCanvasRotateSnapshot | undefined;
325
+ if (!snap) return;
326
+ if (!ctx.setBufferForLayer || !ctx.setCanvasSize) return;
327
+
328
+ // Restore every captured buffer, then restore canvas dims.
329
+ // Order matters: the last setBufferForLayer call resizes canvas
330
+ // to the restored buffer's dims (which equals snap.oldWidth/Height
331
+ // because all entries share the same pre-rotation dims), and the
332
+ // final setCanvasSize is a no-op-or-correction guard.
333
+ for (const entry of snap.entries) {
334
+ const restored = new PixelBuffer(
335
+ entry.width,
336
+ entry.height,
337
+ new Uint8ClampedArray(entry.data),
338
+ );
339
+ ctx.setBufferForLayer(entry.frameIndex, entry.layerId, restored);
340
+ }
341
+ ctx.setCanvasSize(snap.oldWidth, snap.oldHeight);
342
+ },
343
+
344
+ describe(params) {
345
+ return `Rotated canvas by ${String(params["angle"])} degrees`;
346
+ },
347
+ });
348
+
349
+ api.addMenuItem('menu:image:rotate-canvas', {
350
+ commandId: 'rotate_canvas',
351
+ menuPath: 'image',
352
+ group: 'transform',
353
+ order: 40,
354
+ label: 'Rotate Canvas',
355
+ });
356
+ },
357
+ };
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Scale Effect -- resizes the active layer with nearest-neighbor interpolation.
3
+ *
4
+ * Registers:
5
+ * - Command: `scale` (tier: 'frame')
6
+ *
7
+ * computeScale() returns a NEW PixelBuffer sized to (newWidth, newHeight).
8
+ * execute() pushes it through ctx.setActiveBuffer so the canvas adopts the
9
+ * new dimensions. Undo rebuilds the original buffer from a dim+data snapshot.
10
+ */
11
+
12
+ import type { PluginModule } from '../../../src/lib/core/plugin-loader.js';
13
+ import { PixelBuffer } from '../../../src/lib/canvas/pixel-buffer.js';
14
+ import Scaling from '~icons/lucide/scaling';
15
+
16
+ /**
17
+ * Snapshot captured for undo: original dimensions, a copy of the pixel data,
18
+ * and the (frameIndex, layerId) pair the scale was applied to. Mirrors the
19
+ * BufferSnapshot type in rotate.ts -- kept private per module to avoid a
20
+ * cross-effect dependency. See rotate.ts for the full rationale behind
21
+ * capturing frameIndex/layerId for undo.
22
+ */
23
+ interface BufferSnapshot {
24
+ width: number;
25
+ height: number;
26
+ data: Uint8ClampedArray;
27
+ frameIndex: number | null;
28
+ layerId: string | null;
29
+ }
30
+
31
+ /**
32
+ * Snapshot for scale_canvas: captures the pre-scale dimensions and a list
33
+ * of every (frameIndex, layerId) buffer the command touched. Undo iterates
34
+ * the list and writes each captured buffer back through setBufferForLayer,
35
+ * then restores canvas size. Mirrors WholeCanvasRotateSnapshot in rotate.ts.
36
+ */
37
+ interface WholeCanvasScaleSnapshot {
38
+ oldWidth: number;
39
+ oldHeight: number;
40
+ entries: Array<{
41
+ frameIndex: number;
42
+ layerId: string;
43
+ width: number;
44
+ height: number;
45
+ data: Uint8ClampedArray;
46
+ }>;
47
+ }
48
+
49
+ /**
50
+ * Compute scaled pixels using nearest-neighbor sampling.
51
+ *
52
+ * Returns a NEW PixelBuffer sized (newWidth x newHeight). Each destination
53
+ * pixel is mapped back to the nearest source pixel. The returned buffer is
54
+ * independent of the input; callers are responsible for swapping it into
55
+ * the active state (setActiveBuffer) and updating canvas dimensions.
56
+ */
57
+ export function computeScale(
58
+ buffer: PixelBuffer,
59
+ newWidth: number,
60
+ newHeight: number,
61
+ ): PixelBuffer {
62
+ const srcW = buffer.width;
63
+ const srcH = buffer.height;
64
+ const out = new PixelBuffer(newWidth, newHeight);
65
+
66
+ for (let dy = 0; dy < newHeight; dy++) {
67
+ for (let dx = 0; dx < newWidth; dx++) {
68
+ // Nearest-neighbor: map destination to source.
69
+ const sx = Math.floor((dx * srcW) / newWidth);
70
+ const sy = Math.floor((dy * srcH) / newHeight);
71
+ const [r, g, b, a] = buffer.getPixel(sx, sy);
72
+ out.setPixel(dx, dy, r, g, b, a);
73
+ }
74
+ }
75
+
76
+ return out;
77
+ }
78
+
79
+ export const scaleEffectPlugin: PluginModule = {
80
+ name: 'builtin/effects/scale',
81
+ version: '1.0.0',
82
+ dependencies: [],
83
+ register(api) {
84
+ api.addCommand('scale', {
85
+ tier: 'frame',
86
+ label: 'Scale',
87
+ category: 'Effects',
88
+ icon: Scaling,
89
+
90
+ execute(params, ctx) {
91
+ const buffer = ctx.getActiveBuffer?.();
92
+ if (!buffer) return;
93
+
94
+ // Snapshot old dims + raw pixel data + active (frameIndex, layerId).
95
+ // See rotate.ts for why the pair is captured: undo must write back
96
+ // to the ORIGINAL layer/frame regardless of what's active at undo
97
+ // time.
98
+ const snapshot: BufferSnapshot = {
99
+ width: buffer.width,
100
+ height: buffer.height,
101
+ data: new Uint8ClampedArray(buffer.data),
102
+ frameIndex: ctx.getActiveFrameIndex?.() ?? null,
103
+ layerId: ctx.getActiveLayerId?.() ?? null,
104
+ };
105
+
106
+ const scaled = computeScale(
107
+ buffer,
108
+ params["newWidth"],
109
+ params["newHeight"],
110
+ );
111
+
112
+ if (ctx.setActiveBuffer) {
113
+ ctx.setActiveBuffer(scaled);
114
+ } else {
115
+ // Fallback for contexts without buffer replacement: blit the
116
+ // scaled pixels into the existing buffer, clipped to its bounds.
117
+ // Out-of-range pixels (beyond the old buffer dims) are lost and
118
+ // unused cells in the old buffer are cleared to transparent.
119
+ for (let y = 0; y < buffer.height; y++) {
120
+ for (let x = 0; x < buffer.width; x++) {
121
+ if (x < scaled.width && y < scaled.height) {
122
+ const [r, g, b, a] = scaled.getPixel(x, y);
123
+ buffer.setPixel(x, y, r, g, b, a);
124
+ } else {
125
+ buffer.setPixel(x, y, 0, 0, 0, 0);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return snapshot;
131
+ },
132
+
133
+ undo(_params, ctx, snapshot) {
134
+ const snap = snapshot as BufferSnapshot | undefined;
135
+ if (!snap) return;
136
+ const restored = new PixelBuffer(
137
+ snap.width,
138
+ snap.height,
139
+ new Uint8ClampedArray(snap.data),
140
+ );
141
+ // Prefer writing back to the ORIGINAL (frame, layer) pair captured
142
+ // at execute time; fall back to setActiveBuffer for legacy contexts.
143
+ if (
144
+ ctx.setBufferForLayer &&
145
+ snap.frameIndex !== null &&
146
+ snap.layerId !== null
147
+ ) {
148
+ ctx.setBufferForLayer(snap.frameIndex, snap.layerId, restored);
149
+ } else if (ctx.setActiveBuffer) {
150
+ ctx.setActiveBuffer(restored);
151
+ } else {
152
+ const buffer = ctx.getActiveBuffer?.();
153
+ if (!buffer) return;
154
+ for (let y = 0; y < snap.height; y++) {
155
+ for (let x = 0; x < snap.width; x++) {
156
+ const i = (y * snap.width + x) * 4;
157
+ buffer.setPixel(
158
+ x,
159
+ y,
160
+ snap.data[i] ?? 0,
161
+ snap.data[i + 1] ?? 0,
162
+ snap.data[i + 2] ?? 0,
163
+ snap.data[i + 3] ?? 0,
164
+ );
165
+ }
166
+ }
167
+ }
168
+ },
169
+
170
+ describe(params) {
171
+ return `Scaled layer to ${String(params["newWidth"])}x${String(params["newHeight"])}`;
172
+ },
173
+ });
174
+
175
+ api.addMenuItem('menu:effects:scale', {
176
+ commandId: 'scale',
177
+ menuPath: 'effects',
178
+ group: 'transform',
179
+ order: 30,
180
+ label: 'Scale',
181
+ });
182
+
183
+ // --- Whole-canvas scale ---
184
+ //
185
+ // scale_canvas resizes EVERY pixel layer across EVERY frame to the
186
+ // same new dimensions. Canvas size is updated exactly once at the
187
+ // end. See rotate.ts's rotate_canvas for the shared design rationale.
188
+ api.addCommand('scale_canvas', {
189
+ tier: 'project',
190
+ undoable: true,
191
+ label: 'Scale Canvas',
192
+ category: 'Image',
193
+ icon: Scaling,
194
+
195
+ execute(params, ctx): WholeCanvasScaleSnapshot | undefined {
196
+ const newWidth = params["newWidth"];
197
+ const newHeight = params["newHeight"];
198
+ if (!ctx.getAllFrameLayerBuffers || !ctx.setBufferForLayer || !ctx.setCanvasSize) {
199
+ console.warn('scale_canvas: context missing whole-canvas APIs');
200
+ return;
201
+ }
202
+ const pairs = ctx.getAllFrameLayerBuffers();
203
+ if (pairs.length === 0) return;
204
+
205
+ const firstPair = pairs[0];
206
+ if (!firstPair) return;
207
+ const first = firstPair.buffer;
208
+ const snapshot: WholeCanvasScaleSnapshot = {
209
+ oldWidth: first.width,
210
+ oldHeight: first.height,
211
+ entries: pairs.map((p) => ({
212
+ frameIndex: p.frameIndex,
213
+ layerId: p.layerId,
214
+ width: p.buffer.width,
215
+ height: p.buffer.height,
216
+ data: new Uint8ClampedArray(p.buffer.data),
217
+ })),
218
+ };
219
+
220
+ for (const { frameIndex, layerId, buffer } of pairs) {
221
+ const scaled = computeScale(buffer, newWidth, newHeight);
222
+ ctx.setBufferForLayer(frameIndex, layerId, scaled);
223
+ }
224
+
225
+ ctx.setCanvasSize(newWidth, newHeight);
226
+ return snapshot;
227
+ },
228
+
229
+ undo(_params, ctx, snapshot) {
230
+ const snap = snapshot as WholeCanvasScaleSnapshot | undefined;
231
+ if (!snap) return;
232
+ if (!ctx.setBufferForLayer || !ctx.setCanvasSize) return;
233
+
234
+ for (const entry of snap.entries) {
235
+ const restored = new PixelBuffer(
236
+ entry.width,
237
+ entry.height,
238
+ new Uint8ClampedArray(entry.data),
239
+ );
240
+ ctx.setBufferForLayer(entry.frameIndex, entry.layerId, restored);
241
+ }
242
+ ctx.setCanvasSize(snap.oldWidth, snap.oldHeight);
243
+ },
244
+
245
+ describe(params) {
246
+ return `Scaled canvas to ${String(params["newWidth"])}x${String(params["newHeight"])}`;
247
+ },
248
+ });
249
+
250
+ api.addMenuItem('menu:image:scale-canvas', {
251
+ commandId: 'scale_canvas',
252
+ menuPath: 'image',
253
+ group: 'transform',
254
+ order: 50,
255
+ label: 'Scale Canvas',
256
+ });
257
+ },
258
+ };