fix: correct psd masked image placement
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-02 10:14:45 +08:00
parent 305dd21dd5
commit 8c0ce434dc
5 changed files with 168 additions and 7 deletions

View File

@ -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({
<div className="grid min-h-0 flex-1 gap-4 xl:grid-rows-[minmax(0,1fr)_minmax(0,0.78fr)]">
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
{selectedImageLayer ? (
<div className="space-y-3">
<div className="min-h-0 space-y-3 overflow-y-auto pr-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">可替换图片</Badge>
<div className="text-sm font-medium">{selectedImageLayer.layerName}</div>
@ -1727,7 +1728,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
) : selectedTextLayer ? (
<div className="space-y-3">
<div className="min-h-0 space-y-3 overflow-y-auto pr-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">可编辑文字</Badge>
<div className="text-sm font-medium">{selectedTextLayer.layerName}</div>

View File

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

View File

@ -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<string, unknown>;
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,
});

View File

@ -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<Uint8ClampedArray>;
}
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,
};

View File

@ -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],
});