feat: add async PSD render task pipeline
This commit is contained in:
parent
6648f43a20
commit
649825072b
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user