mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-15 06:18:03 -05:00
Add PSD export functionality for canvas layers
Co-authored-by: kent <kent@invoke.ai>
This commit is contained in:
committed by
psychedelicious
parent
8417ee8a7b
commit
91afe7884a
@@ -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",
|
||||
|
||||
15
invokeai/frontend/web/pnpm-lock.yaml
generated
15
invokeai/frontend/web/pnpm-lock.yaml
generated
@@ -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'}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user