diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index 28bcdaa17..9f5f92061 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -350,6 +350,7 @@ function getPlacedAssignedImageStyle(params: { imageWidth: imageSize.width, imageHeight: imageSize.height, replaceMode: layer.replaceMode, + contentBounds: layer.contentBounds, placement, }); @@ -1559,7 +1560,7 @@ export function AgentPsdTemplateEditorDialog({
{selectedImageLayer ? ( -
+
可替换图片
{selectedImageLayer.layerName}
@@ -1727,7 +1728,7 @@ export function AgentPsdTemplateEditorDialog({
) : selectedTextLayer ? ( -
+
可编辑文字
{selectedTextLayer.layerName}
diff --git a/lib/agents/psd-image-placement.ts b/lib/agents/psd-image-placement.ts index e8fda7984..61978e646 100644 --- a/lib/agents/psd-image-placement.ts +++ b/lib/agents/psd-image-placement.ts @@ -1,4 +1,7 @@ -import type { AgentPsdTemplateReplaceMode } from "@/lib/agents/psd-template-config"; +import type { + AgentPsdTemplateImageContentBounds, + AgentPsdTemplateReplaceMode, +} from "@/lib/agents/psd-template-config"; export interface AgentPsdImagePlacement { scale: number; @@ -69,19 +72,59 @@ function resolveBaseFittedSize(params: { }; } +function normalizeContainerBounds(params: { + containerWidth: number; + containerHeight: number; + contentBounds?: AgentPsdTemplateImageContentBounds; +}) { + const { containerWidth, containerHeight, contentBounds } = params; + if (!contentBounds) { + return { + left: 0, + top: 0, + width: containerWidth, + height: containerHeight, + }; + } + + const left = Math.max(0, Math.min(containerWidth - 1, Math.round(contentBounds.left))); + const top = Math.max(0, Math.min(containerHeight - 1, Math.round(contentBounds.top))); + const width = Math.max( + 1, + Math.min(Math.round(contentBounds.width), Math.max(1, containerWidth - left)) + ); + const height = Math.max( + 1, + Math.min(Math.round(contentBounds.height), Math.max(1, containerHeight - top)) + ); + + return { + left, + top, + width, + height, + }; +} + export function resolveAgentPsdImagePlacementFrame(params: { containerWidth: number; containerHeight: number; imageWidth: number; imageHeight: number; replaceMode: AgentPsdTemplateReplaceMode; + contentBounds?: AgentPsdTemplateImageContentBounds; placement?: Partial | null; }) { const { containerWidth, containerHeight, imageWidth, imageHeight, replaceMode } = params; const placement = normalizeAgentPsdImagePlacement(params.placement); - const baseFrame = resolveBaseFittedSize({ + const contentBounds = normalizeContainerBounds({ containerWidth, containerHeight, + contentBounds: params.contentBounds, + }); + const baseFrame = resolveBaseFittedSize({ + containerWidth: contentBounds.width, + containerHeight: contentBounds.height, imageWidth, imageHeight, replaceMode, @@ -89,8 +132,16 @@ export function resolveAgentPsdImagePlacementFrame(params: { const width = baseFrame.width * placement.scale; const height = baseFrame.height * placement.scale; - const left = baseFrame.left - (width - baseFrame.width) / 2 + placement.offsetX; - const top = baseFrame.top - (height - baseFrame.height) / 2 + placement.offsetY; + const left = + contentBounds.left + + baseFrame.left - + (width - baseFrame.width) / 2 + + placement.offsetX; + const top = + contentBounds.top + + baseFrame.top - + (height - baseFrame.height) / 2 + + placement.offsetY; return { width, diff --git a/lib/agents/psd-template-config.ts b/lib/agents/psd-template-config.ts index a312d7a83..a1db31098 100644 --- a/lib/agents/psd-template-config.ts +++ b/lib/agents/psd-template-config.ts @@ -2,7 +2,7 @@ import type { AgentPsdTextColorConfig } from "@/types/agent"; import type { AgentPsdTemplateSecondaryCategoryConfig } from "@/lib/agents/psd-template-secondary-category-config"; export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates"; -export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 3; +export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 4; export type AgentPsdTemplateStatus = "active" | "disabled"; export type AgentPsdTemplateParseStatus = @@ -15,6 +15,13 @@ export type AgentPsdTemplateSlotType = "image" | "text"; export type AgentPsdTextAlign = "left" | "center" | "right"; export type AgentPsdTextStyleSource = "auto" | "custom"; +export interface AgentPsdTemplateImageContentBounds { + left: number; + top: number; + width: number; + height: number; +} + export interface AgentPsdTemplateBaseLayerSlot { id: string; layerId: string; @@ -39,6 +46,7 @@ export interface AgentPsdTemplateImageLayerSlot isDefaultResultSource: boolean; allowUserUploadOverride: boolean; replaceMode: AgentPsdTemplateReplaceMode; + contentBounds?: AgentPsdTemplateImageContentBounds; maskUrl?: string; maskPath?: string; } @@ -204,6 +212,49 @@ export function normalizeAgentPsdTemplateReplaceMode( return "cover"; } +function normalizeAgentPsdTemplateImageContentBounds( + value: unknown, + containerWidth: number, + containerHeight: number +): AgentPsdTemplateImageContentBounds | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const record = value as Record; + const left = normalizePositiveInteger(record.left); + const top = normalizePositiveInteger(record.top); + const width = normalizePositiveInteger(record.width); + const height = normalizePositiveInteger(record.height); + + if (width <= 0 || height <= 0) { + return undefined; + } + + const clampedLeft = Math.min(left, Math.max(0, containerWidth - 1)); + const clampedTop = Math.min(top, Math.max(0, containerHeight - 1)); + const maxWidth = Math.max(1, containerWidth - clampedLeft); + const maxHeight = Math.max(1, containerHeight - clampedTop); + const clampedWidth = Math.min(width, maxWidth); + const clampedHeight = Math.min(height, maxHeight); + + if ( + clampedLeft === 0 && + clampedTop === 0 && + clampedWidth === containerWidth && + clampedHeight === containerHeight + ) { + return undefined; + } + + return { + left: clampedLeft, + top: clampedTop, + width: clampedWidth, + height: clampedHeight, + }; +} + export function normalizeAgentPsdTemplateSlotType( value: unknown ): AgentPsdTemplateSlotType { @@ -311,6 +362,11 @@ export function parseAgentPsdTemplateLayers(value: unknown) { isDefaultResultSource: isEditable && record.isDefaultResultSource === true, allowUserUploadOverride: isEditable ? record.allowUserUploadOverride !== false : false, replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode), + contentBounds: normalizeAgentPsdTemplateImageContentBounds( + record.contentBounds, + width, + height + ), maskUrl: normalizeOptionalUrl(record.maskUrl), maskPath: normalizeText(record.maskPath) || undefined, }); diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts index 0bf9dbc6d..a6403f207 100644 --- a/lib/agents/psd-template-parser.ts +++ b/lib/agents/psd-template-parser.ts @@ -6,6 +6,7 @@ import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, + type AgentPsdTemplateImageContentBounds, type AgentPsdTemplateLayerSlot, } from "@/lib/agents/psd-template-config"; @@ -104,6 +105,44 @@ export interface PsdTemplateSourceLayerEntry { composite: () => Promise; } +function resolveVisibleContentBounds(params: { + rgba: Uint8ClampedArray; + width: number; + height: number; +}): AgentPsdTemplateImageContentBounds | undefined { + const { rgba, width, height } = params; + const pixelCount = width * height; + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + + for (let index = 0; index < pixelCount; index += 1) { + const alpha = rgba[index * 4 + 3] || 0; + if (alpha <= 0) { + continue; + } + + const x = index % width; + const y = Math.floor(index / width); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + if (maxX < minX || maxY < minY) { + return undefined; + } + + return { + left: minX, + top: minY, + width: maxX - minX + 1, + height: maxY - minY + 1, + }; +} + function normalizeLayerName(value: unknown, fallback: string) { const text = String(value || "").trim(); return text || fallback; @@ -522,6 +561,11 @@ export async function preparePsdTemplateRenderAssets(params: { try { const rgba = await entry.node.composite(); + const visibleContentBounds = resolveVisibleContentBounds({ + rgba, + width: entry.layer.width, + height: entry.layer.height, + }); if (params.forceRefreshLayerAssets || !targetLayer.previewUrl) { const thumbnailBuffer = await createLayerThumbnailBuffer({ @@ -623,6 +667,13 @@ export async function preparePsdTemplateRenderAssets(params: { !targetLayer.isEditable || (!params.forceRefreshLayerAssets && targetLayer.maskUrl) ) { + const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); + if (layerIndex >= 0 && isAgentPsdImageLayerSlot(nextLayers[layerIndex])) { + nextLayers[layerIndex] = { + ...nextLayers[layerIndex], + contentBounds: visibleContentBounds, + }; + } continue; } @@ -658,6 +709,7 @@ export async function preparePsdTemplateRenderAssets(params: { if (layerIndex >= 0 && isAgentPsdImageLayerSlot(nextLayers[layerIndex])) { nextLayers[layerIndex] = { ...nextLayers[layerIndex], + contentBounds: visibleContentBounds, maskUrl: uploaded.url, maskPath: uploaded.pathname, }; diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index 5f9262a0a..d28c0dfbb 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -466,6 +466,7 @@ export async function renderAgentPsdTemplate(params: { imageWidth: Math.max(1, Number(metadata.width || 1)), imageHeight: Math.max(1, Number(metadata.height || 1)), replaceMode: layer.replaceMode, + contentBounds: layer.contentBounds, placement: imagePlacements[layer.id], });