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

View File

@ -185,7 +185,8 @@ async function fetchDownloadFile(params: {
params.file.name || `canvas-image-${params.index + 1}`, params.file.name || `canvas-image-${params.index + 1}`,
`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 }; return { ok: true, filename, bytes };
} catch (error) { } catch (error) {
lastError = normalizeDownloadError(error); lastError = normalizeDownloadError(error);

View File

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

View File

@ -70,6 +70,7 @@ import {
type AgentPsdTemplateRenderResponse, type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot, type AgentPsdTemplateTextLayerSlot,
isAgentPsdImageLayerSlot, isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked,
isAgentPsdTextLayerSlot, isAgentPsdTextLayerSlot,
parseAgentPsdTemplates, parseAgentPsdTemplates,
resolveAgentPsdTextLayerEffectiveAlign, 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>( async function parseActionJsonResponse<T>(
response: Response, response: Response,
fallbackMessage: string, fallbackMessage: string,
@ -846,6 +874,9 @@ function createInitialState(
for (const layer of template.layers) { for (const layer of template.layers) {
layerOrderIds.push(layer.id); layerOrderIds.push(layer.id);
if (isAgentPsdLayerSuperLocked(layer)) {
continue;
}
if ( if (
isAgentPsdImageLayerSlot(layer) && isAgentPsdImageLayerSlot(layer) &&
layer.isDefaultResultSource && layer.isDefaultResultSource &&
@ -955,6 +986,7 @@ function resolveTransferredSelectedLayerId(params: {
const nextCandidateLayers = nextTemplate.layers.filter( const nextCandidateLayers = nextTemplate.layers.filter(
(layer) => (layer) =>
layer.isEditable && layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
((isAgentPsdImageLayerSlot(previousLayer) && ((isAgentPsdImageLayerSlot(previousLayer) &&
isAgentPsdImageLayerSlot(layer)) || isAgentPsdImageLayerSlot(layer)) ||
(isAgentPsdTextLayerSlot(previousLayer) && (isAgentPsdTextLayerSlot(previousLayer) &&
@ -1008,6 +1040,9 @@ function createTemplateEditorState(params: {
const usedPreviousLayerIds = new Set<string>(); const usedPreviousLayerIds = new Set<string>();
for (const nextLayer of nextTemplate.layers) { for (const nextLayer of nextTemplate.layers) {
if (isAgentPsdLayerSuperLocked(nextLayer)) {
continue;
}
const matchedPreviousLayer = findMatchingLayer({ const matchedPreviousLayer = findMatchingLayer({
nextLayer, nextLayer,
previousLayers: previousTemplate.layers, previousLayers: previousTemplate.layers,
@ -1019,6 +1054,10 @@ function createTemplateEditorState(params: {
const previousLayerPositionOffset = const previousLayerPositionOffset =
previousLayerPositionOffsets[matchedPreviousLayer.id]; previousLayerPositionOffsets[matchedPreviousLayer.id];
if (isAgentPsdLayerSuperLocked(matchedPreviousLayer)) {
usedPreviousLayerIds.add(matchedPreviousLayer.id);
continue;
}
if (hasLayerPositionOffset(previousLayerPositionOffset)) { if (hasLayerPositionOffset(previousLayerPositionOffset)) {
nextState.layerPositionOffsets[nextLayer.id] = nextState.layerPositionOffsets[nextLayer.id] =
normalizeLayerPositionOffset(previousLayerPositionOffset); normalizeLayerPositionOffset(previousLayerPositionOffset);
@ -1029,11 +1068,15 @@ function createTemplateEditorState(params: {
const previousEditableImageLayers = previousTemplate.layers.filter( const previousEditableImageLayers = previousTemplate.layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot => (layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer) layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
); );
const nextEditableImageLayers = nextTemplate.layers.filter( const nextEditableImageLayers = nextTemplate.layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot => (layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer) layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
); );
const usedPreviousImageLayerIds = new Set<string>(); const usedPreviousImageLayerIds = new Set<string>();
const usedNextImageLayerIds = new Set<string>(); const usedNextImageLayerIds = new Set<string>();
@ -1091,11 +1134,15 @@ function createTemplateEditorState(params: {
const previousEditableTextLayers = previousTemplate.layers.filter( const previousEditableTextLayers = previousTemplate.layers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot => (layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable && isAgentPsdTextLayerSlot(layer) layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
); );
const nextEditableTextLayers = nextTemplate.layers.filter( const nextEditableTextLayers = nextTemplate.layers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot => (layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable && isAgentPsdTextLayerSlot(layer) layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
); );
const usedPreviousTextLayerIds = new Set<string>(); const usedPreviousTextLayerIds = new Set<string>();
@ -1214,7 +1261,9 @@ function getPreferredSelectedLayerId(template: AgentPsdTemplate | null) {
return ""; return "";
} }
const editableLayers = template.layers.filter((layer) => layer.isEditable); const editableLayers = template.layers.filter(
(layer) => layer.isEditable && !isAgentPsdLayerSuperLocked(layer)
);
if (editableLayers.length === 0) { if (editableLayers.length === 0) {
return ""; 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({ export function AgentPsdTemplateEditorDialog({
open, open,
onOpenChange, onOpenChange,
@ -1659,6 +1700,9 @@ export function AgentPsdTemplateEditorDialog({
const textColorAssignmentsRef = useRef(textColorAssignments); const textColorAssignmentsRef = useRef(textColorAssignments);
const textPositionOffsetsRef = useRef(textPositionOffsets); const textPositionOffsetsRef = useRef(textPositionOffsets);
const archivedRenderImageUrlCacheRef = useRef<Record<string, string>>({}); const archivedRenderImageUrlCacheRef = useRef<Record<string, string>>({});
const archivedRenderImageUrlPromiseCacheRef = useRef<
Record<string, Promise<string>>
>({});
const selectedLayerIdRef = useRef(selectedLayerId); const selectedLayerIdRef = useRef(selectedLayerId);
imageAssignmentsRef.current = imageAssignments; imageAssignmentsRef.current = imageAssignments;
@ -1672,6 +1716,7 @@ export function AgentPsdTemplateEditorDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
archivedRenderImageUrlCacheRef.current = {}; archivedRenderImageUrlCacheRef.current = {};
archivedRenderImageUrlPromiseCacheRef.current = {};
} }
}, [open]); }, [open]);
@ -1792,6 +1837,10 @@ export function AgentPsdTemplateEditorDialog({
} }
return selectedTemplate.layers; return selectedTemplate.layers;
}, [selectedTemplate]); }, [selectedTemplate]);
const selectedTemplateLayerById = useMemo(
() => new Map(selectedTemplateLayers.map((layer) => [layer.id, layer])),
[selectedTemplateLayers]
);
const selectedTemplateMode: AgentPsdTemplateMode = const selectedTemplateMode: AgentPsdTemplateMode =
selectedTemplate?.mode === "sku" ? "sku" : "main"; selectedTemplate?.mode === "sku" ? "sku" : "main";
const selectedModeTemplates = useMemo( const selectedModeTemplates = useMemo(
@ -1842,7 +1891,9 @@ export function AgentPsdTemplateEditorDialog({
() => () =>
selectedTemplateLayers.filter( selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot => (layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer) layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
), ),
[selectedTemplateLayers] [selectedTemplateLayers]
); );
@ -1850,7 +1901,9 @@ export function AgentPsdTemplateEditorDialog({
() => () =>
selectedTemplateLayers.filter( selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot => (layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable && isAgentPsdTextLayerSlot(layer) layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
), ),
[selectedTemplateLayers] [selectedTemplateLayers]
); );
@ -1922,7 +1975,15 @@ export function AgentPsdTemplateEditorDialog({
[hiddenLayerIdSet, orderedPreviewLayers] [hiddenLayerIdSet, orderedPreviewLayers]
); );
const orderedEditableLayers = useMemo( 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] [orderedPreviewLayers]
); );
const orderedEditableImageLayers = useMemo( const orderedEditableImageLayers = useMemo(
@ -2028,6 +2089,9 @@ export function AgentPsdTemplateEditorDialog({
} }
for (const layer of selectedTemplateLayers) { for (const layer of selectedTemplateLayers) {
if (isAgentPsdLayerSuperLocked(layer)) {
continue;
}
nextOffsets[layer.id] = normalizeLayerPositionOffset( nextOffsets[layer.id] = normalizeLayerPositionOffset(
layerPositionOffsets[layer.id] layerPositionOffsets[layer.id]
); );
@ -2059,12 +2123,12 @@ export function AgentPsdTemplateEditorDialog({
selectedTemplate, selectedTemplate,
]); ]);
const selectedLayer = useMemo( const selectedLayer = useMemo(() => {
() => const layer =
selectedTemplateLayers.find((layer) => layer.id === selectedLayerId) || selectedTemplateLayers.find((entry) => entry.id === selectedLayerId) ||
null, null;
[selectedLayerId, selectedTemplateLayers] return layer && !isAgentPsdLayerSuperLocked(layer) ? layer : null;
); }, [selectedLayerId, selectedTemplateLayers]);
const isSelectedLayerHidden = Boolean( const isSelectedLayerHidden = Boolean(
selectedLayer && hiddenLayerIdSet.has(selectedLayer.id) selectedLayer && hiddenLayerIdSet.has(selectedLayer.id)
); );
@ -2101,6 +2165,10 @@ export function AgentPsdTemplateEditorDialog({
if (!normalizedLayerId) { if (!normalizedLayerId) {
return; return;
} }
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setHiddenLayerIds((current) => setHiddenLayerIds((current) =>
current.includes(normalizedLayerId) current.includes(normalizedLayerId)
? current ? current
@ -2113,6 +2181,10 @@ export function AgentPsdTemplateEditorDialog({
if (!normalizedLayerId) { if (!normalizedLayerId) {
return; return;
} }
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (layer && isAgentPsdLayerSuperLocked(layer)) {
return;
}
setHiddenLayerIds((current) => setHiddenLayerIds((current) =>
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId) current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
); );
@ -2669,7 +2741,9 @@ export function AgentPsdTemplateEditorDialog({
} }
const validLayerIds = new Set( const validLayerIds = new Set(
selectedTemplateLayers.map((layer) => layer.id) selectedTemplateLayers
.filter((layer) => !isAgentPsdLayerSuperLocked(layer))
.map((layer) => layer.id)
); );
setHiddenLayerIds((current) => setHiddenLayerIds((current) =>
current.filter((layerId) => validLayerIds.has(layerId)) current.filter((layerId) => validLayerIds.has(layerId))
@ -2994,6 +3068,10 @@ export function AgentPsdTemplateEditorDialog({
current: AgentPsdLayerPositionOffset current: AgentPsdLayerPositionOffset
) => Partial<AgentPsdLayerPositionOffset> ) => Partial<AgentPsdLayerPositionOffset>
) { ) {
const layer = selectedTemplateLayerById.get(String(layerId || "").trim());
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setLayerPositionOffsets((current) => ({ setLayerPositionOffsets((current) => ({
...current, ...current,
[layerId]: normalizeLayerPositionOffset({ [layerId]: normalizeLayerPositionOffset({
@ -3011,6 +3089,10 @@ export function AgentPsdTemplateEditorDialog({
if (!normalizedLayerId) { if (!normalizedLayerId) {
return; return;
} }
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setLayerOrderIds((current) => { setLayerOrderIds((current) => {
const fallbackOrder = selectedTemplateLayers.map((layer) => layer.id); const fallbackOrder = selectedTemplateLayers.map((layer) => layer.id);
@ -3111,89 +3193,126 @@ export function AgentPsdTemplateEditorDialog({
if (cachedUrl) { if (cachedUrl) {
return cachedUrl; return cachedUrl;
} }
const cachedPromise =
const fetchCandidates = Array.from( archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
new Set( if (cachedPromise) {
[ return cachedPromise;
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( const archivePromise = (async () => {
getUserFacingRequestErrorMessage( const fetchCandidates = Array.from(
lastError, new Set(
`${label}无法转存到本站存储,请重新上传图片后再试` [
) 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() { async function normalizeRenderImageInputs() {
const nextSourceImageUrl = await archiveRenderImageUrl( let nextSourceImageUrl = sourceImageUrl || "";
sourceImageUrl || "",
"PSD 套版主图"
);
const nextImageAssignments = { ...effectiveImageAssignments }; const nextImageAssignments = { ...effectiveImageAssignments };
const layerNameById = new Map( const layerNameById = new Map(
editableImageLayers.map((layer) => [layer.id, layer.layerName]) 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)) { const archivedJobs = await mapWithConcurrency(
nextImageAssignments[layerId] = await archiveRenderImageUrl( archiveJobs,
layerUrl, PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY,
`PSD 图层素材「${layerNameById.get(layerId) || layerId}」` 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 { return {
@ -3206,6 +3325,9 @@ export function AgentPsdTemplateEditorDialog({
layer: AgentPsdTemplateLayerSlot, layer: AgentPsdTemplateLayerSlot,
event: ReactPointerEvent<HTMLElement> event: ReactPointerEvent<HTMLElement>
) { ) {
if (isAgentPsdLayerSuperLocked(layer)) {
return;
}
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setSelectedLayerId(layer.id); setSelectedLayerId(layer.id);
@ -3264,6 +3386,9 @@ export function AgentPsdTemplateEditorDialog({
layer: AgentPsdTemplateTextLayerSlot, layer: AgentPsdTemplateTextLayerSlot,
event: ReactPointerEvent<HTMLDivElement> event: ReactPointerEvent<HTMLDivElement>
) { ) {
if (isAgentPsdLayerSuperLocked(layer)) {
return;
}
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setSelectedLayerId(layer.id); setSelectedLayerId(layer.id);
@ -3936,6 +4061,8 @@ export function AgentPsdTemplateEditorDialog({
<div className="pointer-events-none absolute inset-0"> <div className="pointer-events-none absolute inset-0">
{orderedVisiblePreviewLayers.map((layer) => { {orderedVisiblePreviewLayers.map((layer) => {
const isLayerSuperLocked =
isAgentPsdLayerSuperLocked(layer);
const layerPositionOffset = const layerPositionOffset =
effectiveLayerPositionOffsets[layer.id] || effectiveLayerPositionOffsets[layer.id] ||
normalizeLayerPositionOffset(); normalizeLayerPositionOffset();
@ -3965,24 +4092,38 @@ export function AgentPsdTemplateEditorDialog({
return ( return (
<div <div
className="pointer-events-auto absolute overflow-hidden" className={`absolute overflow-hidden ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id} key={layer.id}
onClick={(event) => { onClick={
event.stopPropagation(); isLayerSuperLocked
setSelectedLayerId(layer.id); ? undefined
}} : (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) => onPointerDown={(event) =>
handleLayerDragStart(layer, event) isLayerSuperLocked
? undefined
: handleLayerDragStart(layer, event)
} }
style={{ style={{
...assignedImageContainerStyle, ...assignedImageContainerStyle,
zIndex: baseZIndex, zIndex: baseZIndex,
cursor: cursor:
layerDragStateRef.current?.layerId === isLayerSuperLocked
layer.id ? "default"
? "grabbing" : layerDragStateRef.current?.layerId ===
: "grab", layer.id
touchAction: "none", ? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}} }}
title={`${getLayerLabel(layer)}:${layer.layerName}`} title={`${getLayerLabel(layer)}:${layer.layerName}`}
> >
@ -4046,24 +4187,38 @@ export function AgentPsdTemplateEditorDialog({
if (!shouldUseEditedPreview && layer.sourceUrl) { if (!shouldUseEditedPreview && layer.sourceUrl) {
return ( return (
<div <div
className="pointer-events-auto absolute overflow-hidden" className={`absolute overflow-hidden ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id} key={layer.id}
onClick={(event) => { onClick={
event.stopPropagation(); isLayerSuperLocked
setSelectedLayerId(layer.id); ? undefined
}} : (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) => onPointerDown={(event) =>
handleTextDragStart(layer, event) isLayerSuperLocked
? undefined
: handleTextDragStart(layer, event)
} }
style={{ style={{
...layerBoxStyle, ...layerBoxStyle,
zIndex: baseZIndex, zIndex: baseZIndex,
cursor: cursor:
textDragStateRef.current?.layerId === isLayerSuperLocked
layer.id ? "default"
? "grabbing" : textDragStateRef.current?.layerId ===
: "grab", layer.id
touchAction: "none", ? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}} }}
title={`${getLayerLabel(layer)}:${layer.layerName}`} title={`${getLayerLabel(layer)}:${layer.layerName}`}
> >
@ -4091,14 +4246,24 @@ export function AgentPsdTemplateEditorDialog({
return ( return (
<div <div
className="pointer-events-auto absolute overflow-visible" className={`absolute overflow-visible ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id} key={layer.id}
onClick={(event) => { onClick={
event.stopPropagation(); isLayerSuperLocked
setSelectedLayerId(layer.id); ? undefined
}} : (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) => onPointerDown={(event) =>
handleTextDragStart(layer, event) isLayerSuperLocked
? undefined
: handleTextDragStart(layer, event)
} }
style={{ style={{
left: left:
@ -4115,11 +4280,15 @@ export function AgentPsdTemplateEditorDialog({
height: textFrame.height, height: textFrame.height,
zIndex: baseZIndex, zIndex: baseZIndex,
cursor: cursor:
textDragStateRef.current?.layerId === isLayerSuperLocked
layer.id ? "default"
? "grabbing" : textDragStateRef.current?.layerId ===
: "grab", layer.id
touchAction: "none", ? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}} }}
title={`${getLayerLabel(layer)}:${layer.layerName}`} title={`${getLayerLabel(layer)}:${layer.layerName}`}
> >
@ -4135,7 +4304,11 @@ export function AgentPsdTemplateEditorDialog({
<div className="absolute inset-0"> <div className="absolute inset-0">
{orderedVisiblePreviewLayers {orderedVisiblePreviewLayers
.filter((layer) => !layer.isEditable) .filter(
(layer) =>
!layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer)
)
.map((layer) => { .map((layer) => {
const isSelected = selectedLayerId === layer.id; const isSelected = selectedLayerId === layer.id;
const baseZIndex = const baseZIndex =
@ -4169,15 +4342,7 @@ export function AgentPsdTemplateEditorDialog({
touchAction: "none", touchAction: "none",
}} }}
title={`普通图层:${layer.layerName}`} 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", touchAction: "none",
}} }}
title={`${getLayerLabel(layer)}:${layer.layerName}`} 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 <button
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`} aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
@ -4255,18 +4412,7 @@ export function AgentPsdTemplateEditorDialog({
}} }}
title={`${getLayerLabel(layer)}:${layer.layerName}`} title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="button" 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>
)} )}
<div
className={getTextLayerNameBadgeClassName()}
>
{layer.layerName}
</div>
<div
className={getTextLayerHintBadgeClassName()}
>
拖动文字 / 右侧微调
</div>
</div> </div>
); );
} }
@ -4477,19 +4613,7 @@ export function AgentPsdTemplateEditorDialog({
}} }}
title={`${getLayerLabel(layer)}:${layer.layerName}`} title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="button" type="button"
> />
<div
className={getTextLayerNameBadgeClassName()}
>
{layer.layerName}
</div>
<div
className={getTextLayerHintBadgeClassName()}
>
点击编辑文字
</div>
</button>
); );
})} })}
</div> </div>
@ -4973,7 +5097,7 @@ export function AgentPsdTemplateEditorDialog({
</div> </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="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 className="mb-3 flex items-center justify-between gap-3">
<div> <div>
@ -5010,7 +5134,7 @@ export function AgentPsdTemplateEditorDialog({
</div> </div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60"> <div className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">
已隐藏 {hiddenLayerIds.length} /{" "} 已隐藏 {hiddenLayerIds.length} /{" "}
{selectedTemplateLayers.length} {orderedUserManageablePreviewLayers.length}
</div> </div>
</div> </div>
</div> </div>
@ -5019,7 +5143,7 @@ export function AgentPsdTemplateEditorDialog({
ref={hiddenLayerScrollAreaRef} ref={hiddenLayerScrollAreaRef}
> >
<div className="space-y-2 pb-1"> <div className="space-y-2 pb-1">
{orderedPreviewLayers.map((layer) => { {orderedUserManageablePreviewLayers.map((layer) => {
const isHidden = hiddenLayerIdSet.has(layer.id); const isHidden = hiddenLayerIdSet.has(layer.id);
const isSelected = selectedLayerId === layer.id; const isSelected = selectedLayerId === layer.id;
return ( return (

View File

@ -115,6 +115,8 @@ const WORKFLOW_OPTION_PREVIEW_WIDTH = 512;
const WORKFLOW_OPTION_PREVIEW_QUALITY = 62; const WORKFLOW_OPTION_PREVIEW_QUALITY = 62;
const WORKFLOW_OPTION_PREVIEW_EAGER_COUNT = 10; const WORKFLOW_OPTION_PREVIEW_EAGER_COUNT = 10;
const WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT = 12; 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) { function isLikelyStoredWorkflowPreview(url: string, sourceUrl?: string | null) {
const source = String(sourceUrl || "").trim(); const source = String(sourceUrl || "").trim();
@ -157,6 +159,33 @@ function handleWorkflowOptionPreviewError(
image.src = fallbackUrl; 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 { interface WorkflowPanelDerivedState {
progress: ReturnType<typeof getAgentWorkflowProgress>; progress: ReturnType<typeof getAgentWorkflowProgress>;
} }
@ -967,6 +996,10 @@ function AgentWorkflowPanelDarkV2Component({
const latestExternalSelectionsRef = useRef(selections); const latestExternalSelectionsRef = useRef(selections);
const selectionCommitTimerRef = useRef<number | null>(null); const selectionCommitTimerRef = useRef<number | null>(null);
const pendingSelectionCommitRef = useRef<WorkflowSelectionState | null>(null); const pendingSelectionCommitRef = useRef<WorkflowSelectionState | null>(null);
const workflowPreviewPrefetchedUrlsRef = useRef(new Set<string>());
const workflowPreviewImageCacheRef = useRef(
new Map<string, HTMLImageElement>()
);
useEffect(() => { useEffect(() => {
if (latestExternalSelectionsRef.current === selections) { if (latestExternalSelectionsRef.current === selections) {
@ -1141,20 +1174,80 @@ function AgentWorkflowPanelDarkV2Component({
activeEmbeddedOptionLevels[0] || activeEmbeddedOptionLevels[0] ||
null; null;
const workflowOptionPreviewPrefetchUrls = useMemo(() => { const workflowOptionPreviewPrefetchUrls = useMemo(() => {
const options = embedded const urls = new Set<string>();
? activeEmbeddedOptionLevel?.options || []
: flattenedResolvedSteps[0]?.step.options || []; if (embedded) {
return Array.from( appendWorkflowOptionPreviewUrls(
new Set( urls,
options activeEmbeddedOptionLevel?.options,
.slice(0, WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT) WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT
.map((option) => );
buildWorkflowOptionPreviewUrl(option.previewImageUrl, option.previewImageSourceUrl)
) const currentSelectedOption =
.filter(Boolean) activeEmbeddedOptionLevel?.selectedOption || null;
) appendWorkflowOptionPreviewUrls(
); urls,
}, [activeEmbeddedOptionLevel?.options, embedded, flattenedResolvedSteps]); 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( const activeEmbeddedDeepestSelectedOption = useMemo(
() => () =>
[...activeEmbeddedOptionLevels] [...activeEmbeddedOptionLevels]
@ -1257,23 +1350,33 @@ function AgentWorkflowPanelDarkV2Component({
return; return;
} }
const cleanupRef = { current: () => {} }; const urlsToPrefetch = workflowOptionPreviewPrefetchUrls.filter(
cleanupRef.current = scheduleWorkflowIdleTask(() => { (src) => !workflowPreviewPrefetchedUrlsRef.current.has(src)
const images = workflowOptionPreviewPrefetchUrls.map((src) => { );
if (urlsToPrefetch.length === 0) {
return;
}
urlsToPrefetch.forEach((src) => {
workflowPreviewPrefetchedUrlsRef.current.add(src);
});
scheduleWorkflowIdleTask(() => {
urlsToPrefetch.forEach((src, index) => {
const image = new Image(); const image = new Image();
image.decoding = "async"; image.decoding = "async";
(
image as HTMLImageElement & {
fetchPriority?: "high" | "low" | "auto";
}
).fetchPriority =
index < WORKFLOW_OPTION_PREVIEW_EAGER_COUNT ? "high" : "low";
image.src = src; image.src = src;
return image; workflowPreviewImageCacheRef.current.set(src, image);
}); });
cleanupRef.current = () => { trimWorkflowPreviewImageCache(workflowPreviewImageCacheRef.current);
images.forEach((image) => { }, 80);
image.src = "";
});
};
}, 120);
return () => cleanupRef.current();
}, [workflowOptionPreviewPrefetchUrls]); }, [workflowOptionPreviewPrefetchUrls]);
useEffect(() => { useEffect(() => {

View File

@ -3,6 +3,141 @@ import "server-only";
const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_RETRY_COUNT = 3; const DEFAULT_RETRY_COUNT = 3;
const RETRY_DELAY_MS = 1_000; 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) { function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@ -51,6 +186,7 @@ export async function fetchRemoteAssetBuffer(
label?: string; label?: string;
timeoutMs?: number; timeoutMs?: number;
retries?: number; retries?: number;
useMemoryCache?: boolean;
} }
) { ) {
const normalizedUrl = String(url || "").trim(); 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 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 timeoutMs = Math.max(5_000, Math.round(Number(options?.timeoutMs || DEFAULT_TIMEOUT_MS)));
const location = describeRemoteAssetLocation(normalizedUrl); const location = describeRemoteAssetLocation(normalizedUrl);
const shouldUseMemoryCache = options?.useMemoryCache !== false;
for (let attempt = 1; attempt <= attempts; attempt += 1) { if (shouldUseMemoryCache) {
try { const cachedBuffer = readRemoteAssetMemoryCache(normalizedUrl);
const response = await fetch(normalizedUrl, { if (cachedBuffer) {
cache: "no-store", return cachedBuffer;
signal: AbortSignal.timeout(timeoutMs), }
});
if (!response.ok) { const cacheState = getRemoteAssetCacheState();
const statusError = new Error(`${label}下载失败(${response.status})`); const inFlightRequest = cacheState.inFlight.get(normalizedUrl);
if (attempt < attempts && isRetryableStatus(response.status)) { if (inFlightRequest) {
console.warn("[PSD Remote Asset] transient status, retrying download:", { 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, label,
location, location,
status: response.status,
attempt, attempt,
attempts, attempts,
error,
}); });
await sleep(RETRY_DELAY_MS * attempt); await sleep(RETRY_DELAY_MS * attempt);
continue; continue;
} }
throw statusError; console.error("[PSD Remote Asset] failed to download asset:", {
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (attempt < attempts && isRetryableFetchError(error)) {
console.warn("[PSD Remote Asset] transient fetch error, retrying download:", {
label, label,
location, location,
attempt, attemptsTried: attempt,
attempts,
error, error,
}); });
await sleep(RETRY_DELAY_MS * attempt); throw new Error(`${label}下载失败,请稍后重试或检查资源是否可访问(${location})`);
continue;
} }
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, getAgentPsdTemplateRuntimeIssue,
getEditableAgentPsdTemplateLayers, getEditableAgentPsdTemplateLayers,
isAgentPsdImageLayerSlot, isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked,
isAgentPsdTemplateReady, isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady, isAgentPsdTemplateRuntimeReady,
isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot, isAgentPsdTextLayerSlot,
} from "@/lib/agents/psd-template-config"; } from "@/lib/agents/psd-template-config";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer"; import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
@ -100,6 +102,11 @@ export async function executeAgentPsdTemplateRenderRequest(
.map((layerId) => String(layerId || "").trim()) .map((layerId) => String(layerId || "").trim())
.filter(Boolean) .filter(Boolean)
); );
for (const layer of resolvedTemplate.layers) {
if (isAgentPsdLayerSuperLocked(layer)) {
hiddenLayerIdSet.delete(layer.id);
}
}
const normalizedLayerOrderIds = Array.from( const normalizedLayerOrderIds = Array.from(
new Set( new Set(
(body.layerOrderIds || []) (body.layerOrderIds || [])
@ -137,11 +144,13 @@ export async function executeAgentPsdTemplateRenderRequest(
continue; continue;
} }
layerPositionOffsets[layer.id] = normalizeTextPositionOffset( if (!isAgentPsdLayerSuperLocked(layer)) {
body.layerPositionOffsets[layer.id] layerPositionOffsets[layer.id] = normalizeTextPositionOffset(
); body.layerPositionOffsets[layer.id]
);
}
if (!layer.isEditable) { if (!isAgentPsdTemplateLayerUserEditable(layer)) {
continue; continue;
} }

View File

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

View File

@ -36,6 +36,7 @@ export interface AgentPsdTemplateBaseLayerSlot {
width: number; width: number;
height: number; height: number;
isEditable: boolean; isEditable: boolean;
isSuperLocked?: boolean;
isRequired: boolean; isRequired: boolean;
previewUrl?: string; previewUrl?: string;
previewPath?: string; previewPath?: string;
@ -338,6 +339,7 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
} }
const isEditable = record.isEditable === true; const isEditable = record.isEditable === true;
const isSuperLocked = record.isSuperLocked === true;
const baseRecord = { const baseRecord = {
id, id,
layerId, layerId,
@ -349,7 +351,8 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
width, width,
height, height,
isEditable, isEditable,
isRequired: isEditable && record.isRequired === true, isSuperLocked,
isRequired: isEditable && !isSuperLocked && record.isRequired === true,
previewUrl: normalizeOptionalUrl(record.previewUrl), previewUrl: normalizeOptionalUrl(record.previewUrl),
previewPath: normalizeText(record.previewPath) || undefined, previewPath: normalizeText(record.previewPath) || undefined,
sourceUrl: normalizeOptionalUrl(record.sourceUrl), sourceUrl: normalizeOptionalUrl(record.sourceUrl),
@ -400,8 +403,10 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
layers.push({ layers.push({
...baseRecord, ...baseRecord,
slotType: "image", slotType: "image",
isDefaultResultSource: isEditable && record.isDefaultResultSource === true, isDefaultResultSource:
allowUserUploadOverride: isEditable ? record.allowUserUploadOverride !== false : false, isEditable && !isSuperLocked && record.isDefaultResultSource === true,
allowUserUploadOverride:
isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false,
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode), replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
contentBounds: normalizeAgentPsdTemplateImageContentBounds( contentBounds: normalizeAgentPsdTemplateImageContentBounds(
record.contentBounds, record.contentBounds,
@ -565,7 +570,7 @@ export function getAgentPsdTemplateRuntimeMissingLayers(template: AgentPsdTempla
if (hasUsableRenderBase) { if (hasUsableRenderBase) {
return ( return (
isAgentPsdImageLayerSlot(layer) && isAgentPsdImageLayerSlot(layer) &&
layer.isEditable && isAgentPsdTemplateLayerUserEditable(layer) &&
!hasAgentPsdTemplateStoredLayerSource(layer) !hasAgentPsdTemplateStoredLayerSource(layer)
); );
} }
@ -603,7 +608,19 @@ export function isAgentPsdTemplateRuntimeReady(template: AgentPsdTemplate) {
} }
export function getEditableAgentPsdTemplateLayers(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( 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 { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout";
import { import {
isAgentPsdImageLayerSlot, isAgentPsdImageLayerSlot,
isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot, isAgentPsdTextLayerSlot,
type AgentPsdTemplateImageContentBounds, type AgentPsdTemplateImageContentBounds,
type AgentPsdTemplateLayerSlot, type AgentPsdTemplateLayerSlot,
@ -691,7 +692,7 @@ function shouldPrepareLayerPreview(
layer: AgentPsdTemplateLayerSlot, layer: AgentPsdTemplateLayerSlot,
assetProfile: PsdTemplateLayerAssetProfile assetProfile: PsdTemplateLayerAssetProfile
) { ) {
return assetProfile === "full" || layer.isEditable; return assetProfile === "full" || isAgentPsdTemplateLayerUserEditable(layer);
} }
function shouldPrepareLayerSource( function shouldPrepareLayerSource(
@ -702,7 +703,7 @@ function shouldPrepareLayerSource(
return true; return true;
} }
return isAgentPsdImageLayerSlot(layer) && layer.isEditable; return isAgentPsdImageLayerSlot(layer) && isAgentPsdTemplateLayerUserEditable(layer);
} }
export async function preparePsdTemplateRenderAssets(params: { export async function preparePsdTemplateRenderAssets(params: {
@ -729,7 +730,9 @@ export async function preparePsdTemplateRenderAssets(params: {
const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []); const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []);
const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry])); const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry]));
const editableLayerIdSet = new Set( 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]; const nextLayers = [...params.layers];
@ -747,11 +750,12 @@ export async function preparePsdTemplateRenderAssets(params: {
(params.forceRefreshLayerAssets || (params.forceRefreshLayerAssets ||
!String(targetLayer.sourceUrl || "").trim()); !String(targetLayer.sourceUrl || "").trim());
const shouldAnalyzeText = const shouldAnalyzeText =
targetLayer.isEditable && isAgentPsdTemplateLayerUserEditable(targetLayer) &&
isAgentPsdTextLayerSlot(targetLayer) && isAgentPsdTextLayerSlot(targetLayer) &&
targetLayer.styleSource !== "custom"; targetLayer.styleSource !== "custom";
const shouldResolveImageBounds = const shouldResolveImageBounds =
targetLayer.isEditable && isAgentPsdImageLayerSlot(targetLayer); isAgentPsdTemplateLayerUserEditable(targetLayer) &&
isAgentPsdImageLayerSlot(targetLayer);
if ( if (
!shouldUploadPreview && !shouldUploadPreview &&
@ -858,7 +862,10 @@ export async function preparePsdTemplateRenderAssets(params: {
} }
} }
if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable) { if (
!isAgentPsdImageLayerSlot(targetLayer) ||
!isAgentPsdTemplateLayerUserEditable(targetLayer)
) {
if (isAgentPsdImageLayerSlot(preparedLayer)) { if (isAgentPsdImageLayerSlot(preparedLayer)) {
preparedLayer = { preparedLayer = {
...preparedLayer, ...preparedLayer,

View File

@ -5,6 +5,7 @@ import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
import { import {
hasAgentPsdTemplateStoredLayerSource, hasAgentPsdTemplateStoredLayerSource,
isAgentPsdImageLayerSlot, isAgentPsdImageLayerSlot,
isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot, isAgentPsdTextLayerSlot,
resolveAgentPsdTextLayerEffectiveAlign, resolveAgentPsdTextLayerEffectiveAlign,
type AgentPsdTemplate, type AgentPsdTemplate,
@ -25,6 +26,20 @@ type AgentPsdTextPositionOffset = {
offsetY: number; 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) { function clampTextPositionOffsetValue(value: number) {
if (!Number.isFinite(value)) { if (!Number.isFinite(value)) {
return 0; return 0;
@ -113,7 +128,7 @@ async function fitOverlayBufferIntoCanvas(params: {
width: targetWidth, width: targetWidth,
height: targetHeight, height: targetHeight,
}) })
.png() .png(PSD_RENDER_PNG_OPTIONS)
.toBuffer() .toBuffer()
: params.input; : params.input;
@ -121,6 +136,8 @@ async function fitOverlayBufferIntoCanvas(params: {
input: outputBuffer, input: outputBuffer,
left, left,
top, top,
width: targetWidth,
height: targetHeight,
}; };
} }
@ -135,6 +152,38 @@ function sanitizeFileSegment(value: string, fallback: string) {
return normalized || fallback; 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: { function resolveTextOverlayFrame(params: {
layerWidth: number; layerWidth: number;
layerHeight: number; layerHeight: number;
@ -350,7 +399,7 @@ async function createTextOverlayBuffer(params: {
`; `;
const sharp = await getSharp(); 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: { export async function renderAgentPsdTemplate(params: {
@ -406,7 +455,9 @@ export async function renderAgentPsdTemplate(params: {
Boolean(renderBaseUrl) && Boolean(renderBaseUrl) &&
renderBaseUrl !== String(template.previewUrl || "").trim() && renderBaseUrl !== String(template.previewUrl || "").trim() &&
!orderedTemplateLayers.some( !orderedTemplateLayers.some(
(layer) => !layer.isEditable && hiddenLayerIdSet.has(layer.id) (layer) =>
!isAgentPsdTemplateLayerUserEditable(layer) &&
hiddenLayerIdSet.has(layer.id)
); );
function getLayerPositionOffset(layerId: string) { function getLayerPositionOffset(layerId: string) {
@ -433,6 +484,105 @@ export async function renderAgentPsdTemplate(params: {
return remoteBufferCache.get(normalizedUrl)!; 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 }> = 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]) { async function appendStoredLayer(layer: AgentPsdTemplate["layers"][number]) {
const sourceUrl = const sourceUrl = getStoredLayerSourceUrl(layer);
String(layer.sourceUrl || "").trim() ||
String(layer.previewUrl || "").trim();
if (!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) { if (!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) {
throw new Error( throw new Error(
`PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板` `PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板`
@ -498,6 +646,8 @@ export async function renderAgentPsdTemplate(params: {
} }
} }
await prefetchRenderAssets();
if (canUseDedicatedRenderBase) { if (canUseDedicatedRenderBase) {
const renderBaseBuffer = await readCachedBuffer(renderBaseUrl, "PSD 模板底图"); const renderBaseBuffer = await readCachedBuffer(renderBaseUrl, "PSD 模板底图");
await appendBufferOverlay({ await appendBufferOverlay({
@ -514,7 +664,7 @@ export async function renderAgentPsdTemplate(params: {
continue; continue;
} }
if (!layer.isEditable) { if (!isAgentPsdTemplateLayerUserEditable(layer)) {
if (canUseDedicatedRenderBase) { if (canUseDedicatedRenderBase) {
continue; continue;
} }
@ -534,11 +684,8 @@ export async function renderAgentPsdTemplate(params: {
assignmentUrl, assignmentUrl,
"PSD 套版素材" "PSD 套版素材"
); );
const normalizedSourceBuffer = await sharp(sourceBuffer) const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
.rotate() const metadata = await sourceImage.clone().metadata();
.png()
.toBuffer();
const metadata = await sharp(normalizedSourceBuffer).metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({ const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width, containerWidth: layer.width,
containerHeight: layer.height, containerHeight: layer.height,
@ -549,7 +696,8 @@ export async function renderAgentPsdTemplate(params: {
placement: imagePlacements[layer.id], placement: imagePlacements[layer.id],
}); });
const resizedOverlayBuffer = await sharp(normalizedSourceBuffer) const resizedOverlayBuffer = await sourceImage
.clone()
.resize({ .resize({
width: Math.max(1, Math.round(placementFrame.width)), width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)), height: Math.max(1, Math.round(placementFrame.height)),
@ -566,7 +714,7 @@ export async function renderAgentPsdTemplate(params: {
alpha: 0, alpha: 0,
}, },
}) })
.png() .png(PSD_RENDER_PNG_OPTIONS)
.toBuffer(); .toBuffer();
const slotOverlay = await fitOverlayBufferIntoCanvas({ const slotOverlay = await fitOverlayBufferIntoCanvas({
@ -579,6 +727,29 @@ export async function renderAgentPsdTemplate(params: {
canvasHeight: layer.height, 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({ let overlayBuffer = await sharp({
create: { create: {
width: Math.max(1, Math.round(layer.width)), width: Math.max(1, Math.round(layer.width)),
@ -592,8 +763,8 @@ export async function renderAgentPsdTemplate(params: {
}, },
}, },
}) })
.composite(slotOverlay ? [slotOverlay] : []) .composite([slotOverlay])
.png() .png(PSD_RENDER_PNG_OPTIONS)
.toBuffer(); .toBuffer();
if (layer.maskUrl) { if (layer.maskUrl) {
@ -612,7 +783,7 @@ export async function renderAgentPsdTemplate(params: {
alpha: 0, alpha: 0,
}, },
}) })
.png() .png(PSD_RENDER_PNG_OPTIONS)
.toBuffer(); .toBuffer();
overlayBuffer = await sharp(overlayBuffer) overlayBuffer = await sharp(overlayBuffer)
.composite([ .composite([
@ -621,7 +792,7 @@ export async function renderAgentPsdTemplate(params: {
blend: "dest-in", blend: "dest-in",
}, },
]) ])
.png() .png(PSD_RENDER_PNG_OPTIONS)
.toBuffer(); .toBuffer();
} catch (error) { } catch (error) {
console.warn( console.warn(
@ -637,8 +808,8 @@ export async function renderAgentPsdTemplate(params: {
const didAppend = await appendBufferOverlay({ const didAppend = await appendBufferOverlay({
input: overlayBuffer, input: overlayBuffer,
left: layer.x + getLayerPositionOffset(layer.id).offsetX, left: layer.x + layerPositionOffset.offsetX,
top: layer.y + getLayerPositionOffset(layer.id).offsetY, top: layer.y + layerPositionOffset.offsetY,
width: layer.width, width: layer.width,
height: layer.height, height: layer.height,
}); });
@ -797,12 +968,12 @@ export async function renderAgentPsdTemplate(params: {
}, },
}, },
}) })
.png() .png(PSD_RENDER_PNG_OPTIONS)
.toBuffer(); .toBuffer();
const resultBuffer = await sharp(transparentCanvas) const resultBuffer = await sharp(transparentCanvas)
.composite(compositeInputs) .composite(compositeInputs)
.png() .png(PSD_RENDER_PNG_OPTIONS)
.toBuffer(); .toBuffer();
const uploaded = await uploadFile( const uploaded = await uploadFile(

View File

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