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,
|
||||
getEditableAgentPsdTemplateLayers,
|
||||
isAgentPsdTextLayerSlot,
|
||||
isAgentPsdTemplateLayerUserEditable,
|
||||
normalizeAgentPsdTemplateMode,
|
||||
normalizeAgentPsdTemplateStatus,
|
||||
parseAgentPsdTemplateLayers,
|
||||
@ -161,7 +162,10 @@ export async function PUT(
|
||||
const fonts = await readAgentPsdFonts();
|
||||
const fontMap = new Map(fonts.map((font) => [font.id, font]));
|
||||
for (const layer of nextLayers) {
|
||||
if (!layer.isEditable || !isAgentPsdTextLayerSlot(layer)) {
|
||||
if (
|
||||
!isAgentPsdTemplateLayerUserEditable(layer) ||
|
||||
!isAgentPsdTextLayerSlot(layer)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -185,7 +185,8 @@ async function fetchDownloadFile(params: {
|
||||
params.file.name || `canvas-image-${params.index + 1}`,
|
||||
`canvas-image-${params.index + 1}`
|
||||
);
|
||||
const filename = `${baseName}-${params.index + 1}.${ext}`;
|
||||
const sequencePrefix = String(params.index + 1).padStart(3, "0");
|
||||
const filename = `${sequencePrefix}-${baseName}.${ext}`;
|
||||
return { ok: true, filename, bytes };
|
||||
} catch (error) {
|
||||
lastError = normalizeDownloadError(error);
|
||||
|
||||
@ -2915,6 +2915,28 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
).length,
|
||||
[skuResultCards]
|
||||
);
|
||||
const detailReplacementBatchDownloadUrls = useMemo(
|
||||
() =>
|
||||
detailResultCards
|
||||
.map((card) => card.resultUrl)
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
[detailResultCards]
|
||||
);
|
||||
const skuReplacementBatchDownloadUrls = useMemo(
|
||||
() =>
|
||||
skuResultCards
|
||||
.map((card) => card.resultUrl)
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
[skuResultCards]
|
||||
);
|
||||
const detailReplacementOrderedDownloadUrls =
|
||||
detailReplacementBatchDownloadUrls.length > 0
|
||||
? detailReplacementBatchDownloadUrls
|
||||
: mergedResultCollections.detail_replacement;
|
||||
const skuReplacementOrderedDownloadUrls =
|
||||
skuReplacementBatchDownloadUrls.length > 0
|
||||
? skuReplacementBatchDownloadUrls
|
||||
: mergedResultCollections.sku_replacement;
|
||||
const detailRemainingCount = useMemo(
|
||||
() =>
|
||||
detailResultCards.filter(
|
||||
@ -3384,13 +3406,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
zipName: string;
|
||||
scope: "scene" | "detail" | "sku";
|
||||
}) => {
|
||||
const normalizedUrls = Array.from(
|
||||
new Set(
|
||||
(params.imageUrls || [])
|
||||
const normalizedUrls = (params.imageUrls || [])
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter((item) => /^https?:\/\//i.test(item))
|
||||
)
|
||||
);
|
||||
.filter((item) => /^https?:\/\//i.test(item));
|
||||
if (normalizedUrls.length === 0) {
|
||||
toast.error("当前没有可下载的结果图");
|
||||
return;
|
||||
@ -3398,6 +3416,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
|
||||
setBatchDownloadState(params.scope);
|
||||
try {
|
||||
const batchFileBaseName =
|
||||
params.scope === "sku"
|
||||
? "sku-result"
|
||||
: params.scope === "scene"
|
||||
? "scene-result"
|
||||
: "detail-result";
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = "/api/canvas/download-zip";
|
||||
@ -3408,11 +3432,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
payloadInput.name = "payload";
|
||||
payloadInput.value = JSON.stringify({
|
||||
folderName: params.zipName,
|
||||
files: normalizedUrls.map((imageUrl, index) => ({
|
||||
files: normalizedUrls.map((imageUrl) => ({
|
||||
url: imageUrl,
|
||||
name:
|
||||
buildDownloadFilename(imageUrl) ||
|
||||
`${params.zipName}-${String(index + 1).padStart(2, "0")}`,
|
||||
name: `${params.zipName}-${batchFileBaseName}`,
|
||||
})),
|
||||
});
|
||||
form.appendChild(payloadInput);
|
||||
@ -6426,12 +6448,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
);
|
||||
})}
|
||||
{activeResultModeType === "detail_replacement" &&
|
||||
mergedResultCollections.detail_replacement.length > 0 ? (
|
||||
detailReplacementOrderedDownloadUrls.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleBatchDownloadImages({
|
||||
imageUrls: mergedResultCollections.detail_replacement,
|
||||
imageUrls: detailReplacementOrderedDownloadUrls,
|
||||
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
|
||||
scope: "detail",
|
||||
})
|
||||
@ -6460,12 +6482,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</button>
|
||||
) : null}
|
||||
{activeResultModeType === "sku_replacement" &&
|
||||
mergedResultCollections.sku_replacement.length > 0 ? (
|
||||
skuReplacementOrderedDownloadUrls.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleBatchDownloadImages({
|
||||
imageUrls: mergedResultCollections.sku_replacement,
|
||||
imageUrls: skuReplacementOrderedDownloadUrls,
|
||||
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
|
||||
scope: "sku",
|
||||
})
|
||||
|
||||
@ -70,6 +70,7 @@ import {
|
||||
type AgentPsdTemplateRenderResponse,
|
||||
type AgentPsdTemplateTextLayerSlot,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdLayerSuperLocked,
|
||||
isAgentPsdTextLayerSlot,
|
||||
parseAgentPsdTemplates,
|
||||
resolveAgentPsdTextLayerEffectiveAlign,
|
||||
@ -203,6 +204,33 @@ const templateDetailFetcher = async (
|
||||
}
|
||||
};
|
||||
|
||||
const PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY = 4;
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<R>
|
||||
) {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(
|
||||
1,
|
||||
Math.min(items.length, Math.round(Number(concurrency || 1)))
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[currentIndex] = await mapper(items[currentIndex]!, currentIndex);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function parseActionJsonResponse<T>(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
@ -846,6 +874,9 @@ function createInitialState(
|
||||
|
||||
for (const layer of template.layers) {
|
||||
layerOrderIds.push(layer.id);
|
||||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isAgentPsdImageLayerSlot(layer) &&
|
||||
layer.isDefaultResultSource &&
|
||||
@ -955,6 +986,7 @@ function resolveTransferredSelectedLayerId(params: {
|
||||
const nextCandidateLayers = nextTemplate.layers.filter(
|
||||
(layer) =>
|
||||
layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer) &&
|
||||
((isAgentPsdImageLayerSlot(previousLayer) &&
|
||||
isAgentPsdImageLayerSlot(layer)) ||
|
||||
(isAgentPsdTextLayerSlot(previousLayer) &&
|
||||
@ -1008,6 +1040,9 @@ function createTemplateEditorState(params: {
|
||||
|
||||
const usedPreviousLayerIds = new Set<string>();
|
||||
for (const nextLayer of nextTemplate.layers) {
|
||||
if (isAgentPsdLayerSuperLocked(nextLayer)) {
|
||||
continue;
|
||||
}
|
||||
const matchedPreviousLayer = findMatchingLayer({
|
||||
nextLayer,
|
||||
previousLayers: previousTemplate.layers,
|
||||
@ -1019,6 +1054,10 @@ function createTemplateEditorState(params: {
|
||||
|
||||
const previousLayerPositionOffset =
|
||||
previousLayerPositionOffsets[matchedPreviousLayer.id];
|
||||
if (isAgentPsdLayerSuperLocked(matchedPreviousLayer)) {
|
||||
usedPreviousLayerIds.add(matchedPreviousLayer.id);
|
||||
continue;
|
||||
}
|
||||
if (hasLayerPositionOffset(previousLayerPositionOffset)) {
|
||||
nextState.layerPositionOffsets[nextLayer.id] =
|
||||
normalizeLayerPositionOffset(previousLayerPositionOffset);
|
||||
@ -1029,11 +1068,15 @@ function createTemplateEditorState(params: {
|
||||
|
||||
const previousEditableImageLayers = previousTemplate.layers.filter(
|
||||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||||
layer.isEditable && isAgentPsdImageLayerSlot(layer)
|
||||
layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer) &&
|
||||
isAgentPsdImageLayerSlot(layer)
|
||||
);
|
||||
const nextEditableImageLayers = nextTemplate.layers.filter(
|
||||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||||
layer.isEditable && isAgentPsdImageLayerSlot(layer)
|
||||
layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer) &&
|
||||
isAgentPsdImageLayerSlot(layer)
|
||||
);
|
||||
const usedPreviousImageLayerIds = new Set<string>();
|
||||
const usedNextImageLayerIds = new Set<string>();
|
||||
@ -1091,11 +1134,15 @@ function createTemplateEditorState(params: {
|
||||
|
||||
const previousEditableTextLayers = previousTemplate.layers.filter(
|
||||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||||
layer.isEditable && isAgentPsdTextLayerSlot(layer)
|
||||
layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer) &&
|
||||
isAgentPsdTextLayerSlot(layer)
|
||||
);
|
||||
const nextEditableTextLayers = nextTemplate.layers.filter(
|
||||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||||
layer.isEditable && isAgentPsdTextLayerSlot(layer)
|
||||
layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer) &&
|
||||
isAgentPsdTextLayerSlot(layer)
|
||||
);
|
||||
const usedPreviousTextLayerIds = new Set<string>();
|
||||
|
||||
@ -1214,7 +1261,9 @@ function getPreferredSelectedLayerId(template: AgentPsdTemplate | null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const editableLayers = template.layers.filter((layer) => layer.isEditable);
|
||||
const editableLayers = template.layers.filter(
|
||||
(layer) => layer.isEditable && !isAgentPsdLayerSuperLocked(layer)
|
||||
);
|
||||
if (editableLayers.length === 0) {
|
||||
return "";
|
||||
}
|
||||
@ -1448,14 +1497,6 @@ function hasCustomizedTextLayerAppearance(
|
||||
);
|
||||
}
|
||||
|
||||
function getTextLayerNameBadgeClassName() {
|
||||
return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/70 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md";
|
||||
}
|
||||
|
||||
function getTextLayerHintBadgeClassName() {
|
||||
return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md";
|
||||
}
|
||||
|
||||
export function AgentPsdTemplateEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@ -1659,6 +1700,9 @@ export function AgentPsdTemplateEditorDialog({
|
||||
const textColorAssignmentsRef = useRef(textColorAssignments);
|
||||
const textPositionOffsetsRef = useRef(textPositionOffsets);
|
||||
const archivedRenderImageUrlCacheRef = useRef<Record<string, string>>({});
|
||||
const archivedRenderImageUrlPromiseCacheRef = useRef<
|
||||
Record<string, Promise<string>>
|
||||
>({});
|
||||
const selectedLayerIdRef = useRef(selectedLayerId);
|
||||
|
||||
imageAssignmentsRef.current = imageAssignments;
|
||||
@ -1672,6 +1716,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
archivedRenderImageUrlCacheRef.current = {};
|
||||
archivedRenderImageUrlPromiseCacheRef.current = {};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@ -1792,6 +1837,10 @@ export function AgentPsdTemplateEditorDialog({
|
||||
}
|
||||
return selectedTemplate.layers;
|
||||
}, [selectedTemplate]);
|
||||
const selectedTemplateLayerById = useMemo(
|
||||
() => new Map(selectedTemplateLayers.map((layer) => [layer.id, layer])),
|
||||
[selectedTemplateLayers]
|
||||
);
|
||||
const selectedTemplateMode: AgentPsdTemplateMode =
|
||||
selectedTemplate?.mode === "sku" ? "sku" : "main";
|
||||
const selectedModeTemplates = useMemo(
|
||||
@ -1842,7 +1891,9 @@ export function AgentPsdTemplateEditorDialog({
|
||||
() =>
|
||||
selectedTemplateLayers.filter(
|
||||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||||
layer.isEditable && isAgentPsdImageLayerSlot(layer)
|
||||
layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer) &&
|
||||
isAgentPsdImageLayerSlot(layer)
|
||||
),
|
||||
[selectedTemplateLayers]
|
||||
);
|
||||
@ -1850,7 +1901,9 @@ export function AgentPsdTemplateEditorDialog({
|
||||
() =>
|
||||
selectedTemplateLayers.filter(
|
||||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||||
layer.isEditable && isAgentPsdTextLayerSlot(layer)
|
||||
layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer) &&
|
||||
isAgentPsdTextLayerSlot(layer)
|
||||
),
|
||||
[selectedTemplateLayers]
|
||||
);
|
||||
@ -1922,7 +1975,15 @@ export function AgentPsdTemplateEditorDialog({
|
||||
[hiddenLayerIdSet, orderedPreviewLayers]
|
||||
);
|
||||
const orderedEditableLayers = useMemo(
|
||||
() => orderedPreviewLayers.filter((layer) => layer.isEditable),
|
||||
() =>
|
||||
orderedPreviewLayers.filter(
|
||||
(layer) => layer.isEditable && !isAgentPsdLayerSuperLocked(layer)
|
||||
),
|
||||
[orderedPreviewLayers]
|
||||
);
|
||||
const orderedUserManageablePreviewLayers = useMemo(
|
||||
() =>
|
||||
orderedPreviewLayers.filter((layer) => !isAgentPsdLayerSuperLocked(layer)),
|
||||
[orderedPreviewLayers]
|
||||
);
|
||||
const orderedEditableImageLayers = useMemo(
|
||||
@ -2028,6 +2089,9 @@ export function AgentPsdTemplateEditorDialog({
|
||||
}
|
||||
|
||||
for (const layer of selectedTemplateLayers) {
|
||||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||||
continue;
|
||||
}
|
||||
nextOffsets[layer.id] = normalizeLayerPositionOffset(
|
||||
layerPositionOffsets[layer.id]
|
||||
);
|
||||
@ -2059,12 +2123,12 @@ export function AgentPsdTemplateEditorDialog({
|
||||
selectedTemplate,
|
||||
]);
|
||||
|
||||
const selectedLayer = useMemo(
|
||||
() =>
|
||||
selectedTemplateLayers.find((layer) => layer.id === selectedLayerId) ||
|
||||
null,
|
||||
[selectedLayerId, selectedTemplateLayers]
|
||||
);
|
||||
const selectedLayer = useMemo(() => {
|
||||
const layer =
|
||||
selectedTemplateLayers.find((entry) => entry.id === selectedLayerId) ||
|
||||
null;
|
||||
return layer && !isAgentPsdLayerSuperLocked(layer) ? layer : null;
|
||||
}, [selectedLayerId, selectedTemplateLayers]);
|
||||
const isSelectedLayerHidden = Boolean(
|
||||
selectedLayer && hiddenLayerIdSet.has(selectedLayer.id)
|
||||
);
|
||||
@ -2101,6 +2165,10 @@ export function AgentPsdTemplateEditorDialog({
|
||||
if (!normalizedLayerId) {
|
||||
return;
|
||||
}
|
||||
const layer = selectedTemplateLayerById.get(normalizedLayerId);
|
||||
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
|
||||
return;
|
||||
}
|
||||
setHiddenLayerIds((current) =>
|
||||
current.includes(normalizedLayerId)
|
||||
? current
|
||||
@ -2113,6 +2181,10 @@ export function AgentPsdTemplateEditorDialog({
|
||||
if (!normalizedLayerId) {
|
||||
return;
|
||||
}
|
||||
const layer = selectedTemplateLayerById.get(normalizedLayerId);
|
||||
if (layer && isAgentPsdLayerSuperLocked(layer)) {
|
||||
return;
|
||||
}
|
||||
setHiddenLayerIds((current) =>
|
||||
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
|
||||
);
|
||||
@ -2669,7 +2741,9 @@ export function AgentPsdTemplateEditorDialog({
|
||||
}
|
||||
|
||||
const validLayerIds = new Set(
|
||||
selectedTemplateLayers.map((layer) => layer.id)
|
||||
selectedTemplateLayers
|
||||
.filter((layer) => !isAgentPsdLayerSuperLocked(layer))
|
||||
.map((layer) => layer.id)
|
||||
);
|
||||
setHiddenLayerIds((current) =>
|
||||
current.filter((layerId) => validLayerIds.has(layerId))
|
||||
@ -2994,6 +3068,10 @@ export function AgentPsdTemplateEditorDialog({
|
||||
current: AgentPsdLayerPositionOffset
|
||||
) => Partial<AgentPsdLayerPositionOffset>
|
||||
) {
|
||||
const layer = selectedTemplateLayerById.get(String(layerId || "").trim());
|
||||
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
|
||||
return;
|
||||
}
|
||||
setLayerPositionOffsets((current) => ({
|
||||
...current,
|
||||
[layerId]: normalizeLayerPositionOffset({
|
||||
@ -3011,6 +3089,10 @@ export function AgentPsdTemplateEditorDialog({
|
||||
if (!normalizedLayerId) {
|
||||
return;
|
||||
}
|
||||
const layer = selectedTemplateLayerById.get(normalizedLayerId);
|
||||
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLayerOrderIds((current) => {
|
||||
const fallbackOrder = selectedTemplateLayers.map((layer) => layer.id);
|
||||
@ -3111,7 +3193,13 @@ export function AgentPsdTemplateEditorDialog({
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
const cachedPromise =
|
||||
archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
|
||||
if (cachedPromise) {
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
const archivePromise = (async () => {
|
||||
const fetchCandidates = Array.from(
|
||||
new Set(
|
||||
[
|
||||
@ -3177,23 +3265,54 @@ export function AgentPsdTemplateEditorDialog({
|
||||
`${label}无法转存到本站存储,请重新上传图片后再试`
|
||||
)
|
||||
);
|
||||
})();
|
||||
|
||||
archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl] =
|
||||
archivePromise;
|
||||
|
||||
try {
|
||||
return await archivePromise;
|
||||
} finally {
|
||||
delete archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeRenderImageInputs() {
|
||||
const nextSourceImageUrl = await archiveRenderImageUrl(
|
||||
sourceImageUrl || "",
|
||||
"PSD 套版主图"
|
||||
);
|
||||
let nextSourceImageUrl = sourceImageUrl || "";
|
||||
const nextImageAssignments = { ...effectiveImageAssignments };
|
||||
const layerNameById = new Map(
|
||||
editableImageLayers.map((layer) => [layer.id, layer.layerName])
|
||||
);
|
||||
const archiveJobs = [
|
||||
{
|
||||
type: "source" as const,
|
||||
layerId: "",
|
||||
url: sourceImageUrl || "",
|
||||
label: "PSD 套版主图",
|
||||
},
|
||||
...Object.entries(nextImageAssignments).map(([layerId, layerUrl]) => ({
|
||||
type: "assignment" as const,
|
||||
layerId,
|
||||
url: layerUrl,
|
||||
label: `PSD 图层素材「${layerNameById.get(layerId) || layerId}」`,
|
||||
})),
|
||||
].filter((job) => String(job.url || "").trim());
|
||||
|
||||
for (const [layerId, layerUrl] of Object.entries(nextImageAssignments)) {
|
||||
nextImageAssignments[layerId] = await archiveRenderImageUrl(
|
||||
layerUrl,
|
||||
`PSD 图层素材「${layerNameById.get(layerId) || layerId}」`
|
||||
const archivedJobs = await mapWithConcurrency(
|
||||
archiveJobs,
|
||||
PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY,
|
||||
async (job) => ({
|
||||
...job,
|
||||
archivedUrl: await archiveRenderImageUrl(job.url, job.label),
|
||||
})
|
||||
);
|
||||
|
||||
for (const job of archivedJobs) {
|
||||
if (job.type === "source") {
|
||||
nextSourceImageUrl = job.archivedUrl;
|
||||
} else {
|
||||
nextImageAssignments[job.layerId] = job.archivedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@ -3206,6 +3325,9 @@ export function AgentPsdTemplateEditorDialog({
|
||||
layer: AgentPsdTemplateLayerSlot,
|
||||
event: ReactPointerEvent<HTMLElement>
|
||||
) {
|
||||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSelectedLayerId(layer.id);
|
||||
@ -3264,6 +3386,9 @@ export function AgentPsdTemplateEditorDialog({
|
||||
layer: AgentPsdTemplateTextLayerSlot,
|
||||
event: ReactPointerEvent<HTMLDivElement>
|
||||
) {
|
||||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSelectedLayerId(layer.id);
|
||||
@ -3936,6 +4061,8 @@ export function AgentPsdTemplateEditorDialog({
|
||||
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{orderedVisiblePreviewLayers.map((layer) => {
|
||||
const isLayerSuperLocked =
|
||||
isAgentPsdLayerSuperLocked(layer);
|
||||
const layerPositionOffset =
|
||||
effectiveLayerPositionOffsets[layer.id] ||
|
||||
normalizeLayerPositionOffset();
|
||||
@ -3965,24 +4092,38 @@ export function AgentPsdTemplateEditorDialog({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute overflow-hidden"
|
||||
className={`absolute overflow-hidden ${
|
||||
isLayerSuperLocked
|
||||
? "pointer-events-none"
|
||||
: "pointer-events-auto"
|
||||
}`}
|
||||
key={layer.id}
|
||||
onClick={(event) => {
|
||||
onClick={
|
||||
isLayerSuperLocked
|
||||
? undefined
|
||||
: (event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedLayerId(layer.id);
|
||||
}}
|
||||
}
|
||||
}
|
||||
onPointerDown={(event) =>
|
||||
handleLayerDragStart(layer, event)
|
||||
isLayerSuperLocked
|
||||
? undefined
|
||||
: handleLayerDragStart(layer, event)
|
||||
}
|
||||
style={{
|
||||
...assignedImageContainerStyle,
|
||||
zIndex: baseZIndex,
|
||||
cursor:
|
||||
layerDragStateRef.current?.layerId ===
|
||||
isLayerSuperLocked
|
||||
? "default"
|
||||
: layerDragStateRef.current?.layerId ===
|
||||
layer.id
|
||||
? "grabbing"
|
||||
: "grab",
|
||||
touchAction: "none",
|
||||
touchAction: isLayerSuperLocked
|
||||
? "auto"
|
||||
: "none",
|
||||
}}
|
||||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||||
>
|
||||
@ -4046,24 +4187,38 @@ export function AgentPsdTemplateEditorDialog({
|
||||
if (!shouldUseEditedPreview && layer.sourceUrl) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute overflow-hidden"
|
||||
className={`absolute overflow-hidden ${
|
||||
isLayerSuperLocked
|
||||
? "pointer-events-none"
|
||||
: "pointer-events-auto"
|
||||
}`}
|
||||
key={layer.id}
|
||||
onClick={(event) => {
|
||||
onClick={
|
||||
isLayerSuperLocked
|
||||
? undefined
|
||||
: (event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedLayerId(layer.id);
|
||||
}}
|
||||
}
|
||||
}
|
||||
onPointerDown={(event) =>
|
||||
handleTextDragStart(layer, event)
|
||||
isLayerSuperLocked
|
||||
? undefined
|
||||
: handleTextDragStart(layer, event)
|
||||
}
|
||||
style={{
|
||||
...layerBoxStyle,
|
||||
zIndex: baseZIndex,
|
||||
cursor:
|
||||
textDragStateRef.current?.layerId ===
|
||||
isLayerSuperLocked
|
||||
? "default"
|
||||
: textDragStateRef.current?.layerId ===
|
||||
layer.id
|
||||
? "grabbing"
|
||||
: "grab",
|
||||
touchAction: "none",
|
||||
touchAction: isLayerSuperLocked
|
||||
? "auto"
|
||||
: "none",
|
||||
}}
|
||||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||||
>
|
||||
@ -4091,14 +4246,24 @@ export function AgentPsdTemplateEditorDialog({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute overflow-visible"
|
||||
className={`absolute overflow-visible ${
|
||||
isLayerSuperLocked
|
||||
? "pointer-events-none"
|
||||
: "pointer-events-auto"
|
||||
}`}
|
||||
key={layer.id}
|
||||
onClick={(event) => {
|
||||
onClick={
|
||||
isLayerSuperLocked
|
||||
? undefined
|
||||
: (event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedLayerId(layer.id);
|
||||
}}
|
||||
}
|
||||
}
|
||||
onPointerDown={(event) =>
|
||||
handleTextDragStart(layer, event)
|
||||
isLayerSuperLocked
|
||||
? undefined
|
||||
: handleTextDragStart(layer, event)
|
||||
}
|
||||
style={{
|
||||
left:
|
||||
@ -4115,11 +4280,15 @@ export function AgentPsdTemplateEditorDialog({
|
||||
height: textFrame.height,
|
||||
zIndex: baseZIndex,
|
||||
cursor:
|
||||
textDragStateRef.current?.layerId ===
|
||||
isLayerSuperLocked
|
||||
? "default"
|
||||
: textDragStateRef.current?.layerId ===
|
||||
layer.id
|
||||
? "grabbing"
|
||||
: "grab",
|
||||
touchAction: "none",
|
||||
touchAction: isLayerSuperLocked
|
||||
? "auto"
|
||||
: "none",
|
||||
}}
|
||||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||||
>
|
||||
@ -4135,7 +4304,11 @@ export function AgentPsdTemplateEditorDialog({
|
||||
|
||||
<div className="absolute inset-0">
|
||||
{orderedVisiblePreviewLayers
|
||||
.filter((layer) => !layer.isEditable)
|
||||
.filter(
|
||||
(layer) =>
|
||||
!layer.isEditable &&
|
||||
!isAgentPsdLayerSuperLocked(layer)
|
||||
)
|
||||
.map((layer) => {
|
||||
const isSelected = selectedLayerId === layer.id;
|
||||
const baseZIndex =
|
||||
@ -4169,15 +4342,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
touchAction: "none",
|
||||
}}
|
||||
title={`普通图层:${layer.layerName}`}
|
||||
>
|
||||
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||||
{layer.layerName}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-white/85 px-2 py-1 text-[11px] text-black shadow-md backdrop-blur-md">
|
||||
<Move className="h-3 w-3" />
|
||||
拖动图层
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -4218,15 +4383,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
touchAction: "none",
|
||||
}}
|
||||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||||
>
|
||||
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||||
{layer.layerName}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||||
<Move className="h-3 w-3" />
|
||||
拖动图层
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||||
@ -4255,18 +4412,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
}}
|
||||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||||
type="button"
|
||||
>
|
||||
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||||
{layer.layerName}
|
||||
</div>
|
||||
|
||||
{hasAssignedImage ? null : (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||||
<Move className="h-3 w-3" />
|
||||
拖动图层 / 点击替换
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -4441,16 +4587,6 @@ export function AgentPsdTemplateEditorDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={getTextLayerNameBadgeClassName()}
|
||||
>
|
||||
{layer.layerName}
|
||||
</div>
|
||||
<div
|
||||
className={getTextLayerHintBadgeClassName()}
|
||||
>
|
||||
拖动文字 / 右侧微调
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4477,19 +4613,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
}}
|
||||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className={getTextLayerNameBadgeClassName()}
|
||||
>
|
||||
{layer.layerName}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={getTextLayerHintBadgeClassName()}
|
||||
>
|
||||
点击编辑文字
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -4973,7 +5097,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplateLayers.length > 0 ? (
|
||||
{orderedUserManageablePreviewLayers.length > 0 ? (
|
||||
<div className="flex shrink-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@ -5010,7 +5134,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">
|
||||
已隐藏 {hiddenLayerIds.length} /{" "}
|
||||
{selectedTemplateLayers.length}
|
||||
{orderedUserManageablePreviewLayers.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -5019,7 +5143,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
ref={hiddenLayerScrollAreaRef}
|
||||
>
|
||||
<div className="space-y-2 pb-1">
|
||||
{orderedPreviewLayers.map((layer) => {
|
||||
{orderedUserManageablePreviewLayers.map((layer) => {
|
||||
const isHidden = hiddenLayerIdSet.has(layer.id);
|
||||
const isSelected = selectedLayerId === layer.id;
|
||||
return (
|
||||
|
||||
@ -115,6 +115,8 @@ const WORKFLOW_OPTION_PREVIEW_WIDTH = 512;
|
||||
const WORKFLOW_OPTION_PREVIEW_QUALITY = 62;
|
||||
const WORKFLOW_OPTION_PREVIEW_EAGER_COUNT = 10;
|
||||
const WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT = 12;
|
||||
const WORKFLOW_OPTION_NEXT_PREVIEW_PREFETCH_COUNT = 24;
|
||||
const WORKFLOW_OPTION_PREFETCH_CACHE_LIMIT = 320;
|
||||
|
||||
function isLikelyStoredWorkflowPreview(url: string, sourceUrl?: string | null) {
|
||||
const source = String(sourceUrl || "").trim();
|
||||
@ -157,6 +159,33 @@ function handleWorkflowOptionPreviewError(
|
||||
image.src = fallbackUrl;
|
||||
}
|
||||
|
||||
function appendWorkflowOptionPreviewUrls(
|
||||
output: Set<string>,
|
||||
options: WorkflowStep["options"] | undefined,
|
||||
limit: number
|
||||
) {
|
||||
for (const option of (options || []).slice(0, limit)) {
|
||||
const previewUrl = buildWorkflowOptionPreviewUrl(
|
||||
option.previewImageUrl,
|
||||
option.previewImageSourceUrl
|
||||
);
|
||||
if (previewUrl) {
|
||||
output.add(previewUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trimWorkflowPreviewImageCache(
|
||||
cache: Map<string, HTMLImageElement>,
|
||||
limit = WORKFLOW_OPTION_PREFETCH_CACHE_LIMIT
|
||||
) {
|
||||
while (cache.size > limit) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (!firstKey) return;
|
||||
cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkflowPanelDerivedState {
|
||||
progress: ReturnType<typeof getAgentWorkflowProgress>;
|
||||
}
|
||||
@ -967,6 +996,10 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
const latestExternalSelectionsRef = useRef(selections);
|
||||
const selectionCommitTimerRef = useRef<number | null>(null);
|
||||
const pendingSelectionCommitRef = useRef<WorkflowSelectionState | null>(null);
|
||||
const workflowPreviewPrefetchedUrlsRef = useRef(new Set<string>());
|
||||
const workflowPreviewImageCacheRef = useRef(
|
||||
new Map<string, HTMLImageElement>()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestExternalSelectionsRef.current === selections) {
|
||||
@ -1141,20 +1174,80 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
activeEmbeddedOptionLevels[0] ||
|
||||
null;
|
||||
const workflowOptionPreviewPrefetchUrls = useMemo(() => {
|
||||
const options = embedded
|
||||
? activeEmbeddedOptionLevel?.options || []
|
||||
: flattenedResolvedSteps[0]?.step.options || [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
options
|
||||
.slice(0, WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT)
|
||||
.map((option) =>
|
||||
buildWorkflowOptionPreviewUrl(option.previewImageUrl, option.previewImageSourceUrl)
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
const urls = new Set<string>();
|
||||
|
||||
if (embedded) {
|
||||
appendWorkflowOptionPreviewUrls(
|
||||
urls,
|
||||
activeEmbeddedOptionLevel?.options,
|
||||
WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT
|
||||
);
|
||||
}, [activeEmbeddedOptionLevel?.options, embedded, flattenedResolvedSteps]);
|
||||
|
||||
const currentSelectedOption =
|
||||
activeEmbeddedOptionLevel?.selectedOption || null;
|
||||
appendWorkflowOptionPreviewUrls(
|
||||
urls,
|
||||
currentSelectedOption?.children,
|
||||
WORKFLOW_OPTION_NEXT_PREVIEW_PREFETCH_COUNT
|
||||
);
|
||||
|
||||
if (activeEmbeddedStepEntry) {
|
||||
const currentStepComplete = isAgentWorkflowStepSelectionComplete({
|
||||
step: activeEmbeddedStepEntry.step,
|
||||
selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key],
|
||||
});
|
||||
|
||||
if (currentStepComplete) {
|
||||
const nextStepEntry = flattenedResolvedSteps
|
||||
.slice(activeEmbeddedStepIndex + 1)
|
||||
.find(
|
||||
({ step }) =>
|
||||
!isAgentWorkflowStepSelectionComplete({
|
||||
step,
|
||||
selectionValue: optimisticSelections[step.key],
|
||||
})
|
||||
);
|
||||
|
||||
if (nextStepEntry) {
|
||||
const nextStepLevels = getWorkflowStepOptionLevels({
|
||||
step: nextStepEntry.step,
|
||||
selectionValue: optimisticSelections[nextStepEntry.step.key],
|
||||
});
|
||||
const nextStepFirstPendingLevel =
|
||||
nextStepLevels.find((level) => !level.selectedKey) ||
|
||||
nextStepLevels[nextStepLevels.length - 1] ||
|
||||
null;
|
||||
|
||||
appendWorkflowOptionPreviewUrls(
|
||||
urls,
|
||||
nextStepFirstPendingLevel?.options || nextStepEntry.step.options,
|
||||
WORKFLOW_OPTION_NEXT_PREVIEW_PREFETCH_COUNT
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
appendWorkflowOptionPreviewUrls(
|
||||
urls,
|
||||
flattenedResolvedSteps[0]?.step.options,
|
||||
WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT
|
||||
);
|
||||
appendWorkflowOptionPreviewUrls(
|
||||
urls,
|
||||
flattenedResolvedSteps[1]?.step.options,
|
||||
WORKFLOW_OPTION_NEXT_PREVIEW_PREFETCH_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(urls);
|
||||
}, [
|
||||
activeEmbeddedOptionLevel,
|
||||
activeEmbeddedStepEntry,
|
||||
activeEmbeddedStepIndex,
|
||||
embedded,
|
||||
flattenedResolvedSteps,
|
||||
optimisticSelections,
|
||||
]);
|
||||
const activeEmbeddedDeepestSelectedOption = useMemo(
|
||||
() =>
|
||||
[...activeEmbeddedOptionLevels]
|
||||
@ -1257,23 +1350,33 @@ function AgentWorkflowPanelDarkV2Component({
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupRef = { current: () => {} };
|
||||
cleanupRef.current = scheduleWorkflowIdleTask(() => {
|
||||
const images = workflowOptionPreviewPrefetchUrls.map((src) => {
|
||||
const urlsToPrefetch = workflowOptionPreviewPrefetchUrls.filter(
|
||||
(src) => !workflowPreviewPrefetchedUrlsRef.current.has(src)
|
||||
);
|
||||
if (urlsToPrefetch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
urlsToPrefetch.forEach((src) => {
|
||||
workflowPreviewPrefetchedUrlsRef.current.add(src);
|
||||
});
|
||||
|
||||
scheduleWorkflowIdleTask(() => {
|
||||
urlsToPrefetch.forEach((src, index) => {
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
(
|
||||
image as HTMLImageElement & {
|
||||
fetchPriority?: "high" | "low" | "auto";
|
||||
}
|
||||
).fetchPriority =
|
||||
index < WORKFLOW_OPTION_PREVIEW_EAGER_COUNT ? "high" : "low";
|
||||
image.src = src;
|
||||
return image;
|
||||
workflowPreviewImageCacheRef.current.set(src, image);
|
||||
});
|
||||
|
||||
cleanupRef.current = () => {
|
||||
images.forEach((image) => {
|
||||
image.src = "";
|
||||
});
|
||||
};
|
||||
}, 120);
|
||||
|
||||
return () => cleanupRef.current();
|
||||
trimWorkflowPreviewImageCache(workflowPreviewImageCacheRef.current);
|
||||
}, 80);
|
||||
}, [workflowOptionPreviewPrefetchUrls]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -3,6 +3,141 @@ import "server-only";
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_RETRY_COUNT = 3;
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
const DEFAULT_MEMORY_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const DEFAULT_MEMORY_CACHE_MAX_ENTRY_BYTES = 32 * 1024 * 1024;
|
||||
const remoteAssetCacheKey = "__agent_psd_remote_asset_cache__";
|
||||
|
||||
type RemoteAssetCacheEntry = {
|
||||
buffer: Buffer;
|
||||
byteLength: number;
|
||||
expiresAt: number;
|
||||
lastAccessAt: number;
|
||||
};
|
||||
|
||||
type RemoteAssetCacheState = {
|
||||
entries: Map<string, RemoteAssetCacheEntry>;
|
||||
inFlight: Map<string, Promise<Buffer>>;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
function getRemoteAssetCacheState(): RemoteAssetCacheState {
|
||||
const globalObj = globalThis as typeof globalThis & {
|
||||
[remoteAssetCacheKey]?: RemoteAssetCacheState;
|
||||
};
|
||||
|
||||
if (!globalObj[remoteAssetCacheKey]) {
|
||||
globalObj[remoteAssetCacheKey] = {
|
||||
entries: new Map<string, RemoteAssetCacheEntry>(),
|
||||
inFlight: new Map<string, Promise<Buffer>>(),
|
||||
totalBytes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return globalObj[remoteAssetCacheKey] as RemoteAssetCacheState;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function getRemoteAssetCacheConfig() {
|
||||
return {
|
||||
ttlMs: parsePositiveInteger(
|
||||
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_TTL_MS,
|
||||
DEFAULT_MEMORY_CACHE_TTL_MS
|
||||
),
|
||||
maxBytes: parsePositiveInteger(
|
||||
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_MAX_BYTES,
|
||||
DEFAULT_MEMORY_CACHE_MAX_BYTES
|
||||
),
|
||||
maxEntryBytes: parsePositiveInteger(
|
||||
process.env.AGENT_PSD_REMOTE_ASSET_CACHE_MAX_ENTRY_BYTES,
|
||||
DEFAULT_MEMORY_CACHE_MAX_ENTRY_BYTES
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function isRemoteAssetMemoryCacheEnabled() {
|
||||
return process.env.AGENT_PSD_REMOTE_ASSET_CACHE_DISABLED !== "1";
|
||||
}
|
||||
|
||||
function isCacheableRemoteAssetUrl(url: string) {
|
||||
return !/^(data|blob):/i.test(String(url || "").trim());
|
||||
}
|
||||
|
||||
function pruneRemoteAssetCache(state: RemoteAssetCacheState, maxBytes: number) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of state.entries) {
|
||||
if (entry.expiresAt <= now) {
|
||||
state.entries.delete(key);
|
||||
state.totalBytes -= entry.byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.totalBytes <= maxBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entriesByLastAccess = Array.from(state.entries.entries()).sort(
|
||||
(left, right) => left[1].lastAccessAt - right[1].lastAccessAt
|
||||
);
|
||||
|
||||
for (const [key, entry] of entriesByLastAccess) {
|
||||
if (state.totalBytes <= maxBytes) break;
|
||||
state.entries.delete(key);
|
||||
state.totalBytes -= entry.byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
function readRemoteAssetMemoryCache(url: string) {
|
||||
if (!isRemoteAssetMemoryCacheEnabled() || !isCacheableRemoteAssetUrl(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = getRemoteAssetCacheState();
|
||||
const entry = state.entries.get(url);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
state.entries.delete(url);
|
||||
state.totalBytes -= entry.byteLength;
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.lastAccessAt = Date.now();
|
||||
return entry.buffer;
|
||||
}
|
||||
|
||||
function writeRemoteAssetMemoryCache(url: string, buffer: Buffer) {
|
||||
if (!isRemoteAssetMemoryCacheEnabled() || !isCacheableRemoteAssetUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ttlMs, maxBytes, maxEntryBytes } = getRemoteAssetCacheConfig();
|
||||
const byteLength = buffer.byteLength;
|
||||
if (byteLength <= 0 || byteLength > maxEntryBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getRemoteAssetCacheState();
|
||||
const previous = state.entries.get(url);
|
||||
if (previous) {
|
||||
state.totalBytes -= previous.byteLength;
|
||||
}
|
||||
|
||||
state.entries.set(url, {
|
||||
buffer,
|
||||
byteLength,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
lastAccessAt: Date.now(),
|
||||
});
|
||||
state.totalBytes += byteLength;
|
||||
pruneRemoteAssetCache(state, maxBytes);
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@ -51,6 +186,7 @@ export async function fetchRemoteAssetBuffer(
|
||||
label?: string;
|
||||
timeoutMs?: number;
|
||||
retries?: number;
|
||||
useMemoryCache?: boolean;
|
||||
}
|
||||
) {
|
||||
const normalizedUrl = String(url || "").trim();
|
||||
@ -62,7 +198,22 @@ export async function fetchRemoteAssetBuffer(
|
||||
const attempts = Math.max(1, Math.round(Number(options?.retries || DEFAULT_RETRY_COUNT)));
|
||||
const timeoutMs = Math.max(5_000, Math.round(Number(options?.timeoutMs || DEFAULT_TIMEOUT_MS)));
|
||||
const location = describeRemoteAssetLocation(normalizedUrl);
|
||||
const shouldUseMemoryCache = options?.useMemoryCache !== false;
|
||||
|
||||
if (shouldUseMemoryCache) {
|
||||
const cachedBuffer = readRemoteAssetMemoryCache(normalizedUrl);
|
||||
if (cachedBuffer) {
|
||||
return cachedBuffer;
|
||||
}
|
||||
|
||||
const cacheState = getRemoteAssetCacheState();
|
||||
const inFlightRequest = cacheState.inFlight.get(normalizedUrl);
|
||||
if (inFlightRequest) {
|
||||
return inFlightRequest;
|
||||
}
|
||||
}
|
||||
|
||||
const downloadPromise = (async () => {
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(normalizedUrl, {
|
||||
@ -87,7 +238,11 @@ export async function fetchRemoteAssetBuffer(
|
||||
throw statusError;
|
||||
}
|
||||
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
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:", {
|
||||
@ -112,4 +267,20 @@ export async function fetchRemoteAssetBuffer(
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return downloadPromise;
|
||||
}
|
||||
|
||||
@ -10,8 +10,10 @@ import {
|
||||
getAgentPsdTemplateRuntimeIssue,
|
||||
getEditableAgentPsdTemplateLayers,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdLayerSuperLocked,
|
||||
isAgentPsdTemplateReady,
|
||||
isAgentPsdTemplateRuntimeReady,
|
||||
isAgentPsdTemplateLayerUserEditable,
|
||||
isAgentPsdTextLayerSlot,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
|
||||
@ -100,6 +102,11 @@ export async function executeAgentPsdTemplateRenderRequest(
|
||||
.map((layerId) => String(layerId || "").trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
for (const layer of resolvedTemplate.layers) {
|
||||
if (isAgentPsdLayerSuperLocked(layer)) {
|
||||
hiddenLayerIdSet.delete(layer.id);
|
||||
}
|
||||
}
|
||||
const normalizedLayerOrderIds = Array.from(
|
||||
new Set(
|
||||
(body.layerOrderIds || [])
|
||||
@ -137,11 +144,13 @@ export async function executeAgentPsdTemplateRenderRequest(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isAgentPsdLayerSuperLocked(layer)) {
|
||||
layerPositionOffsets[layer.id] = normalizeTextPositionOffset(
|
||||
body.layerPositionOffsets[layer.id]
|
||||
);
|
||||
}
|
||||
|
||||
if (!layer.isEditable) {
|
||||
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -15,14 +15,15 @@ interface QueueState {
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONCURRENCY = 1;
|
||||
const DEFAULT_CONCURRENCY = 2;
|
||||
const MAX_CONCURRENCY = 8;
|
||||
const STALE_RUNNING_MS = 15 * 60 * 1000;
|
||||
const queueStateKey = "__agent_psd_render_task_queue_state__";
|
||||
|
||||
function getQueueConcurrency(): number {
|
||||
const raw = Number.parseInt(process.env.AGENT_PSD_RENDER_TASK_CONCURRENCY || "", 10);
|
||||
if (Number.isFinite(raw) && raw > 0) {
|
||||
return Math.min(raw, 3);
|
||||
return Math.min(raw, MAX_CONCURRENCY);
|
||||
}
|
||||
return DEFAULT_CONCURRENCY;
|
||||
}
|
||||
@ -83,6 +84,7 @@ async function claimNextPendingTask(): Promise<string | null> {
|
||||
async function runSingleTask(taskId: string) {
|
||||
const state = getQueueState();
|
||||
state.running.add(taskId);
|
||||
const startedAtMs = Date.now();
|
||||
console.log("[PSD Render Task] starting:", { taskId });
|
||||
|
||||
try {
|
||||
@ -121,11 +123,13 @@ async function runSingleTask(taskId: string) {
|
||||
.where(eq(agentPsdRenderTask.id, taskId));
|
||||
console.log("[PSD Render Task] succeeded:", {
|
||||
taskId,
|
||||
durationMs: Date.now() - startedAtMs,
|
||||
resultUrl: result.url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[PSD Render Task] failed:", {
|
||||
taskId,
|
||||
durationMs: Date.now() - startedAtMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import type { AgentPsdTemplate } from "@/lib/agents/psd-template-config";
|
||||
import {
|
||||
getEditableAgentPsdTemplateLayers,
|
||||
type AgentPsdTemplate,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
|
||||
export interface AgentPsdTemplateCategoryTagConfig {
|
||||
tags: string[];
|
||||
@ -150,7 +153,7 @@ export function buildAgentPsdTemplateCategoryTagIndex(params: {
|
||||
if (
|
||||
template.status !== "active" ||
|
||||
template.parseStatus !== "completed" ||
|
||||
!template.layers.some((layer) => layer.isEditable)
|
||||
getEditableAgentPsdTemplateLayers(template).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ export interface AgentPsdTemplateBaseLayerSlot {
|
||||
width: number;
|
||||
height: number;
|
||||
isEditable: boolean;
|
||||
isSuperLocked?: boolean;
|
||||
isRequired: boolean;
|
||||
previewUrl?: string;
|
||||
previewPath?: string;
|
||||
@ -338,6 +339,7 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
|
||||
}
|
||||
|
||||
const isEditable = record.isEditable === true;
|
||||
const isSuperLocked = record.isSuperLocked === true;
|
||||
const baseRecord = {
|
||||
id,
|
||||
layerId,
|
||||
@ -349,7 +351,8 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
|
||||
width,
|
||||
height,
|
||||
isEditable,
|
||||
isRequired: isEditable && record.isRequired === true,
|
||||
isSuperLocked,
|
||||
isRequired: isEditable && !isSuperLocked && record.isRequired === true,
|
||||
previewUrl: normalizeOptionalUrl(record.previewUrl),
|
||||
previewPath: normalizeText(record.previewPath) || undefined,
|
||||
sourceUrl: normalizeOptionalUrl(record.sourceUrl),
|
||||
@ -400,8 +403,10 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
|
||||
layers.push({
|
||||
...baseRecord,
|
||||
slotType: "image",
|
||||
isDefaultResultSource: isEditable && record.isDefaultResultSource === true,
|
||||
allowUserUploadOverride: isEditable ? record.allowUserUploadOverride !== false : false,
|
||||
isDefaultResultSource:
|
||||
isEditable && !isSuperLocked && record.isDefaultResultSource === true,
|
||||
allowUserUploadOverride:
|
||||
isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false,
|
||||
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
|
||||
contentBounds: normalizeAgentPsdTemplateImageContentBounds(
|
||||
record.contentBounds,
|
||||
@ -565,7 +570,7 @@ export function getAgentPsdTemplateRuntimeMissingLayers(template: AgentPsdTempla
|
||||
if (hasUsableRenderBase) {
|
||||
return (
|
||||
isAgentPsdImageLayerSlot(layer) &&
|
||||
layer.isEditable &&
|
||||
isAgentPsdTemplateLayerUserEditable(layer) &&
|
||||
!hasAgentPsdTemplateStoredLayerSource(layer)
|
||||
);
|
||||
}
|
||||
@ -603,7 +608,19 @@ export function isAgentPsdTemplateRuntimeReady(template: AgentPsdTemplate) {
|
||||
}
|
||||
|
||||
export function getEditableAgentPsdTemplateLayers(template: AgentPsdTemplate) {
|
||||
return template.layers.filter((layer) => layer.isEditable);
|
||||
return template.layers.filter(isAgentPsdTemplateLayerUserEditable);
|
||||
}
|
||||
|
||||
export function isAgentPsdLayerSuperLocked(
|
||||
layer: Pick<AgentPsdTemplateBaseLayerSlot, "isSuperLocked">
|
||||
) {
|
||||
return layer.isSuperLocked === true;
|
||||
}
|
||||
|
||||
export function isAgentPsdTemplateLayerUserEditable(
|
||||
layer: AgentPsdTemplateLayerSlot
|
||||
) {
|
||||
return layer.isEditable && !isAgentPsdLayerSuperLocked(layer);
|
||||
}
|
||||
|
||||
export function isAgentPsdTextLayerSlot(
|
||||
|
||||
@ -7,6 +7,7 @@ import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
|
||||
import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout";
|
||||
import {
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdTemplateLayerUserEditable,
|
||||
isAgentPsdTextLayerSlot,
|
||||
type AgentPsdTemplateImageContentBounds,
|
||||
type AgentPsdTemplateLayerSlot,
|
||||
@ -691,7 +692,7 @@ function shouldPrepareLayerPreview(
|
||||
layer: AgentPsdTemplateLayerSlot,
|
||||
assetProfile: PsdTemplateLayerAssetProfile
|
||||
) {
|
||||
return assetProfile === "full" || layer.isEditable;
|
||||
return assetProfile === "full" || isAgentPsdTemplateLayerUserEditable(layer);
|
||||
}
|
||||
|
||||
function shouldPrepareLayerSource(
|
||||
@ -702,7 +703,7 @@ function shouldPrepareLayerSource(
|
||||
return true;
|
||||
}
|
||||
|
||||
return isAgentPsdImageLayerSlot(layer) && layer.isEditable;
|
||||
return isAgentPsdImageLayerSlot(layer) && isAgentPsdTemplateLayerUserEditable(layer);
|
||||
}
|
||||
|
||||
export async function preparePsdTemplateRenderAssets(params: {
|
||||
@ -729,7 +730,9 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []);
|
||||
const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry]));
|
||||
const editableLayerIdSet = new Set(
|
||||
params.layers.filter((layer) => layer.isEditable).map((layer) => layer.layerId)
|
||||
params.layers
|
||||
.filter(isAgentPsdTemplateLayerUserEditable)
|
||||
.map((layer) => layer.layerId)
|
||||
);
|
||||
|
||||
const nextLayers = [...params.layers];
|
||||
@ -747,11 +750,12 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
(params.forceRefreshLayerAssets ||
|
||||
!String(targetLayer.sourceUrl || "").trim());
|
||||
const shouldAnalyzeText =
|
||||
targetLayer.isEditable &&
|
||||
isAgentPsdTemplateLayerUserEditable(targetLayer) &&
|
||||
isAgentPsdTextLayerSlot(targetLayer) &&
|
||||
targetLayer.styleSource !== "custom";
|
||||
const shouldResolveImageBounds =
|
||||
targetLayer.isEditable && isAgentPsdImageLayerSlot(targetLayer);
|
||||
isAgentPsdTemplateLayerUserEditable(targetLayer) &&
|
||||
isAgentPsdImageLayerSlot(targetLayer);
|
||||
|
||||
if (
|
||||
!shouldUploadPreview &&
|
||||
@ -858,7 +862,10 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable) {
|
||||
if (
|
||||
!isAgentPsdImageLayerSlot(targetLayer) ||
|
||||
!isAgentPsdTemplateLayerUserEditable(targetLayer)
|
||||
) {
|
||||
if (isAgentPsdImageLayerSlot(preparedLayer)) {
|
||||
preparedLayer = {
|
||||
...preparedLayer,
|
||||
|
||||
@ -5,6 +5,7 @@ import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
|
||||
import {
|
||||
hasAgentPsdTemplateStoredLayerSource,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdTemplateLayerUserEditable,
|
||||
isAgentPsdTextLayerSlot,
|
||||
resolveAgentPsdTextLayerEffectiveAlign,
|
||||
type AgentPsdTemplate,
|
||||
@ -25,6 +26,20 @@ type AgentPsdTextPositionOffset = {
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
const PSD_RENDER_PNG_COMPRESSION_LEVEL = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
9,
|
||||
Number.parseInt(process.env.AGENT_PSD_RENDER_PNG_COMPRESSION_LEVEL || "4", 10)
|
||||
)
|
||||
);
|
||||
const PSD_RENDER_PNG_OPTIONS = {
|
||||
compressionLevel: Number.isFinite(PSD_RENDER_PNG_COMPRESSION_LEVEL)
|
||||
? PSD_RENDER_PNG_COMPRESSION_LEVEL
|
||||
: 4,
|
||||
adaptiveFiltering: false,
|
||||
} as const;
|
||||
|
||||
function clampTextPositionOffsetValue(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
@ -113,7 +128,7 @@ async function fitOverlayBufferIntoCanvas(params: {
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
})
|
||||
.png()
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer()
|
||||
: params.input;
|
||||
|
||||
@ -121,6 +136,8 @@ async function fitOverlayBufferIntoCanvas(params: {
|
||||
input: outputBuffer,
|
||||
left,
|
||||
top,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@ -135,6 +152,38 @@ function sanitizeFileSegment(value: string, fallback: string) {
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<R>
|
||||
) {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(
|
||||
1,
|
||||
Math.min(items.length, Math.round(Number(concurrency || 1)))
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[currentIndex] = await mapper(items[currentIndex]!, currentIndex);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function getStoredLayerSourceUrl(layer: AgentPsdTemplate["layers"][number]) {
|
||||
return (
|
||||
String(layer.sourceUrl || "").trim() ||
|
||||
String(layer.previewUrl || "").trim()
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTextOverlayFrame(params: {
|
||||
layerWidth: number;
|
||||
layerHeight: number;
|
||||
@ -350,7 +399,7 @@ async function createTextOverlayBuffer(params: {
|
||||
`;
|
||||
|
||||
const sharp = await getSharp();
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
return sharp(Buffer.from(svg)).png(PSD_RENDER_PNG_OPTIONS).toBuffer();
|
||||
}
|
||||
|
||||
export async function renderAgentPsdTemplate(params: {
|
||||
@ -406,7 +455,9 @@ export async function renderAgentPsdTemplate(params: {
|
||||
Boolean(renderBaseUrl) &&
|
||||
renderBaseUrl !== String(template.previewUrl || "").trim() &&
|
||||
!orderedTemplateLayers.some(
|
||||
(layer) => !layer.isEditable && hiddenLayerIdSet.has(layer.id)
|
||||
(layer) =>
|
||||
!isAgentPsdTemplateLayerUserEditable(layer) &&
|
||||
hiddenLayerIdSet.has(layer.id)
|
||||
);
|
||||
|
||||
function getLayerPositionOffset(layerId: string) {
|
||||
@ -433,6 +484,105 @@ export async function renderAgentPsdTemplate(params: {
|
||||
return remoteBufferCache.get(normalizedUrl)!;
|
||||
}
|
||||
|
||||
async function prefetchRenderAssets() {
|
||||
const assetCandidates: Array<{
|
||||
url: string;
|
||||
label: string;
|
||||
optional?: boolean;
|
||||
}> = [];
|
||||
const pushAsset = (
|
||||
url: string | undefined,
|
||||
label: string,
|
||||
optional = false
|
||||
) => {
|
||||
const normalizedUrl = String(url || "").trim();
|
||||
if (!normalizedUrl) return;
|
||||
assetCandidates.push({ url: normalizedUrl, label, optional });
|
||||
};
|
||||
|
||||
if (canUseDedicatedRenderBase) {
|
||||
pushAsset(renderBaseUrl, "PSD 模板底图");
|
||||
}
|
||||
|
||||
for (const layer of orderedTemplateLayers) {
|
||||
if (hiddenLayerIdSet.has(layer.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storedLayerSourceUrl = getStoredLayerSourceUrl(layer);
|
||||
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
|
||||
if (!canUseDedicatedRenderBase) {
|
||||
pushAsset(storedLayerSourceUrl, "PSD 图层素材");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAgentPsdImageLayerSlot(layer)) {
|
||||
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
|
||||
if (assignmentUrl) {
|
||||
pushAsset(assignmentUrl, "PSD 套版素材");
|
||||
pushAsset(layer.maskUrl, "PSD 蒙版资源", true);
|
||||
} else {
|
||||
pushAsset(storedLayerSourceUrl, "PSD 图层素材");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAgentPsdTextLayerSlot(layer)) {
|
||||
const hasTextAssignment = Object.hasOwn(textAssignments, layer.id);
|
||||
const defaultText = String(layer.defaultText || "").trim();
|
||||
const text = String(
|
||||
hasTextAssignment ? (textAssignments[layer.id] ?? "") : defaultText
|
||||
).trim();
|
||||
const textColor = normalizeHexColor(
|
||||
textColorAssignments[layer.id] || layer.textColor,
|
||||
layer.textColor
|
||||
);
|
||||
const hasColorChange =
|
||||
normalizeHexColor(textColor, layer.textColor) !==
|
||||
normalizeHexColor(layer.textColor);
|
||||
const textPositionOffset = normalizeTextPositionOffset(
|
||||
textPositionOffsets[layer.id]
|
||||
);
|
||||
const hasPositionChange = hasTextPositionOffset(textPositionOffset);
|
||||
|
||||
if (
|
||||
(!text && !hasTextAssignment) ||
|
||||
(text === defaultText && !hasColorChange && !hasPositionChange)
|
||||
) {
|
||||
pushAsset(storedLayerSourceUrl, "PSD 图层素材");
|
||||
} else {
|
||||
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
|
||||
pushAsset(font?.fileUrl, "PSD 字体资源", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueAssetMap = new Map<
|
||||
string,
|
||||
{ url: string; label: string; optional?: boolean }
|
||||
>();
|
||||
for (const asset of assetCandidates) {
|
||||
const previous = uniqueAssetMap.get(asset.url);
|
||||
uniqueAssetMap.set(asset.url, {
|
||||
...asset,
|
||||
label: previous?.label || asset.label,
|
||||
optional: Boolean(previous?.optional) && Boolean(asset.optional),
|
||||
});
|
||||
}
|
||||
const uniqueAssets = Array.from(uniqueAssetMap.values());
|
||||
|
||||
await mapWithConcurrency(uniqueAssets, 6, async (asset) => {
|
||||
try {
|
||||
await readCachedBuffer(asset.url, asset.label);
|
||||
} catch (error) {
|
||||
if (!asset.optional) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> =
|
||||
[];
|
||||
|
||||
@ -461,9 +611,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
}
|
||||
|
||||
async function appendStoredLayer(layer: AgentPsdTemplate["layers"][number]) {
|
||||
const sourceUrl =
|
||||
String(layer.sourceUrl || "").trim() ||
|
||||
String(layer.previewUrl || "").trim();
|
||||
const sourceUrl = getStoredLayerSourceUrl(layer);
|
||||
if (!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) {
|
||||
throw new Error(
|
||||
`PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板`
|
||||
@ -498,6 +646,8 @@ export async function renderAgentPsdTemplate(params: {
|
||||
}
|
||||
}
|
||||
|
||||
await prefetchRenderAssets();
|
||||
|
||||
if (canUseDedicatedRenderBase) {
|
||||
const renderBaseBuffer = await readCachedBuffer(renderBaseUrl, "PSD 模板底图");
|
||||
await appendBufferOverlay({
|
||||
@ -514,7 +664,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!layer.isEditable) {
|
||||
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
|
||||
if (canUseDedicatedRenderBase) {
|
||||
continue;
|
||||
}
|
||||
@ -534,11 +684,8 @@ export async function renderAgentPsdTemplate(params: {
|
||||
assignmentUrl,
|
||||
"PSD 套版素材"
|
||||
);
|
||||
const normalizedSourceBuffer = await sharp(sourceBuffer)
|
||||
.rotate()
|
||||
.png()
|
||||
.toBuffer();
|
||||
const metadata = await sharp(normalizedSourceBuffer).metadata();
|
||||
const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
|
||||
const metadata = await sourceImage.clone().metadata();
|
||||
const placementFrame = resolveAgentPsdImagePlacementFrame({
|
||||
containerWidth: layer.width,
|
||||
containerHeight: layer.height,
|
||||
@ -549,7 +696,8 @@ export async function renderAgentPsdTemplate(params: {
|
||||
placement: imagePlacements[layer.id],
|
||||
});
|
||||
|
||||
const resizedOverlayBuffer = await sharp(normalizedSourceBuffer)
|
||||
const resizedOverlayBuffer = await sourceImage
|
||||
.clone()
|
||||
.resize({
|
||||
width: Math.max(1, Math.round(placementFrame.width)),
|
||||
height: Math.max(1, Math.round(placementFrame.height)),
|
||||
@ -566,7 +714,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
alpha: 0,
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
|
||||
const slotOverlay = await fitOverlayBufferIntoCanvas({
|
||||
@ -579,6 +727,29 @@ export async function renderAgentPsdTemplate(params: {
|
||||
canvasHeight: layer.height,
|
||||
});
|
||||
|
||||
if (!slotOverlay) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const layerPositionOffset = getLayerPositionOffset(layer.id);
|
||||
if (!layer.maskUrl) {
|
||||
const didAppend = await appendBufferOverlay({
|
||||
input: slotOverlay.input,
|
||||
left:
|
||||
layer.x +
|
||||
layerPositionOffset.offsetX +
|
||||
slotOverlay.left,
|
||||
top:
|
||||
layer.y +
|
||||
layerPositionOffset.offsetY +
|
||||
slotOverlay.top,
|
||||
width: slotOverlay.width,
|
||||
height: slotOverlay.height,
|
||||
});
|
||||
if (!didAppend) continue;
|
||||
continue;
|
||||
}
|
||||
|
||||
let overlayBuffer = await sharp({
|
||||
create: {
|
||||
width: Math.max(1, Math.round(layer.width)),
|
||||
@ -592,8 +763,8 @@ export async function renderAgentPsdTemplate(params: {
|
||||
},
|
||||
},
|
||||
})
|
||||
.composite(slotOverlay ? [slotOverlay] : [])
|
||||
.png()
|
||||
.composite([slotOverlay])
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
|
||||
if (layer.maskUrl) {
|
||||
@ -612,7 +783,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
alpha: 0,
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
overlayBuffer = await sharp(overlayBuffer)
|
||||
.composite([
|
||||
@ -621,7 +792,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
blend: "dest-in",
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@ -637,8 +808,8 @@ export async function renderAgentPsdTemplate(params: {
|
||||
|
||||
const didAppend = await appendBufferOverlay({
|
||||
input: overlayBuffer,
|
||||
left: layer.x + getLayerPositionOffset(layer.id).offsetX,
|
||||
top: layer.y + getLayerPositionOffset(layer.id).offsetY,
|
||||
left: layer.x + layerPositionOffset.offsetX,
|
||||
top: layer.y + layerPositionOffset.offsetY,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
});
|
||||
@ -797,12 +968,12 @@ export async function renderAgentPsdTemplate(params: {
|
||||
},
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
|
||||
const resultBuffer = await sharp(transparentCanvas)
|
||||
.composite(compositeInputs)
|
||||
.png()
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
|
||||
const uploaded = await uploadFile(
|
||||
|
||||
@ -9,6 +9,8 @@ import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
|
||||
import {
|
||||
AGENT_PSD_TEMPLATE_ASSET_VERSION,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdLayerSuperLocked,
|
||||
isAgentPsdTemplateLayerUserEditable,
|
||||
isAgentPsdTextLayerSlot,
|
||||
type AgentPsdTemplate,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
@ -28,14 +30,17 @@ function hasMissingLayerAssets(template: AgentPsdTemplate) {
|
||||
|
||||
return template.layers.some(
|
||||
(layer) => {
|
||||
if (layer.isEditable && !String(layer.previewUrl || "").trim()) {
|
||||
if (
|
||||
isAgentPsdTemplateLayerUserEditable(layer) &&
|
||||
!String(layer.previewUrl || "").trim()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasRenderBase) {
|
||||
return (
|
||||
isAgentPsdImageLayerSlot(layer) &&
|
||||
layer.isEditable &&
|
||||
isAgentPsdTemplateLayerUserEditable(layer) &&
|
||||
!String(layer.sourceUrl || "").trim()
|
||||
);
|
||||
}
|
||||
@ -63,12 +68,20 @@ function hasDedicatedRenderBase(template: AgentPsdTemplate) {
|
||||
|
||||
function repairEditableImageLayerSelection(template: AgentPsdTemplate) {
|
||||
const imageLayers = template.layers.filter(isAgentPsdImageLayerSlot);
|
||||
if (imageLayers.length === 0) {
|
||||
const unlockedImageLayers = imageLayers.filter(
|
||||
(layer) => !isAgentPsdLayerSuperLocked(layer)
|
||||
);
|
||||
if (unlockedImageLayers.length === 0) {
|
||||
return template.layers;
|
||||
}
|
||||
|
||||
const editableImageLayers = imageLayers.filter((layer) => layer.isEditable);
|
||||
const defaultImageLayer = imageLayers.find((layer) => layer.isEditable && layer.isDefaultResultSource);
|
||||
const editableImageLayers = unlockedImageLayers.filter(
|
||||
isAgentPsdTemplateLayerUserEditable
|
||||
);
|
||||
const defaultImageLayer = unlockedImageLayers.find(
|
||||
(layer) =>
|
||||
isAgentPsdTemplateLayerUserEditable(layer) && layer.isDefaultResultSource
|
||||
);
|
||||
const shouldAutoRepairSelection =
|
||||
editableImageLayers.length === 0 ||
|
||||
!defaultImageLayer ||
|
||||
@ -79,7 +92,7 @@ function repairEditableImageLayerSelection(template: AgentPsdTemplate) {
|
||||
}
|
||||
|
||||
const nextPrimaryLayerId = pickPrimaryPsdImageLayerId({
|
||||
imageLayers,
|
||||
imageLayers: unlockedImageLayers,
|
||||
canvasWidth: template.width,
|
||||
canvasHeight: template.height,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user