diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index 621308b39a..cd363befa6 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -64,6 +64,7 @@
"@reduxjs/toolkit": "2.8.2",
"@roarr/browser-log-writer": "^1.3.0",
"@xyflow/react": "^12.7.1",
+ "ag-psd": "^28.2.1",
"async-mutex": "^0.5.0",
"chakra-react-select": "^4.9.2",
"cmdk": "^1.1.1",
diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml
index 4ec994ff3f..9eda60fb94 100644
--- a/invokeai/frontend/web/pnpm-lock.yaml
+++ b/invokeai/frontend/web/pnpm-lock.yaml
@@ -41,6 +41,9 @@ dependencies:
'@xyflow/react':
specifier: ^12.7.1
version: 12.7.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
+ ag-psd:
+ specifier: ^28.2.1
+ version: 28.2.1
async-mutex:
specifier: ^0.5.0
version: 0.5.0
@@ -3655,6 +3658,13 @@ packages:
hasBin: true
dev: true
+ /ag-psd@28.2.1:
+ resolution: {integrity: sha512-dso+nogIiERMsukqwxDE6AXezYK7+t5CKhc4JG5S9neB8JhKMIIie5RdJwW0aaoNf03/wKD2kdk43TBDJskhxQ==}
+ dependencies:
+ base64-js: 1.5.1
+ pako: 2.1.0
+ dev: false
+
/agent-base@7.1.3:
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
engines: {node: '>= 14'}
@@ -3919,7 +3929,6 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- dev: true
/better-opn@3.0.2:
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
@@ -6666,6 +6675,10 @@ packages:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
dev: true
+ /pako@2.1.0:
+ resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+ dev: false
+
/parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 1f71e39b9e..13e14c0108 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1346,6 +1346,16 @@
"problemCopyingLayer": "Unable to Copy Layer",
"problemSavingLayer": "Unable to Save Layer",
"problemDownloadingImage": "Unable to Download Image",
+ "noRasterLayers": "No Raster Layers Found",
+ "noRasterLayersDesc": "Create at least one raster layer to export to PSD",
+ "noActiveRasterLayers": "No Active Raster Layers",
+ "noActiveRasterLayersDesc": "Enable at least one raster layer to export to PSD",
+ "failedToProcessLayers": "Failed to Process Layers",
+ "psdExportSuccess": "PSD Export Complete",
+ "psdExportSuccessDesc": "Successfully exported {{count}} layers to PSD file",
+ "problemExportingPSD": "Problem Exporting PSD",
+ "canvasManagerNotAvailable": "Canvas Manager Not Available",
+ "noValidLayerAdapters": "No Valid Layer Adapters Found",
"pasteSuccess": "Pasted to {{destination}}",
"pasteFailed": "Paste Failed",
"prunedQueue": "Pruned Queue",
@@ -1897,6 +1907,7 @@
"saveCanvasToGallery": "Save Canvas to Gallery",
"saveBboxToGallery": "Save Bbox to Gallery",
"saveLayerToAssets": "Save Layer to Assets",
+ "exportCanvasToPSD": "Export Canvas to PSD",
"cropLayerToBbox": "Crop Layer to Bbox",
"savedToGalleryOk": "Saved to Gallery",
"savedToGalleryError": "Error saving to gallery",
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx
index 629c445082..84636c4b1b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx
@@ -1,6 +1,7 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { EntityListGlobalActionBarAddLayerMenu } from 'features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu';
import { EntityListSelectedEntityActionBarDuplicateButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton';
+import { EntityListSelectedEntityActionBarExportPSDButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarExportPSDButton';
import { EntityListSelectedEntityActionBarFill } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFill';
import { EntityListSelectedEntityActionBarFilterButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton';
import { EntityListSelectedEntityActionBarOpacity } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity';
@@ -22,6 +23,7 @@ export const EntityListSelectedEntityActionBar = memo(() => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarExportPSDButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarExportPSDButton.tsx
new file mode 100644
index 0000000000..25616e78a1
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarExportPSDButton.tsx
@@ -0,0 +1,40 @@
+import { IconButton } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { useExportCanvasToPSD } from 'features/controlLayers/hooks/useExportCanvasToPSD';
+import { selectRasterLayerEntities } from 'features/controlLayers/store/selectors';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFileArrowDownBold } from 'react-icons/pi';
+
+export const EntityListSelectedEntityActionBarExportPSDButton = memo(() => {
+ const { t } = useTranslation();
+ const isBusy = useCanvasIsBusy();
+ const rasterLayers = useAppSelector(selectRasterLayerEntities);
+ const { exportCanvasToPSD } = useExportCanvasToPSD();
+
+ const onClick = useCallback(() => {
+ exportCanvasToPSD();
+ }, [exportCanvasToPSD]);
+
+ const hasActiveLayers = rasterLayers.some((layer) => layer.isEnabled);
+
+ if (!hasActiveLayers) {
+ return null;
+ }
+
+ return (
+ }
+ />
+ );
+});
+
+EntityListSelectedEntityActionBarExportPSDButton.displayName = 'EntityListSelectedEntityActionBarExportPSDButton';
\ No newline at end of file
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useExportCanvasToPSD.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useExportCanvasToPSD.ts
new file mode 100644
index 0000000000..87d014b711
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useExportCanvasToPSD.ts
@@ -0,0 +1,185 @@
+import { logger } from 'app/logging/logger';
+import { useAppSelector } from 'app/store/storeHooks';
+import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
+import { canvasToBlob } from 'features/controlLayers/konva/util';
+import { selectRasterLayerEntities } from 'features/controlLayers/store/selectors';
+import { toast } from 'features/toast/toast';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { serializeError } from 'serialize-error';
+import { writePsd } from 'ag-psd';
+
+const log = logger('canvas');
+
+export const useExportCanvasToPSD = () => {
+ const { t } = useTranslation();
+ const rasterLayers = useAppSelector(selectRasterLayerEntities);
+ const canvasManager = useCanvasManagerSafe();
+
+ const exportCanvasToPSD = useCallback(async () => {
+ try {
+ if (!canvasManager) {
+ toast({
+ id: 'CANVAS_MANAGER_NOT_AVAILABLE',
+ title: t('toast.canvasManagerNotAvailable'),
+ status: 'error',
+ });
+ return;
+ }
+
+ if (rasterLayers.length === 0) {
+ toast({
+ id: 'NO_RASTER_LAYERS',
+ title: t('toast.noRasterLayers'),
+ description: t('toast.noRasterLayersDesc'),
+ status: 'warning',
+ });
+ return;
+ }
+
+ // Get active (enabled) raster layers only
+ const activeLayers = rasterLayers.filter((layer) => layer.isEnabled);
+
+ if (activeLayers.length === 0) {
+ toast({
+ id: 'NO_ACTIVE_RASTER_LAYERS',
+ title: t('toast.noActiveRasterLayers'),
+ description: t('toast.noActiveRasterLayersDesc'),
+ status: 'warning',
+ });
+ return;
+ }
+
+ log.debug(`Exporting ${activeLayers.length} active raster layers to PSD`);
+
+ // Find canvas dimensions by getting the maximum bounds of all layers
+ let maxRight = 0;
+ let maxBottom = 0;
+ let minLeft = Infinity;
+ let minTop = Infinity;
+
+ // Get layer adapters and calculate bounds
+ const layerAdapters = activeLayers.map((layer) => {
+ const adapter = canvasManager.getAdapter({ id: layer.id, type: 'raster_layer' });
+ if (adapter && adapter.type === 'raster_layer_adapter') {
+ const canvas = adapter.getCanvas();
+ const rect = adapter.transformer.$pixelRect.get();
+
+ const left = layer.position.x + rect.x;
+ const top = layer.position.y + rect.y;
+ const right = left + rect.width;
+ const bottom = top + rect.height;
+
+ minLeft = Math.min(minLeft, left);
+ minTop = Math.min(minTop, top);
+ maxRight = Math.max(maxRight, right);
+ maxBottom = Math.max(maxBottom, bottom);
+
+ return { adapter, canvas, rect: { x: left, y: top, width: rect.width, height: rect.height }, layer };
+ }
+ return null;
+ }).filter((item): item is NonNullable => item !== null);
+
+ if (layerAdapters.length === 0) {
+ toast({
+ id: 'NO_VALID_LAYER_ADAPTERS',
+ title: t('toast.noValidLayerAdapters'),
+ status: 'error',
+ });
+ return;
+ }
+
+ // Default canvas size if no valid bounds found
+ const canvasWidth = maxRight > minLeft ? Math.ceil(maxRight - minLeft) : 1024;
+ const canvasHeight = maxBottom > minTop ? Math.ceil(maxBottom - minTop) : 1024;
+
+ log.debug(`PSD canvas dimensions: ${canvasWidth}x${canvasHeight}`);
+
+ // Create PSD layers from active raster layers
+ const psdLayers = await Promise.all(
+ layerAdapters.map(async (layerData, index) => {
+ try {
+ const { adapter, canvas, rect, layer } = layerData;
+
+ const layerDataPSD = {
+ name: layer.name || `Layer ${index + 1}`,
+ left: Math.floor(rect.x - minLeft),
+ top: Math.floor(rect.y - minTop),
+ right: Math.floor(rect.x - minLeft + rect.width),
+ bottom: Math.floor(rect.y - minTop + rect.height),
+ opacity: Math.floor(layer.opacity * 255),
+ hidden: false,
+ blendMode: 'normal' as const,
+ canvas: canvas,
+ };
+
+ log.debug(`Layer "${layerDataPSD.name}": ${layerDataPSD.left},${layerDataPSD.top} to ${layerDataPSD.right},${layerDataPSD.bottom}`);
+
+ return layerDataPSD;
+ } catch (error) {
+ log.error({ error: serializeError(error as Error) }, `Error processing layer ${layerData.layer.name}`);
+ return null;
+ }
+ })
+ );
+
+ // Filter out any failed layers
+ const validLayers = psdLayers.filter((layer) => layer !== null);
+
+ if (validLayers.length === 0) {
+ toast({
+ id: 'FAILED_TO_PROCESS_LAYERS',
+ title: t('toast.failedToProcessLayers'),
+ status: 'error',
+ });
+ return;
+ }
+
+ // Create PSD document
+ const psd = {
+ width: canvasWidth,
+ height: canvasHeight,
+ channels: 3, // RGB
+ bitsPerChannel: 8,
+ colorMode: 3, // RGB mode
+ children: validLayers,
+ };
+
+ log.debug({ layerCount: validLayers.length }, 'Creating PSD with layers');
+
+ // Generate PSD file
+ const buffer = writePsd(psd);
+
+ // Create blob and download
+ const blob = new Blob([buffer], { type: 'application/octet-stream' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.style.display = 'none';
+ a.href = url;
+ a.download = `canvas-layers-${new Date().toISOString().slice(0, 10)}.psd`;
+ document.body.appendChild(a);
+ a.click();
+ URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast({
+ id: 'PSD_EXPORT_SUCCESS',
+ title: t('toast.psdExportSuccess'),
+ description: t('toast.psdExportSuccessDesc', { count: validLayers.length }),
+ status: 'success',
+ });
+
+ log.debug('Successfully exported canvas to PSD');
+ } catch (error) {
+ log.error({ error: serializeError(error as Error) }, 'Problem exporting canvas to PSD');
+ toast({
+ id: 'PROBLEM_EXPORTING_PSD',
+ title: t('toast.problemExportingPSD'),
+ description: String(error),
+ status: 'error',
+ });
+ }
+ }, [rasterLayers, canvasManager, t]);
+
+ return { exportCanvasToPSD };
+};
\ No newline at end of file