feat: add async PSD render task pipeline
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 23:28:17 +08:00
parent 6648f43a20
commit 649825072b
6 changed files with 424 additions and 151 deletions

View File

@ -1,6 +1,8 @@
"use client";
import {
Eye,
EyeOff,
Expand,
Image as ImageIcon,
Loader2,
@ -1229,6 +1231,7 @@ export function AgentPsdTemplateEditorDialog({
const [textColorAssignments, setTextColorAssignments] = useState<
Record<string, string>
>({});
const [hiddenLayerIds, setHiddenLayerIds] = useState<string[]>([]);
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [extendingSlotId, setExtendingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
@ -1400,6 +1403,15 @@ export function AgentPsdTemplateEditorDialog({
() => [...selectedTemplateLayers].reverse(),
[selectedTemplateLayers]
);
const hiddenLayerIdSet = useMemo(
() =>
new Set(hiddenLayerIds.map((layerId) => String(layerId || "").trim()).filter(Boolean)),
[hiddenLayerIds]
);
const orderedVisiblePreviewLayers = useMemo(
() => orderedPreviewLayers.filter((layer) => !hiddenLayerIdSet.has(layer.id)),
[hiddenLayerIdSet, orderedPreviewLayers]
);
const orderedEditableLayers = useMemo(
() => orderedPreviewLayers.filter((layer) => layer.isEditable),
[orderedPreviewLayers]
@ -1422,11 +1434,11 @@ export function AgentPsdTemplateEditorDialog({
);
const shouldShowCompositeFallback = useMemo(
() =>
orderedPreviewLayers.length === 0 ||
orderedPreviewLayers.some(
orderedVisiblePreviewLayers.length === 0 ||
orderedVisiblePreviewLayers.some(
(layer) => !String(layer.sourceUrl || "").trim()
),
[orderedPreviewLayers]
[orderedVisiblePreviewLayers]
);
const effectiveImageAssignments = useMemo(() => {
@ -1511,6 +1523,9 @@ export function AgentPsdTemplateEditorDialog({
null,
[selectedLayerId, selectedTemplateLayers]
);
const isSelectedLayerHidden = Boolean(
selectedLayer && hiddenLayerIdSet.has(selectedLayer.id)
);
const selectedImageLayer =
selectedLayer && isAgentPsdImageLayerSlot(selectedLayer)
? selectedLayer
@ -1534,6 +1549,26 @@ export function AgentPsdTemplateEditorDialog({
const isSelectedImageExtending = Boolean(
selectedImageLayer && extendingSlotId === selectedImageLayer.id
);
function hideLayerTemporarily(layerId: string) {
const normalizedLayerId = String(layerId || "").trim();
if (!normalizedLayerId) {
return;
}
setHiddenLayerIds((current) =>
current.includes(normalizedLayerId) ? current : [...current, normalizedLayerId]
);
}
function restoreHiddenLayer(layerId: string) {
const normalizedLayerId = String(layerId || "").trim();
if (!normalizedLayerId) {
return;
}
setHiddenLayerIds((current) =>
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
);
}
const selectedImageAdjustmentPanel = selectedImageLayer ? (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3.5">
<div className="grid grid-cols-2 gap-2">
@ -1766,6 +1801,7 @@ export function AgentPsdTemplateEditorDialog({
setImageSizes({});
setTextAssignments({});
setTextColorAssignments({});
setHiddenLayerIds([]);
setRenderedResultUrl("");
setRightPanelView("editor");
previousSelectedTemplateRef.current = null;
@ -1804,6 +1840,7 @@ export function AgentPsdTemplateEditorDialog({
setImagePlacements(initialState.imagePlacements);
setTextAssignments(initialState.textAssignments);
setTextColorAssignments(initialState.textColorAssignments);
setHiddenLayerIds([]);
setSelectedLayerId(
resolveTransferredSelectedLayerId({
nextTemplate: selectedTemplate,
@ -1838,6 +1875,15 @@ export function AgentPsdTemplateEditorDialog({
};
}, [open, selectedTemplate?.id]);
useEffect(() => {
if (hiddenLayerIds.length === 0) {
return;
}
const validLayerIds = new Set(selectedTemplateLayers.map((layer) => layer.id));
setHiddenLayerIds((current) => current.filter((layerId) => validLayerIds.has(layerId)));
}, [hiddenLayerIds.length, selectedTemplateLayers]);
useEffect(() => {
previousSelectedTemplateRef.current = selectedTemplate;
}, [selectedTemplate]);
@ -2208,9 +2254,11 @@ export function AgentPsdTemplateEditorDialog({
const payload = (await requestLongTaskResult({
endpoint: "/api/psd-templates/render",
timeoutMs: 8 * 60 * 1000,
maxPollFailures: 8,
payload: {
templateId: selectedTemplate.id,
sourceImageUrl: sourceImageUrl || "",
hiddenLayerIds,
imageAssignments: effectiveImageAssignments,
imagePlacements: effectiveImagePlacements,
textAssignments: effectiveTextAssignments,
@ -2631,7 +2679,8 @@ export function AgentPsdTemplateEditorDialog({
transformOrigin: "top left",
}}
>
{shouldShowCompositeFallback ? (
{shouldShowCompositeFallback &&
orderedVisiblePreviewLayers.length > 0 ? (
<img
alt={`${selectedTemplate.name} 编辑底图`}
className="absolute inset-0 h-full w-full object-cover"
@ -2648,7 +2697,7 @@ export function AgentPsdTemplateEditorDialog({
) : null}
<div className="pointer-events-none absolute inset-0">
{orderedPreviewLayers.map((layer) => {
{orderedVisiblePreviewLayers.map((layer) => {
const layerBoxStyle = {
left: layer.x,
top: layer.y,
@ -2773,6 +2822,9 @@ export function AgentPsdTemplateEditorDialog({
<div className="absolute inset-0">
{orderedEditableImageLayers.map((layer) => {
if (hiddenLayerIdSet.has(layer.id)) {
return null;
}
const isSelected = selectedLayerId === layer.id;
const frameStyle = getLayerFrameStyle(
layer,
@ -2846,6 +2898,9 @@ export function AgentPsdTemplateEditorDialog({
})}
{orderedEditableImageLayers.map((layer) => {
if (hiddenLayerIdSet.has(layer.id)) {
return null;
}
const isSelected = selectedLayerId === layer.id;
const hasAssignedImage = Boolean(
String(
@ -2899,6 +2954,9 @@ export function AgentPsdTemplateEditorDialog({
})}
{orderedEditableTextLayers.map((layer) => {
if (hiddenLayerIdSet.has(layer.id)) {
return null;
}
const isSelected = selectedLayerId === layer.id;
const frameStyle = getLayerFrameStyle(
layer,
@ -3119,155 +3177,257 @@ export function AgentPsdTemplateEditorDialog({
key={`${selectedTemplate?.id || "template"}-${renderedResultUrl}`}
sizeInfoText={sizeInfoText}
/>
) : selectedImageLayer ? (
<div className="flex min-h-0 flex-1 flex-col">
{selectedImageAdjustmentPanel}
</div>
) : selectedTextLayer ? (
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner xl:p-5">
<div className="flex h-full flex-col gap-4">
<Textarea
className="min-h-[180px] flex-1 resize-none rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-white/90 shadow-inner placeholder:text-white/30 focus-visible:border-emerald-500/50 focus-visible:bg-white/[0.04] focus-visible:ring-1 focus-visible:ring-emerald-500/50 xl:min-h-[200px]"
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[selectedTextLayer.id]: event.target.value,
}))
}
placeholder={
selectedTextLayer.placeholder || "请输入文字"
}
rows={4}
value={
effectiveTextAssignments[selectedTextLayer.id] || ""
}
/>
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-xl bg-white/[0.02] px-4 py-3 text-white/60 text-xs shadow-inner">
<span className="flex items-center gap-1.5">
<span className="text-white/40">字体:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.fontLabel ||
selectedTextLayer.fontFamily ||
"系统字体"}
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">字号:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.fontSize}px
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">行高:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.lineHeight}
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">字距:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.letterSpacing}px
</span>
</span>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{isSelectedLayerHidden && selectedLayer ? (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/5 p-8 text-center text-sm text-white/60 shadow-inner">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-amber-500/20 bg-amber-500/10 shadow-inner">
<EyeOff className="h-6 w-6 text-amber-300" />
</div>
<div className="font-medium text-white">图层已临时删除</div>
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
当前会话里不会再预览或导出图层“{selectedLayer.layerName}”,但不会改动模板本身。
</div>
<Button
className="mt-5 rounded-xl bg-emerald-500 px-4 text-white hover:bg-emerald-400"
onClick={() => restoreHiddenLayer(selectedLayer.id)}
type="button"
>
恢复这个图层
</Button>
</div>
{textColorConfig.enabled ? (
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">
文字颜色
) : selectedImageLayer ? (
<div className="flex min-h-0 flex-1 flex-col">
{selectedImageAdjustmentPanel}
</div>
) : selectedTextLayer ? (
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner xl:p-5">
<div className="flex h-full flex-col gap-4">
<Textarea
className="min-h-[180px] flex-1 resize-none rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-white/90 shadow-inner placeholder:text-white/30 focus-visible:border-emerald-500/50 focus-visible:bg-white/[0.04] focus-visible:ring-1 focus-visible:ring-emerald-500/50 xl:min-h-[200px]"
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[selectedTextLayer.id]: event.target.value,
}))
}
placeholder={
selectedTextLayer.placeholder || "请输入文字"
}
rows={4}
value={
effectiveTextAssignments[selectedTextLayer.id] || ""
}
/>
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-xl bg-white/[0.02] px-4 py-3 text-white/60 text-xs shadow-inner">
<span className="flex items-center gap-1.5">
<span className="text-white/40">字体:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.fontLabel ||
selectedTextLayer.fontFamily ||
"系统字体"}
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">字号:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.fontSize}px
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">行高:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.lineHeight}
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">字距:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.letterSpacing}px
</span>
</span>
</div>
{textColorConfig.enabled ? (
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">
文字颜色
</div>
<div className="mt-1.5 text-white/50 text-xs">
选中文字后,可直接点击下面按钮切换品牌预设文字颜色。
</div>
</div>
<div
className="h-10 w-10 shrink-0 rounded-full border-2 border-white/20 shadow-[0_0_15px_rgba(0,0,0,0.5)]"
style={{ backgroundColor: selectedTextColor }}
/>
</div>
<div className="mt-1.5 text-white/50 text-xs">
选中文字后,可直接点击下面按钮切换品牌预设文字颜色。
<div className="grid grid-cols-2 gap-2.5">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(
selectedTextLayer,
color
) ===
getResolvedTextLayerColor(
selectedTextLayer,
selectedTextColor
);
return (
<button
className={`group flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition-all duration-200 ${
isActive
? "border-emerald-500/50 bg-emerald-500/10 text-white shadow-[0_0_15px_rgba(16,185,129,0.15)] ring-1 ring-emerald-500/50"
: "border-white/[0.08] bg-white/[0.02] text-white/80 hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={`text-color-option-${index + 1}`}
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
type="button"
>
<span
className={`h-6 w-6 shrink-0 rounded-full border-2 transition-all ${
isActive
? "scale-110 border-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)]"
: "border-white/20 shadow-inner"
}`}
style={{ backgroundColor: color }}
/>
<span className="flex min-w-0 flex-col">
<span
className={`font-medium text-xs transition-colors ${isActive ? "text-emerald-400" : "text-white/90 group-hover:text-white"}`}
>{`颜色 ${index + 1}`}</span>
<span
className={`font-mono text-[11px] uppercase transition-colors ${isActive ? "text-emerald-500/70" : "text-white/50 group-hover:text-white/70"}`}
>
{color}
</span>
</span>
</button>
);
})}
</div>
</div>
<div
className="h-10 w-10 shrink-0 rounded-full border-2 border-white/20 shadow-[0_0_15px_rgba(0,0,0,0.5)]"
style={{ backgroundColor: selectedTextColor }}
/>
) : null}
</div>
</div>
) : selectedTemplate && selectedTemplateLayers.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
{selectedTemplateDetailError ? (
<>
<div className="font-medium text-white/80">
模板详情加载失败
</div>
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
{selectedTemplateDetailError}
</div>
</>
) : (
<>
<Loader2 className="mb-4 h-6 w-6 animate-spin text-emerald-400" />
<div className="font-medium text-white/80">
正在加载这个模板的编辑图层
</div>
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
首屏先展示模板列表,完整文字图层、图片图层和字体配置会按需补回来。
</div>
</>
)}
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-white/10 bg-white/5 shadow-inner">
<Pointer className="h-6 w-6 text-white/50" />
</div>
请在中间预览区点击图层进行编辑
</div>
)}
{selectedTemplateLayers.length > 0 ? (
<div className="shrink-0 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>
<div className="font-semibold text-sm text-white">
图层显示
</div>
<div className="mt-1 text-white/45 text-xs">
临时删除只影响这次套版预览和导出,不会改动 PSD 模板本身。
</div>
</div>
<div className="grid grid-cols-2 gap-2.5">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(
selectedTextLayer,
color
) ===
getResolvedTextLayerColor(
selectedTextLayer,
selectedTextColor
);
<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}
</div>
</div>
<ScrollArea className="max-h-[220px] pr-2">
<div className="space-y-2">
{orderedPreviewLayers.map((layer) => {
const isHidden = hiddenLayerIdSet.has(layer.id);
const isSelected = selectedLayerId === layer.id;
return (
<button
className={`group flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition-all duration-200 ${
isActive
? "border-emerald-500/50 bg-emerald-500/10 text-white shadow-[0_0_15px_rgba(16,185,129,0.15)] ring-1 ring-emerald-500/50"
: "border-white/[0.08] bg-white/[0.02] text-white/80 hover:border-white/[0.15] hover:bg-white/[0.04]"
<div
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ${
isSelected
? "border-emerald-500/40 bg-emerald-500/10"
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={`text-color-option-${index + 1}`}
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
type="button"
key={`layer-visibility-${layer.id}`}
onClick={() => setSelectedLayerId(layer.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedLayerId(layer.id);
}
}}
role="button"
tabIndex={0}
>
<span
className={`h-6 w-6 shrink-0 rounded-full border-2 transition-all ${
isActive
? "scale-110 border-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)]"
: "border-white/20 shadow-inner"
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm text-white/90">
{layer.layerName}
</div>
<div className="mt-0.5 text-[11px] text-white/45">
{layer.isEditable
? isAgentPsdImageLayerSlot(layer)
? "可编辑图片图层"
: "可编辑文字图层"
: "普通图层"}
</div>
</div>
<button
className={`inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2.5 text-xs transition-all ${
isHidden
? "bg-emerald-500/15 text-emerald-300 hover:bg-emerald-500/20"
: "bg-red-500/10 text-red-300 hover:bg-red-500/15"
}`}
style={{ backgroundColor: color }}
/>
<span className="flex min-w-0 flex-col">
<span
className={`font-medium text-xs transition-colors ${isActive ? "text-emerald-400" : "text-white/90 group-hover:text-white"}`}
>{`颜色 ${index + 1}`}</span>
<span
className={`font-mono text-[11px] uppercase transition-colors ${isActive ? "text-emerald-500/70" : "text-white/50 group-hover:text-white/70"}`}
>
{color}
</span>
</span>
</button>
onClick={(event) => {
event.stopPropagation();
if (isHidden) {
restoreHiddenLayer(layer.id);
return;
}
hideLayerTemporarily(layer.id);
}}
type="button"
>
{isHidden ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
{isHidden ? "恢复" : "临时删除"}
</button>
</div>
);
})}
</div>
</div>
) : null}
</div>
</div>
) : selectedTemplate && selectedTemplateLayers.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
{selectedTemplateDetailError ? (
<>
<div className="font-medium text-white/80">
模板详情加载失败
</div>
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
{selectedTemplateDetailError}
</div>
</>
) : (
<>
<Loader2 className="mb-4 h-6 w-6 animate-spin text-emerald-400" />
<div className="font-medium text-white/80">
正在加载这个模板的编辑图层
</div>
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
首屏先展示模板列表,完整文字图层、图片图层和字体配置会按需补回来。
</div>
</>
)}
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-white/10 bg-white/5 shadow-inner">
<Pointer className="h-6 w-6 text-white/50" />
</div>
请在中间预览区点击图层进行编辑
</ScrollArea>
</div>
) : null}
</div>
)}
</div>

View File

@ -47,10 +47,17 @@ export async function executeAgentPsdTemplateRenderRequest(
if (editableLayers.length === 0) {
throw new Error("当前模板未配置可编辑图层");
}
const hiddenLayerIdSet = new Set(
(body.hiddenLayerIds || []).map((layerId) => String(layerId || "").trim()).filter(Boolean)
);
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const missingFontLayer = editableLayers.find(
(layer) => isAgentPsdTextLayerSlot(layer) && layer.fontId && !fontMap.has(layer.fontId)
(layer) =>
!hiddenLayerIdSet.has(layer.id) &&
isAgentPsdTextLayerSlot(layer) &&
layer.fontId &&
!fontMap.has(layer.fontId)
);
if (missingFontLayer) {
throw new Error(`文字图层“${missingFontLayer.layerName}”引用的字体已不存在`);
@ -65,6 +72,10 @@ export async function executeAgentPsdTemplateRenderRequest(
const textColorAssignments: Record<string, string> = {};
for (const layer of editableLayers) {
if (hiddenLayerIdSet.has(layer.id)) {
continue;
}
if (isAgentPsdImageLayerSlot(layer)) {
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
if (explicitUrl) {
@ -97,6 +108,7 @@ export async function executeAgentPsdTemplateRenderRequest(
const missingRequiredLayer = editableLayers.find(
(layer) =>
!hiddenLayerIdSet.has(layer.id) &&
layer.isRequired &&
(isAgentPsdImageLayerSlot(layer)
? !imageAssignments[layer.id]
@ -108,6 +120,7 @@ export async function executeAgentPsdTemplateRenderRequest(
const uploaded = await renderAgentPsdTemplate({
template: resolvedTemplate,
hiddenLayerIds: Array.from(hiddenLayerIdSet),
imageAssignments,
imagePlacements,
textAssignments,

View File

@ -5,6 +5,7 @@ export const AGENT_PSD_RENDER_TASK_POLL_AFTER_MS = 1500;
export const agentPsdTemplateRenderRequestSchema = z.object({
templateId: z.string().trim().min(1),
sourceImageUrl: z.string().trim().optional().nullable(),
hiddenLayerIds: z.array(z.string().trim().min(1)).default([]),
imageAssignments: z.record(z.string(), z.string()).default({}),
imagePlacements: z
.record(

View File

@ -83,6 +83,7 @@ async function claimNextPendingTask(): Promise<string | null> {
async function runSingleTask(taskId: string) {
const state = getQueueState();
state.running.add(taskId);
console.log("[PSD Render Task] starting:", { taskId });
try {
const [task] = await db
@ -118,7 +119,16 @@ async function runSingleTask(taskId: string) {
updatedAt: new Date(),
})
.where(eq(agentPsdRenderTask.id, taskId));
console.log("[PSD Render Task] succeeded:", {
taskId,
resultUrl: result.url,
});
} catch (error) {
console.error("[PSD Render Task] failed:", {
taskId,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
await db
.update(agentPsdRenderTask)
.set({
@ -154,6 +164,7 @@ export async function drainAgentPsdRenderTaskQueue() {
export async function enqueueAgentPsdRenderTask(taskId: string) {
enqueueLocalTask(taskId);
console.log("[PSD Render Task] enqueued:", { taskId });
await drainAgentPsdRenderTaskQueue();
}
@ -177,6 +188,12 @@ export async function createAgentPsdRenderTask(params: {
createdAt: agentPsdRenderTask.createdAt,
});
console.log("[PSD Render Task] created:", {
taskId: task.id,
userId: params.userId,
templateId: params.request.templateId,
});
return task;
}

View File

@ -299,6 +299,7 @@ async function createTextOverlayBuffer(params: {
export async function renderAgentPsdTemplate(params: {
template: AgentPsdTemplate;
hiddenLayerIds?: string[];
imageAssignments: Record<string, string>;
imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>;
textAssignments: Record<string, string>;
@ -307,6 +308,7 @@ export async function renderAgentPsdTemplate(params: {
}) {
const {
template,
hiddenLayerIds = [],
imageAssignments,
imagePlacements,
textAssignments,
@ -318,6 +320,9 @@ export async function renderAgentPsdTemplate(params: {
const canvasHeight = Math.max(1, Math.round(Number(template.height || 1)));
const remoteBufferCache = new Map<string, Buffer>();
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const hiddenLayerIdSet = new Set(
hiddenLayerIds.map((layerId) => String(layerId || "").trim()).filter(Boolean)
);
async function readCachedBuffer(url: string, label = "PSD 模板资源") {
const normalizedUrl = String(url || "").trim();
@ -398,6 +403,10 @@ export async function renderAgentPsdTemplate(params: {
}
for (const layer of [...template.layers].reverse()) {
if (hiddenLayerIdSet.has(layer.id)) {
continue;
}
if (!layer.isEditable) {
await appendStoredLayer(layer);
continue;

View File

@ -1,9 +1,12 @@
import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from "@/lib/request-error";
type LongTaskResponse = {
success?: boolean;
processing?: boolean;
status?: string;
taskId?: string;
pollAfterMs?: number;
retryAfter?: number;
error?: string;
detail?: string;
[key: string]: any;
@ -17,7 +20,13 @@ function isProcessingPayload(payload: LongTaskResponse | null | undefined) {
}
function buildTaskError(payload: LongTaskResponse | null | undefined, fallback: string) {
const message = payload?.error || payload?.detail || fallback;
const message = getUserFacingRequestErrorMessage(
payload,
fallback,
{
rateLimitMessage: getFriendlyRateLimitMessage(),
}
);
return new Error(String(message || fallback));
}
@ -29,12 +38,44 @@ async function safeParseJson(response: Response) {
}
}
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function shouldRetryLongTaskPoll(status: number | null, payload: LongTaskResponse | null | undefined) {
if (status != null && RETRYABLE_STATUS_CODES.has(status)) {
return true;
}
const message = String(payload?.error || payload?.detail || "").trim();
return /\btoo many requests\b|\brate limit\b|请求过于频繁|限流/i.test(message);
}
function resolveRetryAfterMs(
response: Response | null,
payload: LongTaskResponse | null | undefined,
fallbackMs: number,
attempt: number
) {
const retryAfterHeader = response?.headers.get("Retry-After");
const retryAfterSeconds = Number(retryAfterHeader || payload?.retryAfter || 0);
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
return Math.min(15_000, Math.max(800, Math.round(retryAfterSeconds * 1000)));
}
const exponentialBackoff = Math.min(15_000, fallbackMs + attempt * 800);
return Math.max(800, exponentialBackoff);
}
export async function requestLongTaskResult(params: {
endpoint: string;
payload: Record<string, any>;
pollingParams?: Record<string, string | number | null | undefined>;
timeoutMs?: number;
headers?: HeadersInit;
maxPollFailures?: number;
}) {
const response = await fetch(params.endpoint, {
method: "POST",
@ -67,22 +108,54 @@ export async function requestLongTaskResult(params: {
}
const timeoutMs = Math.max(10_000, params.timeoutMs ?? 8 * 60 * 1000);
const maxPollFailures = Math.max(1, params.maxPollFailures ?? 4);
const startedAt = Date.now();
let pollAfterMs = Math.max(800, Number(result?.pollAfterMs || 1500));
let consecutivePollFailures = 0;
while (Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, pollAfterMs));
const statusResponse = await fetch(`${params.endpoint}?${pollingParams.toString()}`, {
method: "GET",
cache: "no-store",
headers: params.headers,
});
const statusPayload = await safeParseJson(statusResponse);
await sleep(pollAfterMs);
let statusResponse: Response | null = null;
let statusPayload: LongTaskResponse | null = null;
try {
statusResponse = await fetch(`${params.endpoint}?${pollingParams.toString()}`, {
method: "GET",
cache: "no-store",
headers: params.headers,
});
statusPayload = await safeParseJson(statusResponse);
} catch (error) {
consecutivePollFailures += 1;
if (consecutivePollFailures >= maxPollFailures) {
throw error instanceof Error
? error
: new Error("查询任务状态失败");
}
pollAfterMs = resolveRetryAfterMs(null, null, pollAfterMs, consecutivePollFailures);
continue;
}
if (!statusResponse.ok) {
if (
shouldRetryLongTaskPoll(statusResponse.status, statusPayload) &&
consecutivePollFailures < maxPollFailures
) {
consecutivePollFailures += 1;
pollAfterMs = resolveRetryAfterMs(
statusResponse,
statusPayload,
pollAfterMs,
consecutivePollFailures
);
continue;
}
throw buildTaskError(statusPayload, "查询任务状态失败");
}
consecutivePollFailures = 0;
if (!isProcessingPayload(statusPayload)) {
return statusPayload as LongTaskResponse;
}