feat(canvas): build PSD export in the browser from canvas layers
Move PSD generation off the server. The image-generation API only produces raster formats, so drop the PSD request path, the oov/psd decoder, and the LayeredDocumentGenerator wiring. Layer separation now returns clean background, foreground, and text_render_data artifacts into a transparent frame. Export that frame (or any image node) as PSD from the canvas context menu: ag-psd assembles layers, rotation/flip, opacity, editable text, and stroke effects from the current canvas state at download time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.13",
|
||||
"@radix-ui/react-switch": "^1.3.2",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"ag-psd": "31.0.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.7",
|
||||
|
||||
Generated
+18
@@ -23,6 +23,9 @@ dependencies:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.16
|
||||
version: 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.7)(react@19.2.7)
|
||||
ag-psd:
|
||||
specifier: 31.0.2
|
||||
version: 31.0.2
|
||||
lucide-react:
|
||||
specifier: ^1.23.0
|
||||
version: 1.23.0(react@19.2.7)
|
||||
@@ -1246,6 +1249,13 @@ packages:
|
||||
vite: 8.1.3(@types/node@26.1.0)
|
||||
dev: true
|
||||
|
||||
/ag-psd@31.0.2:
|
||||
resolution: {integrity: sha512-5s5PvqbomPIIJ9YjL6robz55rT4RYPiQkww4brisyZEb5jzlHB1EKh47IJg7gIFj0RUrD0cMgdntM907qaZjXA==}
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
pako: 2.1.0
|
||||
dev: false
|
||||
|
||||
/aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1253,6 +1263,10 @@ packages:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
|
||||
/base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
dev: false
|
||||
|
||||
/baseline-browser-mapping@2.10.41:
|
||||
resolution: {integrity: sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -1482,6 +1496,10 @@ packages:
|
||||
- babel-plugin-macros
|
||||
dev: false
|
||||
|
||||
/pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
dev: false
|
||||
|
||||
/picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ export function CanvasNodeContextMenu({
|
||||
<ChevronDown className="context-menu-chevron" size={14} />
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="ui-context-menu-content image-export-submenu">
|
||||
{handlers.exportFormats.map((format) => (
|
||||
{handlers.exportFormats.filter((format) => format !== "psd").map((format) => (
|
||||
<ContextMenuItem key={format} onSelect={() => handlers.exportAs(format)}>
|
||||
<span>{format.toUpperCase()}</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { CanvasNode } from "@/domain/design";
|
||||
import type { Justification, Layer as PsdLayer, LayerTextData, Psd, RGB } from "ag-psd";
|
||||
import { imageAdjustmentImageStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments";
|
||||
import { isShapeNode, parseShapeOptions, shapeEndpointToWorldPoint } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
|
||||
import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor, normalizeStrokeStyle } from "./frameStyle";
|
||||
|
||||
export type ImageExportFormat = "png" | "jpg" | "svg" | "psd";
|
||||
export type CanvasNodeExportOptions = {
|
||||
nodes?: readonly CanvasNode[];
|
||||
hiddenNodeIds?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export function canvasNodeSvg(node: CanvasNode) {
|
||||
const width = Math.max(1, Math.round(node.width));
|
||||
@@ -11,24 +17,31 @@ export function canvasNodeSvg(node: CanvasNode) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${node.width} ${node.height}">${canvasNodeSvgFragment(node, bounds)}</svg>`;
|
||||
}
|
||||
|
||||
export function exportCanvasNode(node: CanvasNode, format: ImageExportFormat) {
|
||||
export function exportCanvasNode(node: CanvasNode, format: ImageExportFormat, options: CanvasNodeExportOptions = {}) {
|
||||
if (node.type === "text") {
|
||||
if (format === "psd") {
|
||||
void exportNodeAsPsd(node, options);
|
||||
return;
|
||||
}
|
||||
exportTextNode(node);
|
||||
return;
|
||||
}
|
||||
if (node.type === "frame") {
|
||||
exportFrameNode(node, format);
|
||||
exportFrameNode(node, format, options);
|
||||
return;
|
||||
}
|
||||
exportImageNode(node, format);
|
||||
exportImageNode(node, format, options);
|
||||
}
|
||||
|
||||
export function exportTextNode(node: CanvasNode) {
|
||||
downloadBlob(canvasNodeSvg(node), `${node.title || "text"}.svg`, "image/svg+xml");
|
||||
}
|
||||
|
||||
export function exportFrameNode(node: CanvasNode, format: ImageExportFormat) {
|
||||
if (format === "psd") return;
|
||||
export function exportFrameNode(node: CanvasNode, format: ImageExportFormat, options: CanvasNodeExportOptions = {}) {
|
||||
if (format === "psd") {
|
||||
void exportNodeAsPsd(node, options);
|
||||
return;
|
||||
}
|
||||
const width = Math.max(1, Math.round(node.width));
|
||||
const height = Math.max(1, Math.round(node.height));
|
||||
const svg = canvasNodeSvg(node);
|
||||
@@ -39,9 +52,12 @@ export function exportFrameNode(node: CanvasNode, format: ImageExportFormat) {
|
||||
downloadSvgAsRaster(svg, `${node.title || "frame"}.${format}`, format, width, height);
|
||||
}
|
||||
|
||||
export function exportImageNode(node: CanvasNode, format: ImageExportFormat) {
|
||||
export function exportImageNode(node: CanvasNode, format: ImageExportFormat, options: CanvasNodeExportOptions = {}) {
|
||||
if (!isImageUrl(node.content)) return;
|
||||
if (format === "psd") return;
|
||||
if (format === "psd") {
|
||||
void exportNodeAsPsd(node, options);
|
||||
return;
|
||||
}
|
||||
if (format === "svg") {
|
||||
void exportImageNodeAsSvg(node);
|
||||
return;
|
||||
@@ -49,6 +65,334 @@ export function exportImageNode(node: CanvasNode, format: ImageExportFormat) {
|
||||
void exportRasterImageNode(node, format);
|
||||
}
|
||||
|
||||
type PsdRenderedLayer = {
|
||||
canvas: HTMLCanvasElement;
|
||||
layer: PsdLayer;
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
async function exportNodeAsPsd(root: CanvasNode, options: CanvasNodeExportOptions) {
|
||||
try {
|
||||
const width = Math.max(1, Math.round(root.width));
|
||||
const height = Math.max(1, Math.round(root.height));
|
||||
if (width > 30_000 || height > 30_000 || width * height > 64_000_000) {
|
||||
throw new Error("Canvas is too large for browser PSD export");
|
||||
}
|
||||
|
||||
const nodes = psdExportNodes(root, options);
|
||||
const rendered = await Promise.all(nodes.map((node) => renderPsdLayer(node, root)));
|
||||
const composite = document.createElement("canvas");
|
||||
composite.width = width;
|
||||
composite.height = height;
|
||||
const context = composite.getContext("2d");
|
||||
if (!context) throw new Error("Failed to create PSD composite canvas");
|
||||
rendered.forEach((item) => context.drawImage(item.canvas, item.left, item.top));
|
||||
|
||||
const documentData: Psd = {
|
||||
width,
|
||||
height,
|
||||
canvas: composite,
|
||||
children: rendered
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item) => item.layer)
|
||||
};
|
||||
const { writePsd } = await import("ag-psd");
|
||||
const data = writePsd(documentData, { trimImageData: true, noBackground: true });
|
||||
downloadBlob(data, `${safeExportFileName(root.title || (root.type === "frame" ? "canvas" : "image"))}.psd`, "image/vnd.adobe.photoshop");
|
||||
} catch (error) {
|
||||
console.error("PSD export failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
function psdExportNodes(root: CanvasNode, options: CanvasNodeExportOptions) {
|
||||
if (root.type !== "frame" || root.layerRole !== "manual-frame") return [root];
|
||||
const allNodes = options.nodes ?? [];
|
||||
const descendantIds = new Set([root.id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
allNodes.forEach((node) => {
|
||||
if (!node.parentId || descendantIds.has(node.id) || !descendantIds.has(node.parentId)) return;
|
||||
descendantIds.add(node.id);
|
||||
changed = true;
|
||||
});
|
||||
}
|
||||
const children = allNodes.filter(
|
||||
(node) =>
|
||||
node.id !== root.id &&
|
||||
descendantIds.has(node.id) &&
|
||||
!options.hiddenNodeIds?.has(node.id) &&
|
||||
node.status !== "generating" &&
|
||||
node.status !== "error" &&
|
||||
(node.type !== "image" || isImageUrl(node.content))
|
||||
);
|
||||
return children.length > 0 ? children : [root];
|
||||
}
|
||||
|
||||
async function renderPsdLayer(node: CanvasNode, bounds: CanvasNode): Promise<PsdRenderedLayer> {
|
||||
const content = await renderPsdLayerContent(node);
|
||||
const rotation = ((node.rotation || 0) * Math.PI) / 180;
|
||||
const cosine = Math.cos(rotation);
|
||||
const sine = Math.sin(rotation);
|
||||
const rotatedWidth = Math.max(1, Math.ceil(Math.abs(content.width * cosine) + Math.abs(content.height * sine)));
|
||||
const rotatedHeight = Math.max(1, Math.ceil(Math.abs(content.width * sine) + Math.abs(content.height * cosine)));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = rotatedWidth;
|
||||
canvas.height = rotatedHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Failed to create PSD layer canvas");
|
||||
context.translate(rotatedWidth / 2, rotatedHeight / 2);
|
||||
context.rotate(rotation);
|
||||
context.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1);
|
||||
context.drawImage(content, -content.width / 2, -content.height / 2);
|
||||
|
||||
const centerX = node.x - bounds.x + node.width / 2;
|
||||
const centerY = node.y - bounds.y + node.height / 2;
|
||||
const left = Math.round(centerX - rotatedWidth / 2);
|
||||
const top = Math.round(centerY - rotatedHeight / 2);
|
||||
const layer: PsdLayer = {
|
||||
name: node.title.trim() || defaultPsdLayerName(node),
|
||||
canvas,
|
||||
left,
|
||||
top,
|
||||
opacity: node.opacity && node.opacity > 0 ? Math.min(node.opacity, 1) : 1,
|
||||
blendMode: "normal"
|
||||
};
|
||||
if (node.type === "text") {
|
||||
layer.text = psdTextLayerData(node, bounds);
|
||||
const strokeWidth = Math.max(0, node.strokeWidth || 0);
|
||||
if (strokeWidth > 0) {
|
||||
layer.effects = {
|
||||
stroke: [
|
||||
{
|
||||
enabled: true,
|
||||
present: true,
|
||||
showInDialog: true,
|
||||
size: { units: "Pixels", value: strokeWidth },
|
||||
position: "outside",
|
||||
fillType: "color",
|
||||
blendMode: "normal",
|
||||
opacity: 1,
|
||||
color: psdColor(node.strokeColor || "#000000")
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
return { canvas, layer, left, top };
|
||||
}
|
||||
|
||||
async function renderPsdLayerContent(node: CanvasNode) {
|
||||
const width = Math.max(1, Math.round(node.width));
|
||||
const height = Math.max(1, Math.round(node.height));
|
||||
if (node.type === "text") return renderPsdTextCanvas(node, width, height);
|
||||
if (node.type === "image" && isImageUrl(node.content)) return renderPsdImageCanvas(node, width, height);
|
||||
|
||||
const localNode: CanvasNode = { ...node, x: 0, y: 0, width, height, rotation: 0, flipX: false, flipY: false };
|
||||
const blob = await svgToRasterBlob(canvasNodeSvg(localNode), width, height, "image/png");
|
||||
return canvasFromImageBlob(blob, width, height);
|
||||
}
|
||||
|
||||
async function renderPsdImageCanvas(node: CanvasNode, width: number, height: number) {
|
||||
const blob = await imageBlobForExport(node.content.trim());
|
||||
const imageURL = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = await loadImage(imageURL);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Failed to create PSD image layer");
|
||||
const adjustments = parseImageAdjustments(node.imageAdjustments);
|
||||
if (!isDefaultImageAdjustments(adjustments)) {
|
||||
context.filter = String(imageAdjustmentImageStyle(adjustments).filter || "none");
|
||||
}
|
||||
const sourceWidth = Math.max(1, image.naturalWidth || image.width);
|
||||
const sourceHeight = Math.max(1, image.naturalHeight || image.height);
|
||||
const scale = Math.min(width / sourceWidth, height / sourceHeight);
|
||||
const targetWidth = sourceWidth * scale;
|
||||
const targetHeight = sourceHeight * scale;
|
||||
context.drawImage(image, (width - targetWidth) / 2, (height - targetHeight) / 2, targetWidth, targetHeight);
|
||||
return canvas;
|
||||
} finally {
|
||||
URL.revokeObjectURL(imageURL);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPsdTextCanvas(node: CanvasNode, width: number, height: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Failed to create PSD text layer");
|
||||
const fontSize = node.fontSize || Math.max(14, Math.min(height * 0.68, 128));
|
||||
const lineHeight = node.lineHeight && node.lineHeight > 0 ? (node.lineHeight <= 4 ? fontSize * node.lineHeight : node.lineHeight) : fontSize * 1.08;
|
||||
const letterSpacing = node.letterSpacing || 0;
|
||||
const fontWeight = textFontWeight(node.fontWeight);
|
||||
const fontStyle = textFontStyle(node.fontWeight);
|
||||
context.font = `${fontStyle} ${fontWeight} ${fontSize}px ${node.fontFamily || "Inter, Arial, sans-serif"}`;
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = normalizeHexColor(node.color || "#111827", "#111827");
|
||||
context.strokeStyle = normalizeHexColor(node.strokeColor || "#000000", "#000000");
|
||||
context.lineWidth = Math.max(0, node.strokeWidth || 0) * 2;
|
||||
context.lineJoin = "round";
|
||||
|
||||
const lines = wrapPsdText(context, transformedText(node), Math.max(1, width), letterSpacing);
|
||||
const startY = Math.max(0, (height - lines.length * lineHeight) / 2);
|
||||
lines.forEach((line, index) => {
|
||||
const lineWidth = measureSpacedText(context, line, letterSpacing);
|
||||
const x = node.textAlign === "left" ? 0 : node.textAlign === "right" ? width - lineWidth : (width - lineWidth) / 2;
|
||||
const y = startY + index * lineHeight;
|
||||
drawSpacedText(context, line, x, y, letterSpacing, context.lineWidth > 0);
|
||||
drawTextDecoration(context, node, x, y, lineWidth, fontSize);
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function wrapPsdText(context: CanvasRenderingContext2D, text: string, maxWidth: number, letterSpacing: number) {
|
||||
return text.split(/\r?\n/).flatMap((sourceLine) => {
|
||||
if (measureSpacedText(context, sourceLine, letterSpacing) <= maxWidth) return [sourceLine];
|
||||
const tokens = sourceLine.includes(" ") ? sourceLine.split(/(\s+)/) : Array.from(sourceLine);
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
tokens.forEach((token) => {
|
||||
const next = `${current}${token}`;
|
||||
if (current && measureSpacedText(context, next, letterSpacing) > maxWidth) {
|
||||
lines.push(current.trimEnd());
|
||||
current = token.trimStart();
|
||||
return;
|
||||
}
|
||||
current = next;
|
||||
});
|
||||
if (current || lines.length === 0) lines.push(current);
|
||||
return lines;
|
||||
});
|
||||
}
|
||||
|
||||
function measureSpacedText(context: CanvasRenderingContext2D, text: string, letterSpacing: number) {
|
||||
return context.measureText(text).width + Math.max(0, Array.from(text).length - 1) * letterSpacing;
|
||||
}
|
||||
|
||||
function drawSpacedText(context: CanvasRenderingContext2D, text: string, x: number, y: number, letterSpacing: number, stroke: boolean) {
|
||||
let cursor = x;
|
||||
Array.from(text).forEach((character) => {
|
||||
if (stroke) context.strokeText(character, cursor, y);
|
||||
context.fillText(character, cursor, y);
|
||||
cursor += context.measureText(character).width + letterSpacing;
|
||||
});
|
||||
}
|
||||
|
||||
function drawTextDecoration(context: CanvasRenderingContext2D, node: CanvasNode, x: number, y: number, width: number, fontSize: number) {
|
||||
const decoration = node.textDecoration || "";
|
||||
if (!decoration.includes("underline") && !decoration.includes("line-through")) return;
|
||||
context.save();
|
||||
context.fillStyle = normalizeHexColor(node.color || "#111827", "#111827");
|
||||
const thickness = Math.max(1, fontSize / 16);
|
||||
if (decoration.includes("underline")) context.fillRect(x, y + fontSize * 0.92, width, thickness);
|
||||
if (decoration.includes("line-through")) context.fillRect(x, y + fontSize * 0.52, width, thickness);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function psdTextLayerData(node: CanvasNode, bounds: CanvasNode): LayerTextData {
|
||||
const fontSize = node.fontSize || Math.max(14, Math.min(node.height * 0.68, 128));
|
||||
const lineHeight = node.lineHeight && node.lineHeight > 0 ? (node.lineHeight <= 4 ? fontSize * node.lineHeight : node.lineHeight) : fontSize * 1.08;
|
||||
const rotation = ((node.rotation || 0) * Math.PI) / 180;
|
||||
const scaleX = node.flipX ? -1 : 1;
|
||||
const scaleY = node.flipY ? -1 : 1;
|
||||
const cosine = Math.cos(rotation);
|
||||
const sine = Math.sin(rotation);
|
||||
const matrixA = cosine * scaleX;
|
||||
const matrixB = sine * scaleX;
|
||||
const matrixC = -sine * scaleY;
|
||||
const matrixD = cosine * scaleY;
|
||||
const localX = -node.width / 2;
|
||||
const localY = -node.height / 2 + fontSize;
|
||||
const centerX = node.x - bounds.x + node.width / 2;
|
||||
const centerY = node.y - bounds.y + node.height / 2;
|
||||
const translateX = centerX + matrixA * localX + matrixC * localY;
|
||||
const translateY = centerY + matrixB * localX + matrixD * localY;
|
||||
return {
|
||||
text: transformedText(node),
|
||||
transform: [matrixA, matrixB, matrixC, matrixD, translateX, translateY],
|
||||
antiAlias: "smooth",
|
||||
orientation: "horizontal",
|
||||
shapeType: "box",
|
||||
boxBounds: [0, 0, node.width, node.height],
|
||||
style: {
|
||||
font: { name: psdFontName(node.fontFamily) },
|
||||
fontSize,
|
||||
fauxBold: Number(textFontWeight(node.fontWeight)) >= 600,
|
||||
fauxItalic: textFontStyle(node.fontWeight) === "italic",
|
||||
leading: lineHeight,
|
||||
tracking: node.letterSpacing ? (node.letterSpacing / fontSize) * 1000 : 0,
|
||||
underline: Boolean(node.textDecoration?.includes("underline")),
|
||||
strikethrough: Boolean(node.textDecoration?.includes("line-through")),
|
||||
fillColor: psdColor(node.color || "#111827"),
|
||||
strokeColor: psdColor(node.strokeColor || "#000000"),
|
||||
fillFlag: true,
|
||||
strokeFlag: Boolean(node.strokeWidth && node.strokeWidth > 0)
|
||||
},
|
||||
paragraphStyle: {
|
||||
justification: psdTextJustification(node.textAlign)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function transformedText(node: CanvasNode) {
|
||||
const content = node.content || node.title;
|
||||
if (node.textTransform === "uppercase") return content.toUpperCase();
|
||||
if (node.textTransform === "lowercase") return content.toLowerCase();
|
||||
if (node.textTransform === "capitalize") return content.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
|
||||
return content;
|
||||
}
|
||||
|
||||
function psdTextJustification(value: CanvasNode["textAlign"]): Justification {
|
||||
if (value === "left" || value === "right") return value;
|
||||
return "center";
|
||||
}
|
||||
|
||||
function psdFontName(value?: string) {
|
||||
const family = (value || "Arial").split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
|
||||
return family || "Arial";
|
||||
}
|
||||
|
||||
function psdColor(value: string): RGB {
|
||||
const normalized = normalizeHexColor(value, "#111827");
|
||||
return {
|
||||
r: Number.parseInt(normalized.slice(1, 3), 16),
|
||||
g: Number.parseInt(normalized.slice(3, 5), 16),
|
||||
b: Number.parseInt(normalized.slice(5, 7), 16)
|
||||
};
|
||||
}
|
||||
|
||||
async function canvasFromImageBlob(blob: Blob, width: number, height: number) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = await loadImage(url);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Failed to create PSD raster layer");
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return canvas;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function safeExportFileName(value: string) {
|
||||
return value.trim().replace(/[/\\?%*:|"<>]/g, "-") || "canvas";
|
||||
}
|
||||
|
||||
function defaultPsdLayerName(node: CanvasNode) {
|
||||
if (node.type === "text") return "Text";
|
||||
if (node.type === "frame") return "Shape";
|
||||
return "Image";
|
||||
}
|
||||
|
||||
function isTransparentImageNode(node: CanvasNode) {
|
||||
return node.layerRole === "background-removed" || node.tone === "transparent-image";
|
||||
}
|
||||
|
||||
@@ -2270,8 +2270,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
toggleLocked: () => toggleLayerLocked(node.id),
|
||||
flipHorizontal: () => toggleNodeTransform(node.id, "flipX"),
|
||||
flipVertical: () => toggleNodeTransform(node.id, "flipY"),
|
||||
exportFormats: node.type === "text" ? ["svg"] : ["png", "jpg", "svg"],
|
||||
exportAs: (format) => exportCanvasNode(node, format),
|
||||
exportFormats: node.type === "text" ? ["svg"] : ["png", "jpg", "svg", "psd"],
|
||||
exportAs: (format) => exportCanvasNode(node, format, { nodes: nodesRef.current, hiddenNodeIds }),
|
||||
deleteNode: () => removeNode(node.id)
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -257,9 +257,9 @@ The generation path uses the configured design agent runner. The default is the
|
||||
}
|
||||
```
|
||||
|
||||
`layer-separation` returns a task id that can be polled with `GET /api/generator/tasks?task_id=<id>&project_id=<project-id>`. When layered output is available, the service asks GPT Image 2 for a PSD, converts PSD layers into canvas image/text nodes, and falls back to the local separation path if PSD generation or decoding fails. Completed tasks return a clean background image, foreground image artifacts with `bbox`, and a `text_render_data` artifact for editable text reconstruction.
|
||||
`layer-separation` returns a task id that can be polled with `GET /api/generator/tasks?task_id=<id>&project_id=<project-id>`. The service separates the source into a clean background, foreground image artifacts with `bbox`, and a `text_render_data` artifact for editable text reconstruction. It does not request PSD from the image-generation API; that API only produces raster image formats.
|
||||
|
||||
The canvas `edit-elements` node action uses the same separation path but writes the separated result beside the original image. The original image remains unchanged; the clean background is a bottom image layer, and a transparent frame above it holds the separated foreground images and editable text layers.
|
||||
The canvas `edit-elements` node action uses the same separation path but writes the separated result beside the original image. The original image remains unchanged; a transparent frame contains the clean background at the bottom, followed by separated foreground images and editable text layers. Users can export that frame as PSD from the canvas context menu; the browser builds the PSD from the current canvas layers at download time.
|
||||
|
||||
## Chat History Replay
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ require (
|
||||
github.com/hibiken/asynq v0.26.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/minio/minio-go/v7 v7.2.1
|
||||
github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/sky-valley/pi v0.2.8
|
||||
github.com/yalue/onnxruntime_go v1.22.0
|
||||
@@ -29,7 +28,6 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/gopherjs/gopherjs v1.21.0 // indirect
|
||||
github.com/grafana/pyroscope-go v1.3.0 // indirect
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.10 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
||||
|
||||
@@ -34,8 +34,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v1.21.0 h1:5HEGrz+XhpCchubMGzuyLuGoCTlL/yCT7sGsT5Se/dw=
|
||||
github.com/gopherjs/gopherjs v1.21.0/go.mod h1:R2HIOen3IzYSzvmvkeD8WOfiLN9wueR/T5Y+6z326Ck=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/pyroscope-go v1.3.0 h1:t3Jehad8vvqN4oRAB0LdmfQ5ZSUXQw3asoft+K4GAT8=
|
||||
@@ -81,8 +79,6 @@ github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9
|
||||
github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f h1:67UmABhaufAdSFaRO/wcaTn0XgHWCirjwfFf21ZNPAc=
|
||||
github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f/go.mod h1:GHI1bnmAcbp96z6LNfBJvtrjxhaXGkbsk967utPlvL8=
|
||||
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
|
||||
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
|
||||
@@ -51,26 +51,25 @@ const nodeActionResultNodeIDOption = "_resultNodeId"
|
||||
const generationRecoveryToolHint = "generation_recovery"
|
||||
|
||||
type DesignService struct {
|
||||
repo design.Repository
|
||||
agent design.AgentRunner
|
||||
assets design.AssetStorage
|
||||
assetCleanup *assetCleanupQueue
|
||||
jobs design.JobQueue
|
||||
search design.Researcher
|
||||
textExtractor design.TextExtractor
|
||||
objectRecognizer design.ObjectRecognizer
|
||||
imageVectorizer design.ImageVectorizer
|
||||
backgroundRemover design.BackgroundRemover
|
||||
layeredDocumentGenerator design.LayeredDocumentGenerator
|
||||
mockupAnalyzer design.MockupModelAnalyzer
|
||||
textExtractionCache textExtractionCacheStore
|
||||
textExtractionCacheTTL time.Duration
|
||||
creativeAgent design.CreativeAgent
|
||||
cancelledAgentTasks sync.Map
|
||||
agentTaskCancels sync.Map
|
||||
generatorTaskMu sync.RWMutex
|
||||
generatorTasks map[string]design.GeneratorTask
|
||||
now func() time.Time
|
||||
repo design.Repository
|
||||
agent design.AgentRunner
|
||||
assets design.AssetStorage
|
||||
assetCleanup *assetCleanupQueue
|
||||
jobs design.JobQueue
|
||||
search design.Researcher
|
||||
textExtractor design.TextExtractor
|
||||
objectRecognizer design.ObjectRecognizer
|
||||
imageVectorizer design.ImageVectorizer
|
||||
backgroundRemover design.BackgroundRemover
|
||||
mockupAnalyzer design.MockupModelAnalyzer
|
||||
textExtractionCache textExtractionCacheStore
|
||||
textExtractionCacheTTL time.Duration
|
||||
creativeAgent design.CreativeAgent
|
||||
cancelledAgentTasks sync.Map
|
||||
agentTaskCancels sync.Map
|
||||
generatorTaskMu sync.RWMutex
|
||||
generatorTasks map[string]design.GeneratorTask
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type agentTaskCancelHandle struct {
|
||||
@@ -137,10 +136,6 @@ func (s *DesignService) SetBackgroundRemover(remover design.BackgroundRemover) {
|
||||
s.backgroundRemover = remover
|
||||
}
|
||||
|
||||
func (s *DesignService) SetLayeredDocumentGenerator(generator design.LayeredDocumentGenerator) {
|
||||
s.layeredDocumentGenerator = generator
|
||||
}
|
||||
|
||||
func (s *DesignService) SetObjectRecognizer(recognizer design.ObjectRecognizer) {
|
||||
s.objectRecognizer = recognizer
|
||||
}
|
||||
|
||||
@@ -280,10 +280,7 @@ func normalizeLayerSeparationInput(input design.GeneratorTaskInputArgs) design.G
|
||||
input.ImageURL = strings.TrimSpace(input.Image[0])
|
||||
}
|
||||
input.ImageURL = strings.TrimSpace(input.ImageURL)
|
||||
input.OutputFormat = strings.TrimSpace(input.OutputFormat)
|
||||
if input.OutputFormat == "" {
|
||||
input.OutputFormat = "psd"
|
||||
}
|
||||
input.OutputFormat = ""
|
||||
input.Prompt = strings.TrimSpace(input.Prompt)
|
||||
input.Resolution = strings.TrimSpace(input.Resolution)
|
||||
input.AspectRatio = strings.TrimSpace(input.AspectRatio)
|
||||
@@ -792,8 +789,8 @@ func generatorTaskInputArgsForNodeAction(target design.Node, req design.NodeActi
|
||||
if normalizeNodeAction(req.Action) == "move-object" {
|
||||
input.SrcBox, input.DstBox = moveObjectBoxesFromSelection(req.Selection)
|
||||
}
|
||||
if normalizeNodeAction(req.Action) == "edit-elements" && input.OutputFormat == "" {
|
||||
input.OutputFormat = "psd"
|
||||
if normalizeNodeAction(req.Action) == "edit-elements" {
|
||||
input.OutputFormat = ""
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@ import (
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
)
|
||||
|
||||
func TestNormalizeLayerSeparationInputDoesNotClaimPSDOutput(t *testing.T) {
|
||||
input := normalizeLayerSeparationInput(design.GeneratorTaskInputArgs{
|
||||
ImageURL: "https://example.com/source.png",
|
||||
})
|
||||
if input.OutputFormat != "" {
|
||||
t.Fatalf("expected layer separation task output format to describe no upstream raster format, got %q", input.OutputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorTaskStatusWaitsForActiveCanvasWork(t *testing.T) {
|
||||
threadID := "thread-image"
|
||||
project := design.Project{
|
||||
|
||||
@@ -10,18 +10,13 @@ import (
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
|
||||
psd "github.com/oov/psd"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -59,18 +54,6 @@ type layerSeparationForegroundCandidate struct {
|
||||
Image image.Image
|
||||
}
|
||||
|
||||
type layerSeparationDocumentLayer struct {
|
||||
Name string
|
||||
Rect image.Rectangle
|
||||
Image image.Image
|
||||
Opacity uint8
|
||||
}
|
||||
|
||||
type layerSeparationDocument struct {
|
||||
Bounds image.Rectangle
|
||||
Layers []layerSeparationDocumentLayer
|
||||
}
|
||||
|
||||
func (s *DesignService) completeLayerSeparationGeneration(ctx context.Context, projectID string, threadID string, target design.Node, req design.NodeActionRequest) error {
|
||||
resultNodeID := nodeActionResultNodeID(target, req)
|
||||
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "图层分离", "正在拆分底图、前景元素和可编辑文字层。"); err != nil {
|
||||
@@ -168,9 +151,6 @@ func (s *DesignService) createLayerSeparationResult(ctx context.Context, project
|
||||
if err != nil {
|
||||
return layerSeparationResult{}, err
|
||||
}
|
||||
if result, ok := s.createLayerSeparationResultFromLayeredDocument(ctx, projectID, target, req, source, bounds, extraction); ok {
|
||||
return result, nil
|
||||
}
|
||||
for _, layer := range extraction.Layers {
|
||||
removeTextLayerFromImage(canvas, layer)
|
||||
}
|
||||
@@ -289,321 +269,6 @@ func (s *DesignService) extractLayerSeparationText(ctx context.Context, target d
|
||||
return s.extractTextExtraction(ctx, target, req)
|
||||
}
|
||||
|
||||
func (s *DesignService) createLayerSeparationResultFromLayeredDocument(ctx context.Context, projectID string, target design.Node, req design.NodeActionRequest, source image.Image, bounds image.Rectangle, extraction design.TextExtraction) (layerSeparationResult, bool) {
|
||||
if s.layeredDocumentGenerator == nil {
|
||||
return layerSeparationResult{}, false
|
||||
}
|
||||
document, err := s.layeredDocumentGenerator.GenerateLayeredDocument(ctx, design.LayeredDocumentRequest{
|
||||
ImageURL: target.Content,
|
||||
Prompt: layerSeparationPSDPrompt(req.Prompt),
|
||||
Model: "gpt-image-2",
|
||||
Size: "auto",
|
||||
OutputFormat: "psd",
|
||||
IdempotencyKey: nodeActionResultNodeID(target, req) + "-psd",
|
||||
})
|
||||
if err != nil {
|
||||
logx.Errorf("layer separation psd generation failed: %v", err)
|
||||
return layerSeparationResult{}, false
|
||||
}
|
||||
data, err := layeredDocumentData(ctx, document)
|
||||
if err != nil {
|
||||
logx.Errorf("layer separation psd download failed: %v", err)
|
||||
return layerSeparationResult{}, false
|
||||
}
|
||||
decoded, err := decodeLayerSeparationDocument(data)
|
||||
if err != nil {
|
||||
logx.Errorf("layer separation psd decode failed: %v", err)
|
||||
return layerSeparationResult{}, false
|
||||
}
|
||||
if !decoded.Bounds.Eq(bounds) {
|
||||
logx.Errorf("layer separation psd dimensions mismatch source=%dx%d psd=%dx%d", bounds.Dx(), bounds.Dy(), decoded.Bounds.Dx(), decoded.Bounds.Dy())
|
||||
return layerSeparationResult{}, false
|
||||
}
|
||||
result, err := s.layerSeparationResultFromDocument(ctx, projectID, source, decoded, extraction)
|
||||
if err != nil {
|
||||
logx.Errorf("layer separation psd conversion failed: %v", err)
|
||||
return layerSeparationResult{}, false
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func layerSeparationPSDPrompt(userPrompt string) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Create a layered PSD from the provided image for element editing. Preserve the exact canvas size and visual alignment. The bottom layer must be one clean background image with all text and small standalone foreground elements removed. Put each small standalone decorative or object element on its own transparent image layer above the background. Keep all text as editable text layers when possible, preserving text, font style, color, alignment, spacing, rotation, and position. Do not rasterize text into the background. Do not split large poster scenery into foreground layers. Return only the PSD file.")
|
||||
if prompt := strings.TrimSpace(userPrompt); prompt != "" {
|
||||
builder.WriteString("\nAdditional instruction: ")
|
||||
builder.WriteString(prompt)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func layeredDocumentData(ctx context.Context, document design.LayeredDocument) ([]byte, error) {
|
||||
if len(document.Data) > 0 {
|
||||
return document.Data, nil
|
||||
}
|
||||
content := strings.TrimSpace(document.Content)
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("layered document returned empty content")
|
||||
}
|
||||
if strings.HasPrefix(content, "data:") {
|
||||
_, payload, ok := strings.Cut(content, ",")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid layered document data url")
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(payload)
|
||||
}
|
||||
if !strings.HasPrefix(content, "http://") && !strings.HasPrefix(content, "https://") {
|
||||
return nil, fmt.Errorf("unsupported layered document url")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, content, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "img-infinite-canvas/1.0")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("download layered document failed: %s", resp.Status)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 96<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("download layered document returned empty body")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func decodeLayerSeparationDocument(data []byte) (layerSeparationDocument, error) {
|
||||
doc, _, err := psd.Decode(bytes.NewReader(data), &psd.DecodeOptions{SkipMergedImage: true})
|
||||
if err != nil {
|
||||
return layerSeparationDocument{}, err
|
||||
}
|
||||
result := layerSeparationDocument{
|
||||
Bounds: doc.Config.Rect,
|
||||
Layers: make([]layerSeparationDocumentLayer, 0, len(doc.Layer)),
|
||||
}
|
||||
var collect func([]psd.Layer)
|
||||
collect = func(layers []psd.Layer) {
|
||||
for i := len(layers) - 1; i >= 0; i-- {
|
||||
layer := layers[i]
|
||||
if len(layer.Layer) > 0 {
|
||||
collect(layer.Layer)
|
||||
}
|
||||
if !layer.Visible() || !layer.HasImage() || layer.Picker == nil || layer.Rect.Empty() {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(layer.UnicodeName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(layer.Name)
|
||||
}
|
||||
result.Layers = append(result.Layers, layerSeparationDocumentLayer{
|
||||
Name: name,
|
||||
Rect: layer.Rect,
|
||||
Image: layer.Picker,
|
||||
Opacity: layer.Opacity,
|
||||
})
|
||||
}
|
||||
}
|
||||
collect(doc.Layer)
|
||||
if result.Bounds.Empty() || len(result.Layers) == 0 {
|
||||
return layerSeparationDocument{}, fmt.Errorf("layered document has no usable layers")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *DesignService) layerSeparationResultFromDocument(ctx context.Context, projectID string, source image.Image, document layerSeparationDocument, extraction design.TextExtraction) (layerSeparationResult, error) {
|
||||
backgroundIndex := layerSeparationBackgroundLayerIndex(document)
|
||||
if backgroundIndex < 0 {
|
||||
return layerSeparationResult{}, fmt.Errorf("layered document has no background layer")
|
||||
}
|
||||
background := image.NewRGBA(document.Bounds)
|
||||
draw.Draw(background, document.Bounds, source, source.Bounds().Min, draw.Src)
|
||||
drawLayerSeparationDocumentLayer(background, document.Layers[backgroundIndex])
|
||||
for _, layer := range extraction.Layers {
|
||||
removeTextLayerFromImage(background, layer)
|
||||
}
|
||||
|
||||
foregrounds := make([]layerSeparationForeground, 0, len(document.Layers))
|
||||
allowLargeForeground := len(extraction.Layers) == 0 && layerSeparationImageIsPlain(background, document.Bounds)
|
||||
for index, layer := range document.Layers {
|
||||
if index == backgroundIndex || layerSeparationDocumentLayerIsText(layer, extraction, document.Bounds) {
|
||||
continue
|
||||
}
|
||||
candidate, ok := layerSeparationForegroundCandidateFromDocumentLayer(layer, document.Bounds)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
count := alphaMaskCount(candidate.Mask, candidate.Rect)
|
||||
if !keepLayerSeparationElement(candidate.Rect, count, document.Bounds) && !(allowLargeForeground && keepLayerSeparationLargeSubject(candidate.Rect, count, document.Bounds)) {
|
||||
continue
|
||||
}
|
||||
foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, candidate)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
removeForegroundMaskFromImage(background, foreground.Mask, foreground.Rect)
|
||||
foregrounds = append(foregrounds, foreground)
|
||||
}
|
||||
return s.finishLayerSeparationResult(ctx, projectID, int64(document.Bounds.Dx()), int64(document.Bounds.Dy()), background, foregrounds, extraction)
|
||||
}
|
||||
|
||||
func layerSeparationImageIsPlain(img *image.RGBA, bounds image.Rectangle) bool {
|
||||
if img == nil || bounds.Empty() {
|
||||
return false
|
||||
}
|
||||
mask := image.NewAlpha(bounds)
|
||||
stats, ok := unmaskedColorStats(img, mask, bounds)
|
||||
return ok && stats.StdDev <= 24
|
||||
}
|
||||
|
||||
func keepLayerSeparationLargeSubject(rect image.Rectangle, count int, bounds image.Rectangle) bool {
|
||||
if rect.Empty() || count <= 0 {
|
||||
return false
|
||||
}
|
||||
totalArea := bounds.Dx() * bounds.Dy()
|
||||
if totalArea <= 0 {
|
||||
return false
|
||||
}
|
||||
bboxArea := rect.Dx() * rect.Dy()
|
||||
if float64(bboxArea)/float64(totalArea) > 0.78 {
|
||||
return false
|
||||
}
|
||||
if float64(rect.Dx())/float64(bounds.Dx()) > 0.9 || float64(rect.Dy())/float64(bounds.Dy()) > 0.96 {
|
||||
return false
|
||||
}
|
||||
return count >= totalArea/80
|
||||
}
|
||||
|
||||
func layerSeparationBackgroundLayerIndex(document layerSeparationDocument) int {
|
||||
bestIndex := -1
|
||||
bestScore := -1
|
||||
totalArea := document.Bounds.Dx() * document.Bounds.Dy()
|
||||
for index, layer := range document.Layers {
|
||||
if layer.Rect.Empty() || layer.Image == nil {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(layer.Name)
|
||||
area := layer.Rect.Intersect(document.Bounds).Dx() * layer.Rect.Intersect(document.Bounds).Dy()
|
||||
if strings.Contains(name, "bg") || strings.Contains(name, "background") || strings.Contains(name, "clean") || strings.Contains(name, "base") {
|
||||
return index
|
||||
}
|
||||
if area > bestScore {
|
||||
bestScore = area
|
||||
bestIndex = index
|
||||
}
|
||||
}
|
||||
if totalArea <= 0 || bestScore < int(float64(totalArea)*0.45) {
|
||||
return -1
|
||||
}
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
func drawLayerSeparationDocumentLayer(dst *image.RGBA, layer layerSeparationDocumentLayer) {
|
||||
if dst == nil || layer.Image == nil || layer.Rect.Empty() {
|
||||
return
|
||||
}
|
||||
rect := layer.Rect.Intersect(dst.Bounds())
|
||||
if rect.Empty() {
|
||||
return
|
||||
}
|
||||
if layer.Opacity == 0 {
|
||||
return
|
||||
}
|
||||
if layer.Opacity == 255 {
|
||||
draw.Draw(dst, rect, layer.Image, rect.Min, draw.Src)
|
||||
return
|
||||
}
|
||||
tmp := image.NewNRGBA(rect)
|
||||
draw.Draw(tmp, rect, layer.Image, rect.Min, draw.Src)
|
||||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||
offset := tmp.PixOffset(x, y)
|
||||
tmp.Pix[offset+3] = uint8(int(tmp.Pix[offset+3]) * int(layer.Opacity) / 255)
|
||||
}
|
||||
}
|
||||
draw.Draw(dst, rect, tmp, rect.Min, draw.Over)
|
||||
}
|
||||
|
||||
func layerSeparationDocumentLayerIsText(layer layerSeparationDocumentLayer, extraction design.TextExtraction, bounds image.Rectangle) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(layer.Name))
|
||||
if strings.Contains(name, "text") || strings.Contains(name, "type") || strings.Contains(name, "font") || strings.Contains(name, "文字") {
|
||||
return true
|
||||
}
|
||||
if len(extraction.Layers) == 0 || layer.Rect.Empty() {
|
||||
return false
|
||||
}
|
||||
layerRect := layer.Rect.Intersect(bounds)
|
||||
if layerRect.Empty() {
|
||||
return false
|
||||
}
|
||||
layerArea := float64(layerRect.Dx() * layerRect.Dy())
|
||||
for _, textLayer := range extraction.Layers {
|
||||
textRect := normalizedTextMaskRect(bounds, textLayer)
|
||||
intersection := layerRect.Intersect(textRect)
|
||||
if intersection.Empty() {
|
||||
continue
|
||||
}
|
||||
intersectionArea := float64(intersection.Dx() * intersection.Dy())
|
||||
if intersectionArea/layerArea > 0.28 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func layerSeparationForegroundCandidateFromDocumentLayer(layer layerSeparationDocumentLayer, bounds image.Rectangle) (layerSeparationForegroundCandidate, bool) {
|
||||
if layer.Image == nil || layer.Rect.Empty() {
|
||||
return layerSeparationForegroundCandidate{}, false
|
||||
}
|
||||
if layer.Opacity == 0 {
|
||||
return layerSeparationForegroundCandidate{}, false
|
||||
}
|
||||
foreground := image.NewNRGBA(bounds)
|
||||
rect := layer.Rect.Intersect(bounds)
|
||||
if rect.Empty() {
|
||||
return layerSeparationForegroundCandidate{}, false
|
||||
}
|
||||
draw.Draw(foreground, rect, layer.Image, rect.Min, draw.Src)
|
||||
if layer.Opacity > 0 && layer.Opacity < 255 {
|
||||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||
offset := foreground.PixOffset(x, y)
|
||||
foreground.Pix[offset+3] = uint8(int(foreground.Pix[offset+3]) * int(layer.Opacity) / 255)
|
||||
}
|
||||
}
|
||||
}
|
||||
contentRect, ok := alphaContentBounds(foreground, bounds, 12)
|
||||
if !ok {
|
||||
return layerSeparationForegroundCandidate{}, false
|
||||
}
|
||||
mask, count := alphaMask(foreground, bounds, 12)
|
||||
if count == 0 {
|
||||
return layerSeparationForegroundCandidate{}, false
|
||||
}
|
||||
return newLayerSeparationForegroundCandidate(foreground, mask, contentRect, bounds), true
|
||||
}
|
||||
|
||||
func alphaMaskCount(mask *image.Alpha, rect image.Rectangle) int {
|
||||
if mask == nil || rect.Empty() {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
rect = rect.Intersect(mask.Bounds())
|
||||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||
if mask.AlphaAt(x, y).A > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *DesignService) createLayerSeparationForegroundCandidates(ctx context.Context, target design.Node, sourceData []byte, sourceContentType string, bounds image.Rectangle, extraction design.TextExtraction) (layerSeparationForegroundCandidate, []layerSeparationForegroundCandidate, bool) {
|
||||
if s.backgroundRemover == nil {
|
||||
return layerSeparationForegroundCandidate{}, nil, false
|
||||
@@ -854,6 +519,7 @@ func newLayerSeparationBackgroundNode(frame design.Node, result layerSeparationR
|
||||
Content: result.BackgroundURL,
|
||||
Tone: "visual",
|
||||
Status: "success",
|
||||
ParentID: frame.ID,
|
||||
LayerRole: "clean-image",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +117,8 @@ func TestRunEditElementsSeparatesBackgroundForegroundAndTextLayers(t *testing.T)
|
||||
if frame.ParentID != "" || frame.FillColor != "transparent" {
|
||||
t.Fatalf("expected top-level transparent separated frame, got %#v", frame)
|
||||
}
|
||||
if background.ParentID != "" {
|
||||
t.Fatalf("expected clean background to be a bottom image layer outside frame, got %#v", background)
|
||||
if background.ParentID != frame.ID {
|
||||
t.Fatalf("expected clean background to be the bottom layer inside the frame, got %#v", background)
|
||||
}
|
||||
foreground := saved.Nodes[3]
|
||||
if foreground.LayerRole != "foreground-image" || foreground.ParentID != frame.ID {
|
||||
@@ -311,98 +311,6 @@ func TestLayerSeparationPosterKeepsComplexBackgroundAndExtractsSmallElements(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayerSeparationDocumentPlacesSmallImagesAndTextOverBackground(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewDesignService(nil, nil, nil, nil, nil)
|
||||
source := testPosterSourceImage()
|
||||
background := testPosterCleanBackgroundImage()
|
||||
ornament := image.NewNRGBA(image.Rect(54, 44, 66, 56))
|
||||
for y := 44; y < 56; y++ {
|
||||
for x := 54; x < 66; x++ {
|
||||
ornament.SetNRGBA(x, y, color.NRGBA{R: 225, G: 32, B: 44, A: 255})
|
||||
}
|
||||
}
|
||||
textRaster := image.NewNRGBA(image.Rect(8, 8, 36, 18))
|
||||
for y := 8; y < 18; y++ {
|
||||
for x := 8; x < 36; x++ {
|
||||
textRaster.SetNRGBA(x, y, color.NRGBA{R: 17, G: 17, B: 17, A: 255})
|
||||
}
|
||||
}
|
||||
|
||||
result, err := service.layerSeparationResultFromDocument(ctx, "project-layered-doc", source, layerSeparationDocument{
|
||||
Bounds: source.Bounds(),
|
||||
Layers: []layerSeparationDocumentLayer{
|
||||
{Name: "clean background", Rect: source.Bounds(), Image: background, Opacity: 255},
|
||||
{Name: "decorative element", Rect: ornament.Bounds(), Image: ornament, Opacity: 255},
|
||||
{Name: "text title", Rect: textRaster.Bounds(), Image: textRaster, Opacity: 255},
|
||||
},
|
||||
}, design.TextExtraction{
|
||||
Layers: []design.ExtractedTextLayer{{Text: "SALE", X: 0.1, Y: 0.1, Width: 0.35, Height: 0.125, Color: "#111111"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Foregrounds) != 1 {
|
||||
t.Fatalf("expected one small image layer, got %#v", result.Foregrounds)
|
||||
}
|
||||
if got := result.Foregrounds[0].BBox; len(got) != 4 || got[0] != 54 || got[1] != 44 || got[2] != 66 || got[3] != 56 {
|
||||
t.Fatalf("unexpected foreground bbox: %#v", got)
|
||||
}
|
||||
if len(result.TextExtraction.Layers) != 1 || result.TextExtraction.Layers[0].Text != "SALE" {
|
||||
t.Fatalf("expected editable OCR text, got %#v", result.TextExtraction.Layers)
|
||||
}
|
||||
backgroundData, _, err := loadImageContent(ctx, result.BackgroundURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decodedBackground, _, err := image.Decode(bytes.NewReader(backgroundData))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ornamentPixel := color.NRGBAModel.Convert(decodedBackground.At(59, 49)).(color.NRGBA)
|
||||
if ornamentPixel.R > 200 && ornamentPixel.G < 80 {
|
||||
t.Fatalf("expected foreground element removed from background, got %#v", ornamentPixel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayerSeparationDocumentAllowsLargeSubjectOnPlainBackground(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewDesignService(nil, nil, nil, nil, nil)
|
||||
bounds := image.Rect(0, 0, 100, 100)
|
||||
source := image.NewNRGBA(bounds)
|
||||
background := image.NewNRGBA(bounds)
|
||||
for y := 0; y < 100; y++ {
|
||||
for x := 0; x < 100; x++ {
|
||||
source.SetNRGBA(x, y, color.NRGBA{R: 230, G: 228, B: 224, A: 255})
|
||||
background.SetNRGBA(x, y, color.NRGBA{R: 230, G: 228, B: 224, A: 255})
|
||||
}
|
||||
}
|
||||
subject := image.NewNRGBA(image.Rect(28, 14, 74, 92))
|
||||
for y := 14; y < 92; y++ {
|
||||
for x := 28; x < 74; x++ {
|
||||
subject.SetNRGBA(x, y, color.NRGBA{R: 36, G: 28, B: 22, A: 255})
|
||||
source.SetNRGBA(x, y, color.NRGBA{R: 36, G: 28, B: 22, A: 255})
|
||||
}
|
||||
}
|
||||
|
||||
result, err := service.layerSeparationResultFromDocument(ctx, "project-plain-subject", source, layerSeparationDocument{
|
||||
Bounds: bounds,
|
||||
Layers: []layerSeparationDocumentLayer{
|
||||
{Name: "background", Rect: bounds, Image: background, Opacity: 255},
|
||||
{Name: "subject", Rect: subject.Bounds(), Image: subject, Opacity: 255},
|
||||
},
|
||||
}, design.TextExtraction{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Foregrounds) != 1 {
|
||||
t.Fatalf("expected large subject foreground on plain background, got %#v", result.Foregrounds)
|
||||
}
|
||||
if got := result.Foregrounds[0].BBox; len(got) != 4 || got[0] != 28 || got[1] != 14 || got[2] != 74 || got[3] != 92 {
|
||||
t.Fatalf("unexpected subject bbox: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForStandaloneGeneratorTask(t *testing.T, service *DesignService, taskID string) design.GeneratorTask {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
@@ -480,21 +388,6 @@ func testPosterSourceImage() *image.NRGBA {
|
||||
return img
|
||||
}
|
||||
|
||||
func testPosterCleanBackgroundImage() *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 80, 80))
|
||||
for y := 0; y < 80; y++ {
|
||||
for x := 0; x < 80; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: uint8(190 + x/3), G: uint8(186 + y/4), B: uint8(174 + (x+y)/8), A: 255})
|
||||
}
|
||||
}
|
||||
for y := 38; y < 70; y++ {
|
||||
for x := 8; x < 50; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: 64, G: 106, B: 204, A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func testPosterForegroundPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 80, 80))
|
||||
|
||||
@@ -74,25 +74,6 @@ type GeneratorTaskInputArgs struct {
|
||||
DstBox *NormalizedBox
|
||||
}
|
||||
|
||||
type LayeredDocumentRequest struct {
|
||||
ImageURL string
|
||||
Prompt string
|
||||
Model string
|
||||
Size string
|
||||
OutputFormat string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type LayeredDocument struct {
|
||||
Content string
|
||||
ContentType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type LayeredDocumentGenerator interface {
|
||||
GenerateLayeredDocument(ctx context.Context, req LayeredDocumentRequest) (LayeredDocument, error)
|
||||
}
|
||||
|
||||
type GeneratorTaskSubmitRequest struct {
|
||||
CID string
|
||||
ProjectID string
|
||||
|
||||
@@ -117,7 +117,7 @@ func normalizeInputImageTransport(value string) string {
|
||||
|
||||
func normalizeImageOutputFormat(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "psd", "png", "jpeg", "jpg", "webp":
|
||||
case "png", "jpeg", "jpg", "webp":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "jpg") {
|
||||
return "jpeg"
|
||||
}
|
||||
@@ -136,47 +136,6 @@ func normalizeImageQuality(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ImageClient) GenerateLayeredDocument(ctx context.Context, req design.LayeredDocumentRequest) (design.LayeredDocument, error) {
|
||||
imageURL := strings.TrimSpace(req.ImageURL)
|
||||
if imageURL == "" {
|
||||
return design.LayeredDocument{}, fmt.Errorf("layered document image_url is required")
|
||||
}
|
||||
outputFormat := normalizeImageOutputFormat(req.OutputFormat)
|
||||
if outputFormat == "" {
|
||||
outputFormat = "psd"
|
||||
}
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
prompt = "Create a layered PSD from the provided image. Preserve the exact canvas and visual design. Put a single clean background layer at the bottom with all text and small foreground elements removed. Put each small standalone foreground element on its own image layer. Keep text as editable text layers when possible. Do not merge text into the background. Return only the layered PSD file."
|
||||
}
|
||||
size := strings.TrimSpace(req.Size)
|
||||
if size == "" {
|
||||
size = "auto"
|
||||
}
|
||||
generated, err := c.Generate(ctx, prompt, ImageRequestOptions{
|
||||
Model: req.Model,
|
||||
Size: size,
|
||||
Count: 1,
|
||||
Images: []string{imageURL},
|
||||
OutputFormat: outputFormat,
|
||||
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
|
||||
})
|
||||
if err != nil {
|
||||
return design.LayeredDocument{}, err
|
||||
}
|
||||
content := strings.TrimSpace(generated.URL)
|
||||
if content == "" && len(generated.URLs) > 0 {
|
||||
content = strings.TrimSpace(generated.URLs[0])
|
||||
}
|
||||
if content == "" {
|
||||
return design.LayeredDocument{}, fmt.Errorf("layered document generation returned empty file")
|
||||
}
|
||||
return design.LayeredDocument{
|
||||
Content: content,
|
||||
ContentType: outputFormatContentType(outputFormat),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
@@ -948,8 +907,6 @@ func stripDataURLPrefix(value string) string {
|
||||
|
||||
func outputFormatContentType(format string) string {
|
||||
switch normalizeImageOutputFormat(format) {
|
||||
case "psd":
|
||||
return "image/vnd.adobe.photoshop"
|
||||
case "jpeg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
)
|
||||
|
||||
func TestNewImageClientDefaultsToTenMinuteTimeout(t *testing.T) {
|
||||
@@ -167,62 +165,36 @@ func TestImageClientSendsQuality(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageClientSendsOutputFormatAndParsesPSDBase64(t *testing.T) {
|
||||
func TestNormalizeImageOutputFormatRejectsPSD(t *testing.T) {
|
||||
if got := normalizeImageOutputFormat("psd"); got != "" {
|
||||
t.Fatalf("expected unsupported PSD output format to be omitted, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageClientSendsOutputFormatAndParsesWebPBase64(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"b64_json":"cHNkLWJ5dGVz"}]}`))
|
||||
_, _ = w.Write([]byte(`{"data":[{"b64_json":"d2VicC1ieXRlcw=="}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
|
||||
image, err := client.Generate(context.Background(), "create layered file", ImageRequestOptions{Model: "gpt-image-2", OutputFormat: "psd"})
|
||||
image, err := client.Generate(context.Background(), "create an image", ImageRequestOptions{Model: "gpt-image-2", OutputFormat: "webp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestBody["output_format"] != "psd" {
|
||||
t.Fatalf("expected output_format=psd, got %#v", requestBody["output_format"])
|
||||
if requestBody["output_format"] != "webp" {
|
||||
t.Fatalf("expected output_format=webp, got %#v", requestBody["output_format"])
|
||||
}
|
||||
if requestBody["stream"] != nil {
|
||||
t.Fatalf("expected psd output to avoid streaming, got %#v", requestBody["stream"])
|
||||
t.Fatalf("expected explicit output format to avoid streaming, got %#v", requestBody["stream"])
|
||||
}
|
||||
if image.URL != "data:image/vnd.adobe.photoshop;base64,cHNkLWJ5dGVz" {
|
||||
t.Fatalf("expected psd data url, got %#v", image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageClientGenerateLayeredDocumentUsesPSDOutput(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
var requestPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/layers.psd"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key", InputImageTransport: "url"})
|
||||
document, err := client.GenerateLayeredDocument(context.Background(), design.LayeredDocumentRequest{
|
||||
ImageURL: "https://example.com/source.png",
|
||||
Prompt: "separate layers",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestPath != "/v1/images/edits" {
|
||||
t.Fatalf("expected image edit endpoint, got %s", requestPath)
|
||||
}
|
||||
if requestBody["output_format"] != "psd" || requestBody["size"] != "auto" {
|
||||
t.Fatalf("expected psd auto request, got %#v", requestBody)
|
||||
}
|
||||
if document.Content != "https://example.com/layers.psd" || document.ContentType != "image/vnd.adobe.photoshop" {
|
||||
t.Fatalf("unexpected layered document: %#v", document)
|
||||
if image.URL != "data:image/webp;base64,d2VicC1ieXRlcw==" {
|
||||
t.Fatalf("expected WebP data URL, got %#v", image)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||
textExtractor := newTextExtractor(c)
|
||||
service := application.NewDesignService(repo, agentRunner, assets, researcher, textExtractor, creativeAgent)
|
||||
service.SetBackgroundRemover(newBackgroundRemover(c))
|
||||
service.SetLayeredDocumentGenerator(newLayeredDocumentGenerator(c))
|
||||
service.SetObjectRecognizer(newObjectRecognizer(c))
|
||||
if mockupAnalyzer, ok := creativeAgent.(design.MockupModelAnalyzer); ok {
|
||||
service.SetMockupModelAnalyzer(mockupAnalyzer)
|
||||
@@ -318,22 +317,6 @@ func newImageGenerator(c config.Config) piagent.ImageGenerator {
|
||||
return client
|
||||
}
|
||||
|
||||
func newLayeredDocumentGenerator(c config.Config) design.LayeredDocumentGenerator {
|
||||
opts := c.Agent.Image
|
||||
apiKey := resolveImageAPIKey(opts)
|
||||
client := piagent.NewImageClient(piagent.ImageClientOptions{
|
||||
BaseURL: opts.BaseURL,
|
||||
APIKey: apiKey,
|
||||
InputImageTransport: opts.InputImageTransport,
|
||||
TimeoutSeconds: opts.TimeoutSeconds,
|
||||
ResponseHeaderTimeoutSeconds: opts.ResponseHeaderTimeoutSeconds,
|
||||
})
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func newTextExtractor(c config.Config) design.TextExtractor {
|
||||
opts := c.Agent.Text
|
||||
switch opts.Driver {
|
||||
|
||||
Reference in New Issue
Block a user