diff --git a/docs/contributing/CANVAS_PROJECTS/CANVAS_PROJECTS.md b/docs/contributing/CANVAS_PROJECTS/CANVAS_PROJECTS.md new file mode 100644 index 00000000000..a05ef29492e --- /dev/null +++ b/docs/contributing/CANVAS_PROJECTS/CANVAS_PROJECTS.md @@ -0,0 +1,205 @@ +# Canvas Projects — Technical Documentation + +## Overview + +Canvas Projects provide a save/load mechanism for the entire canvas state. The feature serializes all canvas entities, generation parameters, reference images, and their associated image files into a ZIP-based `.invk` file. On load, it restores the full state, handling image deduplication and re-uploading as needed. + +## File Format + +The `.invk` file is a standard ZIP archive with the following structure: + +``` +project.invk +├── manifest.json +├── canvas_state.json +├── params.json +├── ref_images.json +├── loras.json +└── images/ + ├── {image_name_1}.png + ├── {image_name_2}.png + └── ... +``` + +### manifest.json + +Schema version and metadata. Validated on load with Zod. + +```json +{ + "version": 1, + "appVersion": "5.12.0", + "createdAt": "2026-02-26T12:00:00.000Z", + "name": "My Canvas Project" +} +``` + +| Field | Type | Description | +|---|---|---| +| `version` | `number` | Schema version, currently `1`. Used for migration logic on load. | +| `appVersion` | `string` | InvokeAI version that created the file. Informational only. | +| `createdAt` | `string` | ISO 8601 timestamp. | +| `name` | `string` | User-provided project name. Also used as the download filename. | + +### canvas_state.json + +The serialized canvas entity tree. Type: `CanvasProjectState`. + +```typescript +type CanvasProjectState = { + rasterLayers: CanvasRasterLayerState[]; + controlLayers: CanvasControlLayerState[]; + inpaintMasks: CanvasInpaintMaskState[]; + regionalGuidance: CanvasRegionalGuidanceState[]; + bbox: CanvasState['bbox']; + selectedEntityIdentifier: CanvasState['selectedEntityIdentifier']; + bookmarkedEntityIdentifier: CanvasState['bookmarkedEntityIdentifier']; +}; +``` + +Each entity contains its full state including all canvas objects (brush lines, eraser lines, rect shapes, images). Image objects reference files by `image_name` which correspond to files in the `images/` folder. + +### params.json + +The complete generation parameters state (`ParamsState`). Optional on load (older files may not have it). This includes all fields from the params Redux slice: + +- Prompts (positive, negative, prompt history) +- Core generation settings (seed, steps, CFG scale, guidance, scheduler, iterations) +- Model selections (main model, VAE, FLUX VAE, T5 encoder, CLIP embed models, refiner, Z-Image models, Klein models) +- Dimensions (width, height, aspect ratio) +- Img2img strength +- Infill settings (method, tile size, patchmatch downscale, color) +- Canvas coherence settings (mode, edge size, min denoise) +- Refiner parameters (steps, CFG scale, scheduler, aesthetic scores, start) +- FLUX-specific settings (scheduler, DyPE preset/scale/exponent) +- Z-Image-specific settings (scheduler, seed variance) +- Upscale settings (scheduler, CFG scale) +- Seamless tiling, mask blur, CLIP skip, VAE precision, CPU noise, color compensation + +### ref_images.json + +Global reference image entities (`RefImageState[]`). These are IP-Adapter / FLUX Redux configs with `CroppableImageWithDims` containing both original and cropped image references. Optional on load. + +### loras.json + +Array of LoRA configurations (`LoRA[]`). Each entry contains: + +```typescript +type LoRA = { + id: string; + isEnabled: boolean; + model: ModelIdentifierField; + weight: number; +}; +``` + +Optional on load. Like models, LoRA identifiers are stored as-is — if a LoRA is not installed when loading, the entry is restored but may not be usable. + +### images/ + +All image files referenced anywhere in the state. Keyed by their original `image_name`. On save, each image is fetched from the backend via `GET /api/v1/images/i/{name}/full` and stored as-is. + +## Key Source Files + +| File | Purpose | +|---|---| +| `features/controlLayers/util/canvasProjectFile.ts` | Types, constants, image name collection, remapping, existence checking | +| `features/controlLayers/hooks/useCanvasProjectSave.ts` | Save hook — collects Redux state, fetches images, builds ZIP | +| `features/controlLayers/hooks/useCanvasProjectLoad.ts` | Load hook — parses ZIP, deduplicates images, dispatches state | +| `features/controlLayers/components/SaveCanvasProjectDialog.tsx` | Save name dialog + `useSaveCanvasProjectWithDialog` hook | +| `features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog.tsx` | Load confirmation dialog + `useLoadCanvasProjectWithDialog` hook | +| `features/controlLayers/components/Toolbar/CanvasToolbarProjectMenuButton.tsx` | Toolbar dropdown UI | +| `features/controlLayers/store/canvasSlice.ts` | `canvasProjectRecalled` Redux action | + +## Save Flow + +1. User clicks "Save Canvas Project" → `SaveCanvasProjectDialog` opens asking for a project name +2. On confirm, `saveCanvasProject(name)` is called +3. Read Redux state via selectors: `selectCanvasSlice()`, `selectParamsSlice()`, `selectRefImagesSlice()`, `selectLoRAsSlice()` +4. Build `CanvasProjectState` from the canvas slice; use `paramsState` directly for params +5. Walk all entities to collect every `image_name` reference via `collectImageNames()`: + - `CanvasImageState.image.image_name` in layer/mask objects + - `CroppableImageWithDims.original.image.image_name` in global ref images + - `CroppableImageWithDims.crop.image.image_name` in cropped ref images + - `ImageWithDims.image_name` in regional guidance ref images +6. Fetch each image from the backend API +7. Build ZIP with JSZip: add `manifest.json` (including `name`), `canvas_state.json`, `params.json`, `ref_images.json`, and all images into `images/` +8. Sanitize the name for filesystem use and generate blob, trigger download as `{name}.invk` + +## Load Flow + +1. User selects `.invk` file → confirmation dialog opens +2. On confirm, parse ZIP with JSZip +3. Validate manifest version via Zod schema +4. Read `canvas_state.json`, `params.json` (optional), `ref_images.json` (optional) +5. Collect all `image_name` references from the loaded state +6. **Deduplicate images**: for each referenced image, check if it exists on the server via `getImageDTOSafe(image_name)` + - Already exists → skip (no upload) + - Missing → upload from ZIP via `uploadImage()`, record `oldName → newName` mapping +7. Remap all `image_name` values in the loaded state using the mapping (only for re-uploaded images whose names changed) +8. Dispatch Redux actions: + - `canvasProjectRecalled()` — restores all canvas entities, bbox, selected/bookmarked entity + - `refImagesRecalled()` — restores global reference images + - `paramsRecalled()` — replaces the entire params state in one action + - `loraAllDeleted()` + `loraRecalled()` — restores LoRAs +9. Show success/error toast + +## Image Name Collection & Remapping + +The `canvasProjectFile.ts` utility provides two parallel sets of functions: + +**Collection** (`collectImageNames`): Walks the entire state tree and returns a `Set` of all referenced `image_name` values. This is used by both save (to know which images to fetch) and load (to know which images to check/upload). + +**Remapping** (`remapCanvasState`, `remapRefImages`): Deep-clones state objects and replaces `image_name` values using a `Map` mapping. Only images that were re-uploaded with a different name are remapped. Images that already existed on the server are left unchanged. + +Both walk the same paths through the state tree: +- Layer/mask objects → `CanvasImageState.image.image_name` +- Regional guidance ref images → `ImageWithDims.image_name` +- Global ref images → `CroppableImageWithDims.original.image.image_name` and `.crop.image.image_name` + +## Extending the Format + +### Adding new optional data (non-breaking) + +Add a new JSON file to the ZIP. No version bump needed. + +1. **Save**: Add `zip.file('new_data.json', JSON.stringify(data))` in `useCanvasProjectSave.ts` +2. **Load**: Read with `zip.file('new_data.json')` in `useCanvasProjectLoad.ts` — check for `null` so older project files without it still load +3. **Dispatch**: Add the appropriate Redux action to restore the data + +### Adding new entity types with images + +1. Extend `CanvasProjectState` type in `canvasProjectFile.ts` +2. Add collection logic in `collectImageNames()` to walk the new entity's objects +3. Add remapping logic in `remapCanvasState()` to update image names +4. Include the new entity array in both save and load hooks +5. Handle it in the `canvasProjectRecalled` reducer in `canvasSlice.ts` + +### Breaking schema changes + +1. Bump `CANVAS_PROJECT_VERSION` in `canvasProjectFile.ts` +2. Update the Zod manifest schema: `version: z.union([z.literal(1), z.literal(2)])` +3. Add migration logic in the load hook: check version, transform v1 → v2 before dispatching + +## UI Architecture + +### Save dialog + +The save flow uses a **nanostore atom** (`$isOpen`) to control the `SaveCanvasProjectDialog`: + +1. `useSaveCanvasProjectWithDialog()` — returns a callback that sets `$isOpen` to `true` +2. `SaveCanvasProjectDialog` (singleton in `GlobalModalIsolator`) — renders an `AlertDialog` with a name input +3. On save → calls `saveCanvasProject(name)` and closes the dialog +4. On cancel → closes the dialog + +### Load dialog + +The load flow uses a **nanostore atom** (`$pendingFile`) to decouple the file dialog from the confirmation dialog: + +1. `useLoadCanvasProjectWithDialog()` — opens a programmatic file input (`document.createElement('input')`) +2. On file selection → sets `$pendingFile` atom +3. `LoadCanvasProjectConfirmationAlertDialog` (singleton in `GlobalModalIsolator`) — subscribes to `$pendingFile` via `useStore()` +4. On accept → calls `loadCanvasProject(file)` and clears the atom +5. On cancel → clears the atom + +The programmatic file input approach was chosen because the context menu component uses `isLazy: true`, which unmounts the DOM tree when the menu closes — a hidden `` element inside the menu would be destroyed before the file dialog returns. diff --git a/docs/features/canvas_projects.md b/docs/features/canvas_projects.md new file mode 100644 index 00000000000..8b161c67455 --- /dev/null +++ b/docs/features/canvas_projects.md @@ -0,0 +1,56 @@ +--- +title: Canvas Projects +--- + +# :material-folder-zip: Canvas Projects + +## Save and Restore Your Canvas Work + +Canvas Projects let you save your entire canvas setup to a file and load it back later. This is useful when you want to: + +- **Switch between tasks** without losing your current canvas arrangement +- **Back up complex setups** with multiple layers, masks, and reference images +- **Share canvas layouts** with others or transfer them between machines +- **Recover from deleted images** — all images are embedded in the project file + +## What Gets Saved + +A canvas project file (`.invk`) captures everything about your current canvas session: + +- **All layers** — raster layers, control layers, inpaint masks, regional guidance +- **All drawn content** — brush strokes, pasted images, eraser marks +- **Reference images** — global IP-Adapter / FLUX Redux images with crop settings +- **Regional guidance** — per-region prompts and reference images +- **Bounding box** — position, size, aspect ratio, and scale settings +- **All generation parameters** — prompts, seed, steps, CFG scale, guidance, scheduler, model, VAE, dimensions, img2img strength, infill settings, canvas coherence, refiner settings, FLUX/Z-Image specific parameters, and more +- **LoRAs** — all added LoRA models with their weights and enabled/disabled state + +## How to Save a Project + +You can save from two places: + +1. **Toolbar** — Click the **Archive icon** in the canvas toolbar, then select **Save Canvas Project** +2. **Context menu** — Right-click the canvas, open the **Project** submenu, then select **Save Canvas Project** + +A dialog will ask you to enter a **project name**. This name is used as the filename (e.g., entering "My Portrait" saves as `My Portrait.invk`) and is stored inside the project file. + +## How to Load a Project + +1. **Toolbar** — Click the **Archive icon**, then select **Load Canvas Project** +2. **Context menu** — Right-click the canvas, open the **Project** submenu, then select **Load Canvas Project** + +A file dialog will open. Select your `.invk` file. You will see a confirmation dialog warning that loading will replace your current canvas. Click **Load** to proceed. + +### What Happens on Load + +- Your current canvas is **completely replaced** — all existing layers, masks, reference images, and parameters are overwritten +- Images that are already present on your InvokeAI server are reused automatically (no duplicate uploads) +- Images that were deleted from the server are re-uploaded from the project file +- If the saved model is not installed on your system, the model identifier is still restored — you will need to select an available model manually + +## Good to Know + +- **No undo** — Loading a project replaces your canvas entirely. There is no way to undo this action, so save your current project first if you want to keep it. +- **Image deduplication** — When loading, images already on your server are not re-uploaded. Only missing images are uploaded from the project file. +- **File size** — The `.invk` file size depends on the number and resolution of images in your canvas. A project with many high-resolution layers can be large. +- **Model availability** — The project saves which model was selected, but does not include the model itself. If the model is not installed when you load the project, you will need to select a different one. diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index da4e31142f2..2896c9f9c81 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -65,6 +65,7 @@ "i18next-http-backend": "^3.0.2", "idb-keyval": "6.2.1", "jsondiffpatch": "^0.7.3", + "jszip": "^3.10.1", "konva": "^9.3.22", "linkify-react": "^4.3.1", "linkifyjs": "^4.3.1", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 3f94ba7d692..6a2ed95ab06 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: jsondiffpatch: specifier: ^0.7.3 version: 0.7.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 konva: specifier: ^9.3.22 version: 9.3.22 @@ -2003,6 +2006,9 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -2672,6 +2678,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.1.1: resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} @@ -2825,6 +2834,9 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2916,6 +2928,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2934,6 +2949,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3210,6 +3228,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} @@ -3298,6 +3319,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3539,6 +3563,9 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -3661,6 +3688,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3718,6 +3748,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3857,6 +3890,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -6153,6 +6189,8 @@ snapshots: dependencies: toggle-selection: 1.0.6 + core-util-is@1.0.3: {} + cosmiconfig@7.1.0: dependencies: '@types/parse-json': 4.0.2 @@ -6957,6 +6995,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + immer@10.1.1: {} import-fresh@3.3.1: @@ -7103,6 +7143,8 @@ snapshots: dependencies: is-docker: 2.2.1 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7192,6 +7234,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7221,6 +7270,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lines-and-columns@1.2.4: {} linkify-react@4.3.1(linkifyjs@4.3.1)(react@18.3.1): @@ -7510,6 +7563,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + pako@2.1.0: {} parent-module@1.0.1: @@ -7578,6 +7633,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + process-nextick-args@2.0.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7843,6 +7900,16 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -7994,6 +8061,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -8051,6 +8120,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8236,6 +8307,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 2db971d06a6..eb27fa04112 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2833,6 +2833,19 @@ "copyCanvasToClipboard": "Copy Canvas to Clipboard", "copyBboxToClipboard": "Copy Bbox to Clipboard" }, + "canvasProject": { + "project": "Project", + "saveProject": "Save Canvas Project", + "loadProject": "Load Canvas Project", + "saveSuccess": "Project Saved", + "saveSuccessDesc": "Saved project with {{count}} images", + "saveError": "Failed to Save Project", + "loadSuccess": "Project Loaded", + "loadSuccessDesc": "Canvas state restored from project file", + "loadError": "Failed to Load Project", + "loadWarning": "Loading a project will replace your current canvas, including all layers, masks, reference images, and generation parameters. This action cannot be undone.", + "projectName": "Project Name" + }, "stagingArea": { "accept": "Accept", "discardAll": "Discard All", diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index 5c1446662ef..2ee6ced8101 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -1,6 +1,8 @@ import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys'; import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal'; import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal'; +import { LoadCanvasProjectConfirmationAlertDialog } from 'features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog'; +import { SaveCanvasProjectDialog } from 'features/controlLayers/components/SaveCanvasProjectDialog'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { CropImageModal } from 'features/cropper/components/CropImageModal'; import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal'; @@ -52,6 +54,8 @@ export const GlobalModalIsolator = memo(() => { + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems.tsx index ca264fa389c..064378b2274 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems.tsx @@ -2,6 +2,8 @@ import { Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-l import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { CanvasContextMenuItemsCropCanvasToBbox } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuItemsCropCanvasToBbox'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; +import { useLoadCanvasProjectWithDialog } from 'features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog'; +import { useSaveCanvasProjectWithDialog } from 'features/controlLayers/components/SaveCanvasProjectDialog'; import { useCopyCanvasToClipboard } from 'features/controlLayers/hooks/copyHooks'; import { useNewControlLayerFromBbox, @@ -14,16 +16,19 @@ import { import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiCopyBold, PiFloppyDiskBold } from 'react-icons/pi'; +import { PiArchiveBold, PiCopyBold, PiFileArrowDownBold, PiFileArrowUpBold, PiFloppyDiskBold } from 'react-icons/pi'; export const CanvasContextMenuGlobalMenuItems = memo(() => { const { t } = useTranslation(); const saveSubMenu = useSubMenu(); + const projectSubMenu = useSubMenu(); const newSubMenu = useSubMenu(); const copySubMenu = useSubMenu(); const isBusy = useCanvasIsBusy(); const saveCanvasToGallery = useSaveCanvasToGallery(); const saveBboxToGallery = useSaveBboxToGallery(); + const saveCanvasProject = useSaveCanvasProjectWithDialog(); + const loadCanvasProject = useLoadCanvasProjectWithDialog(); const newRegionalReferenceImageFromBbox = useNewRegionalReferenceImageFromBbox(); const newGlobalReferenceImageFromBbox = useNewGlobalReferenceImageFromBbox(); const newRasterLayerFromBbox = useNewRasterLayerFromBbox(); @@ -50,6 +55,21 @@ export const CanvasContextMenuGlobalMenuItems = memo(() => { + }> + + + + + + } isDisabled={isBusy} onClick={saveCanvasProject}> + {t('controlLayers.canvasProject.saveProject')} + + } isDisabled={isBusy} onClick={loadCanvasProject}> + {t('controlLayers.canvasProject.loadProject')} + + + + }> diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog.tsx new file mode 100644 index 00000000000..149d5b4f175 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog.tsx @@ -0,0 +1,69 @@ +import { ConfirmationAlertDialog, Flex, Text } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { useCanvasProjectLoad } from 'features/controlLayers/hooks/useCanvasProjectLoad'; +import { CANVAS_PROJECT_EXTENSION } from 'features/controlLayers/util/canvasProjectFile'; +import { atom } from 'nanostores'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +const $pendingFile = atom(null); + +const openFileDialog = (onFileSelected: (file: File) => void) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = CANVAS_PROJECT_EXTENSION; + input.onchange = () => { + const file = input.files?.[0]; + if (file) { + onFileSelected(file); + } + }; + input.click(); +}; + +export const useLoadCanvasProjectWithDialog = () => { + const openDialog = useCallback(() => { + openFileDialog((file) => { + $pendingFile.set(file); + }); + }, []); + + return openDialog; +}; + +export const LoadCanvasProjectConfirmationAlertDialog = memo(() => { + useAssertSingleton('LoadCanvasProjectConfirmationAlertDialog'); + const { t } = useTranslation(); + const { loadCanvasProject } = useCanvasProjectLoad(); + const pendingFile = useStore($pendingFile); + + const onClose = useCallback(() => { + $pendingFile.set(null); + }, []); + + const onAccept = useCallback(() => { + const file = $pendingFile.get(); + if (file) { + void loadCanvasProject(file); + } + $pendingFile.set(null); + }, [loadCanvasProject]); + + return ( + + + {t('controlLayers.canvasProject.loadWarning')} + + + ); +}); + +LoadCanvasProjectConfirmationAlertDialog.displayName = 'LoadCanvasProjectConfirmationAlertDialog'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SaveCanvasProjectDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SaveCanvasProjectDialog.tsx new file mode 100644 index 00000000000..bf947ba7c44 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SaveCanvasProjectDialog.tsx @@ -0,0 +1,92 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + Button, + Flex, + FormControl, + FormLabel, + Input, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { useCanvasProjectSave } from 'features/controlLayers/hooks/useCanvasProjectSave'; +import { atom } from 'nanostores'; +import type { ChangeEvent, RefObject } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const $isOpen = atom(false); + +export const useSaveCanvasProjectWithDialog = () => { + return useCallback(() => { + $isOpen.set(true); + }, []); +}; + +export const SaveCanvasProjectDialog = memo(() => { + useAssertSingleton('SaveCanvasProjectDialog'); + const isOpen = useStore($isOpen); + const cancelRef = useRef(null); + + const onClose = useCallback(() => { + $isOpen.set(false); + }, []); + + return ( + + {isOpen && } + + ); +}); + +SaveCanvasProjectDialog.displayName = 'SaveCanvasProjectDialog'; + +const Content = memo(({ cancelRef }: { cancelRef: RefObject }) => { + const { t } = useTranslation(); + const { saveCanvasProject } = useCanvasProjectSave(); + const [name, setName] = useState('Canvas Project'); + + const onChange = useCallback((e: ChangeEvent) => { + setName(e.target.value); + }, []); + + const onClose = useCallback(() => { + $isOpen.set(false); + }, []); + + const onSave = useCallback(() => { + void saveCanvasProject(name); + $isOpen.set(false); + }, [name, saveCanvasProject]); + + return ( + + + {t('controlLayers.canvasProject.saveProject')} + + + + + {t('controlLayers.canvasProject.projectName')} + + + + + + + + + + + + ); +}); + +Content.displayName = 'SaveCanvasProjectDialogContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index bf186ed6300..4bfb42e8b9a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -10,6 +10,7 @@ import { ToolWidthPicker } from 'features/controlLayers/components/Tool/ToolWidt import { CanvasToolbarFitBboxToLayersButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToLayersButton'; import { CanvasToolbarFitBboxToMasksButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToMasksButton'; import { CanvasToolbarNewSessionMenuButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarNewSessionMenuButton'; +import { CanvasToolbarProjectMenuButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarProjectMenuButton'; import { CanvasToolbarRedoButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarRedoButton'; import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton'; import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton'; @@ -67,6 +68,7 @@ export const CanvasToolbar = memo(() => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarProjectMenuButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarProjectMenuButton.tsx new file mode 100644 index 00000000000..92cdc629acf --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarProjectMenuButton.tsx @@ -0,0 +1,37 @@ +import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useLoadCanvasProjectWithDialog } from 'features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog'; +import { useSaveCanvasProjectWithDialog } from 'features/controlLayers/components/SaveCanvasProjectDialog'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArchiveBold, PiFileArrowDownBold, PiFileArrowUpBold } from 'react-icons/pi'; + +export const CanvasToolbarProjectMenuButton = memo(() => { + const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); + const saveCanvasProject = useSaveCanvasProjectWithDialog(); + const loadCanvasProject = useLoadCanvasProjectWithDialog(); + + return ( + + } + variant="link" + alignSelf="stretch" + /> + + } isDisabled={isBusy} onClick={saveCanvasProject}> + {t('controlLayers.canvasProject.saveProject')} + + } isDisabled={isBusy} onClick={loadCanvasProject}> + {t('controlLayers.canvasProject.loadProject')} + + + + ); +}); + +CanvasToolbarProjectMenuButton.displayName = 'CanvasToolbarProjectMenuButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectLoad.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectLoad.ts new file mode 100644 index 00000000000..21de5d3b22a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectLoad.ts @@ -0,0 +1,157 @@ +import { logger } from 'app/logging/logger'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { parseify } from 'common/util/serialize'; +import { canvasProjectRecalled } from 'features/controlLayers/store/canvasSlice'; +import { loraAllDeleted, loraRecalled } from 'features/controlLayers/store/lorasSlice'; +import { paramsRecalled } from 'features/controlLayers/store/paramsSlice'; +import { refImagesRecalled } from 'features/controlLayers/store/refImagesSlice'; +import type { LoRA, ParamsState, RefImageState } from 'features/controlLayers/store/types'; +import type { CanvasProjectState } from 'features/controlLayers/util/canvasProjectFile'; +import { + checkExistingImages, + collectImageNames, + parseManifest, + processWithConcurrencyLimit, + remapCanvasState, + remapRefImages, +} from 'features/controlLayers/util/canvasProjectFile'; +import { toast } from 'features/toast/toast'; +import JSZip from 'jszip'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { uploadImage } from 'services/api/endpoints/images'; + +const log = logger('canvas'); + +export const useCanvasProjectLoad = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const loadCanvasProject = useCallback( + async (file: File) => { + try { + const zip = await JSZip.loadAsync(file); + + // Validate manifest + const manifestFile = zip.file('manifest.json'); + if (!manifestFile) { + throw new Error('Invalid project file: missing manifest.json'); + } + const manifestData = JSON.parse(await manifestFile.async('string')); + parseManifest(manifestData); + + // Read state files + const canvasStateFile = zip.file('canvas_state.json'); + if (!canvasStateFile) { + throw new Error('Invalid project file: missing canvas_state.json'); + } + const canvasState: CanvasProjectState = JSON.parse(await canvasStateFile.async('string')); + + const paramsFile = zip.file('params.json'); + let projectParams: ParamsState | null = null; + if (paramsFile) { + projectParams = JSON.parse(await paramsFile.async('string')); + } + + const refImagesFile = zip.file('ref_images.json'); + let refImages: RefImageState[] = []; + if (refImagesFile) { + refImages = JSON.parse(await refImagesFile.async('string')); + } + + const lorasFile = zip.file('loras.json'); + let loras: LoRA[] = []; + if (lorasFile) { + loras = JSON.parse(await lorasFile.async('string')); + } + + // Collect all image names referenced in the state + const imageNames = collectImageNames(canvasState, refImages); + + // Check which images already exist on the server + const { missing } = await checkExistingImages(imageNames); + + // Upload missing images from the ZIP + const imageNameMapping = new Map(); + const imagesFolder = zip.folder('images'); + + if (imagesFolder && missing.size > 0) { + await processWithConcurrencyLimit(Array.from(missing), async (imageName) => { + const imageFile = imagesFolder.file(imageName); + if (!imageFile) { + log.warn(`Image ${imageName} referenced but not found in ZIP`); + return; + } + + try { + const blob = await imageFile.async('blob'); + const uploadFile = new File([blob], imageName, { type: 'image/png' }); + const imageDTO = await uploadImage({ + file: uploadFile, + image_category: 'general', + is_intermediate: false, + silent: true, + }); + + // Map old name to new name (only if different) + if (imageDTO.image_name !== imageName) { + imageNameMapping.set(imageName, imageDTO.image_name); + } + } catch (error) { + log.warn({ error: parseify(error) }, `Failed to upload image ${imageName}`); + } + }); + } + + // Remap image names in state objects + const remappedCanvasState = remapCanvasState(canvasState, imageNameMapping); + const remappedRefImages = remapRefImages(refImages, imageNameMapping); + + // Dispatch state restoration + dispatch( + canvasProjectRecalled({ + rasterLayers: remappedCanvasState.rasterLayers, + controlLayers: remappedCanvasState.controlLayers, + inpaintMasks: remappedCanvasState.inpaintMasks, + regionalGuidance: remappedCanvasState.regionalGuidance, + bbox: remappedCanvasState.bbox, + selectedEntityIdentifier: remappedCanvasState.selectedEntityIdentifier, + bookmarkedEntityIdentifier: remappedCanvasState.bookmarkedEntityIdentifier, + }) + ); + + // Restore reference images + dispatch(refImagesRecalled({ entities: remappedRefImages, replace: true })); + + // Restore generation parameters + if (projectParams) { + dispatch(paramsRecalled(projectParams)); + } + + // Restore LoRAs (always clear, even if project has none) + dispatch(loraAllDeleted()); + for (const lora of loras) { + dispatch(loraRecalled({ lora })); + } + + toast({ + id: 'CANVAS_PROJECT_LOAD_SUCCESS', + title: t('controlLayers.canvasProject.loadSuccess'), + description: t('controlLayers.canvasProject.loadSuccessDesc'), + status: 'success', + }); + } catch (error) { + log.error({ error: parseify(error) }, 'Failed to load canvas project'); + toast({ + id: 'CANVAS_PROJECT_LOAD_ERROR', + title: t('controlLayers.canvasProject.loadError'), + description: String(error), + status: 'error', + }); + } + }, + [dispatch, t] + ); + + return { loadCanvasProject }; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectSave.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectSave.ts new file mode 100644 index 00000000000..76a91a2efad --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectSave.ts @@ -0,0 +1,116 @@ +import { logger } from 'app/logging/logger'; +import { useAppStore } from 'app/store/storeHooks'; +import { parseify } from 'common/util/serialize'; +import { downloadBlob } from 'features/controlLayers/konva/util'; +import { selectLoRAsSlice } from 'features/controlLayers/store/lorasSlice'; +import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { CanvasProjectManifest, CanvasProjectState } from 'features/controlLayers/util/canvasProjectFile'; +import { + CANVAS_PROJECT_EXTENSION, + CANVAS_PROJECT_VERSION, + collectImageNames, + processWithConcurrencyLimit, +} from 'features/controlLayers/util/canvasProjectFile'; +import { toast } from 'features/toast/toast'; +import JSZip from 'jszip'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGetAppVersionQuery } from 'services/api/endpoints/appInfo'; + +const log = logger('canvas'); + +const sanitizeFileName = (name: string): string => { + // Replace characters that are invalid in filenames + return name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'canvas-project'; +}; + +export const useCanvasProjectSave = () => { + const { t } = useTranslation(); + const store = useAppStore(); + const { data: appVersion } = useGetAppVersionQuery(); + + const saveCanvasProject = useCallback( + async (name: string) => { + try { + const state = store.getState(); + const canvasState = selectCanvasSlice(state); + const paramsState = selectParamsSlice(state); + const refImagesState = selectRefImagesSlice(state); + const lorasState = selectLoRAsSlice(state); + + // Build the canvas project state + const projectState: CanvasProjectState = { + rasterLayers: canvasState.rasterLayers.entities, + controlLayers: canvasState.controlLayers.entities, + inpaintMasks: canvasState.inpaintMasks.entities, + regionalGuidance: canvasState.regionalGuidance.entities, + bbox: canvasState.bbox, + selectedEntityIdentifier: canvasState.selectedEntityIdentifier, + bookmarkedEntityIdentifier: canvasState.bookmarkedEntityIdentifier, + }; + + // Collect all image names referenced in the state + const imageNames = collectImageNames(projectState, refImagesState.entities); + + // Build ZIP + const zip = new JSZip(); + + // Add manifest + const manifest: CanvasProjectManifest = { + version: CANVAS_PROJECT_VERSION, + appVersion: appVersion?.version ?? 'unknown', + createdAt: new Date().toISOString(), + name, + }; + zip.file('manifest.json', JSON.stringify(manifest, null, 2)); + + // Add state files + zip.file('canvas_state.json', JSON.stringify(projectState, null, 2)); + zip.file('params.json', JSON.stringify(paramsState, null, 2)); + zip.file('ref_images.json', JSON.stringify(refImagesState.entities, null, 2)); + zip.file('loras.json', JSON.stringify(lorasState.loras, null, 2)); + + // Fetch and add images + const imagesFolder = zip.folder('images')!; + await processWithConcurrencyLimit(Array.from(imageNames), async (imageName) => { + try { + const response = await fetch(`/api/v1/images/i/${imageName}/full`); + if (!response.ok) { + log.warn(`Failed to fetch image ${imageName}: ${response.status}`); + return; + } + const blob = await response.blob(); + imagesFolder.file(imageName, blob); + } catch (error) { + log.warn({ error: parseify(error) }, `Failed to fetch image ${imageName}`); + } + }); + + // Generate ZIP blob and trigger download + const blob = await zip.generateAsync({ type: 'blob' }); + const fileName = `${sanitizeFileName(name)}${CANVAS_PROJECT_EXTENSION}`; + downloadBlob(blob, fileName); + + toast({ + id: 'CANVAS_PROJECT_SAVE_SUCCESS', + title: t('controlLayers.canvasProject.saveSuccess'), + description: t('controlLayers.canvasProject.saveSuccessDesc', { count: imageNames.size }), + status: 'success', + }); + } catch (error) { + log.error({ error: parseify(error) }, 'Failed to save canvas project'); + toast({ + id: 'CANVAS_PROJECT_SAVE_ERROR', + title: t('controlLayers.canvasProject.saveError'), + description: String(error), + status: 'error', + }); + } + }, + [appVersion?.version, store, t] + ); + + return { saveCanvasProject }; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 79d3963d122..997a7d47d0e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -1710,6 +1710,36 @@ const slice = createSlice({ state.regionalGuidance.entities = regionalGuidance; return state; }, + canvasProjectRecalled: ( + state, + action: PayloadAction<{ + rasterLayers: CanvasRasterLayerState[]; + controlLayers: CanvasControlLayerState[]; + inpaintMasks: CanvasInpaintMaskState[]; + regionalGuidance: CanvasRegionalGuidanceState[]; + bbox: CanvasState['bbox']; + selectedEntityIdentifier: CanvasState['selectedEntityIdentifier']; + bookmarkedEntityIdentifier: CanvasState['bookmarkedEntityIdentifier']; + }> + ) => { + const { + rasterLayers, + controlLayers, + inpaintMasks, + regionalGuidance, + bbox, + selectedEntityIdentifier, + bookmarkedEntityIdentifier, + } = action.payload; + state.rasterLayers.entities = rasterLayers; + state.controlLayers.entities = controlLayers; + state.inpaintMasks.entities = inpaintMasks; + state.regionalGuidance.entities = regionalGuidance; + state.bbox = bbox; + state.selectedEntityIdentifier = selectedEntityIdentifier; + state.bookmarkedEntityIdentifier = bookmarkedEntityIdentifier; + return state; + }, canvasUndo: () => {}, canvasRedo: () => {}, canvasClearHistory: () => {}, @@ -1768,6 +1798,7 @@ const resetState = (state: CanvasState) => { export const { canvasMetadataRecalled, + canvasProjectRecalled, canvasUndo, canvasRedo, canvasClearHistory, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 8dcd93cc5de..88f77ed24f7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -428,6 +428,9 @@ const slice = createSlice({ } }, paramsReset: (state) => resetState(state), + paramsRecalled: (_state, action: PayloadAction) => { + return action.payload; + }, }, extraReducers(builder) { // Reset params state on logout to prevent user data leakage when switching users @@ -556,6 +559,7 @@ export const { syncedToOptimalDimension, paramsReset, + paramsRecalled, } = slice.actions; export const paramsSliceConfig: SliceConfig = { diff --git a/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.ts b/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.ts new file mode 100644 index 00000000000..97ea31e8bb6 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.ts @@ -0,0 +1,287 @@ +import { deepClone } from 'common/util/deepClone'; +import type { + CanvasControlLayerState, + CanvasInpaintMaskState, + CanvasObjectState, + CanvasRasterLayerState, + CanvasRegionalGuidanceState, + CanvasState, + CroppableImageWithDims, + ImageWithDims, + RefImageState, +} from 'features/controlLayers/store/types'; +import { getImageDTOSafe } from 'services/api/endpoints/images'; +import { z } from 'zod'; + +export const CANVAS_PROJECT_VERSION = 1; +export const CANVAS_PROJECT_EXTENSION = '.invk'; + +// #region Manifest + +const zCanvasProjectManifest = z.object({ + version: z.literal(CANVAS_PROJECT_VERSION), + appVersion: z.string(), + createdAt: z.string(), + name: z.string(), +}); +export type CanvasProjectManifest = z.infer; + +export const parseManifest = (data: unknown): CanvasProjectManifest => { + return zCanvasProjectManifest.parse(data); +}; + +// #endregion + +// #region Canvas Project State + +export type CanvasProjectState = { + rasterLayers: CanvasRasterLayerState[]; + controlLayers: CanvasControlLayerState[]; + inpaintMasks: CanvasInpaintMaskState[]; + regionalGuidance: CanvasRegionalGuidanceState[]; + bbox: CanvasState['bbox']; + selectedEntityIdentifier: CanvasState['selectedEntityIdentifier']; + bookmarkedEntityIdentifier: CanvasState['bookmarkedEntityIdentifier']; +}; + +// #endregion + +// #region Image Name Collection + +/** + * Collects image_name values from a CroppableImageWithDims (used by ref images). + */ +const collectFromCroppableImage = (image: CroppableImageWithDims | null, names: Set): void => { + if (!image) { + return; + } + names.add(image.original.image.image_name); + if (image.crop?.image) { + names.add(image.crop.image.image_name); + } +}; + +/** + * Collects image_name values from an ImageWithDims (used by regional guidance ref images). + */ +const collectFromImageWithDims = (image: ImageWithDims | null, names: Set): void => { + if (!image) { + return; + } + names.add(image.image_name); +}; + +/** + * Collects image_name values from canvas objects (brush lines, images, etc.). + */ +const collectFromObjects = (objects: CanvasObjectState[], names: Set): void => { + for (const obj of objects) { + if (obj.type === 'image' && 'image_name' in obj.image) { + names.add(obj.image.image_name); + } + } +}; + +/** + * Walks the entire canvas state + ref images and returns a deduplicated set of all image_name references. + */ +export const collectImageNames = (canvasState: CanvasProjectState, refImages: RefImageState[]): Set => { + const names = new Set(); + + // Raster layers + for (const layer of canvasState.rasterLayers) { + collectFromObjects(layer.objects, names); + } + + // Control layers + for (const layer of canvasState.controlLayers) { + collectFromObjects(layer.objects, names); + } + + // Inpaint masks + for (const mask of canvasState.inpaintMasks) { + collectFromObjects(mask.objects, names); + } + + // Regional guidance + for (const rg of canvasState.regionalGuidance) { + collectFromObjects(rg.objects, names); + for (const refImage of rg.referenceImages) { + if (refImage.config.type === 'ip_adapter' || refImage.config.type === 'flux_redux') { + collectFromImageWithDims(refImage.config.image, names); + } + } + } + + // Global reference images + for (const refImage of refImages) { + collectFromCroppableImage(refImage.config.image, names); + } + + return names; +}; + +// #endregion + +// #region Image Name Remapping + +/** + * Remaps image_name values in a CroppableImageWithDims. + */ +/** + * Remaps image_name values in a CroppableImageWithDims in-place. + * Caller is responsible for cloning beforehand. + */ +const remapCroppableImage = (image: CroppableImageWithDims | null, mapping: Map): void => { + if (!image) { + return; + } + + const newOriginalName = mapping.get(image.original.image.image_name); + if (newOriginalName) { + image.original.image.image_name = newOriginalName; + } + + if (image.crop?.image) { + const newCropName = mapping.get(image.crop.image.image_name); + if (newCropName) { + image.crop.image.image_name = newCropName; + } + } +}; + +/** + * Remaps image_name in an ImageWithDims. + */ +const remapImageWithDims = (image: ImageWithDims | null, mapping: Map): ImageWithDims | null => { + if (!image) { + return null; + } + + const result = deepClone(image); + const newName = mapping.get(result.image_name); + if (newName) { + result.image_name = newName; + } + return result; +}; + +/** + * Remaps image_name values in canvas objects. + */ +const remapObjects = (objects: CanvasObjectState[], mapping: Map): CanvasObjectState[] => { + return objects.map((obj) => { + if (obj.type === 'image' && 'image_name' in obj.image) { + const newName = mapping.get(obj.image.image_name); + if (newName) { + return { ...obj, image: { ...obj.image, image_name: newName } }; + } + } + return obj; + }); +}; + +/** + * Deep-clones canvas state and remaps all image_name values using the provided mapping. + * Only images present in the mapping are changed (images that already existed on the server are skipped). + */ +export const remapCanvasState = (canvasState: CanvasProjectState, mapping: Map): CanvasProjectState => { + if (mapping.size === 0) { + return canvasState; + } + + const result = deepClone(canvasState); + + for (const layer of result.rasterLayers) { + layer.objects = remapObjects(layer.objects, mapping); + } + + for (const layer of result.controlLayers) { + layer.objects = remapObjects(layer.objects, mapping); + } + + for (const mask of result.inpaintMasks) { + mask.objects = remapObjects(mask.objects, mapping); + } + + for (const rg of result.regionalGuidance) { + rg.objects = remapObjects(rg.objects, mapping); + for (const refImage of rg.referenceImages) { + if (refImage.config.type === 'ip_adapter' || refImage.config.type === 'flux_redux') { + refImage.config.image = remapImageWithDims(refImage.config.image, mapping); + } + } + } + + return result; +}; + +/** + * Deep-clones ref images and remaps all image_name values using the provided mapping. + */ +export const remapRefImages = (refImages: RefImageState[], mapping: Map): RefImageState[] => { + if (mapping.size === 0) { + return refImages; + } + + return refImages.map((refImage) => { + const result = deepClone(refImage); + remapCroppableImage(result.config.image, mapping); + return result; + }); +}; + +// #endregion + +// #region Concurrency + +const MAX_CONCURRENT_REQUESTS = 5; + +/** + * Processes an array of async tasks with a concurrency limit. + */ +export const processWithConcurrencyLimit = async ( + items: T[], + fn: (item: T) => Promise, + limit: number = MAX_CONCURRENT_REQUESTS +): Promise => { + let index = 0; + + const next = async (): Promise => { + while (index < items.length) { + const currentIndex = index++; + await fn(items[currentIndex]!); + } + }; + + const workers = Array.from({ length: Math.min(limit, items.length) }, () => next()); + await Promise.all(workers); +}; + +// #endregion + +// #region Image Existence Check + +/** + * Checks which images already exist on the backend server. + * Returns sets of existing and missing image names. + */ +export const checkExistingImages = async ( + imageNames: Set +): Promise<{ existing: Set; missing: Set }> => { + const existing = new Set(); + const missing = new Set(); + + await processWithConcurrencyLimit(Array.from(imageNames), async (imageName) => { + const dto = await getImageDTOSafe(imageName); + if (dto) { + existing.add(imageName); + } else { + missing.add(imageName); + } + }); + + return { existing, missing }; +}; + +// #endregion