fix: refine PSD templates and result exports
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-05-14 20:29:27 +08:00
parent ca7b709de0
commit edc24cb956
14 changed files with 2097 additions and 1383 deletions

View File

@ -8,6 +8,7 @@ import {
AGENT_PSD_TEMPLATE_ASSET_VERSION,
getEditableAgentPsdTemplateLayers,
isAgentPsdTextLayerSlot,
isAgentPsdTemplateLayerUserEditable,
normalizeAgentPsdTemplateMode,
normalizeAgentPsdTemplateStatus,
parseAgentPsdTemplateLayers,
@ -161,7 +162,10 @@ export async function PUT(
const fonts = await readAgentPsdFonts();
const fontMap = new Map(fonts.map((font) => [font.id, font]));
for (const layer of nextLayers) {
if (!layer.isEditable || !isAgentPsdTextLayerSlot(layer)) {
if (
!isAgentPsdTemplateLayerUserEditable(layer) ||
!isAgentPsdTextLayerSlot(layer)
) {
continue;
}

View File

@ -185,7 +185,8 @@ async function fetchDownloadFile(params: {
params.file.name || `canvas-image-${params.index + 1}`,
`canvas-image-${params.index + 1}`
);
const filename = `${baseName}-${params.index + 1}.${ext}`;
const sequencePrefix = String(params.index + 1).padStart(3, "0");
const filename = `${sequencePrefix}-${baseName}.${ext}`;
return { ok: true, filename, bytes };
} catch (error) {
lastError = normalizeDownloadError(error);

View File

@ -2915,6 +2915,28 @@ export function AgentGenerationWorkspaceDarkV2({
).length,
[skuResultCards]
);
const detailReplacementBatchDownloadUrls = useMemo(
() =>
detailResultCards
.map((card) => card.resultUrl)
.filter((url): url is string => Boolean(url)),
[detailResultCards]
);
const skuReplacementBatchDownloadUrls = useMemo(
() =>
skuResultCards
.map((card) => card.resultUrl)
.filter((url): url is string => Boolean(url)),
[skuResultCards]
);
const detailReplacementOrderedDownloadUrls =
detailReplacementBatchDownloadUrls.length > 0
? detailReplacementBatchDownloadUrls
: mergedResultCollections.detail_replacement;
const skuReplacementOrderedDownloadUrls =
skuReplacementBatchDownloadUrls.length > 0
? skuReplacementBatchDownloadUrls
: mergedResultCollections.sku_replacement;
const detailRemainingCount = useMemo(
() =>
detailResultCards.filter(
@ -3384,13 +3406,9 @@ export function AgentGenerationWorkspaceDarkV2({
zipName: string;
scope: "scene" | "detail" | "sku";
}) => {
const normalizedUrls = Array.from(
new Set(
(params.imageUrls || [])
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
);
const normalizedUrls = (params.imageUrls || [])
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item));
if (normalizedUrls.length === 0) {
toast.error("当前没有可下载的结果图");
return;
@ -3398,6 +3416,12 @@ export function AgentGenerationWorkspaceDarkV2({
setBatchDownloadState(params.scope);
try {
const batchFileBaseName =
params.scope === "sku"
? "sku-result"
: params.scope === "scene"
? "scene-result"
: "detail-result";
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/canvas/download-zip";
@ -3408,11 +3432,9 @@ export function AgentGenerationWorkspaceDarkV2({
payloadInput.name = "payload";
payloadInput.value = JSON.stringify({
folderName: params.zipName,
files: normalizedUrls.map((imageUrl, index) => ({
files: normalizedUrls.map((imageUrl) => ({
url: imageUrl,
name:
buildDownloadFilename(imageUrl) ||
`${params.zipName}-${String(index + 1).padStart(2, "0")}`,
name: `${params.zipName}-${batchFileBaseName}`,
})),
});
form.appendChild(payloadInput);
@ -6426,12 +6448,12 @@ export function AgentGenerationWorkspaceDarkV2({
);
})}
{activeResultModeType === "detail_replacement" &&
mergedResultCollections.detail_replacement.length > 0 ? (
detailReplacementOrderedDownloadUrls.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: mergedResultCollections.detail_replacement,
imageUrls: detailReplacementOrderedDownloadUrls,
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
scope: "detail",
})
@ -6460,12 +6482,12 @@ export function AgentGenerationWorkspaceDarkV2({
</button>
) : null}
{activeResultModeType === "sku_replacement" &&
mergedResultCollections.sku_replacement.length > 0 ? (
skuReplacementOrderedDownloadUrls.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: mergedResultCollections.sku_replacement,
imageUrls: skuReplacementOrderedDownloadUrls,
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
scope: "sku",
})

View File

@ -70,6 +70,7 @@ import {
type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot,
isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked,
isAgentPsdTextLayerSlot,
parseAgentPsdTemplates,
resolveAgentPsdTextLayerEffectiveAlign,
@ -203,6 +204,33 @@ const templateDetailFetcher = async (
}
};
const PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY = 4;
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<R>
) {
const results = new Array<R>(items.length);
let nextIndex = 0;
const workerCount = Math.max(
1,
Math.min(items.length, Math.round(Number(concurrency || 1)))
);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(items[currentIndex]!, currentIndex);
}
})
);
return results;
}
async function parseActionJsonResponse<T>(
response: Response,
fallbackMessage: string,
@ -846,6 +874,9 @@ function createInitialState(
for (const layer of template.layers) {
layerOrderIds.push(layer.id);
if (isAgentPsdLayerSuperLocked(layer)) {
continue;
}
if (
isAgentPsdImageLayerSlot(layer) &&
layer.isDefaultResultSource &&
@ -955,6 +986,7 @@ function resolveTransferredSelectedLayerId(params: {
const nextCandidateLayers = nextTemplate.layers.filter(
(layer) =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
((isAgentPsdImageLayerSlot(previousLayer) &&
isAgentPsdImageLayerSlot(layer)) ||
(isAgentPsdTextLayerSlot(previousLayer) &&
@ -1008,6 +1040,9 @@ function createTemplateEditorState(params: {
const usedPreviousLayerIds = new Set<string>();
for (const nextLayer of nextTemplate.layers) {
if (isAgentPsdLayerSuperLocked(nextLayer)) {
continue;
}
const matchedPreviousLayer = findMatchingLayer({
nextLayer,
previousLayers: previousTemplate.layers,
@ -1019,6 +1054,10 @@ function createTemplateEditorState(params: {
const previousLayerPositionOffset =
previousLayerPositionOffsets[matchedPreviousLayer.id];
if (isAgentPsdLayerSuperLocked(matchedPreviousLayer)) {
usedPreviousLayerIds.add(matchedPreviousLayer.id);
continue;
}
if (hasLayerPositionOffset(previousLayerPositionOffset)) {
nextState.layerPositionOffsets[nextLayer.id] =
normalizeLayerPositionOffset(previousLayerPositionOffset);
@ -1029,11 +1068,15 @@ function createTemplateEditorState(params: {
const previousEditableImageLayers = previousTemplate.layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
);
const nextEditableImageLayers = nextTemplate.layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
);
const usedPreviousImageLayerIds = new Set<string>();
const usedNextImageLayerIds = new Set<string>();
@ -1091,11 +1134,15 @@ function createTemplateEditorState(params: {
const previousEditableTextLayers = previousTemplate.layers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable && isAgentPsdTextLayerSlot(layer)
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
);
const nextEditableTextLayers = nextTemplate.layers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable && isAgentPsdTextLayerSlot(layer)
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
);
const usedPreviousTextLayerIds = new Set<string>();
@ -1214,7 +1261,9 @@ function getPreferredSelectedLayerId(template: AgentPsdTemplate | null) {
return "";
}
const editableLayers = template.layers.filter((layer) => layer.isEditable);
const editableLayers = template.layers.filter(
(layer) => layer.isEditable && !isAgentPsdLayerSuperLocked(layer)
);
if (editableLayers.length === 0) {
return "";
}
@ -1448,14 +1497,6 @@ function hasCustomizedTextLayerAppearance(
);
}
function getTextLayerNameBadgeClassName() {
return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/70 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md";
}
function getTextLayerHintBadgeClassName() {
return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md";
}
export function AgentPsdTemplateEditorDialog({
open,
onOpenChange,
@ -1659,6 +1700,9 @@ export function AgentPsdTemplateEditorDialog({
const textColorAssignmentsRef = useRef(textColorAssignments);
const textPositionOffsetsRef = useRef(textPositionOffsets);
const archivedRenderImageUrlCacheRef = useRef<Record<string, string>>({});
const archivedRenderImageUrlPromiseCacheRef = useRef<
Record<string, Promise<string>>
>({});
const selectedLayerIdRef = useRef(selectedLayerId);
imageAssignmentsRef.current = imageAssignments;
@ -1672,6 +1716,7 @@ export function AgentPsdTemplateEditorDialog({
useEffect(() => {
if (!open) {
archivedRenderImageUrlCacheRef.current = {};
archivedRenderImageUrlPromiseCacheRef.current = {};
}
}, [open]);
@ -1792,6 +1837,10 @@ export function AgentPsdTemplateEditorDialog({
}
return selectedTemplate.layers;
}, [selectedTemplate]);
const selectedTemplateLayerById = useMemo(
() => new Map(selectedTemplateLayers.map((layer) => [layer.id, layer])),
[selectedTemplateLayers]
);
const selectedTemplateMode: AgentPsdTemplateMode =
selectedTemplate?.mode === "sku" ? "sku" : "main";
const selectedModeTemplates = useMemo(
@ -1842,7 +1891,9 @@ export function AgentPsdTemplateEditorDialog({
() =>
selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
),
[selectedTemplateLayers]
);
@ -1850,7 +1901,9 @@ export function AgentPsdTemplateEditorDialog({
() =>
selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable && isAgentPsdTextLayerSlot(layer)
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
),
[selectedTemplateLayers]
);
@ -1922,7 +1975,15 @@ export function AgentPsdTemplateEditorDialog({
[hiddenLayerIdSet, orderedPreviewLayers]
);
const orderedEditableLayers = useMemo(
() => orderedPreviewLayers.filter((layer) => layer.isEditable),
() =>
orderedPreviewLayers.filter(
(layer) => layer.isEditable && !isAgentPsdLayerSuperLocked(layer)
),
[orderedPreviewLayers]
);
const orderedUserManageablePreviewLayers = useMemo(
() =>
orderedPreviewLayers.filter((layer) => !isAgentPsdLayerSuperLocked(layer)),
[orderedPreviewLayers]
);
const orderedEditableImageLayers = useMemo(
@ -2028,6 +2089,9 @@ export function AgentPsdTemplateEditorDialog({
}
for (const layer of selectedTemplateLayers) {
if (isAgentPsdLayerSuperLocked(layer)) {
continue;
}
nextOffsets[layer.id] = normalizeLayerPositionOffset(
layerPositionOffsets[layer.id]
);
@ -2059,12 +2123,12 @@ export function AgentPsdTemplateEditorDialog({
selectedTemplate,
]);
const selectedLayer = useMemo(
() =>
selectedTemplateLayers.find((layer) => layer.id === selectedLayerId) ||
null,
[selectedLayerId, selectedTemplateLayers]
);
const selectedLayer = useMemo(() => {
const layer =
selectedTemplateLayers.find((entry) => entry.id === selectedLayerId) ||
null;
return layer && !isAgentPsdLayerSuperLocked(layer) ? layer : null;
}, [selectedLayerId, selectedTemplateLayers]);
const isSelectedLayerHidden = Boolean(
selectedLayer && hiddenLayerIdSet.has(selectedLayer.id)
);
@ -2101,6 +2165,10 @@ export function AgentPsdTemplateEditorDialog({
if (!normalizedLayerId) {
return;
}
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setHiddenLayerIds((current) =>
current.includes(normalizedLayerId)
? current
@ -2113,6 +2181,10 @@ export function AgentPsdTemplateEditorDialog({
if (!normalizedLayerId) {
return;
}
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (layer && isAgentPsdLayerSuperLocked(layer)) {
return;
}
setHiddenLayerIds((current) =>
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
);
@ -2669,7 +2741,9 @@ export function AgentPsdTemplateEditorDialog({
}
const validLayerIds = new Set(
selectedTemplateLayers.map((layer) => layer.id)
selectedTemplateLayers
.filter((layer) => !isAgentPsdLayerSuperLocked(layer))
.map((layer) => layer.id)
);
setHiddenLayerIds((current) =>
current.filter((layerId) => validLayerIds.has(layerId))
@ -2994,6 +3068,10 @@ export function AgentPsdTemplateEditorDialog({
current: AgentPsdLayerPositionOffset
) => Partial<AgentPsdLayerPositionOffset>
) {
const layer = selectedTemplateLayerById.get(String(layerId || "").trim());
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setLayerPositionOffsets((current) => ({
...current,
[layerId]: normalizeLayerPositionOffset({
@ -3011,6 +3089,10 @@ export function AgentPsdTemplateEditorDialog({
if (!normalizedLayerId) {
return;
}
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setLayerOrderIds((current) => {
const fallbackOrder = selectedTemplateLayers.map((layer) => layer.id);
@ -3111,89 +3193,126 @@ export function AgentPsdTemplateEditorDialog({
if (cachedUrl) {
return cachedUrl;
}
const fetchCandidates = Array.from(
new Set(
[
normalizedUrl,
buildImageProxyUrl(normalizedUrl, { delivery: "proxy" }),
].filter(Boolean)
)
);
let lastError: unknown = null;
for (const candidateUrl of fetchCandidates) {
try {
const response = await fetch(candidateUrl, {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`${label}读取失败(${response.status})`);
}
const blob = await response.blob();
if (!blob.size) {
throw new Error(`${label}读取结果为空`);
}
const extension = inferImageExtension(
normalizedUrl,
blob.type || response.headers.get("content-type") || ""
);
const formData = new FormData();
formData.set(
"file",
new File(
[blob],
`psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`,
{
type: blob.type || "image/png",
}
)
);
const uploadResponse = await fetchUploadWithRetry(formData);
const uploadPayload = await parseActionJsonResponse<{ url?: string }>(
uploadResponse,
`${label}转存失败`,
getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试")
);
const uploadedUrl = String(uploadPayload?.url || "").trim();
if (!uploadedUrl) {
throw new Error(`${label}转存失败`);
}
archivedRenderImageUrlCacheRef.current[normalizedUrl] = uploadedUrl;
return uploadedUrl;
} catch (error) {
lastError = error;
}
const cachedPromise =
archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
if (cachedPromise) {
return cachedPromise;
}
throw new Error(
getUserFacingRequestErrorMessage(
lastError,
`${label}无法转存到本站存储,请重新上传图片后再试`
)
);
const archivePromise = (async () => {
const fetchCandidates = Array.from(
new Set(
[
normalizedUrl,
buildImageProxyUrl(normalizedUrl, { delivery: "proxy" }),
].filter(Boolean)
)
);
let lastError: unknown = null;
for (const candidateUrl of fetchCandidates) {
try {
const response = await fetch(candidateUrl, {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`${label}读取失败(${response.status})`);
}
const blob = await response.blob();
if (!blob.size) {
throw new Error(`${label}读取结果为空`);
}
const extension = inferImageExtension(
normalizedUrl,
blob.type || response.headers.get("content-type") || ""
);
const formData = new FormData();
formData.set(
"file",
new File(
[blob],
`psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`,
{
type: blob.type || "image/png",
}
)
);
const uploadResponse = await fetchUploadWithRetry(formData);
const uploadPayload = await parseActionJsonResponse<{ url?: string }>(
uploadResponse,
`${label}转存失败`,
getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试")
);
const uploadedUrl = String(uploadPayload?.url || "").trim();
if (!uploadedUrl) {
throw new Error(`${label}转存失败`);
}
archivedRenderImageUrlCacheRef.current[normalizedUrl] = uploadedUrl;
return uploadedUrl;
} catch (error) {
lastError = error;
}
}
throw new Error(
getUserFacingRequestErrorMessage(
lastError,
`${label}无法转存到本站存储,请重新上传图片后再试`
)
);
})();
archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl] =
archivePromise;
try {
return await archivePromise;
} finally {
delete archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
}
}
async function normalizeRenderImageInputs() {
const nextSourceImageUrl = await archiveRenderImageUrl(
sourceImageUrl || "",
"PSD 套版主图"
);
let nextSourceImageUrl = sourceImageUrl || "";
const nextImageAssignments = { ...effectiveImageAssignments };
const layerNameById = new Map(
editableImageLayers.map((layer) => [layer.id, layer.layerName])
);
const archiveJobs = [
{
type: "source" as const,
layerId: "",
url: sourceImageUrl || "",
label: "PSD 套版主图",
},
...Object.entries(nextImageAssignments).map(([layerId, layerUrl]) => ({
type: "assignment" as const,
layerId,
url: layerUrl,
label: `PSD 图层素材「${layerNameById.get(layerId) || layerId}」`,
})),
].filter((job) => String(job.url || "").trim());
for (const [layerId, layerUrl] of Object.entries(nextImageAssignments)) {
nextImageAssignments[layerId] = await archiveRenderImageUrl(
layerUrl,
`PSD 图层素材「${layerNameById.get(layerId) || layerId}」`
);
const archivedJobs = await mapWithConcurrency(
archiveJobs,
PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY,
async (job) => ({
...job,
archivedUrl: await archiveRenderImageUrl(job.url, job.label),
})
);
for (const job of archivedJobs) {
if (job.type === "source") {
nextSourceImageUrl = job.archivedUrl;
} else {
nextImageAssignments[job.layerId] = job.archivedUrl;
}
}
return {
@ -3206,6 +3325,9 @@ export function AgentPsdTemplateEditorDialog({
layer: AgentPsdTemplateLayerSlot,
event: ReactPointerEvent<HTMLElement>
) {
if (isAgentPsdLayerSuperLocked(layer)) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelectedLayerId(layer.id);
@ -3264,6 +3386,9 @@ export function AgentPsdTemplateEditorDialog({
layer: AgentPsdTemplateTextLayerSlot,
event: ReactPointerEvent<HTMLDivElement>
) {
if (isAgentPsdLayerSuperLocked(layer)) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelectedLayerId(layer.id);
@ -3936,6 +4061,8 @@ export function AgentPsdTemplateEditorDialog({
<div className="pointer-events-none absolute inset-0">
{orderedVisiblePreviewLayers.map((layer) => {
const isLayerSuperLocked =
isAgentPsdLayerSuperLocked(layer);
const layerPositionOffset =
effectiveLayerPositionOffsets[layer.id] ||
normalizeLayerPositionOffset();
@ -3965,24 +4092,38 @@ export function AgentPsdTemplateEditorDialog({
return (
<div
className="pointer-events-auto absolute overflow-hidden"
className={`absolute overflow-hidden ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id}
onClick={(event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}}
onClick={
isLayerSuperLocked
? undefined
: (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) =>
handleLayerDragStart(layer, event)
isLayerSuperLocked
? undefined
: handleLayerDragStart(layer, event)
}
style={{
...assignedImageContainerStyle,
zIndex: baseZIndex,
cursor:
layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
isLayerSuperLocked
? "default"
: layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
@ -4046,24 +4187,38 @@ export function AgentPsdTemplateEditorDialog({
if (!shouldUseEditedPreview && layer.sourceUrl) {
return (
<div
className="pointer-events-auto absolute overflow-hidden"
className={`absolute overflow-hidden ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id}
onClick={(event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}}
onClick={
isLayerSuperLocked
? undefined
: (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) =>
handleTextDragStart(layer, event)
isLayerSuperLocked
? undefined
: handleTextDragStart(layer, event)
}
style={{
...layerBoxStyle,
zIndex: baseZIndex,
cursor:
textDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
isLayerSuperLocked
? "default"
: textDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
@ -4091,14 +4246,24 @@ export function AgentPsdTemplateEditorDialog({
return (
<div
className="pointer-events-auto absolute overflow-visible"
className={`absolute overflow-visible ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id}
onClick={(event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}}
onClick={
isLayerSuperLocked
? undefined
: (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) =>
handleTextDragStart(layer, event)
isLayerSuperLocked
? undefined
: handleTextDragStart(layer, event)
}
style={{
left:
@ -4115,11 +4280,15 @@ export function AgentPsdTemplateEditorDialog({
height: textFrame.height,
zIndex: baseZIndex,
cursor:
textDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
isLayerSuperLocked
? "default"
: textDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
@ -4135,7 +4304,11 @@ export function AgentPsdTemplateEditorDialog({
<div className="absolute inset-0">
{orderedVisiblePreviewLayers
.filter((layer) => !layer.isEditable)
.filter(
(layer) =>
!layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer)
)
.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const baseZIndex =
@ -4169,15 +4342,7 @@ export function AgentPsdTemplateEditorDialog({
touchAction: "none",
}}
title={`普通图层:${layer.layerName}`}
>
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
{layer.layerName}
</div>
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-white/85 px-2 py-1 text-[11px] text-black shadow-md backdrop-blur-md">
<Move className="h-3 w-3" />
拖动图层
</div>
</div>
/>
);
})}
@ -4218,15 +4383,7 @@ export function AgentPsdTemplateEditorDialog({
touchAction: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
{layer.layerName}
</div>
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
<Move className="h-3 w-3" />
拖动图层
</div>
</div>
/>
) : (
<button
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
@ -4255,18 +4412,7 @@ export function AgentPsdTemplateEditorDialog({
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="button"
>
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
{layer.layerName}
</div>
{hasAssignedImage ? null : (
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
<Move className="h-3 w-3" />
拖动图层 / 点击替换
</div>
)}
</button>
/>
);
})}
@ -4441,16 +4587,6 @@ export function AgentPsdTemplateEditorDialog({
)}
</div>
)}
<div
className={getTextLayerNameBadgeClassName()}
>
{layer.layerName}
</div>
<div
className={getTextLayerHintBadgeClassName()}
>
拖动文字 / 右侧微调
</div>
</div>
);
}
@ -4477,19 +4613,7 @@ export function AgentPsdTemplateEditorDialog({
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="button"
>
<div
className={getTextLayerNameBadgeClassName()}
>
{layer.layerName}
</div>
<div
className={getTextLayerHintBadgeClassName()}
>
点击编辑文字
</div>
</button>
/>
);
})}
</div>
@ -4973,7 +5097,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
)}
{selectedTemplateLayers.length > 0 ? (
{orderedUserManageablePreviewLayers.length > 0 ? (
<div className="flex shrink-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
@ -5010,7 +5134,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">
已隐藏 {hiddenLayerIds.length} /{" "}
{selectedTemplateLayers.length}
{orderedUserManageablePreviewLayers.length}
</div>
</div>
</div>
@ -5019,7 +5143,7 @@ export function AgentPsdTemplateEditorDialog({
ref={hiddenLayerScrollAreaRef}
>
<div className="space-y-2 pb-1">
{orderedPreviewLayers.map((layer) => {
{orderedUserManageablePreviewLayers.map((layer) => {
const isHidden = hiddenLayerIdSet.has(layer.id);
const isSelected = selectedLayerId === layer.id;
return (

View File

@ -115,6 +115,8 @@ const WORKFLOW_OPTION_PREVIEW_WIDTH = 512;
const WORKFLOW_OPTION_PREVIEW_QUALITY = 62;
const WORKFLOW_OPTION_PREVIEW_EAGER_COUNT = 10;
const WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT = 12;
const WORKFLOW_OPTION_NEXT_PREVIEW_PREFETCH_COUNT = 24;
const WORKFLOW_OPTION_PREFETCH_CACHE_LIMIT = 320;
function isLikelyStoredWorkflowPreview(url: string, sourceUrl?: string | null) {
const source = String(sourceUrl || "").trim();
@ -157,6 +159,33 @@ function handleWorkflowOptionPreviewError(
image.src = fallbackUrl;
}
function appendWorkflowOptionPreviewUrls(
output: Set<string>,
options: WorkflowStep["options"] | undefined,
limit: number
) {
for (const option of (options || []).slice(0, limit)) {
const previewUrl = buildWorkflowOptionPreviewUrl(
option.previewImageUrl,
option.previewImageSourceUrl
);
if (previewUrl) {
output.add(previewUrl);
}
}
}
function trimWorkflowPreviewImageCache(
cache: Map<string, HTMLImageElement>,
limit = WORKFLOW_OPTION_PREFETCH_CACHE_LIMIT
) {
while (cache.size > limit) {
const firstKey = cache.keys().next().value;
if (!firstKey) return;
cache.delete(firstKey);
}
}
interface WorkflowPanelDerivedState {
progress: ReturnType<typeof getAgentWorkflowProgress>;
}
@ -967,6 +996,10 @@ function AgentWorkflowPanelDarkV2Component({
const latestExternalSelectionsRef = useRef(selections);
const selectionCommitTimerRef = useRef<number | null>(null);
const pendingSelectionCommitRef = useRef<WorkflowSelectionState | null>(null);
const workflowPreviewPrefetchedUrlsRef = useRef(new Set<string>());
const workflowPreviewImageCacheRef = useRef(
new Map<string, HTMLImageElement>()
);
useEffect(() => {
if (latestExternalSelectionsRef.current === selections) {
@ -1141,20 +1174,80 @@ function AgentWorkflowPanelDarkV2Component({
activeEmbeddedOptionLevels[0] ||
null;
const workflowOptionPreviewPrefetchUrls = useMemo(() => {
const options = embedded
? activeEmbeddedOptionLevel?.options || []
: flattenedResolvedSteps[0]?.step.options || [];
return Array.from(
new Set(
options
.slice(0, WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT)
.map((option) =>
buildWorkflowOptionPreviewUrl(option.previewImageUrl, option.previewImageSourceUrl)
)
.filter(Boolean)
)
);
}, [activeEmbeddedOptionLevel?.options, embedded, flattenedResolvedSteps]);
const urls = new Set<string>();
if (embedded) {
appendWorkflowOptionPreviewUrls(
urls,
activeEmbeddedOptionLevel?.options,
WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT
);
const currentSelectedOption =
activeEmbeddedOptionLevel?.selectedOption || null;
appendWorkflowOptionPreviewUrls(
urls,
currentSelectedOption?.children,
WORKFLOW_OPTION_NEXT_PREVIEW_PREFETCH_COUNT
);
if (activeEmbeddedStepEntry) {
const currentStepComplete = isAgentWorkflowStepSelectionComplete({
step: activeEmbeddedStepEntry.step,
selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key],
});
if (currentStepComplete) {
const nextStepEntry = flattenedResolvedSteps
.slice(activeEmbeddedStepIndex + 1)
.find(
({ step }) =>
!isAgentWorkflowStepSelectionComplete({
step,
selectionValue: optimisticSelections[step.key],
})
);
if (nextStepEntry) {
const nextStepLevels = getWorkflowStepOptionLevels({
step: nextStepEntry.step,
selectionValue: optimisticSelections[nextStepEntry.step.key],
});
const nextStepFirstPendingLevel =
nextStepLevels.find((level) => !level.selectedKey) ||
nextStepLevels[nextStepLevels.length - 1] ||
null;
appendWorkflowOptionPreviewUrls(
urls,
nextStepFirstPendingLevel?.options || nextStepEntry.step.options,
WORKFLOW_OPTION_NEXT_PREVIEW_PREFETCH_COUNT
);
}
}
}
} else {
appendWorkflowOptionPreviewUrls(
urls,
flattenedResolvedSteps[0]?.step.options,
WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT
);
appendWorkflowOptionPreviewUrls(
urls,
flattenedResolvedSteps[1]?.step.options,
WORKFLOW_OPTION_NEXT_PREVIEW_PREFETCH_COUNT
);
}
return Array.from(urls);
}, [
activeEmbeddedOptionLevel,
activeEmbeddedStepEntry,
activeEmbeddedStepIndex,
embedded,
flattenedResolvedSteps,
optimisticSelections,
]);
const activeEmbeddedDeepestSelectedOption = useMemo(
() =>
[...activeEmbeddedOptionLevels]
@ -1257,23 +1350,33 @@ function AgentWorkflowPanelDarkV2Component({
return;
}
const cleanupRef = { current: () => {} };
cleanupRef.current = scheduleWorkflowIdleTask(() => {
const images = workflowOptionPreviewPrefetchUrls.map((src) => {
const urlsToPrefetch = workflowOptionPreviewPrefetchUrls.filter(
(src) => !workflowPreviewPrefetchedUrlsRef.current.has(src)
);
if (urlsToPrefetch.length === 0) {
return;
}
urlsToPrefetch.forEach((src) => {
workflowPreviewPrefetchedUrlsRef.current.add(src);
});
scheduleWorkflowIdleTask(() => {
urlsToPrefetch.forEach((src, index) => {
const image = new Image();
image.decoding = "async";
(
image as HTMLImageElement & {
fetchPriority?: "high" | "low" | "auto";
}
).fetchPriority =
index < WORKFLOW_OPTION_PREVIEW_EAGER_COUNT ? "high" : "low";
image.src = src;
return image;
workflowPreviewImageCacheRef.current.set(src, image);
});
cleanupRef.current = () => {
images.forEach((image) => {
image.src = "";
});
};
}, 120);
return () => cleanupRef.current();
trimWorkflowPreviewImageCache(workflowPreviewImageCacheRef.current);
}, 80);
}, [workflowOptionPreviewPrefetchUrls]);
useEffect(() => {

View File

@ -3,6 +3,141 @@ import "server-only";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_RETRY_COUNT = 3;
const RETRY_DELAY_MS = 1_000;
const DEFAULT_MEMORY_CACHE_TTL_MS = 10 * 60 * 1000;
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 256 * 1024 * 1024;
const DEFAULT_MEMORY_CACHE_MAX_ENTRY_BYTES = 32 * 1024 * 1024;
const remoteAssetCacheKey = "__agent_psd_remote_asset_cache__";
type RemoteAssetCacheEntry = {
buffer: Buffer;
byteLength: number;
expiresAt: number;
lastAccessAt: number;
};
type RemoteAssetCacheState = {
entries: Map<string, RemoteAssetCacheEntry>;
inFlight: Map<string, Promise<Buffer>>;
totalBytes: number;
};
function getRemoteAssetCacheState(): RemoteAssetCacheState {
const globalObj = globalThis as typeof globalThis & {
[remoteAssetCacheKey]?: RemoteAssetCacheState;
};
if (!globalObj[remoteAssetCacheKey]) {
globalObj[remoteAssetCacheKey] = {
entries: new Map<string, RemoteAssetCacheEntry>(),
inFlight: new Map<string, Promise<Buffer>>(),
totalBytes: 0,
};
}
return globalObj[remoteAssetCacheKey] as RemoteAssetCacheState;
}
function parsePositiveInteger(value: string | undefined, fallback: number) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getRemoteAssetCacheConfig() {
return {
ttlMs: parsePositiveInteger(
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_TTL_MS,
DEFAULT_MEMORY_CACHE_TTL_MS
),
maxBytes: parsePositiveInteger(
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_MAX_BYTES,
DEFAULT_MEMORY_CACHE_MAX_BYTES
),
maxEntryBytes: parsePositiveInteger(
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_MAX_ENTRY_BYTES,
DEFAULT_MEMORY_CACHE_MAX_ENTRY_BYTES
),
};
}
function isRemoteAssetMemoryCacheEnabled() {
return process.env.AGENT_PSD_REMOTE_ASSET_CACHE_DISABLED !== "1";
}
function isCacheableRemoteAssetUrl(url: string) {
return !/^(data|blob):/i.test(String(url || "").trim());
}
function pruneRemoteAssetCache(state: RemoteAssetCacheState, maxBytes: number) {
const now = Date.now();
for (const [key, entry] of state.entries) {
if (entry.expiresAt <= now) {
state.entries.delete(key);
state.totalBytes -= entry.byteLength;
}
}
if (state.totalBytes <= maxBytes) {
return;
}
const entriesByLastAccess = Array.from(state.entries.entries()).sort(
(left, right) => left[1].lastAccessAt - right[1].lastAccessAt
);
for (const [key, entry] of entriesByLastAccess) {
if (state.totalBytes <= maxBytes) break;
state.entries.delete(key);
state.totalBytes -= entry.byteLength;
}
}
function readRemoteAssetMemoryCache(url: string) {
if (!isRemoteAssetMemoryCacheEnabled() || !isCacheableRemoteAssetUrl(url)) {
return null;
}
const state = getRemoteAssetCacheState();
const entry = state.entries.get(url);
if (!entry) {
return null;
}
if (entry.expiresAt <= Date.now()) {
state.entries.delete(url);
state.totalBytes -= entry.byteLength;
return null;
}
entry.lastAccessAt = Date.now();
return entry.buffer;
}
function writeRemoteAssetMemoryCache(url: string, buffer: Buffer) {
if (!isRemoteAssetMemoryCacheEnabled() || !isCacheableRemoteAssetUrl(url)) {
return;
}
const { ttlMs, maxBytes, maxEntryBytes } = getRemoteAssetCacheConfig();
const byteLength = buffer.byteLength;
if (byteLength <= 0 || byteLength > maxEntryBytes) {
return;
}
const state = getRemoteAssetCacheState();
const previous = state.entries.get(url);
if (previous) {
state.totalBytes -= previous.byteLength;
}
state.entries.set(url, {
buffer,
byteLength,
expiresAt: Date.now() + ttlMs,
lastAccessAt: Date.now(),
});
state.totalBytes += byteLength;
pruneRemoteAssetCache(state, maxBytes);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
@ -51,6 +186,7 @@ export async function fetchRemoteAssetBuffer(
label?: string;
timeoutMs?: number;
retries?: number;
useMemoryCache?: boolean;
}
) {
const normalizedUrl = String(url || "").trim();
@ -62,54 +198,89 @@ export async function fetchRemoteAssetBuffer(
const attempts = Math.max(1, Math.round(Number(options?.retries || DEFAULT_RETRY_COUNT)));
const timeoutMs = Math.max(5_000, Math.round(Number(options?.timeoutMs || DEFAULT_TIMEOUT_MS)));
const location = describeRemoteAssetLocation(normalizedUrl);
const shouldUseMemoryCache = options?.useMemoryCache !== false;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(normalizedUrl, {
cache: "no-store",
signal: AbortSignal.timeout(timeoutMs),
});
if (shouldUseMemoryCache) {
const cachedBuffer = readRemoteAssetMemoryCache(normalizedUrl);
if (cachedBuffer) {
return cachedBuffer;
}
if (!response.ok) {
const statusError = new Error(`${label}下载失败(${response.status})`);
if (attempt < attempts && isRetryableStatus(response.status)) {
console.warn("[PSD Remote Asset] transient status, retrying download:", {
const cacheState = getRemoteAssetCacheState();
const inFlightRequest = cacheState.inFlight.get(normalizedUrl);
if (inFlightRequest) {
return inFlightRequest;
}
}
const downloadPromise = (async () => {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(normalizedUrl, {
cache: "no-store",
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const statusError = new Error(`${label}下载失败(${response.status})`);
if (attempt < attempts && isRetryableStatus(response.status)) {
console.warn("[PSD Remote Asset] transient status, retrying download:", {
label,
location,
status: response.status,
attempt,
attempts,
});
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
throw statusError;
}
const buffer = Buffer.from(await response.arrayBuffer());
if (shouldUseMemoryCache) {
writeRemoteAssetMemoryCache(normalizedUrl, buffer);
}
return buffer;
} catch (error) {
if (attempt < attempts && isRetryableFetchError(error)) {
console.warn("[PSD Remote Asset] transient fetch error, retrying download:", {
label,
location,
status: response.status,
attempt,
attempts,
error,
});
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
throw statusError;
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (attempt < attempts && isRetryableFetchError(error)) {
console.warn("[PSD Remote Asset] transient fetch error, retrying download:", {
console.error("[PSD Remote Asset] failed to download asset:", {
label,
location,
attempt,
attempts,
attemptsTried: attempt,
error,
});
await sleep(RETRY_DELAY_MS * attempt);
continue;
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
}
console.error("[PSD Remote Asset] failed to download asset:", {
label,
location,
attemptsTried: attempt,
error,
});
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
}
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
})();
if (shouldUseMemoryCache && isCacheableRemoteAssetUrl(normalizedUrl)) {
const cacheState = getRemoteAssetCacheState();
cacheState.inFlight.set(normalizedUrl, downloadPromise);
downloadPromise.then(
() => {
cacheState.inFlight.delete(normalizedUrl);
},
() => {
cacheState.inFlight.delete(normalizedUrl);
}
);
}
throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
return downloadPromise;
}

View File

@ -10,8 +10,10 @@ import {
getAgentPsdTemplateRuntimeIssue,
getEditableAgentPsdTemplateLayers,
isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked,
isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
@ -100,6 +102,11 @@ export async function executeAgentPsdTemplateRenderRequest(
.map((layerId) => String(layerId || "").trim())
.filter(Boolean)
);
for (const layer of resolvedTemplate.layers) {
if (isAgentPsdLayerSuperLocked(layer)) {
hiddenLayerIdSet.delete(layer.id);
}
}
const normalizedLayerOrderIds = Array.from(
new Set(
(body.layerOrderIds || [])
@ -137,11 +144,13 @@ export async function executeAgentPsdTemplateRenderRequest(
continue;
}
layerPositionOffsets[layer.id] = normalizeTextPositionOffset(
body.layerPositionOffsets[layer.id]
);
if (!isAgentPsdLayerSuperLocked(layer)) {
layerPositionOffsets[layer.id] = normalizeTextPositionOffset(
body.layerPositionOffsets[layer.id]
);
}
if (!layer.isEditable) {
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
continue;
}

View File

@ -15,14 +15,15 @@ interface QueueState {
draining: boolean;
}
const DEFAULT_CONCURRENCY = 1;
const DEFAULT_CONCURRENCY = 2;
const MAX_CONCURRENCY = 8;
const STALE_RUNNING_MS = 15 * 60 * 1000;
const queueStateKey = "__agent_psd_render_task_queue_state__";
function getQueueConcurrency(): number {
const raw = Number.parseInt(process.env.AGENT_PSD_RENDER_TASK_CONCURRENCY || "", 10);
if (Number.isFinite(raw) && raw > 0) {
return Math.min(raw, 3);
return Math.min(raw, MAX_CONCURRENCY);
}
return DEFAULT_CONCURRENCY;
}
@ -83,6 +84,7 @@ async function claimNextPendingTask(): Promise<string | null> {
async function runSingleTask(taskId: string) {
const state = getQueueState();
state.running.add(taskId);
const startedAtMs = Date.now();
console.log("[PSD Render Task] starting:", { taskId });
try {
@ -121,11 +123,13 @@ async function runSingleTask(taskId: string) {
.where(eq(agentPsdRenderTask.id, taskId));
console.log("[PSD Render Task] succeeded:", {
taskId,
durationMs: Date.now() - startedAtMs,
resultUrl: result.url,
});
} catch (error) {
console.error("[PSD Render Task] failed:", {
taskId,
durationMs: Date.now() - startedAtMs,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});

View File

@ -1,4 +1,7 @@
import type { AgentPsdTemplate } from "@/lib/agents/psd-template-config";
import {
getEditableAgentPsdTemplateLayers,
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
export interface AgentPsdTemplateCategoryTagConfig {
tags: string[];
@ -150,7 +153,7 @@ export function buildAgentPsdTemplateCategoryTagIndex(params: {
if (
template.status !== "active" ||
template.parseStatus !== "completed" ||
!template.layers.some((layer) => layer.isEditable)
getEditableAgentPsdTemplateLayers(template).length === 0
) {
continue;
}

View File

@ -36,6 +36,7 @@ export interface AgentPsdTemplateBaseLayerSlot {
width: number;
height: number;
isEditable: boolean;
isSuperLocked?: boolean;
isRequired: boolean;
previewUrl?: string;
previewPath?: string;
@ -338,6 +339,7 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
}
const isEditable = record.isEditable === true;
const isSuperLocked = record.isSuperLocked === true;
const baseRecord = {
id,
layerId,
@ -349,7 +351,8 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
width,
height,
isEditable,
isRequired: isEditable && record.isRequired === true,
isSuperLocked,
isRequired: isEditable && !isSuperLocked && record.isRequired === true,
previewUrl: normalizeOptionalUrl(record.previewUrl),
previewPath: normalizeText(record.previewPath) || undefined,
sourceUrl: normalizeOptionalUrl(record.sourceUrl),
@ -400,8 +403,10 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
layers.push({
...baseRecord,
slotType: "image",
isDefaultResultSource: isEditable && record.isDefaultResultSource === true,
allowUserUploadOverride: isEditable ? record.allowUserUploadOverride !== false : false,
isDefaultResultSource:
isEditable && !isSuperLocked && record.isDefaultResultSource === true,
allowUserUploadOverride:
isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false,
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
contentBounds: normalizeAgentPsdTemplateImageContentBounds(
record.contentBounds,
@ -565,7 +570,7 @@ export function getAgentPsdTemplateRuntimeMissingLayers(template: AgentPsdTempla
if (hasUsableRenderBase) {
return (
isAgentPsdImageLayerSlot(layer) &&
layer.isEditable &&
isAgentPsdTemplateLayerUserEditable(layer) &&
!hasAgentPsdTemplateStoredLayerSource(layer)
);
}
@ -603,7 +608,19 @@ export function isAgentPsdTemplateRuntimeReady(template: AgentPsdTemplate) {
}
export function getEditableAgentPsdTemplateLayers(template: AgentPsdTemplate) {
return template.layers.filter((layer) => layer.isEditable);
return template.layers.filter(isAgentPsdTemplateLayerUserEditable);
}
export function isAgentPsdLayerSuperLocked(
layer: Pick<AgentPsdTemplateBaseLayerSlot, "isSuperLocked">
) {
return layer.isSuperLocked === true;
}
export function isAgentPsdTemplateLayerUserEditable(
layer: AgentPsdTemplateLayerSlot
) {
return layer.isEditable && !isAgentPsdLayerSuperLocked(layer);
}
export function isAgentPsdTextLayerSlot(

View File

@ -7,6 +7,7 @@ import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout";
import {
isAgentPsdImageLayerSlot,
isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot,
type AgentPsdTemplateImageContentBounds,
type AgentPsdTemplateLayerSlot,
@ -691,7 +692,7 @@ function shouldPrepareLayerPreview(
layer: AgentPsdTemplateLayerSlot,
assetProfile: PsdTemplateLayerAssetProfile
) {
return assetProfile === "full" || layer.isEditable;
return assetProfile === "full" || isAgentPsdTemplateLayerUserEditable(layer);
}
function shouldPrepareLayerSource(
@ -702,7 +703,7 @@ function shouldPrepareLayerSource(
return true;
}
return isAgentPsdImageLayerSlot(layer) && layer.isEditable;
return isAgentPsdImageLayerSlot(layer) && isAgentPsdTemplateLayerUserEditable(layer);
}
export async function preparePsdTemplateRenderAssets(params: {
@ -729,7 +730,9 @@ export async function preparePsdTemplateRenderAssets(params: {
const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []);
const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry]));
const editableLayerIdSet = new Set(
params.layers.filter((layer) => layer.isEditable).map((layer) => layer.layerId)
params.layers
.filter(isAgentPsdTemplateLayerUserEditable)
.map((layer) => layer.layerId)
);
const nextLayers = [...params.layers];
@ -747,11 +750,12 @@ export async function preparePsdTemplateRenderAssets(params: {
(params.forceRefreshLayerAssets ||
!String(targetLayer.sourceUrl || "").trim());
const shouldAnalyzeText =
targetLayer.isEditable &&
isAgentPsdTemplateLayerUserEditable(targetLayer) &&
isAgentPsdTextLayerSlot(targetLayer) &&
targetLayer.styleSource !== "custom";
const shouldResolveImageBounds =
targetLayer.isEditable && isAgentPsdImageLayerSlot(targetLayer);
isAgentPsdTemplateLayerUserEditable(targetLayer) &&
isAgentPsdImageLayerSlot(targetLayer);
if (
!shouldUploadPreview &&
@ -858,7 +862,10 @@ export async function preparePsdTemplateRenderAssets(params: {
}
}
if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable) {
if (
!isAgentPsdImageLayerSlot(targetLayer) ||
!isAgentPsdTemplateLayerUserEditable(targetLayer)
) {
if (isAgentPsdImageLayerSlot(preparedLayer)) {
preparedLayer = {
...preparedLayer,

View File

@ -5,6 +5,7 @@ import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
import {
hasAgentPsdTemplateStoredLayerSource,
isAgentPsdImageLayerSlot,
isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot,
resolveAgentPsdTextLayerEffectiveAlign,
type AgentPsdTemplate,
@ -25,6 +26,20 @@ type AgentPsdTextPositionOffset = {
offsetY: number;
};
const PSD_RENDER_PNG_COMPRESSION_LEVEL = Math.max(
0,
Math.min(
9,
Number.parseInt(process.env.AGENT_PSD_RENDER_PNG_COMPRESSION_LEVEL || "4", 10)
)
);
const PSD_RENDER_PNG_OPTIONS = {
compressionLevel: Number.isFinite(PSD_RENDER_PNG_COMPRESSION_LEVEL)
? PSD_RENDER_PNG_COMPRESSION_LEVEL
: 4,
adaptiveFiltering: false,
} as const;
function clampTextPositionOffsetValue(value: number) {
if (!Number.isFinite(value)) {
return 0;
@ -113,7 +128,7 @@ async function fitOverlayBufferIntoCanvas(params: {
width: targetWidth,
height: targetHeight,
})
.png()
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer()
: params.input;
@ -121,6 +136,8 @@ async function fitOverlayBufferIntoCanvas(params: {
input: outputBuffer,
left,
top,
width: targetWidth,
height: targetHeight,
};
}
@ -135,6 +152,38 @@ function sanitizeFileSegment(value: string, fallback: string) {
return normalized || fallback;
}
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<R>
) {
const results = new Array<R>(items.length);
let nextIndex = 0;
const workerCount = Math.max(
1,
Math.min(items.length, Math.round(Number(concurrency || 1)))
);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(items[currentIndex]!, currentIndex);
}
})
);
return results;
}
function getStoredLayerSourceUrl(layer: AgentPsdTemplate["layers"][number]) {
return (
String(layer.sourceUrl || "").trim() ||
String(layer.previewUrl || "").trim()
);
}
function resolveTextOverlayFrame(params: {
layerWidth: number;
layerHeight: number;
@ -350,7 +399,7 @@ async function createTextOverlayBuffer(params: {
`;
const sharp = await getSharp();
return sharp(Buffer.from(svg)).png().toBuffer();
return sharp(Buffer.from(svg)).png(PSD_RENDER_PNG_OPTIONS).toBuffer();
}
export async function renderAgentPsdTemplate(params: {
@ -406,7 +455,9 @@ export async function renderAgentPsdTemplate(params: {
Boolean(renderBaseUrl) &&
renderBaseUrl !== String(template.previewUrl || "").trim() &&
!orderedTemplateLayers.some(
(layer) => !layer.isEditable && hiddenLayerIdSet.has(layer.id)
(layer) =>
!isAgentPsdTemplateLayerUserEditable(layer) &&
hiddenLayerIdSet.has(layer.id)
);
function getLayerPositionOffset(layerId: string) {
@ -433,6 +484,105 @@ export async function renderAgentPsdTemplate(params: {
return remoteBufferCache.get(normalizedUrl)!;
}
async function prefetchRenderAssets() {
const assetCandidates: Array<{
url: string;
label: string;
optional?: boolean;
}> = [];
const pushAsset = (
url: string | undefined,
label: string,
optional = false
) => {
const normalizedUrl = String(url || "").trim();
if (!normalizedUrl) return;
assetCandidates.push({ url: normalizedUrl, label, optional });
};
if (canUseDedicatedRenderBase) {
pushAsset(renderBaseUrl, "PSD 模板底图");
}
for (const layer of orderedTemplateLayers) {
if (hiddenLayerIdSet.has(layer.id)) {
continue;
}
const storedLayerSourceUrl = getStoredLayerSourceUrl(layer);
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
if (!canUseDedicatedRenderBase) {
pushAsset(storedLayerSourceUrl, "PSD 图层素材");
}
continue;
}
if (isAgentPsdImageLayerSlot(layer)) {
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
if (assignmentUrl) {
pushAsset(assignmentUrl, "PSD 套版素材");
pushAsset(layer.maskUrl, "PSD 蒙版资源", true);
} else {
pushAsset(storedLayerSourceUrl, "PSD 图层素材");
}
continue;
}
if (isAgentPsdTextLayerSlot(layer)) {
const hasTextAssignment = Object.hasOwn(textAssignments, layer.id);
const defaultText = String(layer.defaultText || "").trim();
const text = String(
hasTextAssignment ? (textAssignments[layer.id] ?? "") : defaultText
).trim();
const textColor = normalizeHexColor(
textColorAssignments[layer.id] || layer.textColor,
layer.textColor
);
const hasColorChange =
normalizeHexColor(textColor, layer.textColor) !==
normalizeHexColor(layer.textColor);
const textPositionOffset = normalizeTextPositionOffset(
textPositionOffsets[layer.id]
);
const hasPositionChange = hasTextPositionOffset(textPositionOffset);
if (
(!text && !hasTextAssignment) ||
(text === defaultText && !hasColorChange && !hasPositionChange)
) {
pushAsset(storedLayerSourceUrl, "PSD 图层素材");
} else {
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
pushAsset(font?.fileUrl, "PSD 字体资源", true);
}
}
}
const uniqueAssetMap = new Map<
string,
{ url: string; label: string; optional?: boolean }
>();
for (const asset of assetCandidates) {
const previous = uniqueAssetMap.get(asset.url);
uniqueAssetMap.set(asset.url, {
...asset,
label: previous?.label || asset.label,
optional: Boolean(previous?.optional) && Boolean(asset.optional),
});
}
const uniqueAssets = Array.from(uniqueAssetMap.values());
await mapWithConcurrency(uniqueAssets, 6, async (asset) => {
try {
await readCachedBuffer(asset.url, asset.label);
} catch (error) {
if (!asset.optional) {
throw error;
}
}
});
}
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> =
[];
@ -461,9 +611,7 @@ export async function renderAgentPsdTemplate(params: {
}
async function appendStoredLayer(layer: AgentPsdTemplate["layers"][number]) {
const sourceUrl =
String(layer.sourceUrl || "").trim() ||
String(layer.previewUrl || "").trim();
const sourceUrl = getStoredLayerSourceUrl(layer);
if (!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) {
throw new Error(
`PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板`
@ -498,6 +646,8 @@ export async function renderAgentPsdTemplate(params: {
}
}
await prefetchRenderAssets();
if (canUseDedicatedRenderBase) {
const renderBaseBuffer = await readCachedBuffer(renderBaseUrl, "PSD 模板底图");
await appendBufferOverlay({
@ -514,7 +664,7 @@ export async function renderAgentPsdTemplate(params: {
continue;
}
if (!layer.isEditable) {
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
if (canUseDedicatedRenderBase) {
continue;
}
@ -534,11 +684,8 @@ export async function renderAgentPsdTemplate(params: {
assignmentUrl,
"PSD 套版素材"
);
const normalizedSourceBuffer = await sharp(sourceBuffer)
.rotate()
.png()
.toBuffer();
const metadata = await sharp(normalizedSourceBuffer).metadata();
const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
const metadata = await sourceImage.clone().metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width,
containerHeight: layer.height,
@ -549,7 +696,8 @@ export async function renderAgentPsdTemplate(params: {
placement: imagePlacements[layer.id],
});
const resizedOverlayBuffer = await sharp(normalizedSourceBuffer)
const resizedOverlayBuffer = await sourceImage
.clone()
.resize({
width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)),
@ -566,7 +714,7 @@ export async function renderAgentPsdTemplate(params: {
alpha: 0,
},
})
.png()
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
const slotOverlay = await fitOverlayBufferIntoCanvas({
@ -579,6 +727,29 @@ export async function renderAgentPsdTemplate(params: {
canvasHeight: layer.height,
});
if (!slotOverlay) {
continue;
}
const layerPositionOffset = getLayerPositionOffset(layer.id);
if (!layer.maskUrl) {
const didAppend = await appendBufferOverlay({
input: slotOverlay.input,
left:
layer.x +
layerPositionOffset.offsetX +
slotOverlay.left,
top:
layer.y +
layerPositionOffset.offsetY +
slotOverlay.top,
width: slotOverlay.width,
height: slotOverlay.height,
});
if (!didAppend) continue;
continue;
}
let overlayBuffer = await sharp({
create: {
width: Math.max(1, Math.round(layer.width)),
@ -592,8 +763,8 @@ export async function renderAgentPsdTemplate(params: {
},
},
})
.composite(slotOverlay ? [slotOverlay] : [])
.png()
.composite([slotOverlay])
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
if (layer.maskUrl) {
@ -612,7 +783,7 @@ export async function renderAgentPsdTemplate(params: {
alpha: 0,
},
})
.png()
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
overlayBuffer = await sharp(overlayBuffer)
.composite([
@ -621,7 +792,7 @@ export async function renderAgentPsdTemplate(params: {
blend: "dest-in",
},
])
.png()
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
} catch (error) {
console.warn(
@ -637,8 +808,8 @@ export async function renderAgentPsdTemplate(params: {
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x + getLayerPositionOffset(layer.id).offsetX,
top: layer.y + getLayerPositionOffset(layer.id).offsetY,
left: layer.x + layerPositionOffset.offsetX,
top: layer.y + layerPositionOffset.offsetY,
width: layer.width,
height: layer.height,
});
@ -797,12 +968,12 @@ export async function renderAgentPsdTemplate(params: {
},
},
})
.png()
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
const resultBuffer = await sharp(transparentCanvas)
.composite(compositeInputs)
.png()
.png(PSD_RENDER_PNG_OPTIONS)
.toBuffer();
const uploaded = await uploadFile(

View File

@ -9,6 +9,8 @@ import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import {
AGENT_PSD_TEMPLATE_ASSET_VERSION,
isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked,
isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
@ -28,14 +30,17 @@ function hasMissingLayerAssets(template: AgentPsdTemplate) {
return template.layers.some(
(layer) => {
if (layer.isEditable && !String(layer.previewUrl || "").trim()) {
if (
isAgentPsdTemplateLayerUserEditable(layer) &&
!String(layer.previewUrl || "").trim()
) {
return true;
}
if (hasRenderBase) {
return (
isAgentPsdImageLayerSlot(layer) &&
layer.isEditable &&
isAgentPsdTemplateLayerUserEditable(layer) &&
!String(layer.sourceUrl || "").trim()
);
}
@ -63,12 +68,20 @@ function hasDedicatedRenderBase(template: AgentPsdTemplate) {
function repairEditableImageLayerSelection(template: AgentPsdTemplate) {
const imageLayers = template.layers.filter(isAgentPsdImageLayerSlot);
if (imageLayers.length === 0) {
const unlockedImageLayers = imageLayers.filter(
(layer) => !isAgentPsdLayerSuperLocked(layer)
);
if (unlockedImageLayers.length === 0) {
return template.layers;
}
const editableImageLayers = imageLayers.filter((layer) => layer.isEditable);
const defaultImageLayer = imageLayers.find((layer) => layer.isEditable && layer.isDefaultResultSource);
const editableImageLayers = unlockedImageLayers.filter(
isAgentPsdTemplateLayerUserEditable
);
const defaultImageLayer = unlockedImageLayers.find(
(layer) =>
isAgentPsdTemplateLayerUserEditable(layer) && layer.isDefaultResultSource
);
const shouldAutoRepairSelection =
editableImageLayers.length === 0 ||
!defaultImageLayer ||
@ -79,7 +92,7 @@ function repairEditableImageLayerSelection(template: AgentPsdTemplate) {
}
const nextPrimaryLayerId = pickPrimaryPsdImageLayerId({
imageLayers,
imageLayers: unlockedImageLayers,
canvasWidth: template.width,
canvasHeight: template.height,
});