This commit is contained in:
Kent Keirsey
2025-06-29 21:44:31 -04:00
committed by psychedelicious
parent e4aae1a591
commit 11fe3b6332
5 changed files with 72 additions and 175 deletions

View File

@@ -1350,6 +1350,8 @@
"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",
"noVisibleRasterLayers": "No Visible Raster Layers",
"noVisibleRasterLayersDesc": "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",

View File

@@ -1,8 +1,6 @@
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';
@@ -10,19 +8,12 @@ import { PiFileArrowDownBold } from 'react-icons/pi';
export const RasterLayerExportPSDButton = 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}

View File

@@ -1,8 +1,7 @@
import { writePsd } from 'ag-psd';
import { logger } from 'app/logging/logger';
import { useAppSelector } from 'app/store/storeHooks';
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectRasterLayerEntities } from 'features/controlLayers/store/selectors';
import { downloadBlob } from 'features/controlLayers/konva/util';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,7 +11,6 @@ const log = logger('canvas');
export const useExportCanvasToPSD = () => {
const { t } = useTranslation();
const rasterLayers = useAppSelector(selectRasterLayerEntities);
const canvasManager = useCanvasManagerSafe();
const exportCanvasToPSD = useCallback(async () => {
@@ -26,123 +24,26 @@ export const useExportCanvasToPSD = () => {
return;
}
if (rasterLayers.length === 0) {
// Get visible raster layer adapters using the compositor module
const adapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
if (adapters.length === 0) {
toast({
id: 'NO_RASTER_LAYERS',
title: t('toast.noRasterLayers'),
description: t('toast.noRasterLayersDesc'),
id: 'NO_VISIBLE_RASTER_LAYERS',
title: t('toast.noVisibleRasterLayers'),
description: t('toast.noVisibleRasterLayersDesc'),
status: 'warning',
});
return;
}
// Get active (enabled) raster layers only
const activeLayers = rasterLayers.filter((layer) => layer.isEnabled);
log.debug(`Exporting ${adapters.length} visible raster layers to PSD`);
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`);
// Ensure all rect calculations are up to date before proceeding
const rectCalculationPromises = activeLayers.map(async (layer) => {
const adapter = canvasManager.getAdapter({ id: layer.id, type: 'raster_layer' });
if (adapter && adapter.type === 'raster_layer_adapter') {
await adapter.transformer.requestRectCalculation();
}
});
// Wait for all rect calculations to complete with a timeout
try {
await Promise.race([
Promise.all(rectCalculationPromises),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Rect calculation timeout')), 5000);
}),
]);
} catch (error) {
log.warn({ error: serializeError(error as Error) }, 'Rect calculation timeout or error, proceeding anyway');
}
// 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' });
log.debug(`Layer "${layer.name}": adapter found = ${!!adapter}, type = ${adapter?.type}`);
if (adapter && adapter.type === 'raster_layer_adapter') {
// Get the actual pixel bounds of the layer content from the transformer
const pixelRect = adapter.transformer.$pixelRect.get();
const layerPosition = adapter.state.position;
log.debug(
`Layer "${layer.name}": pixelRect=${JSON.stringify(pixelRect)}, position=${JSON.stringify(layerPosition)}`
);
// Alternative approach: use full canvas and adjust positioning
const canvas = adapter.getCanvas();
const left = layerPosition.x;
const top = layerPosition.y;
const right = left + canvas.width;
const bottom = top + canvas.height;
log.debug(
`Layer "${layer.name}": canvas size = ${canvas.width}x${canvas.height}, position = ${left},${top}`
);
// Skip layers with invalid canvas dimensions
if (canvas.width === 0 || canvas.height === 0) {
log.debug(`Layer "${layer.name}": skipping due to invalid canvas dimensions`);
return null;
}
minLeft = Math.min(minLeft, left);
minTop = Math.min(minTop, top);
maxRight = Math.max(maxRight, right);
maxBottom = Math.max(maxBottom, bottom);
// Temporarily remove the empty bounds filter to see what's happening
// if (pixelRect.width === 0 || pixelRect.height === 0) {
// log.debug(`Layer "${layer.name}": skipping due to empty bounds`);
// return null;
// }
return { adapter, canvas, rect: { x: left, y: top, width: canvas.width, height: canvas.height }, layer };
}
return null;
})
.filter((item): item is NonNullable<typeof item> => item !== null);
log.debug(`Found ${layerAdapters.length} valid layer adapters out of ${activeLayers.length} active layers`);
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;
// Get the union rect of all adapters using the compositor module
const visibleRect = canvasManager.compositor.getRectOfAdapters(adapters);
// Validate canvas dimensions
if (canvasWidth <= 0 || canvasHeight <= 0) {
if (visibleRect.width <= 0 || visibleRect.height <= 0) {
toast({
id: 'INVALID_CANVAS_DIMENSIONS',
title: t('toast.invalidCanvasDimensions'),
@@ -151,7 +52,7 @@ export const useExportCanvasToPSD = () => {
return;
}
if (canvasWidth > 30000 || canvasHeight > 30000) {
if (visibleRect.width > 30000 || visibleRect.height > 30000) {
toast({
id: 'CANVAS_TOO_LARGE',
title: t('toast.canvasTooLarge'),
@@ -161,65 +62,50 @@ export const useExportCanvasToPSD = () => {
return;
}
log.debug(`PSD canvas dimensions: ${canvasWidth}x${canvasHeight}`);
log.debug(`PSD canvas dimensions: ${visibleRect.width}x${visibleRect.height}`);
// Create PSD layers from active raster layers
// Create PSD layers from visible raster layer adapters
const psdLayers = await Promise.all(
layerAdapters.map((layerData, index) => {
try {
const { adapter: _adapter, canvas, rect, layer } = layerData;
adapters.map((adapter, index) => {
const layer = adapter.state;
const canvas = adapter.getCanvas();
const layerPosition = adapter.state.position;
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,
};
const layerDataPSD = {
name: layer.name || `Layer ${index + 1}`,
left: Math.floor(layerPosition.x - visibleRect.x),
top: Math.floor(layerPosition.y - visibleRect.y),
right: Math.floor(layerPosition.x - visibleRect.x + canvas.width),
bottom: Math.floor(layerPosition.y - visibleRect.y + canvas.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}`
);
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;
}
return layerDataPSD;
})
);
// 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,
width: visibleRect.width,
height: visibleRect.height,
channels: 3, // RGB
bitsPerChannel: 8,
colorMode: 3, // RGB mode
children: validLayers,
children: psdLayers,
};
log.debug(
{
layerCount: validLayers.length,
canvasDimensions: { width: canvasWidth, height: canvasHeight },
layers: validLayers.map((l) => ({
layerCount: psdLayers.length,
canvasDimensions: { width: visibleRect.width, height: visibleRect.height },
layers: psdLayers.map((l) => ({
name: l.name,
bounds: { left: l.left, top: l.top, right: l.right, bottom: l.bottom },
})),
@@ -230,22 +116,15 @@ export const useExportCanvasToPSD = () => {
// Generate PSD file
const buffer = writePsd(psd);
// Create blob and download
// Create blob and download using the utility function
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);
const fileName = `canvas-layers-${new Date().toISOString().slice(0, 10)}.psd`;
downloadBlob(blob, fileName);
toast({
id: 'PSD_EXPORT_SUCCESS',
title: t('toast.psdExportSuccess'),
description: t('toast.psdExportSuccessDesc', { count: validLayers.length }),
description: t('toast.psdExportSuccessDesc', { count: psdLayers.length }),
status: 'success',
});
@@ -259,7 +138,7 @@ export const useExportCanvasToPSD = () => {
status: 'error',
});
}
}, [rasterLayers, canvasManager, t]);
}, [canvasManager, t]);
return { exportCanvasToPSD };
};

View File

@@ -321,6 +321,7 @@ export const downloadBlob = (blob: Blob, fileName: string) => {
a.click();
document.body.removeChild(a);
a.remove();
URL.revokeObjectURL(url);
};
/**

View File

@@ -0,0 +1,24 @@
declare module 'ag-psd' {
export interface PsdLayer {
name: string;
left: number;
top: number;
right: number;
bottom: number;
opacity: number;
hidden: boolean;
blendMode: 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'colorDodge' | 'colorBurn' | 'hardLight' | 'softLight' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity';
canvas: HTMLCanvasElement;
}
export interface PsdDocument {
width: number;
height: number;
channels: number;
bitsPerChannel: number;
colorMode: number;
children: PsdLayer[];
}
export function writePsd(psd: PsdDocument): ArrayBuffer;
}