fix: refine PSD templates and result exports
This commit is contained in:
parent
ca7b709de0
commit
edc24cb956
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user