fix: support PSD clipping mask target layers

This commit is contained in:
Your Name 2026-06-01 20:03:11 +08:00
parent c00b9e333d
commit cffed9ab50
4 changed files with 128 additions and 15 deletions

View File

@ -96,6 +96,7 @@ const SYSTEM_FONT_OPTIONS = [
const EMPTY_CATEGORY_SELECT_VALUE = "__none__";
const EMPTY_BRAND_SELECT_VALUE = "__unassigned__";
const ALL_TEMPLATE_BRANDS_SELECT_VALUE = "__all_template_brands__";
const SELF_CLIP_MASK_SELECT_VALUE = "__self_clip_mask__";
const UNASSIGNED_BRAND_LABEL = "未分类";
const MAX_PSD_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024;
const PSD_TEMPLATE_MODE_OPTIONS: Array<{
@ -1679,7 +1680,22 @@ export function PsdTemplatesSettingsClient({
: "当前筛选条件下没有图层,请换个关键词试试。"}
</div>
) : (
filteredLayers.map((layer, index) => (
filteredLayers.map((layer, index) => {
const clipMaskLayerOptions =
layer.slotType === "image"
? draftTemplate.layers.filter(
(candidate) =>
candidate.id !== layer.id &&
(candidate.previewUrl || candidate.sourceUrl) &&
!(
candidate.slotType === "text" &&
!candidate.previewUrl &&
!candidate.sourceUrl
)
)
: [];
return (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
@ -1836,6 +1852,9 @@ export function PsdTemplatesSettingsClient({
forceClipMask: checked
? false
: current.forceClipMask,
clipMaskLayerId: checked
? undefined
: current.clipMaskLayerId,
};
}
@ -1875,6 +1894,9 @@ export function PsdTemplatesSettingsClient({
forceClipMask: checked
? current.forceClipMask
: false,
clipMaskLayerId: checked
? current.clipMaskLayerId
: undefined,
}
: {
...current,
@ -1994,7 +2016,7 @@ export function PsdTemplatesSettingsClient({
<div className="space-y-0.5">
<div className="text-sm font-medium">强制剪切蒙版</div>
<div className="text-xs text-muted-foreground">
开启后替换图会按当前 PSD 图层原始透明区域剪切,适合需要贴合固定形状的图片槽位。
开启后替换图会按指定目标图层的透明区域剪切,适合替换图需要贴合超级锁或固定形状的图片槽位。
</div>
</div>
<Switch
@ -2008,6 +2030,10 @@ export function PsdTemplatesSettingsClient({
forceClipMask: current.isEditable
? checked
: false,
clipMaskLayerId:
current.isEditable && checked
? current.clipMaskLayerId
: undefined,
}
: current
)
@ -2015,6 +2041,55 @@ export function PsdTemplatesSettingsClient({
/>
</div>
{layer.forceClipMask ? (
<div className="space-y-2 rounded-xl border p-3 md:col-span-2 xl:col-span-3">
<div className="text-sm font-medium">剪切蒙版目标图层</div>
<Select
value={
layer.clipMaskLayerId ||
SELF_CLIP_MASK_SELECT_VALUE
}
disabled={
!layer.isEditable ||
layer.isSuperLocked === true
}
onValueChange={(value) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
clipMaskLayerId:
value === SELF_CLIP_MASK_SELECT_VALUE
? undefined
: value,
}
: current
)
}
>
<SelectTrigger>
<SelectValue placeholder="选择用于裁切的目标图层" />
</SelectTrigger>
<SelectContent>
<SelectItem value={SELF_CLIP_MASK_SELECT_VALUE}>
使用当前替换图层自身透明区域
</SelectItem>
{clipMaskLayerOptions.map((candidate) => (
<SelectItem
key={`${layer.id}-clip-mask-${candidate.id}`}
value={candidate.id}
>
{candidate.layerName} · {candidate.width} x {candidate.height}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
如果 PSD 里目标形状是超级锁图层,请在这里选择那个目标图层;前台只能移动替换图,目标图仍保持锁定不可选中。
</div>
</div>
) : null}
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">填充模式</div>
<Select
@ -2327,7 +2402,8 @@ export function PsdTemplatesSettingsClient({
</div>
)}
</div>
))
);
})
)}
</div>
</ScrollArea>

View File

@ -1488,12 +1488,18 @@ function renderTextLayerPreviewContent(
}
function getAssignedImageMaskStyle(
layer: AgentPsdTemplateImageLayerSlot
layer: AgentPsdTemplateImageLayerSlot,
layers: AgentPsdTemplateLayerSlot[]
): CSSProperties {
const clipMaskLayer = layer.clipMaskLayerId
? layers.find((item) => item.id === layer.clipMaskLayerId)
: null;
const fallbackMaskUrl = layer.forceClipMask
? getPsdLayerPreviewSourceUrl(layer)
? getPsdLayerPreviewSourceUrl(clipMaskLayer || layer)
: "";
const normalizedMaskUrl = String(layer.maskUrl || fallbackMaskUrl || "").trim();
const normalizedMaskUrl = String(
layer.maskUrl || fallbackMaskUrl || ""
).trim();
const hasMask = Boolean(normalizedMaskUrl);
const maskImageUrl = hasMask
? `url(${buildPsdLayerMaskUrl(normalizedMaskUrl, layer.width)})`
@ -4162,6 +4168,11 @@ export function AgentPsdTemplateEditorDialog({
if (isAgentPsdImageLayerSlot(layer)) {
const assignedUrl =
effectiveImageAssignments[layer.id];
const isDefaultAssignedImageLayer =
layer.id ===
getDefaultSourceImageLayer(
selectedTemplateLayers
)?.id;
const layerImageUrl =
assignedUrl ||
getPsdLayerPreviewSourceUrl(layer);
@ -4171,11 +4182,21 @@ export function AgentPsdTemplateEditorDialog({
const assignedImageContainerStyle = assignedUrl
? {
...layerBoxStyle,
...getAssignedImageMaskStyle(layer),
...getAssignedImageMaskStyle(
layer,
selectedTemplateLayers
),
}
: layerBoxStyle;
const previewZIndex = assignedUrl
? Math.max(baseZIndex, renderBaseZIndex + 1)
? Math.max(
baseZIndex,
renderBaseZIndex + 1,
isDefaultAssignedImageLayer
? (orderedPreviewLayers.length + 1) *
PSD_LAYER_Z_INDEX_STEP
: 0
)
: baseZIndex;
return (

View File

@ -50,6 +50,7 @@ export interface AgentPsdTemplateImageLayerSlot
isDefaultResultSource: boolean;
allowUserUploadOverride: boolean;
forceClipMask?: boolean;
clipMaskLayerId?: string;
replaceMode: AgentPsdTemplateReplaceMode;
contentBounds?: AgentPsdTemplateImageContentBounds;
maskUrl?: string;
@ -417,6 +418,10 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
allowUserUploadOverride:
isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false,
forceClipMask: isEditable && !isSuperLocked && record.forceClipMask === true,
clipMaskLayerId:
isEditable && !isSuperLocked && record.forceClipMask === true
? normalizeText(record.clipMaskLayerId) || undefined
: undefined,
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
contentBounds: normalizeAgentPsdTemplateImageContentBounds(
record.contentBounds,

View File

@ -596,6 +596,13 @@ export async function renderAgentPsdTemplate(params: {
if (assignmentUrl) {
pushAsset(assignmentUrl, "PSD 套版素材");
pushAsset(layer.maskUrl, "PSD 蒙版资源", true);
if (layer.forceClipMask && layer.clipMaskLayerId) {
pushAsset(
getStoredLayerSourceUrl(layerMap.get(layer.clipMaskLayerId) || layer),
"PSD 剪切蒙版目标资源",
true
);
}
} else {
pushAsset(
storedLayerSourceUrl,
@ -995,6 +1002,9 @@ export async function renderAgentPsdTemplate(params: {
const layerPositionOffset = getLayerPositionOffset(layer.id);
if (!layer.maskUrl && !layer.forceClipMask) {
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
const didAppend = await appendBufferOverlay({
input: slotOverlay.input,
left:
@ -1008,9 +1018,6 @@ export async function renderAgentPsdTemplate(params: {
width: slotOverlay.width,
height: slotOverlay.height,
});
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
if (!didAppend) continue;
continue;
}
@ -1035,9 +1042,12 @@ export async function renderAgentPsdTemplate(params: {
const shouldUseLayerSourceAsMask = !layer.maskUrl && layer.forceClipMask;
if (layer.maskUrl || shouldUseLayerSourceAsMask) {
try {
const clipMaskLayer = layer.clipMaskLayerId
? layerMap.get(layer.clipMaskLayerId)
: null;
const rawMaskBuffer = layer.maskUrl
? await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源")
: await readReusableLayerBuffer(layer);
: await readReusableLayerBuffer(clipMaskLayer || layer);
if (!rawMaskBuffer) {
throw new Error("Missing PSD layer mask source");
}
@ -1076,6 +1086,10 @@ export async function renderAgentPsdTemplate(params: {
}
}
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x + layerPositionOffset.offsetX,
@ -1083,9 +1097,6 @@ export async function renderAgentPsdTemplate(params: {
width: layer.width,
height: layer.height,
});
if (isDefaultSourceImageLayer) {
await appendRenderBaseOverlay();
}
if (!didAppend) continue;
continue;
}