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,174 @@
1
+ /**
2
+ * Aseprite Importer Plugin -- registers an importer for .ase/.aseprite files.
3
+ *
4
+ * Converts parsed Aseprite data into PixelWeaver's internal state:
5
+ * canvas dimensions, layers, frames with pixel data, and palette.
6
+ */
7
+
8
+ import type { PluginModule } from '../../../src/lib/core/plugin-loader.js';
9
+ import type { PluginAPI } from '../../../src/lib/core/plugin-types.js';
10
+ import FileImage from '~icons/lucide/file-image';
11
+ import { parseAseprite } from './aseprite-parser.js';
12
+ import type { Palette } from '../../../src/lib/color/palette.js';
13
+ import type { AsepriteLayer } from './aseprite-parser.js';
14
+ import { rgbToHex } from '../../../src/lib/color/color-utils.js';
15
+
16
+ /**
17
+ * Build a PixelWeaver layer tree from the flat Aseprite layer list.
18
+ * Aseprite layers use childLevel to indicate nesting depth; we reconstruct
19
+ * the parent-child relationships and create PW layers accordingly.
20
+ *
21
+ * Returns a map from Aseprite layer index to PixelWeaver layer ID.
22
+ */
23
+ function createLayerTree(api: PluginAPI, aseLayers: AsepriteLayer[]): Map<number, string> {
24
+ const indexToId = new Map<number, string>();
25
+
26
+ // Track the group stack: each entry is the PW group layer ID at that depth.
27
+ // childLevel 0 = root, childLevel 1 = inside a depth-0 group, etc.
28
+ const groupStack: string[] = [];
29
+
30
+ for (let i = 0; i < aseLayers.length; i++) {
31
+ const aseLayer = aseLayers[i];
32
+ if (!aseLayer) continue;
33
+
34
+ // Trim the group stack to match the current child level
35
+ while (groupStack.length > aseLayer.childLevel) {
36
+ groupStack.pop();
37
+ }
38
+
39
+ const parentId = groupStack.length > 0 ? groupStack[groupStack.length - 1] : undefined;
40
+ const isVisible = (aseLayer.flags & 1) !== 0;
41
+ // Aseprite opacity is 0-255, PixelWeaver uses 0-100
42
+ const pwOpacity = Math.round((aseLayer.opacity / 255) * 100);
43
+
44
+ // Build options object, omitting parentId entirely when undefined
45
+ // (exactOptionalPropertyTypes disallows passing { parentId: undefined })
46
+ const layerOptions = parentId !== undefined ? { parentId } : {};
47
+
48
+ if (aseLayer.type === 1) {
49
+ // Group layer
50
+ const groupId = api.addGroup(aseLayer.name, layerOptions);
51
+ api.setLayerVisibility(groupId, isVisible);
52
+ api.setLayerOpacity(groupId, pwOpacity);
53
+ indexToId.set(i, groupId);
54
+ groupStack.push(groupId);
55
+ } else {
56
+ // Pixel layer (type 0) or tilemap (type 2, treated as pixel)
57
+ const layerId = api.addLayer(aseLayer.name, layerOptions);
58
+ api.setLayerVisibility(layerId, isVisible);
59
+ api.setLayerOpacity(layerId, pwOpacity);
60
+ indexToId.set(i, layerId);
61
+ }
62
+ }
63
+
64
+ return indexToId;
65
+ }
66
+
67
+ export const asepriteImporterPlugin: PluginModule = {
68
+ name: 'builtin/import-aseprite',
69
+ version: '1.0.0',
70
+ description: 'Import Aseprite .ase/.aseprite files',
71
+
72
+ register(api) {
73
+ api.addCommand('import_aseprite', {
74
+ tier: 'project',
75
+ execute() { /* Will be wired to file picker */ },
76
+ undo() {},
77
+ describe() { return 'Import Aseprite file'; },
78
+ label: 'Aseprite (.ase/.aseprite)',
79
+ category: 'File',
80
+ icon: FileImage,
81
+ });
82
+ api.addMenuItem('menu:file:import:aseprite', {
83
+ commandId: 'import_aseprite',
84
+ menuPath: 'file/import',
85
+ group: 'import',
86
+ order: 10,
87
+ label: 'Aseprite (.ase/.aseprite)',
88
+ });
89
+
90
+ api.addImporter('aseprite', {
91
+ label: 'Aseprite File',
92
+ extensions: ['ase', 'aseprite'],
93
+
94
+ async import(data) {
95
+ const buffer = data instanceof File ? await data.arrayBuffer() : data;
96
+ const aseFile = await parseAseprite(buffer);
97
+
98
+ // 1. Set canvas dimensions
99
+ api.setCanvasSize(aseFile.width, aseFile.height);
100
+
101
+ // 2. Clear existing layers and frames to start fresh
102
+ api.deserializeLayers({ layers: [], activeLayerId: '' });
103
+ api.deserializeFrames({
104
+ frames: [],
105
+ currentFrameIndex: 0,
106
+ globalFps: 12,
107
+ originX: 0,
108
+ originY: 0,
109
+ });
110
+
111
+ // 3. Create PixelWeaver layers from the Aseprite layer list
112
+ const layerIndexToId = createLayerTree(api, aseFile.layers);
113
+
114
+ // 4. Create frames and populate pixel data
115
+ for (let f = 0; f < aseFile.frames.length; f++) {
116
+ const aseFrame = aseFile.frames[f];
117
+ if (!aseFrame) continue;
118
+ const frameIndex = api.addFrame({ afterIndex: f - 1 });
119
+
120
+ // Set per-frame duration if specified
121
+ if (aseFrame.duration > 0) {
122
+ api.setFrameDuration(frameIndex, aseFrame.duration);
123
+ }
124
+
125
+ // Copy cel pixel data into each layer's PixelBuffer for this frame
126
+ for (const cel of aseFrame.cels) {
127
+ const layerId = layerIndexToId.get(cel.layerIndex);
128
+ if (!layerId) continue; // layer not mapped (e.g., unsupported type)
129
+ if (cel.pixels.length === 0) continue; // empty or unsupported cel
130
+
131
+ // Create a full-canvas-sized PixelBuffer and place cel pixels at (cel.x, cel.y)
132
+ const pixelBuffer = api.createPixelBuffer(aseFile.width, aseFile.height);
133
+
134
+ for (let py = 0; py < cel.height; py++) {
135
+ for (let px = 0; px < cel.width; px++) {
136
+ const srcIdx = (py * cel.width + px) * 4;
137
+ const destX = cel.x + px;
138
+ const destY = cel.y + py;
139
+ pixelBuffer.setPixel(
140
+ destX,
141
+ destY,
142
+ cel.pixels[srcIdx] ?? 0,
143
+ cel.pixels[srcIdx + 1] ?? 0,
144
+ cel.pixels[srcIdx + 2] ?? 0,
145
+ cel.pixels[srcIdx + 3] ?? 0,
146
+ );
147
+ }
148
+ }
149
+
150
+ api.setFramePixelData(frameIndex, layerId, pixelBuffer);
151
+ }
152
+ }
153
+
154
+ // Select the first frame
155
+ if (api.getFrames().length > 0) {
156
+ api.setCurrentFrame(0);
157
+ }
158
+
159
+ // 5. Set the project palette from Aseprite colors
160
+ if (aseFile.palette.length > 0) {
161
+ const palette: Palette = {
162
+ name: 'Imported (Aseprite)',
163
+ colors: aseFile.palette
164
+ .filter((c) => c.a > 0) // skip fully transparent entries
165
+ .map((c) => rgbToHex(c.r, c.g, c.b)),
166
+ };
167
+ // Deduplicate colors while preserving order
168
+ palette.colors = [...new Set(palette.colors)];
169
+ api.setProjectPalette(palette);
170
+ }
171
+ },
172
+ });
173
+ },
174
+ };
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Aseprite (.ase/.aseprite) binary file parser.
3
+ *
4
+ * Parses the binary format into a structured AsepriteFile object with layers,
5
+ * frames, cels, and palette data. All pixel data is normalized to RGBA
6
+ * regardless of the source color depth (indexed, grayscale, or RGBA).
7
+ *
8
+ * Format spec: https://github.com/aseprite/aseprite/blob/main/docs/ase-file-specs.md
9
+ * All multi-byte integers are little-endian.
10
+ */
11
+
12
+ // --- Public types ---
13
+
14
+ export interface AsepriteFile {
15
+ width: number;
16
+ height: number;
17
+ colorDepth: number; // 8 = indexed, 16 = grayscale, 32 = RGBA
18
+ transparentIndex: number;
19
+ frames: AsepriteFrame[];
20
+ layers: AsepriteLayer[];
21
+ palette: AsepriteColor[];
22
+ }
23
+
24
+ export interface AsepriteFrame {
25
+ duration: number; // milliseconds
26
+ cels: AsepriteCel[];
27
+ }
28
+
29
+ export interface AsepriteLayer {
30
+ name: string;
31
+ flags: number; // bit 0: visible, bit 1: editable, bit 4: group
32
+ type: number; // 0 = normal (image), 1 = group, 2 = tilemap
33
+ opacity: number; // 0-255
34
+ blendMode: number;
35
+ childLevel: number; // nesting depth (0 = root)
36
+ }
37
+
38
+ export interface AsepriteCel {
39
+ layerIndex: number;
40
+ x: number;
41
+ y: number;
42
+ opacity: number; // 0-255
43
+ width: number;
44
+ height: number;
45
+ pixels: Uint8Array; // always RGBA, 4 bytes per pixel
46
+ linkedFrame?: number; // if this cel references another frame's cel
47
+ }
48
+
49
+ export interface AsepriteColor {
50
+ r: number;
51
+ g: number;
52
+ b: number;
53
+ a: number;
54
+ name?: string;
55
+ }
56
+
57
+ // --- Constants ---
58
+
59
+ const FILE_MAGIC = 0xa5e0;
60
+ const FRAME_MAGIC = 0xf1fa;
61
+
62
+ // Chunk type identifiers
63
+ const CHUNK_LAYER = 0x2004;
64
+ const CHUNK_CEL = 0x2005;
65
+ const CHUNK_PALETTE = 0x2019;
66
+ const CHUNK_OLD_PALETTE_1 = 0x0004; // legacy palette (fallback for indexed files)
67
+ const CHUNK_OLD_PALETTE_2 = 0x0011; // legacy palette variant
68
+
69
+ // Cel type identifiers
70
+ const CEL_RAW = 0;
71
+ const CEL_LINKED = 1;
72
+ const CEL_COMPRESSED = 2;
73
+ const CEL_COMPRESSED_TILEMAP = 3;
74
+
75
+ // --- Binary reader helper ---
76
+
77
+ /**
78
+ * Wraps a DataView with a position cursor for sequential reads.
79
+ * All reads are little-endian as per the Aseprite format.
80
+ */
81
+ class BinaryReader {
82
+ private view: DataView;
83
+ pos: number;
84
+
85
+ constructor(buffer: ArrayBuffer, offset = 0) {
86
+ this.view = new DataView(buffer);
87
+ this.pos = offset;
88
+ }
89
+
90
+ byte(): number {
91
+ const v = this.view.getUint8(this.pos);
92
+ this.pos += 1;
93
+ return v;
94
+ }
95
+
96
+ word(): number {
97
+ const v = this.view.getUint16(this.pos, true);
98
+ this.pos += 2;
99
+ return v;
100
+ }
101
+
102
+ short(): number {
103
+ const v = this.view.getInt16(this.pos, true);
104
+ this.pos += 2;
105
+ return v;
106
+ }
107
+
108
+ dword(): number {
109
+ const v = this.view.getUint32(this.pos, true);
110
+ this.pos += 4;
111
+ return v;
112
+ }
113
+
114
+ /** Read a STRING: WORD length prefix followed by UTF-8 bytes. */
115
+ string(): string {
116
+ const len = this.word();
117
+ const bytes = new Uint8Array(this.view.buffer, this.pos, len);
118
+ this.pos += len;
119
+ return new TextDecoder().decode(bytes);
120
+ }
121
+
122
+ /** Read raw bytes without advancing the position cursor. */
123
+ bytes(length: number): Uint8Array {
124
+ const result = new Uint8Array(this.view.buffer, this.pos, length);
125
+ this.pos += length;
126
+ return result;
127
+ }
128
+
129
+ /** Skip forward by n bytes. */
130
+ skip(n: number): void {
131
+ this.pos += n;
132
+ }
133
+ }
134
+
135
+ // --- Zlib decompression using browser DecompressionStream ---
136
+
137
+ async function decompress(data: Uint8Array): Promise<Uint8Array> {
138
+ // DecompressionStream('deflate') handles raw deflate; Aseprite uses zlib
139
+ // which is deflate with a zlib header -- 'deflate' mode in the Web API
140
+ // actually handles the zlib wrapper (RFC 1950), not raw deflate (RFC 1951).
141
+ const ds = new DecompressionStream('deflate');
142
+ const writer = ds.writable.getWriter();
143
+ void writer.write(data as unknown as BufferSource);
144
+ void writer.close();
145
+ const reader = ds.readable.getReader();
146
+ const chunks: Uint8Array[] = [];
147
+ for (;;) {
148
+ const { done, value } = await reader.read();
149
+ if (done) break;
150
+ chunks.push(value);
151
+ }
152
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
153
+ const result = new Uint8Array(totalLength);
154
+ let offset = 0;
155
+ for (const chunk of chunks) {
156
+ result.set(chunk, offset);
157
+ offset += chunk.length;
158
+ }
159
+ return result;
160
+ }
161
+
162
+ // --- Pixel format conversion ---
163
+
164
+ /**
165
+ * Convert indexed pixel data to RGBA using the palette.
166
+ * Pixels matching transparentIndex become fully transparent.
167
+ */
168
+ function indexedToRgba(
169
+ indexed: Uint8Array,
170
+ palette: AsepriteColor[],
171
+ transparentIndex: number,
172
+ ): Uint8Array {
173
+ const rgba = new Uint8Array(indexed.length * 4);
174
+ for (let i = 0; i < indexed.length; i++) {
175
+ const idx = indexed[i];
176
+ const out = i * 4;
177
+ if (idx === undefined || idx === transparentIndex) {
178
+ // Fully transparent
179
+ rgba[out] = 0;
180
+ rgba[out + 1] = 0;
181
+ rgba[out + 2] = 0;
182
+ rgba[out + 3] = 0;
183
+ } else if (idx < palette.length) {
184
+ const entry = palette[idx];
185
+ if (entry) {
186
+ rgba[out] = entry.r;
187
+ rgba[out + 1] = entry.g;
188
+ rgba[out + 2] = entry.b;
189
+ rgba[out + 3] = entry.a;
190
+ }
191
+ } else {
192
+ // Out-of-range index: treat as transparent
193
+ rgba[out] = 0;
194
+ rgba[out + 1] = 0;
195
+ rgba[out + 2] = 0;
196
+ rgba[out + 3] = 0;
197
+ }
198
+ }
199
+ return rgba;
200
+ }
201
+
202
+ /**
203
+ * Convert grayscale pixel data (2 bytes per pixel: gray, alpha) to RGBA.
204
+ */
205
+ function grayscaleToRgba(gray: Uint8Array): Uint8Array {
206
+ const pixelCount = gray.length / 2;
207
+ const rgba = new Uint8Array(pixelCount * 4);
208
+ for (let i = 0; i < pixelCount; i++) {
209
+ const g = gray[i * 2] ?? 0;
210
+ const a = gray[i * 2 + 1] ?? 0;
211
+ const out = i * 4;
212
+ rgba[out] = g;
213
+ rgba[out + 1] = g;
214
+ rgba[out + 2] = g;
215
+ rgba[out + 3] = a;
216
+ }
217
+ return rgba;
218
+ }
219
+
220
+ // --- Chunk parsers ---
221
+
222
+ function parseLayerChunk(reader: BinaryReader): AsepriteLayer {
223
+ const flags = reader.word();
224
+ const type = reader.word();
225
+ const childLevel = reader.word();
226
+ reader.word(); // default width (ignored)
227
+ reader.word(); // default height (ignored)
228
+ const blendMode = reader.word();
229
+ const opacity = reader.byte();
230
+ reader.skip(3); // reserved
231
+ const name = reader.string();
232
+
233
+ return { name, flags, type, opacity, blendMode, childLevel };
234
+ }
235
+
236
+ /**
237
+ * Parse the cel chunk header and raw/compressed pixel data.
238
+ * Returns the cel metadata; pixel data for compressed cels is decompressed asynchronously.
239
+ */
240
+ async function parseCelChunk(
241
+ reader: BinaryReader,
242
+ chunkEnd: number,
243
+ colorDepth: number,
244
+ palette: AsepriteColor[],
245
+ transparentIndex: number,
246
+ ): Promise<AsepriteCel> {
247
+ const layerIndex = reader.word();
248
+ const x = reader.short();
249
+ const y = reader.short();
250
+ const opacity = reader.byte();
251
+ const celType = reader.word();
252
+ reader.skip(7); // reserved / z-index (not needed)
253
+
254
+ if (celType === CEL_LINKED) {
255
+ // Linked cel: references pixels from another frame
256
+ const frameIndex = reader.word();
257
+ return {
258
+ layerIndex,
259
+ x,
260
+ y,
261
+ opacity,
262
+ width: 0,
263
+ height: 0,
264
+ pixels: new Uint8Array(0),
265
+ linkedFrame: frameIndex,
266
+ };
267
+ }
268
+
269
+ if (celType === CEL_COMPRESSED_TILEMAP) {
270
+ // Tilemap cels are not supported; skip the rest
271
+ return {
272
+ layerIndex,
273
+ x,
274
+ y,
275
+ opacity,
276
+ width: 0,
277
+ height: 0,
278
+ pixels: new Uint8Array(0),
279
+ };
280
+ }
281
+
282
+ const width = reader.word();
283
+ const height = reader.word();
284
+
285
+ let rawPixels: Uint8Array;
286
+
287
+ if (celType === CEL_RAW) {
288
+ // Bytes per pixel depends on color depth: 4 (RGBA), 2 (grayscale), 1 (indexed)
289
+ const bpp = colorDepth / 8;
290
+ rawPixels = reader.bytes(width * height * bpp);
291
+ } else if (celType === CEL_COMPRESSED) {
292
+ // Remaining bytes in this chunk are zlib-compressed pixel data
293
+ const compressedSize = chunkEnd - reader.pos;
294
+ const compressed = reader.bytes(compressedSize);
295
+ rawPixels = await decompress(compressed);
296
+ } else {
297
+ // Unknown cel type; return empty
298
+ return { layerIndex, x, y, opacity, width, height, pixels: new Uint8Array(0) };
299
+ }
300
+
301
+ // Convert to RGBA based on color depth
302
+ let rgbaPixels: Uint8Array;
303
+ if (colorDepth === 32) {
304
+ rgbaPixels = rawPixels;
305
+ } else if (colorDepth === 16) {
306
+ rgbaPixels = grayscaleToRgba(rawPixels);
307
+ } else {
308
+ // colorDepth === 8 (indexed)
309
+ rgbaPixels = indexedToRgba(rawPixels, palette, transparentIndex);
310
+ }
311
+
312
+ return { layerIndex, x, y, opacity, width, height, pixels: rgbaPixels };
313
+ }
314
+
315
+ function parsePaletteChunk(reader: BinaryReader): AsepriteColor[] {
316
+ const size = reader.dword();
317
+ const firstIndex = reader.dword();
318
+ const lastIndex = reader.dword();
319
+ reader.skip(8); // reserved
320
+
321
+ // Pre-fill with transparent black for indices before firstIndex
322
+ const colors: AsepriteColor[] = Array.from(
323
+ { length: size },
324
+ (): AsepriteColor => ({ r: 0, g: 0, b: 0, a: 255 }),
325
+ );
326
+
327
+ for (let i = firstIndex; i <= lastIndex; i++) {
328
+ const flags = reader.word();
329
+ const r = reader.byte();
330
+ const g = reader.byte();
331
+ const b = reader.byte();
332
+ const a = reader.byte();
333
+ // Omit name when absent (exactOptionalPropertyTypes disallows explicit undefined)
334
+ const color: AsepriteColor = { r, g, b, a };
335
+ if (flags & 1) {
336
+ color.name = reader.string();
337
+ }
338
+ colors[i] = color;
339
+ }
340
+
341
+ return colors;
342
+ }
343
+
344
+ /**
345
+ * Parse the legacy palette chunk (0x0004).
346
+ * Used as a fallback when no new-format palette chunk (0x2019) is present.
347
+ */
348
+ function parseOldPaletteChunk(reader: BinaryReader): AsepriteColor[] {
349
+ const numPackets = reader.word();
350
+ const colors: AsepriteColor[] = [];
351
+
352
+ for (let p = 0; p < numPackets; p++) {
353
+ const skipCount = reader.byte(); // entries to skip from current position
354
+ let count = reader.byte(); // number of colors in this packet
355
+ if (count === 0) count = 256;
356
+ // Pad skipped slots with black
357
+ for (let s = 0; s < skipCount; s++) {
358
+ colors.push({ r: 0, g: 0, b: 0, a: 255 });
359
+ }
360
+ for (let c = 0; c < count; c++) {
361
+ const r = reader.byte();
362
+ const g = reader.byte();
363
+ const b = reader.byte();
364
+ colors.push({ r, g, b, a: 255 });
365
+ }
366
+ }
367
+
368
+ return colors;
369
+ }
370
+
371
+ // --- Main parser ---
372
+
373
+ /**
374
+ * Parse an Aseprite file from an ArrayBuffer.
375
+ * Returns the complete file structure with all layers, frames, cels, and palette.
376
+ */
377
+ export async function parseAseprite(buffer: ArrayBuffer): Promise<AsepriteFile> {
378
+ const reader = new BinaryReader(buffer);
379
+
380
+ // --- File header (128 bytes) ---
381
+ reader.dword(); // bytes 0-3: file size (not needed for parsing)
382
+ const magic = reader.word(); // bytes 4-5
383
+ if (magic !== FILE_MAGIC) {
384
+ throw new Error(`Not a valid Aseprite file (magic: 0x${magic.toString(16)}, expected 0x${FILE_MAGIC.toString(16)})`);
385
+ }
386
+
387
+ const frameCount = reader.word(); // bytes 6-7
388
+ const width = reader.word(); // bytes 8-9
389
+ const height = reader.word(); // bytes 10-11
390
+ const colorDepth = reader.word(); // bytes 12-13
391
+ reader.dword(); // bytes 14-17: flags (unused)
392
+ reader.skip(10); // bytes 18-27: deprecated speed, reserved
393
+ const transparentIndex = reader.byte(); // byte 28
394
+ reader.skip(99); // bytes 29-127: remaining header padding
395
+
396
+ const layers: AsepriteLayer[] = [];
397
+ let palette: AsepriteColor[] = [];
398
+ let hasNewPalette = false;
399
+ const frames: AsepriteFrame[] = [];
400
+
401
+ // --- Parse frames ---
402
+ for (let f = 0; f < frameCount; f++) {
403
+ const frameStart = reader.pos;
404
+
405
+ // Frame header (16 bytes)
406
+ const frameSizeBytes = reader.dword();
407
+ const frameMagic = reader.word();
408
+ if (frameMagic !== FRAME_MAGIC) {
409
+ throw new Error(`Invalid frame magic at frame ${String(f)} (0x${frameMagic.toString(16)})`);
410
+ }
411
+
412
+ const oldChunkCount = reader.word();
413
+ const duration = reader.word();
414
+ reader.skip(2); // reserved
415
+ const newChunkCount = reader.dword();
416
+
417
+ // Use new chunk count if available, otherwise fall back to old
418
+ const chunkCount = newChunkCount !== 0 ? newChunkCount : oldChunkCount;
419
+
420
+ const cels: AsepriteCel[] = [];
421
+
422
+ // --- Parse chunks within this frame ---
423
+ for (let c = 0; c < chunkCount; c++) {
424
+ const chunkStart = reader.pos;
425
+ const chunkSize = reader.dword();
426
+ const chunkType = reader.word();
427
+ const chunkEnd = chunkStart + chunkSize;
428
+
429
+ switch (chunkType) {
430
+ case CHUNK_LAYER:
431
+ layers.push(parseLayerChunk(reader));
432
+ break;
433
+
434
+ case CHUNK_CEL:
435
+ cels.push(
436
+ await parseCelChunk(reader, chunkEnd, colorDepth, palette, transparentIndex),
437
+ );
438
+ break;
439
+
440
+ case CHUNK_PALETTE:
441
+ palette = parsePaletteChunk(reader);
442
+ hasNewPalette = true;
443
+ break;
444
+
445
+ case CHUNK_OLD_PALETTE_1:
446
+ case CHUNK_OLD_PALETTE_2:
447
+ // Only use legacy palette if no new-format palette has been seen
448
+ if (!hasNewPalette) {
449
+ palette = parseOldPaletteChunk(reader);
450
+ }
451
+ break;
452
+
453
+ // Other chunk types (tags, slices, user data, etc.) are skipped
454
+ }
455
+
456
+ // Advance to end of chunk regardless of how much was read
457
+ reader.pos = chunkEnd;
458
+ }
459
+
460
+ frames.push({ duration, cels });
461
+
462
+ // Advance to end of frame in case of padding
463
+ reader.pos = frameStart + frameSizeBytes;
464
+ }
465
+
466
+ // --- Resolve linked cels ---
467
+ // Linked cels reference the pixels of the same layer in another frame.
468
+ for (const frame of frames) {
469
+ for (let i = 0; i < frame.cels.length; i++) {
470
+ const cel = frame.cels[i];
471
+ if (!cel) continue;
472
+ if (cel.linkedFrame !== undefined && cel.pixels.length === 0) {
473
+ const sourceFrame = frames[cel.linkedFrame];
474
+ if (sourceFrame) {
475
+ const sourceCel = sourceFrame.cels.find(
476
+ (sc) => sc.layerIndex === cel.layerIndex && sc.linkedFrame === undefined,
477
+ );
478
+ if (sourceCel) {
479
+ // Rebuild without linkedFrame so exactOptionalPropertyTypes is happy
480
+ const rebuilt: AsepriteCel = {
481
+ width: sourceCel.width,
482
+ height: sourceCel.height,
483
+ pixels: sourceCel.pixels,
484
+ x: sourceCel.x,
485
+ y: sourceCel.y,
486
+ layerIndex: cel.layerIndex,
487
+ opacity: cel.opacity,
488
+ };
489
+ frame.cels[i] = rebuilt;
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ return { width, height, colorDepth, transparentIndex, frames, layers, palette };
497
+ }