Add PSD export functionality for canvas layers

Co-authored-by: kent <kent@invoke.ai>
This commit is contained in:
Cursor Agent
2025-06-27 14:28:12 +00:00
committed by psychedelicious
parent 8417ee8a7b
commit 91afe7884a
6 changed files with 253 additions and 1 deletions

View File

@@ -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",

View File

@@ -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'}

View File

@@ -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",

View File

@@ -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(() => {
<EntityListSelectedEntityActionBarFilterButton />
<EntityListSelectedEntityActionBarTransformButton />
<EntityListSelectedEntityActionBarSaveToAssetsButton />
<EntityListSelectedEntityActionBarExportPSDButton />
<EntityListSelectedEntityActionBarDuplicateButton />
<EntityListNonRasterLayerToggle />
<EntityListGlobalActionBarAddLayerMenu />

View File

@@ -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 (
<IconButton
onClick={onClick}
isDisabled={isBusy}
minW={8}
variant="link"
alignSelf="stretch"
aria-label={t('controlLayers.exportCanvasToPSD')}
tooltip={t('controlLayers.exportCanvasToPSD')}
icon={<PiFileArrowDownBold />}
/>
);
});
EntityListSelectedEntityActionBarExportPSDButton.displayName = 'EntityListSelectedEntityActionBarExportPSDButton';

View File

@@ -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<typeof item> => 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 };
};