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